文章目录
- 一、 什么是函数栈帧?
- 二、 理解函数栈帧能解决什么问题呢?
- 三、 函数栈帧的创建和销毁解析
- 3.1、什么是栈?
- 3.2、认识相关寄存器和汇编指令
- 3.2.1 相关寄存器
- 3.2.2 相关汇编命令
- 3.3、 解析函数栈帧的创建和销毁
- 3.3.1 预备知识
- 3.3.2 代码和环境搭建
- 3.3.3 函数栈帧的创建
- 3.3.4 函数栈帧的销毁
- 四、总结与开局疑难解答
一、 什么是函数栈帧?
函数栈帧是用于在计算机程序中实现函数调用的一种数据结构。在函数调用过程中,每个函数都需要在内存中创建一个栈帧,用于存储局部变量、返回地址和参数等。
-
具体来说,函数栈帧通常包含以下部分:
-
局部变量表:存储函数的局部变量,包括基本数据类型(如整数、浮点数等)和对象引用(如指针)。
-
返回地址:存储函数的返回地址,即函数执行完毕后需要跳转到的地址。
-
参数表:存储函数的输入参数,通常按照传递的顺序排列。
-
操作数栈:用于存储函数的临时数据和中间结果,通常使用栈结构进行操作。
-
当一个函数被调用时,会在内存中创建一个新的栈帧,并将其压入调用该函数的栈中。当函数执行完毕后,该栈帧会被弹出栈并销毁。因此,函数栈帧在函数调用过程中起到了存储和传递数据的作用。
函数栈帧的实现方式取决于具体的编程语言和编译器。在一些高级编程语言中,编译器通常会为每个函数自动创建和销毁栈帧,而无需程序员手动管理。而在低级编程语言或手动控制内存分配的情况下,程序员需要手动创建和销毁栈帧。
二、 理解函数栈帧能解决什么问题呢?
理解函数栈帧有什么用呢?
只要理解了函数栈帧的创建和销毁,以下问题就能够很好的额理解了:
- 局部变量是如何创建的?
- 为什么局部变量不初始化内容是随机的?
- 函数调用时参数时如何传递的?传参的顺序是怎样的?
- 函数的形参和实参分别是怎样实例化的?
- 函数调用是怎么做的?函数的返回值是如何带会的?
让我们一起走进函数栈帧的创建和销毁的过程中。
三、 函数栈帧的创建和销毁解析
3.1、什么是栈?
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
- 在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈(Push):将数据项添加到栈的顶部。这相当于把数据放到栈的最上面。出栈(Pop):从栈的顶部移除数据项。这相当于移除栈顶的数据项。但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)。就像一个桶,先放的东西最后才能拿出
- 在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的
在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的
3.2、认识相关寄存器和汇编指令
3.2.1 相关寄存器
- 【eax】:通用寄存器,保留临时数据,常用于返回值
- 【ebx】 :通用寄存器,保留临时数据
- 【ebp】:栈底寄存器
- 【esp】:栈顶寄存器
- 【eip】:指令寄存器,保存当前指令的下一条指令的地址
3.2.2 相关汇编命令
- 【mov】:数据转移指令
- 【push】:数据入栈,同时esp栈顶寄存器也要发生改变
- 【pop】:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
- 【add】:加法命令
- 【sub】:减法命令
- 【lea】 :load effective address,加载有效地址
- 【call】:函数调用,1. 压入返回地址 2. 转入目标函数
- 【jump】:通过修改eip,转入目标函数,进行调用
- 【ret】:恢复返回地址,压入eip,类似pop eip命令
3.3、 解析函数栈帧的创建和销毁
- 首先我们达成一些预备知识才能有效的帮助我们理解,函数栈帧的创建和销毁。
3.3.1 预备知识
- 每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间
- 这块空间的维护是使用了2个寄存器:
esp
和ebp
,【ebp】 记录的是栈底的地址,esp
记录的是栈顶的地址
如图所示:
- 函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,本次演示以VS2022为例。
3.3.2 代码和环境搭建
- 这段代码,如果我们在VS2019编译器上调试,调试进入Add函数后,我们就可以观察到函数的调用堆栈
#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;
}
- 首先我们来做一些环境的搭建工作
-
首先直接在键盘上按下F10【笔记本按下Fn + F10】。
-
以往写代码的时候,我们都知道要写这么一个main函数,程序就是从这里开始运行的
-
接下去在按下F10后到监视窗口打开【调用堆栈】的窗口
- 然后就出现了这样的界面。此时我们的main函数就从第13行开始运行了
-
一直按F10,当调试箭头运行到第【22行】的时候,就会自动进入到exe_common.inl,此时我们就可以观察到底是哪个函数调用了main函数
-
通过下图可知是invoke_main这个函数调用的,我们了解到这里就可以了~~
- 然后,关掉这个【调用堆栈】的窗口后,重新调试起来
- 调出【反汇编】【内存】【监视】这三个窗口
【反汇编】
【内存】
【监视】
好,现在我们的环境已经全部搭建好了
3.3.3 函数栈帧的创建
- 接下去,我们正式开始分析函数栈帧究竟是如何创建的
- 去掉符号名,方便看内存
- 从上图看到已经进入到main函数了
- main函数是由invoke_main这个函数来进行调用的,所以我们先画出它的函数栈帧
- 首先看到左边的两个寄存器【esp】和【ebp】,分别用来维护栈顶和栈顶。
- 对于栈来说是从【高地址】向【低地址】使用的。
- 好,接下去的话就要执行第一条指令。将栈中push一个ebp,也就是将ebp中的值进行一个压栈的操作,此时的ebp中存放的是invoke_main函数栈帧的ebp
00EE18D0 push ebp
-
随着push入栈的操作,维护栈顶的esp就要往上
-
然后我们看寄存器的变化
- 我们再继续执行一下push这句指令,你就会发现【esp】中所存放的地址变小了,原来存的是【ebp】中的值,只是这个存放的形式是倒着存放的,是因为有大小端存储的问题
- 接下来第二条,【mov】,我们在上面有讲到过是一个数据转移指令,这条指令的含义就是把esp的值存放到ebp中去
00EE18D1 mov ebp,esp
- 此时相当于产生了main函数的【ebp】,这个值就是invoke_main函数栈帧的【esp】,从这里开始就要开始维护main函数的函数栈帧了
- 通过VS再来看一下,【ebp】中就会存放【esp】的地址了
第三条指令
- 接下来第三条,sub是一条减法命令,那意思就是让esp中的地址减去一个16进制数字【0xe4】,产生新的esp,此时的esp是main函数栈帧的esp
00EE18D3 sub esp,0E4h
-
此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量,临时数据以及调试信息等
-
通过图,此时你也可以认为【esp】指向了低地址的一块空间
-
来看一下寄存器中存放的内存变化
第四、五、六条指令
00EE18D9 push ebx //将寄存器ebx的值压栈,esp-4
00EE18DA push esi //将寄存器esi的值压栈,esp-4
00EE18DB push edi //将寄存器edi的值压栈,esp-4
- 上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复
- 那随着寄存器的入栈,维护栈顶的寄存器也将发生变化
- 此时esp也随着压栈而变化
- 到VS里来看一下三次的变化:
第七、八、九、十条指令
- 下面的代码是在初始化main函数的栈帧空间,【非常重要】
00EE18DC lea edi,[ebp-24h]
00EE18DF mov ecx,9
00EE18E4 mov eax,0CCCCCCCCh
00EE18E9 rep stos dword ptr es:[edi]
上面的这段代码最后4句,等价于下面的伪代码:
edi = ebp-0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for(; ecx = 0; --ecx,edi+=4)
{
*(int*)edi = eax;
}
- 首先要来看的就是【lea】就是我们在上面讲到过的【load effective address】加载有效地址的意思,那也就是从【ebp】这个维护栈顶的寄存器减去24h的位置,加载到寄存器【edi】里面去
-
然后再将9放到【ecx】中去;以及将【0CCCCCCCCh】这块地址存到【eax】中去;
-
从【edi】所存放的这块地址的开始,每次初始化4个字节的数据,dword值就是4个字节的大小
-
这4句话的操作就是从edi开始,每次初始化4个字节的数据,总共初始化ecx次,初始化的内容为【0xCCCCCCCC】,总共初始化到ebp的地址结束
- 到这里,main函数才刚刚被初始化完成
- 那么里面的cccccccc是初始化的什么内容呢?–>我们来看一下
char arr[20];
printf("%s",arr);
- 可以看到上面的程序输出“烫烫烫烫烫烫烫烫烫烫”这一串,是因为main函数调用时,在栈区开辟的空间的其中每一个字节都被初始化为0xCC,上图中arr数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC(两个连续排列的0xCC)的汉字编码就是“烫”,所以0xCCCC被当作文本就是“烫”,烫烫烫就这么来的
第十一、十二、十三条指令
- 我们开始初始化三个变量,每条指令对应上一条代码
int a = 10;
00EE18F5 mov dword ptr [ebp-8],0Ah
int b = 20;
00EE18FC mov dword ptr [ebp-14h],14h
int c = 0;
00EE1903 mov dword ptr [ebp-20h],0
-
其中【mov】是数据转移指令,也就是是将10这个值【ebp - 8】这块地址上
-
为什么说0Ah就是10呢?因为0Ah是10的十六进制表示形式,在十六进制中A值得就是10
-
对于14h的话就是16 * 1 + 4 = 20,那就是将20这个值放到【ebp - 14】这块地址上去
-
最后一句就是将0这个值放到【ebp - 20】这块地址上去
-
对于为什么-8,-14,-20呢,这是取决于编译器本身的,我是用的是VS2022,可能你到其他编译器上就不一样了
-
这就可以得出一个结论:我们所定义的变量在栈内存中并不是呈现一个连续存放的,可能是分散的,
-
接下去继续来看这三次的存放值的变化~~
- 我们再来看图,也将这些画出来。
第十四、十五、十六、十七条指令
- 此时main函数中的变量创建好了,那就要调用Add函数了
00EE190A mov eax,dword ptr [ebp-14h]
00EE190D push eax
00EE190E mov ecx,dword ptr [ebp-8]
00EE1911 push ecx
- 来看第一条,将【ebp-14h】这块地址的内容放到寄存器【eax】中去,那这个时候你就会想到这个【ebp-14】是刚才放数值20,然后压栈。
- 第三条就是将【ebp-8】中的内容放到寄存器【ecx】中去,它【ebp-8】的地方存放的就是我们刚才放10的地方,然后压栈。
- 这样就可以看出,这两个变量相当于实参的一份临时拷贝,这里就回到我们前面学的函数的形参就是实参的一份临时拷贝
再来到VS中看看
第十八条指令
00EE1912 call 00EE10B9
- 对于这条【call】指令而言,比较特殊,它有两个作用
①压入返回地址
②转入目标函数
- 这里就是要压的是 call指令的下一条地址
00EE1917 //这条就是要压入的地址
- 然后我们来在vs中看一下,当运行到图中的那条语句的时候就要按F11,不能按F10,和调试一个道理
- 把这块地址压入栈中
第十九、二十、二一条指令
- 到19条指令开始,就进入Add函数了,这里的函数前面和在main函数中的前面也是非常的相似
- 所以这个就是在开辟栈帧
00EE1790 push ebp
00EE1791 mov ebp,esp
00EE1793 sub esp,0CCh
00EE1799 push ebx
00EE179A push esi
00EE179B push edi
- 首先来看第一条指令。也就是将之前的【ebp】栈底寄存器的值压入到栈顶中
00EE1790 push ebp
- 对于此处的【ebp】,自从它在维护main函数的栈底后就没有再动过来,所以这里push上来的就是main函数的【ebp】
00EE1791 mov ebp,esp
- 接着再来看第二条,也就是将main函数的【esp】重新赋给【ebp】,这里要注意了,不要搞混,此时的【ebp】应该算是在维护Add函数的栈底了
- 于是,栈就变成了这样:
00EE1793 sub esp,0CCh
- 接着第三条,【sub】命令使得【esp】存放的地址块减去一个CC的大小,继续结合上面那条指令,此时Add函数的栈顶和栈底都被找到了
- 此时就相当于是在做一个迭代的操作
第二二、二三、二四条指令
00EE1799 push ebx
00EE179A push esi
00EE179B push edi
- 接下去还是一样的三条压栈操作
- 来到VS中观看【esp】的变化
- 接着将这三个寄存器压入栈
第二五、二六、二七、二八条指令
- 对于这四条指令和上面main函数的创建过程类似,便不做不过分析
00EE179C lea edi,[ebp-0Ch]
00EE179F mov ecx,3
00EE17A4 mov eax,0CCCCCCCCh
00EE17A9 rep stos dword ptr es:[edi]
- 继续到VS中观看的变化
第二十九条指令
- 接下去我们进入第二十九条指令,也就是对Add函数中存放计算总和的变量z进行初始化操作。
- 【mov】做数据转移,将0放到【ebp-8】这块地址上去
int z = 0;
00EE17B5 mov dword ptr [ebp-8],0
- 然后我们在Add的栈帧中初始化这个变量z
第三十、三十一、三十二条指令
- 接下去的三条指令就是对两个形参的值进行一个相加
z = x + y;
00EE17BC mov eax,dword ptr [ebp+8]
00EE17BF add eax,dword ptr [ebp+0Ch]
00EE17C2 mov dword ptr [ebp-8],eax
- 那么上面不是只初始化了一个变量z吗,变量x和变量y在哪里呢?
- 我们之前有做过了一步操作,也就是将这两个实参的拷贝进行了一个压栈操作,那时就说了对于这个就是形参
00EE190A mov eax,dword ptr [ebp-14h]
00EE190D push eax
00EE190E mov ecx,dword ptr [ebp-8]
00EE1911 push ecx
- 此时我们就要通过这三句指令去找回这两个形参的值,关键的就是【ebp+8】和【ebp+0Ch】。因为我们在入栈的时候【ebp】寄存器存放的地址都是逐渐变小的,因为 栈是从高地址往低地址生长的,所以我们要去找回之前压入的内容,就要把地址加回去
- 如下图所示
- 找到这两个值之后,首先将【10】放到【eax】寄存器中去,然后再将【20】在加到寄存器【eax】原有的值上去,此时【eax】中存放的便是【30】
- 注意看寄存器【eax】的变化
- 这里还可以直接到指令这里来看。直接将鼠标放到【z】上面就可以看到了
- 然后再将计算出来存放在【eax】中的值再放回【ebp-8】这块地址上去
00EE17C2 mov dword ptr [ebp-8],eax
- 首先到VS中来看看变化
- 然后修改一下之前Add函数栈帧中存放z的内容
第三十三条指令
z
计算出来了,此时就要执行【return z】这句代码,将z返回给main函数,但是函数栈帧中可不是这么做的
return z;
00EE17C5 mov eax,dword ptr [ebp-8]
- 看上面的指令可以看到,是将【ebp-8】中的内容转存到寄存器【eax】中去
- 从【eax】~【ebx】这些寄存器都可以用来存放临时数据,并不是说上一次用过了就不能再用了,这其实和我们在定义一个变量后进行反复使用是一个道理。
- 然后在Add函数调用结束后,它所对应的函数栈帧就会被销毁,此时被创建出来的临时变量【z】就不复存在了,因为【z】也是存放在Add的函数栈帧中的,所以这一步的操作其实就是将我们在Add函数中计算出来的值给保存起来,因为寄存器而言程序没有结束的话它是不会被销毁的,我们后面还可以到这个寄存器中去取数据
3.3.4 函数栈帧的销毁
接下去要进行的就是函数栈帧的销毁操作
第三十四、三十五、三十六条指令
- 接下来就是三条pop的指令,也就是在栈顶弹出对应的值,然后放到对应的寄存器中去
00EE17C8 pop edi //在栈顶弹出一个值,存放到edi中,esp+4
00EE17C9 pop esi //在栈顶弹出一个值,存放到esi中,esp+4
00EE17CA pop ebx //在栈顶弹出一个值,存放到ebx中,esp+4
- 我们先到VS中来看看
- 通过图示来看一下
第三十七条指令
- 当给Add函数预开辟函数栈帧的时候,最后一步是把【esp】中存放的内容给到【ebp】,也就是相当于就是让【ebp】指向和【esp】的同一块空间
- 下面这句指令就是将【ebp】中存放的内容给到【esp】,那其实就是让【esp】指向和【ebp】的同一块空间
00EE17D8 mov esp,ebp
- 通过图示来看一下
- 到VS中来看一下
第三十八条指令
00EE17DA pop ebp
- 这句指令很重要,因为此时Add函数的函数栈帧已经被销毁了,此时我们要回到main函数的函数栈帧,那么两个维护栈顶和栈底的寄存器就要发生变化,此时我们要pop的【ebp】是之前压栈进来的main函数的ebp
- pop的作用:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
- pop了之后【esp】也要发生一个变化
- 到VS中再来看一下变化。此时不要混淆了,栈是从高地址往低地址增长的,所以栈底的地址来的大一些
第三十九条指令
- 这里只有一个【ret】,这个指令会从栈顶弹出一个值,那这个时候从上图其实可以看到此时的【esp】栈顶寄存器指向的这块地址,这块地址是call指令的下一条指令地址,就是我们在进入Add函数前提前压入的地址
00EE17DB ret
- 此时就会直接跳转到call指令下一条指令的地址处,继续往下执行
- 再来看看【esp】的变化
第四十条指令
- 有的同学看到的就是一个【esp】的变化,【add】是加法命令,也就是将【esp】的位置加上一个8,一块内存空间是4,加8的话那此时【esp】是不是就来到了【edi】的位置
- 这其实就是在【销毁Add函数的函数形参x,y】,这下你应该明白函数形参是在什么时候销毁的了吧,没错,就是从Add函数回到main函数之后
0046185D 83 C4 08 add esp,8
- 我们来看看示意图:
- 一样,VS也来看看【esp】的变化
第四十一条指令
00EE191A mov dword ptr [ebp-20h],eax
-
将eax中值,存档到ebp-0x20的地址处,其实就是存储到main函数中ret变量中,而此时eax中就是Add函数中计算的x和y的和,可以看出来,本次函数的返回值是由eax寄存器带回来的。程序是在函数调用返回之后,在eax中去读取返回值的。
-
先前在Add函数中计算出来的30,首先放到【eax】寄存器中保存起来,现在过来好几条指令后,它还保存在里面,我们只需要使用【mov】将数据做一个转移即可
-
到VS里来看看变化
- 最后main函数栈帧的销毁也同理,这里就不再介绍了
- 以下是这个栈的全局浏览图
拓展了解:
其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。具体可以参考《程序员的自我修养》一书的第10章。
到这里我们给大家完整的演示了main函数栈帧的创建,Add函数站真的额创建和销毁的过程,相信大家已经能够基本理解函数的调用过程,函数传参的方式,也能够回答最开始的问题了
四、总结与开局疑难解答
① 局部变量是如何创建的?
- 首先为函数分配好栈帧空间,将这块栈帧空间初始化好后,然后给局部在栈帧里分配空间
② 为什么局部变量不初始化内容是随机的?
- 因为函数栈帧中的空间是预先初始化好的【0xCCCCCCCCh】,若是不为变量初始化内容,那使用的就是初始化好后的内容,以字符的形式打印出来便是烫烫烫烫烫烫
③ 函数调用时参数时如何传递的?传参的顺序是怎样的?
- 当还没有进入函数的时候,就已经将函数实参做了一份临时拷贝,并从右向左压入栈中【FILO】,当真正进入到函数栈帧中时,通过指针的偏移量,就可以顺着找回来,找到这份临时拷贝的形参
④ 函数的形参和实参分别是怎样实例化的?
- 形参确实是我在压栈的时候开辟的一块空间,它和实参只是值相同,但是空间是独立的,所以形参是实参的一份临时拷贝,改变形参的值不会影响到实参
⑤ 函数调用是怎么做的?返回值是如何带会的?
- 当执行到【call】指令的时候,把call指令的下一条指令地址压入栈中,相当于记住了这个地址。接着进入到函数中,当函数执行结束的时候,回到主函数中,再执行【ret】指令就可以回到call指令的下一条指令地址
- 返回值是通过寄存器带回来的、将函数中计算出来的返回值存放到寄存器中,因为寄存器不会随着函数的调用结束而被销毁,最后再将寄存器中存放的数据转存回对应的内存块中即可
好了,函数的栈帧的创建与销毁所有内容就到这里就结束了
如果有什么问题可以私信我或者评论里交流~~
感谢大家的收看,希望我的文章可以帮助到正在阅读的你🌹🌹🌹