RISC-V 汇编基础
上一课我们把 C 语言的指针和位运算拽到了门电路旁边。现在还有一道缝没填上:sum += a[i] 这么一行字,CPU 里那几十亿个门到底是"照着什么"动起来的?答案是指令——一串二进制码,每一条都对应硬件里一小段被点亮的通路。汇编就是这串二进制的人类可读写法。
这一篇只干一件事:把 RV32I 这套指令集讲透到"你能手写一个循环、并且知道每条指令在硬件里意味着什么"的程度。
1. 指令集是硬件与软件之间的合同
CPU 不认识 C,也不认识 Java。它只认识一件事:从内存里取一个 32 位的数,按事先约定好的规则解码,然后驱动相应的电路。这份"事先约定好的规则"就是 ISA(Instruction Set Architecture,指令集架构)。
ISA 规定了三样东西,一样都不能少:
| ISA 规定什么 | 具体内容 | 为什么必须由 ISA 定 |
|---|---|---|
| 有哪些寄存器 | RV32I:32 个 32 位通用寄存器 | 编译器要知道往哪儿放变量 |
| 有哪些指令 | add / lw / beq …… | 二进制码怎么解码是硬连线的 |
| 指令怎么编码 | 每条 32 位,字段位置固定 | 取指译码电路按位切分 |
本质一句话:ISA 是一份合同——编译器承诺只生成合同内的指令,硬件承诺合同内的指令都能正确执行。x86、ARM、RISC-V 就是三份不同的合同。
RISC-V 这份合同的特点是模块化:RV32I 是必须实现的基础整数指令集(32 位,Integer),只有约 40 条指令;乘除法(M)、原子操作(A)、单精度浮点(F)、压缩指令(C)都是可选扩展,拼起来写作 RV32IMAC。所以"精简"不是指指令少得不能用,而是指基础集合小、扩展靠拼。
2. 32 个格子:寄存器堆
RV32I 有 32 个通用寄存器,编号 x0–x31,每个 32 位。它们是 CPU 里唯一"零延迟"的存储——所有算术运算的操作数都必须先在寄存器里。
x0 值得单独说一句。它硬连线为 0,写它没有任何效果。看似浪费了一个格子,实际上它让一大批操作免费复用了现成指令:
| 想做的事 | 借 x0 实现 | 说明 |
|---|---|---|
| 寄存器清零 | add t0, x0, x0 |
不需要专门的 clear 指令 |
| 装载小常数 | addi t0, x0, 42 |
不需要专门的 load-immediate |
| 寄存器拷贝 | addi t1, t0, 0 |
不需要专门的 mov |
| 无条件不做事 | addi x0, x0, 0 |
这就是 nop |
| 判断是否为 0 | beq t0, x0, L |
不需要专门的 test 指令 |
3. 三类指令,一张表讲完
RV32I 的日常用法可以粗暴归成三类。先记住这张表,后面全是它的展开:
| 类别 | 代表指令 | 语义 | 关键限制 |
|---|---|---|---|
| 算术 / 逻辑 | add sub and or xor slt |
寄存器 ← 寄存器 op 寄存器 | 三个操作数都是寄存器 |
| 带立即数 | addi andi ori slli srli srai |
寄存器 ← 寄存器 op 常数 | 立即数只有 12 位(slli 系为 5 位) |
| 访存 | lw lh lb sw sh sb |
寄存器 ↔ 内存 | 只有这类能碰内存 |
| 控制流 | beq bne blt bge jal jalr |
改写 PC | 分支偏移以 2 字节为单位 |
最需要提前咬住的一条规则:RISC-V 是 load/store 架构。除了 lw/sw 这一类,没有任何指令能直接操作内存。C 里一行 a[i] += 1,在 RISC-V 里必然拆成"取出来 → 算 → 写回去"三步。x86 可以写 add [rax], 1 一步搞定,RISC-V 不行——这是有意的取舍:指令简单了,流水线才好做。
4. 每条指令 32 位:编码格式与 12 位立即数之谜
RV32I 的每条指令都是恰好 32 位,字段位置严格固定。译码电路不需要先"读一遍看多长",直接按位切开就行。
这张图解释了两件常被当成"死记硬背"的事:
- 为什么寄存器是 32 个:字段留了 5 位,
2^5 = 32。多一个都编不进去。 - 为什么
addi的常数不能超过 2047:I 型只剩 12 位放立即数,还得留一位做符号。想装addi t0, x0, 100000汇编器会直接报错,得改用lui+addi两条拼(伪指令li就是自动帮你拼这个)。
5. 访存:lw / sw、偏移寻址与小端
访存指令的写法是 lw rd, offset(rs1):地址 = 寄存器 rs1 的值 + 立即数 offset。这个"基址 + 偏移"的形式不是随便设计的——它刚好覆盖了两个最常见的场景:结构体字段(基址是对象指针,偏移是字段位置)和栈上局部变量(基址是 sp,偏移是槽位)。
三个必须记住的细节:
- 加载有宽度和符号之分:
lw(4 字节)、lh(2 字节,符号扩展)、lhu(2 字节,零扩展)、lb/lbu同理。把char当成有符号还是无符号读错,一个 0xFF 就会变成 −1 或 255 两个结果。 - 偏移也是 12 位有符号。所以
lw t0, 5000(a0)编不进去,必须先addi把基址推过去。 - 对齐:
lw要求地址是 4 的倍数。RV32I 规范允许实现不支持非对齐访问,真实的嵌入式核上一个非对齐lw就是一发异常。
6. 控制流:把 C 的 for 循环拆成分支
RISC-V 没有"标志位寄存器"这种东西。x86 是 cmp 先设 flags、再 jz 看 flags;RISC-V 把比较和跳转压进了同一条指令:
| 指令 | 含义 | 有符号? |
|---|---|---|
beq rs1, rs2, L |
相等则跳 | — |
bne rs1, rs2, L |
不等则跳 | — |
blt rs1, rs2, L |
rs1 < rs2 则跳 | 有符号 |
bge rs1, rs2, L |
rs1 ≥ rs2 则跳 | 有符号 |
bltu / bgeu |
同上 | 无符号 |
有符号和无符号版本的区别是实打实的坑:比较 -1 和 1,blt 认为 −1 小(对),bltu 把 −1 当成 0xFFFFFFFF,认为它更大。指针和长度比较请用 u 版,带负数的循环变量用普通版。
把一个 C 循环手工翻译一遍,对应关系就清楚了:
1 | int sum_array(int *a, int n) { |
对应的 RV32I 汇编(a0 = 数组基址,a1 = n,返回值放 a0):
1 | sum_array: |
两处值得停一下:
- 循环条件被取反了。C 写的是"
i < n就继续",汇编写的是"i >= n就跳走"。因为分支指令只能表达"满足则跳",而循环体在顺序位置上紧跟其后。 slli t2, t1, 2就是 C 里隐式的指针算术。a[i]在 C 里看着像一次索引,在机器上是"乘元素大小、加基址、再访存"三条指令。这也是为什么遍历int数组和遍历 40 字节的结构体数组,代价并不一样。
7. 让它真的跑起来:60 行的 RV32I 解释器
汇编最难受的一点是"看不见状态"。与其装一整套交叉编译工具链,不如直接写一个只支持上面这几条指令的迷你解释器——寄存器就是一个长度 32 的数组,内存就是一个 bytearray,取指译码执行三步循环。
1 | MASK = 0xFFFFFFFF |
运行输出(只打印累加那一步):
1 | step pc 指令 t0(和) t1(i) t4(取到) |
八个元素求和,跑掉了 60 条指令——平均每个元素 7 条多。这个数字很有意思:C 里那句 s += a[i] 看着是"一次操作",在机器层面是循环判断、地址计算、访存、累加、自增、回跳六件事。真机上编译器会把它优化掉一大半(把 &a[i] 变成递增指针省掉 slli+add,把回跳换成末尾判断少跳一次),但优化不会改变这个事实:高级语言的一行,是机器的一小段。
8. 那些"其实不存在"的指令
上面代码里的 li、mv、j、ret、nop 都不是真指令,是伪指令(pseudo-instruction)——汇编器负责翻译成真指令:
| 伪指令 | 汇编器实际展开 | 备注 |
|---|---|---|
li t0, 42 |
addi t0, x0, 42 |
常数超 12 位时展开成 lui + addi |
mv t1, t0 |
addi t1, t0, 0 |
|
nop |
addi x0, x0, 0 |
写 x0,等于什么都没干 |
j label |
jal x0, label |
返回地址扔进 x0 即丢弃 |
ret |
jalr x0, 0(ra) |
跳到 ra 保存的地址 |
not t0, t1 |
xori t0, t1, -1 |
全 1 异或 = 按位取反 |
beqz t0, L |
beq t0, x0, L |
伪指令是"人类友好层",反汇编时看到的往往是展开后的样子。碰上 objdump 输出里全是 addi x0, x0, 0 不要慌,那是一串 nop。
9. 坑合集
| 坑 | 现象 | 原因 / 正确做法 |
|---|---|---|
| 立即数溢出 | 汇编器报 immediate out of range |
I 型只有 12 位有符号,用 li 让汇编器拼 lui+addi |
写 x0 没反应 |
addi x0, t0, 0 之后 x0 还是 0 |
x0 硬连线为 0,写入被丢弃——这是特性不是 bug |
srli vs srai |
负数右移后变成巨大正数 | srli 补 0,srai 补符号位;有符号数除以 2 要用 srai |
blt 比较指针 |
大地址被判成"小于" | 地址是无符号语义,用 bltu/bgeu |
非对齐 lw |
嵌入式核上直接异常 | 地址须 4 字节对齐;按字节读再拼 |
忘了 t 与 s 的区别 |
函数调用回来后 t0 变了 |
t0–t6 调用者保存,跨调用要用 s0–s11 或压栈 |
| 分支目标太远 | relocation truncated |
B 型分支偏移 ±4KB,远跳要用 jal/jalr |
倒数第二条那个"跨调用寄存器被改"的问题,是手写汇编函数时几乎必踩的一个:它背后是一整套调用约定——谁保存哪些寄存器、参数怎么传、栈帧怎么开。写任何跨函数的汇编之前,这本账必须先算清楚。
10. 寄存器机与栈机:一个中性对照
如果你同时写 C 和 Java,会注意到两边的"底层"长得不太一样。JVM 字节码是栈式虚拟机,RISC-V 是寄存器式真实机器。同一个 a + b:
| RISC-V(寄存器机) | JVM 字节码(栈机) | |
|---|---|---|
a + b 怎么写 |
add t0, t1, t2 |
iload_1; iload_2; iadd |
| 操作数在哪 | 显式指定 3 个寄存器编号 | 隐式取栈顶两个 |
| 指令长度 | 固定 32 位 | 变长(多数 1 字节) |
| 指令条数 | 少(1 条) | 多(3 条) |
| 编码密度 | 低 | 高 |
| 谁负责分配寄存器 | 编译器(静态) | JIT 在运行时把栈操作映射到真实寄存器 |
两种设计各有其道理:栈式编码紧凑、跨平台分发方便、不必假设目标机有多少寄存器;寄存器式一条指令干更多事、更贴近硬件、便于流水线与乱序执行。JVM 选前者是因为它要在"任何机器"上分发;RISC-V 选后者是因为它本身就是那台机器。字节码最终仍要被 JIT 翻译成某种寄存器机指令——很可能就是 RISC-V 或 ARM 的这几条。
11. 🐾 小结
| 要点 | 一句话 |
|---|---|
| ISA 是什么 | 硬件与编译器之间的合同:寄存器 + 指令 + 编码 |
| RV32I 规模 | 约 40 条基础整数指令,扩展(M/A/F/C)按需拼 |
| 32 个寄存器 | 因为编码字段留了 5 位;x0 恒 0,撑起一批伪指令 |
| load/store 架构 | 只有 lw/sw 一类能碰内存,算术必须先进寄存器 |
| 12 位立即数 | R 型腾出的 funct7+rs2 正好 12 位,范围 −2048…2047 |
| 分支设计 | 比较与跳转合成一条,没有标志位;注意有符号/无符号两套 |
a[i] 的真身 |
移位算偏移 + 加基址 + lw,三条指令 |
| 伪指令 | li/mv/j/ret/nop 都是汇编器的糖,真指令是 addi/jal/jalr |
| 最大的收获 | 高级语言的一行 = 机器的一小段;心里有这把尺,性能直觉才立得住 |

