深入理解函数栈帧:从创建到销毁的底层逻辑
前言
函数栈帧是什么?
函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:
- 函数参数和函数返回值
- 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
- 保存- 上下文信息(包括在函数调用前后需要保持不变的寄存器)。
什么是栈?
栈是一种遵循“先进后出(LIFO)”原则的数据结构,同时也是程序内存中一块连续的、用于存储函数参数、局部变量、返回地址等临时数据的区域,由栈指针(如x86架构的
ESP)管理,内存生长方向为“从高地址向低地址延伸”,是函数调用、执行与返回的底层支撑。
我们了解完以上概念,接下我先提出一些疑问大家可以先行思考,等待本篇末尾为大家揭晓答案。
- 局部变量是如何创建的?
- 为什么局部变量不初始化内容是随机的?
- 函数调用时参数时如何传递的?传参的顺序是怎样的?函数的形参和实参分别是怎样实例化的?
- 函数的返回值是如何带回的?
本篇博客比较枯燥,希望大家耐心看完。让我们一起走进函数栈帧的创建和销毁的过程中。本篇博客代码是基于vs 2019版本去进行调试,版本越高,函数封装越好,越不易观察。
函数栈帧的深度解析
函数栈帧的预备知识
相关寄存器
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器b
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址
相关汇编代码
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1. 压入返回地址 2. 转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
解析函数栈帧的创建和销毁
(1)我们每一次函数的调用,都是需要为这个函数额外开辟空间的,这个也叫做函数栈帧的空间。
(2)我们开辟的这块空间是用两个寄存器去维护的:esp和ebp,ebp是记录栈底的地址,esp是记录栈顶的地址。
(3)栈的空间是向上生长的,也就是从高地址到低地址,并且是由程序自动销毁,非程序员手动销毁。
如图示意:
函数调用堆栈
演示代码如下:
#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 6;
int b = 5;
int ret = 0;
ret = Add(a, b);
printf("%d\n", ret);
return 0;
}
我们在vs2019版本上进行调试,当调试进入Add函数,我们右击调用堆栈,就可以看到它们之间的调用关系:一开始是由invoke_main函数调用main函数,而Add函数是由main函数所调用。
上面我们提到函数开辟的栈帧空间是由两个寄存器去维护的。而随着函数的调用ebp和esp这两个寄存器也会随之移动。invoke_main 函数应该会有自己的栈帧, main 函数和 Add 函数也会维护自己的栈帧,每个函数栈帧都有自己的 ebp 和 esp 来维护栈帧空间。
准备环境:
我们为了调试方便,可以关闭简化一些代码,让我们研究栈帧过程足够清晰。
转到反汇编代码:
调试到main函数开始执行的第一行,右击鼠标转到反汇编。
注:VS编译器每次调试都会为程序重新分配内存,课件中的反汇编代码是一次调试代码过程中数据,每次调试略有差异。
int main()
{
//函数栈帧的创建00BE1820 push
ebp
00BE1821 mov
ebp,esp
00BE1823 sub
esp,0E4h
00BE1829 push
ebx
00BE182A push
esi
00BE182B push
edi
00BE182C lea
edi,[ebp-24h]
00BE182F mov
ecx,9
00BE1834 mov
eax,0CCCCCCCCh
00BE1839 rep stos dword ptr es:[edi]
//main函数中的核心代码
int a = 3;
00BE183B mov
dword ptr [ebp-8],3
int b = 5;
00BE1842 mov
dword ptr [ebp-14h],5
int ret = 0;
00BE1849 mov
dword ptr [ebp-20h],0
ret = Add(a, b);
00BE1850 mov
eax,dword ptr [ebp-14h]
00BE1853 push
eax
00BE1854 mov
ecx,dword ptr [ebp-8]
00BE1857 push
ecx
00BE1858 call
00BE10B4
00BE185D add
esp,8
00BE1860 mov
dword ptr [ebp-20h],eaxprintf("%d\n", ret);
00BE1863 mov
eax,dword ptr [ebp-20h]
00BE1866 push
eax
00BE1867 push
0BE7B30h
00BE186C call
00BE10D2
00BE1871 add
esp,8
return 0;
00BE1874 xor
eax,eax
}
函数栈帧的创建
main函数栈帧的创建:
我们接下来插接一下汇编代码:
00BE1820 push ebp //把ebp寄存器中的值进行压栈,此时的ebp中存放的是invoke_main函数栈帧的ebp,esp-4
00BE1821 mov ebp,esp //move指令会把esp的值存放到ebp中,相当于产生了main函数的
ebp,这个值就是invoke_main函数栈帧的esp
00BE1823 sub esp,0E4h //sub会让esp中的地址减去一个16进制数字0xe4,产生新的esp,此时的esp是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量,临时数据已经调试信息等。00BE1829 push ebx //将寄存器ebx的值压栈,esp-4
00BE182A push esi //将寄存器esi的值压栈,esp-4
00BE182B push edi //将寄存器edi的值压栈,esp-4
//上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复。
//下面的代码是在初始化main函数的栈帧空间。
//1. 先把ebp-24h的地址,放在edi中
//2. 把9放在ecx中
//3. 把0xCCCCCCCC放在eax中
//4. 将从edp-0x2h到ebp这一段的内存的每个字节都初始化为0xCC00BE182C lea
edi,[ebp-24h]
00BE182F mov ecx,9
00BE1834 mov eax,0CCCCCCCCh
00BE1839 rep stos dword ptr es:[edi]
翻译下来就等价于下面:
edi = ebp-0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for(; ecx = 0; --ecx,edi+=4)
{
*(int*)edi = eax;
}

