函数栈帧的创建与销毁:从汇编视角彻底理解函数调用机制
一、前言
我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。那函数在底层到底是如何调用的?函数的返回值又是如何带回来的?函数参数是如何传递的?为什么局部变量不初始化时内容总是"烫烫烫"?这些问题都和函数栈帧有关系。
想要真正理解函数的运行机制,理解计算机的底层架构是必不可少的。本文将从汇编视角出发,结合VS2019编译器的反汇编代码,带领大家逐步深入,彻底搞懂函数栈帧的创建和销毁全过程。
二、核心概念铺垫
2.1 什么是栈?
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈。没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器:用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FILO)。就像叠成一叠的书,先叠上去的书在最下面,因此要最后才能取出。
在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
关键结论:在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。 在我们常见的 i386 或者 x86-64 下,栈顶由称为 esp 的寄存器进行定位。
2.2 认识相关寄存器和汇编指令
在深入分析之前,我们必须先认识几个核心寄存器和汇编指令:
相关寄存器:
| 寄存器 | 作用 |
|---|---|
eax | 通用寄存器,保留临时数据,常用于返回值 |
ebx | 通用寄存器,保留临时数据 |
ebp | 栈底寄存器,记录当前栈帧的底部 |
esp | 栈顶寄存器,记录当前栈帧的顶部 |
eip | 指令寄存器,保存当前指令的下一条指令的地址 |
相关汇编指令:
| 指令 | 作用 |
|---|---|
mov | 数据转移指令 |
push | 数据入栈,同时 esp 栈顶寄存器也要发生改变 |
pop | 数据弹出至指定位置,同时 esp 栈顶寄存器也要发生改变 |
sub | 减法命令 |
add | 加法命令 |
call | 函数调用:①压入返回地址 ②转入目标函数 |
ret | 恢复返回地址,压入 eip,类似 pop eip 命令 |
2.3 预备知识
首先我们要达成一些预备知识,才能有效地帮助我们理解函数栈帧的创建和销毁:
- 每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
- 这块空间的维护是使用了2个寄存器:
esp和ebp,ebp记录的是栈底的地址,esp记录的是栈顶的地址。 - 函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,本次演示以 VS2019 为例。
栈帧在内存中的布局如下:
高地址
| |
| 已使用的栈空间 |
| |
| 当前函数栈帧 | ← ebp(栈底)
| 局部变量/临时数据 |
| ... | ← esp(栈顶,向下增长)
| |
低地址
三、原理剖析:函数栈帧的创建过程
3.1 演示代码
为了观察函数栈帧的变化,我们使用一段最简单的代码:
#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 3;
int b = 5;
int ret = 0;
ret = Add(a, b);
printf("%d\n", ret);
return 0;
}
重要提示: 在VS2019编译器上调试前,建议关闭"支持仅我的代码调试"选项(项目属性 → C/C++ → 常规 → 支持仅我的代码调试 → 否),这样可以让汇编代码中排除一些编译器附加的代码,观察更清晰。
3.2 观察调用堆栈
调试进入 Add 函数后,右击勾选【显示外部代码】,可以观察到函数的调用堆栈:
kernel32.dll!7Seeb028()
test.exe!mainCRTStartup(void * _formal=0x02de6000) 行 17
test.exe!__scrt_common_main_seh() 行 288
test.exe!invoke_main() 行 78
test.exe!main() 行 306
test.exe!Add(int x=3, int y=5) 行 295
函数调用堆栈是反馈函数调用逻辑的。我们可以清晰地观察到:main 函数调用之前,是由 invoke_main 函数来调用 main 函数的。 每个函数栈帧都有自己的 ebp 和 esp 来维护栈帧空间。
3.3 main 函数栈帧的创建
调试到 main 函数开始执行的第一行,右击鼠标转到反汇编。
注:VS编译器每次调试都会为程序重新分配内存,以下反汇编代码是一次调试过程中的数据,每次调试略有差异。
; ========== 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 函数核心代码 ==========
00BE183B mov dword ptr [ebp-8], 3 ; int a = 3
00BE1842 mov dword ptr [ebp-14h], 5 ; int b = 5
00BE1849 mov dword ptr [ebp-20h], 0 ; int ret = 0
接下来我们逐行拆解这些汇编代码:
① 保存旧的栈底
00BE1820 push ebp
把 ebp 寄存器中的值进行压栈。此时的 ebp 中存放的是 invoke_main 函数栈帧的 ebp,esp 减 4。
② 建立新的栈底
00BE1821 mov ebp, esp
mov 指令会把 esp 的值存放到 ebp 中,相当于产生了 main 函数的 ebp。这个值就是 invoke_main 函数栈帧的 esp。
③ 开辟栈帧空间
00BE1823 sub esp, 0E4h
sub 会让 esp 中的地址减去一个十六进制数字 0xE4,产生新的 esp。此时的 esp 是 main 函数栈帧的 esp。
此时,结合上一条指令的 ebp 和当前的 esp,ebp 和 esp 之间维护了一块栈空间。这块栈空间就是为 main 函数开辟的,就是 main 函数的栈帧空间。 这一段空间中将存储 main 函数中的局部变量、临时数据以及调试信息等。
④ 保存寄存器上下文
00BE1829 push ebx
00BE182A push esi
00BE182B push edi
上面3条指令保存了3个寄存器的值在栈区。这3个寄存器在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复。
⑤ 初始化栈帧空间
00BE182C lea edi, [ebp-24h]
00BE182F mov ecx, 9
00BE1834 mov eax, 0CCCCCCCCh
00BE1839 rep stos dword ptr es:[edi]
上面的这段代码最后4句,等价于下面的伪代码:
edi = ebp - 0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for (; ecx != 0; --ecx, edi += 4) {
*(int*)edi = eax;
}
核心结论:这段代码是在初始化 main 函数的栈帧空间,将栈帧中的每个字节都初始化为 0xCC。
3.4 "烫烫烫"的真相
之所以很多初学者会遇到输出"烫"这么一个奇怪的字,是因为 main 函数调用时,在栈区开辟的空间中每一个字节都被初始化为 0xCC。如果一个未初始化的数组恰好在这块空间上创建,0xCCCC(两个连续排列的 0xCC)的汉字编码就是"烫"。
#include <stdio.h>
int main()
{
char arr[20]; // 未初始化
printf("%s\n", arr); // 输出:烫烫烫烫...
return 0;
}
所以,局部变量不初始化时内容是随机的(实际上是 0xCC),这就是为什么局部变量使用前必须初始化的底层原因。
3.5 局部变量的创建
继续分析 main 函数中的核心代码:
00BE183B mov dword ptr [ebp-8], 3 ; 将3存储到 ebp-8 的地址处
00BE1842 mov dword ptr [ebp-14h], 5 ; 将5存储到 ebp-14h 的地址处
00BE1849 mov dword ptr [ebp-20h], 0 ; 将0存储到 ebp-20h 的地址处
以上汇编代码表示变量 a、b、ret 的创建和初始化。局部变量的创建是在局部变量所在函数的栈帧空间中创建的。 ebp-8 的位置其实就是 a 变量,ebp-14h 的位置是 b 变量,ebp-20h 的位置是 ret 变量。
四、实战案例:函数调用与传参的底层真相
4.1 函数传参的本质
; 调用 Add 函数
ret = Add(a, b);
; 传参:把参数 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
核心结论:函数传参的本质就是把参数 push 到栈帧空间中。传参顺序是从右到左(先传 b,再传 a)。
4.2 call 指令的奥秘
00BE1858 call 00BE10B4
00BE185D add esp, 8
00BE1860 mov dword ptr [ebp-20h], eax
call 指令是要执行函数调用逻辑的。在执行 call 指令之前,先会把 call 指令的下一条指令的地址进行压栈操作。这个操作是为了解决当函数调用结束后,要回到 call 指令的下一条指令的地方,继续往后执行。
此时栈上的布局:
高地址
| ... main 函数栈帧 ... |
| 局部变量 ret=0 | ← ebp-20h
| 局部变量 b=5 | ← ebp-14h
| 局部变量 a=3 | ← ebp-8
| main 函数的 ebp | ← ebp
| 返回地址 (00BE185D) | ← call 压入
| 形参 b'=5 | ← push eax
| 形参 a'=3 | ← push ecx ← esp
低地址
4.3 Add 函数栈帧的创建
当我们跳转到 Add 函数,就要开始观察 Add 函数的反汇编代码了:
00BE1760 push ebp
00BE1761 mov ebp, esp
00BE1763 sub esp, 0CCh
00BE1769 push ebx
00BE176A push esi
00BE176B push edi
00BE176C mov dword ptr [ebp-8], 0 ; int z = 0
; 计算 x + y
00BE1773 mov eax, dword ptr [ebp+8] ; 将 ebp+8 处的数字(形参 x)存入 eax
00BE1776 add eax, dword ptr [ebp+0Ch] ; 将 ebp+12 处的数字(形参 y)加到 eax
00BE1779 mov dword ptr [ebp-8], eax ; 结果保存到 z
; 返回值
00BE177C mov eax, dword ptr [ebp-8] ; 将 z 的值放入 eax,通过 eax 带回
关键发现:
ebp+8和ebp+0Ch(ebp+12)找的是在Add函数调用之前压栈的参数a'和b'- 形参其实是实参的一份拷贝,对形参的修改不会影响实参
- 返回值是通过
eax寄存器带回来的
4.4 函数栈帧的销毁
当函数调用要结束返回的时候,前面创建的函数栈帧也开始销毁:
00BE177F pop edi ; 恢复 edi,esp+4
00BE1780 pop esi ; 恢复 esi,esp+4
00BE1781 pop ebx ; 恢复 ebx,esp+4
00BE1782 mov esp, ebp ; esp = ebp,回收 Add 函数的栈帧空间
00BE1784 pop ebp ; 弹出栈顶值到 ebp,恢复 main 函数的 ebp
00BE1785 ret ; 弹出返回地址,跳回 main 函数继续执行
回到 main 函数后:
00BE185D add esp, 8 ; esp 直接+8,跳过 main 中压栈的 a' 和 b'
00BE1860 mov dword ptr [ebp-20h], eax ; 将 eax(返回值6)存入 ret
五、常见问题与踩坑点
5.1 为什么局部变量不初始化内容是随机的?
答: 栈帧空间在创建时会被编译器初始化为 0xCC。未初始化的局部变量读取到的就是这些 0xCC,表现为"烫烫烫"或其他乱码。局部变量使用前必须初始化,这是铁律。
5.2 函数参数传递是值传递还是地址传递?
答: C语言中所有参数传递本质上都是值传递。即使是传指针,传递的也是指针变量中存储的地址值。形参是实参的一份拷贝,存储在独立的栈帧空间中,对形参的修改不会影响实参。
5.3 返回值是怎么带回来的?
答:
- 内置类型(int、char、指针等):通过
eax寄存器带回 - 较大的对象:在主调函数的栈帧中预留空间,通过隐式指针传递,被调函数将结果直接写入
5.4 栈溢出是怎么发生的?
答: 栈空间是有限的(通常1MB)。如果函数调用层次太深(如无限递归),或者局部变量占用过大(如超大数组),会导致栈帧不断叠加,最终超出栈的容量,触发 Stack Overflow。
六、全文总结
通过本文的完整分析,我们可以得出以下核心结论:
- 函数栈帧是函数调用时在栈上开辟的空间,由
ebp(栈底)和esp(栈顶)两个寄存器维护 - 栈向下增长(高地址→低地址),遵循 FILO 原则
- 局部变量在栈帧空间中创建,未初始化时内容为
0xCC(即"烫烫烫") - 函数传参从右到左压栈,形参是实参的拷贝,通过
ebp偏移访问 - 返回值通过
eax寄存器带回,大对象通过预留栈空间地址带回 - 函数返回时,通过
pop恢复寄存器、mov esp, ebp回收栈帧、ret跳转回原位置
理解函数栈帧,是理解C语言底层机制的关键一步。掌握了这些,你就能真正明白:局部变量的生命周期、函数调用的开销、递归的深度限制、以及为什么调试时总能看到那些"烫"字。

1432

被折叠的 条评论
为什么被折叠?



