· 5 min read

Data Hazard Resolution in a 5-Stage RISC-V Pipeline: Forwarding & Stall

Implementing dual-path data forwarding (MEM→EX, WB→EX) and load-use hazard detection with single-cycle stall in a pipelined RISC-V processor.

#risc-v #pipeline #hazard #forwarding #deep-dive

TL;DR

双路转发消除 RAW 冒险(0 周期损失),load-use 冒险单周期停顿,零气泡分支冲刷。


1. The Problem: RAW Data Hazards

流水线的本质是时间上的指令级并行——多条指令的不同阶段在同一时刻执行。当后续指令依赖前序指令尚未写回的结果时,Read After Write (RAW) 数据冒险就产生了。

考虑以下指令序列:

Cycle 1:  ADD x1, x2, x3   [IF]
Cycle 2:  ADD x1, x2, x3   [ID]   SUB x4, x1, x5   [IF]
Cycle 3:  ADD x1, x2, x3   [EX]   SUB x4, x1, x5   [ID]
Cycle 4:  ADD x1, x2, x3   [MEM]  SUB x4, x1, x5   [EX]  ← SUB 读 x1
Cycle 5:  ADD x1, x2, x3   [WB]   SUB x4, x1, x5   [MEM]

ADD 在第 5 周期的 WB 阶段才将结果写入寄存器堆,但 SUB 在第 4 周期的 EX 阶段就需要 x1 作为 ALU 操作数。没有转发机制的话,SUB 从寄存器堆读到的是 x1 的旧值——程序行为完全错误。

解决方案的核心思想:不要等数据写回寄存器堆,直接从流水线寄存器中转发(Forward)


2. Forward Unit: Dual-Path Data Forwarding

转发单元在 EX 阶段插入一个 3:1 多路选择器(MUX),根据目标寄存器地址匹配结果,决定 ALU 操作数的来源:

                          Forward MUX (RS1)
              ┌───────────────────────────────────┐
              │                                   │
EX/MEM 寄存器 │  2'b01 (最高优先级: MEM→EX 转发)  │──▶ ALU 操作数 A
  ────────────▶                                   │
              │                                   │
MEM/WB 寄存器 │  2'b10 (次优先级: WB→EX 转发)     │──▶ ALU 操作数 A
  ────────────▶                                   │
              │                                   │
Register File │  2'b00 (默认: 从寄存器堆读取)     │──▶ ALU 操作数 A
  ────────────▶                                   │
              └───────────────────────────────────┘

转发条件判断的核心逻辑——同一套比较器适用于 RS1 和 RS2:

