C语言——函数栈帧

目录

 

一、学习函数栈帧需要了解什么

1:函数栈帧是什么?

2:寄存器

3:汇编指令

4:每一个函数调用都要创建函数栈帧

二:函数栈帧的创建与销毁详解:

2.1:main函数也是被调用的

2.2:main函数的函数栈帧的创建与销毁

 第一步:push ebp

第二步:move ebp,esp

第四步:3个push

第五步:lea加载有效地址

第六步:把main函数里面的空间全初始化

第七步:main函数中变量的创建 

1:变量a的创建

2:变量b的创建

3:变量c的创建 

三:在main函数中调用Add函数: 

1:传参

2:进入Add函数 

​编辑

2.1:Add函数中变量z的创建 

2.2:Add函数中的求x+y的和 

四:回到main函数: 


 

一、学习函数栈帧需要了解什么

     在学习函数栈帧前先要进行一点知识铺垫,这样会有助于我们后面对函数栈帧的理解

1:函数栈帧是什么?

函数栈帧是在内存中的栈区为被调函数开辟的一块空间,里面用来存放该函数中定义的变量等东西(下文会详细讲到),当函数运行完毕栈帧将被销毁。再向大家介绍一下“栈”这个概念,“栈”实际上时一种数据结构,它是一种先进后出的数据表,何为先进后出?举个简单的例子:就像洗盘子,最先吃完饭的人把盘子放在水池的最低端,比他后吃完饭的人会把盘子落在他盘子上面,当洗碗的时候,会从最上面的盘子开始洗,这也就意味着,虽然你第一个吃完饭,但你的盘子却是最后一个被洗的。这就对应栈的先进后出。对栈常见的操作有两种:

  • Push(入栈):为栈增加一个元素,就相当于往水池里放盘子
  • Pop (出栈): 从栈中取出一个元素,相当于洗完一个盘子把这个洗过的盘子从水池中拿出来

2:寄存器

eax:是"累加器"(accumulator), 用来存放函数的返回值。
ebx:是"基地址"(base)寄存器,可作为储存器指针来使用, 在内存寻址时存放基地址。
ecx: 是计数器(counter), 在循环和指针操作时,要用它来控制循环次数。
edx:是"数据寄存器’,在进行乘、除法运算时,可作为默认的操作数参数参与运算。
ebp和esp:他俩都是指针寄存器它最经常被用作高级语言函数调用的"框架指针",简单来说这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。
ebp:存放栈底的地址(指向栈底)
esp:存放栈顶的地址(指向栈顶)
edi和esi:它俩都是变址寄存器,常用来配合使用完成数据的赋值操作

3:汇编指令

  • move:move A,B (将数据B移到数据A)
  • push:压栈(入栈)
  • pop:出栈
  • call:调用函数
  • add:加法
  • sub:减法
  • rep: 重复
  • lea:加载有效地址

4:每一个函数调用都要创建函数栈帧

所有的函数调用都会在内存里面的栈区创建函数栈帧,包括main函数。通过上面对函数栈帧的介绍我们知道,函数栈帧是为被调函数在内存的栈区中开辟的一块空间,所以这里间接证明了,main函数也是被调函数。可能很多小伙伴的认知都停留在,main函数是主函数,可以在main函数中调用其他函数,从来没有想过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;
}

2.1:main函数也是被调用的

首先点击F10进行调试,在窗口界面找到“调用堆栈”,点击,调出此窗口,此时我们就能很直观的看出main函数是被调用的。

c98a099663bf40af884743973aa21184.gif

接下来一直点击F10直到程序调试结束

83e4a11f05674b00be46f6df0d6579db.gif

调试结束后我们发现main 函数被 __tmainCRTStartup 这个函数调用。 

2.2:main函数的函数栈帧的创建与销毁

由于main函数是被其他函数所调用,所以在 __tmainCRTStartup 这个函数调用main函数的时候会为main函数在内存的栈区中开辟空间:

ed5eec5a1deb482d83f68936af4f3da0.png

接下来我们调到反汇编进行调试,深入了解函数栈帧

43141de31372419c8d7d3b9c1ea37f05.png

 第一步:push ebp

通过上图可以看出第一步是push ebp,这是因为mian函数是被__tmainCRTStartup 这个函数调用的,在调用main函数之前,esp和ebp分别指向__tmainCRTStartup 函数的栈顶和栈底,当调用main函数的时候,就要为main函数开辟相应的函数栈帧,此时esp和ebp就需要移动去指向main函数的函数栈帧。那这里的第一步就是push ebp,具体过程如下图:

a5238751a59e46e395c5218157b47cec.gif

push ebp就是把__mainCRTStartup 函数栈底的地址压栈,ebp的值压入后,esp指针会上移一位

87b76d49379b4980a7d0a3ffd958cf98.gif

如上图,再push ebp没有执行的时候,esp里面存的地址是0x0037fdb8,当执行完push ebp后,esp存的地址变成了0x0037fdb4,可见地址减小了4,这就意味着esp指针往上走了4个字节。,通过内存窗口我们也可以很容易看出: 

912d791cac3149dbb278db17ad478c6c.png

此时esp所指向的地址里面存的数据就是ebp所指向的地址0037fe04,说明此时我们已经成功地把ebp所指向的地址压入栈中。

第二步:move ebp,esp

sub esp,0ECh,就是给esp减去一个0E4h。这里的0E4h是一个十六进制的数字(h表示是十六进制),0E4对应的10进制数字就是228。这也就意味着esp指向的地址会减小228,对应图示就是esp指针会上移228个字节

5cb56ad8a880498c889e9520f6e5cd36.gif

如上图,第三步执行后esp的值变成了0x0037FCD0,也就是说此时esp指向0x0037FCD0这个地址 

aad6c72f0e254d8b8ffbde93ea0b62a2.gif

如上图所示,紫色这一块空间就是为main函数申请的空间

第四步:3个push

fac40c182c614335b4134dd59a5429b1.png

如上图,此时有三个push操作,也就是分别把ebx、esi、edi压入栈中 。

f41f69f060454be4a450861e869bf1aa.png

如上图:执行完push ebx后,esp指向的地址减小了4个字节(从0037FCD0变到00F37CCC,前者比后者大4)。此时esp所指向的地址里面存储的就是ebx的值005c200,这说明ebx成功压栈。剩下的两个push esi和push edi也是这样入栈,这里就不一一讲解了,可以通过下面的动图进行深入理解。

b916112c4184466bb8d4108cb5e5cc44.png

第五步:lea加载有效地址

ddb5ad03ffef452493160a7c3a7e6f98.png

lea是load effective address(加载有效地址)的缩写。而 lea edi,[ebp-0E4h]的意思就是把ebp-0E4h这个地址放到edi里面。还记得第二步move ebp,esp嘛?。执行完第二步后ebp和esp指向了同一个地址,然后第三步sub esp,0E4h,让esp指向的地址减了0E4h(228),此后ebp指向的地址没有发生任何变化,第四步的3个push操作让esp指向的地址又减小了12(一次push减小4,3次push就减小12)。而当前的第五步中的地址ebp-0E4h也就是在执行完第三步后esp所指向的地址,就是要把这个地址放到edi里面(其实就是让edi指向这个地址,因为edi是一个变址寄存器,用存储地址的)如下图:

37ca383b97fb4c428ced3c57f0af1f9b.png

d8edcd46c6004cb5a9e2e4690fc3adcb.png

通过上面两张图可以看出,确实如我们上面分析的那样:执行完lea指令后edi指向的地址就是在第三步执行结束后esp所指向的地址(0x0037fcd0)。 

 图示如下:

f276c3ef33964503a9db82ee5b03359c.png

第六步:把main函数里面的空间全初始化

执行完lea后,我们来看下面这三行汇编指令,这三行汇编指令放在一起只为了执行一件事情,所以把这三条指令(分别标上序号1 , 2 ,3)放在一起看。如下图所示:

87c4b9b5a5994a1fbd579a00f78a721e.png

序号1指令中的ecx是一个计数器,该指令完成的操作是把39h(39h是十六进制数,对应十进制数           57)存到ecx里。
序号2指令完成的操作是把CCCCCCCC存到eax里。
序号3指令中rep的目的是重复其上面的指令,而重复的次数就是ecx存的值,stos的目的是把eax             中的值拷贝到es:[edi]所指向的地址里面。一个word代表两个字节,dword其实就是double           word的缩写,所以dword代表4个字节。因此,一次拷贝4个字节,重复57次,那最后就一共           拷贝了4×57=228个字节

 

也就是说,在执行完这三条指令后edi所指向的地址(0x0037fcd0)和其后面的228个地址里面所存储的内容全被赋值为CCCCCCCC,其实这里0x0037fcd0后面的第228个地址就是ebp所指向的地址。也就是说从edi所指向的地址一直到ebp所指向的地址中间这一部分全被赋值成CCCCCCCC(也就是定义时没赋值是打印后出现的烫烫烫)。
4c9f75e443fe4e86965fa66d78779e82.png