ps:大家知道为啥有时候会打印出烫烫烫吗?
是因为main函数调用时,在栈区开辟的空间的其中每一个字节都被初始化为0xCC,而arr数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC(两个连续排列的0xCC)的汉字编码就是“烫”,所以0xCCCC被当作文本就是“烫”。
上面我们进行了main函数空间的开辟和初始化,接下来我们在分析main函数的核心代码:
int a = 3;
00BE183B mov dword ptr [ebp-8],3 //将3存储到ebp-8的地址处,ebp-8的位置其实就是a变量
int b = 5;
00BE1842 mov dword ptr [ebp-14h],5 //将5存储到ebp-14h的地址处,ebp-14h的位置其实是b变量
int ret = 0;
00BE1849 mov dword ptr [ebp-20h],0 //将0存储到ebp-20h的地址处,ebp-20h的位置其实是ret变量
//以上汇编代码表示的变量a,b,ret的创建和初始化,这就是局部的变量的创建和初始化
//其实是局部变量的创建时在局部变量所在函数的栈帧空间中创建的
//调用Add函数
ret = Add(a, b);
//调用Add函数时的传参
//其实传参就是把参数push到栈帧空间中
00BE1850 mov eax,dword ptr [ebp-14h] //传递b,将ebp-14h处放的5放在eax寄存器中
00BE1853 push eax
//将eax的值压栈,esp-4
00BE1854 mov ecx,dword ptr [ebp-8] //传递a,将ebp-8处放的3放在ecx寄存器中
00BE1857 push ecx
//将ecx的值压栈,esp-4
//跳转调用函数
00BE1858 call 00BE10B4
00BE185D add esp,8
00BE1860 mov dword ptr [ebp-20h],eax

图片中的 a’ 和 b’ 其实就是 Add 函数的形参 x , y 。这里的分析很好的说明了函数的传参过程,以及函数在进行值传递调用的时候,形参其实是实参的一份拷贝。对形参的修改不会影响实参。
函数栈帧的销毁
通过我前面写的博客可以知道,当函数调用结束返回的时候,前面创建的函数栈帧也开始会被销毁。
下面我们就来梳理一下销毁的过程:
00BE177F pop edi //在栈顶弹出一个值,存放到edi中,esp+4
00BE1780 pop esi //在栈顶弹出一个值,存放到esi中,esp+4
00BE1781 pop ebx //在栈顶弹出一个值,存放到ebx中,esp+4
00BE1782 mov esp,ebp //再将Add函数的ebp的值赋值给esp,相当于回收了Add函数的栈帧空间
00BE1784 pop ebp //弹出栈顶的值存放到ebp,栈顶此时的值恰好就是main函数的ebp,esp+4,此时恢复了main函数的栈帧维护,esp指向main函数栈帧的栈顶,ebp指向了main函数栈帧的栈底。
00BE1785 ret
//ret指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是call指令下一条指令的地址,此时esp+4,然后直接跳转到call指令下一条指令的地址处,继续往下执行。
此刻我们再回到call指令的下一条指令的地方:
但调用完Add函数,回到main函数的时候,继续往下执行,可以看到:
00BE185D add esp,8
//esp直接+8,相当于跳过了main函数中压栈的
a'和b'
00BE1860 mov dword ptr [ebp-20h],eax //将eax中值,存档到ebp-0x20的地址处,其实就是存储到main函数中ret变量中
//而此时eax中就是Add函数中计算的x和y的和,可以看出来,本次函数的返回值是由eax寄存器带回来的。程序是在函数调用返回之后,在eax中去读取返回值的。
后续就是用pop函数弹出那三个寄存器,并且通过移动esp和ebp指针不断将之前函数栈帧创建的空间逐一释放。
总结来说,两个指针维护所在栈的空间,将ebp值给压栈,esp随着压栈操作,指针继续往上走,再压栈寄存器,进行初始化,为局部变量创建空间,在进行局部变量的初始化,且初始化空间不一定连续,未初始化值也取决于编辑器。销毁时就是用pop函数出栈,ebp和esp指针则通过之前压入栈预存的地址来往下走进而释放原来所维护的空间。从而将空间还给操作系统。
复盘
| 问题 | 底层逻辑(基于函数栈帧) |
|---|---|
| 局部变量是如何创建的? | 函数栈帧创建时,通过 sub esp, 偏移量 在栈上分配连续内存空间,该空间即为局部变量的存储区域。例如 int a 会在栈帧中占据对应字节的内存,地址由 ebp 偏移确定。 |
| 为什么局部变量不初始化内容是随机的? | 栈帧内存是“重复利用”的,若局部变量未初始化,其内容是之前栈帧使用后残留的数据,因此表现为“随机”。(调试模式下编译器会填充 0xCCCCCCCC 作为未初始化标记,本质也是栈内存残留) |
| 函数调用时参数如何传递?传参顺序是怎样的? | 参数通过“压栈”传递,x86 架构下通常从右到左压栈(如 func(a,b,c) 先压 c、再 b、再 a),这样在被调用方栈帧中,第一个参数的地址离 ebp 更近,便于读取。 |
| 函数的形参和实参分别是怎样实例化的? | 实参在调用方栈帧中,通过压栈传递;形参在被调用方栈帧中,是实参的“副本”(值传递时),其内存空间在被调用方栈帧初始化时分配,与实参内存独立但值相同。 |
| 函数的返回值是如何带回的? | 通常通过寄存器传递(如 x86 架构用 EAX 寄存器),函数执行完毕前将返回值存入 EAX,调用方从 EAX 中读取该值完成返回值的接收。 |
好了本篇文章就到这里就结束了,欢迎佬们指导与评论区交流,我们下期再见!
更多推荐



所有评论(0)