函数栈帧是用于在计算机程序中实现函数调用的一种数据结构。在函数调用过程中,每个函数都需要在内存中创建一个栈帧,用于存储局部变量、返回地址和参数等。
具体来说,函数栈帧通常包含以下部分:
局部变量表:存储函数的局部变量,包括基本数据类型(如整数、浮点数等)和对象引用(如指针)。
返回地址:存储函数的返回地址,即函数执行完毕后需要跳转到的地址。
参数表:存储函数的输入参数,通常按照传递的顺序排列。
操作数栈:用于存储函数的临时数据和中间结果,通常使用栈结构进行操作。
当一个函数被调用时,会在内存中创建一个新的栈帧,并将其压入调用该函数的栈中。当函数执行完毕后,该栈帧会被弹出栈并销毁。因此,函数栈帧在函数调用过程中起到了存储和传递数据的作用。
函数栈帧的实现方式取决于具体的编程语言和编译器。在一些高级编程语言中,编译器通常会为每个函数自动创建和销毁栈帧,而无需程序员手动管理。而在低级编程语言或手动控制内存分配的情况下,程序员需要手动创建和销毁栈帧。
理解函数栈帧有什么用呢?
只要理解了函数栈帧的创建和销毁,以下问题就能够很好的额理解了:
让我们一起走进函数栈帧的创建和销毁的过程中。
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的
在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的
- 【eax】:通用寄存器,保留临时数据,常用于返回值
- 【ebx】 :通用寄存器,保留临时数据
- 【ebp】:栈底寄存器
- 【esp】:栈顶寄存器
- 【eip】:指令寄存器,保存当前指令的下一条指令的地址
- 【mov】:数据转移指令
- 【push】:数据入栈,同时esp栈顶寄存器也要发生改变
- 【pop】:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
- 【add】:加法命令
- 【sub】:减法命令
- 【lea】 :load effective address,加载有效地址
- 【call】:函数调用,1. 压入返回地址 2. 转入目标函数
- 【jump】:通过修改eip,转入目标函数,进行调用
- 【ret】:恢复返回地址,压入eip,类似pop eip命令
esp
和 ebp
,【ebp】 记录的是栈底的地址, esp
记录的是栈顶的地址如图所示:
#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后到监视窗口打开【调用堆栈】的窗口
一直按F10,当调试箭头运行到第【22行】的时候,就会自动进入到exe_common.inl,此时我们就可以观察到底是哪个函数调用了main函数
通过下图可知是invoke_main这个函数调用的,我们了解到这里就可以了~~
【反汇编】
【内存】
【监视】
好,现在我们的环境已经全部搭建好了
00EE18D0 push ebp
随着push入栈的操作,维护栈顶的esp就要往上
然后我们看寄存器的变化
00EE18D1 mov ebp,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
第七、八、九、十条指令
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;
}
然后再将9放到【ecx】中去;以及将【0CCCCCCCCh】这块地址存到【eax】中去;
从【edi】所存放的这块地址的开始,每次初始化4个字节的数据,dword值就是4个字节的大小
这4句话的操作就是从edi开始,每次初始化4个字节的数据,总共初始化ecx次,初始化的内容为【0xCCCCCCCC】,总共初始化到ebp的地址结束
char arr[20];
printf("%s",arr);
第十一、十二、十三条指令
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,可能你到其他编译器上就不一样了
这就可以得出一个结论:我们所定义的变量在栈内存中并不是呈现一个连续存放的,可能是分散的,
接下去继续来看这三次的存放值的变化~~
第十四、十五、十六、十七条指令
00EE190A mov eax,dword ptr [ebp-14h]
00EE190D push eax
00EE190E mov ecx,dword ptr [ebp-8]
00EE1911 push ecx
再来到VS中看看
第十八条指令
00EE1912 call 00EE10B9
①压入返回地址
②转入目标函数
00EE1917 //这条就是要压入的地址
第十九、二十、二一条指令
00EE1790 push ebp
00EE1791 mov ebp,esp
00EE1793 sub esp,0CCh
00EE1799 push ebx
00EE179A push esi
00EE179B push edi
00EE1790 push ebp
00EE1791 mov ebp,esp
00EE1793 sub esp,0CCh
第二二、二三、二四条指令
00EE1799 push ebx
00EE179A push esi
00EE179B push edi
第二五、二六、二七、二八条指令
00EE179C lea edi,[ebp-0Ch]
00EE179F mov ecx,3
00EE17A4 mov eax,0CCCCCCCCh
00EE17A9 rep stos dword ptr es:[edi]
第二十九条指令
int z = 0;
00EE17B5 mov dword ptr [ebp-8],0
第三十、三十一、三十二条指令
z = x + y;
00EE17BC mov eax,dword ptr [ebp+8]
00EE17BF add eax,dword ptr [ebp+0Ch]
00EE17C2 mov dword ptr [ebp-8],eax
00EE190A mov eax,dword ptr [ebp-14h]
00EE190D push eax
00EE190E mov ecx,dword ptr [ebp-8]
00EE1911 push ecx
00EE17C2 mov dword ptr [ebp-8],eax
第三十三条指令
z
计算出来了,此时就要执行【return z】这句代码,将z返回给main函数,但是函数栈帧中可不是这么做的 return z;
00EE17C5 mov eax,dword ptr [ebp-8]
接下去要进行的就是函数栈帧的销毁操作
第三十四、三十五、三十六条指令
00EE17C8 pop edi //在栈顶弹出一个值,存放到edi中,esp+4
00EE17C9 pop esi //在栈顶弹出一个值,存放到esi中,esp+4
00EE17CA pop ebx //在栈顶弹出一个值,存放到ebx中,esp+4
第三十七条指令
00EE17D8 mov esp,ebp
第三十八条指令
00EE17DA pop ebp
第三十九条指令
00EE17DB ret
第四十条指令
0046185D 83 C4 08 add esp,8
第四十一条指令
00EE191A mov dword ptr [ebp-20h],eax
将eax中值,存档到ebp-0x20的地址处,其实就是存储到main函数中ret变量中,而此时eax中就是Add函数中计算的x和y的和,可以看出来,本次函数的返回值是由eax寄存器带回来的。程序是在函数调用返回之后,在eax中去读取返回值的。
先前在Add函数中计算出来的30,首先放到【eax】寄存器中保存起来,现在过来好几条指令后,它还保存在里面,我们只需要使用【mov】将数据做一个转移即可
到VS里来看看变化
拓展了解:
其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。具体可以参考《程序员的自我修养》一书的第10章。
到这里我们给大家完整的演示了main函数栈帧的创建,Add函数站真的额创建和销毁的过程,相信大家已经能够基本理解函数的调用过程,函数传参的方式,也能够回答最开始的问题了
① 局部变量是如何创建的?
② 为什么局部变量不初始化内容是随机的?
③ 函数调用时参数时如何传递的?传参的顺序是怎样的?
④ 函数的形参和实参分别是怎样实例化的?
⑤ 函数调用是怎么做的?返回值是如何带会的?
好了,函数的栈帧的创建与销毁所有内容就到这里就结束了
如果有什么问题可以私信我或者评论里交流~~
感谢大家的收看,希望我的文章可以帮助到正在阅读的你🌹🌹🌹