上一课我们把 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 个通用寄存器,编号 x0x31,每个 32 位。它们是 CPU 里唯一"零延迟"的存储——所有算术运算的操作数都必须先在寄存器里。

RV32I 寄存器堆(ABI 约定的角色) x0 = zero 恒为 0,写入被丢弃 x1 = ra 返回地址 x2 = sp 栈指针 x3 = gp / x4 = tp 全局指针 / 线程指针,日常极少手动碰 x5–x7, x28–x31 = t0–t6 临时寄存器 调用者保存:函数调用后内容可能已被改掉 x8–x9, x18–x27 = s0–s11 保存寄存器 被调用者保存:谁改谁负责恢复,跨调用安全 x10–x17 = a0–a7 参数 / 返回值 前 8 个整型参数走 a0–a7;返回值放 a0(必要时 a0+a1 拼 64 位)

共 32 个 × 32 位 = 128 字节。这就是 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 位,字段位置严格固定。译码电路不需要先"读一遍看多长",直接按位切开就行。

R 型:add rd, rs1, rs2 (三个寄存器) funct7 [7] rs2 [5] rs1 [5] funct3 rd [5] opcode bit 31 ←──────────────────────────────────────────────→ bit 0

I 型:addi rd, rs1, imm / lw rd, imm(rs1)
imm [12]
rs1 [5]
funct3
rd [5]
opcode

R 型腾出的 funct7 + rs2 共 12 位 → I 型正好拿去装立即数。
所以 imm 只有 12 位,且是有符号:范围 −2048 … +2047。
rs1/rs2/rd 各 5 位 → 恰好编号 32 个寄存器,这也是"为什么是 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,偏移是槽位)。

int a[4] = {3, 1, 4, 1}; 在内存里(小端,基址 0x1000)

地址低 →




a[0]
a[1]
a[2]
a[3]

0x1000
0x1004
0x1008
0x100C

a[0] = 3 的 4 个字节(小端:低位字节放低地址)
03
00
00
00
lb 取到 0x03;lw 取到 0x00000003
lw t0, 8(a0) → 地址 = a0 + 8 = 0x1008 → 取到 a[2] = 4

三个必须记住的细节:

  1. 加载有宽度和符号之分lw(4 字节)、lh(2 字节,符号扩展)、lhu(2 字节,零扩展)、lb/lbu 同理。把 char 当成有符号还是无符号读错,一个 0xFF 就会变成 −1 或 255 两个结果。
  2. 偏移也是 12 位有符号。所以 lw t0, 5000(a0) 编不进去,必须先 addi 把基址推过去。
  3. 对齐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 同上 无符号

有符号和无符号版本的区别是实打实的坑:比较 -11blt 认为 −1 小(对),bltu 把 −1 当成 0xFFFFFFFF,认为它更大。指针和长度比较请用 u 版,带负数的循环变量用普通版。

把一个 C 循环手工翻译一遍,对应关系就清楚了:

1
2
3
4
5
int sum_array(int *a, int n) {
int s = 0;
for (int i = 0; i < n; i++) s += a[i];
return s;
}

对应的 RV32I 汇编(a0 = 数组基址,a1 = n,返回值放 a0):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sum_array:
li t0, 0 # s = 0
li t1, 0 # i = 0
loop:
bge t1, a1, done # if (i >= n) 跳出——注意条件取反了
slli t2, t1, 2 # t2 = i * 4 (左移 2 位 = 乘 4,一个 int 4 字节)
add t3, a0, t2 # t3 = &a[i]
lw t4, 0(t3) # t4 = a[i]
add t0, t0, t4 # s += a[i]
addi t1, t1, 1 # i++
j loop
done:
mv a0, t0 # 返回值放 a0
ret # 跳回 ra

两处值得停一下:

  • 循环条件被取反了。C 写的是"i < n 就继续",汇编写的是"i >= n 就跳走"。因为分支指令只能表达"满足则跳",而循环体在顺序位置上紧跟其后。
  • slli t2, t1, 2 就是 C 里隐式的指针算术a[i] 在 C 里看着像一次索引,在机器上是"乘元素大小、加基址、再访存"三条指令。这也是为什么遍历 int 数组和遍历 40 字节的结构体数组,代价并不一样。

7. 让它真的跑起来:60 行的 RV32I 解释器

汇编最难受的一点是"看不见状态"。与其装一整套交叉编译工具链,不如直接写一个只支持上面这几条指令的迷你解释器——寄存器就是一个长度 32 的数组,内存就是一个 bytearray,取指译码执行三步循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
MASK = 0xFFFFFFFF

