第2章 RISC-V微架构基础:经典五级流水线

各位同学,咱们今天聊聊RISC-V最经典的微架构——五级流水线。说实话,这玩意儿是每个做CPU验证的人必须啃透的骨头。我当年刚入行时,师傅丢给我一份五级流水线的Verilog代码,让我先跑通仿真再说。结果呢?一跑就懵了,数据冒险、控制冒险、结构冒险全冒出来了。嗯,今天咱们就把这五级流水线拆开揉碎,讲清楚。

2.1 五级流水线概览

经典的五级流水线,说白了就是把一条指令的执行过程切成五段。每一段由一个独立的硬件模块负责,这样就能实现指令级并行——每个时钟周期都有一条新指令进入流水线,同时有一条旧指令退休。

五级流水线阶段:

  • IF(取指):从指令存储器中取出指令
  • ID(译码):解析指令,生成控制信号,读取寄存器
  • EX(执行):ALU运算或地址计算
  • MEM(访存):数据存储器读写
  • WB(写回):将结果写回寄存器堆

你想想看,如果没有流水线,一条指令得等上一条完全执行完才能开始。那效率得多低?五级流水线理论上能把吞吐量提升5倍。当然,实际中因为各种冒险,达不到这个理想值。

经典五级流水线结构 IF 取指 ID 译码 EX 执行 MEM 访存 WB 写回 IF/ID ID/EX EX/MEM MEM/WB 关键点: 流水线寄存器(红色)保存上一阶段的执行结果,并在下一时钟周期传递给下一阶段。 每个时钟周期,所有阶段同时工作,但处理的是不同的指令。

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(分支目标缓冲器)预测分支方向。这个比较复杂,咱们后面章节再细讲。

嗯,五级流水线的基础就这些。你想想看,每个阶段都不复杂,但组合在一起,再加上冒险处理,就变得很有意思了。做验证的时候,我建议你先把每个阶段单独验证,再联调流水线。特别是数据冒险的转发逻辑,一定要写定向测试用例覆盖所有可能的转发路径。