流水线冒险

流水线冒险分类、转发机制与Load-Use阻塞解决方案

Posted by CloudingYu on March 23, 2026

一、流水线回顾与冒险概述

1.1 五级流水线与流水线寄存器

回顾五级流水线结构:

阶段 缩写 功能
取指 IF 从指令存储器读取指令
译码 ID 解析指令、读寄存器
执行 EX ALU 运算或地址计算
访存 MEM 读写数据存储器
写回 WB 将结果写回寄存器文件

四级流水线寄存器(PC 不计数)将五个阶段分隔开。信号跟着数据一起向后流动——每个阶段用到的信号在该阶段结束后可以丢弃,不需要的则继续向后传递。

在任何一个阶段,有效的信号一定是当前这条指令起效果的。保留在流水线寄存器中的信息一起被传递到下一个流水段。

1.2 流水线冒险的三种类型

实际程序中流水线无法完美执行,会遇到三类冒险(Hazard):

类型 英文 成因
结构冒险 Structure Hazard 两条指令同时需要同一硬件资源
数据冒险 Data Hazard 前后指令存在数据依赖关系
控制冒险 Control Hazard 分支/跳转导致后续指令不确定

测试用例中每条指令都是 5 级流水完整执行,但实际使用中不会有那么多理想情况。这里重点讲数据冒险的处理。


二、寄存器文件与时钟设计

2.1 读口与写口的时序分离

寄存器文件(Register File)有一组读口(Read Port)和一个写口(Write Port)。为了避免同时读写同一个寄存器时出现不稳定,需要用时序来错开:

  • 前半周期(下降沿):组合逻辑计算、信号传播
  • 后半周期(上升沿后):写使能有效,数据写入寄存器

目的是让信号经过前面的组合逻辑计算稳定后,再进行写操作。如果不分开,信号变化会导致电路震荡、写入错误的值。

2.2 双读口设计的必要性

RISC-V 的 R 型指令有两个源寄存器(rs1、rs2),因此寄存器文件需要两个读口(RA、RB),可以在同一个周期内同时读出两个寄存器的值。

把 I-MEM 和 D-MEM 分开、寄存器文件做双读口——这些看起来简单的设计,在历史上都曾是专利。但本质上就是”遇到冲突就多来一套”的思路。


三、控制冒险与 MIPS 延迟槽

3.1 分支指令的时序问题

分支指令(如 BEQ)需要等到 EX 阶段完成比较,才能确定是否跳转:

  • EX 阶段:计算出两个寄存器是否相等
  • 第七个周期:转移地址送到 PC
  • 第八个周期:根据转移地址取指令

但在等待期间,后续指令已经进入了流水线(取指→译码→执行)。如果不跳转,这些指令正常执行;如果跳转,这些指令全部需要抛弃(flush)。

3.2 MIPS 的延迟槽设计

MIPS 延迟槽(Branch Delay Slot):MIPS 架构规定——分支指令的下一条指令无论如何都会被执行(不论是否跳转)。

动机:

  • 既然取出来的指令已经在执行了,”别浪费”
  • 通过编译器把一条与分支无关的指令调度到延迟槽中

代价

  • 硬件电路变复杂(需要额外处理异常、中断时延迟槽指令的提交)
  • 编译器/软件调度变复杂
  • 阅读二进制代码时需要特别小心(下一条指令会被执行)

就像吃了第七个饼饱了,就说前六个饼没必要——随着发展大家发现,多执行一条指令换来的好处带来了硬软件复杂度的巨大代价。现代 RISC-V 已经不再使用延迟槽。

3.3 Load 指令引发的延迟

Load 指令的结果必须等到 MEM 阶段才能获得(其他 R/I 型指令 EX 阶段就有结果)。如果不做任何处理,后续依赖该结果的指令需要等待 3 个周期


四、数据冒险与转发(Forwarding)

4.1 数据依赖的类型

两条指令对同一寄存器执行读/写操作,排列组合后有三种有意义的依赖:

依赖类型 英文 含义 是否成问题
写后读 RAW (Read After Write) 先写后读,后一条用前一条的结果 最频繁、必须处理
读后写 WAR (Write After Read) 先读后写,写覆盖了读需要的值 乱序时才成问题
写后写 WAW (Write After Write) 两条指令写同一寄存器 乱序时才成问题
读后读 RAR 两条指令读同一寄存器 不成问题

正常流水线不会发生 WAR 和 WAW——因为指令按顺序流动,读一定在写之前,写也一定按顺序。但后续讲乱序执行时,这两种依赖就需要认真处理了。

4.2 非转发情况下的数据通路

以指令序列 add R1, R2, R3sub R4, R1, R5and R6, R1, R7 为例:

  • add 的结果在 WB 阶段才写入寄存器文件(第 5 周期)
  • sub 在 EX 阶段就需要 R1 的值(第 3 周期)
  • and 在 EX 阶段也需要 R1 的值(第 4 周期)

如果不做任何处理,suband 读到的都是 R1旧值——错误。

解决方案的层级:

  1. 硬件阻塞(Stall):等待前一条指令写回后再执行
  2. 软件阻塞(插入 NOP):由编译器插入无操作指令
  3. 转发/旁路(Forwarding/Bypassing):将前一条指令的结果直接从流水线寄存器旁路给后一条

4.3 转发路径

ALU 的结果一旦算出(EX 阶段结束)就可以被后续指令使用,不需要等到写回寄存器文件。

