栈帧与函数调用约定
函数调用时,参数、返回地址、局部变量都存放在哪里?栈帧(Stack Frame)是管理函数调用的核心机制,而调用约定(Calling Convention)则是函数间"约法三章"的规则
“用完就还”的内存区域
你写了一个函数 A,它调用函数 B,B 又调用了 C……所有函数都正确返回了,数据一点没乱。这是怎么做到的?
关键在于一个特殊的内存区域——栈(Stack)。
类比:食堂的餐盘架
学校食堂里,餐盘架上的弹簧会把盘子托起来:
- 你放上一个盘子(push),弹簧压下一点
- 你取走最上面的盘子(pop),弹簧弹起一点
- 最后放上去的盘子总是最先被取走
这和函数调用一模一样:
- 调用函数时,数据被压入栈
- 函数返回时,数据被弹出栈
- 后调用的函数先返回(LIFO:Last In, First Out)
栈指针(Stack Pointer)
CPU 用一个专门的寄存器来管理栈——SP(Stack Pointer,栈指针),它始终指向栈顶(当前可读写的位置):
高地址
┌───────────┐
│ 旧的数据 │
├───────────┤
│ ... │
├───────────┤ ← SP 指向这里
│ 栈顶元素 │ (当前栈顶)
├───────────┤
│ 空 闲 区 │
└───────────┘
低地址
栈的生长方向:从高地址向低地址(向下生长)
💡 大多数架构(x86、ARM 等)的栈是向下生长的——push 让 SP 减小,pop 让 SP 增大。这看似反直觉,但只是设计惯例。
PUSH 和 POP
PUSH:压入栈
PUSH R1 ; 把 R1 的值压到栈顶
PUSH 干了三件事:
- SP = SP - 4(或者 8,取决于架构的字长)
- 在 SP 指向的新位置写入数据
- 原来的值不受影响
PUSH R1 之前: PUSH R1 之后:
高地址 高地址
┌──────────┐ ┌──────────┐
│ ... │ │ ... │
├──────────┤ ├──────────┤
│ │ ← SP │ R1 的值 │ ← SP(减了 4)
├──────────┤ ├──────────┤
│ 空闲区域 │ │ 空闲区域 │
└──────────┘ └──────────┘
低地址 低地址
POP:弹出栈
POP R2 ; 把栈顶的值取出来,放到 R2 中
POP 也干了三件事:
- 读取 SP 指向的值
- 存入 R2
- SP = SP + 4
🔑 POP 不会真的”擦除”内存中的数据——它只是移动 SP。原来的值还在那里,但被认为是”垃圾”,下次 PUSH 会直接覆盖它。
CALL 和 RET
CALL:调用函数
CALL 指令做了两件事:
- 把返回地址压入栈(当前指令的下一条地址)
- 跳转到目标函数
_main: ; 主程序
MOV R1, #42
CALL _func ; ① 压入返回地址 ② 跳转到 _func
_next: ; 这是返回地址 ← 函数执行完会回到这里
ADD R1, #1
_func: ; 函数开始
; 函数体...
RET ; 从栈顶弹出返回地址并跳回去
CALL 之前: CALL 之后(刚进入 _func):
┌──────────────┐
│ 返回地址 │ ← SP
│(_next 的地址)│
├──────────────┤
│ ... │
RET:返回
RET(Return)指令:
- 从栈顶弹出地址
- 跳转到那个地址
_func:
; 函数体...
RET ; pop 返回地址 → 跳回 _next
嵌套调用
_main:
CALL _A ; 压入 _main_ret1,跳到 _A
_main_ret1:
; ...
_A:
; 做点事...
CALL _B ; 压入 _A_ret,跳到 _B
_A_ret:
RET ; 弹出 _A_ret → 跳回 _A_ret
_B:
; 做点事...
RET ; 弹出 _main_ret1 → 跳回 _main_ret1
调用过程中的栈变化(A 调用 B):
主程序 → 调用 A → A → 调用 B → B → 返回 → A → 返回 → 主程序
栈状态:
┌─────────┐
┌─────┤ B 返回地址│
┌─────┤ A 返回地址│
┌─────┤ A 返回地址│
│ A 返回地址│
│ _main返回地址│
时间 ──────────────────────────────────────────────────────→
💡 这就是栈的威力——无论嵌套多深,只要每个函数正确 RET,控制流就会原路返回,不会迷路。
栈帧(Stack Frame)
每个函数在被调用时,会在栈上分配一块属于自己的区域——栈帧(Stack Frame/Activation Record)。
一个典型的栈帧包含:
高地址
┌──────────────────┐
│ 调用者的栈帧 │
├──────────────────┤
│ 参数 N │ ← 如果函数有多个参数,从右向左压入
│ 参数 2 │
│ 参数 1 │
├──────────────────┤
│ 返回地址 │ ← CALL 指令压入的
├──────────────────┤
│ 旧 BP(基址指针) │ ← 保存调用者的 BP
├──────────────────┤
│ 局部变量区域 │
│ 局部变量 1 │
│ 局部变量 2 │
├──────────────────┤ ← SP(当前栈顶)
│ 空闲区域 │
└──────────────────┘
低地址
基址指针(BP / Base Pointer)
BP 是一个专门用来”定位”栈帧的寄存器。它就像一个标记线——不管栈顶怎么变(push/pop 导致 SP 变化),BP 始终指向当前函数的栈帧底部:
_func:
; 函数序言(Prologue)
PUSH BP ; 保存老的 BP
MOV BP, SP ; BP = SP,固定当前栈帧的底部
SUB SP, #16 ; 为局部变量腾出 16 字节空间
; 现在可以通过 BP 来访问参数和局部变量了
; [BP + 8] = 第一个参数
; [BP + 12] = 第二个参数
; [BP - 4] = 第一个局部变量
; [BP - 8] = 第二个局部变量
; 函数体...
MOV [BP - 4], #10 ; 局部变量 1 = 10
MOV [BP - 8], #20 ; 局部变量 2 = 20
LOAD R1, [BP + 8] ; 读取第一个参数
ADD R1, [BP - 4] ; 加上局部变量
; 函数尾声(Epilogue)
MOV SP, BP ; 恢复 SP(释放局部变量空间)
POP BP ; 恢复老的 BP
RET ; 返回
🔑 函数序言(Prologue) 和 函数尾声(Epilogue) 是每个函数的”标准包装”——序言建立栈帧,尾声销毁栈帧。
为什么需要栈帧?
画一个实际例子,看看栈帧如何让嵌套函数井井有条:
int add(int a, int b) {
int result = a + b;
return result;
}
int main() {
int x = 3;
int y = 5;
int z = add(x, y);
return z;
}
对应的汇编执行过程(简化 x86):
_main:
; 序言
PUSH BP
MOV BP, SP
SUB SP, #12 ; 为 3 个局部变量(x, y, z)腾空间
; int x = 3;
MOV [BP - 4], #3 ; x = 3
; int y = 5;
MOV [BP - 8], #5 ; y = 5
; 准备参数并调用 add(x, y)
PUSH [BP - 4] ; push x
PUSH [BP - 8] ; push y
CALL _add
; 调用返回后,清理压入的参数
ADD SP, #8 ; 弹出两个参数(8 字节)
MOV [BP - 12], R0 ; z = 返回值
; 尾声
MOV SP, BP
POP BP
RET
add 被调用时的栈状态:
高地址
┌──────────────────────┐
│ main 的局部变量区域 │
│ [BP_main - 4] = x │
│ [BP_main - 8] = y │
│ [BP_main - 12] = z │
├──────────────────────┤
│ 参数: x │ ← 被 main 压入
├──────────────────────┤
│ 参数: y │ ← 被 main 压入
├──────────────────────┤
│ 返回地址 (回 main) │ ← CALL 指令压入
├──────────────────────┤
│ 旧 BP(main 的 BP) │ ← add 的序言压入
├──────────────────────┤ ← BP_add(add 的基址)
│ add 的局部变量 │
│ result │
├──────────────────────┤ ← SP_add
│ 空闲区域 │
└──────────────────────┘
低地址
不用 BP 行不行?
可以。但有了 BP,你就不需要关心 SP 来回变化了:
- 参数永远在
[BP + 偏移](正偏移,朝高地址) - 局部变量永远在
[BP - 偏移](负偏移,朝低地址)
即使你在函数中 PUSH 了 10 次(SP 变了 10 次),BP 仍然指着原来的位置,参数和局部变量的访问不受影响。
调用约定的核心规则
调用约定(Calling Convention)是什么?
不同的语言、编译器、操作系统之间,函数调用的”游戏规则”可能不一样。调用约定就是一套关于如何调用函数的书面规则——参数放哪?返回值放哪?谁来清理栈?
常见的调用约定
| 约定 | 参数传递 | 栈清理方 | 优点 | 典型环境 |
|---|---|---|---|---|
| cdecl | 栈(从右向左压入) | 调用者清理 | 支持可变参数 | C 语言(x86) |
| stdcall | 栈(从右向左压入) | 被调用者清理 | 代码体积小 | Windows API |
| fastcall | 前 2 个参数用寄存器,其余用栈 | 被调用者清理 | 速度快 | 编译器优化 |
| System V AMD64 | 前 6 个参数用寄存器(RDI, RSI, …) | 调用者清理 | 速度快 | Linux x86-64 |
cdecl(C Declaration)
; cdecl 调用 add(3, 5)
; 参数从右向左压入栈
; 调用者负责清理栈
PUSH #5 ; 先压入右边的参数(y=5)
PUSH #3 ; 再压入左边的参数(x=3)
CALL _add ; 调用 add
ADD SP, #8 ; ⚠️ 调用者清理栈(弹出 2 个参数 × 4 字节)
fastcall(寄存器传参)
; fastcall 调用 add(3, 5)
; 前 2 个参数用 ECX、EDX 传递
MOV ECX, #3 ; 第一个参数 → ECX
MOV EDX, #5 ; 第二个参数 → EDX
CALL _add ; 调用 add
; 不需要清理栈——参数没有压入栈!
💡 为什么 fastcall 更快? — 寄存器比内存快几十倍,而且不需要压栈出栈的指令。但寄存器数量有限,参数一多还是得上栈。
谁来保存寄存器?
调用者调用一个函数前,可能正在使用一些寄存器。被调用者也想用这些寄存器——冲突了!解决方案是约定一组规则:
调用者保存(Caller-Saved): 被调用者保存(Callee-Saved):
调用者自己保护现场 被调用者保证不破坏现场
例: 例:
_main: _func:
MOV R1, #100 ; ← 调用者正在用 PUSH R4 ; 保存
CALL _func ; 不敢保证 R1 还在 PUSH R5 ; 保存
; 可能需要重新加载 R1 ; 使用 R4, R5...
; 或者调用前自己 PUSH R1 ~ POP R5 ; 恢复
; 调用结束后 POP R1 POP R4 ; 恢复
RET
| 规则 | 含义 | 典型寄存器 |
|---|---|---|
| 调用者保存 | 如果你在乎某个寄存器的值,调用前自己压栈保护 | R0-R3(临时寄存器) |
| 被调用者保存 | 被调用者必须保证这些寄存器的值在返回后不变 | R4-R11(通用寄存器) |
类比:图书馆占座
- 调用者保存 = 你离开座位去书架找书,回来发现位子可能被别人占了——所以走之前把值钱东西收好(PUSH)
- 被调用者保存 = 你找别人帮忙看座位,对方说”你放心走,位子我一定帮你守住”——但举手之劳,对方需要自己搬个凳子坐在你位子上
自己动手:手写一个栈帧
让我们用汇编手动构建一个完整的函数调用。假设要实现:
int multiply_add(int a, int b, int c) {
return a * b + c;
}
按照 cdecl 约定:
_multiply_add:
; 序言
PUSH BP
MOV BP, SP ; BP = 栈帧底部
; [BP + 8] = a(返回地址占 4 字节 + 旧 BP 占 4 字节 = 8 字节偏移)
; [BP + 12] = b
; [BP + 16] = c
; 函数体
LOAD R0, [BP + 8] ; R0 = a
LOAD R1, [BP + 12] ; R1 = b
MUL R0, R1 ; R0 = a * b
LOAD R1, [BP + 16] ; R1 = c
ADD R0, R1 ; R0 = a * b + c
; 尾声
MOV SP, BP
POP BP
RET ; 返回值在 R0 中
_main:
; 调用 multiply_add(3, 4, 5) = 3 * 4 + 5 = 17
PUSH #5 ; c
PUSH #4 ; b
PUSH #3 ; a
CALL _multiply_add
ADD SP, #12 ; 清理 3 个参数
; R0 = 17(返回值)
常见错误
错误 1:序言尾声不匹配
_func:
PUSH BP
MOV BP, SP
; ...函数体...
POP BP
RET ; ✅ 正确
_bad_func:
PUSH BP
MOV BP, SP
; ...函数体...
RET ; ❌ 忘了 POP BP!栈帧泄漏!
⚠️ 每个 PUSH 必须对应一个 POP。否则栈会失衡,函数返回时可能跳到错误地址。
错误 2:栈清理错误(cdecl 下)
PUSH #3
PUSH #5
CALL _add
; ❌ 忘了 ADD SP, #8 清理参数
; 下一次 PUSH 就会在错误的位置操作
错误 3:访问超出栈帧
_func:
PUSH BP
MOV BP, SP
SUB SP, #8 ; 为局部变量分配 8 字节
MOV [BP + 100], #1 ; ❌ 试图在栈帧外写数据——可能覆盖别人的数据!
小结
栈帧是计算机科学中最重要的基础机制之一——它是函数调用能够正常嵌套、递归、返回的基石:
| 概念 | 作用 |
|---|---|
| 栈(Stack) | 后进先出的内存区域,向下生长 |
| SP(栈指针) | 指向当前栈顶 |
| PUSH / POP | 往栈上放/取数据 |
| CALL / RET | 调用和返回函数 |
| BP(基址指针) | 固定标记栈帧位置,便于访问参数和局部变量 |
| 栈帧(Stack Frame) | 每个函数在栈上分配的工作区 |
| 调用约定 | 函数间关于参数、返回、寄存器保存的”合同” |
为什么这很重要? 没有栈,函数就无法嵌套调用——整个程序只能平铺展开。有了栈,函数可以任意嵌套、递归,而每个函数的局部数据彼此隔离,不会互相干扰。
接下来,你将深入学习参数传递的具体细节——哪些参数放寄存器,哪些放栈上,以及不同的调用约定如何影响程序行为:参数传递(寄存器 vs 栈)。