def s32(x): # 无符号 32 位 -> 有符号
x &= MASK
return x - (1 << 32) if x & 0x80000000 else x

ABI = {"zero":0,"ra":1,"sp":2,"a0":10,"a1":11,
"t0":5,"t1":6,"t2":7,"t3":28,"t4":29}

def rnum(name):
if name in ABI: return ABI[name]
if name.startswith("x"): return int(name[1:])
raise ValueError(name)

PROG = """
li t0, 0
li t1, 0
loop:
bge t1, a1, done
slli t2, t1, 2
add t3, a0, t2
lw t4, 0(t3)
add t0, t0, t4
addi t1, t1, 1
j loop
done:
mv a0, t0
"""

# 汇编:拆标签
labels, code = {}, []
for line in PROG.strip().splitlines():
line = line.split("#")[0].strip()
if not line: continue
if line.endswith(":"):
labels[line[:-1]] = len(code); continue
op, _, rest = line.partition(" ")
args = [a.strip() for a in rest.split(",") if a.strip()]
code.append((op, args))

# 执行环境:寄存器 + 一小片"内存"(字节数组)
reg = [0] * 32
mem = bytearray(64)
DATA = [3, 1, 4, 1, 5, 9, 2, 6]
for i, v in enumerate(DATA):
mem[i*4:i*4+4] = v.to_bytes(4, "little") # 小端存放
reg[ABI["a0"]] = 0 # 数组基址 = 内存偏移 0
reg[ABI["a1"]] = len(DATA) # n

pc, steps = 0, 0
print(f"{'step':>4} {'pc':>3} {'指令':<20} {'t0(和)':>7} {'t1(i)':>6} {'t4(取到)':>8}")
while pc < len(code):
op, a = code[pc]
nxt = pc + 1
if op == "li": reg[rnum(a[0])] = int(a[1]) & MASK
elif op == "mv": reg[rnum(a[0])] = reg[rnum(a[1])]
elif op == "addi": reg[rnum(a[0])] = (reg[rnum(a[1])] + int(a[2])) & MASK
elif op == "add": reg[rnum(a[0])] = (reg[rnum(a[1])] + reg[rnum(a[2])]) & MASK
elif op == "slli": reg[rnum(a[0])] = (reg[rnum(a[1])] << int(a[2])) & MASK
elif op == "lw":
off, _, base = a[1].partition("(")
addr = reg[rnum(base.rstrip(")"))] + int(off)
reg[rnum(a[0])] = int.from_bytes(mem[addr:addr+4], "little")
elif op == "bge":
if s32(reg[rnum(a[0])]) >= s32(reg[rnum(a[1])]): nxt = labels[a[2]]
elif op == "j": nxt = labels[a[0]]
else: raise ValueError(op)
reg[0] = 0 # x0 永远为 0
steps += 1
if op == "add" and a[0] == "t0":
print(f"{steps:>4} {pc:>3} {op+' '+','.join(a):<20} "
f"{reg[ABI['t0']]:>7} {reg[ABI['t1']]:>6} {reg[ABI['t4']]:>8}")
pc = nxt

print(f"\n共执行 {steps} 条指令,a0(返回值)= {reg[ABI['a0']]},期望 = {sum(DATA)}")

运行输出(只打印累加那一步):

1
2
3
4
5
6
7
8
9
10
11
step  pc 指令                     t0(和)  t1(i)   t4(取到)
7 6 add t0,t0,t4 3 0 3
14 6 add t0,t0,t4 4 1 1
21 6 add t0,t0,t4 8 2 4
28 6 add t0,t0,t4 9 3 1
35 6 add t0,t0,t4 14 4 5
42 6 add t0,t0,t4 23 5 9
49 6 add t0,t0,t4 25 6 2
56 6 add t0,t0,t4 31 7 6

共执行 60 条指令,a0(返回值)= 31,期望 = 31

八个元素求和,跑掉了 60 条指令——平均每个元素 7 条多。这个数字很有意思:C 里那句 s += a[i] 看着是"一次操作",在机器层面是循环判断、地址计算、访存、累加、自增、回跳六件事。真机上编译器会把它优化掉一大半(把 &a[i] 变成递增指针省掉 slli+add,把回跳换成末尾判断少跳一次),但优化不会改变这个事实:高级语言的一行,是机器的一小段

8. 那些"其实不存在"的指令

上面代码里的 limvjretnop 都不是真指令,是伪指令(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 字节对齐;按字节读再拼
忘了 ts 的区别 函数调用回来后 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
最大的收获 高级语言的一行 = 机器的一小段;心里有这把尺,性能直觉才立得住