参数传递(寄存器 vs 栈)
函数调用时,参数可以通过寄存器或栈来传递——寄存器快但数量有限,栈慢但容量大。不同的调用约定就是在这两者之间做权衡
函数的”输入”从哪里来?
你写 add(3, 5),3 和 5 这两个数据怎么到 add 函数手里的?
我们已经知道 栈帧 是函数调用的工作区,但参数到底走哪条路进函数——寄存器还是栈?这取决于 调用约定(Calling Convention)。
类比:快餐店取餐
- 寄存器传参(fastcall) = 你直接把饭卡递给窗口阿姨——快,但一次只能递一样东西(寄存器有限)
- 栈传参(cdecl/stdcall) = 你把点餐单写在纸条上,从窗口递进去——慢,但能写很多内容(栈空间大)
方式一:寄存器传参
原理
把参数直接放进寄存器,被调用者从寄存器中读取:
; 寄存器传参版本:add(3, 5)
MOV R0, #3 ; 第一个参数 → R0
MOV R1, #5 ; 第二个参数 → R1
CALL _add
; ...
_add:
; 此时 R0 = 3, R1 = 5
ADD R0, R1 ; R0 = 3 + 5 = 8
RET ; 返回值在 R0
优点
- 极快——访问寄存器比访问内存快几十倍
- 不需要清理栈——没有 PUSH/POP 的开销
缺点
- 数量有限——通用寄存器就那么多(x86-32 有 8 个,ARM 有 16 个)
- 调用者需要保存——如果调用者正在用某个寄存器传参,得先 PUSH 保存
典型例子:System V AMD64(Linux x86-64)
Linux 上的 64 位程序使用 System V AMD64 调用约定,前 6 个整数参数通过寄存器传递:
| 参数顺序 | 寄存器 |
|---|---|
| 第 1 个 | RDI |
| 第 2 个 | RSI |
| 第 3 个 | RDX |
| 第 4 个 | RCX |
| 第 5 个 | R8 |
| 第 6 个 | R9 |
| 第 7 个及以后 | 栈 |
; System V AMD64 调用 conventions
; func(a, b, c, d, e, f, g)
MOV RDI, #1 ; a → RDI
MOV RSI, #2 ; b → RSI
MOV RDX, #3 ; c → RDX
MOV RCX, #4 ; d → RCX
MOV R8, #5 ; e → R8
MOV R9, #6 ; f → R9
PUSH #7 ; g → 栈
CALL _func
; 调用者清理栈(如果有栈参数的话)
方式二:栈传参
原理
参数被按顺序压入栈,被调用者通过 BP + 偏移来读取:
; 栈传参版本:add(3, 5)
PUSH #5 ; 第二个参数先入栈(从右向左)
PUSH #3 ; 第一个参数后入栈
CALL _add
ADD SP, #8 ; 清理参数(调用者清理)
_add:
PUSH BP
MOV BP, SP ; BP = 当前栈帧底部
; [BP + 8] = 3(第一个参数)
; [BP + 12] = 5(第二个参数)
R1 = [BP + 8] ; R1 = 3
R2 = [BP + 12] ; R2 = 5
R0 = R1 + R2 ; R0 = 8
POP BP
RET
CALL _add 后的栈布局(假设每个参数 4 字节):
高地址
┌──────────────┐
│ 5 (第二个参数) │ [BP + 12]
├──────────────┤
│ 3 (第一个参数) │ [BP + 8]
├──────────────┤
│ 返回地址 │ [BP + 4]
├──────────────┤ ← BP
│ 旧 BP │ [BP + 0]
├──────────────┤ ← SP
│ 局部变量区域 │
└──────────────┘
低地址
🔑 为什么从右向左压入? — 因为这样支持可变参数函数(如
printf)。最左边的参数在固定的位置([BP + 8]),不管后面有多少个参数。被调用者知道第一个参数总是从 [BP + 8] 开始。
参数传递顺序:从右向左 vs 从左向右
从右向左(cdecl, stdcall)
; func(a, b, c) → 压入顺序:c → b → a
PUSH c ; 先压右边
PUSH b
PUSH a ; 最后压左边
CALL _func
为什么多数约定选择从右向左?
因为 C 语言的可变参数函数(variadic functions)需要这个顺序:
printf("%d %d", a, b);
printf 不知道调用者传了多少个参数,但它知道第一个参数(格式化字符串)一定在栈顶往下固定位置。从右向左压入保证了第一个参数(最左边的参数)总是在最顶上,读取方便。
从左向右(少数约定)
有些语言(如 Pascal)使用从左向右压入——不支持可变参数,但看起来更自然。
方式三:混合传参(最常用)
现代调用约定大多是寄存器 + 栈混合——前几个参数用寄存器,后面的用栈:
; System V AMD64: 前 6 个用寄存器,后面的用栈
; func(a, b, c, d, e, f, g, h)
MOV RDI, #1 ; a → 寄存器 ┐
MOV RSI, #2 ; b → 寄存器 │
MOV RDX, #3 ; c → 寄存器 │前 6 个走寄存器(快)
MOV RCX, #4 ; d → 寄存器 │
MOV R8, #5 ; e → 寄存器 │
MOV R9, #6 ; f → 寄存器 ┘
PUSH #8 ; h → 先压入 ┐
PUSH #7 ; g → 后压入 ┘后面的走栈(慢但无限)
CALL _func
为什么混合最好?
| 方式 | 速度 | 容量 | 适用场景 |
|---|---|---|---|
| 纯寄存器 | ⚡ 极快 | 📦 极少(~6 个) | 参数少的函数 |
| 纯栈 | 🐢 较慢 | 📦 无限 | 参数多的函数 |
| 混合 | ⚡ 快 | 📦 够用 | 最通用 |
💡 编译器会尽量把参数多的函数拆成多个参数少的函数,或者把结构体指针而不是结构体本身传过去——就是为了多用寄存器,少用栈。
返回值的传递
单个返回值
大多数调用约定用寄存器返回:
; 返回值通常放在 R0(或 RAX、EAX)
_add:
ADD R0, R1 ; R0 = R0 + R1
RET ; 返回值在 R0 中
; 调用者直接使用
CALL _add
; R0 就是返回值
MOV [result], R0 ; 存结果
多个返回值
如果函数需要返回多个值,有两种方式:
方式 1:用多个寄存器
; 返回两个值:R0 放商,R1 放余数
_div:
DIV R0, R1 ; R0 = R0 / R1
MOV R1, R0 ; R1 = 余数...(简化示例)
RET
方式 2:返回指针,写回内存
如果返回的数据太大,调用者会在栈上分配空间,传入指针:
; func(&result) — 传入一个指针,函数往里写数据
SUB SP, #64 ; 为结果分配 64 字节空间
MOV R0, SP ; R0 = 指向这块空间的指针
CALL _func
_func:
; R0 指向输出缓冲区
MOV [R0], #1 ; 写结果到缓冲区
MOV [R0 + 4], #2
RET
调用者保存 vs 被调用者保存 — 实战
假设主程序正在用 R1-R3,现在要调用一个函数:
_main:
MOV R1, #100 ; 主程序使用 R1
MOV R2, #200 ; 主程序使用 R2
MOV R3, #300 ; 主程序使用 R3
; 要调用 _func,但担心 R1-R3 被覆盖
; 方案 A:调用者保存
PUSH R1 ; 保护 R1
PUSH R2 ; 保护 R2
PUSH R3 ; 保护 R3
CALL _func
POP R3 ; 恢复 R3
POP R2 ; 恢复 R2
POP R1 ; 恢复 R1
; 方案 B:相信被调用者保存(如果 R1-R3 被定义为被调用者保存)
CALL _func ; 直接调用,_func 会自己保存和恢复 R1-R3
; R1-R3 的值仍然完好
实际情况中,调用约定会明确每个寄存器属于哪类:
| 分类 | 典型寄存器 | 角色 |
|---|---|---|
| 调用者保存 | R0-R3(临时) | 函数可以随意修改,调用者若需要就自己保护 |
| 被调用者保存 | R4-R11(长期) | 函数必须保护和恢复,调用者可以放心使用 |
; 被调用者的正确行为
_func:
PUSH R4 ; 保存 R4(被调用者保存)
PUSH R5 ; 保存 R5(被调用者保存)
; ... 使用 R4, R5 做计算 ...
POP R5 ; 恢复 R5
POP R4 ; 恢复 R4
RET
⚠️ 违反规则的后果:如果被调用者修改了被调用者保存的寄存器但没有恢复,调用者会在不知情的情况下得到错误数据——这是最难调试的 bug 之一。
实际例子:多种调用方式对比
int sum_four(int a, int b, int c, int d) {
return a + b + c + d;
}
cdecl 版本(纯栈)
; 调用 sum_four(1, 2, 3, 4)
PUSH #4 ; d
PUSH #3 ; c
PUSH #2 ; b
PUSH #1 ; a
CALL _sum_four
ADD SP, #16 ; 调用者清理 4 个参数 × 4 字节
_sum_four:
PUSH BP
MOV BP, SP
LOAD R0, [BP + 8] ; a
LOAD R1, [BP + 12] ; b
LOAD R2, [BP + 16] ; c
LOAD R3, [BP + 20] ; d
ADD R0, R1
ADD R0, R2
ADD R0, R3
POP BP
RET
fastcall 版本(混合)
; 调用 sum_four(1, 2, 3, 4)
MOV ECX, #1 ; a → ECX
MOV EDX, #2 ; b → EDX
PUSH #4 ; d → 栈(从右向左)
PUSH #3 ; c → 栈
CALL _sum_four
; fastcall 由被调用者清理栈参数
_sum_four:
PUSH BP
MOV BP, SP
; ECX = a, EDX = b
; [BP + 8] = c, [BP + 12] = d
MOV EAX, ECX ; EAX = a
ADD EAX, EDX ; EAX = a + b
ADD EAX, [BP + 8] ; EAX = a + b + c
ADD EAX, [BP + 12] ; EAX = a + b + c + d
POP BP
RET #8 ; ⚠️ 被调用者清理了 8 字节的栈参数
对比总结
| 方面 | cdecl | stdcall | fastcall |
|---|---|---|---|
| 参数传递 | 全栈 | 全栈 | 前 2 个寄存器,其余栈 |
| 栈清理 | 调用者 | 被调用者 | 被调用者 |
| 支持可变参数 | ✅ | ❌ | ❌ |
| 速度 | 🐢 | 🐢 | ⚡ |
| 代码体积 | 大(每处调用都要加清理代码) | 小(清理代码在函数里,只写一次) | 最小 |
常见错误
错误 1:调用约定不匹配
; 函数是用 cdecl 编译的,但调用者用了 stdcall 的方式
PUSH #3
PUSH #5
CALL _add
; ❌ 缺少 ADD SP, #8(cdecl 要求调用者清理)
; 或者更糟:_add 是 stdcall,自己 RET #8 清理了
; 然后调用者又 ADD SP, #8——重复清理!栈被破坏
⚠️ 这是链接时最常见的错误之一——不同的模块用了不同的调用约定。
错误 2:寄存器传参时忘了保护
MOV R0, #important_value ; 调用者正在用 R0
MOV R1, #5
CALL _func ; _func 可能把 R0 覆盖了!
; R0 不再是 important_value
错误 3:BP 未设置就访问参数
_func:
; ❌ 没设置 BP,直接访问 [SP + 偏移]
LOAD R0, [SP + 4] ; 但 SP 随时可能变化!
PUSH R1
; 现在 [SP + 4] 指向的是 R1,而不是参数了!
RET
小结
参数传递是函数调用中最核心的”接头暗号”——它定义了数据如何在函数之间交接:
| 传递方式 | 速度 | 容量 | 适用场景 |
|---|---|---|---|
| 寄存器传参 | ⚡ 极快 | ❌ 有限 | 少量参数(≤ 6 个) |
| 栈传参 | 🐢 较慢 | ✅ 无限 | 大量参数 / 可变参数 |
| 混合传参 | ⚡ 快 | ✅ 够用 | 现代主流方案 |
关键原则:
- 前几个参数优先用寄存器(更快)
- 后面的参数用栈(容量更大)
- 调用约定必须前后一致——调用者和被调用者遵守同一套规则
- 返回值通常放在 R0 或 EAX 中
为什么这很重要? 参数传递机制直接决定了函数调用的性能和灵活性。理解了寄存器 vs 栈的取舍,你就明白了为什么编译器在某些情况下生成看起来”绕远路”的代码——背后都是在遵循调用约定的规则。
接下来,你将看到栈帧和参数传递如何共同支撑一个更神奇的特性——函数调用自己:递归的汇编实现。