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 的核心知识点串起来了。你可以把它当作本章的“地图”——学完后再回来看,应该能理解每个模块之间的关联。
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 在仿真里很难发现,因为编译器通常不会生成边界值。我的建议是:写一个随机测试用例,专门测立即数的边界值。