本篇定位:RISC-V 专题第 2 篇。目标是让你看懂汇编、调通死机、会写 inline asm。核心是两件事:① 32 个寄存器各自叫什么、干什么、谁负责保存;② 指令长什么样、怎么编码、RVC 压缩怎么回事。这一格通了,你打开任何一个 .s 启动文件、任何一段反汇编,都不再是乱码。
先建立心智模型:RISC-V 是 load-store 架构 RISC-V 的运算指令绝不直接碰内存。要做 c = a + b,必须先 lw 把 a、b 从内存 load 进寄存器,再 add 寄存器,再 sw 把结果存回内存。只有 load/store 指令访问内存,运算只在寄存器间。这叫 load-store 架构(RISC 的标志),和 x86 的"运算能直接操作内存"相反。
目录
- 一、32 个通用寄存器 + ABI 别名
-
- 1.1 寄存器全表(rv32i/rv64i 通用)
- 1.2 两个核心概念:caller-saved vs callee-saved
- 1.3 ra 的特殊地位
- 二、base 指令集(rv32i):40 条左右
-
- 2.1 整数运算(R/I 型)
- 2.2 访存指令(load-store,唯一碰内存的指令)
- 2.3 控制流(分支 + 跳转)
- 2.4 系统指令
- 2.5 伪指令(汇编器展开成真指令)
- 三、指令格式:六种
- 四、RVC 压缩指令:16 位变长
-
- 4.1 原理
- 4.2 C 扩展的常见压缩形式
- 4.3 编译时控制
- 五、调用约定(ILP32/LP64)详解
-
- 5.1 参数传递
- 5.2 栈帧布局
- 5.3 ILP32 / LP64 / f / d 后缀
- 六、gp 相对寻址:RISC-V 特有
- 七、ARM vs RISC-V 寄存器/调用约定对照
- 八、本篇小结
-
- 速查表
一、32 个通用寄存器 + ABI 别名
RISC-V 有 32 个通用寄存器 x0-x31。但汇编里几乎不写 x5、x10 这种数字,而是用 ABI 约定的别名(ra/sp/gp/a0…)。这层别名是"调用约定"的一部分——大家约定哪个寄存器干什么活,函数之间才能配合。
1.1 寄存器全表(rv32i/rv64i 通用)
| x0 | zero | 硬连线 0 | — | 永远是 0,写进去也被忽略。要常量 0 就用它 |
| x1 | ra | 返回地址 | caller-saved | jal/jalr 自动存这。函数返回用 ret(=jalr x0,ra,0) |
| x2 | sp | 栈指针 | callee-saved | 每个函数入口 sp 下移,出口上移 |
| x3 | gp | 全局指针 | — | 指向 .sdata 中间,gp 相对寻址用。启动时设一次 |
| x4 | tp | 线程指针 | — | RTOS 常用它存当前 TCB 指针(类似 ARM 的 PSP 上下文) |
| x5-x7 | t0-t2 | 临时寄存器 | caller-saved | 函数里随便用,不保留 |
| x8-x9 | s0/fp, s1 | 保存寄存器 | callee-saved | s0 常当帧指针 fp |
| x10-x11 | a0-a1 | 参数/返回值 | caller-saved | 前 2 个参数 + 函数返回值 |
| x12-x17 | a2-a7 | 参数 | caller-saved | 参数 3-8(a0-a7 共传 8 个整型参数) |
| x18-x27 | s2-s11 | 保存寄存器 | callee-saved | 跨函数调用要保留 |
| x28-x31 | t3-t6 | 临时寄存器 | caller-saved | 同 t0-t2 |
1.2 两个核心概念:caller-saved vs callee-saved
这是调用约定的灵魂,不懂这个你看不懂任何 prologue/epilogue。
- caller-saved(t0-t6, a0-a7, ra):调用方负责保存。意思是"函数 A 要调函数 B,A 如果还想要这些寄存器的值,A 自己在调 B 之前压栈"。被调的 B 可以随便改它们,不负责保留。
- callee-saved(sp, gp, tp, s0-s11):被调方负责保存。意思是"函数 B 如果要用 s0-s11,B 必须在入口压栈、出口恢复,保证 B 返回时这些寄存器和进来时一样"。
嵌入式视角:为什么这么分 这是个分工优化。caller-saved 寄存器"短命",适合放临时变量,调用前要用就自己存;callee-saved 寄存器"长命",适合放跨调用的变量,被调方保证不动它。这样编译器能高效分配寄存器,减少压栈。这条约定直接决定栈回溯算法找哪个寄存器。
1.3 ra 的特殊地位
ra(x1)是返回地址寄存器,但它被归为 caller-saved。这意味着:
- jal ra, func 执行时,硬件把 PC+4(返回地址)写进 ra,然后跳 func
- func 内部如果不再调用别的函数(叶子函数),ra 一直保留返回地址,直接 ret 返回
- func 内部如果要调用别的函数,ra 会被覆盖,所以 func 入口必须先 sw ra, (sp) 把它存起来,出口 lw ra 恢复
这就是为什么非叶子函数的 prologue 一定有 sw ra——你看启动汇编或反汇编,函数开头 addi sp,sp,-16; sw ra,12(sp); sw s0,8(sp) 是标准动作。
二、base 指令集(rv32i):40 条左右
不用背,看懂即可。分六类:
2.1 整数运算(R/I 型)
add rd, rs1, rs2 # rd = rs1 + rs2 (R型,寄存器-寄存器)
addi rd, rs1, imm # rd = rs1 + imm (I型,寄存器-立即数)
sub rd, rs1, rs2 # rd = rs1 – rs2 (没有 subi,用 addi 负立即数)
and/or/xor rd, rs1, rs2
andi/ori/xori rd, rs1, imm
sll/srl/sra rd, rs1, rs2 # 逻辑左移/右移/算术右移
slli/srli/srai rd, rs1, shamt
slt rd, rs1, rs2 # rd = (rs1 < rs2) ? 1 : 0 (有符号)
sltu rd, rs1, rs2 # 无符号版
slti/sltiu rd, rs1, imm
lui rd, imm # rd = imm << 12 (加载立即数高位,U型)
auipc rd, imm # rd = PC + (imm << 12) (PC相对,用于位置无关)
没有 subi、没有 mov RISC-V 极简:subi 没有,用 addi rd, rs, -imm 代替;mov 没有,用 addi rd, rs, 0 代替;nop 就是 addi x0, x0, 0。这种"能用已有指令组合就不加新指令"的思路贯穿整个 base ISA,是它保持精简的原因。
2.2 访存指令(load-store,唯一碰内存的指令)
lw rd, off(rs1) # rd = Mem[rs1 + off] (32位 load)
lh rd, off(rs1) # 16位,符号扩展
lhu rd, off(rs1) # 16位,零扩展
lb rd, off(rs1) # 8位,符号扩展
lbu rd, off(rs1) # 8位,零扩展
sw rs2, off(rs1) # Mem[rs1+off] = rs2 (32位 store)
sh rs2, off(rs1) # 16位
sb rs2, off(rs1) # 8位
# rv64 多了:
ld rd, off(rs1) # 64位 load
sd rs2, off(rs1) # 64位 store
注意寻址形式只有 off(rs1) 一种(基址+偏移),没有 ARM 的 [r1, r2](双寄存器)或 r1!(前/后变址)。要双寄存器寻址,先 add 再 lw。
2.3 控制流(分支 + 跳转)
# 分支(B型,条件跳,不存返回地址)
beq rs1, rs2, label # if (rs1==rs2) goto label
bne rs1, rs2, label
blt rs1, rs2, label # 有符号 <
bge rs1, rs2, label # 有符号 >=
bltu/bgeu # 无符号版
# 跳转(J型/I型,无条件)
jal rd, label # rd = PC+4; PC = label (J型,相对跳转,范围±1MB)
jalr rd, rs1, imm # rd = PC+4; PC = rs1+imm (I型,间接跳转,任意地址)
2.4 系统指令
ecall # 环境调用(触发 trap,用于系统调用)
ebreak # 断点(触发 trap,调试用)
fence # 内存屏障(IO与内存保序)
fence.i # 指令缓存屏障(改代码后同步 icache)
csrrw/csrsc/crrc … # CSR 读写(③专题详讲)
2.5 伪指令(汇编器展开成真指令)
RISC-V 汇编里很多"指令"其实是伪指令,汇编器展开成 1-2 条真指令。常见的:
| li rd, imm | lui+addi(大立即数) | 加载立即数 |
| mv rd, rs | addi rd, rs, 0 | 寄存器拷贝 |
| nop | addi x0, x0, 0 | 空操作 |
| ret | jalr x0, ra, 0 | 函数返回 |
| j label | jal x0, label | 无条件跳转(不存 ra) |
| call func | auipc+jalr ra | 远调用(超出±1MB) |
| la rd, symbol | auipc+addi | 加载符号地址 |
| bnez rs, label | bne rs, x0, label | 非零则跳 |
三、指令格式:六种
RISC-V 指令定长 32 位(标准),有六种格式。不用背编码细节,但要知道哪类指令用哪种格式,以及立即数位段分布——这关系到你反汇编时能不能手动解码。
R 型: [funct7(7)|rs2(5)|rs1(5)|funct3(3)|rd(5)|opcode(7)] 寄存器-寄存器运算 add/sub
I 型: [imm(12)|rs1(5)|funct3(3)|rd(5)|opcode(7)] 立即数运算/访存 addi/lw
S 型: [imm[11:5]|rs2(5)|rs1(5)|funct3(3)|imm[4:0]|opcode(7)] store sw/sh/sb
B 型: [imm[12|10:5]|rs2|rs1|funct3|imm[4:1|11]|opcode] 分支 beq/bne
U 型: [imm[31:12]|rd|opcode] 长立即数 lui/auipc
J 型: [imm[20|10:1|11|19:12]|rd|opcode] 跳转 jal
立即数位段是错位拼接的——栈回溯解码的坑 注意 B 型和 J 型的立即数不是连续的:B 型 imm 分散在 [12,10:5] 和 [4:1,11] 两段;J 型分散在 [20,10:1,11,19:12]。这是为了硬件解码优化(让关键位对齐在固定位置),但软件手动解码时要按位段拼回来。
四、RVC 压缩指令:16 位变长
这是 RISC-V 省 Flash 的大杀器。-march 带 c(如 rv32imac)就启用。
4.1 原理
常用指令(addi/lw/sw/jal/mv/li…)在很多场景下立即数很小、寄存器号也集中在常用那几个,完全可以用 16 位编码。RVC 把约 100 多个常用形式压缩成 16 位,省 ~30% 代码体积。硬件解码时自动识别 16/32 位混合(看最低 2 位)。
指令最低 2 位:
00, 01, 10 → 16 位压缩指令(C 扩展)
11 → 32 位标准指令
4.2 C 扩展的常见压缩形式
| addi rd, rd, 0 | c.mv rd, rs | — |
| addi sp, sp, -imm | c.addi16sp | rd=sp |
| lw rd, off(sp) | c.lwsp rd, off | 基址=sp |
| sw rs, off(sp) | c.swsp rs, off | 基址=sp |
| lw rd, off(rs1) | c.lw rd, off(rs1) | rd/rs1 在 s0-s1/a0-a5 |
| jal ra, offset | c.jal ra, offset | 短偏移 |
| jalr ra, rs1, 0 | c.jalr ra, rs1 | — |
| j offset | c.j offset | rd=x0 |
4.3 编译时控制
-march=rv32imac # 带 c,启用压缩
-march=rv32ima # 不带 c,强制 32 位(代码大但栈回溯简单)
-mno-relax # 关 linker relaxation(⑨专题)
调试死机频繁阶段,可以临时 -march=rv32ima 关掉 C,让栈回溯简单点;量产再开回来省代码。这是个实用权衡。
五、调用约定(ILP32/LP64)详解
5.1 参数传递
- 整型参数:a0-a7(最多 8 个),超出走栈
- 返回值:a0/a1(最多 2 个)
- 浮点参数(有 F/D 扩展):fa0-fa7(这是 -mabi 带 f/d 的事)
- 结构体返回:小结构体(a0/a1 装下)走寄存器,大的隐式传指针
5.2 栈帧布局
标准函数 prologue:
func:
addi sp, sp, -16 # 开栈帧
sw ra, 12(sp) # 存返回地址(非叶子函数必须)
sw s0, 8(sp) # 存 callee-saved
addi s0, sp, 16 # s0 = 帧指针(可选)
…
lw s0, 8(sp)
lw ra, 12(sp)
addi sp, sp, 16
ret
5.3 ILP32 / LP64 / f / d 后缀
| ilp32 | 32 | 软浮点(走整数寄存器/栈) | rv32imac |
| ilp32f | 32 | 单精度走 fa0-fa7 | rv32imafc |
| ilp32d | 32 | 双精度走浮点寄存器 | rv32imafdc |
| lp64 | 64 | 软浮点 | rv64imac |
| lp64d | 64 | 双精度 | rv64imafdc |
ABI 必须全工程一致——以ROM 固化为例 ROM 固化的函数和你程序的 -mabi 必须一致,否则浮点参数传递方式不同,调用 sqrtf 拿到错误参数。ABI 不匹配不会编译报错,运行时才崩——因为编译器各自按自己的约定生成代码,链接器看符号匹配就链接上了。这种 bug 极难查。固化前 -march/-mabi 双方锁死。
六、gp 相对寻址:RISC-V 特有
gp(x3)在启动时被设成 .sdata 段的中点,之后访问 ±2KB 内的小全局变量用 gp 相对寻址,2 条指令变 1 条。
# 不用 gp(2条):
lui a0, %hi(var)
lw a0, %lo(var)(a0)
# 用 gp(1条,若 var 在 gp ±2KB 内):
lw a0, %gprel(var)(gp)
- -msmall-data-limit=8:≤8 字节的全局/静态放 .sdata/.sbss,走 gp。N 别设太大(gp 范围 ±2KB),否则寻址不到。
- 启动汇编必须设 gp:la gp, __global_pointer$。忘了设 gp,gp 相对寻址全崩——这是启动汇编必做 5 件事之一(⑦专题)。
七、ARM vs RISC-V 寄存器/调用约定对照
| 通用寄存器数 | 16(R0-R15) | 32(x0-x31) |
| 返回地址 | LR(R14) | ra(x1) |
| 栈指针 | SP(R13),MSP/PSP 两套 | sp(x2),单栈(软件切) |
| 帧指针 | 可选 R7/R11 | 可选 s0(x8) |
| 参数寄存器 | R0-R3(4个) | a0-a7(8个) |
| callee-saved | R4-R11 | s0-s11 |
| caller-saved | R0-R3,R12,LR | t0-t6,a0-a7,ra |
| 全局指针 | 无 | gp(x3)——RISC-V 特有 |
| 线程指针 | 无(用 PSP) | tp(x4) |
八、本篇小结
- 32 寄存器 + ABI 别名,核心是 caller-saved(t/a/ra) vs callee-saved(s/sp/gp/tp) 的保存责任划分
- ra 是返回地址寄存器,非叶子函数 prologue 必压 ra
- base ISA 是 load-store 架构,运算不碰内存,只有 lw/sw 访存
- 六种指令格式,立即数位段错位拼接(B/J 型),手动解码要拼位段
- RVC 压缩指令 16/32 混合,省 ~30%
- 调用约定:a0-a7 传参、a0-a1 返回、ILP32/LP64 + f/d 后缀决定浮点传递
- ABI 必须全工程一致,ROM 固化尤其要锁死 -march/-mabi
速查表
| 函数调用 | jal ra, func(近)/ call func(远) |
| 函数返回 | ret(=jalr x0,ra,0) |
| 加载立即数 | li(伪指令,展开 lui+addi) |
| 寄存器拷贝 | mv(=addi rd,rs,0) |
| 访问内存 | lw/sw(唯一碰内存的指令) |
| 常量 0 | x0(zero),写它被忽略 |
| 存返回地址 | ra(x1),非叶子函数要压栈 |
| callee-saved | s0-s11,sp(被调方保) |
| caller-saved | t0-t6,a0-a7,ra(调用方保) |
| 参数 | a0-a7(8个) |
| 返回值 | a0-a1(2个) |
| 关 RVC | -march=rv32ima(去 c) |
| gp 寻址范围 | ±2KB,-msmall-data-limit 控 |




