最近在Linux下使用有些蹩脚的Thunk技术将C++类成员函数变成可回调函数时(供libevent调用),发现好多汇编语法都忘记了, 干脆花点时间重新熟悉一下,查漏补缺,温故知新。
汇编语言主要由三个组件构成:
- 操作码助记符: 例如push, mov, sub, call等等。
- 数据段: 程序声明的静态数据区。数据段通常可分为普通数据段,只读数据段和bss段。
-
命令: 汇编器保留的专门关键字用于在助记符被转换成指令码时,指示汇编器如何执行 专门的函数。因为这些指令(
directive
)只是用来指导汇编器如何生成汇编代码,它没有真正 的硬件指令码与之对应,所以常称之为伪指令。GNU中的命令之前有一个点号(.
),它的名称对 多数平台来说是不区分大小写的,但通常将其写成小写形式。 关于详细介绍,可在 这里 查阅。汇编语言程序中,最为重要的命令之一就是
.section
。这个命令定义内存段。 汇编语言通常至少声明:数据段
,bss段
,文本段
。
Intel与AT&T语法的主要区别:
- AT&T使用$标示立即操作数,而Intel的立即操作数必须要做特殊界定。
- AT&T在寄存器名称前加上%前缀,Intel不需要这样做。例如,AT&T语法 引用EAX寄存器写作: %eax
- AT&T语法处理源和目标操作数时,与Intel使用相反的顺序。例如把十进制 值4传送到EAX寄存器,AT&T的语法是 mov $4, %eax; Intel语法是 mov eax, 4.
- AT&T语法在助记符后面使用一个单独的字符来引用操作中使用的数据长度,
例如
b
,l
,q
等; Intel语法中数据长度被声明为单独的操作数。 AT&T的指令 movl $test, %eax 等同于 Intel语法的 mov eax, dword ptr test. - 长调用和跳转使用不同语法定义段和偏移值。AT&T语法使用ljmp $section, $offset; Intel使用 jmp section:offset.
- _start标签: GNU汇编器默认使用此标签来标明程序的起始点。_start标签用于 标明程序应该从这条指令开始运行。如果连接器找不到这个标签,它会生成一条错误消息。
也可在使用除_start 之外的其他标签作为起时点。可以使用连接器的 -e 参数定义 新的起时点名称.
- .globl命令: 此命令用于声明外部程序可以访问的程序标签。如果编写被外部 汇编语言或C语言程序使用的库函数,就应该使用此命令声明函数段。
汇编程序的段落布局大致如下:
.section .data
# TODO: initialized data here
.section .bss
# TODO: uninitialized data here
.section .text
# TODO: code here
.globl _start
_start:
# instruction code goes here
一个简单的demo
.section .data # 定义数据段
output:
.ascii "The processor vendor ID is 'xxxxxxxxxxxx'\n" # 'x'占位
.section .text # 定义代码段
.global _start # 导出入口点标号
_start:
movl $0, %eax # 向EAX寄存器传参
cpuid # 执行cpuid指令
# 从EBX, EDX, ECX寄存器中取值,并填充到 output数据段中
movl $output, %edi
movl %ebx, 28(%edi)
movl %edx, 32(%edi)
movl %ecx, 36(%edi)
movl $4, %eax # Linux write 系统调用号
movl $1, %ebx # 传入标准输出描述符
movl $output, %ecx # 传入buf地址
movl $42, %edx # 传入长度
int $0x80 # 陷入中断
movl $1, %eax # Linux exit 系统调用号
movl $0, %ebx # 传入退出码0
int $0x80 # 执行 exit(0)
将_start标号改为main, 则可由gcc自动帮我们完成编译和链接步骤
稍微改造一下, 使用printf
.section .data
output:
.asciz "The processor vendor ID is '%s'\n"
.section .bss
.lcomm buffer, 12
.section .text # 定义代码段
.global _start # 导出入口点标号
_start:
movl $0, %eax # 向EAX寄存器传参
cpuid # 执行cpuid指令
# 从EBX, EDX, ECX寄存器中取值,并填充到buffer中
movl $buffer, %edi
movl %ebx, (%edi)
movl %edx, 4(%edi)
movl %ecx, 8(%edi)
pushl $buffer # buffer入栈
pushl $output # 格式化字符串入栈
call printf # 调用printf
addl $8, %esp # 清理栈,保持栈平衡
pushl $0 # 退出码入栈
call exit # 调用exit
几点说明:
.asciz
伪指令声明其后的字符串以\0
结束。.lcomm
用来预留一定长度的字节空间。它的一般带两个参数[.lcomm symbol, length]
。 预留出来的字节可用symbol来引用。某些汇编器可能允许它带第三个参数。- 当 $buffer, $output入栈之后,使用call指令调用C库的printf函数。
众多的调用约定旨在告诉编译器两个重要责任:
1 |
|
明白了这些常见的调用约定,再来看以上的汇编。要想调用变参的printf,需要遵循cdecl
调用约定。
当我们写下 printf(“%d\n”, “hello world”) 时,先将”hello world”也即buffer入栈,然后是格式化字符串
output入栈。调用结束,由我们自己将栈保持平衡。