学过计算机组成原理的同学可能对计算机的工作过程有一定的了解了。计算机的工作过程,笼统地讲就是程序的执行过程。现在,我们通过反汇编一个简单的C程序,逐步分析汇编代码,来了解程序是怎样工作的。
准备一个C程序
int g( int x ) { return x + 7; } int f( int x ) { return g(x); } int main(void) { return f(11) + 1; }
使用如下命令得到汇编代码
gcc -S -o main.S main.c -m32
命令中的参数意义:
-S :表示生成汇编代码
-m32 :表示生成32位格式
生成的汇编代码如下
为了简洁,删掉了其中的一些辅助信息
分析汇编代码
大家知道,程序是从main处开始执行的,我们就先来看看main函数的汇编码。
首先看这两行
pushl %ebp
movl %esp, %ebp
可以发现,三段代码开头都是这两句。将栈基指针压栈,再将栈基指针指向栈顶,这么做的用意是保存当前基址,并开始一个新的栈,每个函数就有独立的栈空间。当函数返回时,能恢复到之前的栈空间。
再看下面两行
subl %4, %esp
movl %11, (%esp)
这两行的作用是将参数压栈。由于栈是由高地址向低地址扩充,所以入栈是栈顶指针减4(32位指令格式)。在函数 f 中也看到了类似的语句(以下语句中为什么参数是8(%esp)会在后面说明)
subl $4, %esp
movl 8(%ebp), %eax
movl %eax, (%esp)
由此我们知道在调用函数前需要把参数逐个压栈。这里函数只有一个参数,而如果是两个或更多参数,入栈顺序是怎样的呢?经查资料并验证,参数入栈顺序是从右到左依次入栈的。
接下来调用 call 命令,跳转到函数 f 。我们知道 call 命令等同以下语句
pushl %eip
movl f, %eip
将下一条指令入栈后,再将寄存器 eip 的值赋为目标函数 f 的第一条指令地址。这么做是为了当被调用的函数执行结束后,需要返回当前函数继续执行,所以必须要保存下一条指令,否则程序就无法继续执行了。
按着执行顺序,我们来看函数 f ,如前所说,前两条是保存main函数的栈基指针,后面三句是因为要调用函数 g ,先将参数入栈。
现在来回答前面的问题,为什么参数传递是
mov 8(%ebp), %eax
根据刚刚分析的过程,在main函数中参数入栈后进行了下面的操作:
- 调用 f 函数的指令 call f,使 call f 下一条指令的地址被压栈了,占用栈空间4个字节
- 进入 f 函数后,将main函数的栈基地址入栈了,这也占用了4个字节
因此, 8(%ebp) 即是前一个函数的第一个参数。
函数 g 过程与 f 类似,不再赘述,现在来看看函数退出过程是怎样的。
退出过程
我们看到,函数 main 和函数 f 退出时都使用了语句
leave
ret
leave指令相当于如下指令:
movl %ebp, %esp
popl %ebp
- 第一句是将 esp 赋值为 ebp,其实就是释放当前函数所使用的栈空间。
- 第二句是将栈顶指针赋值给ebp,并出栈,注意,此时的栈顶值实际上是前一个函数的栈基地址,所以这一句的作用就是把ebp恢复到调用函数(前一个函数)的栈基地址。
ret 作用是继续执行原来的程序,相当于
popl %eip
细心的你可能已经发现,为什么函数 g 没有leave呢?这是因为函数 g 内部没有使用变量,没有调用函数,栈空间是空的,编译器优化了指令。
总结
最后,通过这个实验,总结一下程序运行的过程:
调用其它函数时,将指令指针入栈保存,以便函数执行结束能返回来继续下一条指令的执行;
被调用函数执行时,要将当前栈基地址压栈,以便调用结束后能恢复到调用函数栈空间;
函数参数入栈,参数入栈顺序是从右到左进栈;
函数退出时,将 esp 赋值为 ebp,释放当前函数所使用的栈空间;
然后将栈顶元素出栈保存到 ebp,把ebp恢复到调用函数(前一个函数)的栈基地址;
eip退回到上一个函数即将要执行的那条语句的地址上。