Skip to content
Published at:

11. 调用约定

汇编指令定义了 CPU 能执行什么操作,但没有定义函数之间如何通信——参数放在哪个寄存器?返回值通过谁传递?哪些寄存器子函数可以随意修改?这些规则由 ABI(Application Binary Interface)规定。本章详述 RISC-V 的函数调用约定,这是让汇编代码与 C/Rust/任何编译语言互操作的基础。

RISC-V ABI 规范

ABI 是函数间传递信息、管理寄存器的标准化协议。没有 ABI,A 编译器产出的 .o 文件无法链接 B 编译器产出的库——它们在"如何沟通"上存在根本分歧。

RISC-V ABI 由 RISC-V psABI 规范 定义,它涵盖:

  1. 寄存器用途分类:哪些寄存器是 caller-saved,哪些是 callee-saved
  2. 参数传递约定:整数/浮点参数分别通过哪些寄存器传递,超出寄存器数量后如何用栈补传
  3. 返回值约定:返回值放哪个寄存器,大结构体如何处理
  4. 栈帧布局:栈帧中 ra、fp、局部变量、传出参数区的布局规则
  5. 对齐要求: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 名称分类用途
x0zero硬连线零
x1racaller-saved返回地址
x2spcallee-saved栈指针
x3gp全局指针(静态链接用)
x4tp线程指针(线程局部存储)
x5-x7t0-t2caller-saved临时寄存器
x8s0/fpcallee-saved帧指针 / 被保存寄存器 0
x9s1callee-saved被保存寄存器 1
x10-x11a0-a1caller-saved函数参数 0-1 / 返回值
x12-x17a2-a7caller-saved函数参数 2-7
x18-x27s2-s11callee-saved被保存寄存器 2-11
x28-x31t3-t6caller-saved临时寄存器 3-6

参数传递

整数与指针参数

前 8 个整数参数依次通过 a0-a7 传递。注意寄存器名称的双重身份——a0 既是"参数 0"又是"返回值",a1 既是"参数 1"又是"第二返回分量(128 位时)"。

asm
# 调用一个三参数函数 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+ 个参数通过传递,由调用者分配在栈帧的"传出参数区":

asm
# 调用十参数函数 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)的正偏移:

asm
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 内容无定义调用者不得依赖其值
asm
# 返回 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),可能完全不需要栈帧:

asm
# Leaf 函数:将两个参数相加返回
# 不需要任何栈操作
add_two:
    add  a0, a0, a1       # a0 = a0 + a1
    ret                    # 返回——ra 未被修改,无需保存

Leaf 函数如果只需要修改 a0-a7 和 t0-t6 寄存器,可以完全省略 prologue/epilogue——它们对调用者透明。

标准非 Leaf 函数

一旦函数内部有 call,ra 就会被覆盖——必须保存 ra:

asm
# 非 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 恢复:

asm
# 使用 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 动态栈分配)。

asm
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 寄存器,完全不需要栈帧:

asm
# 零栈帧 Leaf 函数
square:
    mul  a0, a0, a0       # a0 = a0 * a0(需 M 扩展)
    ret

尾调用优化

当一个函数在末尾调用另一个函数并直接返回其结果时,可以复用当前栈帧——这称为尾调用优化(tail-call optimization)。

asm
# 未优化版本
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,调子函数后会被破坏。

完整示例:阶乘计算

以下是一个完整的、有良好注释的递归阶乘函数,展示调用约定的完整实践:

asm
# 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 零开销但子调用后值不可靠——按变量生命周期选寄存器