1. 从零上手 RISC-V:为什么 base ISA 和 ABI 寄存器约定是绕不开的第一道坎
刚接触 RISC-V 的人,十有八九会卡在同一个地方:指令集手册翻了几十页,每个字母都认识,但连起来就是不知道在说什么。尤其是看到x0到x31这 32 个通用寄存器,再看到zero、ra、sp、a0、t0这些别名,脑子里第一反应就是——这俩到底是不是一回事?写汇编的时候该用哪个?函数调用的时候寄存器怎么分配?为什么有的寄存器调用完就变了,有的却能保持不变?
这些问题看起来零碎,但它们背后其实指向同一个核心:RISC-V 的 base ISA 定义了硬件层面有哪些寄存器,而 ABI 寄存器约定定义了软件层面怎么用这些寄存器。前者是“有什么”,后者是“怎么用”。搞不清楚这两层关系,后面写汇编、看反汇编、做 CPU 设计、移植操作系统,每一步都会踩坑。
我自己最开始学 RISC-V 的时候,就是直接跳进指令列表里背add、sub、lw、sw,结果写了一个简单的函数调用,发现返回值莫名其妙丢了,排查半天才发现是a0和t0用混了。后来才明白,指令本身不难,难的是寄存器约定这套“潜规则”。你写汇编可以随便用寄存器,但只要你调用了别人写的函数,或者别人调用了你的函数,就必须遵守 ABI 约定,否则程序行为完全不可预测。
这篇文章就是想把这件事讲透。我会从 base ISA 的寄存器文件讲起,把 32 个通用寄存器的编号、位宽、用途说清楚,然后重点拆解 ABI 寄存器约定——哪些寄存器由调用者保存,哪些由被调用者保存,参数怎么传,返回值怎么回,栈指针怎么动。中间会穿插实际的汇编代码示例、栈帧布局图、常见错误排查表,以及我在实际写 RISC-V 汇编和看编译器输出时踩过的坑。无论你是刚学 RISC-V 的学生,还是准备做 RISC-V CPU 设计的工程师,或者只是想在嵌入式项目里手写几段汇编,这篇内容都能让你少走弯路。
2. Base ISA 寄存器文件:32 个通用寄存器到底怎么来的
2.1 RV32I 的寄存器文件结构
RISC-V 的 base ISA 目前最常用的是 RV32I,也就是 32 位整数指令集。它定义了一个包含 32 个通用寄存器(General Purpose Register,GPR)的寄存器文件,编号从x0到x31。每个寄存器的位宽是 XLEN,对于 RV32I 来说就是 32 位。如果是 RV64I,那就是 64 位。这一点很关键:寄存器的数量是固定的 32 个,但位宽随 XLEN 变化。
为什么是 32 个?这不是随便定的。32 个寄存器意味着指令中需要 5 位来编码寄存器索引(2^5 = 32)。RISC-V 的指令长度是 32 位,如果寄存器索引占 5 位,一条典型的 R 型指令需要三个寄存器操作数(rs1、rs2、rd),那就是 15 位,再加上 7 位 opcode 和 3 位 funct3,以及 7 位 funct7,刚好 32 位。如果寄存器数量增加到 64 个,就需要 6 位索引,三个操作数就是 18 位,指令编码会变得非常紧张。所以 32 个寄存器是在指令编码效率和寄存器压力之间权衡的结果。
这里有一个很多人一开始会忽略的点:x0是硬连线到 0 的。不管你往x0里写什么,读出来永远是 0。这个设计看起来有点浪费,但实际上非常有用。比如你想把一个寄存器清零,不需要专门的清零指令,直接用addi x5, x0, 0就行。再比如,你想丢弃一个计算结果,把它写到x0就相当于扔掉了。还有,x0可以作为常量 0 的来源,简化指令设计。
2.2 通用寄存器的编号与别名对照
虽然硬件层面只有x0到x31这 32 个编号,但在汇编语言和 ABI 层面,每个寄存器都有一个或多个别名。这些别名不是硬件强制的,而是软件约定俗成的。下面这张表是我整理的最常用的别名对照,建议刚开始学的时候直接背下来,后面看汇编和写汇编都会用到。
| 寄存器编号 | ABI 别名 | 用途说明 | 是否由调用者保存 |
|---|---|---|---|
| x0 | zero | 硬连线常量 0 | 不适用 |
| x1 | ra | 返回地址 | 是 |
| x2 | sp | 栈指针 | 是 |
| x3 | gp | 全局指针 | 不适用 |
| x4 | tp | 线程指针 | 不适用 |
| x5 | t0 | 临时寄存器 0 | 是 |
| x6 | t1 | 临时寄存器 1 | 是 |
| x7 | t2 | 临时寄存器 2 | 是 |
| x8 | s0/fp | 保存寄存器 0 / 帧指针 | 否 |
| x9 | s1 | 保存寄存器 1 | 否 |
| x10 | a0 | 参数 0 / 返回值 0 | 是 |
| x11 | a1 | 参数 1 / 返回值 1 | 是 |
| x12 | a2 | 参数 2 | 是 |
| x13 | a3 | 参数 3 | 是 |
| x14 | a4 | 参数 4 | 是 |
| x15 | a5 | 参数 5 | 是 |
| x16 | a6 | 参数 6 | 是 |
| x17 | a7 | 参数 7 | 是 |
| x18 | s2 | 保存寄存器 2 | 否 |
| x19 | s3 | 保存寄存器 3 | 否 |
| x20 | s4 | 保存寄存器 4 | 否 |
| x21 | s5 | 保存寄存器 5 | 否 |
| x22 | s6 | 保存寄存器 6 | 否 |
| x23 | s7 | 保存寄存器 7 | 否 |
| x24 | s8 | 保存寄存器 8 | 否 |
| x25 | s9 | 保存寄存器 9 | 否 |
| x26 | s10 | 保存寄存器 10 | 否 |
| x27 | s11 | 保存寄存器 11 | 否 |
| x28 | t3 | 临时寄存器 3 | 是 |
| x29 | t4 | 临时寄存器 4 | 是 |
| x30 | t5 | 临时寄存器 5 | 是 |
| x31 | t6 | 临时寄存器 6 | 是 |
这张表看起来简单,但里面有几个细节值得展开说。第一,x8有两个别名:s0和fp。fp是帧指针(frame pointer),在某些编译器和调试场景下会用到。如果你用-fno-omit-frame-pointer编译,编译器会把s0当作帧指针来用,指向当前栈帧的底部。如果你不需要调试信息,编译器通常会省略帧指针,把s0当作普通的保存寄存器用。第二,gp和tp这两个寄存器比较特殊,它们不参与普通的函数调用约定,而是用于全局数据访问和线程局部存储。在嵌入式裸机程序里,gp通常指向.sdata段的中间位置,方便用一条指令访问全局变量。tp在多线程环境里指向当前线程的控制块。第三,a0到a7这 8 个寄存器既可以传参数,也可以传返回值。具体怎么用,后面讲 ABI 的时候会详细说。
2.3 为什么 RISC-V 的寄存器设计比 x86 更“干净”
如果你之前接触过 x86 汇编,可能会觉得 RISC-V 的寄存器设计有点“太规整了”。x86 的寄存器有eax、ebx、ecx、edx、esi、edi、ebp、esp,每个寄存器都有历史遗留的特殊用途,比如ecx在某些指令里是循环计数器,eax在乘除法里是累加器。这种设计是历史演进的结果,不是一开始就规划好的。
RISC-V 不一样,它从一开始就是重新设计的。32 个通用寄存器在硬件层面完全对等,没有哪个寄存器有硬连线的特殊功能(除了x0固定为 0)。所有的特殊用途都是软件约定出来的。这意味着什么呢?意味着你可以写一个完全不用 ABI 约定的程序,把x1当累加器用,把x10当循环计数器用,硬件层面完全没问题。但只要你调用了库函数,或者你的代码被别人的代码调用,就必须回到 ABI 约定上来。
这种“硬件对等、软件约定”的设计哲学,是 RISC-V 简洁性的重要体现。它把复杂性从硬件推到了软件,让硬件实现更简单,同时给软件更大的灵活性。但代价就是,写汇编的人必须清楚地知道 ABI 约定,否则程序就会出各种莫名其妙的问题。
3. ABI 寄存器约定:函数调用背后的“交通规则”
3.1 调用者保存与被调用者保存的本质区别
ABI 寄存器约定里最核心的概念,就是调用者保存(caller-saved)和被调用者保存(callee-saved)。这两个词听起来很学术,但用生活化的类比就很好理解。
想象你和同事共用一张办公桌。桌子上有一些公共物品,比如订书机、胶带、剪刀。你离开桌子去开会之前,如果用了订书机,你得把它放回原位,因为你的同事可能也要用。这些公共物品就相当于“调用者保存”寄存器——你(调用者)用完了,得自己负责保存和恢复,不能指望别人帮你保留。
另外,桌子上还有每个人自己的抽屉。你同事的抽屉里放了他的私人物品,你如果要用他的抽屉,用完之后必须把东西放回原样,否则他回来会找不到自己的东西。这些私人抽屉就相当于“被调用者保存”寄存器——被调用者(你的同事)有责任保证这些寄存器在函数返回时和调用前一样。
具体到 RISC-V 的寄存器:
- 调用者保存寄存器:
ra、t0-t6、a0-a7。这些寄存器在函数调用过程中可能被被调用者随意修改。如果调用者在调用返回后还需要这些寄存器的值,必须在调用前自己保存到栈上,调用后恢复。 - 被调用者保存寄存器:
s0-s11、sp。这些寄存器如果被被调用者使用了,被调用者必须在函数开头把它们保存到栈上,在函数返回前恢复。调用者可以放心,这些寄存器在函数调用前后保持不变。
为什么要有这个区分?核心目的是减少不必要的内存访问。如果所有寄存器都由调用者保存,那么每次函数调用前,调用者都得把可能用到的寄存器全部压栈,调用后再全部弹栈,开销很大。如果所有寄存器都由被调用者保存,那么被调用者每次都得保存所有它用到的寄存器,哪怕调用者根本不在乎这些寄存器的值。调用者保存和被调用者保存的划分,是在两者之间找平衡:临时变量用调用者保存寄存器,跨调用需要保持的变量用被调用者保存寄存器。
3.2 参数传递与返回值:a0-a7 和栈的配合
RISC-V 的函数调用约定规定,前 8 个整型参数通过a0到a7传递。如果参数超过 8 个,多出来的参数通过栈传递。返回值通过a0和a1返回。如果返回值是 64 位整数,a0存低 32 位,a1存高 32 位。如果是更大的结构体,返回值可能通过栈上的隐藏指针传递。
这里有一个很容易踩的坑:参数在栈上的布局顺序。假设你有一个函数void foo(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j),前 8 个参数a到h通过a0到a7传递,第 9 个参数i和第 10 个参数j通过栈传递。那么i和j在栈上的顺序是什么?是i在低地址还是j在低地址?
根据 RISC-V 的 ABI 规范,栈上的参数按顺序排列,第一个栈参数在最低地址。也就是说,i在sp指向的位置,j在sp+4的位置(RV32)或sp+8的位置(RV64)。这个顺序和 x86 的 cdecl 调用约定正好相反。x86 cdecl 是从右往左压栈,所以最后一个参数在最低地址。RISC-V 是从左往右,第一个栈参数在最低地址。如果你从 x86 转到 RISC-V,这个地方特别容易搞混。
还有一个细节:栈指针的对齐。RISC-V 的 ABI 要求栈指针sp始终保持 16 字节对齐。也就是说,sp的值必须是 16 的倍数。这个要求是为了支持向量指令和某些需要对齐的内存访问。如果你在函数里分配栈空间,分配的大小必须是 16 的倍数。比如你需要 20 字节的局部变量空间,实际要分配 32 字节。这个对齐要求在很多嵌入式场景下看起来有点浪费,但它是 ABI 的一部分,不遵守的话,某些库函数可能会崩溃。
3.3 栈帧布局:从 prologue 到 epilogue 的完整流程
一个标准的 RISC-V 函数,在汇编层面通常包含三个部分:prologue(序言)、body(函数体)、epilogue(尾声)。Prologue 负责建立栈帧、保存被调用者保存寄存器、调整栈指针。Epilogue 负责恢复寄存器、释放栈帧、返回。
下面是一个典型的 RISC-V 汇编函数示例,我加了详细注释:
# 函数原型:int add3(int a, int b, int c) # 参数:a0 = a, a1 = b, a2 = c # 返回值:a0 = a + b + c add3: # Prologue:建立栈帧 addi sp, sp, -16 # 分配 16 字节栈空间 sw ra, 12(sp) # 保存返回地址 sw s0, 8(sp) # 保存 s0(如果需要用的话) sw s1, 4(sp) # 保存 s1(如果需要用的话) # 注意:这里没有用 s0/s1,所以其实不需要保存 # 但为了演示栈帧布局,还是保留了 # Body:函数体 add a0, a0, a1 # a0 = a + b add a0, a0, a2 # a0 = a + b + c # Epilogue:恢复栈帧并返回 lw ra, 12(sp) # 恢复返回地址 lw s0, 8(sp) # 恢复 s0 lw s1, 4(sp) # 恢复 s1 addi sp, sp, 16 # 释放栈空间 ret # 返回(实际上是 jalr x0, 0(ra))这个例子很简单,但它展示了栈帧的基本结构。sp在 prologue 里减去 16,在 epilogue 里加回 16。ra被保存在12(sp)的位置,因为ra是调用者保存寄存器,如果这个函数还要调用其他函数,ra会被覆盖,所以必须保存。s0和s1是被调用者保存寄存器,如果函数体里用到了它们,就必须保存和恢复。
这里有一个常见的优化:叶子函数(leaf function)不需要保存ra。叶子函数是指不调用其他函数的函数。因为不调用其他函数,ra不会被覆盖,所以不需要保存。编译器通常会对叶子函数做这个优化,减少栈操作。如果你手写汇编,也可以利用这一点。
还有一个细节:ret指令实际上是jalr x0, 0(ra)的伪指令。它跳转到ra指向的地址,同时把返回地址写到x0(也就是丢弃)。这个设计很巧妙,因为x0是硬连线的 0,写进去相当于没写,所以ret不会破坏任何寄存器。
4. 实操:手写汇编验证 ABI 约定
4.1 环境准备与工具链选择
要验证 ABI 寄存器约定,最直接的方法就是手写一段汇编,编译、链接、运行,然后观察寄存器的变化。我平时用的工具链是riscv64-unknown-elf-gcc和riscv64-unknown-elf-binutils,配合 QEMU 的qemu-riscv32或qemu-riscv64来运行。如果你没有真实的 RISC-V 硬件,QEMU 用户模式是最方便的验证环境。
安装工具链的方式取决于你的操作系统。在 Ubuntu 上,可以直接用包管理器安装:
sudo apt update sudo apt install gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf sudo apt install qemu-user qemu-user-static如果你用的是 macOS,可以用 Homebrew 安装:
brew tap riscv-software-src/riscv brew install riscv-tools brew install qemu安装完成后,验证一下:
riscv64-unknown-elf-gcc --version qemu-riscv64 --version如果这两个命令都能输出版本信息,环境就准备好了。接下来我会用一个完整的例子,演示函数调用过程中寄存器的变化。
4.2 完整示例:从汇编到运行
下面这段汇编代码定义了两个函数:main和add3。main调用add3,传入三个参数,然后检查返回值。
.section .text .globl main # 函数:main # 调用 add3(10, 20, 30),然后检查返回值是否为 60 main: addi sp, sp, -16 # 分配栈空间 sw ra, 12(sp) # 保存返回地址 li a0, 10 # 参数 1 = 10 li a1, 20 # 参数 2 = 20 li a2, 30 # 参数 3 = 30 call add3 # 调用 add3 # 此时 a0 应该是 60 li t0, 60 # 期望值 bne a0, t0, fail # 如果不等于 60,跳转到 fail # 成功:退出码 0 li a0, 0 lw ra, 12(sp) addi sp, sp, 16 ret fail: # 失败:退出码 1 li a0, 1 lw ra, 12(sp) addi sp, sp, 16 ret # 函数:add3 # 参数:a0 = a, a1 = b, a2 = c # 返回值:a0 = a + b + c add3: add a0, a0, a1 # a0 = a + b add a0, a0, a2 # a0 = a + b + c ret # 返回编译和运行:
riscv64-unknown-elf-gcc -march=rv32i -mabi=ilp32 -nostdlib -static -o test test.S qemu-riscv32 ./test echo $?如果输出是0,说明add3正确返回了 60。如果输出是1,说明返回值不对。这个例子虽然简单,但它验证了参数传递(a0-a2)、返回值(a0)、返回地址(ra)的完整流程。
4.3 用反汇编观察编译器生成的代码
手写汇编能帮你理解 ABI,但实际项目中更多是看编译器生成的汇编。你可以写一个 C 函数,然后用-S选项生成汇编,观察编译器怎么处理寄存器。
int add3(int a, int b, int c) { return a + b + c; } int main() { int result = add3(10, 20, 30); return result == 60 ? 0 : 1; }编译:
riscv64-unknown-elf-gcc -march=rv32i -mabi=ilp32 -O2 -S -o test.s test.c生成的汇编大概是这样:
add3: add a0, a0, a1 add a0, a0, a2 ret main: addi sp, sp, -16 sw ra, 12(sp) li a0, 10 li a1, 20 li a2, 30 call add3 addi a0, a0, -60 snez a0, a0 lw ra, 12(sp) addi sp, sp, 16 ret注意add3是叶子函数,编译器没有保存ra,也没有分配栈空间。main不是叶子函数,所以保存了ra。这就是编译器对 ABI 约定的实际应用。你可以试着改一下 C 代码,比如在main里加一些局部变量,看看编译器怎么使用s0-s11和t0-t6。
5. 常见问题与排查技巧实录
5.1 寄存器使用错误速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方法 |
|---|---|---|---|
| 函数返回值丢失 | a0在调用后被覆盖 | 检查调用后是否使用了a0 | 调用前保存a0,或改用s寄存器 |
| 调用返回后程序跑飞 | ra被覆盖 | 检查被调用函数是否保存了ra | 在 prologue 中保存ra |
| 局部变量值莫名其妙变了 | 使用了t寄存器保存跨调用变量 | 检查变量是否在函数调用后使用 | 改用s寄存器,或在调用前保存 |
| 栈指针不对齐导致崩溃 | sp没有 16 字节对齐 | 检查sp的增减量 | 确保每次调整sp都是 16 的倍数 |
| 参数传递错误 | 参数顺序或寄存器分配错误 | 对照 ABI 表检查a0-a7 | 前 8 个参数用a0-a7,多余的用栈 |
| 递归函数栈溢出 | 每次递归都分配栈空间但没释放 | 检查 epilogue 是否正确恢复sp | 确保sp在返回前恢复到调用前的值 |
这张表是我在实际调试中总结出来的,基本上覆盖了 90% 的 ABI 相关错误。其中最常见的就是ra被覆盖和a0丢失。ra被覆盖通常发生在非叶子函数里忘记保存ra。a0丢失通常发生在调用函数后,调用者以为a0还是原来的值,但实际上被被调用者修改了。
5.2 独家避坑技巧:从实际项目中总结
第一个技巧:用s寄存器保存跨调用变量,用t寄存器保存临时变量。这个原则听起来简单,但实际写汇编的时候很容易搞混。我的习惯是,如果一个变量在函数调用后还要用,就放到s0-s11里;如果只是当前基本块内用一下,就放到t0-t6里。这样能最大程度减少栈操作。
第二个技巧:叶子函数尽量不碰sp。叶子函数不需要保存ra,也不需要为调用其他函数预留空间。如果局部变量不多,可以直接用t寄存器,完全不碰栈。这样函数调用开销最小。编译器在-O2以上会自动做这个优化,但手写汇编的时候要自己有这个意识。
第三个技巧:调试时用-fno-omit-frame-pointer。这个编译选项会让编译器把s0当作帧指针fp来用,指向当前栈帧的底部。这样在 GDB 里调试的时候,可以很容易地回溯调用栈。虽然会多用一个寄存器,稍微影响性能,但调试阶段非常值得。
第四个技巧:注意gp和tp的初始化。在裸机程序里,gp通常需要在启动代码里初始化,指向.sdata段的中间位置。如果gp没有正确初始化,访问全局变量会出错。tp在多线程环境里由运行时库初始化,单线程程序一般不用管。但如果你在裸机环境里用了需要gp的代码,一定要检查启动代码。
5.3 一个真实的调试案例
我之前在做一个 RISC-V 的嵌入式项目时,遇到过一个很诡异的问题:一个函数在单独测试时完全正常,但集成到系统里后,返回值偶尔会变成随机数。排查了很久,最后发现是ra被覆盖了。
具体是这样的:这个函数本身是叶子函数,没有保存ra。但在某个版本里,我在函数中间加了一段调试代码,调用了一个打印函数。这样一来,这个函数就不再是叶子函数了,ra被打印函数覆盖。函数返回时,ret跳转到了打印函数里的某个地址,程序就跑飞了。
这个问题的教训是:当你修改一个函数,增加了函数调用时,一定要检查ra是否被保存。叶子函数和非叶子函数的 prologue 是不一样的。编译器会自动处理这个变化,但手写汇编的时候很容易忘记。
还有一个类似的坑:中断处理函数。中断处理函数可能在任何时候被触发,它必须保存所有它用到的寄存器,包括ra、t寄存器、a寄存器。如果中断处理函数调用了其他函数,还必须保存ra。很多人在写中断处理函数时,只保存了a0和t0,结果中断返回后主程序的行为完全乱了。正确的做法是,中断处理函数要么保存所有可能被修改的寄存器,要么只使用s寄存器(但s寄存器也需要在中断入口保存,因为主程序可能正在用)。
6. 从 ABI 约定看 RISC-V CPU 设计的几个关键点
如果你是在做 RISC-V CPU 设计,ABI 寄存器约定对你的影响可能比写汇编的人更大。因为 CPU 设计需要决定寄存器文件的实现方式,而 ABI 约定会影响寄存器文件的设计取舍。
第一个设计点:寄存器文件的读写端口数量。RISC-V 的典型指令需要同时读两个寄存器(rs1、rs2),写一个寄存器(rd)。所以寄存器文件通常需要两个读端口和一个写端口。如果你要做双发射或者多发射,可能需要更多的端口。ABI 约定里,a0-a7和t0-t6是调用者保存的,使用频率很高,s0-s11是被调用者保存的,使用频率相对低一些。但这不意味着你可以对寄存器区别对待,因为硬件层面所有寄存器是对等的。
第二个设计点:x0的处理。x0硬连线到 0,这意味着寄存器文件里可以不为x0分配实际的存储单元,读x0时直接返回 0,写x0时忽略。这样可以节省一点面积。但要注意,有些指令会把结果写到x0,比如ret实际上是jalr x0, 0(ra),这时候写x0的操作必须被正确忽略,不能产生副作用。
第三个设计点:栈指针的快速更新。sp是使用最频繁的寄存器之一,几乎每个函数调用都会修改sp。有些 CPU 设计会为sp做特殊优化,比如在流水线里提前计算sp的新值。但要注意,sp在硬件层面和普通寄存器没有区别,任何优化都不能改变程序可见的行为。
第四个设计点:返回地址栈(RAS)。ra用于函数返回,call和ret指令会读写ra。很多高性能 CPU 会实现一个返回地址栈,在call时把返回地址压入 RAS,在ret时从 RAS 弹出预测的返回地址。这样可以提高返回指令的预测准确率。RAS 的实现不需要改变 ABI,但它依赖于call和ret的配对使用。如果程序里用jalr手动跳转而不是用call/ret,RAS 的预测可能会失效。
第五个设计点:ABI 对编译器的影响。虽然 CPU 设计不直接涉及编译器,但 ABI 约定是编译器和 CPU 之间的契约。如果你在设计 CPU 时改变了某些寄存器的行为(比如让x0不再是 0),编译器生成的代码就会出错。所以 CPU 设计必须严格遵守 base ISA 和 ABI 的规定,不能随意发挥。
7. 寄存器约定的扩展:从 RV32I 到 RV64I 和浮点扩展
7.1 RV64I 的寄存器变化
RV64I 把通用寄存器的位宽从 32 位扩展到 64 位,但寄存器的数量和编号不变。x0到x31还是 32 个寄存器,只是每个寄存器变成 64 位。ABI 别名也不变,a0还是x10,sp还是x2。
但有一个重要的变化:参数传递和返回值的规则变了。在 RV32I 里,一个 64 位整数需要两个寄存器传递(a0和a1)。在 RV64I 里,一个 64 位整数只需要一个寄存器。这意味着同样的 C 代码,在 RV32I 和 RV64I 下生成的汇编可能完全不同。如果你在做跨平台开发,这一点要特别注意。
另外,RV64I 的栈帧布局也有变化。RV32I 里,每个栈槽是 4 字节,RV64I 里是 8 字节。栈指针的对齐要求还是 16 字节,但在 RV64I 里,16 字节对齐意味着sp的低 4 位必须是 0。这个对齐要求对某些需要 16 字节对齐的向量指令很重要。
7.2 浮点扩展的寄存器约定
RISC-V 的浮点扩展(F 和 D)增加了 32 个浮点寄存器,编号从f0到f31。浮点寄存器的 ABI 约定和通用寄存器类似,但有一些区别。
浮点参数通过fa0到fa7传递,浮点返回值通过fa0和fa1返回。ft0到ft11是调用者保存的临时浮点寄存器,fs0到fs11是被调用者保存的浮点寄存器。浮点寄存器的保存和恢复通常使用fsw和flw指令(RV32)或fsd和fld指令(RV64)。
这里有一个容易忽略的点:浮点寄存器的保存粒度。在 RV32I 里,f寄存器是 32 位,d寄存器是 64 位。如果你用了d扩展,保存fs0需要 8 字节的栈空间。栈帧的大小计算要考虑到这一点。另外,浮点寄存器的保存和恢复不能直接用sw和lw,必须用浮点加载存储指令。
7.3 压缩指令扩展对寄存器约定的影响
RISC-V 的压缩指令扩展(C 扩展)把常用的 32 位指令压缩成 16 位,减少代码体积。C 扩展对寄存器约定有一个重要影响:它只能访问 8 个常用寄存器。具体来说,压缩指令可以访问x8-x15(也就是s0、s1、a0-a5)和x0。这意味着编译器在生成压缩指令时,会优先使用这些寄存器。
如果你在做 CPU 设计,支持 C 扩展需要额外的解码逻辑。如果你在写汇编,了解 C 扩展的寄存器限制可以帮助你写出更紧凑的代码。比如,把常用的变量放在a0-a5和s0-s1里,可以让编译器生成更多的压缩指令。
8. 实际项目中的寄存器分配策略
8.1 编译器如何分配寄存器
编译器在分配寄存器时,会尽量把频繁使用的变量放在寄存器里,把不频繁使用的变量溢出到栈上。这个过程叫寄存器分配。RISC-V 的 ABI 约定为寄存器分配提供了框架:调用者保存寄存器用于临时变量,被调用者保存寄存器用于跨调用变量。
但编译器的实际分配策略比这个框架复杂得多。比如,编译器可能会把一个跨调用变量放在t0里,然后在调用前把它保存到栈上,调用后再恢复。这样做的好处是,如果函数里没有调用,就不需要保存。坏处是,如果有调用,就需要额外的栈操作。编译器会根据变量的使用频率和函数的调用情况做权衡。
我在看编译器生成的汇编时,经常发现一些“反直觉”的分配。比如,一个循环计数器被放在了s0里,而不是t0里。这是因为循环计数器在循环体内频繁使用,放在s寄存器里可以避免每次迭代都保存和恢复。编译器的寄存器分配算法会考虑这些因素,手写汇编的时候也可以借鉴这个思路。
8.2 手写汇编时的寄存器规划
如果你要手写一段比较长的汇编,建议先做寄存器规划。我的习惯是:
- 列出所有需要在函数内使用的变量。
- 区分哪些变量在函数调用后还需要用,哪些只在当前基本块内用。
- 跨调用变量分配到
s0-s11,临时变量分配到t0-t6。 - 参数和返回值用
a0-a7。 - 如果
s寄存器不够用,把不常用的跨调用变量放到栈上。 - 在 prologue 里保存用到的
s寄存器和ra,在 epilogue 里恢复。
这个流程看起来简单,但实际做的时候很容易漏掉某个寄存器。我的经验是,在 prologue 和 epilogue 里用注释列出所有保存和恢复的寄存器,这样检查的时候一目了然。
8.3 一个寄存器规划的实例
假设你要写一个函数,计算两个数组的点积。函数原型是int dot_product(int *a, int *b, int n)。参数a在a0,b在a1,n在a2。返回值在a0。
寄存器规划:
s0:数组a的指针(跨调用,因为循环里可能调用其他函数?这里没有调用,但为了安全还是用s)s1:数组b的指针s2:循环计数器is3:累加和t0:临时变量,用于加载数组元素t1:临时变量,用于加载数组元素
Prologue 需要保存s0-s3和ra(如果函数不是叶子函数)。这里dot_product不调用其他函数,所以是叶子函数,不需要保存ra。但为了通用性,还是保存一下。
dot_product: addi sp, sp, -32 sw ra, 28(sp) sw s0, 24(sp) sw s1, 20(sp) sw s2, 16(sp) sw s3, 12(sp) mv s0, a0 # s0 = a mv s1, a1 # s1 = b mv s2, a2 # s2 = n li s3, 0 # s3 = 0 (累加和) loop: blez s2, done # 如果 n <= 0,结束 lw t0, 0(s0) # t0 = *a lw t1, 0(s1) # t1 = *b mul t0, t0, t1 # t0 = *a * *b add s3, s3, t0 # s3 += t0 addi s0, s0, 4 # a++ addi s1, s1, 4 # b++ addi s2, s2, -1 # n-- j loop done: mv a0, s3 # 返回值 = s3 lw ra, 28(sp) lw s0, 24(sp) lw s1, 20(sp) lw s2, 16(sp) lw s3, 12(sp) addi sp, sp, 32 ret这个例子展示了完整的寄存器规划流程。注意mul指令是 M 扩展的,如果你的 CPU 不支持 M 扩展,需要用软件乘法代替。另外,栈帧大小是 32 字节,因为保存了 5 个寄存器(ra、s0-s3),每个 4 字节,共 20 字节,向上对齐到 16 的倍数就是 32 字节。
9. 从 ABI 约定看 RISC-V 生态的成熟度
ABI 寄存器约定看起来只是一张寄存器用途表,但它背后反映的是整个 RISC-V 生态的成熟度。一个指令集能不能被广泛采用,不仅取决于指令本身的设计,还取决于软件生态是否完善。ABI 约定就是软件生态的基础设施之一。
RISC-V 的 ABI 约定有几个版本,最早的是 1.0 版本,后来有 2.0 版本。不同版本之间有一些细微的差别,比如参数传递的规则、栈对齐的要求。如果你在移植旧的 RISC-V 代码,可能会遇到 ABI 版本不兼容的问题。这时候需要检查编译器的-mabi选项,确保和库函数的 ABI 一致。
另外,RISC-V 的 ABI 约定还在演进中。比如,随着向量扩展的普及,向量寄存器的 ABI 约定也在制定中。如果你在做前沿的 RISC-V 开发,可能需要关注最新的 ABI 规范草案。
我在实际项目中的一个体会是:ABI 约定是 RISC-V 学习中最容易被忽视,但最重要的一部分。很多人花大量时间研究指令编码、流水线设计,却忽略了 ABI 约定。结果写出来的代码在单模块测试时没问题,一集成就各种崩溃。花半天时间把 ABI 寄存器约定搞清楚,后面能省下好几天的调试时间。
最后分享一个我常用的检查方法:当你写完一段汇编后,用objdump反汇编,然后逐行检查寄存器的使用是否符合 ABI 约定。重点检查ra是否在非叶子函数里被保存,s寄存器是否在 prologue 和 epilogue 里成对出现,sp的调整是否 16 字节对齐。这个检查流程能帮你发现大部分 ABI 相关的错误。