一.什么是函数栈帧
函数栈帧,是程序在运行时为每一次函数调用在栈内存中分配的一块独立的内存空间,它在函数调用时创建,在函数运行结束后销毁。
二.函数栈帧的创建与销毁
以这样一个简单的函数为例,首先程序运行时要调用main函数,会调用一些汇编指令创建main函数的栈帧,
如图的三条指令就是创建main函数栈帧的过程,其中ebp,esp都是寄存器,ebp是指向栈底的指针,esp指向栈顶,这里向创建了栈底指针,然后使栈顶指针和栈底指向同一地方,最后减小栈顶指针移动到栈顶与栈底间大小为0E4h,这样就创建了main函数的栈帧,如图
如图,这些汇编指令是分别是在
将 ebx、esi、edi 这三个寄存器的值压入栈中保护。
内存初始化(将区域初始化为0xCCCCCCCC,中文字符为著名的烫烫烫),用于防止未初始化变量的错误。
调试器检查。
这些汇编指令与函数栈帧的创建与销毁关系不大,这里不再深入讨论。
如图,紧接着,这些步骤首先是将main函数内的局部变量入栈(注意这里并不是压栈操作),压栈的指令是push,这里使用的是move指令,是通过直接修改栈内存来赋值。这里在指令上访问局部变量都是通过栈底指针的偏移来访问的,例如:这里的a入栈的指令实际上是,mov dword ptr [ebp-4], 0Ah 但是这里为了方便阅读修改成了a。这里的a和b进行了赋值,但是ret的值暂时不确定。a,b,ret入栈后,main函数的栈帧如图:
紧接着是sum中的形式参数a,b进行压栈操作(谁调用,谁压栈,将a,b从右向左压入main函数的栈顶),这里a,b mov指令同样作了便于阅读的修改,不再赘述。a,b压栈后栈顶指针要进行修改,这里vs将修改操作进行了隐藏。将形参a,b压栈后main函数的栈帧如图:
如图,接下来的call指令的作用有两个其一是:将本指令下一行指令的地址压入栈中即将0x00E818D1这一地址压栈(目的是让main知道执行完sum函数后要从此处接着执行)。
其二是:让程序直接跳转到sum函数的地址0E8117Ch去执行sum函数。
add指令的作用是sum函数运行结束后,将esp的地址加8,实际上执行的是将已经不需要的形参a,出栈。
最后mov指令的作用是将sum函数的返回值给ret。
这里将add指令地址压栈后的main函数栈帧,如图:
接下来main完了就进入sum函数。
如图:与main函数一样,先通过栈顶指针与栈底指针创建sum函数的栈帧(注意这里push ebp时将main函数栈帧中的ebp压栈保存了),然后执行保存寄存器
内存初始化,调试器检查操作。
如图然后将temp入栈(不是压栈),之后将形参a,b的值加到eax寄存器中,再将eax的值移动到temp中,之后因为temp是局部变量无法出sum函数,需要通过eax寄存器带出(即将temp的值移动到寄存器eax中)。经过这些操作sum与main的栈帧如图:
sum函数运行结束后,到sum的右括号时,会执行指令 mov esp, ebp,pop ebp(pop将存入栈中的main中的ebp的值出栈并赋值给ebp,使ebp重新指向了main栈帧的栈底) ,经过这些操作销毁了sum的栈帧,但是注意这里并没有对栈上的数据进行清理(此时访问虽然并不安全,但是能得到正确的访问结果),此时函数栈帧如图:
紧接着会执行 ret 指令将栈顶内容出栈,并将栈顶的内容存入CPU的PC寄存器中,而此时栈顶存放的是前面存放的sum函数执行完毕后,下一行指令的地址,而CPU运行指令的顺序看的就是PC寄存器中的地址,由此sum函数就知道执行完自己后再执行什么指令,也就是说此时将执行前面讲过的指令:
将形参a,b出栈,并利用eax寄存器中存放的temp(a+b)的值给ret赋值,最后main函数执行完毕,销毁main函数的栈帧。
以上就是函数栈帧的创建与销毁。
为什么压栈要从右向左?
压栈操作之所以要从左向右是因为C/C++要支持可变参函数(如printf),这类可变参函数的参数个数不确定(func(int a,...)),而编译器需要从第一个参数开始访问,我们知道编译器访问栈中元素是通过栈底指针的偏移,但是假设这里是从左向右压栈,那么先压栈的a就在最下面,而我们编译器在编译阶段根本不知道我们究竟传入了多少个参数,导致了编译器找不到a。
而如果我们从右向左压栈,那么这里的a最后压栈,永远在栈顶,通过ebp的偏移就很轻松的能够访问到a了,之后从a向下访问就行了。