值得注意的是:在执行完这三条指令后,edi所指向的地址已经发生改变,此时的edi和edp指向同一地址。

baf402b5da7745a0931cf7832626054e.gif

第七步:main函数中变量的创建 

1:变量a的创建

c9680aabc1874496ad7c871eeac9145d.png

接着就来到了红框圈起来的指令了,mov dword ptr [ebp-8],0Ah的意思是:把0Ah(对应十进制的10)放在edp-8(8是8个字节的意思)这个地址里面

1588665dec6c40ccb63f5af5d5e2b325.gif

可见执行完这条指令后,原本ebp-8这个地址所指向空间中的CCCCCCCC被替换成了0Ah(10),说明变量a已经创建完毕。 

动图演示:

7e41b10a09ad40e08624521989ba2c41.gif

这里也说明了一个问题,如果只是对变量进行声明而不进行初始化赋值,那内存里面存的就是CCCCCCCC(随机值,只是vs2013放的是CCCCCCCC,其他的编译器可能是其他的值),这也就是为什么我们有时打印变量会出现“烫烫烫烫”的原因 

2:变量b的创建

668fc5d36f254423b54b44717fe83ae4.png

和上面一样,这里的mov dword ptr [ebp-14h],14h指令执行的操作就是把14h(对应十进制的20)存到ebp-14h这个地址所指向的空间里。 注意,这里出现的两个14h没有任何关联,如果让b等于其他任何一个整数,始终都是把这个整数存到ebp-14h所指向的这个空间里面。
0f1091c90d444520a9e86e29ebd4bb69.gif

51345be823274f2b84645b48fb55ae08.gif 

可以看出在vs2013这个编译器上,变量a和b在内存中的存储地址相差了8个字节,至于相差几个字节这以取决于编译器不同的编译器会有不同的效果。

3:变量c的创建 

3a4ba49f20434453bdce83847b0748cb.gif 

三:在main函数中调用Add函数: 

1:传参

0f221d96566546f2a91931dc0628a8bf.png

此时出现了两组mov和push。第一组中的mov eax,dword ptr [ebp-14h]意思是,把ebp-14h这个地址里面存放的值(也就是20)赋值给eax,然后push(压栈)eax。同理,第二组先把ebp-8这个地址里面存的值(也就是10)赋值给ecx,然后push(压栈)ecx。
e43612f776964798a55da1e3116144ae.png

49d7052c52ad4ccc8a5ea37984f8b1e7.png

经过这两次压栈后,esp指针指向的地址减小8个字节(一次减小4个字节,也就是1个整型) 

动图演示:

d2a96877be274f0f89e16c14f3608969.gif

以上就是函数调用时的传参过程

2:进入Add函数 

在执行call指令之前,先看一下call指令下一条指令的地址,也就是add指令的地址003F1450aa344532a46a4ac38af811f520d9bff9.png

执行call指令时我们需要点击F11才能进入到Add函数的内部去一探究竟。

9c31936445784aaeb262948e2452151d.png

点击F11执行了call指令后,我们不但进入了Add函数的内部还让esp指针上移了4个字节,为什么会上移呢?那一定是又元素压栈,因为只有这样才能让栈顶指针上移,那我们再来看一下压入的元素是什么呢?不难发现,压入的这个值就是call指令下一条指令add的地址003F1450

3a78a04b09bb45aaa31f6ce01e08d5e8.gif

那为什么要把call指令下一条指令的地址存起来呢?是因为在Add函数调用结束的时候,需要返回继续执行call指令的下一条指令,所以在执行call指令的时候要把call指令的下一条指令的地址存下来,在Add函数调用结束的时候,就能根据存的这个地址找到call指令的下一条指令,进而让程序继续进行。
 

接着再点击一次F11,就真真正正的进入到Add函数的内部了

d83eab11ce6b4902baaa041ca57bcc86.png

蓝色的部分是不是特别眼熟,这部分和main函数前面那部分是一样的,就是为Add函数创建函数栈帧。

f8a2f9502ab34035bee2a4b2b03c728b.gif

实际结果:

7961c510455d422088bcb800fa350f72.png

eeb7cf015de14f7b8fd276dae2eb8349.pngc8aa6a2bafa24fffa2d7603c8d693135.pngaf9d0e1f9bc24e40892143585343f21a.png

