一、单周期CPU数据通路复习
1.1 整体结构概览
从最经典的单周期CPU数据通路图出发,复习整条指令执行路径。推荐亲手画这张图——在这个阶段,一定要画每个模块有哪些连线、跟哪些连接、输出到了什么地方、在什么时候起作用。把这个连接关系画清楚特别重要。
1.2 数据通路核心模块
整个单周期CPU的数据通路包括以下关键组件:
- PC(Program Counter,程序计数器):就是一个加法器,输出地址,每次加1或加4,取决于指令存储器如何组织。严格按RISC-V规范是加4(32位指令)。
- 指令存储器:根据PC地址输出32位(或64位)指令。
- 指令译码(Decode / Control Unit):根据指令的OP和function字段生成各种控制信号(jump、mem to reg、branch、ALU control、shift、立即数等)。
- 寄存器堆(Register File):两个读端口(RS1/RS2给出地址,Q1/Q2输出数据),一个写端口。这是”一个比较特别的存储器”,反应极快但消耗资源多、贵、数量少。
- ALU(算术逻辑单元):纯组合逻辑,信号一来经过一段延时后输出结果。
- 数据存储器(Data Memory):load指令从中取数,store指令往里写数。
- 多路选择器(MUX):在寄存器数据和立即数之间选择,在ALU结果和memory结果之间选择等。
1.3 指令执行流程(以R型为例)
1
2
3
PC → 指令存储器 → 指令取出 → 译码(Decode) →
寄存器堆取数(RS1,RS2) → ALU计算 →
结果送到写回端 → 等待时钟沿 → 写入寄存器堆
电路稳定下来,所有的信号都稳定好,这个时候下一个时钟沿来的时候把它写进去——这是整个的逻辑。
1.4 五件事情:经典五级流水线的雏形
一条指令的执行自然地分为五件大事:
- 取指(Fetch) — 从指令存储器取指令
- 译码(Decode) — 解析指令,生成控制信号,从寄存器堆读数据
- 执行(Execute) — ALU计算
- 访存(Memory) — 访问数据存储器(load/store)
- 写回(Write Back) — 将结果写回寄存器堆
二、流水线(Pipeline)基本原理
2.1 为什么要流水线:从”一个人干”到”五个人分工”
一个任务一个人干不过来,大家一起来同时干。从”人力分工”的角度引入流水线概念:
- 空间并行:多个CPU,每个跑一个程序——太贵
- 时间并行(流水线):把一个任务分成多个阶段,类似装配线
理想状态下,五级流水可以提高五倍吞吐量。提升的是吞吐量(throughput),不是一条指令的执行时间。吞吐永远是计算机生涯中遇到的最大问题,计算倒未必是最大的问题。很多时候是数据传不进来,计算跟不上数据,数据吞吐量跟不上。
2.2 流水线如何工作
每个时钟周期,流水线的每一级都在同时处理一条不同的指令:
1
2
3
4
5
时钟周期1: 指令1 ← 取指
时钟周期2: 指令2 ← 取指 | 指令1 ← 译码
时钟周期3: 指令3 ← 取指 | 指令2 ← 译码 | 指令1 ← 执行
时钟周期4: 指令4 ← 取指 | 指令3 ← 译码 | 指令2 ← 执行 | 指令1 ← 访存
时钟周期5: 指令5 ← 取指 | 指令4 ← 译码 | 指令3 ← 执行 | 指令2 ← 访存 | 指令1 ← 写回
在任何一个时刻,每条指令处于什么阶段,在五级流水里,每级流水里都有一条指令。关键是脑子里要有这个概念——在任何一个时刻这条指令处于什么阶段,不是它有五个阶段,而是它处于什么阶段。
2.3 为什么是五级(不是三级、七级)
五级流水的划分并非随意。从”任务均衡分配”的角度解释:
- 取指:存储器操作
- 译码:组合逻辑
- 执行:组合逻辑(ALU)
- 访存:存储器操作
- 写回:写操作
memory这一大块又不太好分解,写回是一个写操作。所以这五级流水就是这么来的。有一些东西不好分(register file整体、ALU整体)。如果任务分配不均衡,流水线就失去意义——一个人干绝大部分活,另一个人只扫尾,那就跟一个人干差不多。
2.4 搬水桶的比喻:单条指令不会变快
大家排成一排拎水桶搬砖头,你交给我,我就交给他,每次只干一个小事情。一桶水从那边递到目的地,水桶的速度增快了没有?没有。不停的光倒手了,倒不好还掉地上了。对一桶水是没有快的。还要做这件事情,就是因为整体上会快一点。
这个比喻精确地说明了:流水线提升的是整体吞吐率,单条指令的延迟反而会因为流水线寄存器开销而略有增加。
三、流水线寄存器与信号传递
3.1 为什么需要流水线寄存器
在流水线CPU中,同一时刻有多条指令处于不同阶段,每条指令都有自己的控制信号和数据。如果不做隔离,新指令一来就会把前一条指令的状态冲掉。
怎么能达到这么多条指令同时运行还互相不冲突呢?就要靠在电路图中间插入寄存器,把当前状态锁定住。
D触发器的原理:
- 时钟沿到来时 $Q = D$(锁存新值)
- 在时钟沿之间,Q保持不变(稳定输出给下一级使用)
3.2 流水线寄存器的位置与命名
在单周期CPU的数据通路上插入四级流水线寄存器(因为五级之间需要四个间隔):
- IF/ID:取指与译码之间,锁存取出的指令
- ID/EX:译码与执行之间,锁存译码结果、寄存器数据、控制信号
- EX/MEM:执行与访存之间,锁存ALU结果和控制信号
- MEM/WB:访存与写回之间,锁存memory读出的数据和写回控制信号
加上原有就有时钟控制的PC和Register File,整个电路就有了多个时钟同步点。
3.3 控制信号的”陪跑”
控制信号必须在流水线中跟数据一起”陪跑”:control unit生成了各种控制信号,这个控制信号也得跟着一直往后走。在这儿用不用?不用,跟着走。在这儿用不用?也不用,跟着走。只能跟着数据硬跑,因为你必须跟着数据一起陪跑,然后等数据到这儿的时候,你跟他同一个节拍过来,同一阶段过来,然后把它写进去。
控制信号在不同阶段递减——越往后需要的信号越少(例如write enable只需在WB阶段使用)。
3.4 信号稳定与时钟沿
从物理视角看,0变到1在示波器上不是干净漂亮的,上来了以后是有个波动的,有时候这个毛刺还会很高。万一这个时钟沿而来就采样到了0这个位置,那它这上面可就是一直是0,一直到下一个时钟沿来的时候才会采样到这个1。
这就是为什么流水线寄存器必须在时钟沿到来时信号已经稳定下来——否则会采样到错误的毛刺。
四、流水线中的冲突(Hazards)
4.1 冲突的两大类型
流水线中的两类根本冲突:
1
2
3
4
5
流水线冲突:
├── 数据冲突(Data Hazard)
│ └── 我要用的数据还没准备好
└── 控制冲突(Control Hazard)
└── 发现是跳转指令,后面已取入的指令全部作废
4.2 数据冲突的直观例子
第一条指令是把一个memory里的数据存到S2寄存器里。第二条指令上来就要用S2做计算。第一拍才开始取指,第二拍译码好了,第三拍该计算了——数据哪去了?发现这个时候才好(到第五拍才写回)。
这是最经典的 load-use data hazard:load指令在第五拍才把数据写入寄存器,紧接着的下一条指令在第三拍就需要这个数据。
4.3 解决数据冲突的三种方法
方法一:阻塞(Stall / 插nop)
硬件检测到数据依赖后,自动在流水线中停顿(插入bubble)。方案简单但效率低:两个指令冲突需要插三个nop,五条指令变八条。
方法二:编译器调度(Compiler Scheduling / 指令重排)
由编译器在编译时(compile time)分析数据依赖关系,调整指令顺序,或插入nop指令。这相当于把复杂度转移到了编译器一侧——硬件不用改动。
方法三:转发(Forwarding / Bypassing)——硬件解决方案
去银行排队存钱。我要取500,他前面要存200——他直接给我不就可以了吗?大家就不用去ATM机排队排半天了。
核心思想:ALU在EX阶段就已经算出了结果(第三拍),为什么非得等到第五拍写回寄存器堆再读出来呢?直接从流水线后面的阶段”拉根线”把数据转发到前面需要的地方。
4.4 转发(Forwarding)的实现原理
转发需要两条路径:
- 从EX/MEM寄存器转发:前一条指令刚执行完,结果在EX/MEM寄存器中
- 从MEM/WB寄存器转发:前前一条指令的结果在MEM/WB寄存器中
转发判断逻辑(Hazard Detection Unit):
- 比较当前指令的源寄存器编号(RS1/RS2)与前一条指令的目的寄存器编号(RD)
- 如果匹配且前一条指令确实会写寄存器(RegWrite有效),则触发转发
- 通过多路选择器选择转发来的数据而非寄存器堆读出的旧数据
只要是寄存器编号一样就可以——不是数据一样,而是编号。并且确实是要写(RegWrite有效)才行。因为有的时候电路里数据噼里啪啦在变,连上去就有信号,但只有真的要写才说明这个数据是有效的。
4.5 Load-Use Hazard:转发无法完全解决的情况
当load指令后面紧跟一条使用load结果的指令时,数据直到MEM阶段才从data memory出来,无法通过转发回到EX阶段(因为”时空穿梭往回走是没有的”)。
解决方案:必须阻塞一拍(one-cycle stall),让load的数据从MEM/WB寄存器转发给下一条指令。
五、控制冲突(Control Hazard)与分支处理
5.1 控制冲突的本质
第一条指令取指、译码、执行……都特别好。突然等到这条指令发现是跳转!都已经执行到第五条了,后面进来好多人——但是跳转全部作废。
分支指令(BEQ/BNE等)只有在EX阶段才能确定是否跳转,此时后面已经取入了3条指令。如果跳转发生,这3条指令全部作废(flush)。
5.2 分支处理的演进思路
方案一:阻塞(等到结果再走)
最简单的方案——发现分支后停止取指,等ALU算出结果。代价是损失3个周期。
方案二:将分支判断提前到译码阶段
核心思想:
- 在ID阶段增加一个简单的相等比较器(专门用于BEQ/BNE判断)
- 不需要经过完整的ALU,电路极简,速度极快
- 只需比较两个寄存器值是否相等即可确定分支方向
- 将分支代价从”抛弃3条指令”降为”抛弃1条指令”
就这么一个小小的电路就能产生重大的结果。
方案三:分支预测(Branch Prediction)——课外扩展
分支预测在lab中不要求做,但有兴趣可以尝试。
5.3 分支发生时对流水线的刷新(Flush)
当分支跳转发生时:
- 将IF/ID阶段的指令flush掉(清空)
- 将ID/EX阶段的指令也flush掉(如果是原方案)
- 如果分支判断提前到ID阶段,只需flush IF/ID中的一条指令
- PC更新为分支目标地址,从新的地址开始取指
六、硬件与软件设计哲学
6.1 硬件工程师 vs 软件工程师
从流水线设计延伸到了硬件和软件工程的文化差异:
| 维度 | 硬件工程师 | 软件工程师 |
|---|---|---|
| 设计节奏 | 稳定,有现成模块就拿来用 | 快速迭代,”小步快跑” |
| 修改成本 | 极高,”能不修改坚决不修改” | 相对低,重构是常态 |
| 调试难度 | 只能靠打印信号观察 | 有step-by-step调试器 |
| 核心竞争力 | 经验积累,越老越吃香 | 学习能力,熬夜能力 |
| 从业人数 | 少 | 多 |
经常有软件的说”我要代码重构”——在硬件这儿没事别重构,放着能用就不出错就好了。一旦说”我做个重构”,不改变功能,就把代码理顺了——你不知道以前的时候那儿加那个是干什么用的。有时候就是为了延时,放好几个非门……这在逻辑上没有什么意义,但硬件上有时候就这样。
硬件升级就是这么重要的事情。只要硬件一更新,所有的软件无感知地继续在上面加速跑了。钱花了,速度还快了——这就是一个很理想的状况。
6.2 互联网公司的代码观
互联网公司同时提十个业务,可能只有一个能活下来。与其费大力气优化代码,不如等业务活下来后再找高水平程序员重构。但对个人来说,学习提升是自己的事——提升了自然有人付更高工资。
七、Lab实验建议
7.1 流水线CPU实验要点
- 渐进策略:先做单周期能跑通,再加流水线寄存器,组件基本上不会变化,就加几个新组件上去
- 关键模块:register file、control unit、ALU
- 难点提示:register file和control unit是核心,指令理解不能错,控制信号列了不能错
当变到流水线的时候,组件基本上不会变化,就加几个新组件上去——其实就是这个中间加几个阻断。
7.2 扩展挑战(选做)
- Forwarding/Bypass:代码逻辑清晰,加几根线和一个hazard detection模块即可
- 分支预测:不要求,但有兴趣可以尝试
- 建议保留原始版本作为”救生圈”——万一加了新功能破坏原功能可以回退
7.3 调试技巧:小黄鸭调试法
遇到bug了特别难解决的时候,去找一只小橡皮鸭放在自己的桌子上,然后把思路完完整整地、尽可能详细地给它讲一遍。这是一个很有效的方法。因为很多时候你认为大错误不会有——就是在一些你认为”这地方肯定是对的”的地方。仔细讲一遍以后,就发现bug在哪了。
实验展望
- 后续将涉及MMU、特权指令集、中断等话题,建议优先完成这些核心功能。
- 如果精力充沛,可以做forwarding和分支预测的性能优化。
- 鼓励使用大模型辅助学习,但要对整体框架熟悉,成为”有经验的程序员”。