函数栈帧的创建与销毁(揭露底层的奥秘)


前言

🎈在我们初学c语言的时候,是否对这些问题总是有疑惑?
1.局部变量是如何创建的?
2.为什么局部变量不初始化的值是随机的?
3.函数调用时参数时如何传递的?传参的顺序是怎样的?
4.函数的形参和实参分别是怎样实例化的?
5.函数的返回值是如何代回的?
想要解决这些困惑,我们必须了解C语言底层的一个运行机制——函数栈帧的创建和销毁。

一、什么是函数栈帧?

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

想要了解函数栈帧,我们必须先了解什么是栈。
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈。就像叠成一叠的书,先叠上去的书在最下面,因此要最后才能取出。

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

在这里插入图片描述

🎈了解了什么是栈,我们来看看什么是函数栈帧?
函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:
1️⃣函数参数和函数返回值
2️⃣临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
3️⃣保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。




二、认识相关寄存器和汇编指令

分析函数栈帧的创建于销毁是从反汇编角度分析的,因此我们要先认识汇编语言中几个相关的寄存器和汇编指令:
相关寄存器:

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

注:栈区是通过ebp和esp两个寄存器进行维护的,执行哪一个函数这两个寄存器就维护哪个函数的栈帧。

汇编指令:

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



三、函数栈帧的创建与销毁的解析

1.预备知识

首先我们达成一些预备知识才能有效的帮助我们理解,函数栈帧的创建和销毁。

1.每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
2.这块空间的维护是使用了2个寄存器: esp 和ebp , ebp 记录的是栈底的地址, esp 记录的是栈顶的地址。
3.函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,本次演示以VS2019为例。

2.具体解析

🎈先看总体思路
以该简单的加法程序为例,我们利用它的反汇编程序分析函数的栈帧与销毁:
在这里插入图片描述
下面是该程序对应的反汇编:
在这里插入图片描述

1️⃣main函数栈帧的创建

由于main函数是另外一个函数invoke_main调用的,在invoke_main 函数之前的函数调用我们就暂时不考虑了。那我们可以确定, invoke_main 函数应该会有自己的栈帧, main函数和Add 函数也会维护自己的栈帧,每个函数栈帧都有自己的ebp和esp来维护栈帧空间。那接下来我们从main函数的栈帧创建开始讲解:

在这里插入图片描述
第一、二行:

将调用main函数的函数(既invoke_main函数)的ebp压栈,再将invoke_main函数的esp赋给ebp,则ebp此刻与esp重合(如图)

在这里插入图片描述

第三至六行:

给esp减去一个值0E4h,则此时esp向上移动0E4h个字节(注意不是向下,因为在栈中,上面是低地址,下面是高地址)。此时的esp是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数 中的局部变量,临时数据以及调试信息等。
然后再按顺序压入三个寄存器的值ebx、esi、edi(这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复)。

执行后效果如下⭕
在这里插入图片描述

第七至十行:

将[ebp-24h]这个地址存入edi寄存器中,将数值9存入ecx寄存器中,将0CCCCCCCCh传入eax寄存器中,最后第十行rep stos dword ptr es:[edi] 的意思是将从ebp-24h到ebp这一段的9*4(dword:双词,一个词两个字节,这里意思就是九个dword)字节的内存全部初始化为0xCCCCCCCC。

执行后效果如下⭕
在这里插入图片描述
至此,main函数栈帧的创建就完成了,可以看到,未初始化的空间被赋值为0xCCCCCCCC,可以认为是随机值。而因为0xCCCC(两个连续排列的0xCC)的汉字编码就是“烫”,0xCCCC被当作文本就是“烫”,所以在我们平时打印一个未初始化的变量时,会看到“烫烫”这一奇怪的现象。

2️⃣初始化变量

在这里插入图片描述

接下来到了程序的真正核心部分,也就是我们平时的创建变量。那么在函数栈帧中是如何创建以及初始化变量的呢?其实也不难理解。
我们可以看到,第一行反汇编将0Ah(10)赋到了ebp-8这个位置,所以ebp-8就是变量a的地址。(b和ret变量同理)

执行后效果如下⭕
在这里插入图片描述

可见,这里的初始化是将原来的值0xCCCCCCCC覆盖为初始化后的值。而且,a、b、ret的地址并不是固定的

3️⃣函数的传参

在这里插入图片描述
函数传参是调用函数的关键,也是函数调用之前的准备工作(从函数栈帧的创建与销毁过程我们可知,函数传参是在调用函数之前完成的)

