2. RISC-V指令集架构基础
好,我们正式开始啃 RISC-V 最核心的 RV32I 基础指令集。这部分是 RISC-V 的基石,一共 40 多条指令,但别被数字吓到——它们分类清晰,每条指令都有明确用途。我个人习惯把这部分分成五类来学:整数运算、逻辑移位、比较分支、跳转和 Load/Store。今天我们就逐个击破。
整数运算指令:加减与立即数
整数运算指令是 CPU 最基础的操作。RV32I 里只有加法(ADD)和减法(SUB),没有乘除——乘除在 M 扩展里。为什么这么设计?说白了,RISC-V 追求极简,把复杂操作留给软件组合或可选扩展。
核心指令速览:
ADD rd, rs1, rs2—— rd = rs1 + rs2SUB rd, rs1, rs2—— rd = rs1 - rs2ADDI rd, rs1, imm12—— rd = rs1 + 符号扩展的 12 位立即数
这里有个细节:ADDI 的立即数是符号扩展的。这意味着你可以用 ADDI 加负数,变相实现减立即数。我在项目中遇到过有人写 ADDI x5, x5, -1 来实现自减,完全没问题。
另外,RISC-V 没有 SUBI 指令。为什么?因为 ADDI 加负数已经覆盖了。你想想看,少一条指令,硬件就少一个解码分支,面积和功耗都能省一点。
逻辑指令:与或异或
逻辑指令包括 AND、OR、XOR 以及它们的立即数版本 ANDI、ORI、XORI。这些指令按位操作,常用于掩码、置位、清零和校验。
| 指令 | 功能 | 典型用途 |
|---|---|---|
AND rd, rs1, rs2 |
按位与 | 提取位域、清零特定位 |
OR rd, rs1, rs2 |
按位或 | 置位特定位 |
XOR rd, rs1, rs2 |
按位异或 | 翻转位、校验 |
ANDI rd, rs1, imm12 |
立即数与 | 快速掩码 |
ORI rd, rs1, imm12 |
立即数或 | 快速置位 |
XORI rd, rs1, imm12 |
立即数异或 | 快速翻转 |
避坑指南:我曾经在调试一个 SPI 控制器时,发现数据总是不对。查了半天,原来是掩码写错了——ANDI 的立即数只有 12 位,符号扩展后高位全是 1,把不该清的数据也清了。记住:立即数只有 12 位,高位会符号扩展!
移位指令:逻辑左移、逻辑右移、算术右移
移位指令有三条:SLL(逻辑左移)、SRL(逻辑右移)、SRA(算术右移)。它们都有立即数版本:SLLI、SRLI、SRAI。
逻辑右移和算术右移的区别,嗯,这里要注意:逻辑右移高位补 0,算术右移高位补符号位(即原数的最高位)。
// 假设 x5 = 0x80000001(最高位为 1)
SRLI x6, x5, 1 // x6 = 0x40000000(高位补 0)
SRAI x7, x5, 1 // x7 = 0xC0000000(高位补 1,保持符号)
为什么需要算术右移?因为对有符号数做除法时,算术右移相当于除以 2 并向下取整。我在做定点数运算时经常用到这个特性。
比较指令:SLT 与 SLTU
比较指令只有两条:SLT(有符号小于)和 SLTU(无符号小于)。它们把比较结果(0 或 1)写入目标寄存器。
SLT rd, rs1, rs2—— 若 rs1 < rs2(有符号),则 rd = 1,否则 rd = 0SLTU rd, rs1, rs2—— 若 rs1 < rs2(无符号),则 rd = 1,否则 rd = 0SLTI rd, rs1, imm12—— 立即数版本的有符号比较SLTIU rd, rs1, imm12—— 立即数版本的无符号比较
常见陷阱:有符号和无符号比较的结果可能完全不同。比如 0xFFFFFFFF 和 0x00000001:有符号下 0xFFFFFFFF 是 -1,小于 1;无符号下 0xFFFFFFFF 是 4294967295,远大于 1。选错比较指令,条件判断就全错了。
分支跳转指令:控制流的灵魂
分支指令改变程序执行流。RV32I 有 6 条条件分支指令和 2 条无条件跳转指令。
条件分支(BEQ、BNE、BLT、BGE、BLTU、BGEU):
- BEQ:相等则跳转
- BNE:不等则跳转
- BLT:有符号小于则跳转
- BGE:有符号大于等于则跳转
- BLTU:无符号小于则跳转
- BGEU:无符号大于等于则跳转
无条件跳转(JAL、JALR):
- JAL:跳转并链接(保存返回地址到 rd)
- JALR:通过寄存器间接跳转并链接
这里有个设计巧思:JAL 的 rd 如果设为 x0(零寄存器),就相当于普通跳转,不保存返回地址。RISC-V 用这种方式减少指令数量,一条 JAL 同时覆盖了函数调用和普通跳转。
分支指令的编码格式:分支指令的立即数编码比较特殊,不是连续的 12 位,而是分散在指令的不同位置。这是为了硬件实现时能更快地计算目标地址。嗯,设计者做了取舍——软件编译时多花点功夫解码,硬件执行时就能快一点。
Load/Store 指令:与内存打交道
Load/Store 指令是 CPU 访问内存的唯一途径。RISC-V 是典型的 Load/Store 架构——只有这两类指令能访问内存,运算指令只能在寄存器之间操作。
Load 指令:
- LB:加载字节(有符号扩展)
- LH:加载半字(有符号扩展)
- LW:加载字
- LBU:加载字节(无符号扩展)
- LHU:加载半字(无符号扩展)
Store 指令:
- SB:存储字节
- SH:存储半字
- SW:存储字
注意 Load 有符号扩展和无符号扩展的区别。LB 把 8 位数据符号扩展到 32 位,LBU 则高位补 0。选错的话,数据值可能完全不对。
// 假设内存地址 0x1000 处存放 0x80
LB x5, 0(x0) // x5 = 0xFFFFFF80(符号扩展)
LBU x6, 0(x0) // x6 = 0x00000080(无符号扩展)
我的经验:在 SoC 集成调试时,Load/Store 指令是最容易出问题的。我曾经遇到一个 bug:CPU 读外设寄存器时总是读到 0,查了两天才发现是地址对齐问题——外设要求 4 字节对齐访问,而我用了 LB 读一个 32 位寄存器的低字节。记住:外设寄存器通常要求按字访问,别用字节指令去读。
知识体系总览
下面这张图把 RV32I 基础指令集的结构梳理清楚了。你可以把它当作速查地图。
RV32I 这 40 条指令,每条我都亲手写过对应的汇编代码,也在 FPGA 上跑过。说实话,刚开始觉得它们太简单,后来才发现——正是这种简单,让 RISC-V 的硬件实现异常干净,也让调试变得可控。你想想看,如果每条指令都像 x86 那样变长编码、隐含副作用,光解码器就能把人逼疯。