函数栈帧的创建与销毁详解

函数栈帧的创建与销毁:从汇编视角彻底理解函数调用机制

一、前言

我们在写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 预备知识

首先我们要达成一些预备知识,才能有效地帮助我们理解函数栈帧的创建和销毁:

  1. 每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
  2. 这块空间的维护是使用了2个寄存器:espebpebp 记录的是栈底的地址,esp 记录的是栈顶的地址。
  3. 函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,本次演示以 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 函数的。 每个函数栈帧都有自己的 ebpesp 来维护栈帧空间。

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 函数栈帧的 ebpesp 减 4。

② 建立新的栈底

00BE1821  mov   ebp, esp

mov 指令会把 esp 的值存放到 ebp 中,相当于产生了 main 函数的 ebp。这个值就是 invoke_main 函数栈帧的 esp

③ 开辟栈帧空间

00BE1823  sub   esp, 0E4h

sub 会让 esp 中的地址减去一个十六进制数字 0xE4,产生新的 esp。此时的 espmain 函数栈帧的 esp

此时,结合上一条指令的 ebp 和当前的 espebpesp 之间维护了一块栈空间。这块栈空间就是为 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 的地址处

以上汇编代码表示变量 abret 的创建和初始化。局部变量的创建是在局部变量所在函数的栈帧空间中创建的。 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+8ebp+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

六、全文总结

通过本文的完整分析,我们可以得出以下核心结论:

  1. 函数栈帧是函数调用时在栈上开辟的空间,由 ebp(栈底)和 esp(栈顶)两个寄存器维护
  2. 栈向下增长(高地址→低地址),遵循 FILO 原则
  3. 局部变量在栈帧空间中创建,未初始化时内容为 0xCC(即"烫烫烫")
  4. 函数传参从右到左压栈,形参是实参的拷贝,通过 ebp 偏移访问
  5. 返回值通过 eax 寄存器带回,大对象通过预留栈空间地址带回
  6. 函数返回时,通过 pop 恢复寄存器、mov esp, ebp 回收栈帧、ret 跳转回原位置

理解函数栈帧,是理解C语言底层机制的关键一步。掌握了这些,你就能真正明白:局部变量的生命周期、函数调用的开销、递归的深度限制、以及为什么调试时总能看到那些"烫"字。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值