// RS1 转发优先级
always_comb begin
    forward_a = 2'b00; // 默认: RegFile
    if (ex_reg_we && (ex_rd_addr != 5'h0) && (ex_rd_addr == id_rs1_addr))
        forward_a = 2'b01; // MEM 前递 (最高优先级)
    else if (mem_reg_we && (mem_rd_addr != 5'h0) && (mem_rd_addr == id_rs1_addr))
        forward_a = 2'b10; // WB 前递
end

三个判断条件缺一不可:

  1. ex_reg_we / mem_reg_we — 上游指令确实写寄存器(排除 SWBEQ 等不写 rd 的指令)
  2. ex_rd_addr != 5'h0 — 目标不是 x0(RISC-V 硬编码的零寄存器,写入被忽略)
  3. ex_rd_addr == id_rs1_addr — 上游写入的寄存器正好是当前指令要读的源操作数

优先级设计原因: MEM→EX 转发的数据比 WB→EX 更新一个周期(来自更近的流水线阶段),因此必须赋予更高优先级。两个阶段同时匹配时(极端情况),取最新的。


3. Hazard Unit: Load-Use Detection

转发并非万能。当一条 LW(Load Word)指令紧跟一条使用其结果的指令时,转发无法解决问题:

Cycle 1:  LW   x1, 0(x2)   [IF]
Cycle 2:  LW   x1, 0(x2)   [ID]   ADD  x4, x1, x5  [IF]
Cycle 3:  LW   x1, 0(x2)   [EX]   ADD  x4, x1, x5  [ID]
          LW: 计算有效地址          ADD: 解码,发现依赖 x1
Cycle 4:  LW   x1, 0(x2)   [MEM]  ADD  x4, x1, x5  [EX]  ← 转发无意义!
          LW: 从内存读取数据         ADD: 需要 ALU 操作数
          ↑ 数据在 MEM 结束后才可用   ↑ ADD 必须在此时拿到操作数
          时间线冲突,无法转发

根本原因: LW 在 MEM 阶段才从数据存储器拿到数据,但 ADD 在 EX 阶段就需要。即使你在 MEM 结束后转发,ADD 的 EX 阶段已经过去了——ALU 已经用旧值算完了。转发只能从 EX/MEM 或 MEM/WB 拿结果,而 LW 在 EX 阶段的结果是内存地址,不是数据。

因此必须 停顿(Stall)一个周期,让 ADD 的 EX 阶段推迟到 LW 拿到数据之后:

// Load-Use 冒险检测
load_use_hazard = ex_mem_re && (ex_rd_addr != 5'h0) &&
    ((ex_rd_addr == id_rs1_addr) || (ex_rd_addr == id_rs2_addr));

// 控制信号
stall_pipeline = load_use_hazard;  // 停顿 IF 和 ID
flush_ex = load_use_hazard;        // 清空 EX 中的无效指令

停顿期间的流水线状态:

  • PC 冻结 — 不取新指令(IF 阶段停顿)
  • IF/ID 寄存器冻结 — ID 阶段的指令保持不变(重复解码)
  • EX 阶段插入气泡(bubble) — NOP 操作,不产生副作用
  • MEM / WB 正常推进 — LW 继续从内存读取数据

停顿之后,LW 的数据出现在 MEM/WB 流水线寄存器中,此时 ADD 在 ID 阶段,下一周期进入 EX 时可以从 WB 路径转发。总开销:1 个时钟周期


4. Pipeline Flush Logic

冲刷(Flush)是清除已经进入流水线的错误指令。三种场景对应不同的冲刷范围:

场景 1: 分支预测失败 (Branch Mispredict)
┌────────┬────────┬────────┬────────┬────────┐
│   IF   │   ID   │   EX   │  MEM   │   WB   │
│ wrong  │ wrong  │ 正确! │        │        │
│ ← flush         │        │        │        │
└────────┴────────┴────────┴────────┴────────┘
  EX 阶段计算出实际分支结果 → 冲刷 IF/ID 中的错误路径指令

场景 2: Load-Use Hazard
┌────────┬────────┬────────┬────────┬────────┐
│   IF   │   ID   │   EX   │  MEM   │   WB   │
│        │  stall │ bubble │        │        │
│ freeze  │ freeze │ ← flush │        │        │
└────────┴────────┴────────┴────────┴────────┘
  ID 阶段检测到冒险 → 冻结 IF/ID,EX 插入气泡

场景 3: 异常/中断 (Exception / Interrupt)
┌────────┬────────┬────────┬────────┬────────┐
│   IF   │   ID   │   EX   │  MEM   │   WB   │
│ flush  │ flush  │ flush  │ flush  │ trap!  │
└────────┴────────┴────────┴────────┴────────┘
  WB 阶段触发 trap → 冲刷整条流水线 → 跳转到异常处理入口

分支冲刷的优化: 零气泡分支(zero-bubble branch)通过在 ID 阶段提前计算分支条件实现。如果在 ID 阶段就能确定分支方向,IF 阶段取到的下一条指令可能是错误的,但只有 1 条需要冲刷(而非等到 EX 阶段才发现,那时已经取了 2 条错误指令)。配合 BTB(Branch Target Buffer)预测跳转目标地址,可将分支惩罚进一步压缩到 0 周期。


5. Timing Diagram: Forwarding in Action

一个完整的双路转发时序,展示 ADD 的结果如何通过 EX/MEM 路径转发给 SUB:

Cycle 1:  ADD x1, x2, x3   [IF]
Cycle 2:  ADD x1, x2, x3   [ID]   SUB x4, x1, x5   [IF]
Cycle 3:  ADD x1, x2, x3   [EX]   SUB x4, x1, x5   [ID]
Cycle 4:  ADD x1, x2, x3   [MEM]  SUB x4, x1, x5   [EX]
          ┊                                    ↑
          ┊  ADD 的 ALU 结果 (x2+x3)          ┊
          ┊  已经锁存在 EX/MEM 流水线寄存器中  ┊
          ┊                                    ┊
          └──── Forward MUX: 选择 EX/MEM ──────┘
                       操作数 A = forwarded x1

Cycle 5:  ADD x1, x2, x3   [WB]   SUB x4, x1, x5   [MEM]
Cycle 6:                         SUB x4, x1, x5   [WB]

关键观察: SUB 在第 4 周期的 EX 阶段执行 ALU 运算时,操作数 A 来自转发路径而非寄存器堆。结果完全正确,且零停顿。这就是转发的价值——用硬件比较器和 MUX 的延迟(通常 1-2 个门延迟)换取 0 个时钟周期的流水线损失。


6. Verification

验证数据冒险处理的完整性:

冒险类型解决方案流水线损失
RAW (EX→EX)MEM→EX 转发0 周期
RAW (MEM→EX)WB→EX 转发0 周期
Load-Use单周期 Stall + WB→EX 转发1 周期
分支预测失败ID 阶段 Flush0 周期气泡
异常全流水线 FlushN 周期(固定开销)

性能影响量化: 假设 CPI = 1(理想),load-use 频率约 10-15%,分支频率约 15-20%。转发消除的 RAW 冒险原本需要插入 2 个气泡(无转发时每次 RAW 损失 2 周期),转发后降至 0。Load-use 的 1 周期停顿是不可避免的硬件限制。

转发单元的面积开销:2 个 3:1 MUX + 6 组比较器(每路 RS1/RS2 各 3 组地址比较),约占流水线总面积的 3-5%。时序开销:转发 MUX 在 EX 阶段关键路径上增加约 0.1-0.2ns 延迟,在典型 100MHz RISC-V 核心中不构成瓶颈。


Related Articles:

🔙 Back to Projects