2. RISC-V指令集架构基础:RV32I基础指令集解析、指令编码格式、寄存器约定与ABI、特权架构简介

好,咱们正式开始啃指令集这块硬骨头。很多同学一听到“指令集架构”就觉得是理论课,想打瞌睡。我当年刚接触RISC-V时也这么想,直到在FPGA上跑第一个裸机程序,发现一条lui指令译码死活不对,才老老实实回头把指令编码格式翻了个底朝天。说白了,指令集就是你跟处理器核沟通的“合同”——你写什么二进制,它就得干什么事。

2.1 RV32I基础指令集解析

RV32I是RISC-V的“最小公倍数”子集。它只有不到50条指令,但足够跑一个完整的操作系统。我经常跟团队说:如果你能把RV32I在FPGA上验证透了,后面加乘除法、原子操作、压缩指令,都是锦上添花。

RV32I按功能分成以下几类:

类别 指令数 典型指令 我的验证关注点
整数运算 13 ADD, SUB, AND, OR, XOR, SLL, SRL, SRA 溢出标志?RISC-V没有标志位,全靠软件处理
逻辑运算 4 ANDI, ORI, XORI 立即数符号扩展是否正确
移位运算 6 SLLI, SRLI, SRAI 移位量只取低5位,别多移
加载/存储 8 LB, LH, LW, LBU, LHU, SB, SH, SW 非对齐访问要触发异常
分支跳转 8 BEQ, BNE, BLT, BGE, BLTU, BGEU, JAL, JALR 分支预测错误时的流水线冲刷
控制状态 6 ECALL, EBREAK, FENCE, FENCE.I, CSRRW, CSRRS CSR读写权限检查

关键认知:RV32I没有乘除法指令(那是M扩展的事),没有条件执行(ARM那套),没有标志位寄存器。所有比较结果都通过通用寄存器传递。这简化了硬件,但编译器要更聪明。

我记得第一次用Verilog写RISC-V译码器时,被SLTI和SLTIU的区别坑过——一个有符号比较,一个无符号比较,立即数都要先做符号扩展。如果你在FPGA上验证时发现比较结果反了,八成是符号扩展位没处理好。

2.2 指令编码格式:六种“模具”

RISC-V最优雅的设计之一,就是所有指令都只用六种编码格式。不像x86那种变长指令,译码器写到你怀疑人生。这六种格式分别是:R型、I型、S型、B型、U型、J型。

我习惯把它们想象成六套“模具”,每套模具上有固定的“槽位”(字段),不同的指令往对应的槽位里填不同的值。

RV32I 六种指令编码格式 R型 funct7[31:25] | rs2[24:20] | rs1[19:15] | funct3[14:12] | rd[11:7] | opcode[6:0] 例: ADD x1, x2, x3 → 0000000 00011 00010 000 00001 0110011 I型 imm[31:20] | rs1[19:15] | funct3[14:12] | rd[11:7] | opcode[6:0] 例: ADDI x1, x2, 5 → 000000000101 00010 000 00001 0010011 S型 imm[31:25] | rs2[24:20] | rs1[19:15] | funct3[14:12] | imm[11:7] | opcode[6:0] 例: SW x2, 8(x1) → 0000000 00010 00001 010 01000 0100011 B型 imm[12|10:5] | rs2[24:20] | rs1[19:15] | funct3[14:12] | imm[4:1|11] | opcode[6:0] 例: BEQ x1, x2, label → 立即数分散在4个位置,译码时要拼回去 U型 imm[31:12] | rd[11:7] | opcode[6:0] 例: LUI x1, 0x12345 → 00010010001101000101 00001 0110111 J型 imm[20|10:1|11|19:12] | rd[11:7] | opcode[6:0] 例: JAL x1, label → 立即数同样分散,B型和J型是译码器的噩梦 注意:所有格式的opcode都在[6:0]位置,译码器可以先看opcode再决定用哪套模具

实战技巧:在FPGA验证中,我建议你写一个“指令解码器”的独立测试模块。把六种格式的指令各挑几条,用$display打印出每个字段的值,跟RISC-V规范逐位比对。我曾经用这个方法半小时内抓到了三个译码bug。

2.3 寄存器约定与ABI

RISC-V有32个通用寄存器,每个都是32位宽(RV32)。但硬件并不限制你怎么用它们——寄存器就是寄存器。不过,为了让人写的代码和编译器生成的代码能愉快地合作,大家约定了一套调用约定(Calling Convention),也就是ABI(Application Binary Interface)。

这套约定不是硬性规定,但你不遵守的话,函数调用就会乱套。我见过一个项目,有人把sp(栈指针)当普通寄存器用,结果函数返回时栈指针指向了错误地址,程序直接跑飞。嗯,查这种bug真的很痛苦。

寄存器 ABI名称 用途 谁负责保存
x0 zero 硬连线为0,写入无效 —
x1 ra 返回地址 调用者
x2 sp 栈指针 被调用者
x3 gp 全局指针 —
x4 tp 线程指针 —
x5–x7 t0–t2 临时寄存器 调用者
x8 s0/fp 保存寄存器/帧指针 被调用者
x9 s1 保存寄存器 被调用者
x10–x17 a0–a7 函数参数/返回值 调用者
x18–x27 s2–s11 保存寄存器 被调用者
x28–x31 t3–t6 临时寄存器 调用者

避坑指南:我曾经在验证一个RTOS上下文切换时,发现任务切换后ra寄存器值不对。查了两天才发现,是编译器把ra当临时寄存器用了,但ABI规定ra必须由调用者保存。解决方案:在任务切换的汇编代码中,手动保存和恢复所有“调用者保存”寄存器。

2.4 特权架构简介

RV32I本身只定义了用户模式(U-Mode)下的指令行为。但一个完整的处理器核,尤其是要跑Linux的,必须支持特权架构。RISC-V定义了三个特权级别:

  • 机器模式(M-Mode):最高权限,必须实现。处理异常、中断、物理内存属性。Bootloader和固件跑在这里。
  • 监管模式(S-Mode):可选,用于运行操作系统内核。支持页表、虚拟内存、异常委托。
  • 用户模式(U-Mode):可选,运行应用程序。权限最低,不能直接访问CSR。

在FPGA原型验证中,我建议你从M-Mode开始验证。为什么呢?因为M-Mode是基础,所有异常处理、中断响应、CSR读写都在这里定义。我记得第一次在FPGA上验证mret指令时,从异常处理程序返回后PC值不对,原因是mepc寄存器没有正确更新——嗯,后来发现是译码器把mret当成普通指令处理了,没有做特殊控制。

验证重点:特权架构的验证核心是CSR(Control and Status Registers)。RV32I定义了至少6个必须实现的CSR:mstatus、mepc、mcause、mtvec、mip、mie。每个CSR的读写权限、位域定义、复位值,都要在验证计划中逐条列出。

特权架构还有一个重要概念叫异常委托(Exception Delegation)。简单说,就是M-Mode可以把某些异常类型“委托”给S-Mode处理,这样操作系统可以直接处理缺页异常、系统调用,而不需要每次都陷入M-Mode。我在验证这个功能时,写了一个测试用例:在U-Mode下执行ecall,然后检查medeleg寄存器是否把ecall-from-U-mode委托给了S-Mode。结果发现委托位设置正确,但S-Mode的异常向量表没初始化,导致程序跳转到0地址——又是一个经典的“忘记初始化”bug。