当前位置:首页 » 《随便一记》 » 正文

图解函数栈帧

8 人参与  2022年03月15日 16:32  分类 : 《随便一记》  评论

点击全文阅读


函数栈帧

  • 🎂前言
  • 🌹栈帧的概念
  • 💖准备工作
  • 😀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。

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

研究的方法: 图解。

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

🌹栈帧的概念

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

💖准备工作

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

代码文本编辑

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

点击转到反汇编

  1. 转到反汇编。
    反汇编

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

😁main函数的被调用

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

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

显示如下:

调用堆栈窗口

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

main函数被调用情况

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

调用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函数栈帧

这里是编译器为__tmainCRTStartup函数开辟的函数栈帧,可以看见,ebp寄存器指向栈底,esp寄存器指向栈顶,以此来维护__tmainCRTStartup函数的函数栈帧。

下面我们一一分析main函数的汇编指令操作:
汇编指令

push        ebp

指将ebp寄存器中的值进行压栈操作(push)。

即在编译器已为__tmainCRTStartup函数开辟的栈帧上面进行压栈。

压栈操作

因为esp是指向栈顶的寄存器,所以每次压栈之后esp寄存器所指的位置会上升,反之如果执行pop弹出的操作,esp寄存器所指的位置则会下降。

esp位置上升

此时__tmainCRTStartup函数的函数栈帧也随之增加。

栈帧增加

下一个操作。

汇编指令

mov            ebp,esp

这条操作的指令是将esp的值赋给ebp。

也就是说让esp里存放所指向的地址赋给ebp,那么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应该是向上走。

esp向上0E4h

现在ebp和esp所维护的这段空间就是为main函数开辟的函数栈帧。

main函数栈帧

至此,main函数的函数栈帧就开辟完成了。


🤣main函数栈帧的初始化

我们在写代码的时候一定出现过一个问题,就是使用未初始化的变量或内容进行打印,结果控制台输出的东西完全是我们意想不到的结果。例如:

随机值
为什么这里会出现随机值呢?

下面就可以给出答案。

汇编指令

下面三条指令全部是push。

push                    ebx
push                    esi
push                    edi

三次push操作

三次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。

给a开辟空间并赋值

汇编指令

move                                  dword ptr [ebp-14h],14h

同上,这里的十六进制数字14h转换为十进制是20,将20放进[ebp-14h]的位置。

给b开辟空间并赋值

汇编指令

move                         dword ptr [ebp-20],0

同上,将0赋给[ebp-20h]的位置,也就是为c变量开辟空间并赋值。

给变量c开辟空间并赋值

汇编指令
看起来似乎到了函数调用了,但其实不然,调用函数之前,先在主调函数内创建实参的临时拷贝,再进行调用函数,接下来一一分析。

move               eax,dwor ptr [ebp-14h]

这句指令的意思是将[ebp-14h]位置存放的值赋给eax寄存器。

而我们可以看到:ebp - 14h的位置不就是我们刚刚创建的b变量吗?

这个操作把实参b的值存放到了寄存器eax中。

下一指令:

push               eax

将eax压栈,这里我们记住eax中存放的值就是实参b的值。

push
汇编指令

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进行逐语句调试。

会跳转到这个步骤。
jmp语句
这就是call指令的下一条指令,编译器把调用函数之后的下一条指令存放在栈上,将来被调用函数返回之后,便可根据这个地址直接执行调用函数后需要执行的指令。

在这一点上就可以体现编译器对函数栈帧的调用的严谨。

既要考虑到如何调用函数分配空间,也要考虑到函数调用结束怎么回到本该执行的下一条指令。

存放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函数栈帧


👧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函数初始化后

🎈Add函数实现加法运算

汇编指令

mov         dword ptr [edp-8],0

这是开辟临时变量的步骤。

将0赋给edp-8的位置,也就是给变量z开辟了一块空间。

创建z变量

汇编指令

mov               eax,dword ptr [ebp+8]

将ebp+8位置的值放进寄存器eax中保存。

可以从图上看到,ebp+8的位置时从main函数传过来的实参临时拷贝中的10。

ebp+8的位置

此时

eax: 10

add                  eax,dword ptr [ebp+0Ch]

这里我们可以计算,0Ch转换为十进制也就是12,所有ebp+12应该是从当前ebp位置往下数3个格子(因为一个格子是4个字节)。

找到ebp+0Ch的位置:

ebp+0Ch

将这里面的值加到寄存器eax中去:

此时

eax: 30

汇编指令

mov                      dword ptr [ebp-8],eax

把寄存器eax里的值放进[ebp-8]的位置里。

z的值变为30

此时就已经完成了计算功能。

🧨Add函数返回值实现

函数功能实现之后,就要返回函数值了。

汇编指令

mov                         eax,dword ptr [ebp-8]

指令:将[ebp-8]地址的值放进eax寄存器,也就是把刚才计算结果30存放进寄存器中。

🎆Add函数栈帧的销毁

pop是出栈指令,把栈中的值弹出到指定的地方。

pop               edi
pop               esi
pop               ebx

连续三个pop,将之前初始化Add函数是压栈的三个元素弹出。

pop出栈
三个元素出栈后,Add函数的栈帧随之减少,esp所指向的位置也随机发生更改。

汇编指令

mov                               esp,ebp

和创建函数栈帧时的操作类似,但又不同,将ebp的值赋给esp。
即esp将会直接指向ebp指向的位置。

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指令的下一条指令。

call指令的下一条指令

add                          esp,8

把8加给esp寄存器,让其向下移动两个单元格(一个格子4个字节)

esp向下两个单元
此时栈顶两个元素就不再被维护,main函数的函数栈帧也随之减少。

汇编指令

mov                           dword ptr [ebp-20h],eax

将eax寄存器里的值赋给ebp-20h位置,eax是我们在Add函数里计算结束后存放的返回值(30),ebp-20的位置时变量c的地址。

c值修改

至此,Add函数的功能,栈帧开辟到结束就全部解释完毕了。

🍕🍕总结

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

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

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

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

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


点击全文阅读


本文链接:http://zhangshiyu.com/post/36251.html

函数  寄存器  指令  
<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1