第一行:将ebp-14h这个地址中放的20(也就是b的值)赋到eax寄存器中,然后压入eax;同理,将ebp-8这个地址中放的10(也就是a的值)赋到ecx寄存器中,然后压入ecx。

执行后效果如下⭕
在这里插入图片描述

由此我们可知,函数传参的顺序是从右到左的。并且,形参和实参的空间不同,改变形参不会改变实参。

4️⃣Add函数的调用

当我们进入call指令后,便进入的Add函数的运行,与main函数相同,Add函数也需要先创建属于它自己的函数栈帧。当然,call 指令是要执行函数调用逻辑的,在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。
在这里插入图片描述
在这里插入图片描述
接下来我们就真正地进入Add函数中

a.Add函数栈帧的创建

在这里插入图片描述

第一、二行:
将ebp(main函数的ebp)压入,然后将main函数的esp赋给ebp,则ebp此刻与esp重合(如图(展示上半部分))

在这里插入图片描述

第三至六行:
给esp减去一个值0E4h,则此时esp向上移动0CCh个字节,与main函数栈帧创建同理,此时esp和edp之间维护的块栈空间就是为Add函数创建的栈帧空间。
然后再按顺序压入三个寄存器的值ebx、esi、edi。

执行后效果如下⭕

在这里插入图片描述

第七至十行
与main函数同理,初始化这片空间,此处不再赘述,直接上图

执行后效果如下⭕
在这里插入图片描述

b.Add函数加法功能实现模块

接下来到了关键部分,也就是Add函数到底是如何进行加法运算的?

在这里插入图片描述

如图,创建z变量,是把0这个值赋到ebp-8这个地址中。而在加法运算中,将edp+8中的值移入eax寄存器中,由刚刚我们的推导中可以发现,edp+8中的值刚好是我们的形参a’,里面的值就是a的值10。同样的道理,将eax(现在的值是10)和ebp+0Ch中的值(也就是b的值20)相加,计算出来eax的值为30。最后再将eax的值移动到ebp-8这个地址中,也就是移入z的地址,这时z便等于30了。
而这里的return z返回值是先将z的值移入eax寄存器中,待跳出Add函数后eax的值依然为z的值,在main函数中利用eax代回z的值即可

执行后效果如下⭕
在这里插入图片描述

c.Add函数栈帧的销毁

上面Add函数已经完成他的使命了,接下来就需要销毁他的函数栈帧,并重新返回main函数。
在这里插入图片描述

如图,三个寄存器的值先依次弹出栈并将其值存放到对应寄存器中。然后将edp的值赋给esp(中间三行可忽略),这样一来esp和edp再次重合,相当于回收了Add函数的栈。接着弹出ebp地址(这里的epb地址是main函数的ebp地址)并存放到ebp寄存器中。
而ret指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是call指令下一条指令的地址,此时esp+4,然后直接跳转到call指令下一条指令的地址处,继续往下执行。

执行后效果如下⭕
在这里插入图片描述
这样一来便很好地回归了main函数,而且能够接着函数调用后的程序继续运行。

5️⃣形参的销毁与返回值的代回

在这里插入图片描述
图中红框内的第一行指令为销毁形参,栈顶寄存器esp加8直接跳过了两个形参的空间。而第二行便进行的返回值的代回,我们知道此时eax中的值为30,将其代入ebp-20h(也就是ret的地址)便得到我们想要的结果。

最终结果如下⭕
在这里插入图片描述
介绍到此为止,后面printf函数的调用和main函数的销毁与上面同理,不再进行赘述。


总结

了解了函数栈帧的创建与销毁的全过程,便可以回答开头我们提出的五个问题了。
1.局部变量是如何创建的?
答:利用mov指令,将指定的值移动到系统给定的地址中。若局部变量未初始化,则为随机值。
2.为什么局部变量不初始化的值是随机的?
答:函数栈帧创建过程中,为栈帧中的空间都赋予了0xCCCCCCCC的值(不同编译器的值可能不同),若变量不初始化则为这个值,可以认为这个值就是随机值。
3.函数调用时参数时如何传递的?传参的顺序是怎样的?
答:在main函数中创建形参的临时空间并赋予其与实参相同的值(利用eax寄存器)。到子函数中需要调用参数时,便通过地址的移位去找到形参的位置并读入。从右到左。
4.函数的形参和实参分别是怎样实例化的?
答:分别创建自己的空间,改变形参的值不会改变实参。
5.函数的返回值是如何代回的?
答:通过地址的移位去找到形参的位置并读入其中的值。

本文到这里就结束啦!如有错误欢迎指正。记得给个三连哦~🎉

  • 17
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 14
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值