第四章:RV32I整数指令集实现
好,咱们今天来啃一块硬骨头——RV32I整数指令集的实现。说实话,这部分内容在RISC-V处理器设计中属于核心中的核心。你想想看,一个CPU能不能正常工作,全靠这些指令能不能正确执行。
RV32I一共有多少条指令?47条。不多不少,刚好够用。我刚开始接触RISC-V时,第一反应是「这么少?」,后来才发现,这正是RISC-V的精妙之处——精简到极致,但功能一个不少。
4.1 指令分类与编码格式
RV32I的指令可以分为六大类:
- 算术运算指令:加减法
- 逻辑运算指令:与、或、异或
- 移位指令:左移、右移
- 比较指令:小于比较(有符号/无符号)
- 分支与跳转指令:条件分支、无条件跳转
- 加载存储指令:内存访问
这些指令对应四种编码格式:R型、I型、S型、B型、U型和J型。嗯,严格来说是六种,但B型和S型、U型和J型在结构上很相似。
核心要点:所有RV32I指令都是32位固定长度,操作码(opcode)统一放在低7位。这个设计让译码器变得非常简单——我当年做第一个版本时,译码逻辑只用了不到100行Verilog。
4.2 算术运算指令实现
算术运算指令包括ADD、ADDI、SUB、LUI、AUIPC。说白了就是加法和减法,再加上两个特殊的立即数加载指令。
ADD和SUB是R型指令,格式如下:
// R型指令格式
// [31:25] funct7 | [24:20] rs2 | [19:15] rs1 | [14:12] funct3 | [11:7] rd | [6:0] opcode
// ADD: funct7=7'b0000000, funct3=3'b000, opcode=7'b0110011
// SUB: funct7=7'b0100000, funct3=3'b000, opcode=7'b0110011
我在项目中遇到过一个问题:ADD和SUB的opcode和funct3完全一样,唯一的区别在funct7的最高位。刚开始写译码器时,我差点把这两个搞混了。后来我养成了一个习惯——把funct7的bit5单独拉出来作为加减法的选择信号。
ADDI是I型指令,把立即数符号扩展后与rs1相加。这里有个坑:立即数是12位的,需要符号扩展到32位。我曾经见过有人忘了做符号扩展,结果负数加法全错了。
LUI和AUIPC比较特殊。LUI加载20位立即数到寄存器的高20位,低12位补0。AUIPC则是把PC加上20位立即数左移12位的值。这两个指令在链接时非常有用,特别是加载大常数时。
个人经验:实现AUIPC时,记得PC值是指令地址本身,不是PC+4。这一点和分支指令不同,我刚开始就搞混了,调试了好几天。
4.3 逻辑运算指令实现
逻辑运算指令包括AND、ANDI、OR、ORI、XOR、XORI。这些指令的实现相对简单,就是按位操作。
以AND为例:
// AND: R型,funct3=3'b111
assign result = rs1 & rs2;
// ANDI: I型,funct3=3'b111
assign result = rs1 & {{20{imm[11]}}, imm};
你想想看,逻辑运算其实就是几个门电路的事。但在处理器中,我们需要把它们统一到ALU里。我一般会在ALU中做一个功能选择信号,用case语句来选择执行哪种运算。
4.4 移位指令实现
移位指令包括SLL、SLLI、SRL、SRLI、SRA、SRAI。左移和逻辑右移比较简单,算术右移需要符号扩展。
这里有个细节:移位量只取低5位(对于RV32I)。也就是说,移位范围是0到31。为什么?因为32位寄存器,移32位以上就没意义了。
// SLL: 逻辑左移
assign result = rs1 << rs2[4:0];
// SRA: 算术右移,需要保持符号位
assign result = $signed(rs1) >>> rs2[4:0];
注意:在Verilog中,算术右移要用$signed()系统函数。我见过有人直接用 >>> 操作符,但忘了把操作数声明为signed类型,结果仿真全对,综合后全错。嗯,这个坑我踩过。
4.5 比较指令实现
比较指令包括SLT、SLTI、SLTU、SLTIU。SLT是set less than,如果rs1小于rs2,rd置1,否则置0。
SLT和SLTU的区别在于:SLT是有符号比较,SLTU是无符号比较。这个区别在硬件实现上很关键。
// SLT: 有符号比较
assign result = ($signed(rs1) < $signed(rs2)) ? 32'd1 : 32'd0;
// SLTU: 无符号比较
assign result = (rs1 < rs2) ? 32'd1 : 32'd0;
我建议在实现比较指令时,把有符号和无符号的比较逻辑分开写。虽然Verilog的<操作符默认是无符号的,但加上$signed()会更清晰。我曾经在代码审查时看到有人把SLT和SLTU写反了,结果整个操作系统的调度都出了问题。
4.6 分支与跳转指令实现
分支指令包括BEQ、BNE、BLT、BGE、BLTU、BGEU。跳转指令包括JAL和JALR。
分支指令是B型格式,跳转指令是J型和I型格式。它们的共同点是都需要计算目标地址。
分支指令的目标地址计算:
// B型指令的立即数编码比较特殊
// imm[12|10:5|4:1|11] 对应指令位 [31|30:25|11:8|7]
assign branch_target = pc + {{19{imm[12]}}, imm[12:0], 1'b0};
跳转指令的目标地址计算:
// JAL: J型,目标地址 = PC + 立即数
assign jal_target = pc + {{11{imm[20]}}, imm[20:0], 1'b0};
// JALR: I型,目标地址 = rs1 + 立即数,且最低位强制为0
assign jalr_target = {rs1[31:1] + imm[31:1], 1'b0};
关键点:分支和跳转指令的目标地址都是按字节对齐的,所以最低位总是0。JALR指令比较特殊,它把rs1的最低位强制为0,这是RISC-V架构的一个设计选择。
我在实现分支指令时,会把比较逻辑和分支逻辑分开。比较逻辑复用比较指令的硬件,分支逻辑负责计算目标地址和判断是否跳转。这样设计的好处是硬件复用率高,代码也清晰。
4.7 加载存储指令实现
加载指令包括LB、LH、LW、LBU、LHU。存储指令包括SB、SH、SW。
加载指令从内存读取数据,存储指令向内存写入数据。这里的关键是字节序(endianness)和对齐问题。
RISC-V使用小端序(little-endian),即低地址存低字节。加载指令需要处理符号扩展和零扩展:
// LW: 加载字,直接读取32位
assign load_data = mem[addr];
// LB: 加载字节,符号扩展到32位
assign load_data = {{24{mem[addr][7]}}, mem[addr][7:0]};
// LBU: 加载无符号字节,零扩展到32位
assign load_data = {24'd0, mem[addr][7:0]};
存储指令需要处理字节掩码:
// SW: 存储字,直接写入32位
// SB: 存储字节,只写入低8位,其他位不变
// SH: 存储半字,只写入低16位,其他位不变
避坑指南:我曾经在实现SB指令时,忘了做字节掩码,结果存储字节时把整个字都覆盖了。调试了整整一个下午才发现问题。所以,存储指令的字节掩码一定要仔细处理。
4.8 整体数据通路设计
好了,上面我们把每条指令都过了一遍。现在来看看整体数据通路怎么设计。
下面这张图展示了RV32I核心的数据通路结构:
从图中可以看出,指令从指令存储器取出后,经过译码得到操作数和立即数,然后送入ALU进行计算,最后写回寄存器堆或访问数据存储器。控制单元负责生成所有模块的控制信号。
我个人习惯把数据通路分成五个阶段:取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)。虽然RV32I是单周期实现,但分阶段思考有助于理解指令的执行流程。
4.9 实现中的注意事项
最后,我总结几个实现RV32I时的常见问题:
- 立即数符号扩展:所有I型、S型、B型指令的立即数都需要符号扩展到32位。U型指令的立即数不需要符号扩展,因为它是20位无符号数。
- 分支目标地址对齐:分支和跳转的目标地址必须是2字节对齐的,因为RISC-V指令是16位或32位对齐的。
- 加载存储对齐:虽然RISC-V允许非对齐访问,但实现起来比较复杂。我建议在初期实现时只支持对齐访问,等系统稳定后再考虑非对齐的情况。
- 寄存器x0:x0是硬连线的0,写入x0的操作会被忽略。这个特性在实现时一定要处理好,否则会出现莫名其妙的问题。
重要提醒:在Verilog实现中,x0的读操作要返回0,写操作要忽略。我见过有人把x0当成普通寄存器来用,结果仿真时数据全乱了。记住,x0不是寄存器,它是一个常数0。
好了,RV32I整数指令集的实现就讲到这里。这些指令虽然看起来简单,但它们是整个RISC-V处理器的基础。把这一块吃透了,后面的乘除法扩展、原子操作、压缩指令扩展都会轻松很多。
公众号:蓝海资料掘金营,微信deep3321