细谈函数栈帧的创建与销毁

函数栈帧是如何创建的?又是如何销毁的?涉及到内存空间的本质问题,我们大多数人可能是一头雾水,栈是什么?有什么用?接触细节原理,印象才能更加深刻,带你深入的学习栈是如何创建与销毁能帮助你理解更多深层的问题,局部变量是如何创建的,为什么一开始要从main函数开始呢?函数的形参与实参有和关系,返回值又怎么返回的等等问题。理解这些问题你对编程的理解将会大大提升的。

一. 什么是函数栈帧?

我们在写C语言代码时,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。
那函数如何调用?函数的返回值如何返回的?函数参数是如何传递的?这些问题都与函数栈帧有关系。

函数栈帧(stack frame)就是函数调用过程中在程序的调用栈所开辟的空间,这些空间是用来存放:

  • 函数参数和函数返回值
  • 局部变量
  • 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。

二. 理解函数栈帧能解决什么问题呢?

理解函数栈帧有什么用呢?

只要理解了函数栈帧的创建和销毁,一下问题就能很好的理解了

  • 局部变量是如何创建的?
  • 为什么局部变量不初始化内容是随机值?
  • 函数调用时参数是如何传递的?传参的顺序是怎样的?
  • 函数的形参与实参分别是怎么实例化的?
  • 函数的返回值是如何带回来的?

等讲完函数栈帧的创建于销毁的过程后,这些问题我会一一解释的。

三.剖析函数栈帧的创建和销毁

1.什么是栈

栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函
数,没有局部变量,也就没有我们如今看到的所有的计算机语言。

在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可
以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出
栈(First In Last Out, FIFO)。就像叠成一叠的术,先叠上去的书在最下面,因此要最后才能取出。

在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据
从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。
在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的。

2.认识相关寄存器和汇编指令

寄存器:寄存器是独立于内存是集成到CPU上的
硬盘
内存
寄存器
三者都是存放内存的是相互独立的。

2.1相关寄存器

eax: 通用寄存器,保留临时数据,常用于返回值。
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址。

2.2相关汇编命令

mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1.压入返回值2.转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令

3.解析函数栈帧的创建和销毁

3.1相关知识

首先我们需要知道一些基本的知识才能有效的帮助我们理解,函数栈帧的创建和销毁。

  1. 函数每次调用的时候,都要为本次函数开辟空间,就是函数栈帧的空间
  2. 这块函数栈帧空间是由两个寄存器维护的:esp ,ebp,ebp是维护记录栈底的地址,esp是维护记录栈顶的地址。所谓栈顶,栈底,是根据栈的使用方式来确定的,栈是先使用的后弹出,就像书本从上往下叠,而书本的下面是栈底,书的上面是栈顶。
    例如:
    在这里插入图片描述
  3. 函数栈帧的创建和销毁在不同的编译器的实现方法大同小异,本次演示的是VS2019。
  4. 栈空间的使用方式是先使用高地址的空间,再使用低地址的空间
  5. push(压栈):给栈顶放一个元素。pop(出栈)从栈顶删除一个元素。
    在这里插入图片描述
3.2函数的调用堆栈

当我们按F10调试,进入函数的调用堆栈里发现main函数被调用了。可是main函数又被谁调用的呢?

在这里插入图片描述

函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到,main函数调用之前,是由谁调用main函数的。
main函数是由一个_…main函数调用的。

在_…main函数之前还有函数调用,我们先不管。

那我们可以确定,_…main函数应该也会有自己的函数栈帧,main函数和Add函数也会维护自己的栈帧,每个函数栈帧都有自己的ebp,esp来维护栈帧空间。
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
所以:
在这里插入图片描述
在main函数调用之前已经有函数在开辟空间了。
在这里插入图片描述
好的理解这个,我们就从main函数的栈帧创建开始将:

3.3转到反汇编

调试到main函数开始执行的第一行,右击鼠标转到反汇编上。
在这里插入图片描述

