一、流水线回顾与冒险概述
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, R3 → sub R4, R1, R5 → and R6, R1, R7 为例:
add的结果在 WB 阶段才写入寄存器文件(第 5 周期)sub在 EX 阶段就需要R1的值(第 3 周期)and在 EX 阶段也需要R1的值(第 4 周期)
如果不做任何处理,sub 和 and 读到的都是 R1 的旧值——错误。
解决方案的层级:
- 硬件阻塞(Stall):等待前一条指令写回后再执行
- 软件阻塞(插入 NOP):由编译器插入无操作指令
- 转发/旁路(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 转发检测条件
转发需要满足三个条件:
- 前一条指令要写寄存器(RegWrite 信号有效)——如果前一条不写(如 BEQ、SW),没有什么可转发的
- 目的寄存器 ≠ X0(RISC-V 中 X0 硬连线为 0,写 X0 无意义)
- 前一条的目的寄存器 = 当前指令的源寄存器
对于 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 依赖后:
- 冻结 PC:PC 值保持不变,不取新指令
- 冻结 IF/ID 流水线寄存器:当前指令保持在译码阶段
- 在 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 上跑。