文章目录
本文是在vs2022,x86环境下进行的
一.何为函数栈帧
函数栈帧(Function Stack Frame),也称为活动记录(Activation Record)或堆栈帧(Stack Frame),是在函数调用过程中在程序的执行堆栈上分配的一块内存区域。它用于存储函数的局部变量、参数、返回地址和其他与函数执行相关的信息。
每当一个函数被调用时,一个新的函数栈帧就会被创建,并被添加到当前的执行堆栈的顶部。这个新的函数栈帧包含了函数的局部变量以及其他必要的上下文信息。函数栈帧的创建和销毁是通过栈指针的移动来完成的。
函数栈帧通常包括以下内容:
- 局部变量:函数内部定义的变量,在函数栈帧中分配内存空间来存储它们的值。
- 参数:函数调用时传递给函数的参数值被保存在函数栈帧中,供函数使用。
- 返回地址:调用函数之前的代码执行到函数调用语句时,将当前代码的返回地址压入堆栈,函数执行完后,根据该返回地址返回到正确的位置。
- 帧指针(Frame Pointer):也称为基址指针(Base Pointer),用于快速访问函数栈帧中的局部变量和其他上下文信息。
- 函数调用时需要保留的寄存器值:例如,有些寄存器可能需要在函数调用期间被暂时保存,并在函数返回时恢复到原始状态。
我们要注意,函数栈帧的创建和销毁是由编译器或者解释器负责的,它们根据函数的参数、局部变量以及其他上下文信息生成相应的汇编指令来分配和释放堆栈空间。函数栈帧的创建和销毁过程确保了函数调用的正确性和数据的隔离,使得程序能够有效地管理函数的执行和嵌套调用。
所以搞明白函数栈帧的创建与销毁可以让我们解决平常不太明白的问题,而且还可以提高编程的准确性和效率,并避免因不正确的函数调用而引发的错误和异常情况。
二.基本知识
在了解函数栈帧的创建与销毁之前,我们需要先了解一些知识
1.栈
栈(Stack)是一种常见的数据结构,用于存储和管理数据。它是一个特殊的线性数据结构,遵循“后进先出”(Last-In-First-Out,LIFO)的原则,意味着最后加入的元素首先被取出。
栈通常由一个连续的内存区域组成,可以想象成一摞叠在一起的盘子。新的元素(如数据项或指针)通过压栈(Push)操作添加到栈顶,而从栈中取出元素则通过弹栈(Pop)操作从栈顶移除。因此,只能访问或操作栈顶元素,而不能直接访问或修改其他位置上的元素。
在x86架构及类似的架构中,栈通常位于高地址区域。

2.寄存器
寄存器(Register)是计算机中用于临时存储和处理数据的一组内部存储器。它们是位于中央处理器(CPU)内部的一种高速存储器,与主内存相比,寄存器具有更快的读写速度和更低的访问延迟。
在x86架构中,常见的寄存器在函数调用中扮演重要角色,包括以下几个:
- ebp(BasePointer):ebp寄存器是基址指针寄存器,用于指向当前函数的栈帧的基地址。在函数调用时,ebp通常会保存上一个函数的ebp值,并将其设置为当前栈帧的基地址。通过ebp,可以方便地访问局部变量和函数参数。
- esp(Stack Pointer):esp寄存器是栈指针寄存器,用于指示当前栈顶位置。栈是用来存储函数调用过程中的局部变量、返回地址和其他临时数据的重要数据结构。esp随着栈帧的创建和销毁而进行调整。
- ebx(Base Register X):ebx寄存器是通用寄存器之一,用于存储临时数据或者作为内存地址的基址寄存器。
- esi(Source Index):esi寄存器是源索引寄存器,通常用于存储源数据的基址或者作为循环计数器的一部分。
- edi(Destination Index):edi寄存器是目的地索引寄存器,通常用于存储目标数据的基址或者作为循环计数器的一部分。
- ecx(Counter Register X):ecx寄存器是计数器寄存器,用于存储循环的计数值或者作为一般性目的寄存器。
- eax()Extended Accumulator:寄存器 eax 是 x86 架构中的通用寄存器之一,用于存储函数返回值、操作数和临时数据等。
3.汇编指令
- mov(Move):mov指令用于将数据从一个位置复制到另一个位置。例如,mov ax, bx会将BX寄存器的值复制到AX寄存器中。
- push(Push onto Stack):push指令用于将数据推入栈中。它将给定的值或寄存器中的值压入栈顶,并相应地调整栈指针。例如,push ax会将ax寄存器中的值压入栈中。
- call(Call Procedure):call指令用于调用子程序或函数。它将当前的程序执行地址压入栈中,并跳转到指定的子程序开始执行。在子程序执行完成后,使用 ret 指令返回到 call 指令之后的下一条指令。
- ret(Return):用于从子程序中返回到调用点,恢复程序控制流并进行栈帧的清理
- add(Addition):add指令用于执行加法操作。它可以将给定的值添加到目标操作数中,然后将结果存储在目标操作数中。例如,add ax,10会将AX寄存器的值增加10。
- sub(Subtraction):sub指令用于执行减法操作。它可以将给定的值从目标操作数中减去,然后将结果存储在目标操作数中。例如,sub ax, 5会将AX寄存器的值减去5。
- rep stos(Repeat Store String):rep stos指令用于将一个字节或字的值重复存储到一段内存区域中。
- lea(load effecitve address):在x86汇编中用于计算有效地址,并将结果存储在目标寄存器中。
三.函数栈帧的创建与销毁详解
展示所用代码,我们这里简单的定义了一个加法函数,并调用其进行运算
1.代码展示
#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);
}
2.函数调用堆栈
我们先按F11开启调试,然后打开调用堆栈

