函数栈帧
- 🎂前言
- 🌹栈帧的概念
- 💖准备工作
- 😀main函数栈帧的创建及初始化
- 😁main函数的被调用
- 😂main函数栈帧的开辟
- 🤣main函数栈帧的初始化
- 👩临时变量的创建。
- 👨Add函数栈帧的创建
- 🧑Add函数栈帧的创建
- 👧Add函数栈帧的初始化
- 🎈Add函数实现加法运算
- 🧨Add函数返回值实现
- 🎆Add函数栈帧的销毁
- 🎇返回到main函数指令
- 🍕🍕总结
🛸🛸文章开始之前,我想对各位提几个问题,看看你们能答出几个,看完本文之后,你们又能回答出几个?
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值?
- 函数是怎么传参的?传参的顺序是怎样的?
- 形参和实参是什么关系?
- 函数调用是怎么做的?
- 函数调用结束后是怎么返回的?
🎂前言
研究的函数: 一个加法函数。
原因:加法函数是比较简单的函数,实现逻辑比较单一,可以更为清楚的观察到函数栈帧的创建和销毁,而不是花费更多精力去研究复杂的函数。
#include <stdio.h>
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;
}
使用的编译器: VS2013。
原因:版本过高过新的编译器在对栈帧分配上进行的封装处理较为完善,我们在学习时不易于看清楚里面的具体步骤,较低版本的编译器在学习时较为友好。
研究的方法: 图解。
原因:本文将以画图、截图配上文字解释加以说明,可以更加直观的理解函数栈帧的分配情况。
🌹栈帧的概念
栈帧是指为一个函数调用单独分配的那部分栈空间。 比如,当运行中的程序调用另一个函数时,就要进入一个新的栈帧,原来函数的栈帧称为调用者的帧,新的栈帧称为当前帧。 被调用的函数运行结束后当前帧全部收缩,回到调用者的帧。
💖准备工作
- 将代码编辑在编译器中。
- 开始调试并按下鼠标右键。(按下F10)
- 转到反汇编。
😀main函数栈帧的创建及初始化
😁main函数的被调用
首先我们需要明确,main()函数也就是我们平时说的主函数,他其实也是需要被其他函数调用的。
- 我们先在调试状态下打开调用堆栈窗口。
显示如下:
- 接下来我们一直按F10进行调试,直到主函数return 0被返回,即可出现以下界面。
往上翻即可找到调用main()函数的函数。
也就是说main()函数其实是被一个叫__tmainCRTStartup的函数所调用的。
😂main函数栈帧的开辟
我们知道,函数和局部变量的开辟是在栈上完成的,并且栈的使用习惯是先使用高地址,后使用低地址。
假设栈空间如下:
我们知道main函数也是被其他函数调用的,所以在栈上其实还有编译器为__tmainCRTStartup函数开辟的空间,这一点心中要明确。
接下来我们看反汇编里的汇编指令:
这一部分汇编指令其实就是对main函数的栈帧进行开辟。
这里介绍一下大家对指令里陌生的东西:
寄存器:ebp,esp,ebx,esi,edi,ecx,eax等等。
其中我们需要着重记住几个寄存器的功能。
维护函数栈帧的寄存器:
- esp - 存放指向栈顶的地址的寄存器。
- ebp - 存放指向栈底的地址的寄存器。
初始化函数值的寄存器:
- edi - 用于存放开始进行初始化的地址。
- ecx - 用于存放初始化元素的数量。
- eax - 用于存放将要初始化为什么东西的内容。
下面先给出在执行main函数之前栈的情况:
这里是编译器为__tmainCRTStartup函数开辟的函数栈帧,可以看见,ebp寄存器指向栈底,esp寄存器指向栈顶,以此来维护__tmainCRTStartup函数的函数栈帧。
下面我们一一分析main函数的汇编指令操作:
push ebp
指将ebp寄存器中的值进行压栈操作(push)。
即在编译器已为__tmainCRTStartup函数开辟的栈帧上面进行压栈。
因为esp是指向栈顶的寄存器,所以每次压栈之后esp寄存器所指的位置会上升,反之如果执行pop弹出的操作,esp寄存器所指的位置则会下降。
此时__tmainCRTStartup函数的函数栈帧也随之增加。
下一个操作。
mov ebp,esp
这条操作的指令是将esp的值赋给ebp。
也就是说让esp里存放所指向的地址赋给ebp,那么ebp所指向的位置将会发生更改。
此时ebp和esp指向了同一位置。
但是注意:
- 此时ebp和esp没有在维护__tmainCRTStartup函数了,但不代表他的函数栈帧被销毁了,因为栈空间的使用只能先销毁低地址,再销毁高地址。
- 将来main函数返回之后,esp寄存器和ebp寄存器还是要回来维护__tmainCRTStartup函数的,这里第一条指令push的ebpc操作就是伏笔,在main函数返回值后会执行pop这个ebp的操作,直接把ebp寄存器弹向之前存放的位置,也就是__tmainCRTStartup函数的栈底。
再下一个操作:
sub esp,0E4h
这里解释一下,sub就是减法操作,这里0E4h表示十六进制的数字,h为标识符,所以0E4h其实就是十进制的228。
合起来就是将esp里存放的地址减去0E4h的大小。
因为上面是低地址下面是高地址,所以减去0E4h应该是向上走。
现在ebp和esp所维护的这段空间就是为main函数开辟的函数栈帧。
至此,main函数的函数栈帧就开辟完成了。
🤣main函数栈帧的初始化
我们在写代码的时候一定出现过一个问题,就是使用未初始化的变量或内容进行打印,结果控制台输出的东西完全是我们意想不到的结果。例如:
为什么这里会出现随机值呢?
下面就可以给出答案。
下面三条指令全部是push。
push ebx
push esi
push edi
三次push压栈之后,esp的位置自动发生变化,main函数的函数栈帧也随即增大。
接下来:
lea: load effective address(加载有效地址)
lea edi,[ebp - 0E4h]
前面介绍过几个重要的寄存器:
所以这里的edi是用来存放开始进行初始化的地址的,也就是把【ebp - 0E4h】这个地址放进edi保存起来。
下面两个操作都是针对初始化用的寄存器的:
mov ecx,39h
mov eax,0CCCCCCCCh
ecx是存放初始化内容的次数的,所以是把39h这个次数存放在寄存器ecx中。
而eax是存放要初始化为的内容的,所以将0CCCCCCCCh存放进eax寄存器中。
接下来的指令就是初始化的关键:
rep stos dword ptr es:[edi]
dword的意思是double word - 一个word是两个字节,所以一个dword是4个字节。
整个指令的意思是从edi存放的位置开始往下每四个字节算一次,重复ecx里存放的值这么多次,把这些内容全部改为eax里存放的值。
也就是从ebp - 0E4h位置开始往下39h个整型的位置全部初始化为0CCCCCCCCh
至此main函数栈帧里的内容已被全部初始化。
此时栈里的情况:
为了防止有的码友不相信,这里我们计算一波。
十六进制39h转换成十进制是57。
57次,一次4个字节,也就是57乘以4等于228个字节。
而十六进制0E4h转化为十进制正号等于228。
所以至此,main函数栈帧里的所有内容全部被初始化为0CCCCCCCCh。
👩临时变量的创建。
这里的指令看的不够清晰,因为编译器默认显示了变量名,这不适合我们学习具体情况,所以我们应该把显示变量名给勾选掉。
把勾选去掉即可,效果如下:
这里就可以把具体位置看的比较清晰。
move dword ptr [ebp-8],0Ah
十六进制的0Ah转换成十进制也就是10,这条指令的意思就是将0Ah这个数放进[ebp-8]的位置。
也就是把ebp-8这个位置分配给变量a,将里面的值赋为10。
move dword ptr [ebp-14h],14h
同上,这里的十六进制数字14h转换为十进制是20,将20放进[ebp-14h]的位置。
move dword ptr [ebp-20],0
同上,将0赋给[ebp-20h]的位置,也就是为c变量开辟空间并赋值。
看起来似乎到了函数调用了,但其实不然,调用函数之前,先在主调函数内创建实参的临时拷贝,再进行调用函数,接下来一一分析。
move eax,dwor ptr [ebp-14h]
这句指令的意思是将[ebp-14h]位置存放的值赋给eax寄存器。
而我们可以看到:ebp - 14h的位置不就是我们刚刚创建的b变量吗?
这个操作把实参b的值存放到了寄存器eax中。
下一指令:
push eax
将eax压栈,这里我们记住eax中存放的值就是实参b的值。
move ecx,dword ptr [ebp-8]
push ecx
同上,将[ebp-8]位置的值放在ecx里,之后将ecx压栈。
而ebp-8位置放的就是a变量。
执行到这里,其实就不难看出上面的操作其实是在给Add函数传参,开辟两个空间存放实参的临时拷贝。
注意: 这里传参的顺序是先传b后传a,并且是在main函数的栈帧内部进行的。
👨Add函数栈帧的创建
在创建Add函数的函数栈帧之前,编译器还做了一件事情:
call 00B910E1
乍一看这个指令非常奇怪,但我们将调试进行下去,直到call指令的时候按F11进行逐语句调试。
会跳转到这个步骤。
这就是call指令的下一条指令,编译器把调用函数之后的下一条指令存放在栈上,将来被调用函数返回之后,便可根据这个地址直接执行调用函数后需要执行的指令。
在这一点上就可以体现编译器对函数栈帧的调用的严谨。
既要考虑到如何调用函数分配空间,也要考虑到函数调用结束怎么回到本该执行的下一条指令。
之后便开始对Add函数栈帧的创建。
🧑Add函数栈帧的创建
注意第一个操作:
push ebp
这里把ebp中存放的值进行压栈,也就是说这个位置存放的是原来ebp所指向的位置。
这里给出标记: ebp:main
表示的是这里的ebp存放的是main函数栈底的位置,将来在pop这个值得时候将会把ebp直接弹回main函数栈底的位置,继而继续维护main函数。
接下来的操作和开辟main函数栈帧十分相似:
mov ebp,esp
sub esp,0CCh
先将esp指向的位置赋给ebp,这样ebp就会和esp指向同一个位置,作为即将开辟栈帧的栈底。
再给esp减去0CCh的值,也就是往上偏移0CCh个字节长度,十六进制0CCh转化为十进制为204。这也就是编译器为Add函数分配的函数栈帧的大小。
注: 栈帧空间分配是编译器自行决定的,无法人为估测。
此时ebp到esp之间的部分就是编译器为Add函数分配的函数栈帧。
👧Add函数栈帧的初始化
和main函数一样,在函数栈帧创建完毕之后,会通过三个寄存器对函数栈帧
初始化,这里再次把寄存器作用给大家展示:
首先进行三个push指令
push ebx
push esi
push edi
前面介绍过:
lea:load effective address(加载有效地址)
lea edi,[ebp+FFFFFF34h]
move ecx,33h
move eax,0CCCCCCCCh
指令的意思是:
- 将[ebp+FFFFFF34]地址加载到寄存器edi中。
- 将33h作为次数放进寄存器ecx中。
- 将0CCCCCCCCh作为要初始化为的内容存放在eax中。
而FFFFFF34的二进制序列是:11111111111111111111111100110100
显然,这是一个负数,所有ebp+FFFFFF34其实他的地址是在减小,所以此时存放的位置其实是可以计算得到的。
十六进制数33h的十进制形式为51,也就是要重复进行51次值覆盖。
覆盖值为0CCCCCCCCh。
rep stos dword ptr es:[edi]
最后的指令就是从edi寄存器放的位置开始往下33h次进行值覆盖,覆盖内容为0CCCCCCCCh。
🎈Add函数实现加法运算
mov dword ptr [edp-8],0
这是开辟临时变量的步骤。
将0赋给edp-8的位置,也就是给变量z开辟了一块空间。
mov eax,dword ptr [ebp+8]
将ebp+8位置的值放进寄存器eax中保存。
可以从图上看到,ebp+8的位置时从main函数传过来的实参临时拷贝中的10。
此时
eax: 10
add eax,dword ptr [ebp+0Ch]
这里我们可以计算,0Ch转换为十进制也就是12,所有ebp+12应该是从当前ebp位置往下数3个格子(因为一个格子是4个字节)。
找到ebp+0Ch的位置:
将这里面的值加到寄存器eax中去:
此时
eax: 30
mov dword ptr [ebp-8],eax
把寄存器eax里的值放进[ebp-8]的位置里。
此时就已经完成了计算功能。
🧨Add函数返回值实现
函数功能实现之后,就要返回函数值了。
mov eax,dword ptr [ebp-8]
指令:将[ebp-8]地址的值放进eax寄存器,也就是把刚才计算结果30存放进寄存器中。
🎆Add函数栈帧的销毁
pop是出栈指令,把栈中的值弹出到指定的地方。
pop edi
pop esi
pop ebx
连续三个pop,将之前初始化Add函数是压栈的三个元素弹出。
三个元素出栈后,Add函数的栈帧随之减少,esp所指向的位置也随机发生更改。
mov esp,ebp
和创建函数栈帧时的操作类似,但又不同,将ebp的值赋给esp。
即esp将会直接指向ebp指向的位置。
一旦执行完上面的操作指令,也就意味着esp,ebp两个寄存器不再维护Add函数栈帧了,开辟的空间将全部返还给操作系统。
pop ebp
将栈顶的元素弹出到ebp位置。
注意看这里的栈顶所放元素:
前文中已经提到过,这里压栈ebp的用途,就是为了在销毁Add函数之后ebp可以找到main函数栈底位置,继而继续维护main函数的栈帧。
所以这条指令将使ebp指向原main函数栈底。
此时Add函数栈帧已全部销毁。
🎇返回到main函数指令
接下来esp就指向了00B910E1。
前文提到过,这是call调用指令的下一条指令,所以直接返回到main函数的下一条指令。
现在在反汇编调试按F10将直接从Add函数跳转到main函数call指令的下一条指令。
add esp,8
把8加给esp寄存器,让其向下移动两个单元格(一个格子4个字节)
此时栈顶两个元素就不再被维护,main函数的函数栈帧也随之减少。
mov dword ptr [ebp-20h],eax
将eax寄存器里的值赋给ebp-20h位置,eax是我们在Add函数里计算结束后存放的返回值(30),ebp-20的位置时变量c的地址。
至此,Add函数的功能,栈帧开辟到结束就全部解释完毕了。
🍕🍕总结
函数栈帧用到汇编语言的知识,用最底层的角度看待函数调用的关系。
文章开头的几个问题其实在阅读到这的时候应该都能够解决了。
请注意:搞清楚函数栈帧并不能让你写代码更厉害,刷算法更牛逼,函数栈帧仅仅是类似于修炼内功一样的存在,理清楚底层的逻辑有助于我们思考一些比较复杂的问题,
例如递归算法,用函数栈帧的思想就很容易掌握。
最后,别忘了👍点赞👍+✔收藏✔+👀关注👀走一波~