前话
汇编不用多说,就是符号化了机器指令,是一种很低级(靠近硬件)的编程。很多人接触比较多的是 windows 下的 Intel 汇编,然而 unix/linux 下 or 使用 gcc 的话,用的是 ATT 汇编。(unix 最初是 AT&T 实验室中的 Ken Thompson 发明的。)
Intel 汇编和 ATT 汇编使用的指令基本一样,就是写法上有些差异,鉴于前者有很多人介绍,而后者相对比较少,故有了此篇文章。 (me 也曾经想在 linux 看下汇编的写法,然而搜到的基本都是 Intel 汇编,赶脚很失落,O__O"…)
ATT 汇编格式
ATT 汇编大体格式是: 指令 源操作数 目的操作数 ,比如将 10 移动到 eax 寄存器的写法: movl $10, %eax 。
- ATT 汇编的源操作数和目的操作数和 Intel 正好相反,也就是数据流向是从左到右;
- ATT 中立即数前需要加 $ 符号, 寄存器前加 % 符号;
- ATT 的指令加后缀 b 、 w、 l 、q 表明处理的数据长度,分别是字节、字(2B)、双字(4B)、四字(8B);
- ATT 以寄存器中的值为地址的内存单元的访问(间接寻址)是加上括号比如 (%eax),而非 Intel 的 [EAX] 。
ATT 常用指令
(1) 移动指令 movN src dst : movl $a, %ebx
(2) 运算指令 加 addN 、 减 subN 、按位与 andN 、按位异或 xorN src dst : xor %eax %eax (将 eax 值置为 0)
(3) 比较指令 cmpN src dst : cmp (%eax) $10
(4) 跳转指令 jmp、大于跳转 jg、小于跳转 jl 、大于等于跳转 jge、 jle、 je、 jne
(5) 函数调用 call 和返回 ret
ATT 汇编代码示例
(1) hello,world
上来先见识一下 hello,world 的写法:
- .data
- msg:
- .ascii "hello,world\0"
- .text
- .globl _main
- _main:
- # 函数调用的“开场白”
- pushl %ebp
- movl %esp, %ebp
- andl $-16, %esp
- # 调用 puts 函数
- pushl $msg
- call _puts
- # 设置返回值
- movl $0, %eax
- # 函数调用的“结束语”
- leave
- ret
上面 "hello,world" 字符串的输出是通过调用 c 的库函数 puts 实现的。 汇编上面的代码可以:$ gcc -o hello hello.s 。
(1) 在 linux 下汇编此程序如果说找不到 _start 那么将程序中的 _main 改成 _start 就好。
(2) 除了使用 gcc 命令之外,也可以先汇编 $ as -o hello.o hello.s 然后再链接 $ ld hello.o -o hello 生成可执行文件。(如果 windows 下说找不到 _puts 标识符,可以 $ld hello.o -lmsvcrt )。
程序解释:
(1) 实现 "hello,world" 打印输出的其实只有下面两行:
- pushl $msg
- call _puts
_main 是一个函数。汇编函数的写法有一些约定俗成的习惯,比如进入函数的“开场白” (第三行可以省略):
- pushl %ebp
- movl %esp, %ebp
- andl $-16, %esp
和退出函数的“结束语”:
- leave
- ret
(3) 数据段 .data 是定义函数外的数据的, 文本段 .text 就是通常所的代码段。
(4) c 程序的惯例是 : main 正常结束返回 0, 非正常结束返回其他值;上面程序的第 18 行就是设置程序的返回值;
ATT汇编代码实例
前面是汇编的 hello,world 程序。为什么借助于 c 的 puts 函数呢?那是因为只有借助于 c 标准库的可移植性从而达到汇编代码的——可移植性,因为 linux 和 windows 的更低一级的系统调用是不一样的。
程序实例 2 : 调用 c 的 printf 函数
- .data
- _format:
- .ascii "the answer to life, the universe and everything = %d\n\0"
- a: .long 42
- .text
- .global _main
- _main:
- pushl %ebp
- movl %esp, %ebp
- # 调用 printf 函数
- pushl $42 # 右边数第一个参数
- pushl $_format # 右边第二个参数
- call _printf
- pushl a # 右边数第一个参数
- pushl $_format
- call _printf
- movl $0, %eax
- leave # 函数返回前的准备: (1) movl %ebp, %esp (2) popl %ebp
- ret
程序解释:
(1) 函数的开场白(主要是两行,保存原来的 %ebp 和重新设定 %ebp) 就不多说了,结束语有两行:leave 和 ret 。 leave 实际上是开场白的“反操作”,具体的可以参看代码中的注释部分。
(2) c 的 printf 函数,在汇编中调用是 _printf 函数,如同例子 1 中的 _puts 而不是 puts 函数。
(3) 汇编中调用函数之前,需要将参数压入栈中,参数压入的顺序是从右到左。比如 printf("\d", 42); 调用先压入 42 再压入 "%d" 。
(4) 汇编中变量实际上都是标号 LABEL,$LABEL 取的是变量对应的地址,而 LABEL 则代表变量的值;$42 是常量 42 的写法;上面的汇编代码的两次 printf 调用输出结果是一样的。
c 中函数调用的一些细节
(1) 虽然标准 c 中没有说明函数调用时候参数求值的顺序(从左到右?),然而至少从 gas 中可以看到,参数是从右到左传递过去的;
(2) c 的被调用函数 callee 不负责存储参数,参数是调用者 caller 压入栈中的,参数的清理也是调用者 caller 的责任;
(3) 在汇编函数中, 8(%ebp) 是函数的从左到右数的第一个参数,12(%ebp) 是第二个参数。试问 4(%ebp) 是谁呢? 是 caller 的 %eip 的值,在 call 指令的时候压入的值,这是为了知道当函数调用结束返回到被调用函数的时候从哪里继续执行。而 (%ebp) 是 caller 的 %ebp 的值, 也就是调用者的 %ebp 的旧值,这是在 pushl %ebp 步实现的。
栈指针和帧指针
进程所控制的存储区有一部分叫栈, 这是一种后进先出 LIFO 的数据结构,局部变量以及函数调用均使用的栈这一部分存储区。栈有一个栈底 bottom 和栈顶 top 一说,栈底一般是不移动的,数据的入栈出栈均是移动的栈顶指针 sp (stack pointer)。x86 的栈是向低地址方向延伸的,也就是压入一个 long 比如 42,那么栈顶指针向低地址方向偏移 4 个字节,下面是入栈 push 和出栈 pop 的一些细节:
- pushl S : 栈顶指针 %esp 减 4 (上移); S 压入 %esp 指向的栈顶;
- popl D : 将 %esp 指向的栈顶元素弹到 D 中; 站顶指针 %esp 加 4 (下移);
函数调用的实现
函数调用其实没有太神秘的地方,不过从底层 stack 的角度去看有助于 us 理解递归调用(函数自己调用自己)。函数 A 有自己的一块栈区域,函数 B 也有自己的一块,它们位置的区分是通过一个叫 帧指针 (frame pointer) 的概念,实际上就是前面说的栈底 bottom 的玩意。当 A 在执行的时候, SP 实际上都是针对的 BP (base pointer) 来说的, 当 A 调用了 B ,那么一般重置 BP ,让 B 的控制区域不会打扰到 A 的区域。这就是为什么函数调用的“开场白”一般就是:pushl %ebp 和 movl %esp, %ebp 两句。
%ebp 存储的就是 帧指针, %esp 存储的就是栈(顶)指针。
虽说重置 %ebp 是为了保护不同函数的控制区域,但是底层并没有一种强有力的机制保证: B 函数不会读写 A 函数的数据。即使如 c 这样的高级语言也没有提供这一种保护机制,所以经常会看到“野指针”不知道修改了哪一部分数据,尼玛!
继续 ATT 汇编代码实例
程序实例 3 : 加法函数 add
- .data
- _format:
- .ascii "sum = %d\n\0"
- .text
- .global _main
- _main:
- pushl %ebp
- movl %esp, %ebp
- andl $-16, %esp
- pushl $10 # 第二个函数参数
- pushl $20 # 第一个函数参数
- call _fun_add # 调用 _fun_add 函数
- pushl %eax # %eax 存储函数的返回结果
- pushl $_format
- call _printf
- movl $0, %eax
- leave
- ret
- .global _fun_add
- _fun_add:
- pushl %ebp
- movl %esp, %ebp
- andl $-16, %esp
- movl 12(%ebp), %eax # 第二个函数参数
- movl 8(%ebp), %edx # 第一个函数参数
- addl %edx, %eax # 结果在 %eax 之中
- leave
- ret
程序实例 4 : 循环控制
- .data
- _format:
- .ascii "sum = %d\n\0"
- _array:
- .long 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
- .text
- .global _main
- _main:
- pushl %ebp
- movl %esp, %ebp
- andl $-16, %esp
- xorl %eax, %eax # 存放结果
- xorl %esi, %esi # 循环变量
- lea _array, %ebx # 数组的基地址
- LOOP:
- cmpl $10, %esi # cmp src, dst 是 dst - src
- jge PRINT
- addl (%ebx, %esi, 4), %eax # 相当于 c 的 result += array[i]
- inc %esi
- jmp LOOP
- PRINT:
- pushl %eax # %eax 存储函数的返回结果
- pushl $_format
- call _printf
- movl $0, %eax
- leave
- ret