目录
[栈帧生命周期完整指南:压栈、抬栈、平栈、调用约定与 x86/x64 差异](#栈帧生命周期完整指南:压栈、抬栈、平栈、调用约定与 x86/x64 差异)
[抬栈(Raising the Stack)](#抬栈(Raising the Stack))
[平栈(Flat Stack)](#平栈(Flat Stack))
[跳过参数区域" 的本质(对应 __stdcall 调用约定)](#跳过参数区域" 的本质(对应 __stdcall 调用约定))
["参数仍留在栈上" 的本质(对应 __cdecl 调用约定)](#"参数仍留在栈上" 的本质(对应 __cdecl 调用约定))

前言
在逆向分析、漏洞利用以及免杀对抗中,理解函数调用背后的栈行为至关重要。
无论是分析程序执行流程,还是编写稳定的Shellcode与Loader,栈帧的构建与销毁都是最基础却最容易被忽视的核心机制。
在x86架构下,不同调用约定决定了函数参数由谁负责清理,从而形成了两种典型模式:
一种是由被调用函数清理参数(如 __stdcall),另一种是由调用者清理参数(如 __cdecl)。
这两种模式在汇编层面分别体现为 ret n 与 ret + add esp, n 的差异,看似只是指令形式不同,实际上却直接影响:
- 栈帧生命周期的管理方式
- 可变参数函数的实现能力
- 以及逆向分析时对调用关系的判断
本文将从栈帧结构入手,结合调试器行为,系统梳理"压栈、构建、恢复、清理"这一完整生命周期,并深入分析平栈与抬栈在底层执行上的本质区别。
栈帧生命周期完整指南:抬栈、平栈、调用约定
栈是一种后进先出 (LIFO) 数据结构
-
用于管理函数调用中的临时数据(参数、局部变量、返回地址、寄存器状态)。
-
在 x86/x64 架构中,通过栈指针 (ESP/RSP) 和基指针 (EBP/RBP) 实现精确控制。
高地址
+-----------------------+
| |
| 调用者栈帧 |
| |
+-----------------------+ <-- EBP (调用者基指针)
| |
| 参数 N (最后压入) | <-- 参数从右向左压栈
+-----------------------+
| ... |
+-----------------------+
| 参数 1 (最先压入) |
+-----------------------+
| 返回地址 (Return Addr)| <-- CALL指令自动压入
+-----------------------+ <-- EBP (当前函数基指针)
| 旧 EBP 值 | <-- 保存调用者的 EBP
+-----------------------+
| 局部变量 |
+-----------------------+
| 保存的寄存器 (如EBX) |
+-----------------------+
| ... |
+-----------------------+ <-- ESP (当前栈顶指针)
| |
| |
+-----------------------+
低地址
核心操作有三种:
-
压栈 (Push):分配空间,ESP/RSP 减小。
-
抬栈 (Pop):恢复数据,ESP/RSP 增大(常指 pop 指令恢复上下文)。
-
平栈 (Stack Cleanup):批量调整 ESP/RSP 清理局部变量或参数(mov esp, ebp 或 add esp, N)。
这些操作共同构成栈帧生命周期:构建 → 使用 → 恢复上下文 → 清理。
抬栈(Raising the Stack)
-
抬栈是指**被调用函数(callee)**在返回时负责清理栈上的参数。
-
栈指针
ESP通过ret n指令(n为参数字节数)自动调整,不仅弹出返回地址,还跳过参数区域,恢复调用前的栈状态。; 被调用函数
my_function:
push ebp
mov ebp, esp
mov eax, [ebp + 8] ; 参数1
mov ebx, [ebp + 12] ; 参数2
mov esp, ebp
pop ebp
ret 8 ; 抬栈:清理 8 字节参数; 调用者
push 2
push 1
call my_function ; 无需额外清理初始: ESP → [空]
push 2: ESP → [2]
push 1: ESP → [1 | 2]
call: ESP → [返回地址 | 1 | 2]
push ebp: ESP → [旧EBP | 返回地址 | 1 | 2]
...函数体...
mov esp,ebp
pop ebp: ESP → [返回地址 | 1 | 2]
ret 8: ESP → [干净] ← 直接跳过8字节参数
平栈(Flat Stack)
-
平栈是指**调用者(caller)**在函数返回后负责清理栈上的参数。
-
被调用函数仅使用
ret指令返回,弹出返回地址,栈指针ESP不变,参数清理任务留给调用者。; 被调用函数
my_function:
push ebp
mov ebp, esp
mov eax, [ebp + 8]
mov ebx, [ebp + 12]
mov esp, ebp
pop ebp
ret ; 只返回; 调用者
push 2
push 1
call my_function
add esp, 8 ; 平栈:调用者清理call 后: ESP → [返回地址 | 1 | 2]
...函数体 (ret 后)...
ESP → [返回地址 | 1 | 2] ← 参数仍存在
add esp, 8: ESP → [干净]
为什么效果看似类似但本质不同?
抬栈:被调用者用 ret n 批量清理 + 返回(数据恢复+空间跳过)。
平栈:调用者用 add esp, n 纯空间清理(不读取数据)。抬栈针对固定参数场景更高效,平栈灵活支持变参。
栈结构构造过程
- 无论抬栈还是平栈,函数调用中的栈帧构造和析构遵循相似的流程,但在参数清理上有关键差异。
栈帧构造(Prologue)
push ebp ; 压栈:保存调用者基指针
mov ebp, esp ; 建立当前栈帧基址
sub esp, 12 ; 平栈/压栈:分配 12 字节局部变量空间
栈帧析构(Epilogue)
mov esp, ebp ; 平栈:撤销局部变量空间
pop ebp ; 抬栈:恢复调用者 EBP
ret / ret n ; 返回 + 可能清理参数
- 差异 :抬栈约定中
ret n负责参数;平栈约定中调用者后续add esp, n负责。
栈帧生命周期文本图
初始 (调用前): ESP=0xFFFFFFF0 [干净]
压栈参数+调用:
push 20 ESP=0xFFFFFFE8 [20]
push 42 ESP=0xFFFFFFE4 [42 | 20]
call B ESP=0xFFFFFFE0 [返回地址 | 42 | 20]
函数B序言(压栈):
push ebp ESP=0xFFFFFFDC [旧EBP | 返回地址 | 42 | 20]
mov ebp, esp
sub esp, 12 ESP=0xFFFFFFD0 [局部变量12B | 旧EBP | ...]
函数体:通过 [ebp+8]、[ebp-4] 访问
尾声:
mov esp, ebp ESP=0xFFFFFFDC [旧EBP | 返回地址 | 42 | 20] ← 平栈清理局部变量
pop ebp ESP=0xFFFFFFE0 [返回地址 | 42 | 20] ← 抬栈恢复EBP
ret ESP=0xFFFFFFE4 [42 | 20] ← 抬栈弹出返回地址
调用者平栈:
add esp, 8 ESP=0xFFFFFFEC [干净]
抬栈与平栈的关系
-
本质 :两种栈管理策略是对栈清理责任的划分,依赖于调用约定(如
cdecl、stdcall)。 -
选择依据:由应用程序二进制接口(ABI)和函数需求决定。
调试器中的行为分析
抬栈:
- 在
ret n执行后,ESP直接跳过参数区域,栈恢复干净。
跳过参数区域" 的本质(对应 __stdcall 调用约定)
-
在函数返回时执行
ret n指令后,栈指针ESP直接向前移动 n 字节(n 为参数的总大小)。 -
栈指针从返回地址的位置"跃迁"到参数区域上方的调用者数据部分,参数数据虽然仍留在内存中,但已脱离栈的管理范围(ESP 不再指向它们)。
ret 8 执行前:
ESP → [返回地址]
[参数2] (4字节)
[参数1] (4字节) ← 参数区域(共8字节)ret 8 执行后:
[返回地址] ← 已弹出到 EIP,作为返回地址
[参数2] ← 被"跳过",ESP 不再指向这里
[参数1] ← 被"跳过"
ESP → [调用者数据] ← 直接跳到参数区域上方,栈顶恢复为调用前的状态 -
为什么叫"跳过" :
-
ESP 指针没有经过参数区域 ,而是从返回地址位置直接跃迁到参数区域上方的调用者数据,参数数据仍在内存中,但逻辑上已从栈中"消失",不再属于当前栈帧的管理范围。
高地址
+-----------------------+
| 调用者局部变量 | ← ESP 位置(函数调用前)
+-----------------------+
| |
+-----------------------+
| 其他数据 |
+=======================+ ← 函数调用前栈状态
| 参数2 (4字节) | ← 压栈方向
+-----------------------+ ↓
| 参数1 (4字节) |
+-----------------------+
| CALL指令压入的 |
| 返回地址 | ← ESP 位置(函数内)
+-----------------------+
| 旧EBP |
+-----------------------+
| 局部变量 |
+-----------------------+
| ... |
+=======================+ ← 函数执行中栈状态
| 参数2 (4字节) |
+-----------------------+
| 参数1 (4字节) |
+-----------------------+
| 返回地址 (已弹出) | ← ret执行时的ESP起点
+-----------------------+
| 旧EBP |
+-----------------------+
| 局部变量 (已释放) |
+-----------------------+
| |
+=======================+ ← ret 8 执行后
| 调用者局部变量 | ← ESP 位置(ret 8 后)→ 直接跳过参数区域
+-----------------------+
| |
+-----------------------+
▲ 关键路径:
函数内:ESP 指向局部变量底部
ret 8 执行时:
步骤1:弹出返回地址 → ESP 移动到 [旧EBP]
步骤2:ESP += 8 → 直接跳到参数区域上方(箭头所示位置)
结果:栈顶回到函数调用前状态,参数区域逻辑上消失
-
平栈:
- 在
ret执行后,ESP仅移动到返回地址位置,参数仍留在栈上,需观察调用者的清理操作。
"参数仍留在栈上" 的本质(对应 __cdecl 调用约定)
-
函数返回时仅执行
ret指令(不指定参数大小),栈指针ESP在弹出返回地址后停在参数区域的顶部(即参数2的位置)。 -
参数数据仍然在栈指针覆盖的范围内,被视为有效栈数据,需要调用者后续手动清理。
ret 执行前:
ESP → [返回地址]
[参数2] (4字节)
[参数1] (4字节) ← 参数区域(共8字节)ret 执行后:
[返回地址] ← 已弹出到 EIP,作为返回地址
ESP → [参数2] ← 仍指向参数区域顶部!
[参数1] -
参数数据仍在栈指针(ESP)的覆盖范围内 ,属于当前栈帧的一部分。
-
虽然函数已返回,但参数数据未被清理,后续如果进行新的压栈操作(如
push),新数据会覆盖这些参数。 -
因此,参数仍被视为"有效栈数据",但需要调用者手动调整
ESP来清理(否则会造成栈不平衡)。高地址
+-----------------------+
| 调用者局部变量 | ← ESP 位置(函数调用前)
+-----------------------+
| |
+-----------------------+
| 其他数据 |
+=======================+ ← 函数调用前栈状态
| 参数2 (4字节) | ← 压栈方向
+-----------------------+ ↓
| 参数1 (4字节) |
+-----------------------+
| CALL指令压入的 |
| 返回地址 | ← ESP 位置(函数内)
+-----------------------+
| 旧EBP |
+-----------------------+
| 局部变量 |
+-----------------------+
| ... |
+=======================+ ← 函数执行中栈状态
| 参数2 (4字节) |
+-----------------------+
| 参数1 (4字节) | ← 参数仍留在栈上!
+-----------------------+
| 返回地址 (已弹出) | ← ret执行后的ESP位置
+-----------------------+
| 旧EBP |
+-----------------------+
| 局部变量 (已释放) |
+-----------------------+
| |
+=======================+ ← ret 执行后状态
| 调用者局部变量 |
+-----------------------+
| |
+-----------------------+
▲ 关键路径:
函数内:ESP 指向局部变量底部
ret 执行时:
仅弹出返回地址 → ESP 停在参数2位置add esp, 8 ; 手动将ESP移动到参数区域上方
-
对比
| 操作 | 抬栈模式 (__stdcall) | 平栈模式 (__cdecl) |
|---|---|---|
| 函数调用前 | ESP → [调用者数据] | ESP → [调用者数据] |
| [其他数据] | [其他数据] | |
| 压入参数后 | ESP → [参数1] | ESP → [参数1] |
| [参数2] | [参数2] | |
| CALL执行后 | ESP → [返回地址] | ESP → [返回地址] |
| 函数内执行时 | ESP → [局部变量底部] | ESP → [局部变量底部] |
| ret 指令执行时 | 1. 弹出返回地址 | 1. 弹出返回地址 |
| 2. ESP += 8 → 直接跳到调用者数据 | ESP 保持不变 → 停在参数2位置 | |
| 关键区别 | 参数区域被逻辑跳过 | 参数区域仍被栈包含 |
| 栈是否干净 | ✅ 立即恢复调用前状态 | ❌ 需调用者手动 add esp,8 |
为什么需要两种模式?
-
抬栈模式优势
- 适合固定参数函数(如 WinAPI)
- 被调用函数知道参数大小,可自行清理
- 减少调用者代码量(每个调用点无需重复清理指令)
-
平栈模式必要性
- 必须用于可变参数函数 (如
printf) - 被调用函数无法预知参数数量
- 只有调用者知道实际压栈了多少参数,必须自行清理
- 必须用于可变参数函数 (如
调试器实操验证(OllyDbg/x64dbg)
-
抬栈模式观察:
- 在
ret 8指令处下断点 - 执行前:ESP = 0012FFC0
- 执行后:ESP = 0012FFC8(直接 +8)
- 在
-
平栈模式观察:
- 在
ret指令处下断点 - 执行前:ESP = 0012FFC0
- 执行后:ESP = 0012FFC4(停在参数顶部)
- 继续单步:看到调用者执行
add esp,8
- 在
用快递比喻:
- 抬栈 = 快递员送货后直接带走包装箱(栈恢复干净)
- 平栈 = 快递员只放下包裹,包装箱留在你门口(需你自己处理)
- "跳过参数区域" = 快递员离开时完全无视包装箱(ESP指针不经过它)
- "参数仍留在栈上" = 包装箱还堆在你家门口(ESP仍指向它)