不懂函数栈帧,你敢说你了解C语言吗?

不懂函数栈帧,你敢说你了解C语言吗?,第1张

已经一周多没有更新了,这次更新一个大工程----函数栈帧的创建与销毁

如果说学习各种语言、刷各种各样的题目是在锻炼自己的思维逻辑,那么学习函数栈帧就是在修炼我们的内功!🔥🔥🔥

函数栈帧的创建与销毁的整个过程就是我们定义、调用一个函数,并对其传参,最终得到返回值以及对函数空间的释放的整个过程

可能有的小伙伴不理解为什么要了解这个东西,但是这个东西真的是非常重要!!!

当我们充分了解函数栈帧之后,我们就相当于可以看到函数创建、传参、返回和销毁的整个过程。这样更有利于我们理解和使用函数,也可以帮助我们在以后的学习中避免一些潜在性的错误。

总之,入股不亏,下面就开始跟着我一起修炼吧!!!✨
大家注意,在不同版本的VS中,我们观察到的函数栈帧的创建和销毁的过程有略微的差异。通常是版本越低,观察的就越细致,所以在这篇博客中,我采用VS2013来展示整个过程。

当然,大家也可以用其他的版本测试,它们的过程只是有略微的差异,但整体框架还是一致的。
在正文开始前,先提出几个问题👇👇👇

1、局部变量是怎么创建的?
2、为什么没有初始化的局部变量的值是随机值?
3、函数是怎么传参的?传参的顺序是怎样的?
4、形参和实参是什么关系?
5、函数调用是怎么做的?
6、函数调用结束后是怎么返回的?

以上几个问题的答案就藏在正文部分,小伙伴们开始带着问题学习吧!💪💪💪


首先,要学习函数栈帧,就必须要了解两个寄存器----ebp和esp这两个寄存器里存放的是两个地址,而这两个地址是用来维护函数栈帧的。

那么这两个寄存器是通过什么方法来对函数栈帧进行维护的呢?我们来通过图解了解一下:

我们知道,在创建每一个函数时, *** 作系统都会在栈区上为它开辟一块空间。而ebp和esp就分别指向这块空间的两端,它们之间的区域就是这个函数的函数栈帧。通常把ebp叫做栈底指针,把esp叫做栈顶指针。


好的,了解这两个寄存器的作用只是我们万里长征的第一步。下面用一段非常简单的代码来观察函数栈帧的创建和销毁的整个过程。代码如下:

#define _CRT_SECURE_NO_WARNINGS
#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函数被其他函数调用起来了

这里很多小伙伴可能会困惑,不是只有main函数调用其他函数吗?怎么main函数也能被调用呢?
没关系,大家不要慌,接下来就来解释这个东西!
我们继续向下调试,把F10按起来,一直到代码的最后,再点击F10,就会看到下面的东西:

红色方框的意思是----main函数被叫做__tmainCRTStartup()的这个函数所调用。
而绿色方框的意思是----红色方框里的函数又被叫做mainCRTStartup()的这个函数所调用。

随着最后一次F10,之前的代码模块也会变成下面这样:

在其中,我们也可以找到这两个陌生的函数:👇


通过上面的观察,我们不难得到这三个函数的关系:👇👇👇

好的,现在我们来分析一下这个代码中涉及的四个函数(上面的三个和代码中的Add函数)

既然栈区中空间的使用是从高地址到低地址的,那么根据上面的逻辑,我们应该是先使用mainCRTStartup函数,用它来调用__mainCRTStartup函数,再进一步调用main函数,最后main函数再调用Add函数。
所以它们在栈区中的位置应该是从高地址依次向低地址靠近。


这就是函数在栈区上的大致分布,但是每创建一个函数都需要用ebp和esp来维护,那这几个函数所占空间使用之后是怎么还给 *** 作系统的呢?下面来一步一步分析。
下面,我们需要把函数的汇编代码调出来观察
将鼠标置于黄色箭头处->右击鼠标->转到反汇编

那我们上面那段代码所对应的汇编代码如下图所示(在下面的图片中只是一部分汇编代码,其他的会在后文中出现):👇👇👇

看到这里的时候,可能小伙伴们又要头大了,不用担心,我们一步一来慢慢梳理,其实不难理解✨

我们可以看到,上面的汇编代码是从main函数开始的,但是我们前面也证明了main函数是被__mainCRTStartup函数调用的,所以在栈区中,我们首先要为__mainCRTStartup函数开辟一块空间,再让ebp和esp对其进行维护


