本文通过对由gcc对简单C语言代码编译生成的汇编码进行逐句分析解读,来学习x86的汇编结构和堆栈机制。文章涉及细节较多,难免出错,望读者不吝赐教!
一、代码C语言代码: gcc -S -ohello.s hello.c输出文件: 二、分析下面对hello.s进行逐句分析。 第1行为gcc留下的文件信息;第2行标识下面一段是代码段,第3、4行表示这是add函数的入口,第5行为入口标号;6~12行为add函数体,稍后分析;13行为add函数的代码段的大小;14行指示下面是数据段;15~18行定义了main中要用到的两个字符串常量;19行同第二行,20、21行定义了main函数入口,22行为main入口标号。23行开始正式进入main函数,直至49行;50行为main函数代码段体积。51、52行为 gcc留下的信息。 下面从main函数开始单步分析每一句话,并跟踪堆栈状态。 初始状态,堆栈状态如图一: 高 +----------------+ <-- esp (栈顶) 高 +----------------+ | | | | | | | +----------------+ | + 若干 + | | | | | | | +----------------+ | +----------------+ <-- esp | | | | | | | +----------------+ | +----------------+ V | | V | | 低 + .... + 低 + .... + 图一 图二 23 leal 4(%esp), %ecx 将esp所指地址加4得到的地址存入ecx。 24 andl $-16, %esp -16的补码为11...10000,这句话使esp指针下移若干位,新地址末四位是0,故按16字节对齐,如图二。对齐是为了加速CPU访存。 25 pushl -4(%ecx) 将ecx所指地址(也就是程序开始时esp所指位置,如图一所示)的内容压栈。这个内容是eip。关于这句的用途,后面有详细解释。 26 pushl %ebp 将ebp压栈,保存ebp的值,以便在退出函数时恢复。 27 movl %esp, %ebp 将ebp移动到esp的位置。 28 pushl %ecx 将ecx的值压栈,保存,在退出函数时,通过这个值来恢复esp的初始值。 现在,堆栈状态如图三: 高 +----------------+ <-- old esp | | | | +---- 若干 ----+ | | | | +----------------+ | | | | +----------------+ | | eip | 25 pushl -4(%ecx) | +----------------+ <-- ebp 27 movl %esp, %ebp | | old ebp | 26 pushl %ebp (we don't know what old ebp is, but we have to backup it) | +----------------+ <-- esp | | old esp+4 | 28 pushl %ecx (ecx =old esp + 4) | +----------------+ | | | | +----------------+ V | | 低 + .... + 图三 29 subl $36, %esp esp向下移动36字节,留出空间给局部变量使用,每个存储单元4字节,故共9格。这里预留的空间有些多,在后续的分析中会发现,很多空都没用上。在第四部分的优化后的代码中也可以看到,36被优化成了20,预留的空间正好用满。 30 movl $3, -8(%ebp) a = 3, 将a的值存入堆栈(加载到内存中)。 31 movl $4, -12(%ebp) b = 4, 将b的值存入堆栈(加载到内存中)。 32 movl -12(%ebp), %eax 33 movl %eax, 4(%esp) 将b的值调入寄存器,并且入栈,为调用add函数准备参数。 34 movl -8(%ebp), %eax 35 movl %eax, (%esp) 将a的值调入寄存器,并且入栈,为调用add函数准备参数。 36 call add 调用add函数。注意,在这里,call指令隐含执行了一条push %eip的指令,记录当前代码段执行的位置。 下面进入add函数代码。 6 pushl %ebp 将ebp值压栈保存。 7 movl %esp, %ebp 移动ebp至当前esp位置。 8 movl 12(%ebp), %edx 9 movl 8(%ebp), %eax 将两个参数加载到寄存器。 10 addl %edx, %eax 相加,结果存入eax寄存器。 11 popl %ebp 12 ret 出栈,恢复ebp原来的值,函数返回,结果保存在eax中。注意,在ret指令中隐含执行了pop %eip的指令,从pop出来的eip所指的代码处继续执行。 下面回到main函数中。 37 movl %eax, -16(%ebp) 将函数返回值存入堆栈(内存)。 38 movl -16(%ebp), %eax 将变量c的值加载到寄存器。(此句冗余,编译时加优化选项可消除) 39 movl %eax, 4(%esp) 40 movl $.LC0, (%esp) 将变量c的值和.LC0的地址存入堆栈,为调用printf函数准备参数。 41 call printf 调用printf函数,不跟踪分析。 这个过程中堆栈状态如图四: 高 +----------------+ <-- old esp | | | | +---- 若干 ----+ | | | | +----------------+ | | eip | | +----------------+ | | old ebp | | +----------------+ | | old esp+4 | | +----------------+ | | | | +----------------+ | | 3 | 30 movl $3, -8(%ebp) a = 3 | +----------------+ | | 4 | 31 movl $4, -12(%ebp) b = 4 | +----------------+ | | 7 | 37 movl %eax, -16(%ebp) eax中为add函数的返回值。 | +----------------+ | | | | +----------------+ | | | | +----------------+ | | | | +----------------+ | | | | +----------------+ | | 4 / 7 | 33 movl %eax, 4(%esp) / 39 movl %eax, 4(%esp) | +----------------+ <-- esp (29 subl $36, %esp) | | 3 / .LC0 | 35 movl %eax, (%esp) / 40 movl $.LC0, (%esp) | +----------------+ | | eip | | +----------------+ <-- ebp (7 movl %esp, %ebp) | | ebp | 6 pushl %ebp | +----------------+ | | | | +----------------+ V | | 低 + .... + 图四 42 movl $.LC1, (%esp) 将.LC1地址存入堆栈,注意,这里gcc将printf“偷换”成了puts,所以只传一个参数。 43 call puts 调用puts函数。 44 movl $0, %eax 主函数将要返回0,将0存入eax寄存器。 45 addl $36, %esp 将esp回到函数开始时的位置。 46 popl %ecx 47 popl %ebp 48 leal -4(%ecx), %esp 这三句与程序开始正好相对,恢复寄存器状态到进入函数前的状态。开始的这句话:25 pushl -4(%ecx),存入了esp初始时刻指向单元的内容(应该是eip),但整个程序中都没用上。 49 ret 从main函数返回,返回值由eax带回。图五是图三的拷贝,可以从此图看清楚备份了哪些东西。 | | | | +---- 若干 ----+ | | | | +----------------+ | | eip | | +----------------+ | | old ebp | | +----------------+ | | old esp+4 | | +----------------+ | | | | +----------------+ V | | 低 + .... + 图五 三、总结分析完这简单的代码后,我们进行一些小小的总结。 1、我们体会一些x86是如何使用堆栈的。堆栈是个动态的空间,在运行的过程中,其中保存的内容主要有两种:局部变量和堆栈转移时保存的指针(寄存器的值)。 2、esp是栈顶指针,pop和push操作将会自动调整esp的值,其他操作,除非esp作为算术运算的结果寄存器外,esp不会改变。个人觉得这里堆栈称之为堆栈有一点点不合理,因为对堆栈的操作并不是完全的pop/push操作的集合,更多的时候是直接通过地址来取数。发生函数调用时,4(%esp)是第一个参数,8(%esp)是第二个参数,依此类推,注意,这里加的4,是隐含指令push %eip导致的。push的操作,首先将esp向低地址方向移动4位,然后在这个单元里存入数据;pop的操作,现从esp所指向的单元里取出数据到指定寄存器,然后将esp向高地址方向移动4位。 3、一个代码段(这里一个函数就是一个代码段)运行时使用堆栈空间中连续的空间,ebp总是指向当前运行中的函数的堆栈空间的第一个位置,也就是基地址的意思。一个代码段在存取自己所使用的数据时总是通过ebp来索引,而获取参数总是通过esp索引。所以在进入一个函数时,必须保存ebp的值,然后将 ebp指向自己的数据其实地址,在退出函数时,恢复ebp的值,使调用它的函数在它返回后能继续正常运行。在main函数开始时改变了esp的值,所以改变之前也需要备份esp的值。 4、函数返回值默认存放在eax寄存器中。 5、寻址方法:
6、main函数中为何要按16字节对齐esp?Linux下面GCC默认的堆栈是16字节对齐的,而这样对齐是为了加快CPU访问效率。这里,不对esp进行16字节对齐并不会影响程序的正确执行。具体的解释参见瀚海xhacker的文章: http://202.38.64.3/cgi/bbscon?bn=ASM&fn=M47918B7C&num=23887、25 pushl -4(%ecx)的作用。(以下解释摘自瀚海foxman和xhacker的帖子) *** foxman *** 一般来说这不是必需的,当进入一个函数之后,堆栈是这样的 *** xhacker *** 另外再加上这个gcc的这个参数 *************** 四、编译器优化后的代码gcc -O3 -S -ohello_O3.s hello.c输出文件:/* file: hello_O3.s */ 1 .file "hello.c" 2 .text 3 .p2align 4,,15 4 .globl add 5 .type add, @function 6 add: 7 pushl %ebp 8 movl %esp, %ebp 9 movl 12(%ebp), %eax 10 addl 8(%ebp), %eax 11 popl %ebp 12 ret 13 .size add, .-add 14 .section .rodata.str1.1,"aMS",@progbits,1 15 .LC0: 16 .string "a+b=%d\n" 17 .LC1: 18 .string "Hello World!" 19 .text 20 .p2align 4,,15 21 .globl main 22 .type main, @function 23 main: 24 leal 4(%esp), %ecx 25 andl $-16, %esp 26 pushl -4(%ecx) 27 pushl %ebp 28 movl %esp, %ebp 29 pushl %ecx 30 subl $20, %esp 31 movl $7, 8(%esp) 32 movl $.LC0, 4(%esp) 33 movl $1, (%esp) 34 call __printf_chk 35 movl $.LC1, (%esp) 36 call puts 37 addl $20, %esp 38 xorl %eax, %eax 39 popl %ecx 40 popl %ebp 41 leal -4(%ecx), %esp 42 ret 43 .size main, .-main 44 .ident "GCC: (Ubuntu 4.3.2-1ubuntu12) 4.3.2" 45 .section .note.GNU-stack,"",@progbits 从代码中,我们看到add函数虽然得到了相应的代码,但并没有被调用,而c=a+b则直接在编译时计算出了其值:7!其它地方并没有太多的优化。函数调用时相应的保存寄存器状态/返回时恢复等结构化的操作都没有改变。 五、进一步讨论main函数的参数argc,argv是如何传递的?看下面的代码: /* t.c */ 1 #include <stdio.h> 2 3 int main(int argc, char **argv){ 4 char *c; 5 if(argc == 1) 6 return 1; 7 else{ 8 c = argv[1]; 9 puts(c); 10 } 11 return 0; 12 } 13 gcc -S -ot.s t.c的输出文件: /* g.s */ 1 .file "hello.c" 2 .text 3 .globl main 4 .type main, @function 5 main: 6 leal 4(%esp), %ecx 7 andl $-16, %esp 8 pushl -4(%ecx) 9 pushl %ebp 10 movl %esp, %ebp 11 pushl %ecx 12 subl $36, %esp 13 movl %ecx, -28(%ebp) 14 movl -28(%ebp), %eax 15 cmpl $1, (%eax) 16 jne .L2 17 movl $1, -24(%ebp) 18 jmp .L3 19 .L2: 20 movl -28(%ebp), %edx 21 movl 4(%edx), %eax 22 addl $4, %eax 23 movl (%eax), %eax 24 movl %eax, -8(%ebp) 25 movl -8(%ebp), %eax 26 movl %eax, (%esp) 27 call puts 28 movl $0, -24(%ebp) 29 .L3: 30 movl -24(%ebp), %eax 31 addl $36, %esp 32 popl %ecx 33 popl %ebp 34 leal -4(%ecx), %esp 35 ret 36 .size main, .-main 37 .ident "GCC: (Ubuntu 4.3.2-1ubuntu12) 4.3.2" 38 .section .note.GNU-stack,"",@progbits 这里面,第6~12行与之前相同,备份寄存器,移动esp,为代码段预留数据空间。执行完这一段后,这里,%ecx是一个“指针”,指向%esp+4的位置,也就是存放argc的位置。(注意,这里的指针不完全同于C语言中指针的概念,这里的指针是指某寄存器的值是一个内存单元的地址,C语言中,指针是指某变量的值是一个内存单元的地址。) 13 movl %ecx, -28(%ebp) 将ecx这个“指针”复制到堆栈。 14 movl -28(%ebp), %eax 再把这个“指针”加载到寄存器。 15 cmpl $1, (%eax) 注意,因为%eax中存放的是“指针”,所以这里有括号。(%eax)即为初始时刻的4(%esp)。 16 jne .L2 比较,如果argc!=1,跳转到.L2处。 17 movl $1, -24(%ebp) 18 jmp .L3 如果相等,将main函数欲返回的值存到堆栈中,并且跳转到.L3。 下面看.L2的内容: 20 movl -28(%ebp), %edx 注意,这里-28(%ebp)是指向存放argc单元的“指针”。 21 movl 4(%edx), %eax 再将这个指针向上移动4字节,取出其中的值,即为argv的地址,更准确的说是argv[0]的地址。argv在C语言中是char**型指针。也就是说,%eax-->argv[0],(%eax)==argv[0] 22 addl $4, %eax %eax(argv[0]的地址)是一个内存地址,加4后就变成argv[1]的地址。(%eax)==argv[1] 23 movl (%eax), %eax 再将这个地址的内容加载到%eax,此时%eax=argv[1]。 24 movl %eax, -8(%ebp) 注意,这里%eax外面没有括号,所以复制的是argv[1],也就是一个char*型的参数。 25 movl -8(%ebp), %eax 将参数加载到寄存器,这句话有些冗余,优化后会被去除。 26 movl %eax, (%esp) 为puts准备参数。 27 call puts 28 movl $0, -24(%ebp) 从puts返回后,准备该分支main函数的返回值,0。可以看到,保存这个返回值的地方同17行。这样,无论从哪个分支出来,都可以直接返回-24(%ebp)的内容。 L3则是函数的一些扫尾工作,不需要再分析了。 六、实践
|
|