第2章 RISC-V微架构基础:经典五级流水线
各位同学,咱们今天聊聊RISC-V最经典的微架构——五级流水线。说实话,这玩意儿是每个做CPU验证的人必须啃透的骨头。我当年刚入行时,师傅丢给我一份五级流水线的Verilog代码,让我先跑通仿真再说。结果呢?一跑就懵了,数据冒险、控制冒险、结构冒险全冒出来了。嗯,今天咱们就把这五级流水线拆开揉碎,讲清楚。
2.1 五级流水线概览
经典的五级流水线,说白了就是把一条指令的执行过程切成五段。每一段由一个独立的硬件模块负责,这样就能实现指令级并行——每个时钟周期都有一条新指令进入流水线,同时有一条旧指令退休。
五级流水线阶段:
- IF(取指):从指令存储器中取出指令
- ID(译码):解析指令,生成控制信号,读取寄存器
- EX(执行):ALU运算或地址计算
- MEM(访存):数据存储器读写
- WB(写回):将结果写回寄存器堆
你想想看,如果没有流水线,一条指令得等上一条完全执行完才能开始。那效率得多低?五级流水线理论上能把吞吐量提升5倍。当然,实际中因为各种冒险,达不到这个理想值。
2.2 取指(IF)与PC逻辑
取指阶段的核心任务就两个:维护程序计数器(PC),从指令存储器中读指令。听起来简单吧?但这里面的坑不少。
2.2.1 PC更新逻辑
PC的更新方式取决于指令类型。正常情况是PC = PC + 4(RV32I每条指令4字节)。遇到分支或跳转时,PC就得跳转到目标地址。
| 指令类型 | PC更新方式 | 说明 |
|---|---|---|
| 顺序执行 | PC = PC + 4 | 默认情况 |
| 分支(条件跳转) | PC = PC + 偏移量 | BEQ、BNE等 |
| 无条件跳转 | PC = PC + 偏移量 | JAL |
| 间接跳转 | PC = rs1 + 偏移量 | JALR |
我曾经踩过的坑:PC更新时序没处理好,导致取指阶段读到的指令地址总是落后一拍。仿真时波形看着没问题,但上板跑程序就乱跳。后来发现是PC寄存器在时钟上升沿更新,而指令存储器是组合逻辑读地址,需要额外一个周期才能读到正确指令。解决办法是把PC更新逻辑改成在时钟下降沿触发,或者加一级流水线缓冲。
2.2.2 指令存储器接口
取指阶段需要每个周期都能读指令。我建议用同步RAM实现指令存储器,读延迟一个周期。这样IF阶段实际上需要两个周期才能完成取指——第一个周期送地址,第二个周期拿到指令。但为了保持五级流水线的节奏,通常会把指令存储器做成组合逻辑读,或者用双端口RAM。
// 简化的PC逻辑(Verilog)
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
pc <= 32'h8000_0000; // 复位地址
else if (branch_taken)
pc <= branch_target;
else if (jal_taken)
pc <= jal_target;
else
pc <= pc + 4;
end
// 指令存储器读取(组合逻辑)
assign instruction = instr_mem[pc[31:2]];
2.3 译码(ID)与立即数生成
译码阶段要做三件事:解析指令类型、生成控制信号、读取寄存器堆。其中立即数生成是个容易出错的地方。
2.3.1 RISC-V指令格式
RISC-V有六种基本指令格式:R型、I型、S型、B型、U型、J型。每种格式的立即数编码位置都不一样。你想想看,如果译码逻辑写错了,立即数算出来就是错的,那后面的运算全完蛋。
| 格式 | 指令示例 | 立即数位置 | 立即数位数 |
|---|---|---|---|
| R型 | ADD rd, rs1, rs2 | 无立即数 | 0 |
| I型 | ADDI rd, rs1, imm | [31:20] | 12 |
| S型 | SW rs2, imm(rs1) | [31:25] + [11:7] | 12 |
| B型 | BEQ rs1, rs2, imm | [31] + [7] + [30:25] + [11:8] | 13 |
| U型 | LUI rd, imm | [31:12] | 20 |
| J型 | JAL rd, imm | [31] + [19:12] + [20] + [30:21] | 21 |
个人经验:写立即数生成逻辑时,我习惯先把每种格式的立即数提取出来,再做符号扩展。千万别把不同格式的立即数混在一起处理。我曾经见过一个设计,把S型立即数的bit[11:5]和bit[4:0]顺序搞反了,结果存数据时地址全错。这种bug在仿真里很难发现,因为地址错位后数据还能写进去,只是写到了错误的位置。
2.3.2 寄存器堆读取
寄存器堆一般用同步写、异步读的方式实现。译码阶段需要读rs1和rs2两个源操作数。这里有个经典问题:如果当前指令的rs1正好是上一条指令的rd,而且上一条指令还没写回,那就发生了数据冒险。
// 立即数生成逻辑示例
wire [31:0] imm_i = { {20{instruction[31]}}, instruction[31:20] };
wire [31:0] imm_s = { {20{instruction[31]}}, instruction[31:25], instruction[11:7] };
wire [31:0] imm_b = { {19{instruction[31]}}, instruction[31], instruction[7],
instruction[30:25], instruction[11:8], 1'b0 };
wire [31:0] imm_u = { instruction[31:12], 12'b0 };
wire [31:0] imm_j = { {11{instruction[31]}}, instruction[31], instruction[19:12],
instruction[20], instruction[30:21], 1'b0 };
2.4 执行(EX)与ALU设计
执行阶段是ALU大显身手的地方。加减乘除、移位、逻辑运算,全在这里完成。对于RISC-V来说,ALU需要支持RV32I基础指令集的所有运算。
2.4.1 ALU功能划分
我个人习惯把ALU分成几个功能单元:算术单元、逻辑单元、移位单元、比较单元。这样设计的好处是各单元可以独立优化,也方便后续扩展乘除法指令。
| 功能单元 | 支持运算 | 延迟(典型) |
|---|---|---|
| 算术单元 | ADD, SUB, ADDI | 1周期 |
| 逻辑单元 | AND, OR, XOR, ANDI, ORI, XORI | 1周期 |
| 移位单元 | SLL, SRL, SRA, SLLI, SRLI, SRAI | 1周期 |
| 比较单元 | SLT, SLTU, SLTI, SLTIU | 1周期 |
2.4.2 ALU控制信号生成
ALU做什么运算,由译码阶段生成的ALU控制信号决定。我建议用独热码(one-hot)编码,这样控制逻辑简单,也不容易出错。
// ALU控制信号(独热码)
localparam ALU_ADD = 6'b000001;
localparam ALU_SUB = 6'b000010;
localparam ALU_AND = 6'b000100;
localparam ALU_OR = 6'b001000;
localparam ALU_XOR = 6'b010000;
localparam ALU_SLT = 6'b100000;
// ALU实现
always @(*) begin
case (alu_ctrl)
ALU_ADD: alu_out = src_a + src_b;
ALU_SUB: alu_out = src_a - src_b;
ALU_AND: alu_out = src_a & src_b;
ALU_OR: alu_out = src_a | src_b;
ALU_XOR: alu_out = src_a ^ src_b;
ALU_SLT: alu_out = ($signed(src_a) < $signed(src_b)) ? 32'b1 : 32'b0;
default: alu_out = 32'b0;
endcase
end
注意:ALU的时序路径往往是整个流水线的关键路径。特别是加法器,进位链太长会导致时序违例。我在一个项目中遇到过ALU加法器路径延迟太大,导致EX阶段无法在一个时钟周期内完成。解决办法是把加法器拆成两级流水,或者用进位选择加法器(Carry Select Adder)优化。如果频率要求不高,也可以用行波进位加法器,但一定要在综合时检查时序报告。
2.5 流水线冒险与处理策略
五级流水线不是完美的。指令之间会有依赖关系,导致三种冒险:数据冒险、控制冒险、结构冒险。这里我重点讲数据冒险和控制冒险,因为结构冒险在RISC-V里相对少见(除非你的存储器端口不够)。
2.5.1 数据冒险
数据冒险发生在当前指令需要用到上一条指令的计算结果,但结果还没写回寄存器。典型的场景是:
add x1, x2, x3 // x1在WB阶段才写回
add x4, x1, x5 // ID阶段读x1,但x1还没写回
解决数据冒险有三种方法:
- 插入气泡(Stall):暂停流水线,等数据准备好。简单但损失性能。
- 转发(Forwarding):把EX或MEM阶段的结果直接送给ALU,不用等写回。这是最常用的方法。
- 编译器调度:在编译时插入无关指令,避免冒险。需要编译器支持。
转发逻辑的核心思想:如果当前指令的rs1/rs2等于前面某条指令的rd,而且那条指令的结果已经算出来了(在EX或MEM阶段),就直接把结果从那里拿过来,不用等写回寄存器堆。
2.5.2 控制冒险
控制冒险发生在分支指令。取指阶段取了分支后面的指令,但分支结果还没出来。如果分支跳转了,那已经取进来的指令就是错的,必须冲刷掉。
我常用的策略是:
- 预测不跳转:默认继续取顺序下一条指令。如果分支跳转,冲刷已取的指令,从目标地址重新取指。
- 分支延迟槽:在分支后面放一条总是执行的指令。RISC-V没有强制要求延迟槽,但有些实现会加。
- 分支预测:用BTB(分支目标缓冲器)预测分支方向。这个比较复杂,咱们后面章节再细讲。
嗯,五级流水线的基础就这些。你想想看,每个阶段都不复杂,但组合在一起,再加上冒险处理,就变得很有意思了。做验证的时候,我建议你先把每个阶段单独验证,再联调流水线。特别是数据冒险的转发逻辑,一定要写定向测试用例覆盖所有可能的转发路径。