11. 调用约定
汇编指令定义了 CPU 能执行什么操作,但没有定义函数之间如何通信——参数放在哪个寄存器?返回值通过谁传递?哪些寄存器子函数可以随意修改?这些规则由 ABI(Application Binary Interface)规定。本章详述 RISC-V 的函数调用约定,这是让汇编代码与 C/Rust/任何编译语言互操作的基础。
RISC-V ABI 规范
ABI 是函数间传递信息、管理寄存器的标准化协议。没有 ABI,A 编译器产出的 .o 文件无法链接 B 编译器产出的库——它们在"如何沟通"上存在根本分歧。
RISC-V ABI 由 RISC-V psABI 规范 定义,它涵盖:
- 寄存器用途分类:哪些寄存器是 caller-saved,哪些是 callee-saved
- 参数传递约定:整数/浮点参数分别通过哪些寄存器传递,超出寄存器数量后如何用栈补传
- 返回值约定:返回值放哪个寄存器,大结构体如何处理
- 栈帧布局:栈帧中 ra、fp、局部变量、传出参数区的布局规则
- 对齐要求:sp 16 字节对齐,结构体对齐,变长参数栈传递规则
两组核心寄存器分类
RISC-V 的 32 个通用寄存器被 ABI 划分为两个基本阵营:
Caller-saved(调用者保存)寄存器:t0-t6, a0-a7, ra。子函数可以随意修改这些寄存器的值,调用者(caller)如果需要保留原值,必须在调用前自己压栈保存。
Callee-saved(被调用者保存)寄存器:s0-s11, sp。子函数必须保证这些寄存器在函数返回时的值与入口时相同——如果需要使用,必须在 prologue 中保存到栈、epilogue 中恢复。
这个分类命名是汇编初学者的高频困惑来源。caller-saved 的准确含义不是"调用者必须保存",而是"调用者不该期待值被保留"——如果你想保留,你自己保存。同理,callee-saved 不是"被调用者以外的代码不用保存",而是"被调用者若使用,必须在返回前恢复"。
寄存器用途总表
| 寄存器 | ABI 名称 | 分类 | 用途 |
|---|---|---|---|
| x0 | zero | — | 硬连线零 |
| x1 | ra | caller-saved | 返回地址 |
| x2 | sp | callee-saved | 栈指针 |
| x3 | gp | — | 全局指针(静态链接用) |
| x4 | tp | — | 线程指针(线程局部存储) |
| x5-x7 | t0-t2 | caller-saved | 临时寄存器 |
| x8 | s0/fp | callee-saved | 帧指针 / 被保存寄存器 0 |
| x9 | s1 | callee-saved | 被保存寄存器 1 |
| x10-x11 | a0-a1 | caller-saved | 函数参数 0-1 / 返回值 |
| x12-x17 | a2-a7 | caller-saved | 函数参数 2-7 |
| x18-x27 | s2-s11 | callee-saved | 被保存寄存器 2-11 |
| x28-x31 | t3-t6 | caller-saved | 临时寄存器 3-6 |
参数传递
整数与指针参数
前 8 个整数参数依次通过 a0-a7 传递。注意寄存器名称的双重身份——a0 既是"参数 0"又是"返回值",a1 既是"参数 1"又是"第二返回分量(128 位时)"。
# 调用一个三参数函数 func(a, b, c)
li a0, 10 # 第一参数
li a1, 20 # 第二参数
li a2, 30 # 第三参数
call func # a0-a2 携带参数,func 从它们读取当参数类型小于 64 位(如 int 是 32 位,char 是 8 位),参数仍占一个完整的寄存器——a0 的低 32 位存 int 值,高 32 位无定义。被调用者根据需要执行截断或符号扩展。
超出 8 个参数
第 9+ 个参数通过栈传递,由调用者分配在栈帧的"传出参数区":
# 调用十参数函数 func(a0, ..., a9)
# a0-a7: 前 8 个参数 → 寄存器传递
# a8, a9: 第 9、10 参数 → 栈传递
# 调用者压入栈参数(在 call 前)
addi sp, sp, -16 # 分配传出参数区(16 字节,两个 64 位值)
li t0, 80
sd t0, 8(sp) # 第 10 参数放在 sp+8
li t0, 90
sd t0, 0(sp) # 第 9 参数放在 sp+0
# 设置寄存器参数
li a0, 10 # 参数 1
li a1, 20 # 参数 2
# ... a2-a7 同理
li a7, 70 # 参数 8
call func # 栈参数通过 sp 正偏移访问
addi sp, sp, 16 # 调用后释放传出参数区被调用者如何读取栈上的参数?从自己的栈帧视角,使用相对于 sp(或 fp)的正偏移:
func:
# 第 9 参数(调用者放在 0(sp) 的)现在位于 0(sp_after_call)
# 但由于被调用者可能分配了自己的栈帧,实际偏移需计算
ld t0, 16(sp) # 示例:读取栈传入的第 9 参数
# ... 实际偏移取决于被调用者的栈帧大小浮点参数
浮点参数通过 fa0-fa7 传递(需 F/D 扩展支持)。整数和浮点寄存器独立计数——func(int, double, int) 中 a0→第一个 int,fa0→double,a1→第二个 int。详见第 16 章(浮点指令)。
大型结构体
大于 2 个 XLEN(即 >16 字节在 RV64 下)的结构体不是按值放在寄存器中传递,而是调用者在栈上分配一份副本,然后传指针给被调用者。被调用者通过指针读写结构体内容。这是 C 语言 ABI 层面对"值传递大型结构体"性能优化的结果——防止大量数据无意义地在寄存器间搬来搬去。
小于等于 16 字节的结构体在可能时通过 a0-a1(或 fa0-fa1)的寄存器对直接传递。
返回值
返回值遵循与参数对称但更简单的约定:
| 返回类型 | 位置 | 说明 |
|---|---|---|
| 整数 ≤ 64 位 | a0 | 被调用者写入 a0 |
| 整数 65-128 位 | a0 + a1 | 低位在 a0,高位在 a1 |
| 浮点 | fa0 | 需 F/D 扩展 |
| 大结构体(>16 字节) | 调用者分配,a0 传指针 | 被调用者通过指针写入 |
| void(无返回值) | a0/a1 内容无定义 | 调用者不得依赖其值 |
# 返回 64 位整数的函数
get_answer:
li a0, 42
ret # 调用者从 a0 读取 42
# 返回 128 位整数的函数
get_u128:
li a0, 0xFFFFFFFFFFFFFFFF # 低 64 位
li a1, 0x0000000000000001 # 高 64 位
ret # 调用者从 a0:a1 读取结果注意:函数可以无返回值但仍修改 a0——这是合法的。但调用者若期望无返回值,不得依赖 a0 的值。如果你想安全地"不污染 a0",在函数末尾给 a0 赋一个明确的值即可。
函数框架:Prologue 与 Epilogue
任何函数都可以分解为三段式结构:
函数入口 → Prologue(保存上下文,分配栈帧)
→ Body(实际逻辑)
→ Epilogue(恢复上下文,释放栈帧,返回)最小函数(Leaf 函数)
Leaf 函数是不调用任何其他函数的函数。它们通常不需要保存 ra(因为没有 call 会覆盖 ra),可能完全不需要栈帧:
# Leaf 函数:将两个参数相加返回
# 不需要任何栈操作
add_two:
add a0, a0, a1 # a0 = a0 + a1
ret # 返回——ra 未被修改,无需保存Leaf 函数如果只需要修改 a0-a7 和 t0-t6 寄存器,可以完全省略 prologue/epilogue——它们对调用者透明。
标准非 Leaf 函数
一旦函数内部有 call,ra 就会被覆盖——必须保存 ra:
# 非 Leaf 函数:调用另一个函数
outer_func:
# === Prologue ===
addi sp, sp, -16 # 分配栈帧(16 字节,含填充以对齐)
sd ra, 8(sp) # 保存返回地址
# === Body ===
call inner_func # 调用子函数——ra 被覆盖为 inner_func 的返回点
# === Epilogue ===
ld ra, 8(sp) # 恢复返回地址
addi sp, sp, 16 # 释放栈帧
ret # 返回到 outer_func 的调用者使用 Callee-saved 寄存器的函数
如果函数需要使用 s0-s11 中的寄存器,必须在 prologue 保存它们,在 epilogue 恢复:
# 使用 s0 和 s1 的函数
complex_func:
# === Prologue ===
addi sp, sp, -32 # 分配 32 字节(ra + s0 + s1 + 8 字节对齐填充)
sd ra, 24(sp)
sd s0, 16(sp)
sd s1, 8(sp)
# === Body ===
li s0, 0 # 用 s0 做循环计数器
li s1, 100 # 用 s1 做上限
loop:
addi s0, s0, 1
call helper_func # helper_func 可能修改 t0-t6 和 a0-a7
blt s0, s1, loop # 但 s0 和 s1 安全——helper_func 必须保留它们
mv a0, s0
# === Epilogue ===
ld ra, 24(sp)
ld s0, 16(sp)
ld s1, 8(sp)
addi sp, sp, 32
ret这是 callee-saved 寄存器设计的核心价值:在函数内需要保留跨调用的值时(循环计数器、累计值等),使用 s 寄存器无需每次调用前后手动压栈——只在函数入口/出口各做一次。
栈帧结构
栈帧是被调用者使用的栈区域。完整的栈帧从高地址到低地址包括:
高地址 ┌──────────────────────────┐
│ 调用者的栈帧 │
├──────────────────────────┤
│ 栈传入参数(第 9+ 个) │ ← 调用者在 call 前压入,被调用者通过 sp+正偏移 读取
│ 返回地址 (ra) │
├──────────────────────────┤
│ 帧指针 (fp/s0) 备份 │ ← 被调用者保存的旧 fp 值
│ 被保存的寄存器 (s0-s11) │ ← 被调用者在 prologue 压入
├──────────────────────────┤
│ 局部变量 / 数组 │ ← 被调用者的局部数据
├──────────────────────────┤
│ 传出参数区 │ ← 被调用者准备调用下一级函数时使用
低地址 ├──────────────────────────┤ ← 当前 sp帧指针 fp (s0)
fp (x8) 是帧指针(Frame Pointer),它是一个可选的 callee-saved 寄存器,指向当前栈帧的固定位置(通常是保存的 fp 备份处)。启用 fp 后,局部变量和参数的访问通过 fp 的固定偏移完成,而非 sp(sp 可能在函数执行过程动态变化,如 alloca 动态栈分配)。
func_with_fp:
# Prologue(标准 fp 链式设置)
addi sp, sp, -32
sd ra, 24(sp)
sd fp, 16(sp) # 保存调用者的 fp
addi fp, sp, 24 # fp 指向栈帧中的固定锚点
# Body:通过 fp 访问参数和局部变量
ld t0, 8(fp) # 假设调用者通过栈传入的参数
sd t0, -8(fp) # 局部变量 1
# ... fp 不随 sp 变化而改变(如动态 alloca) ...
# Epilogue
ld ra, 24(sp)
ld fp, 16(sp) # 恢复调用者的 fp 链
addi sp, sp, 32
ret启用 fp 的优势在于调试:调试器可以沿 fp 链回溯调用栈(stack unwinding)——每个 fp 指向上一帧的 fp 存放位置。现代编译器在 -fomit-frame-pointer 优化下常省略 fp,改用 .eh_frame 段的 DWARF 元数据做回溯。
Leaf 函数优化
Leaf 函数不调用外部函数 → ra 不会被覆盖 → 无需保存 ra。如果 Leaf 函数也不使用 s 寄存器,完全不需要栈帧:
# 零栈帧 Leaf 函数
square:
mul a0, a0, a0 # a0 = a0 * a0(需 M 扩展)
ret尾调用优化
当一个函数在末尾调用另一个函数并直接返回其结果时,可以复用当前栈帧——这称为尾调用优化(tail-call optimization)。
# 未优化版本
func_a:
addi sp, sp, -16 # 分配栈帧
sd ra, 8(sp)
# ... 一些计算 ...
call func_b # 调用 func_b
ld ra, 8(sp) # 恢复 ra
addi sp, sp, 16 # 释放栈帧
ret # 返回
# 尾调用优化版本
func_a:
# ... 一些计算 ...
# 不分配新栈帧,不保存 ra——直接尾调用
tail func_b # 等价于:auipc + jalr x0(不写 ra)tail 伪指令展开后不修改 ra——func_b 返回时直接返回到 func_a 的调用者,跳过 func_a 的 epilogue。这是函数式语言编译器的核心优化,汇编程序员在深层递归或调用链末端同样可以手工应用。
寄存器保护决策树
汇编编程中,"该用哪个寄存器"和"该不该保存"是最频繁的决策。以下决策树总结:
需要保存跨函数调用的值?
├── 是 → 使用 s0-s11
│ ├── 需在 prologue 保存到栈,epilogue 恢复
│ └── 优点:调用其他函数后值不变,省心
│ └── 缺点:多两条访存指令(sd/ld),函数入口出口各一次
│
└── 否 → 使用 t0-t6 或 a0-a7
├── 无需保存,零栈开销
├── 优点:快
└── 注意:任何 call 都可能破坏它们——必须假定调用后值无效实用建议:在函数中,需要生存期跨越子调用的变量放 s 寄存器;生命周期局限在当前函数内(或一个基本块内)的临时值用 t 寄存器。参数 a0-a7 在函数入口是输入,但可以当临时寄存器使用——它们也是 caller-saved,调子函数后会被破坏。
完整示例:阶乘计算
以下是一个完整的、有良好注释的递归阶乘函数,展示调用约定的完整实践:
# int factorial(int n)
# 输入:a0 = n
# 输出:a0 = n!
factorial:
# === Prologue ===
addi sp, sp, -16
sd ra, 8(sp) # 保存 ra——因为要递归 call 自己
sd s0, 0(sp) # 保存 s0——用它持有跨调用的 n
# === Body ===
mv s0, a0 # s0 = n(将参数移到 callee-saved 寄存器)
li t0, 1
ble s0, t0, base_case # if n <= 1 goto base_case
# 递归:factorial(n-1)
addi a0, s0, -1 # a0 = n-1
call factorial # a0 = factorial(n-1)
mul a0, s0, a0 # a0 = n * factorial(n-1)
j done
base_case:
li a0, 1 # factorial(0) = factorial(1) = 1
done:
# === Epilogue ===
ld s0, 0(sp) # 恢复 s0
ld ra, 8(sp) # 恢复 ra
addi sp, sp, 16 # 释放栈帧
ret要点分析:
- s0 的使用:n 在
call factorial前后都需要保留。如果放在 t0,则递归调用返回后 t0 值不可预测。s0 确保跨调用安全。 - ra 必须在 prologue 保存:
call factorial覆盖 ra,epilogue 中需要复原才能返回到调用者。 - 基案 (base case) 不调用自身,ra 和 s0 实际上不需要保存——但为简单起见保持了一致性。在手工优化的汇编中可以省略基案的 prologue/epilogue。
本章要点
- ABI 划分寄存器为 caller-saved (t0-t6, a0-a7, ra) 和 callee-saved (s0-s11, sp)——关键区分是"子函数是否可能修改它们"
- 前 8 个整数参数走 a0-a7,超出部分走栈;返回值 ≤64 位在 a0,65-128 位在 a0:a1 对
- 函数 = Prologue(保存 ra + s 寄存器,分配栈帧)+ Body + Epilogue(恢复寄存器,释放栈帧,ret)
- Leaf 函数不调子函数则无需保存 ra,可能完全省去栈帧;尾调用优化跳过 epilogue 直接跳转
- s0-s11 跨调用安全但需保存/恢复,t0-t6 零开销但子调用后值不可靠——按变量生命周期选寄存器