接下来,我们就从汇编代码的第一行开始,逐一向下梳理!

第一行为----00F71410 push ebp
push的意思为----压栈----在栈区现有的空间的顶部加一个元素
这里是把ebp中存放的地址当做一个元素放在栈顶
这里还要说明,每当栈顶多出一个元素,esp就要相应的向上挪动一个位置

那么,第一行汇编代码执行之后就会是下面这种情形:👇

上面的过程在监视窗口中也可以观察到

执行第一行汇编代码前,ebp和esp的地址如下:
当第一行执行之后,esp向低地址靠近了一步,那么理论上来说,它所对应的地址值应该减小4个字节(在64位环境下,一个地址的大小为4个字节)。结果到底是不是这样呢?

由图可知,当第一行代码执行结束之后,esp所对应的地址果然减小了4个字节

那么现在esp所指向的地址处存放的到底是不是ebp中的“地址1”呢?我们通过内存看一下:

可以看到,确实是这样的,只不过VS中为小端存储,在内存中的数据信息是倒过来的,但它们所代表的值是一样的。

接着来看第二行汇编代码:👇

第二行汇编代码为----00F71411 mov ebp,esp
它的意思是----把esp中的值赋给ebp----也就是说现在寄存器ebp中存放的地址和esp是一样的,它们将指向同一个地址。


也可以通过监视来验证一下:

可以看到,ebp和esp中所存放的值是一样的。

接着来看第三行汇编代码:👇

第三行汇编代码为----00F71413 sub esp,0E4h
它的意思是----将esp中的值减去0E4h

那么0E4h代表多少呢?我们在监视中看一下:

这样可能不太好理解,我们可以将其转换为十进制再看一下:

也就是说,第三行汇编代码是将esp中的值减去228,一个整形的大小为4个字节,即将esp指向的地址向低地址处挪动57个整形空间


其实啊,这块空间就是为main函数开辟的函数栈帧!

好的,接下来,我们来看一下后面三行汇编代码:

接下来的三行汇编代码为----00F71419 push ebx
00F7141A push esi
00F7141B push edi
我们可以看到三个push,它的意思是----在栈顶补充三个元素----ebx,esi,edi


同样的,我们可以通过监视和内存来观察一下:

那么这三个元素是用来干嘛的呢?先不要着急,后文中会给出解释,我们接着向下看!
接下来,需要我们一起分析下面的四行汇编代码:

三次push之后的四行汇编代码----
00F7141C lea edi,[ebp-0E4h]
00F71422 mov ecx,39h
00F71427 mov eax,0CCCCCCCCh
00F7142C rep stos dword ptr es:[edi]
lea其实是----load effective address(加载有效地址)的缩写
它的意思是----把ebp-0E4h这个地址放进edi里面

大家还记不记得,我们上文中已经见到过0E4h这个东西了,其实ebp-0E4h就是进行三次push之前esp所指向的位置,也就是main函数函数栈帧的栈顶,即地址4。所以这一步汇编代码执行的命令就是----将地址4放进edi里面。

大家可以看到,后面还有两行代码为“mov”
这两行代码的意思分别为----
把39h这个值放进ecx里
把0CCCCCCCCh 这个值放进eax里

那么,这么做的用意是什么呢?其实这是为紧接着后面的那一行汇编代码做准备。

00F7142C rep stos dword ptr es:[edi]
它的意思是----从edi所在地址开始,依次将高地址空间中的值改变为0CCCCCCCCh,一共改变39h次,具体是多少次,感兴趣的小伙伴可以将其转变为十进制看一下哦!

只有文字说明不够直观,我们来运行一步看一下效果:

也就是将从地址4到ebp之间的空间(main函数的函数栈帧)里的值全部改为CCCCCCCC

如下图所示:

到这里的时候,可能有的小伙伴会问:怎么这么久了,还没有开始执行C语言的代码呢?
哈哈,不要着急,这就开始了!🔥🔥🔥
先给大家看一下接下来的汇编代码:

接下来,我们还是一行一行的地分析。

首先是这一行代码----
00F7142E mov dword ptr [ebp-8],0Ah
它的意思是----把0Ah(十进制中的10)这个值放到地址为ebp-8这个位置的空间中

大家有没有觉得这一步可能是在存储我们的C语言代码中的a的值呢?答案是正确的,这一步就是在存储a的值
我们知道,VS中一个地址大小为4个字节,那么ebp-8就是从ebp开始向低地址挪动两个空间的位置。👇👇👇

由于画图空间有限,图中仅列出一部分数据!

