【C语言】函数栈帧的创建和销毁

一、核心概念:什么是栈和栈帧?

  1. 栈 (Stack)

    • 它是内存中的一块连续区域,专门用于管理函数调用。

    • 它遵循 "后进先出" 的原则,就像一摞盘子,你总是取最上面的那个。

    • 有一个 栈指针 (SP - Stack Pointer) 寄存器,始终指向栈的顶部。

    • 在x86架构中,栈的生长方向是 从高地址向低地址 扩展的。

  2. 栈帧 (Stack Frame)

    • 也叫活动记录,是栈中为单个函数调用所分配的一块内存区域。

    • 每一个函数调用都会在栈上创建一个属于自己的栈帧。

    • 当函数调用结束时,其对应的栈帧会被销毁。

    • 它包含了该函数执行所需的所有信息,如局部变量、参数、返回地址等。

二、主角寄存器

在深入过程之前,先认识两个关键寄存器(以x86-32位为例):

  • esp (Extended Stack Pointer)栈顶指针,始终指向系统栈的顶部。

  • ebp (Extended Base Pointer)栈底指针 ,又称帧指针,指向当前函数栈帧的底部。函数内部通过ebp的固定偏移来访问参数和局部变量。

可以把ebpesp想象成一个画框的上下边框,它们框出了当前函数栈帧的范围

三、调用过程

第一阶段:函数栈帧的创建(函数调用开始时)

假设我们有如下代码,main函数调用add函数。

cpp 复制代码
int add(int a, int b) {
    int result = a + b;
    return result;
}

int main() {
    int a = 10;
    int b = 20;
    int ret = add(a, b);
    return 0;
}

main函数执行到int ret = add(a, b);这一行时,栈帧的创建过程如下:

步骤 1:参数压栈 (从右向左)
  • 编译器通常以从右向左的顺序将函数参数压入栈中。

    1. 将参数b的值(20)压入栈。

    2. 将参数a的值(10)压入栈。

  • 此时,esp栈顶指针会向上(低地址方向)移动,指向最后压入的参数a

注意 :这一步有时也通过push指令完成,它会隐式地减少esp并存入数据。

步骤 2:调用函数,压入返回地址
  • 执行 call add 指令。这条指令做了两件事:

    1. 下一条指令的地址 (即call add后面那条指令的地址,这里是int ret = ...的地址)压入栈中。这个地址就是返回地址,函数执行完后要回到这里。

    2. 跳转到add函数的代码开始处执行。

步骤 3:进入新函数,保存旧栈帧
  • 现在,CPU开始执行add函数体的代码。

  • add函数首先要建立自己的"地盘"(栈帧)。

    1. push ebp : 将调用者(main函数)的ebp 值压栈保存。这是为了在add函数返回时,能恢复main函数的栈帧。

    2. mov ebp, esp : 让ebp指向新的栈顶。此时,ebpesp指向同一个位置,这就是add函数栈帧的底部

步骤 4:为新栈帧分配空间
  • sub esp, XXh : 将栈顶指针esp向上(低地址)移动一段距离(XX是16进制数)。这段新开辟的空间就是用于存放add函数的局部变量 等数据。比如这里的int result

至此,add函数的栈帧已经完全创建好了。此时栈的布局如下图所示:

高地址

...


| 参数 b (20) | <-- main函数的栈帧


| 参数 a (10) |


| main的返回地址 |


| 保存的main的ebp | <-- [ebp]指向这里 (add栈帧的底部)


| |

| add函数的局部变量 |

| (例如: result) | <-- [esp]指向这里 (add栈帧的顶部)

| |


...

低地址

第二阶段:函数体内的操作

在创建好的栈帧内,函数可以自由访问它的数据:

  • 访问参数 : 通过 [ebp + 8] 访问第一个参数a,通过 [ebp + 12] 访问第二个参数b

  • 访问局部变量 : 通过 [ebp - 4] 等方式访问局部变量result

函数体内部的运算(a + b)就在CPU的寄存器中进行,然后将结果存入局部变量result的位置。

第三阶段:函数栈帧的销毁(函数返回时)

add函数执行到return result;时,开始销毁自己的栈帧。

步骤 1:返回值处理
  • 通常,函数的返回值会存放在 eax 寄存器中。所以会执行 mov eax, [ebp - 4],将result的值放入eax
步骤 2:恢复栈指针和基址指针
  • mov esp, ebp : 将esp移回ebp的位置。这一步直接回收了为局部变量分配的所有栈空间esp现在指向保存的旧ebp

  • pop ebp : 将栈顶(esp指向的值)弹出到ebp寄存器中。这个值正是之前保存的main函数的ebp。现在,ebp就恢复指向了main函数的栈帧底部。同时,esp会自动下移(pop指令的效果),指向返回地址

步骤 3:返回调用者
  • 执行 ret 指令。这条指令会:

    1. 将栈顶的返回地址 弹出到指令指针寄存器eip中。

    2. CPU接着从eip指向的地址(即main函数中call add的下一条指令)开始执行。

步骤 4:清理栈上的参数
  • 此时,esp指向参数amain函数需要清理为add函数调用压入的参数。

  • 通常通过 add esp, 8 指令实现(因为两个int参数共8字节)。这条指令让esp向下移动,完全回到了调用add之前的位置。

至此,add函数的栈帧被完全销毁,仿佛从未存在过。栈的状态和main函数调用add之前一模一样。main函数可以继续执行后续代码,并从eax寄存器中取得add函数的返回值,赋给局部变量ret

四、总结与要点

阶段 关键操作 作用
创建 参数压栈(从右向左) 传递参数
call 指令压入返回地址 确保函数能正确返回
push ebp / mov ebp, esp 保存旧栈帧,建立新栈帧基线
sub esp, XX 为局部变量分配空间
销毁 mov eax, [ebp-4] 将返回值存入eax
mov esp, ebp 回收局部变量空间
pop ebp 恢复调用者的栈帧基线
ret 跳回调用处,继续执行
add esp, XX (在调用者中) 清理参数空间

五、理解栈帧的意义

  1. 实现函数调用/返回机制 :通过返回地址和保存的ebp,保证了函数能层层调用并正确返回。

  2. 隔离作用域:每个函数的局部变量都在自己的栈帧中,实现了变量的隔离,避免了命名冲突。

  3. 支持递归:递归的每一层调用都有自己的栈帧,互不干扰。

  4. 调试利器 :调试器就是通过分析栈帧链(每个保存的ebp都指向上一个栈帧的底部)来生成调用堆栈(Call Stack)信息的。

相关推荐
ALex_zry3 小时前
构建通用并发下载工具:用Golang重构wget脚本的实践分享
开发语言·重构·golang
努力努力再努力wz3 小时前
【Linux进阶系列】:信号(下)
java·linux·运维·服务器·开发语言·数据结构·c++
21号 13 小时前
21.事务和锁(重点)
开发语言·数据库
zzzsde4 小时前
【C++】stack和queue:使用&&OJ题&&模拟实现
开发语言·c++
TU^4 小时前
C语言习题~day27
c语言·数据结构·算法
m0_748233644 小时前
C++与Python:内存管理与指针的对比
java·c++·python
孤廖4 小时前
面试官问 Linux 编译调试?gcc 编译流程 + gdb 断点调试 + git 版本控制,连 Makefile 都标好了
linux·服务器·c++·人工智能·git·算法·github
终焉代码4 小时前
【Linux】进程初阶(1)——基本进程理解
linux·运维·服务器·c++·学习·1024程序员节
我想吃余4 小时前
Linux进程间通信:管道与System V IPC的全解析
linux·服务器·c++