打开调用堆栈之后记得勾选显示外部代码

我们可以清楚的看到main()函数其被 invoke_main()函数调用的
然后 invoke_main()函数被__scrt_common_main_seh()函数调用,而__scrt_common_main_seh()函数又被__scrt_common_main()函数调用,最后__scrt_common_main()被mainCRTStartup()调用,每个函数栈帧都有自己的 ebp 和 esp 来维护栈帧空间
本文为了更加清晰的展示,就只展示到invoke_main()函数,之前的调用暂且忽略
3.反汇编
在使用之前我们要先做一些事前工作,右击解决发难点击属性,然后打开C/C++一栏的常规,将支持仅我的代码调试改为否,这样可以让我们观察汇编指令的时候更加清晰。
F11开启调试,右击背景板点击转到反汇编
右击之后勾选我所选择的几个选项,此举是为了更方便观察
4.函数栈帧的创建
int main()
{
00F01840 push ebp
00F01841 mov ebp,esp
00F01843 sub esp,0E4h
00F01849 push ebx
00F0184A push esi
00F0184B push edi
00F0184C lea edi,[ebp-24h]
00F0184F mov ecx,9
00F01854 mov eax,0CCCCCCCCh
00F01859 rep stos dword ptr es:[edi]
int a = 10;
00F0185B mov dword ptr [ebp-8],0Ah
int b = 20;
00F01862 mov dword ptr [ebp-14h],14h
int c = 0;
00F01869 mov dword ptr [ebp-20h],0
c = Add(a, b);
00F01870 mov eax,dword ptr [ebp-14h]
00F01873 push eax
00F01874 mov ecx,dword ptr [ebp-8]
00F01877 push ecx
00F01878 call 00F010B9
00F0187D add esp,8
00F01880 mov dword ptr [ebp-20h],eax
printf("%d\n", c);
00F01883 mov eax,dword ptr [ebp-20h]
00F01886 push eax
00F01887 push 0F07B30h
00F0188C call 00F010D7
00F01891 add esp,8
}
调用main之前,invoke_main()函数的栈帧就已经创建好了。
执行以下代码
int main()
{
00F01840 push ebp ; 将当前函数调用的基址指针(ebp)保存到堆栈上
00F01841 mov ebp,esp ; 将当前堆栈指针(esp)的值赋给 ebp,以建立新的基址指针
00F01843 sub esp,0E4h ; 通过将0E4h(228个字节)的大小从堆栈指针减去来为局部变量和临时数据分配空间
00F01849 push ebx ; 将 ebx 寄存器的值保存到堆栈中,以备将来使用
00F0184A push esi ; 将 esi 寄存器的值保存到堆栈中,以备将来使用
00F0184B push edi ; 将 edi 寄存器的值保存到堆栈中,以备将来使用
00F0184C lea edi,[ebp-24h] ; 使用 `lea` 指令计算出一个相对于 ebp 的地址,该地址是一个缓冲区([ebp-24h])用于存储数据
00F0184F mov ecx,9 ; 设置 ecx 的值为 9,即要重复操作的次数
00F01854 mov eax,0CCCCCCCCh ; 将 eax 的值设置为 0CCCCCCCCh,这是内存填充模式(常用于调试目的)
00F01859 rep stos dword ptr es:[edi] ; 使用 `rep stos` 指令将 eax 中的值重复存储到 [edi] 所指向的位置,即将缓冲区填充满

变量的创建
int a = 10;
00F0185B mov dword ptr [ebp-8],0Ah ; 将值 10 存储到位于 ebp-8 的地址处,即变量 a 的内存空间
int b = 20;
00F01862 mov dword ptr [ebp-14h],14h ; 将值 20 存储到位于 ebp-14 的地址处,即变量 b 的内存空间
int c = 0;
00F01869 mov dword ptr [ebp-20h],0 ; 将值 0 存储到位于 ebp-20 的地址处,即变量 c 的内存空间

Add函数传参
c = Add(a, b);
00F01870 mov eax,dword ptr [ebp-14h] ; 将变量 b 的值加载到寄存器 eax 中
00F01873 push eax ; 将 eax 寄存器中的值压入堆栈,作为第二个参数传递给函数 Add
00F01874 mov ecx,dword ptr [ebp-8] ; 将变量 a 的值加载到寄存器 ecx 中
00F01877 push ecx ; 将 ecx 寄存器中的值压入堆栈,作为第一个参数传递给函数 Add

Add函数的调用
00F01878 call 00F010B9 ; 调用函数 Add
00F0187D add esp,8 ; 调整堆栈指针,清除参数空间
00F01880 mov dword ptr [ebp-20h],eax ; 将函数返回值存储到变量 c 的内存空间
执行call语句后会调用Add函数,但他会在之前先将下一条指令进行压栈。
Add函数的反汇编代码如下
int Add(int x, int y)
{
00F01780 push ebp ; 将当前函数调用的基址指针(ebp)保存到堆栈上
00F01781 mov ebp,esp ; 将当前堆栈指针(esp)的值赋给 ebp,以建立新的基址指针
00F01783 sub esp,0CCh ; 通过将0CCh(204个字节)的大小从堆栈指针减去来为局部变量和临时数据分配空间
00F01789 push ebx ; 将 ebx 寄存器的值保存到堆栈中,以备将来使用
00F0178A push esi ; 将 esi 寄存器的值保存到堆栈中,以备将来使用
00F0178B push edi ; 将 edi 寄存器的值保存到堆栈中,以备将来使用
int z = 0;
00F0178C mov dword ptr [ebp-8],0 ; 将值 0 存储到位于 ebp-8 的地址处,即变量 z 的内存空间
z = x + y;
00F01793 mov eax,dword ptr [ebp+8] ; 将第一个参数 x 的值加载到寄存器 eax 中
00F01796 add eax,dword ptr [ebp+0Ch] ; 将第二个参数 y 的值加到寄存器 eax 中
00F01799 mov dword ptr [ebp-8],eax ; 将寄存器 eax 中的结果存储到变量 z 的内存空间
return z;
00F0179C mov eax,dword ptr [ebp-8] ; 将变量 z 的值加载到寄存器 eax 中
}
我们在这里可以看到通过ebp+8和ebp+12的地址访问到了由a和b传到寄存器ecx和eax的值,他们就是我们Add函数中的两个形参x和y

5.函数的销毁
Add函数的销毁
00F0179F pop edi ; 从堆栈中弹出值并恢复到 edi 寄存器
00F017A0 pop esi ; 从堆栈中弹出值并恢复到 esi 寄存器
00F017A1 pop ebx ; 从堆栈中弹出值并恢复到 ebx 寄存器
00F017A2 mov esp,ebp ; 将基址指针 ebp 的值赋给栈指针 esp,恢复原来的堆栈位置
00F017A4 pop ebp ; 从堆栈中弹出值并恢复到 ebp 寄存器
00F017A5 ret ; 从当前函数返回,将控制权交还给调用方

main函数的销毁
call指令回到这里
00F0187D add esp,8 ; 调整堆栈指针,清除参数空间,将形参消除
00F01880 mov dword ptr [ebp-20h],eax ; 将函数返回值存储到变量 c 的内存空间
下面和上面所讲同理,这里就不再赘述了
printf("%d\n", c);
00F01883 mov eax,dword ptr [ebp-20h] ; 将变量 c 的值加载到寄存器 eax 中
00F01886 push eax ; 将寄存器 eax 的值压入堆栈中(作为参数传递给 printf 函数)
00F01887 push 0F07B30h ; 将字符串格式化控制符 "%d\n" 的地址压入堆栈中(作为参数传递给 printf 函数)
00F0188C call 00F010D7 ; 调用 printf 函数
00F01891 add esp,8 ; 调整堆栈指针,清除参数空间
}
00F01894 xor eax,eax ; 将寄存器 eax 的值与自身进行异或操作,相当于将其置零
00F01896 pop edi ; 从堆栈中弹出值并恢复到 edi 寄存器
00F01897 pop esi ; 从堆栈中弹出值并恢复到 esi 寄存器
00F01898 pop ebx ; 从堆栈中弹出值并恢复到 ebx 寄存器
00F01899 add esp,0E4h ; 调整堆栈指针,回收局部变量和临时数据所占用的空间
00F0189F cmp ebp,esp ; 比较基址指针 ebp 和堆栈指针 esp 的值
00F018A1 call 00F01253 ; 调用某个函数(未给出具体代码)
00F018A6 mov esp,ebp ; 将基址指针 ebp 的值赋给栈指针 esp,恢复原来的堆栈位置
00F018A8 pop ebp ; 从堆栈中弹出值并恢复到 ebp 寄存器
00F018A9 ret ; 从当前函数返回,将控制权交还给调用方
四.尾声
以上就是本文章的全部内容,相信大家理解后可以解决许多平常疑惑的问题,如果文章有什么问题,请大家及时提出,我会进一步修改和完善的。
本文详细解析了函数栈帧的概念,介绍了栈的工作原理、寄存器在函数调用中的作用以及汇编指令在栈帧创建和销毁中的应用。通过实例展示了函数调用过程中的栈帧操作,帮助读者掌握函数调用堆栈管理和内存管理的关键概念。





3438





