《函数栈帧的创建与销毁》
编程之路,大道至简。在学习编程时,我们需要一些“功法”来帮助自己修炼,让自己和他人之间拉开距离。本篇功法致力于讲解函数栈帧部分知识,让你对从前无法解答的函数相关知识,做出更好的理解,接下来,让anduin带领大家入门这篇功法——《函数栈帧的创建与销毁》。
在学习基础知识时,我们对于以下问题都有许多困惑?
这夺命七连招
你是否能接住?
- 局部变量是怎么创建的?
- 为什么未初始化局部变量的值是随机值?
- 函数如何传参?传参顺序是怎样的?
- 形参和实参是什么关系?
- 函数是怎么调用的?
- 函数调用后如何返回?
- 返回值如何返回?
如果无法做出“招式”来应对,不必担忧,这是每一名程序员在筑基时总会遇到的瓶颈期,只要吃透这本功法,筑基就入门了,让我们一起修炼!
在学习函数栈帧的销毁和创建前,我们需要对讲解过程中的一些寄存器和汇编指令初步了解。
- eax:常用寄存器,常用于存储函数调用的返回值
- esp(重要):栈顶寄存器,记录栈顶的地址
- ebp(重要):栈底寄存器,记录栈底的地址
其他寄存器均为常用寄存器,用于保留数据。
- mov:数据移动
- sub:减法命令
- add:加法命令
- push:压栈,从栈顶放一个元素,改变esp的位置
- pop:出栈,从栈顶删除一个元素,改变esp的位置
- call:函数调用
- lea:load effective address 加载有效地址
- rep:重复指令
- stos:把寄存器中的值拷贝到指定地址处
在创建函数时,操作系统都会在栈区
上为函数开辟一块空间。而ebp
和esp
就分别处于栈顶
和栈底
,他们之间的区域就是这个函数的函数栈帧
。ebp在栈底,储存栈底的地址,叫做栈底指针
,esp在栈顶,储存栈顶的地址,叫做栈顶指针
。
栈底到栈顶地址由高变低。
图示中,esp和ebp维护的是main函数的函数栈帧
,但是当调用
另一个函数时,esp和ebp就需要维护调用函数的函数栈帧
。
调用堆栈是编译器的一种机制,可以在程序调用多个函数时,追踪每个函数在完成执行时应该返回控制的点,观察函数之间的调用关系。
要调用堆栈。这一操作需要按F10
,进入调试状态,再在窗口中点击调用堆栈
。
继续按F10,到程序结束时,调用堆栈界面会出现如下情况:
而__tmainCRTStartup()
和mainCRTStartup()
这两个函数又是什么?在crtexe.c
文件中观察:
所以发现main函数是被__tmainCRTStartup()
调用的而__tmainCRTStartup()
又被mainCRTStartup()
调用,而Add
函数又被main
函数调用。而平常main函数的返回值,就被放在mainret
中。
上面我们说到,每一个函数被调用时都会开辟空间,那么这么多函数被调用,对于这个程序,在栈区上函数的栈帧就是这样:
通过以上两部分内容的了解,我们对函数栈帧有了一个初步的认识,接下来就进入正题。
以简单函数作为讲解案例:
#include
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = 0;
c = Add(a, b);
printf("%d\n", c);
return 0;
}
鼠标右击代码,转到反汇编
,查看汇编代码,并取消勾选符号名
,从汇编角度进行讲解:
首先,由于main函数是被__tmainCRTStartup()
所调用的,所以一开始栈区为:
接着我们再观察main函数中的汇编代码:
汇编指令讲解:
栈顶
,栈顶指针esp的位置要移到栈顶的ebp
处,而地址从上到下由低到高
,所以esp处的地址的值减 4
。移动
到__tmainCRTStartup的esp处
,此时esp和ebp的值相等。产生了main函数的ebp
。值减小
,esp的位置上移
,这时就产生了main函数的esp
。此时esp到ebp的一块很大的空间就为main函数的函数栈帧
。栈顶
,栈顶指针esp的位置上移,esp的值减4。栈顶
,栈顶指针esp的位置上移,esp的值减4。栈顶
,栈顶指针esp的位置上移,esp的值减4。 查看内存
和监视
,发现以上三个寄存器的次序为edi -> esi -> ebx
,且esp的值与edi的地址:0x00dEF66C
相同。
lea edi,[ebp+FFFFFF1Ch]:显示
符号名,将[ebp - 0E4h]加载到edi中。这时的这个位置就是之前main函数的函数栈帧顶部
的位置,将这个值放入edi。
move ecx,39h:把39h放入ecx寄存器中。
move eax,0CCCCCCCCh:把0CCCCCCCCh放入eax寄存器中。
rep stos dword ptr es:[edi]:rep重复指令,将ecx寄存器中数据的值为次数,每次重复
ecx的值都会减少
,stos表示把eax的值拷贝到指定的地址,word为两个字节,d为double,也就是双字,告诉stos一次拷贝双字
的地址,也就是拷贝CCCCCCCC
到目的地址。
拷贝的地址的范围:ebp - 0E4h ~ ebp
模块对应过程图:
经以上过程,main函数的函数栈帧就开辟完成
。
函数栈帧开辟完成后,需要创建局部变量
,以及调用Add函数
,我们查看以下过程的汇编代码:
注:之前开辟的CCCCCCCC的区域大小均为4个字节
。
汇编指令讲解:
(想象一下,如果这里变量并没有初始化
,那么这一动作,在变量中放的值为CCCCCCCC
,这就是烫烫烫,就是随机值
)
ebp向上5个单位处
。所对应数据在a变量向上3个单位处(位置取决于编译器)。ebp向上8个单位处
。所对应数据在b变量向上3个单位处。这三步对应的内存分布:
20放到eax
中。该步为局部变量b传参
。栈顶
,栈顶指针esp的位置上移,esp的值减4。10放到ecx
中。该步为局部变量a传参
。栈顶
,栈顶指针esp的位置上移,esp的值减4。 通过这四步不难观察到函数传参为从右向左
传参。
009F1A50
,紧接着按F11,观察内存变化,ecx上方两个单位处的值改变,这个值为该指令的的下一条指令的地址,即让把call下一条指令的地址压栈
。这五条命令对应的内存分布:
模块对应过程图:
该过程汇编指令和main函数栈帧创建的汇编指令相似:
汇编指令讲解:
栈顶
,栈顶指针esp的位置上移,esp的值减4。add函数的esp
。此时esp到ebp的一块很大的空间就为add函数的函数栈帧
。栈顶
,栈顶指针esp的位置上移,esp的值减4。栈顶
,栈顶指针esp的位置上移,esp的值减4。栈顶
,栈顶指针esp的位置上移,esp的值减4。显示
符号名,将[ebp - 0CCh]加载到edi中,这时的这个位置就是之前add函数的函数栈帧顶部
的位置,将这个值放入edi。ebp - OCCh ~ ebp
汇编指令讲解:
初始化
局部变量z
。main函数参数a传参的值
。main函数参数b传参的值
,也就是10和20相加,此时eax中的值为30。z
。当Add函数中x和y变量相加时,发现形参并不是
在Add函数中创建
的,而是我使用了main函数传参时压栈压进去的空间,说明形参是实参的一份临时拷贝
。
结束调用
,局部变量z销毁
,为了让值安全返回,将z = 30放入eax寄存器
中。模块对应过程图:
随着计算结果的返回,函数也将结束调用,这时Add
的函数栈帧开始销毁
:
汇编指令讲解:
esp
,移向Add函数的栈底指针处
。Add的栈底指针弹出
,这时Add的栈顶和栈底指针均已出栈
,Add函数栈帧销毁
,且由于ebp弹出,这时的esp
为维护main函数的esp
,指向位置为call指令下一条指令的地址处。call指令的下一条指令
,于是弹出该值,跳转
到call指令下一条指令处,继续执行main函数
。当Add函数栈帧销毁后,栈顶
值为call指令下一条指令
,弹出该值并跳转到该指令处,汇编指令继续执行:
下个元素
,下两个元素为局部变量a,b传参时开辟的空间
,esp + 8跳过两个单位,这时栈顶指针esp
位于寄存器ebi
处。eax
的值放入ebp - 20h(ebp - 32)中,也就是变量c
中,此时c的值为30。这就说明,程序在函数结束调用后,从eax中读取返回值。
而接下来的过程就是main函数销毁栈帧的过程,这里就不再赘述,有兴趣可以自己理解一下…
局部变量是怎么创建的?
所在的函数栈帧创建完成并初识化为CCCCCCCC后,在函数栈帧区域内,以一块空间作为该局部变量的区域。
为什么未初始化局部变量的值是随机值?
函数栈帧创建完毕后,区域内存放的值为CCCCCCCC,若变量未初始化,则该区域值不变,于是生成随机值。
函数如何传参?传参顺序是怎样的?
实参在传参的时候,会从右往左传参,参数依次放入寄存器中,并压栈,当函数调用时,函数会通过指针偏移量来找到该参数,
传参顺序从右往左。
形参和实参是什么关系?
形参和实参的值相同,但是调用函数找到形参是通过指针偏移量来寻找的,所以改变形参的值不会对实参造成影响。
形参是实参的一份临时拷贝。
函数是怎么调用的?
以原函数的栈顶作为调用函数的栈底指针,为调用函数开辟新的栈帧,用call指令调用函数。
函数调用后如何返回?
调用前在栈顶压入call指令下一条地址,并将上一个函数的栈顶指针作为调用函数的ebp,在函数调用结束后,ebp出栈,找到上一次函数的ebp,回到栈帧空间,由于记住了call指令的下一条地址,用ret指令返回该地址,回到call指令的下方。
返回值如何返回?
通过eax寄存器带回。
在学习这门功法后,这夺命七连招
你是否接住了?如果接住了,那么恭喜你,你的功法已入门。
到这里,本篇功法就到此为止了,编程之路,路途遥远,虽然可能会遇到根骨欠佳(基础不扎实),招式杀伤力低(刷题少),空有其形(画图能力不太行),元神不稳(编码习惯不足),但这些都可以通过自身的努力来完善,希望在我们可以共同在编程之路上领悟我们自己的法则。
如果觉得本篇功法还不错的话,还请道友留下宝贵的三连!