汇编语言编写的代码和机器代码接近,和机器密切相关,现在通常我们已经不会用它来编写程序了。但它仍然很重要,特别是希望能够了解程序是如何转换成机器代码来运行的时候。本篇总结汇编语言的部分知识和C程序如何编译成机器代码。
程序编码
汇编语言中不区分数据类型,将存储器看作一个很大的按字节寻址的数组。程序存储器包含程序的目标代码、操作系统需要的一些信息、运行时栈和用户分配的存储器块。程序存储器用虚拟地址寻址,由操作系统负责管理虚拟地址空间,将虚拟地址转换为物理地址。
gcc 命令使用 -S 选项可以产生汇编代码,使用 -c 选项会编译代码产生目标文件, -O2 选项为优化级别。
例:
/* a.c */ int accum = 0; int sum(int x, int y) { int t = x + y; accum += t; return t; }
$ gcc -O2 -S a.c # 生成a.s汇编文件 $ gcc -O2 -c a.c # 生成a.o目标文件
生成的汇编文件中 sum 函数为:
sum: pushl %ebp movl %esp, %ebp movl 12(%ebp), %eax addl 8(%ebp), %eax addl %eax, accum popl %ebp ret
可以用反汇编器来根据目标代码来生成类似于汇编代码的格式:
$ objdump -d a.o ... 00000000 <sum>: 0: 55 push %ebp 1: 89 e5 mov %esp,%ebp 3: 8b 45 0c mov 0xc(%ebp),%eax 6: 03 45 08 add 0x8(%ebp),%eax 9: 01 05 00 00 00 00 add %eax,0x0 f: 5d pop %ebp 10: c3 ret
sum 函数有17个字节,还可以用 gdb 查看它的字节表示:
$ gdb a.o (gdb) x/17xb sum 0x0 <sum>: 0x55 0x89 0xe5 0x8b 0x45 0x0c 0x03 0x45 0x8 <sum+8>: 0x08 0x01 0x05 0x00 0x00 0x00 0x00 0x5d 0x10 <sum+16>: 0xc3
x/17xb 表示“检查17个十六进制字节”。得到的结果和 objdump 的相同。
在主文件中调用 sum 函数:
/* m.c */ int main() { return sum(1, 3); }
编译生成可执行文件:
$ gcc -O2 -o m a.o m.c
反编译 m 文件,可以看到函数 sum 和 a.o 目标文件中的有两个不同:一是虚拟空间地址不同,二是 accum全局变量现在已由具体的地址代替。
$ objdump -d m ... 080483a0 <sum>: 80483a0: 55 push %ebp 80483a1: 89 e5 mov %esp,%ebp 80483a3: 8b 45 0c mov 0xc(%ebp),%eax 80483a6: 03 45 08 add 0x8(%ebp),%eax 80483a9: 01 05 b0 95 04 08 add %eax,0x80495b0 80483af: 5d pop %ebp 80483b0: c3 ret
GAS指令
Intel用字表示16位数据类型,32位数称为双字,64位数称为四字。见前一篇中C数据类型的大小的表。GAS中每个操作都有一个字符后缀,表示操作数的大小。
IA32 CPU中包含一组8个存储32位值的寄存器,它们用来存储整数数据和指针。所有8个寄存器可以以16位和32位来访问,前四个寄存器还可以独立访问两个低位字节。
大多数指令具有操作数,指示操作要引用的源数据值和放置结果的目的位置。操作数有三种:
- 立即数,即常数值,写为 $ 加一个整数。
- 寄存器,表示某个寄存器的内容,用E_a表示寄存器a,引用R[E_a]表示它的值。
- 存储器引用,根据地址访问某个存储器位置,用M[Addr]表示存储在存储器中地址Addr开始的值的引用。
伸缩因子s必须是1、2、4、8。
类型 | 格式 | 操作数值 | 名称 |
---|---|---|---|
数据传送
最常用的指令是数据传送指令。IA32限制传送指令的两个操作数不能都指向存储器位置。
指令 | 效果 | 描述 |
---|---|---|
movsbl 和 movzbl 复制字节,并扩展为32位。
IA32的栈向低地址方向增长,压栈减小栈指针的值并存内容到存储器中,出栈相反。按惯例,将栈倒着画,栈顶放在下面。
pushl %ebp 相当于: subl $4,%esp movl %ebp,(%esp) popl %eax 相当于: movl (%esp),%eax addl $4,%esp
算术和逻辑
下表中包含了四类整数操作:加载有效地址、一元操作、二元操作和移位操作。
指令 | 效果 | 描述 |
---|---|---|
除 leal 外,每条指令都有以 w 和 b 结尾的对字和字节操作的版本。
加载有效地址指令 leal 并不引用存储器,而是将有效地址写到目的操作数,目的操作数必须是寄存器。
下表中是一些特殊的算术操作:64位的乘积和整数除法。
指令 | 效果 | 描述 |
---|---|---|
控制
程序执行通常还需要控制操作执行的顺序。汇编语言提供了非顺序控制流的机制,基本操作是跳转到程序的另一部分。
CPU中有一组单个位的条件码寄存器,描述最近的算术或逻辑操作的属性。主要包括:
- CF :进位标志,最近的操作使最高位产生了进位,可用来检查无符号操作数的溢出。
- ZF :零标志,最近的操作得到的结果为0。
- SF :符号标志,最近的操作得到的结果为负数。
- OF :溢出标志,最近的操作导致一个二进制补码溢出。
leal 指令不改变条件码,其他的算术和逻辑操作都会设置条件码。此外,下面的操作只设置条件码:
指令 | 基于 | 描述 |
---|---|---|
cmpl 和 testl 指令都有对应的 w 和 b 结尾的字和字节版本。
可以根据条件码的某种组合来设置整数寄存器或执行条件分支指令。
下面的指令根据条件码的组合将一个字节设置为0或1,可以用 movzbl 指令对高位字节清零来得到32位结果。
指令 | 同义名 | 效果 | 设置条件 |
---|---|---|---|
跳转指令使执行切换到程序中的一个新位置,跳转的目的地通常用标号指明。当跳转条件满足时,指令会跳转到一条带标号的目的地。
指令 | 同义名 | 跳转条件 | 描述 |
---|---|---|---|
jmp 指令是无条件跳转。可以是直接跳转,以一个标号作为跳转目标,如 .L1 ;也可以是间接跳转,跳转目标从寄存器或存储器中读出,如 *(%eax) 。
条件跳转只能是直接跳转。
汇编代码中跳转目标以符号标号书写,汇编器和链接器会产生跳转目标的适当编码。最常用的编码是PC相关的,即以目标指令的地址和紧跟在跳转指令后面的那条指令的地址的差作为编码,这样的好处是指令编码简洁,且目标代码存储位置变化时不必改变。另一种编码方法是给出绝对地址。
C语言控制流翻译成汇编代码的结构,通常都是以跳转实现。不同的编译器或同一编译器的不同优化级别产生的汇编代码的结构往往都是不同的。下面只就一些典型的处理方式做分析,实际当中会有一定不同。
C语言的条件语句是用有条件跳转和无条件跳转结合起来实现的。
C语言if-else语句: 汇编实现的结构: if (test-expr) t = test-expr; then-statement if (t) else goto true; else-statement else-statement goto done; true: then-statement done:
C语言中的三种循环也是以条件测试和跳转的组合来实现,大多数会根据循环的 do-while 形式来产生循环代码。
C语言do-while语句: 汇编实现的结构: do loop: body-statement body-statement while (test-expr); t = test-expr; if (t) goto loop;
C语言while语句: 汇编实现的结构: while (test-expr) t = test-expr; body-statement if (!t) goto done; loop: body-statement t = test-expr; if (t) goto loop; done:
C语言for语句: 汇编实现的结构: for (init-expr; test-expr; update-expr) init-expr; body-statement t = test-expr; if (!t) goto done; loop: body-statement update-expr; t = test-expr; if (t) goto loop; done:
C语言的 switch 语句的实现在分支较多和值的跨度较小时会使用跳转表。跳转表是一个数组,表项i是代码段的地址,使用跳转表使代码可以快速跳转到要执行的分支,和分支数无关。
跳转表类似下面这样声明:
.section .rodata 只读数据 .align 4 .align 4 .L8: 下面是不同的case .long .L2 .long .L3 .long .L4 .long .L5 .long .L2 .long .L6 .long .L2 .long .L7
过程
过程调用包括数据和控制的传递,还在进入时为过程的局部变量分配空间,退出时释放空间。IA32提供了转移控制的指令,数据传递和局部变量的分配释放则通过操纵程序栈实现。
栈用来传递过程参数、存储返回信息、保存寄存器和用于本地存储。为单个过程分配的部分栈称为栈帧。寄存器 %ebp 作为帧指针,寄存器 %esp 作为栈指针,栈帧以这两个指针定界。程序执行时,栈指针可以移动。
若P调用Q,Q的参数放在P的栈帧中。调用时,P的返回地址压入栈中形成P的栈帧的末尾,返回地址是程序从Q返回时应该继续执行的地方。Q的栈帧从保存的帧指针开始,后面是其他寄存器的值和不能放在寄存器中的局部变量。栈向低地址方向增长。
下面是过程调用和返回的指令:
指令 | 描述 |
---|---|
调用可以是直接的和间接的, call 指令将返回地址入栈,并跳转到被调用过程的起始处,返回地址是call 后面的指令的地址。 leave 指令使栈指针指向 call 存储的返回地址。 ret 指令从栈中弹出地址,并跳转到那里。寄存器 %eax 可以用来返回值。
leave 等价于: movl %ebp, %esp popl %ebp
程序寄存器组被所有过程共享。寄存器 %eax 、 %edx 、 %ecx 为调用者保存寄存器,由P保存,Q可以覆盖这些寄存器而不破坏P的数据。寄存器 %ebx 、 %esi 、 %edi 为被调用者保存寄存器,Q需要在覆盖它们之前将值保存到栈中,并在返回前恢复它们。
int swap(int *x, int *y) { int t = *x; *x = *y; *y = t; return *x + *y; } int func() { int a = 1234; int b = 4321; int s = swap(&a, &b); return s; }
.p2align 4,,15 .globl swap .type swap, @function swap: pushl %ebp 压入帧指针 movl %esp, %ebp 将帧指针设为栈指针指向位置 movl 8(%ebp), %edx 读取x movl 12(%ebp), %ecx 读取y pushl %ebx 压入%ebx(被调用者保存寄存器) movl (%edx), %eax 读取*x movl (%ecx), %ebx 读取*y movl %ebx, (%edx) 将*y写入x指向存储器位置 movl %eax, (%ecx) 将*x写入y指向存储器位置 addl (%edx), %eax *x+*y popl %ebx 恢复%ebx popl %ebp 弹出帧指针(%esp等于原值) ret 返回 .size swap, .-swap .p2align 4,,15 .globl func .type func, @function func: pushl %ebp 压入帧指针 movl $5555, %eax 保存计算结果(编译器-O2优化) movl %esp, %ebp 将帧指针设为栈指针指向位置 popl %ebp 弹出帧指针(%esp未变) ret 返回 .size func, .-func
数组
设有数据类型 T , L 为 T 的字节大小。
C中的数组声明 T a[N] 在存储器中分配了 L*N 字节的连续区域,设 x 为起始位置,声明还引入了标识符 a,作为指向数组开头的指针,指针的值为 x 。数组元素 i 的存放地址为 x + L*i 。
C中指针运算的值会根据指针引用的数据类型的大小进行调整。若 p 指针指向类型 T 的数据, p 的值为 x,则 p+i 的值为 x + L*i 。
对于循环中数组的引用,编译器优化通常会用指针运算代替循环变量来遍历数组,用最后的数组元素的地址和指针的比较作为测试条件。
对于固定大小的数组,编译器会进行多种优化,如使用指针变量来访问,用移位和加法代替乘法指令。动态分配的数组在编译时不能确定大小,需要用 calloc 等函数在堆中创建,必须使用乘法指令。
typedef int *matrix; int matrix_e(matrix a, int i, int j, int n) { return a[(i*n) + j]; }
matrix_e: pushl %ebp movl %esp, %ebp movl 20(%ebp), %eax 读取i imull 12(%ebp), %eax 计算i*n movl 8(%ebp), %edx 读取a addl 16(%ebp), %eax 计算i*n+j popl %ebp movl (%edx,%eax,4), %eax 读取a[i*n+j] ret
结构和联合
结构的实现类似于数组,组成部分存储在连续区域,指向结构的指针即结构的第一个字节的地址。编译器保存关于每个结构类型的信息,指示每个域的字节偏移。
struct rec { int i; int j; int a[3]; int *p; } *r; r->p = &r->a[r->i + r->j];
movl 4(%eax), %edx 读取r->j addl (%eax), %edx 加上r->i leal 8(,%edx,4), %edx 计算&r->a[r->i+r->j] movl %edx, 20(%eax) 存入r->p
联合的语法和结构一样,但它的不同的域引用相同的存储器块。
内嵌汇编
现在已经很少使用汇编代码写程序了,但是在一些如访问寄存器或获取条件码等的场合,仍然需要汇编代码。
可以编写独立的汇编代码文件,然后编译它并和C代码链接起来。GCC也支持将汇编和C代码混合起来,即内嵌汇编。
内嵌汇编像过程调用一样写代码:
asm(code-string);
code-string 为字符串形式的汇编代码序列,编译器将它插入到生成的汇编代码中,错误检查会由汇编器来执行。
asm 有一个扩展版本,它可以指定汇编代码序列的操作数和要被覆盖的寄存器。
asm(code-string[ : output-list[ : input-list[ : overwrite-list]]]);
code-string 由用 ; 分隔的汇编代码指令序列组成,输入输出操作数用引用 %0 、...、 %9 表示,操作数根据它们第一次在 input-list 和 output-list 中出现的顺序编号。输入输出列表由 , 分隔的操作数对组成,每个操作数对由空格分隔的操作数类型的字符串和括号包含的操作数组成。 = 表示赋值, r 表示整数寄存器。操作数是C表达式。寄存器要在前面加 % 。
int umul(unsigned x, unsigned y, unsigned *dst) { int res; /* * movl x,%eax 读取x * mull y 无符号乘法乘以y * movl %eax,*dst 存储结果的低4字节到*dst * setae %dl 设置低位字节 * movzbl %dl,res 零扩展设为res */ asm("movl %2,%%eax; mull %3; movl %%eax,%0; setae %%dl; movzbl %%dl,%1" : "=r" (*dst), "=r" (res) /* 输出 */ : "r" (x), "r" (y) /* 输入 */ : "%eax", "%edx" /* 覆盖 */ ); return res; }
umul: pushl %ebp movl %esp, %ebp movl 8(%ebp), %ecx 读取x pushl %ebx movl 12(%ebp), %ebx 读取y #APP # 4 "asm.c" 1 movl %ecx,%eax; mull %ebx; movl %eax,%ebx; setae %dl; movzbl %dl,%ecx # 0 "" 2 #NO_APP movl 16(%ebp), %eax 读取dst movl %ebx, (%eax) 保存*dst movl %ecx, %eax 设res为结果 popl %ebx popl %ebp ret
对齐
通常计算机系统对基本数据类型的可允许的地址做了一些限制,要求地址必须是某个值k(常为2、4、8)的倍数。对齐简化了处理器和存储器系统之间接口的硬件设计。
Linux要求2字节数据类型的地址必须是2的倍数,更大的数据类型的地址必须是4的倍数。Microsoft Windows要求任何k字节数据类型的地址必须是k的倍数。
编译器在汇编代码中指明全局数据所需的对齐,如跳转表中的 .align 4 ,它使该指令后面的数据从4的倍数的地址开始。
malloc 等分配存储器的库函数必须设计为返回的指针能满足最糟情况的对齐限制。
对于结构,它的起始地址和域都有一些对齐要求。如:
struct s { /* 地址示例:x为间隙 */ char c; /* bf9483e0: cx-------------- */ short s[2]; /* bf9483e2: --s0s1xx-------- */ int i; /* bf9483e8: --------iiii---- */ char d; /* bf9483ec: ------------dxxx */ };
缓冲区溢出
C对数组引用不做边界检查,同时局部变量和状态信息(寄存器值和返回指针等)都存放在栈中,这使得越界的数组写操作会破坏存储在栈中的状态信息,程序使用被破坏的状态时就会出现严重的错误。
常见的状态破坏称为缓冲区溢出,就是实际保存内容的大小超过了缓冲区大小,导致写越界。缓冲区溢出能被用来让程序执行非本意的函数,这是最常见的通过计算机网络攻击系统安全的方法。
GDB调试器
GDB支持对机器级程序的运行时评估和分析。一般先运行 objdump 来获得程序的反汇编版本,以帮助确定断点等。断点可以设置在函数入口后面,或某个地址。使用 gdb 执行程序,遇到一个断点时,程序会停下来,将控制返回给用户。在断点处,可以查看各个寄存器和存储器。还可以单步跟踪程序,一次执行几条命令,或前进到下一个断点。
下面是一些常用的命令:
开始和停止 quit 退出gdb run 运行程序(设置命令行参数) kill 停止程序 断点 break func 设置断点在func函数入口 break *0x80483c7 设置断点在地址0x80483c7 delete 1 删除断点1 delete 删除全部断点 执行 stepi 执行一条指令 stepi n 执行n条指令 nexti 执行一条指令(可以通过子例程调用) continue 恢复执行 finish 运行直到当前函数返回 检查代码 disas 反汇编当前函数 disas func 反汇编func函数 disas 0x80483b7 反汇编在地址0x80483b7附近的函数 disas 0x80483b7 0x80483c7 反汇编在指定地址范围的代码 print /x $eip 十六进制打印程序计数器 检查数据 print $eax 十进制打印%eax的内容 print /x $eax 十六进制打印%eax的内容 print /t $eax 二进制打印%eax的内容 print 0x100 打印0x100的二进制表示 print /x 100 打印100的十六进制表示 print /x ($ebp+8) 十六进制打印%ebp+8的内容 print *(int *) 0xbffff760 打印地址0xbffff760的整数 print *(int *) ($ebp+8) 打印地址%ebp+8的整数 x/2w 0xbffff760 检查地址0xbffff760开始的双字(4字节) x/20xb func 检查func函数20字节的十六进制表示 有用的信息 info frame 当前栈帧的信息 info registers 全部寄存器的值 help gdb帮助