高级 #assembly#calling-convention

参数传递(寄存器 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 字节的栈参数

对比总结

方面cdeclstdcallfastcall
参数传递全栈全栈前 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 栈的取舍,你就明白了为什么编译器在某些情况下生成看起来”绕远路”的代码——背后都是在遵循调用约定的规则。

接下来,你将看到栈帧和参数传递如何共同支撑一个更神奇的特性——函数调用自己:递归的汇编实现