c36a1650c7ab4ff3b89e8be341b23c24.png

 

2.1:Add函数中变量z的创建 

这里变量z的创建过程和main函数中变量a、b、c的创建过程一模一样,详细过程就不再进行赘述,忘了的下伙伴可以往上翻翻看看前面的介绍。这里就简单的用动画为大家演示一下: 

342cb3039cc440798d584eb2b9652d4d.gif

6958838fa5904bec851d26b67b8b6783.png 

2.2:Add函数中的求x+y的和 

接着往下,终于来到了 z = x + y,要求两数和了:

47f618a2e705467789ca4e831d5de99d.png

第一条指令mov eax,dword ptr [ebp-8]的执行结果是:把ebp-8里面存的值(也就是30)存到eax里面,这里的eax是一个寄存器,它不会随着函数调用的结束而销毁掉。如果没有这条指令,在Add函数代用调用结束后的时候,ebp-8(变量z的地址)这个地址所指向的空间会被释放掉,这样的话30x+y的和就无处可寻。(上图序号1和2之间的 )的右边表明Add函数调用结束)
接着标号2、3、4的这3条指令都执行的是pop(出栈)操作,比如pop edi的意思就是把栈顶数据弹出至edi这个寄存器里,然后栈顶指针下移,后面两个同理,动画演示如下:

497affa1939a4ba298ada17b834175a7.gif

36bbf66586e7427b8309a031b66a581f.gif 

通过实际的调试也可以看出没每执行一次pop指令,esp指针的值就加4. 这和每执行一次push指令,esp的值就减4形成呼应。

接着执行标号5的指令 mov esp,ebp,这条指令执行的结果就是把ebp存的地址给esp,也就是说,执行完这条指令后,esp和edp指向同一个地址空间。动画演示如下: 

c6b7629ac7764fc88adc93e81a33a283.gif

 

接着执行标号6的指令:pop ebp。这条指令的执行结果是:把栈顶数据弹出至edp里面进行储存。此时的栈顶数据是什么呢?通过动画不拿发现:此时的栈顶数据其实就是main函数栈底的地址呀,这样以来,执行完这条指令后edp就又指向main函数的栈底了,而此时栈顶存储的数据也变成了call指令下一条子陵的地址。动画演示如下:
 

877246d405b34803bcf23dc6af0fa2a4.gif 

最后执行ret指令,ret指令会从栈顶弹出元素给IP,也就是下一条要执行的指令的地址。此时的栈顶存储的就是call指令下一条指令的地址,这就是为什们当时要存call指令下一条指令的地址。是为了在Add函数调用结束后这个黄色的小箭头能够回到正确的地方继续执行接下来的指令 

8525165f0257440ca7fa0c2c316206f8.gif 

通过上面的调试动图可以看出:在执行完ret指令之后esp的值从个位上的8变成了个位上的c(c对应十进制下的12),二者相差4。说明确实把栈顶数据弹出了
动画演示如下: 

6411f51a06894e46b724b278508d923e.gif 

到这里return z的操作就顺利结束了 

四:回到main函数: 

此时黄色的小箭头成功地来到了main函数中call指令的下一条指令add,这完全归功于最初我们压栈的add指令的地址。

dbeccf0cbfc7484188c17f4647f17e60.png 

首先执行add esp,8这条指令,该指令的执行结果是让esp指针指向的地址加8,动画展示如下:

a179d1958951468ca73769d278339913.gif

这条指令执行完就相当于释放了形参变量的存储空间

接着执行:mov dword ptr [ebp-20h],eax指令。执行结果是:把eax里面存的值(30)赋值给ebp-20h所指向的这块空间,其实就是变量c的存储空间

36caf985318a4a6ab84526dc88af7d1b.gif 

通过调试可以看出:在执行这条指令之前ebp-20h所指向的这块空间存的值是0,在执行完这条指令之后,ebp-20h所指向的这块空间里面存的就是1e(十进制下的30)。并且可以看出ebp-20h和&c的值相同,说明他们指向同一块空间——变量c的存储空间.动画演示如下:

7c55f4eb5ed0490aacf181c5f0f958e6.gif 

现在我们就清楚函数的返回值是怎么带回来的了——返回值首先会放到寄存器里,当我们真的回到主调函数时,需要用到这个返回值的时候,去访问寄存器,从寄存器里面取值。  

到这里整个函数栈帧就结束了! 

 

  • 13
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Byte Master

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值