💙作者:阿润菜菜
📖专栏:C++
本文目录
什么是栈帧
在调试中观察
总结
什么是栈帧
那我们先来看看什么是栈:
栈(stack)是限定仅在表尾进行插入或者删除的线性表。栈是一种数据结构,它按照后进先出的原则存储数据。把数据元素存放到栈顶时,叫压栈(push) ,从栈顶删除一个元素,叫出栈(pop)。那什么是栈帧(Stack Frame)呢?
预备知识:
每一次函数的调用,都会在调用栈(call stack)上维护一个独立的栈帧空间(stack frame).每个独立的栈帧一般包括:
- 函数的返回地址和参数
- 临时变量: 包括函数的非静态局部变量以及编译器自动生成的其他临时变量
- esp、ebp这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的
- .ebp(栈底指针):该指针永远指向系统栈最上面一个栈帧的底部
- esp(栈顶指针):该指针永远指向系统栈最上面一个栈帧的栈顶
- 栈是从高地址向低地址延伸,一个函数的栈帧用ebp 和 esp 这两个寄存器来划定范围.ebp 指向当前的栈帧的底部,esp 始终指向栈帧的顶部
-
压栈push :esp上移朝低地址移动;出栈pop:栈顶元素弹出,esp下移高地址
在调试中观察
我们使用的环境是VS2013,由于函数栈帧是底层知识,而越高级的编译器越难以抽离出函数栈帧分装的过程,不容易学习和观察。同时在不同的编译器下,函数调用栈帧的创建也是略有差异的,但大体思路都是一样的。
每一个函数独占自己的栈帧空间。当前正在运行的函数的栈帧总是在栈顶。
如图:
在调用main函数的时候,会在栈中开辟一块空间,由ebp和esp共同来维护(在调用哪个函数,ebp和esp就会维护哪块空间)
但main函数是被怎么调用的呢?是被系统内提前建立好的函数栈帧调用的:
通过反汇编可以看到main函数是被_tmainCRTStartup()函数调用的,通过一系列汇编指令调用main函数同时esp和ebp来进行维护: 我们来看下这些汇编指令走的过程
当执行压栈push时, ebp压到esp顶部,esp上移
执行move时,mov ebp,esp 就是把esp的地址交给edp
此时ebp和esp指向了同一个地址
下一步是sub esp,0E4h 就是把esp减去0E4h使esp上移。也就是为main函数开辟了空间
下面就是三个push:分别压进了ebx,esi和edi三个值(具体是什么值,无需关心,后面会自动弹出)
接下来lea (load effective address) 就是为edi加载有效地址 [ebp - 0E4h]
通过下面mov和rep stos三个命令,我们把ebx到ebp之间的栈空间初始化为eax里的内容
此时main函数的栈帧空间已开辟好,开始执行真正的有内容的代码:
32位中,word是两个字节,dword(double word)四字节
mov 把0Ah(也就是10)放到ebp-8的位置上,而ebp-8实际上就是为int a开辟一个空间 (局部变量int b =20 ,c = 0 的创建 与变量a 类似)
接下来就是调用Add函数了,我们可以看到是一条mov 指令,把[ebp -14h]值(也就是变量b值)放到eax中;然后就是push,压栈eax(b =20), 下面接着一条mov和push命令,类似压栈将变量a的值压入ecx;
那么刚刚做的步骤是在为Add函数传参吗?是的。接下来call 命令就是调用,通过调试窗口我们可以清楚的看到a上面就是call指令的下一条指令的地址。这一步是在调用函数的同时把下一条指令的地址压上去,作为函数回归的标记
至此就来到我们的Add函数栈帧,与上面讲的main函数栈帧开辟一样。参数是从右向左压栈的,从上面我们也可以清楚的看到形参不是在Add函数内部创建的,而是回来到我们传参的空间,这也直接证明了形参是实参的临时拷贝这句话 !
那Add函数是如何带回返回值的呢?可以看到把[ebp-8]里的值也就是int z 放到eax里面,因为这里的eax是寄存器(硬件)啊,寄存器不会因为程序退出就销毁的,相当于拿一个(全局的)寄存器把返回值保存起来,等到执行main函数我们再把它拿出来。
那么函数怎么返回呢?
在 return z执行后,我们pop弹出,把栈顶的元素取出放到edi里面去,依次pop三次,esp指针就往下走。当我们函数调用完了那这个空间就没必要存在了,所以mov把ebp的地址给esp。
此时esp指到ebp,pop一下把栈顶的元素弹出来,因为里面放的是main函数的栈底指针,把结果弹到ebp里面去就可以瞬间到main栈底了
最后ret这条指令就是栈顶弹出call下一条指令地址然后跳过去,回来后就到了call下一条指令地方。此时add 就把形参的空间还给操作系统,然后把eax的值给[ebp-32]空间就是变量int c的空间。
觉得配合图示很难理解,大家可以结合实操快速掌握函数栈帧的创建和销毁过程
总结
在函数调用的过程中,有函数的调用者(caller)和被调用的函数(callee). 调用者需要知道被调用者函数返回值; 被调用者需要知道传入的参数和返回的地址
函数调用:
- 参数入栈: 将参数按照调用约定(C语言是从右向左)依次压入系统栈中
- 返回地址入栈: 将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行
- 代码跳转: 处理器将代码区跳转到被调用函数的入口处;
- 栈帧调整:
1.将调用者的ebp压栈处理,保存指向栈底的ebp的地址(方便函数返回之后的现场恢复),此时esp指向新的栈顶位置;push ebp
2.将当前栈帧切换到新栈帧(将eps值装入ebp,更新栈帧底部), 这时ebp指向栈顶,而此时栈顶就是old ebpmov ebp, esp
3.给新栈帧分配空间sub esp, XXX
函数返回:
- 保存被调用函数的返回值到 eax 寄存器中
mov eax, xxx
- 恢复 esp 同时回收局部变量空间
mov ebp, esp
- 将上一个栈帧底部位置恢复到 ebp
pop ebp
- 弹出当前栈顶元素,从栈中取到返回地址,并跳转到该位置
ret
内容参考:系统栈的工作原理
本文完。如有建议或问题欢迎评论区讨论