一 、预备知识
• 栈(Stack)
栈是能够在函数运行之前自动分配足够的空间资源,函数运行完毕后自动回收资源。
• 堆(Heap)
堆的空间资源不同于栈,想要获取必须由程序员手动申请,然后由操作系统根据一定的算法进行分配。操作系统只有在进程结束时会自动回收该进程对应的堆空间资源,不过最好由程序员手动释放资源。
• 栈帧(Stack Frame)
栈帧的分配是从高地址向低地址逐步执行的。一个栈帧大小不是无限的,其最靠近低地址的一端称为栈顶,最接近高地址的一端称为栈底,栈顶地址和栈底地址各自保存在专门的寄存器里边,这两个专门的寄存器存放的值都是地址,故亦可分别称之为栈顶指针、栈底指针。
一个栈帧栈底地址减去栈顶地址所得的值决定了该栈帧的大小,可以通过让栈顶指针自增与自减分别控制栈帧的缩小与扩大。
要注意,栈的生长方向是从高地址到低地址的,栈是向下生长的;pop操作后,栈中的数据并没有被清空,只是该数据我们无法直接访问。
我们一般将%ebp到%esp之间的区域当作栈帧。
• 堆栈指针寄存器和基址指针寄存器
堆栈指针寄存器和基址指针寄存器都属于通用寄存器。
堆栈指针寄存器用来存放栈帧的栈顶地址,根据数据位数不同可以分为三种,16位的 sp,32位的 esp,64位的 rsp,为说明方便,下文以 esp 为例进行阐述。
基址指针寄存器用来存放栈帧的栈底地址,根据数据位数不同可以分为三种,16位的 bp,32位的 ebp,64位的 rbp,为说明方便,下文以 ebp 为例进行阐述。
• 指令寄存器 ip
该寄存器总是存放下一条执行指令的所在地址。
• 函数调用指令 call
调用一个函数时,一定会执行 call 指令,汇编中调用 printf 函数的写法如下。
call printf
call 指令包括两个步骤,第一步是让当前指令寄存器 ip 的值入栈,作为返回地址,第二步是将指令寄存器 ip 的值修改为接下来即将调用的函数第一条机器指令的所在地址,从而实现跳转。
• 不同函数的机器指令段的共性
每个函数的机器指令段的开头,都有以下几步操作:
1.第一步,在栈帧中保存上一栈帧的栈底地址,汇编指令为 push ebp。
2.第二步,将上一栈帧的栈顶地址作为当前函数栈帧的栈底地址,汇编指令为 mov ebp, esp。
3.第三步,为当前函数的局部变量开辟足够的空间,汇编指令为 sub esp, M,M 为局部变量占用栈帧空间的字节数。
每个函数的机器指令段的末尾,都有以下几步操作:
第一步,将 esp 恢复为为局部变量开辟空间之前的值,汇编指令为 mov esp, ebp,恢复后,esp 的值恰好是上一栈帧栈底地址的地址。
第二步,将 ebp 恢复为上一栈帧的栈底地址,汇编指令为 mov ebp, [esp],恢复后,esp 的值恰好是存放返回地址的地址。
第三步,将 eip 恢复为 call 指令第一步骤所操作的值,汇编指令为 mov eip, [esp],恢复后,esp 的值恰好为刚执行完的函数的第一个形参的入栈地址。
第四步,将 esp 值恢复为为刚执行完的函数的参数开辟空间之前的值,汇编指令为 pop …,恢复后,esp 的值恰好是当前栈帧最靠近 0 地址的局部变量的地址。
二 、 C 函数调用过程剖解
#include <stdio.h>
int main(void)
{
int apple = 10;
int pear = 20;
int total = 0;
printf("apple = %d, pear = %d.\n", apple, pear);
total = apple + pear;
return 0;
}
1.printf 函数调用之前,参数从右向左入栈。
2.调用 call 指令,此时存储在指令寄存器 ip 中的值是 printf 函数下一条语句 total = apple + pear; 对应的机器指令的地址,该地址入栈,同时指令寄存器 ip 的值修改为 printf 函数在代码段中的第一条指令的地址。
3.开始执行 printf 函数时,会进行三步操作——在 printf 函数栈帧中保存 main 函数栈帧的栈底地址;将 main 函数栈帧的栈顶地址作为 printf 函数栈帧的栈底地址;为 printf 函数的局部变量开辟足够的空间。三步操作执行完之后便开始执行 printf 函数的主体机器指令段。
3.printf 函数的主体机器指令段执行完毕后,便开始收尾工作——将 esp 恢复为为 printf 函数局部变量开辟空间之前的值;将 ebp 恢复为 main 函数栈帧的栈底地址;将 eip 恢复为语句 total = apple + pear; 对应的机器指令地址;将 esp 值恢复为为 printf 函数的参数开辟空间之前的值,恢复后,esp 的值恰好是 total 的地址。