首先,我们先来了解下栈帧和栈的基本知识:
- 栈帧也常被称为“活动记录”(activation record),是编译器用来实现过程/函数调用的一种数据结构。
- 从逻辑上讲,栈帧就是一个函数执行的环境,包含所有与函数调用相关的数据:主要包括函数参数、函数中的局部变量、函数执行完后的返回地址,被函数修改的需要恢复的任何寄存器的副本。
另外,需要注意的是:栈是从高地址向低地址延伸的。每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中维持着所需要的各种信息。寄存器ebp用来指向当前的栈帧的底部(高地址),寄存器esp用来指向当前的栈帧的顶部(低地址),即在函数调用执行过程中,寻找所需参数或变量信息时使用寄存器ebp来寻址,因为寄存器esp的值是经常变化的,而寄存器ebp的值对一个函数的栈帧来讲是不变的。
栈是一种LIFO形式的数据结构,所有的数据都是后进先出。这种形式的数据结构正好满足我们调用函数的方式: 父函数调用子函数,父函数在前,子函数在后;返回时,子函数先返回,父函数后返回栈支持两种基本操作,push和pop。push将数据压入栈中,pop将栈中的数据弹出并存储到指定寄存器或者内存中。
pop操作后,栈中的数据并没有被清空,只是该数据我们无法直接访问。栈的形式不一定必须时向下生长的,也可以向上生长,只是我们的系统调用栈(call stack)用到的是向下生长形式而已。
有了上面的基础知识,让我们以一个实际的例子来讲解一下。
int Add(int a, int b)
{
int c;
c=a+b;
return c;
}
Main()
{
Add(10, 5);
printf("hello world!");
}
程序从main()函数开始执行,首先将main()函数压入系统调用栈(call stack)(下面如无特殊说明,用栈代指系统调用栈(call stack)),并给它分配一个栈帧用以保存所需信息。然后执行main函数中第一条语句,这是一条函数调用语句,在实际调用执行Add函数前需要做一些准备工作。
首先将5和10两个参数压入栈,同时更新ESP指针的值,如下图所示:
注意,参数5和10压入栈的顺序,应该是从右向左依次压入系统调用栈(call stack)。
接下来我们需要搞清楚的是:当Add函数调用执行完毕之后,我们需通过某种方式返回到main函数中继续执行下面的指令,在本例中也就是执行print函数。解决这个问题的方式就是将下一条指令的地址压入栈中。
以上准备工作就绪,下面开始调用执行Add函数。
首先,我们需要将main函数用来寻址参数或变量信息的EBP寄存器值压入栈中保存,以便于从Add函数返回之后,从栈中取出EBP的值赋给EBP寄存器让main函数用来寻址参数或变量信息,同时更新ESP的值。为了Add函数能够寻址到所需信息,将此时的ESP寄存器的值赋值给EBP寄存器(图中的EBP-new)。此时将接着执行int c语句,为变量c开辟一段内存空间压入栈中,同时更新ESP的值。接下来执行c=a+b,然后返回c,但main函数中并没有声明变量来存储该返回值,故该返回值丢失。函数返回时将ESP更新为EBP-new,接着将EBP-old弹出赋值给EBP寄存器,让main函数拿来寻址所需信息,此时就从Add函数的栈帧恢复到了main函数的栈帧。接着弹出RET(Add函数的返回地址),对应的汇编代码中会有一条ret指令,该指令会将RET返回地址保存到EIP寄存器中,然后处理器根据这个地址无条件跳转到main函数的相应位置去取下一条指令即print函数继续执行。print函数调用执行过程中压栈出栈过程与Add函数类似,不再细说。