我们也可以通过内存块验证一下:

好的,这里向大家提出一个问题:我们这个地方存储的是10,是因为我们在代码中将其初始化了。那如果在设置变量的时候,没有将其初始化,会出现什么情况呢?

相信大家都遇到过----烫烫烫…这种东西吧,其实这就是上面图中的CCCCCCCC。所以,大家一定要养成给变量初始化的好习惯!
好的,弄清楚了a的存储之后,后面的b和c就是一样的道理了!

b和c的存储对应的汇编代码分别为----
00F71435 mov dword ptr [ebp-14h],14h
00F7143C mov dword ptr [ebp-20h],0
14h和20h分别代表十进制中的20和32,一个整形是4个字节,所以二者间隔均为2个整形。

执行后的结果如图所示:


好的,到这里,我们的万里长征就算走完一半了,💪💪💪接下来就到我们的Add函数了!
Add函数所对应的汇编代码如图:

现在,我们来分析一下从黄色箭头开始往下的四行汇编代码:

四行代码分别为----
00F71443 mov eax,dword ptr [ebp-14h]
00F71446 push eax
00F71447 mov ecx,dword ptr [ebp-8]
00F7144A push ecx
第一行的意思是----将ebp-14h这个地址所对应的空间里存储的值(即b=20)赋给eax。
第二行的意思是----将eax元素放在栈顶。
相同的,后面两行代码是将ebp-8这个地址所对应的空间里存储的值(即a=10)赋给ecx,再将ecx这个元素放到栈顶。

如图:


那么,为什么要这么做呢?
其实啊,这个步骤就是我们通常说的----传参。
可能小伙伴们还没有很深切的感觉,不要着急,我们接着向下看!

下面一行汇编代码为----
00F7144B call 00F710E1
这里的call指令其实就是函数的调用指令,我们按下F11,就可以调用Add函数的汇编代码了

效果如图:

大家可以看到,我们执行call指令之后,就进入了Add函数的内部。
但其实,call指令发生的动作不仅仅是这一个!

当执行call指令时,我们的内存中也发生了变化:

大家可以看到,在栈顶又多出了一个元素,那么这个元素代表什么呢?

原来,这个元素代表call指令的下一条指令的地址。即将下一条指令的地址当做一个元素放在栈顶。


这样做的目的是:当Add函数执行完毕之后,可以凭借这个地址继续向下执行Add函数外的汇编代码

好的,到这里之后,我们Add函数外面的代码分析就先告一段落,接下来,开始分析Add函数内的具体 *** 作(按下F11,进入函数内部)!🌟
Add函数内部汇编代码如图所示:

很明显地可以发现,这里是在为Add函数开辟空间,过程和前面的main函数是一样的,这里就不过多赘述了,开辟之后图解如下:

紧接着的下一步就是----在ebp-8的位置上放上z=0

好的,接下来就到了Add环节中的重点了!💫
为了方便大家理解,我把剩下的汇编代码放在这里:

首先,来看第一条“mov”指令:

第一条mov指令的汇编代码为----
00F713E5 mov eax,dword ptr [ebp+8]
它的意思是----将ebp+8这个地址所对应空间中的值赋给eax

让我们一起来看看ebp+8是哪个元素:

原来,这个元素是C语言代码中的a=10,也就是将a的值赋给了eax。
再看接下来的add指令:

add指令汇编代码为----
00F713E8 add eax,dword ptr [ebp+0Ch]
它的意思是----将ebp+0Ch(ebp+12)
这个位置所对应的空间中的值加到eax中。

由图中可以看到,ebp+12所代表的值就是b=20,那么,相加之后的eax就是30。

紧接着,下一条mov指令:

下一条mov指令汇编代码为----
00F713EB mov dword ptr [ebp-8],eax
它的意思是----将eax中的值赋给ebp-8(也就是z)

如图:

通过监视内存窗口检验一下:

至此,就完成了所有的计算过程!
接下来是数值返回 *** 作!

Add函数中最后一行汇编代码为----
00F713EE mov eax,dword ptr [ebp-8]
它的意思是----将ebp-8(也就是计算出的z=30)的值放到eax中

那么,为什么要多出这么一步呢?
小伙伴们,有没有想过一个问题:
当自定义函数调用结束之后,里面的数据会自动销毁,那么怎么保存计算出的结果呢?

答案是:为了保存最终结果,要在函数销毁之前,将其存在一个寄存器中。这也是为什么要把z的值放到eax中的原因!