int main()
{
//函数栈帧的创建
00C21410 	 push                   ebp
00C21411 	 mov                    ebp,esp 
中00C21413	 sub                    esp, 0E4h
00C21419 	 push                   ebx
00C2141A 	 push                   esi
00C2141B 	 push                   edi                   
00C2141C 	lea                     edi, [ebp+FFFFFF1Ch]
00C21422 	mov                     ecx, 39h
00C21427 	mov                     eax, OCCCCCCCCh
00C2142C 	rep stos                dword ptr es:[edi]

//main函数中的核心代码
inta=10;
00C2142E	 mov                    dword ptr [ebp-8], 0Ah
intb=20;
00C21435	 mov                    dword ptr [ebp-14h],14h
intC=0;
00C2143C	 mov                    dword ptr [ebp-20h],0
C = Add(a, b);00C21443 8B 45 EC      mov        eax, dword ptr [ebp-14h]
00C21446 50            	push		eax
00C21447 8B 4D F8		mov			ecx,dword ptr [ebp-8]
00C2144A 51				push		ecx
00C2144B E8 91 FC FF FF call		00C210E1
00C21450 83 C4 08  		add			esp,8
00C21453 89 45 E0		mov			dword ptr [ebp- 20h],eax



3.4剖解函数栈帧的创建

上面是main函数转化而来的汇编代码

接下来我们就一行一行的拆解汇编代码。

int main()
{
//函数栈帧的创建
00C21410 	 push                   ebp   把ebp寄存器中的值进行压栈,此时的ebp中存放的是_tmainCRTStartup函数栈帧的ebp,ebp-4
00C21411 	 mov                    ebp,esp move指令会把esp的值存放到ebp中,相当于产生了main函数的ebp,这个值就是_tmainCRTStartup函数栈帧的esp00C21413	 sub                    esp, 0E4h//sub会让esp中的地址减去一个16进制数字0xe4h,产生新的
//esp,此时的esp是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一
//个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量
00C21419 	 push                   ebx//将寄存器ebx的值压栈,esp-4
00C2141A 	 push                   esi//将寄存器esi的值压栈,esp-4
00C2141B 	 push                   edi//将寄存器edi的值压栈,esp-4         
//上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄
//存器原来的值,以便在退出函数时恢复。      

//下面的代码是在初始化main函数的栈帧空间。
//1. 先把ebp-0e4h的地址,放在edi中
//2. 把9放在ecx中
//3. 把0xCCCCCCCC放在eax中
//4. 将从edp-0e4h到ebp这一段的内存的每个字节都初始化为0xCC   
00C2141C 	lea                     edi, [ebp+FFFFFF1Ch]
00C21422 	mov                     ecx, 39h
00C21427 	mov                     eax, OCCCCCCCCh
00C2142C 	rep stos                dword ptr es:[edi]

//main函数中的核心代码
inta=10;
00C2142E	 mov                    dword ptr [ebp-8], 0Ah
intb=20;
00C21435	 mov                    dword ptr [ebp-14h],14h
intC=0;
00C2143C	 mov                    dword ptr [ebp-20h],0
C = Add(a, b);00C21443 8B 45 EC      mov        eax, dword ptr [ebp-14h]
00C21446 50            	push		eax
00C21447 8B 4D F8		mov			ecx,dword ptr [ebp-8]
00C2144A 51				push		ecx
00C2144B E8 91 FC FF FF call		00C210E1
00C21450 83 C4 08  		add			esp,8
00C21453 89 45 E0		mov			dword ptr [ebp- 20h],eax

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
所以你知道为什么局部变量不初始化会变成随机值了吗?
因为在函数栈帧开辟的时候,栈帧里面已经被初始化成cccccccc,,是不是有时你还会莫名其妙的打印出

烫烫烫烫烫烫烫烫烫~就是因为这个原因。
在这里插入图片描述
之所以上面的程序输出“烫”这么一个奇怪的字,是因为main函数调用时,在栈区开辟的空间的其中每一
个字节都被初始化为0xCC,而arr数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC(两
个连续排列的0xCC)的汉字编码就是“烫”,所以0xCCCC被当作文本就是“烫”。

以上就是main函数栈帧的开辟过程,没错,这只是开辟main函数栈帧空间,main函数的功能还没实现,接下来再来看看main函数是如何实现功能的。

//main函数中的核心代码--main函数功能的实现
inta=10;
00C2142E	 mov                    dword ptr [ebp-8], 0Ah
//将Ah存储到ebp-8的地址处,ebp-8的位置其实就a变量
intb=20;
00C21435	 mov                    dword ptr [ebp-14h],14h
//将14h存储到ebp-14h的地址处,ebp-14h的位置就是b变量
intC=0;
00C2143C	 mov                    dword ptr [ebp-20h],0
//将0存储到ebp-20h的地址处,ebp-20h的位置其实是ret变量

以上汇编代码就表示局部变量a,b,ret的创建和初始化

所以局部变量的创建是在栈帧空间中创建的
在这里插入图片描述
好了main函数的局部变量也创建好了,要准备进入Add函数了,
也就是要调用Add函数了,不过Add函数是怎么传参的呢?
请往下面接着看:

//调用Add函数
C = Add(a, b);
//调用Add函数时的传参
//其实传参就是把参数push到栈帧空间中00C21443 8B 45 EC      mov        eax, dword ptr [ebp-14h]
//传递b,将ebp-14h处放的20放在eax寄存器
00C21446 50            	push		eax//将eax的值压栈,esp-4
00C21447 8B 4D F8		mov			ecx,dword ptr [ebp-8]
//传递a,将ebp-8处放的10放在ecx寄存器中
00C2144A 51				push		ecx//将ecx的值压栈,esp-4
//跳转调用函数
00C2144B E8 91 FC FF FF call		00C2145083C408//看这是什么,这是call指令下一条的地址。为什么这样呢?
//因为函数调用后,还要回来继续执行下面的代码,所以将下面的指令的地址记住,如何等函数结束时能找回来
00C21450 83 C4 08  		add			esp,8
00C21453 89 45 E0		mov			dword ptr [ebp- 20h],eax

在这里插入图片描述
call 指令是要执行函数调用逻辑的,在执行call指令之前先会把call指令的下一条指令的地址进行压栈
操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。

call调用函数后,就会进入Add函数内部,下面就是Add函数的反汇编了,看看前面是不是跟main函数很像呢,没错,前面也是在为Add函数预开辟栈帧空间。我们再来好好顺一顺吧。

在这里插入图片描述
在这里插入图片描述

预栈帧空间
在这里插入图片描述
在这里插入图片描述
图片中的 ecx 和 eax 其实就是 Add 函数的形参 x , y 。这里的分析很好的说明了函数的传参过程,以及

函数在进行值传递调用的时候,形参其实是实参的一份拷贝。对形参的修改不会影响实参。
在这里插入图片描述
代码执行到Add函数的时候,就要开始创建Add函数的栈帧空间了。

在Add函数中创建栈帧的方法和在main函数中是相似的,在栈帧空间的大小上略有差异而已

  1. . 将main函数的 ebp 压栈
  2. 计算新的 ebp 和 esp
  3. 将 ebx , esi , edi 寄存器的值保存
  4. 计算求和,在计算求和的时候,我们是通过 ebp 中的地址进行偏移访问到了函数调用前压栈进去的参数,这就是形参访问。
  5. 将求出的和放在 eax 寄存器尊准备带回

以上就是main函数和Add函数栈帧的创建。接下来我们来看函数栈帧是如何销毁的呢?

3.5函数栈帧的销毁

当函数调用要结束返回的时候,前面创建的函数栈帧也开始销毁。
那具体是怎么销毁的呢?我们看一下反汇编代码

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
所以最后就还剩下main函数的栈帧空间
在这里插入图片描述

3.6.拓展了解:

其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一
般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中
通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。具体可以参考《程序员的自我修养》一书的第10章。

四.总结:

我们来总结一下以上问题已经相关注意点。

1.局部变量是如何创建的?

1.首先先为函数分配栈帧空间
2.初始化部分栈帧空间ccccccc
3.然后在函数栈帧里给局部变量分配一些空间

2.为什么局部变量不初始化内容是随机值?

因为变量是我们自己放进去的,如果初始化就会把随机值覆盖了,不初始化就会保留原来的随机值

3.函数调用时参数是如何传递的?传参的顺序是怎样的?

1.在调用函数之前系统就先push push 把这两个参数从右向左开始压栈进去。
2.而真正到函数调用的时候我们是通过指针的偏移量来找到形参

4.函数的形参与实参分别是怎么实例化的?

形参确实是在我们压栈时开辟的空间,它和形参只是值上相同,但空间是独立的是不同的空间
故形参是实参的临时拷贝,改变形参不会影响实参

5.函数的返回值是如何带回来的?

函数的返回值一般是通过存放在寄存器中然后带回来的

6.注意点:

1.我们在调用之前就把call指令的下一条指令地址记住了,存进去了压进去了

2.我们在调用的时候就把main函数指向栈底的地址记住下,存进去压进去了,然后最好pop弹出去,把这个地址放进edp中,edp就更新到mian函数栈底去了

3.epb和eps是存放维护当前栈空间函数的栈底地址,跟函数地址没有关系。它当有函数调用时就维护这个函数栈帧,有另一个函数调用时就维护另一个函数栈帧。

  • 6
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

小陶来咯

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

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

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

打赏作者

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

抵扣说明:

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

余额充值