🚀 Hazard 解决:Forwarding 和 Stall 实现
流水线让 CPU 变快。
但它也带来了一个新问题:
👉 指令之间会互相“撞车”
这就是 Hazard(冒险 / 冲突)。
如果不处理,CPU 跑得再快也没用,因为结果会错。
🔍 一、Hazard 到底是什么?
在流水线 CPU 里,多条指令同时运行在不同阶段。
这很快。
但也意味着:
- 后面的指令可能提前需要前面的结果
- 分支结果可能还没出来
- 资源可能被抢占
👉 所以 Hazard 本质上就是:
流水线中的时序冲突
🧩 二、最常见的是 Data Hazard
先看一个例子:
|
1 2 3 |
ADD x1, x2, x3 SUB x4, x1, x5 |
第一条指令:
- 计算
x1 = x2 + x3
第二条指令:
- 需要马上读取
x1
问题来了:
👉 第一条指令的结果,通常要到 WB(Write Back)阶段 才真正写入寄存器
👉 但第二条指令在 ID / EX 阶段 就已经要用了
所以第二条读到的,很可能还是旧值。
⚠️ 三、如果不处理,会发生什么?
我们把它放进 5-stage pipeline 看一下:
| Cycle | Instr 1 | Instr 2 |
|---|---|---|
| 1 | IF | |
| 2 | ID | IF |
| 3 | EX | ID |
| 4 | MEM | EX |
| 5 | WB | MEM |
在 Cycle 4:
- 第一条
ADD的结果刚从 ALU 出来 - 第二条
SUB已经在 EX 阶段要用x1
👉 但这时 x1 还没写回寄存器
所以结果错误。
🔥 四、解决方法1:Forwarding(旁路)
Forwarding 是最核心的解决方案。
它的思想非常直接:
既然结果已经算出来了,就不要等写回寄存器,直接送给后面的 ALU
🧠 举个例子
|
1 2 3 |
ADD x1, x2, x3 SUB x4, x1, x5 |
正常情况:
ADD在 EX 阶段算出结果- 要等到 WB 才写回
x1
Forwarding:
ADD在 EX 阶段的输出,直接转发给SUB的输入
👉 不等写回
👉 直接旁路
🏗️ 五、Forwarding 的硬件逻辑
你需要比较:
- 当前 EX 阶段指令的源寄存器
rs1 / rs2 - 前一条或前两条指令的目标寄存器
rd
如果匹配,就说明可以转发。
Forwarding 判断规则
EX hazard
如果上一条指令要写回寄存器,并且:
|
1 2 |
EX/MEM.rd == ID/EX.rs1 |
或者
|
1 2 |
EX/MEM.rd == ID/EX.rs2 |
就从 EX/MEM 转发。
MEM hazard
如果上上一条指令的结果在 MEM/WB 阶段:
|
1 2 |
MEM/WB.rd == ID/EX.rs1 |
或者
|
1 2 |
MEM/WB.rd == ID/EX.rs2 |
就从 MEM/WB 转发。
💻 六、Forwarding Unit Verilog 示例
下面是一个最小 Forwarding Unit:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
module forwarding_unit ( input [4:0] id_ex_rs1, input [4:0] id_ex_rs2, input [4:0] ex_mem_rd, input ex_mem_regwrite, input [4:0] mem_wb_rd, input mem_wb_regwrite, output reg [1:0] forward_a, output reg [1:0] forward_b ); always @(*) begin // default: no forwarding forward_a = 2'b00; forward_b = 2'b00; // EX hazard if (ex_mem_regwrite && (ex_mem_rd != 0) && (ex_mem_rd == id_ex_rs1)) forward_a = 2'b10; if (ex_mem_regwrite && (ex_mem_rd != 0) && (ex_mem_rd == id_ex_rs2)) forward_b = 2'b10; // MEM hazard if (mem_wb_regwrite && (mem_wb_rd != 0) && !(ex_mem_regwrite && (ex_mem_rd != 0) && (ex_mem_rd == id_ex_rs1)) && (mem_wb_rd == id_ex_rs1)) forward_a = 2'b01; if (mem_wb_regwrite && (mem_wb_rd != 0) && !(ex_mem_regwrite && (ex_mem_rd != 0) && (ex_mem_rd == id_ex_rs2)) && (mem_wb_rd == id_ex_rs2)) forward_b = 2'b01; end endmodule |
🔄 七、Forwarding MUX 怎么接?
Forwarding Unit 只告诉你“该转发谁”。
真正的数据路径里,还需要 MUX(多路选择器) 来切换 ALU 输入。
比如 ALU 的 A 输入:
00→ 原始寄存器值10→ 从 EX/MEM 转发01→ 从 MEM/WB 转发
示例写法
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
always @(*) begin case (forward_a) 2'b00: alu_in1 = id_ex_reg1; 2'b10: alu_in1 = ex_mem_alu_result; 2'b01: alu_in1 = mem_wb_write_data; default: alu_in1 = id_ex_reg1; endcase end always @(*) begin case (forward_b) 2'b00: alu_in2 = id_ex_reg2; 2'b10: alu_in2 = ex_mem_alu_result; 2'b01: alu_in2 = mem_wb_write_data; default: alu_in2 = id_ex_reg2; endcase end |
⚠️ 八、为什么仅有 Forwarding 还不够?
因为有一种情况,Forwarding 也救不了:
Load-Use Hazard
看这个例子:
|
1 2 3 |
LW x1, 0(x2) ADD x3, x1, x4 |
问题:
LW的数据不是在 EX 阶段算出来- 它要等到 MEM 阶段访问内存之后 才真正拿到数据
但下一条 ADD 在 EX 阶段已经想用 x1
👉 这时即使你想转发,也没有数据可转
所以必须:
让流水线暂停一个周期
这就是 Stall
🛑 九、解决方法2:Stall(暂停)
Stall 的本质是:
让后面的指令等等
具体做法:
- PC 不更新
- IF/ID 寄存器不更新
- 往 ID/EX 插入一个空操作(bubble / NOP)
这样就能给 LW 多一个周期,把数据准备好。
💻 十、Hazard Detection Unit Verilog 示例
下面是最经典的 load-use 检测逻辑:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
module hazard_detection_unit ( input id_ex_memread, input [4:0] id_ex_rd, input [4:0] if_id_rs1, input [4:0] if_id_rs2, output reg pc_write, output reg if_id_write, output reg control_stall ); always @(*) begin // default: no stall pc_write = 1'b1; if_id_write = 1'b1; control_stall = 1'b0; if (id_ex_memread && ((id_ex_rd == if_id_rs1) || (id_ex_rd == if_id_rs2)) && (id_ex_rd != 0)) begin pc_write = 1'b0; if_id_write = 1'b0; control_stall = 1'b1; end end endmodule |
🧠 十一、control_stall 是怎么工作的?
当检测到 load-use hazard:
pc_write = 0
→ PC 不前进if_id_write = 0
→ 当前 IF/ID 保持不变control_stall = 1
→ 把控制信号清零,相当于插入 NOP
插入 bubble 的常见写法
|
1 2 3 4 5 |
assign regwrite_ctrl = control_stall ? 1'b0 : decoded_regwrite; assign memread_ctrl = control_stall ? 1'b0 : decoded_memread; assign memwrite_ctrl = control_stall ? 1'b0 : decoded_memwrite; assign aluop_ctrl = control_stall ? 4'b0000 : decoded_aluop; |
👉 这样进入 EX 阶段的就是一条“什么都不做”的假指令
📈 十二、Forwarding 和 Stall 的关系
很多人会误解:
有了 Forwarding,就不需要 Stall
这是错的。
正确关系是:
- Forwarding:解决大多数算术数据依赖
- Stall:解决 forwarding 无法覆盖的情况,尤其是 load-use hazard
👉 这两个要一起用
🧩 十三、一个完整例子
看这段代码:
|
1 2 3 4 5 |
ADD x1, x2, x3 SUB x4, x1, x5 LW x6, 0(x1) ADD x7, x6, x8 |
怎么处理?
第1、2条
SUB 依赖 ADD
👉 用 Forwarding
第3条
LW 用 x1
👉 也可以通过 forwarding 拿到 ADD 的结果
第4条
ADD 依赖 LW
👉 必须 Stall 1 个周期
这就是现实 CPU 里最常见的混合场景。
🚨 十四、新手最容易犯的错误
❌ 1. 忘记排除 x0
RISC-V 的 x0 永远是 0,不能被当成正常写回目标
❌ 2. MEM hazard 覆盖 EX hazard
如果同时匹配,应该优先取最近的数据,也就是 EX/MEM
❌ 3. 只做 forwarding,不做 stall
这样一跑 load 指令就错
❌ 4. stall 时只停 PC,不插 bubble
这样流水线状态会乱掉
🔥 十五、最重要的认知
Hazard 处理的本质,不是“修 bug”。
而是:
让流水线在有依赖时,仍然保持正确和高效
也就是说:
- Forwarding 是“少等”
- Stall 是“该等就等”
真正的 CPU 设计,不是单纯追求快,而是:
在正确的前提下尽量不浪费周期
🚀 十六、你现在完成了什么?
如果你已经有:
- 5-stage pipeline
- forwarding unit
- hazard detection unit
- stall / bubble 插入逻辑
那么你就已经做到了:
一个真正像样的流水线 CPU 核心
这已经不是“教学玩具”了。
📌 总结
- Hazard 是流水线中的时序冲突
- 最常见的是 data hazard
- Forwarding 用来直接转发结果,减少等待
- Stall 用来处理 load-use 这类无法立即转发的情况
- 两者必须配合使用
👉 一句话:
流水线 CPU 快,不是因为没有依赖,而是因为它知道什么时候该绕开,什么时候该等待
下一篇:
《Branch Hazard:分支为什么会让流水线失速?》
你将看到:
- 为什么
BEQ会打断流水线 - Flush 是怎么实现的
- 静态预测和动态预测的基本思路