上面的所有 *** 作都是在不断地创建栈帧和加长栈帧,下面就到了销毁栈帧的时候了!👇
首先,来看Add函数结束之后的几行汇编代码:

我们可以看到,最开始的是三个pop指令
它的意思是----出栈(与压栈相对),三个pop指令分别让edi,esi和ebx从栈顶d出。

如图:

再来观察一下,内存中是否还存在这三个元素:

可以看到,三次pop之后,esp的值增大了12,也就表明内存中少了三个整形元素。

既然我们已经使用完了Add函数,那它就没有继续存在的价值了,就应该将他的栈帧还给内存了,也就是销毁Add函数的函数栈帧。
那它是怎么销毁的呢?一条指令就能搞定!

销毁Add函数的指令----
00F713F4 mov esp,ebp
它的意思是----将ebp里的值赋给esp,也就是让esp和ebp指向同一个地址。
当一块空间不在ebp和esp之间的时候,它也就不再被维护,自然也就不再属于它了。

如图:

紧接着还有一条指令:

汇编代码----00F713F6 pop ebp
它的意思是----d出ebp
这个ebp是创建Add函数栈帧之前放在栈顶的main函数的ebp的位置,目的是销毁栈帧时,能顺利地找到main函数的栈底

如图:

那么,还有一条返回指令:

00F713F7 ret

返回?怎么返回?返回到哪儿?❓
这可能是很多小伙伴会问的问题,接下来,来解答这个问题!

我们在进入Add函数之前,在栈顶放了一个“下一条指令的地址”,就是因为考虑到,最终要从Add函数里回来,所以提前在栈顶放了一个“回家的路牌”,这样就可以顺着那个地址返回到原来的地方!

不得不说,VS的编译器还真是够老谋深算的啊❗️❗️❗️
还需要注意的一点就是,ret这个指令在执行时,也是有两个动作的,它会将栈顶的那个地址d出
如图:

接下来,我们按下F10将其返回:

可以看到,黄色箭头指向了call指令的下一条指令
现在,Add函数已经被我们“干掉了”,那么a和b的两个形参也就没有存在的必要了,所以下一条指令就江它们销毁了!

00F71450 add esp,8
这条指令的意思是----将esp所指向的地址+8,也就是向高地址挪动两个整形的空间,那么,两个形参也就不复存在了!

如图:

我们接着来看下一条指令:

00F71453 mov dword ptr [ebp-20h],eax
它的意思是----将eax中的值赋给ebp-20h这个位置所对应的空间中
从上面的图中可以看到,ebp-20h就是C,即将最终的计算结果赋给c,整个计算也就到此结束啦!

接下来就是main函数的函数栈帧的销毁了
由于上文中已经演示了Add函数栈帧的销毁过程,这里就不再赘述了,感兴趣的小伙伴可以自己动动小手试一试啊!
万里长征到此结束!!!


下面来回答文章开头提出的几个问题:
1、局部变量是怎么创建的?

首先要先在栈区上为函数开辟一块空间,再将其空间内的所有值初始化为CCCCCCCC类型的值,最后再在其中的某一块小空间中存放局部变量。

2、为什么没有初始化的局部变量的值是随机值?

这个问题和第一个问题是相辅相成的。

因为刚开始,函数所占空间中的值是编译器自动存放的CCCCCCCC类型的随机值,只有当其有初始化值得时候,才会改变。

3、函数是怎么传参的?传参的顺序是怎样的?

在main函数开辟空间之后,分别将变量的值放在其他寄存器中,再将其放到栈顶。当调用函数时,利用指针的偏移量找到对应的值。

4、形参和实参是什么关系?

形参和实参只是有相同的值,但是在内存中所处的空间不同。形参是实参的一份临时拷贝,改变形参不会影响实参。

5、函数调用是怎么做的?

在原有函数的栈顶为被调用函数开辟新的栈帧,用call指令调用该函数。

6、函数调用结束后是怎么返回的?

首先要在调用函数之前,将call指令的下一条指令的地址放到栈顶。函数使用结束后,根据这个地址回到原来的地方。


以上就是这篇博客的所有内容了!
小伙伴们可千万不要觉得这个东西不重要啊!
在学习各种语言的过程中,不断修炼我们的内功真的是非常!非常!非常重要的!!!
希望对大家有帮助!!!💖💖💖
有什么疑问也可以一起讨论呀!
好的,我们下次再见!

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/867032.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-12
下一篇 2022-05-12

发表评论

登录后才能评论

评论列表(0条)

保存