三条转发路径:

转发路径 来源 去向 用途
EX/MEM → EX 上一条指令的 ALU 结果 当前指令的 ALU 输入 最常用的 RAW 转发
MEM/WB → EX 上上条指令的结果 当前指令的 ALU 输入 隔一条指令的依赖
MEM/WB → MEM Load 结果 Store 的数据输入 Store 指令依赖前面的 Load

ALU 出来不能直接给寄存器文件——那会导致电路震荡。ALU 结果出来一定是先给下一级的 ALU 输入,等稳定后再写回寄存器。

4.4 转发检测条件

转发需要满足三个条件:

  1. 前一条指令要写寄存器(RegWrite 信号有效)——如果前一条不写(如 BEQ、SW),没有什么可转发的
  2. 目的寄存器 ≠ X0(RISC-V 中 X0 硬连线为 0,写 X0 无意义)
  3. 前一条的目的寄存器 = 当前指令的源寄存器

对于 R 型指令有两个源寄存器(RS、RT),两个都需要检测。

4.5 多级转发的优先级

当隔一条和隔两条的指令都需要转发同一个寄存器时(例如连续两条指令都写了 R1),应该使用最近的那条指令的结果:

  • 优先使用 EX/MEM 的转发数据(上一条指令的结果)
  • 如果 EX/MEM 没有命中,再使用 MEM/WB 的转发数据(上上条指令的结果)

当 C1 和 C2 都为 1 时,应该取 C1 = 1、C2 = 0——转近的不转远的。因为上上条的结果已经被上条覆盖了。

4.6 Forwarding Unit 的实现

Forwarding Unit 独立于流水线,是一个组合逻辑模块:

  • 接收所有阶段的寄存器编号和数据
  • 输出 MUX 选择信号,决定 ALU 输入来自寄存器文件还是转发路径
  • 控制逻辑实际上很简单——约 50 行代码即可完成

图画了一大片看起来复杂,但写成代码可能就 50 行。不过这 50 行代码不理解了就写成一团。


五、必须阻塞的情况:Load-Use Hazard

5.1 为什么转发不够

转发只能把已经计算出来的结果提前送出。但 Load 指令的结果要到 MEM 阶段才能获得,而紧接着的下一条指令在 EX 阶段就需要这个数据——时间上差了 1 个周期。

转发无法解决,必须阻塞一个周期

5.2 阻塞检测逻辑

在 ID 阶段检测:

1
2
3
条件:前一条指令是 Load(MemRead 有效)
      且 前一条指令的目的寄存器 == 当前指令的任一源寄存器
→ 触发 Stall

5.3 阻塞的实现

检测到 Load-Use 依赖后:

  1. 冻结 PC:PC 值保持不变,不取新指令
  2. 冻结 IF/ID 流水线寄存器:当前指令保持在译码阶段
  3. 在 EX 阶段插入气泡(Bubble / NOP):将 EX 阶段的控制信号清零(相当于一条空指令通过)

经过一个周期的阻塞后,Load 的结果到达 MEM/WB 阶段,可以通过转发路径送给下一条指令的 EX 阶段。

实现方法可以停时钟(clock gating),也可以不停时钟而通过使能信号阻止寄存器更新。加一个开关逻辑——当 stall 有效时,always @(posedge clk & !stall) 就行。

5.4 编译器的作用

编译器可以在一定程度上通过指令调度减少 Load-Use 阻塞的影响:

  • 在 Load 和 Use 之间插入一条无关的指令
  • 关键在于正确识别代码的依赖结构,在不破坏语义的前提下调整指令顺序

编译器说着简单,实际上要考虑很多因素。RISC-V 的指令集简单,写编译器的同学就开心;x86 的复杂指令集做编译器调度就麻烦得多。


六、流水线冒险处理方案总结

冒险类型 现象 解决方案
结构冒险 两条指令争用同一硬件 分离 I-MEM/D-MEM、寄存器文件双读口
数据冒险(RAW-ALU) 后续指令需要 ALU 结果 转发/旁路(Forwarding)
数据冒险(RAW-Load) 后续指令需要 Load 结果 阻塞 1 周期 + 转发
控制冒险 分支指令不确定跳转与否 冲刷流水线(flush)+ 分支预测(后续讲)

七、硬件-软件接口的权衡

7.1 复杂度的分配

历史上硬件和软件之间存在持续的”推诿战”:

策略 代表 做法
硬件承担复杂 x86 / CISC 硬件做更多事,指令复杂但功能强大
软件承担复杂 RISC-V / RISC 硬件尽量简化,编译器来优化指令调度

Intel IA-64 安腾是典型反面案例——英特尔信心满满地说让编译器来适应我们的 CPU,结果直接翻车。这说明不能把所有事情都推给软件。

7.2 二进制翻译与生态壁垒

当硬件指令集不同时,有一种实用的解决方案:二进制翻译(Binary Translation)——将一种指令集的二进制代码逐条翻译为另一种指令集。

NVIDIA 真正的护城河不是 GPU 硬件本身——GPU 相对好造,国产也能做。真正的壁垒是 CUDA 生态——全世界的 AI 工程师都在 CUDA 上开发,你让他们针对你的 GPU 重新开发几乎不可能。这就是为什么大家要做二进制翻译——把 CUDA 的二进制翻译到自己的 GPU 上跑。