2. FPGA基础回顾

好,咱们正式开始啃指令集这块硬骨头。说实话,RISC-V 的 RV32I 是我见过最清爽的基础指令集——总共就 40 多条指令,但麻雀虽小五脏俱全。你想想看,一个能跑 Linux 的处理器核,底层就是靠这些指令撑起来的。

我个人习惯把 RV32I 分成六大类来理解:算术运算、逻辑运算、移位、内存访问、条件分支、无条件跳转。每类指令都有固定的格式,学起来其实很有规律。

核心要点:RV32I 是 RISC-V 的“最小公倍数”——所有 RISC-V 处理器都必须实现它。你做软核,第一步就是把这 40 多条指令的译码和执行逻辑写对。

2.1 指令格式:六种基本类型

RISC-V 的指令长度固定为 32 位,但根据操作数的来源和去向,分成了六种格式。我刚开始学的时候觉得有点多,后来发现其实就两种核心模式:寄存器-寄存器 和 寄存器-立即数,其他都是变种。

格式 用途 典型指令 立即数位数
R 型 寄存器-寄存器运算 ADD, SUB, AND, OR, XOR, SLL, SRL, SRA, SLT, SLTU 无
I 型 立即数运算 / 加载 ADDI, ANDI, ORI, XORI, SLLI, SRLI, SRAI, SLTI, SLTIU, LB, LH, LW, LBU, LHU, JALR 12 位(有符号)
S 型 存储 SB, SH, SW 12 位(有符号,拆分两段)
B 型 条件分支 BEQ, BNE, BLT, BGE, BLTU, BGEU 12 位(有符号,偶数对齐)
U 型 高位立即数加载 LUI, AUIPC 20 位(高 20 位)
J 型 无条件跳转 JAL 20 位(有符号,偶数对齐)

这里有个细节我提醒你注意:S 型和 B 型的立即数都是拆成两段放的。为什么这么设计?说白了是为了让指令译码器能复用硬件——立即数的 bit 位置和寄存器索引位置不冲突。我在写第一版译码器时没注意这个,结果立即数拼接逻辑写错了,调试了两天才发现。

我的经验:写 Verilog 译码时,先把每种格式的立即数提取函数写好,单独测试。比如 imm_I = { {20{inst[31]}}, inst[31:20] },这种写法在仿真里一眼就能看出符号扩展对不对。

2.2 寄存器约定与 ABI

RISC-V 有 32 个通用寄存器,每个 32 位宽(RV32)。但硬件上它们是完全对称的——没有哪个寄存器有特殊功能。那为什么还要搞 ABI?因为编译器需要约定:哪个寄存器存返回值、哪个存栈指针、哪个是临时变量。没有约定,不同模块编译出来的代码就没法互相调用。

我画了一张寄存器约定的速查表,你写汇编或者看反汇编时经常要用到:

寄存器 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 临时寄存器 否

踩坑提醒:我曾经在写软核的 C 语言编译器支持时,忘了把 sp(x2)初始化为有效 RAM 地址。结果第一条函数调用就把栈写到了 0 地址,整个系统直接挂掉。记住:上电后第一件事就是初始化 sp!

2.3 指令编码细节:从汇编到机器码

咱们拿一条最简单的 ADD x1, x2, x3 来拆解。这条指令把 x2 和 x3 相加,结果写入 x1。它的机器码是 0x003100B3,二进制展开后:

31      25 24    20 19    15 14    12 11     7 6       0
┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│ funct7  │ rs2     │ rs1     │ funct3  │ rd      │ opcode  │
│ 0000000 │ 00011   │ 00010   │ 000     │ 00001   │ 0110011 │
└─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘

你看,R 型指令的 opcode 是 0110011(0x33),funct3 是 000,funct7 是 0000000。译码器就是靠这些字段的组合来区分指令的。我建议你在写 Verilog 时,把 opcode 和 funct3 的译码做成 case 语句,funct7 只在 R 型指令里用——这样逻辑清晰,也容易 debug。

再来看一条 I 型指令:ADDI x1, x2, 0xFF。它的机器码是 0xFF210113:

31      20 19    15 14    12 11     7 6       0
┌───────────┬─────────┬─────────┬─────────┬─────────┐
│ imm[11:0] │ rs1     │ funct3  │ rd      │ opcode  │
│ 1111111111 │ 00010   │ 000     │ 00001   │ 0010011 │
└───────────┴─────────┴─────────┴─────────┴─────────┘

注意立即数 0xFF 是正数,所以高 20 位全是 0。如果是负数,比如 ADDI x1, x2, -1,立即数就是 0xFFF,符号位扩展到 32 位就是 0xFFFFFFFF。

调试技巧:我习惯在仿真波形里把指令的各个字段(opcode、rs1、rs2、rd、funct3、funct7、imm)单独拉出来显示。这样一旦指令执行结果不对,一眼就能看出是译码错了还是运算单元错了。

2.4 知识体系总览

下面这张 SVG 图把 RV32I 的核心知识点串起来了。你可以把它当作本章的“地图”——学完后再回来看,应该能理解每个模块之间的关联。

RV32I 指令集架构知识体系 RV32I 指令集架构知识体系 RV32I 基础整数指令集 六种指令格式 32 个通用寄存器 六大指令类别 R 型 I 型 S 型 B 型 U 型 J 型 x0 zero x1 ra x2 sp x10-x17 a0-a7 x5-x7 t0-t2 x8-x9 s0-s1 算术运算 逻辑运算 移位 内存访问 条件分支 无条件跳转 核心原则:所有指令长度固定 32 位,译码逻辑统一 立即数符号扩展、寄存器索引、opcode 译码是三大基本功

2.5 动手实践:用 Verilog 写一个译码器框架

光看不练假把式。我建议你现在就打开编辑器,写一个最简单的译码器模块。不需要实现全部指令,先搞定 R 型和 I 型的 ADD/ADDI 就行。下面是我常用的框架:

module decoder (
    input  [31:0] inst,
    output        valid,
    output [ 6:0] opcode,
    output [ 2:0] funct3,
    output [ 6:0] funct7,
    output [ 4:0] rs1,
    output [ 4:0] rs2,
    output [ 4:0] rd,
    output [31:0] imm
);

    assign opcode = inst[6:0];
    assign funct3 = inst[14:12];
    assign funct7 = inst[31:25];
    assign rs1    = inst[19:15];
    assign rs2    = inst[24:20];
    assign rd     = inst[11:7];

    // 立即数提取(仅示例 I 型和 R 型)
    wire [31:0] imm_i = { {20{inst[31]}}, inst[31:20] };

    // 指令有效判断
    assign valid = (opcode == 7'b0110011) ||  // R-type
                   (opcode == 7'b0010011);    // I-type

endmodule

这段代码虽然简单,但它是整个软核的“入口”。你之后添加新指令,就是在这个框架上扩展 funct3 和 funct7 的 case 分支。嗯,这里要注意:opcode 的位宽是 7 位,但实际有效的低 2 位通常是 11——这是 RISC-V 为了未来扩展预留的。

又一个坑:我曾经在写立即数提取时,把 I 型指令的立即数范围搞错了。ADDI 的立即数是 12 位有符号,范围是 -2048 到 2047。如果你传了 2048,它会被截断成 0。这种 bug 在仿真里很难发现,因为编译器通常不会生成边界值。我的建议是:写一个随机测试用例,专门测立即数的边界值。