文章目录
- 一. 什么是栈帧?——类比草稿纸系统
- 二. 内存布局详解
- 栈的内存组织结构
- 关键寄存器的作用
- 三. 函数栈帧创建全过程
- 初始状态:main 函数栈帧
- 🔧 步骤1:参数压栈
- 🔧 步骤2:调用函数
- 🔧 步骤3:建立 add 函数栈帧
- 1. 保存调用者的 EBP
- 2. 建立新的栈帧基准
- 3. 为局部变量分配空间
- 🔧 步骤4:访问参数和局部变量
- 🔧 步骤5:函数返回与栈帧销毁
- 1. 准备返回值
- 2. 回收局部变量空间
- 3. 恢复调用者的 EBP
- 4. 返回调用者
- 🔧 步骤6:调用者清理参数
- 四. 高级栈帧特性
- 栈帧链式结构
- 栈保护机制
- 调用约定比较
- 较大对象的返回
- 五. 常见栈相关问题
- 栈空间耗尽
- 栈缓冲区溢出
- 未初始化的局部变量
- 返回已销毁栈帧中的地址
- 六. 专业总结
- 函数栈帧的核心机制
- 性能考虑
- 安全考虑
一. 什么是栈帧?——类比草稿纸系统
想象你正在做一道复杂的数学题:
- 先在草稿纸上写下题目要求,也就是函数参数
- 然后在草稿纸上计算中间步骤,也就是局部变量和临时数据
- 记录做完小题后应该回到的位置,也就是函数返回地址
- 最后把答案放到约定的位置,例如 EAX 寄存器
- 做完后把这张草稿纸收起来,也就是销毁函数栈帧
函数栈帧就像函数执行时使用的一张“草稿纸”,它为本次函数调用提供一块工作空间。
一个函数栈帧中通常包含:
- 函数参数或与参数传递有关的数据
- 非静态局部变量
- 编译器产生的临时数据
- 函数返回地址
- 保存的寄存器值
- 栈保护数据等编译器附加信息
对于较小的整数、指针等返回值,通常通过 EAX 等寄存器带回,而不一定直接存放在当前函数的栈帧中。
理解函数栈帧有助于回答以下问题:
- 局部变量是如何创建的?
- 为什么未初始化的局部变量可能出现不确定值?
- 函数参数是如何传递的?
- 参数的传递顺序是怎样的?
- 形参和实参之间是什么关系?
- 函数的返回值是如何带回调用者的?
- 递归调用为什么可能导致栈溢出?
二. 内存布局详解
栈的内存组织结构
下面是一个简化的进程虚拟地址空间示意图,实际布局会受到操作系统、编译器、链接方式和地址空间随机化等因素影响。
高地址 +-------------------------+ | 命令行参数和环境变量 | +-------------------------+ | 栈区 | | ↓ | ← 通常从高地址向低地址增长 | main 函数栈帧 | | ↓ | | 函数调用链 | | ↓ | | 当前函数栈帧 | ← ESP 指向当前栈顶 +-------------------------+ | 未映射或共享区域 | +-------------------------+ | 堆区 | | ↑ | ← 通常从低地址向高地址增长 +-------------------------+ | .bss 段 | ← 未显式初始化或初始化为 0 的全局变量 +-------------------------+ | .data 段 | ← 已初始化的全局变量 +-------------------------+ | .text 段 | ← 程序代码和机器指令 低地址需要注意:
- 栈向低地址增长是常见的 x86 平台实现方式
- 堆的增长方向和具体地址布局并不是 C 语言标准规定的
- 现代系统通常还会使用地址空间随机化,因此每次运行时的具体地址可能不同
关键寄存器的作用
- ESP(Stack Pointer):栈顶寄存器,保存当前栈顶地址
- EBP(Base Pointer):在保留栈帧指针时,用作当前栈帧的基准地址
- EIP(Instruction Pointer):指令指针寄存器,保存下一条将要执行的指令地址
- EAX(Accumulator):通用寄存器,经常用于保存临时数据和函数返回值
- EBX、ESI、EDI:通用寄存器,在某些调用约定下,被调用函数修改前需要先保存原值
在 64 位 x86 程序中,对应的寄存器通常为:
- ESP → RSP
- EBP → RBP
- EIP → RIP
- EAX → RAX
三. 函数栈帧创建全过程
下面使用如下代码进行说明:
#include<stdio.h>intadd(intx,inty){intc=x+y;returnc;}intmain(void){inta=5;intb=10;intret=0;ret=add(a,b);printf("%d\n",ret);return0;}以下栈帧结构是为了便于理解而给出的简化示意。局部变量的实际偏移、栈空间大小和寄存器保存方式由编译器决定。
初始状态:main 函数栈帧
在执行add函数之前,main函数已经建立了自己的栈帧。
高地址 +-------------------------+ | argc、argv | ← 程序启动参数 +-------------------------+ | main 的返回地址 | ← 返回 invoke_main 等调用者 +-------------------------+ | 保存的调用者 EBP | +-------------------------+ | 局部变量 a = 5 | ← 示例:[EBP - 4] +-------------------------+ | 局部变量 b = 10 | ← 示例:[EBP - 8] +-------------------------+ | 局部变量 ret = 0 | ← 示例:[EBP - 12] +-------------------------+ | ESP | ← 当前栈顶 +-------------------------+ 低地址这里的[EBP - 4]、[EBP - 8]等偏移只是简化示例,不能认为所有编译器都会采用完全相同的布局。
🔧 步骤1:参数压栈
C 代码:
ret=add(a,b);在本例使用的 32 位 cdecl 调用约定下,参数通常按照从右向左的顺序压栈:
push dword ptr [ebp-8] ; 先压入 b 的值 10,对应形参 y push dword ptr [ebp-4] ; 再压入 a 的值 5,对应形参 x栈状态变化如下:
高地址 +-------------------------+ | argc、argv | +-------------------------+ | main 的返回地址 | +-------------------------+ | 保存的调用者 EBP | +-------------------------+ | 局部变量 a = 5 | +-------------------------+ | 局部变量 b = 10 | +-------------------------+ | 局部变量 ret = 0 | +-------------------------+ | 参数 y = 10 | ← 先压入,对应 b +-------------------------+ | 参数 x = 5 | ← 后压入,ESP 指向这里 +-------------------------+ 低地址这里压入栈中的数值是实参a和b的副本。
因此,在值传递情况下:
x是a的副本y是b的副本- 修改
x和y不会直接修改a和b
比喻理解:在计算小题
add(a, b)之前,先把小题所需的两个数据写到新的草稿区域中。
🔧 步骤2:调用函数
call指令主要完成两个操作:
将call指令下一条指令的地址压入栈中,作为返回地址跳转到目标函数地址执行
call add其逻辑效果可以理解为:
push call 指令下一条指令的地址 跳转到 add 函数入口执行后的栈状态如下:
高地址 +-------------------------+ | argc、argv | +-------------------------+ | main 的返回地址 | +-------------------------+ | 保存的调用者 EBP | +-------------------------+ | 局部变量 a = 5 | +-------------------------+ | 局部变量 b = 10 | +-------------------------+ | 局部变量 ret = 0 | +-------------------------+ | 参数 y = 10 | +-------------------------+ | 参数 x = 5 | +-------------------------+ | add 的返回地址 | ← call 压入,ESP 指向这里 +-------------------------+ 低地址返回地址并不是
call指令本身的地址,而是call指令执行完以后,程序应该继续执行的下一条指令地址。
🔧 步骤3:建立 add 函数栈帧
一个典型的函数序言如下:
add: push ebp ; 保存 main 函数的 EBP mov ebp, esp ; 建立 add 函数的栈帧基准 sub esp, 4 ; 为局部变量 c 分配空间具体过程如下。
1. 保存调用者的 EBP
push ebp将main函数当前的 EBP 压入栈中,以便add函数返回时恢复。
2. 建立新的栈帧基准
mov ebp, esp此时 EBP 和 ESP 都指向刚刚保存的main函数 EBP。
3. 为局部变量分配空间
sub esp, 4ESP 向低地址移动 4 字节,为局部变量c预留空间。
建立后的栈帧布局如下:
高地址 +-------------------------+ | 参数 y = 10 | ← [EBP + 12] +-------------------------+ | 参数 x = 5 | ← [EBP + 8] +-------------------------+ | add 的返回地址 | ← [EBP + 4] +-------------------------+ | 保存的 main EBP | ← EBP 指向这里 +-------------------------+ | 局部变量 c | ← [EBP - 4] +-------------------------+ | ESP | ← 当前栈顶 +-------------------------+ 低地址比喻理解:
add函数拿到了一张新的草稿纸,先记录上一张草稿纸的位置,然后建立自己的参考线,并为中间计算结果留出空间。
🔧 步骤4:访问参数和局部变量
C 代码:
intc=x+y;对应的简化汇编代码为:
mov eax, [ebp+8] ; 读取参数 x add eax, [ebp+12] ; 加上参数 y mov [ebp-4], eax ; 将结果存入局部变量 c通过 EBP 进行偏移寻址:
高地址 +-------------------------+ | 参数 y = 10 | ← EBP + 12 +-------------------------+ | 参数 x = 5 | ← EBP + 8 +-------------------------+ | add 的返回地址 | ← EBP + 4 +-------------------------+ 基址 → | 保存的 main EBP | ← EBP +-------------------------+ | 局部变量 c = 15 | ← EBP - 4 +-------------------------+ 栈顶 → | ESP | +-------------------------+ 低地址可以看到:
- 正偏移通常用于访问参数和返回地址
- 负偏移通常用于访问局部变量
- 具体偏移量由栈帧布局和编译器生成的代码决定
🔧 步骤5:函数返回与栈帧销毁
add函数的简化返回代码如下:
mov eax, [ebp-4] ; 将返回值放入 EAX mov esp, ebp ; 回收局部变量空间 pop ebp ; 恢复 main 函数的 EBP ret ; 弹出返回地址并跳转如果函数序言中保存了 EBX、ESI、EDI 等寄存器,那么在销毁栈帧之前,还需要先恢复它们:
pop edi pop esi pop ebx1. 准备返回值
mov eax, [ebp-4]将局部变量c的值 15 放入 EAX。
按照本例的调用约定,EAX 用来带回整数类型的函数返回值。
2. 回收局部变量空间
mov esp, ebp执行前:
+-------------------------+ | 参数 y = 10 | +-------------------------+ | 参数 x = 5 | +-------------------------+ | add 的返回地址 | +-------------------------+ | 保存的 main EBP | ← EBP +-------------------------+ | 局部变量 c = 15 | ← ESP +-------------------------+执行后:
+-------------------------+ | 参数 y = 10 | +-------------------------+ | 参数 x = 5 | +-------------------------+ | add 的返回地址 | +-------------------------+ | 保存的 main EBP | ← ESP 和 EBP +-------------------------+ESP 重新指向保存的main函数 EBP,相当于丢弃了add函数的局部变量空间。
3. 恢复调用者的 EBP
pop ebp该指令完成:
- 将栈顶保存的
main函数 EBP 放回 EBP 寄存器 - ESP 增加 4 字节
执行后:
+-------------------------+ | 参数 y = 10 | +-------------------------+ | 参数 x = 5 | +-------------------------+ | add 的返回地址 | ← ESP +-------------------------+此时 EBP 已经恢复为main函数的栈帧基址。
4. 返回调用者
retret指令会:
- 从栈顶取出返回地址
- ESP 增加 4 字节
- 将返回地址写入 EIP
- 从该地址继续执行
执行后:
+-------------------------+ | 参数 y = 10 | +-------------------------+ | 参数 x = 5 | ← ESP +-------------------------+程序回到main函数中call add后面的下一条指令。
比喻理解:
add函数做完小题,把答案放到约定的位置,然后收起自己的草稿纸,回到main函数的草稿纸继续执行。
🔧 步骤6:调用者清理参数
在 cdecl 调用约定下,参数由调用者负责清理:
add esp, 8 ; 清理两个 int 参数占用的 8 字节空间 mov [ebp-12], eax ; 将返回值保存到 retadd esp, 8并不是把栈中原有的字节立即清零,而是让 ESP 向高地址移动 8 字节,使这部分空间不再属于当前有效栈内容。
最终栈状态如下:
高地址 +-------------------------+ | argc、argv | +-------------------------+ | main 的返回地址 | +-------------------------+ | 保存的调用者 EBP | +-------------------------+ | 局部变量 a = 5 | +-------------------------+ | 局部变量 b = 10 | +-------------------------+ | 局部变量 ret = 15 | ← 从 EAX 中取得返回值 +-------------------------+ | ESP | ← 恢复到调用 add 前的位置 +-------------------------+ 低地址至此,add函数的一次完整调用结束。
四. 高级栈帧特性
栈帧链式结构
在编译器保留 EBP 作为栈帧指针时,每个函数通常会在栈中保存调用者的 EBP,从而形成一条栈帧链:
当前栈帧 ↓ 保存的调用者 EBP ↓ 上一个栈帧 ↓ 保存的上上个调用者 EBP ↓ ……调试器可以利用这条链恢复函数调用关系和调用栈信息。
但是,栈帧链并不是始终存在:
- 编译器可能省略栈帧指针
- 开启优化后,EBP 可能被当作普通寄存器使用
- 函数可能被内联,不再拥有独立的调用栈帧
- 尾调用优化可能改变正常的调用栈结构
下面的代码只能作为 32 位 GCC 环境下理解 EBP 链的教学示例:
#include<stdio.h>voidprint_stack_trace(void){void**frame;__asm__volatile("movl %%ebp, %0":"=r"(frame));while(frame!=NULL){void*return_address=frame[1];printf("Return address: %p\n",return_address);frame=(void**)frame[0];}}该代码存在以下限制:
- 需要保留 EBP 栈帧指针
- 不适用于多数 64 位环境
- 直接读取栈帧链可能访问无效内存
- 实际项目应使用调试器或专门的栈展开库
栈保护机制
为了检测栈缓冲区溢出,编译器可能在局部变量与返回地址之间放置一个特殊值,称为:栈金丝雀(Stack Canary)
以下是一个平台相关的简化示例:
sub esp, 18h mov eax, dword ptr gs:[14h] mov [ebp-0Ch], eax ; 保存金丝雀值 ; 函数体代码 mov ecx, [ebp-0Ch] xor ecx, dword ptr gs:[14h] jne stack_corrupted ; 金丝雀被破坏,说明可能发生越界写入其基本思想是:
- 函数进入时保存一个难以预测的值
函数返回前再次检查该值- 如果数值发生变化,说明局部缓冲区可能越界覆盖了控制信息
- 程序终止,而不是继续使用可能已被篡改的返回地址
金丝雀的位置、获取方式和检查指令会因操作系统、编译器和体系结构而不同。
调用约定比较
下面的表格主要针对 Microsoft 32 位 x86 环境:
| 特性 | cdecl | stdcall | fastcall |
|---|---|---|---|
| 参数传递 | 通常通过栈传递 | 通常通过栈传递 | 前两个适合的参数通常通过 ECX、EDX,其他参数通过栈 |
| 参数压栈顺序 | 通常从右向左 | 通常从右向左 | 剩余栈参数通常从右向左 |
| 参数清理 | 调用者清理 | 被调用者清理 | 通常由被调用者清理 |
| 是否支持可变参数 | 支持 | 通常不支持 | 通常不支持 |
| 常见名称修饰 | _func | _func@8 | @func@8 |
例如,cdecl 调用完成后可能出现:
call add add esp, 8而 stdcall 中,被调用函数可能使用:
ret 8ret 8不仅弹出返回地址,还会额外移动 ESP,从而清理由参数占用的 8 字节栈空间。
在 64 位环境中,调用约定和名称修饰规则通常与上述 32 位形式不同,不能直接套用该表。
较大对象的返回
对于int、指针等较小的返回值,通常可以直接通过 EAX 等寄存器返回。
如果返回对象较大,寄存器空间不足,编译器可能采用以下方式:
- 调用者在自己的栈帧或其他位置预留返回对象空间
- 调用者把该空间的地址作为隐藏参数传给被调用函数
- 被调用函数通过该地址直接构造或写入返回对象
- 函数返回后,调用者从预留空间中获得结果
因此,“函数返回值一定放在 EAX 中”只适用于部分类型和部分调用约定。
五. 常见栈相关问题
栈空间耗尽
真正意义上的栈溢出通常是指程序使用的栈空间超过系统为线程分配的栈容量。
例如,无终止条件的递归:
voidstack_overflow(void){intdata[1024];data[0]=0;stack_overflow();}每调用一次stack_overflow,都需要创建新的栈帧。
递归层数不断增加后,栈空间最终会被耗尽,程序可能发生异常或崩溃。
即使递归存在终止条件,如果递归深度过大,也可能发生栈空间耗尽。
栈缓冲区溢出
下面的问题不是“栈空间耗尽”,而是栈缓冲区越界写入:
#include<string.h>voidbuffer_overflow(void){charbuffer[8];strcpy(buffer,"very_long_string");}字符串长度超过buffer的容量后,strcpy会继续向后写入数据,可能覆盖:
- 其他局部变量
- 栈金丝雀
- 保存的寄存器
- 保存的 EBP
- 函数返回地址
这会产生未定义行为,并可能导致程序崩溃或安全漏洞。
应根据目标缓冲区大小限制输入长度,例如:
#include<stdio.h>voidsafe_copy(constchar*source){charbuffer[8];snprintf(buffer,sizeof(buffer),"%s",source);}即使使用所谓的“安全函数”,也仍然需要正确传递缓冲区大小并检查返回值。
未初始化的局部变量
#include<stdio.h>intmain(void){intvalue;printf("%d\n",value);return0;}value是未初始化的自动局部变量。
读取它的值会产生未定义行为,不能认为它一定是:
- 0
- 随机数
0xCCCCCCCC- 上一次函数调用留下的数据
在某些 VS 调试环境中,编译器会将未初始化的栈空间填充为0xCC。
例如:
#include<stdio.h>intmain(void){chararr[20];printf("%s\n",arr);return0;}由于arr:
- 没有被初始化
- 不一定包含字符串结束符
'\0' - 被
%s当作字符串读取
程序会产生未定义行为。
在部分中文 Windows 编码环境中,连续的0xCC 0xCC可能被显示为“烫”,因此调试时可能看到一串“烫烫烫”。
这只是特定编译器、调试模式和字符编码共同作用的结果,不能把“未初始化变量一定是烫烫烫”当作通用规律。
返回已销毁栈帧中的地址
int*dangerous_return(void){intlocal=42;return&local;}local是dangerous_return函数栈帧中的局部变量。
函数返回后:
- 该栈帧被销毁
local的生命周期结束- 返回的指针成为悬空指针
继续解引用该指针属于未定义行为:
int*pointer=dangerous_return();printf("%d\n",*pointer);// 未定义行为虽然原来的内存内容可能暂时仍然是 42,但这块空间随时可能被后续函数调用覆盖,因此不能继续使用。
正确方式之一是由调用者提供存储空间:
voidsafe_result(int*result){if(result!=NULL){*result=42;}}也可以直接按值返回:
intsafe_return(void){intlocal=42;returnlocal;}这里返回的是local的数值,而不是local在栈中的地址。
六. 专业总结
函数栈帧的核心机制
工作空间
每次函数调用通常都会获得相应的运行时空间,用于保存局部变量、临时数据和上下文信息。
参数传递
参数可能通过栈、寄存器或二者结合的方式传递,具体由调用约定决定。
返回地址保存
call指令会保存函数执行结束后需要返回的位置,ret指令根据该地址返回调用者。返回值传递
较小的返回值通常通过寄存器带回,较大的对象可能通过调用者预留空间和隐藏指针返回。
递归支持
同一个函数的每一次递归调用都有独立的调用状态,因此不同递归层之间可以拥有各自的参数和局部变量。
调试支持
在保留栈帧信息的情况下,调试器可以根据返回地址、栈帧指针和调试信息恢复函数调用链。
生命周期管理
自动局部变量通常随着函数进入而创建,随着函数返回而结束生命周期。
栈展开
在支持异常处理的语言和运行时中,异常处理机制可能根据栈展开信息依次销毁或退出函数调用层级。C 语言本身没有内置的异常处理机制。
性能考虑
- 栈空间分配通常只需要调整栈指针,因此速度较快
- 栈空间容量有限,具体大小由操作系统、线程配置和运行环境决定
- 不应在栈上创建过大的数组或对象
- 递归深度过大可能耗尽栈空间
- 开启编译优化后,部分局部变量可能只存在于寄存器中
- 被内联的函数可能不会创建独立栈帧
安全考虑
- 不要读取未初始化的局部变量
- 不要返回局部变量的地址
- 对数组和字符串操作进行严格的边界检查
- 根据目标缓冲区大小限制写入长度
- 启用编译器提供的栈保护机制
- 对来自外部的长度、索引和指针进行验证
- 避免无限递归和无法控制的递归深度
理解函数栈帧机制,是深入学习 C 语言、汇编语言、函数调用、调试、内存管理和程序安全的重要基础。
通过函数栈帧可以更加清楚地理解:
实参准备 ↓ 参数传递 ↓ call 保存返回地址 ↓ 创建被调用函数栈帧 ↓ 执行函数体 ↓ 返回值放入约定位置 ↓ 销毁被调用函数栈帧 ↓ ret 返回调用者 ↓ 调用者清理参数并接收返回值