图解函数栈帧 - 函数的创建与销毁

Posted Aaronskr

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了图解函数栈帧 - 函数的创建与销毁相关的知识,希望对你有一定的参考价值。

🛸🛸文章开始之前,我想对各位提几个问题,看看你们能答出几个,看完本文之后,你们又能回答出几个?

  • 局部变量是怎么创建的?
  • 为什么局部变量的值是随机值?
  • 函数是怎么传参的?传参的顺序是怎样的?
  • 形参和实参是什么关系?
  • 函数调用是怎么做的?
  • 函数调用结束后是怎么返回的?

🎂前言

研究的函数: 一个加法函数。

原因:加法函数是比较简单的函数,实现逻辑比较单一,可以更为清楚的观察到函数栈帧的创建和销毁,而不是花费更多精力去研究复杂的函数。

#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。

原因:版本过高过新的编译器在对栈帧分配上进行的封装处理较为完善,我们在学习时不易于看清楚里面的具体步骤,较低版本的编译器在学习时较为友好。

研究的方法: 图解。

原因:本文将以画图、截图配上文字解释加以说明,可以更加直观的理解函数栈帧的分配情况。

🌹栈帧的概念

栈帧是指为一个函数调用单独分配的那部分栈空间。 比如,当运行中的程序调用另一个函数时,就要进入一个新的栈帧,原来函数的栈帧称为调用者的帧,新的栈帧称为当前帧。 被调用的函数运行结束后当前帧全部收缩,回到调用者的帧。

💖准备工作

  1. 将代码编辑在编译器中。

  1. 开始调试并按下鼠标右键。(按下F10)

  1. 转到反汇编。

😀main函数栈帧的创建及初始化

😁main函数的被调用

首先我们需要明确,main()函数也就是我们平时说的主函数,他其实也是需要被其他函数调用的。

  1. 我们先在调试状态下打开调用堆栈窗口。

显示如下:

  1. 接下来我们一直按F10进行调试,直到主函数return 0被返回,即可出现以下界面。

往上翻即可找到调用main()函数的函数。


也就是说main()函数其实是被一个叫__tmainCRTStartup的函数所调用的。


😂main函数栈帧的开辟

我们知道,函数和局部变量的开辟是在栈上完成的,并且栈的使用习惯是先使用高地址,后使用低地址。

假设栈空间如下:

我们知道main函数也是被其他函数调用的,所以在栈上其实还有编译器为__tmainCRTStartup函数开辟的空间,这一点心中要明确。

接下来我们看反汇编里的汇编指令:

这一部分汇编指令其实就是对main函数的栈帧进行开辟。

这里介绍一下大家对指令里陌生的东西:

寄存器:ebp,esp,ebx,esi,edi,ecx,eax等等。

其中我们需要着重记住几个寄存器的功能。

维护函数栈帧的寄存器:

  1. esp - 存放指向栈顶的地址的寄存器。
  2. ebp - 存放指向栈底的地址的寄存器。

初始化函数值的寄存器:

  1. edi - 用于存放开始进行初始化的地址。
  2. ecx - 用于存放初始化元素的数量。
  3. 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指向了同一位置。

但是注意:

  1. 此时ebp和esp没有在维护__tmainCRTStartup函数了,但不代表他的函数栈帧被销毁了,因为栈空间的使用只能先销毁低地址,再销毁高地址。
  2. 将来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

指令的意思是:

  1. 将[ebp+FFFFFF34]地址加载到寄存器edi中。
  2. 将33h作为次数放进寄存器ecx中。
  3. 将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函数的功能,栈帧开辟到结束就全部解释完毕了。

🍕🍕总结

函数栈帧用到汇编语言的知识,用最底层的角度看待函数调用的关系。

文章开头的几个问题其实在阅读到这的时候应该都能够解决了。

请注意:搞清楚函数栈帧并不能让你写代码更厉害,刷算法更牛逼,函数栈帧仅仅是类似于修炼内功一样的存在,理清楚底层的逻辑有助于我们思考一些比较复杂的问题,

例如递归算法,用函数栈帧的思想就很容易掌握。

最后,别忘了👍点赞👍+✔收藏✔+👀关注👀走一波~

以上是关于图解函数栈帧 - 函数的创建与销毁的主要内容,如果未能解决你的问题,请参考以下文章

图解C/C++语言底层:函数调用过程之函数栈帧的创建和销毁(上)

图解C/C++底层:函数栈帧的创建和销毁(下篇)

图解C/C++底层:函数栈帧的创建和销毁(下篇)

C语言深入逐汇编详解函数栈帧的创建和销毁过程

函数栈帧的创建和销毁

函数栈帧的创建与销毁,带你了解代码底层原理