这段C代码是怎么工作的?
一、实验内容
根据所给example.c代码分别生成.cpp,.s,.o和ELF可执行文件,并加载运行,分析.s汇编代码在CPU上的执行过程。通过实验理解计算机的工作机理。
example.c代码如下
1 int g(int x) 2 { 3 return x+3; 4 } 5 6 int f(int x) 7 { 8 return g(x); 9 } 10 11 int main(void) 12 { 13 return f(8)+1; 14 }
二、实验过程
1.首先生成预编译文件、汇编文件和ELF可执行文件,见图1所示。
图1
2.本文的目标主要是简单理解一下底层汇编的执行过程,上面生成的example.s代码摘抄如下
1 .file "example.c" 2 .text 3 .globl g 4 .type g, @function 5 g: 6 .LFB0: 7 .cfi_startproc 8 pushl %ebp 9 .cfi_def_cfa_offset 8 10 .cfi_offset 5, -8 11 movl %esp, %ebp 12 .cfi_def_cfa_register 5 13 movl 8(%ebp), %eax 14 addl $3, %eax 15 popl %ebp 16 .cfi_def_cfa 4, 4 17 .cfi_restore 5 18 ret 19 .cfi_endproc 20 .LFE0: 21 .size g, .-g 22 .globl f 23 .type f, @function 24 f: 25 .LFB1: 26 .cfi_startproc 27 pushl %ebp 28 .cfi_def_cfa_offset 8 29 .cfi_offset 5, -8 30 movl %esp, %ebp 31 .cfi_def_cfa_register 5 32 subl $4, %esp 33 movl 8(%ebp), %eax 34 movl %eax, (%esp) 35 call g 36 leave 37 .cfi_restore 5 38 .cfi_def_cfa 4, 4 39 ret 40 .cfi_endproc 41 .LFE1: 42 .size f, .-f 43 .globl main 44 .type main, @function 45 main: 46 .LFB2: 47 .cfi_startproc 48 pushl %ebp 49 .cfi_def_cfa_offset 8 50 .cfi_offset 5, -8 51 movl %esp, %ebp 52 .cfi_def_cfa_register 5 53 subl $4, %esp 54 movl $8, (%esp) 55 call f 56 addl $1, %eax 57 leave 58 .cfi_restore 5 59 .cfi_def_cfa 4, 4 60 ret 61 .cfi_endproc 62 .LFE2: 63 .size main, .-main 64 .ident "GCC: (Ubuntu/Linaro 4.6.3-1ubuntu5) 4.6.3" 65 .section .note.GNU-stack,"",@progbits
先介绍几个寄存器以方便下面的阐述。ebp和esp是两个指针寄存器,用来指向当前所用栈的底部和顶部;eip指令寄存器是用来记录的是CPU将要执行的下 一条指令地址,也就是说CPU接下来要执行哪一条指令是通过eip来指引的;eax数据寄存器通常用来保存API的返回值,由于操作的效率比较高,因而使用 的频率也比较高。
从main()函数开始分析
pushl %ebp
movl %esp, %ebp
刚开始栈顶指针esp1指向初始位置,然后将之前的栈底指针ebp1入栈并且新的ebp和esp同时指向了地址减少4字节后的位置即图中所示 的esp2/ebp2,很明显这一步的作用就是保存之前栈环境的前提下建立新的堆栈框架,堆栈变化如图2所示。
图2
subl $4, %esp
movl $8, (%esp)
将esp向低地址增长4字节并将8入栈esp指向途中esp3所指位置,堆栈变化如图3所示。
图3
call f
CPU执行CALL指令时通常做两步操作,第一步将EIP压栈做保存;第二部跳转,堆栈变化如图4所示。
图4
将当前eip入栈并跳转到f中去。跳到f后做的第一件事儿仍然是
pushl %ebp
movl %esp, %ebp
前面已经说过这是保存之前栈环境的前提下建立新的堆栈框架,堆栈变化如图5所示
图5
subl $4, %esp
movl 8(%ebp), %eax
movl %eax, (%esp)
在此过程中将8(%ebp)的参数传入eax中,并将eax入栈,堆栈变化见图6所示
图6
call g
调用g函数,堆栈变化见图7
图7
来到g中不变的首先做新堆栈框架搭建,堆栈变化见图8
pushl %ebp
movl %esp, %ebp
图8
movl 8(%ebp), %eax
addl $3, %eax
popl %ebp
ret
在此过程中,将之前保存的变量再次转移到eax中,并将eas加3,实际上此事eas就为11了。然后出栈一次,ebp重归于ebp3,esp重归于esp7,堆栈变化见图9
图9
好了,现在又重新回到函数f中了
leave
宏指令leave包括
movl %ebp, %esp
popl %ebp
这两条指令,实际上就是刚进函数时建堆栈的逆过程,堆栈变化见图10
图10
ret
回到main()函数中,堆栈变化如图11所示
图11
addl $1, %eax
将eax中的数再次给加1
leave
ret
最后一夜回到解放前,重回程序开始的地方,堆栈变化见图12
图12
二、实验总结
上面的分析是对函数调用做了堆栈的分析。实际上计算机的工作原理就是如此,在调用其他函数时先将数据压栈做现场保护,接着去处理被调用函数,被调用函数还有可能调用其他函数,这时候同样需要做现场保护。如我在前面说的,call指令首先做现场保护(将EIP压栈等)然后转移到被调用函数。最后由leave指令做拆除被调用堆栈环境工作并由ret返回。其最核心的思想便是栈了,因此认真的写一篇博文再仔细体会一下栈操作的细节还是很有必要的。