组合/时序逻辑与流水线
前两篇我们把 C 翻译成了 RISC-V、把函数调用拆成了栈帧。但还有一个更底层的问题没回答:一条指令从"二进制"变成"动作",在硬件里到底经历了什么? 这一篇往下钻到门电路和时钟,再往上拉到流水线——看同一颗 CPU 是怎么靠"让多条指令重叠执行"把吞吐翻几倍的,以及翻倍的代价(冒险)。
一句话定位:组合逻辑管"算",时序逻辑管"记",流水线管"快",冒险管"坑"。
1. 组合逻辑:没有记忆的电路
组合逻辑(combinational logic)的定义是——输出只取决于当前输入,和过去发生过什么无关。给它同一组输入,永远得到同一组输出,它肚子里不存任何状态。
最小例子是半加器(half adder):两个 1 比特相加。
1 | a ──┬── XOR ── sum |
| a | b | sum (a⊕b) | carry (a·b) |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 1 | 1 | 0 |
| 1 | 0 | 1 | 0 |
| 1 | 1 | 0 | 1 |
用 C 描述就是个纯函数,没有任何"上次的值"参与:
1 | // 半加器:纯函数,无状态、无记忆 |
只要 a、b 给定,sum 和 carry 就唯一确定。这就是组合逻辑的本质——一个布尔函数 y = f(输入)。真值表 → 布尔表达式 → 门电路,三者等价。
组合逻辑是有"速度上限"的。每个门从输入变化到输出稳定需要传播延迟 t_pd,一条链路上延迟最长那条路径叫关键路径(critical path),它决定了这段逻辑能被时钟驱动的最快频率。
1 | ┌──────────────────────┐ |
2. 时序逻辑:靠时钟"记住"东西
组合逻辑算得再快,也算不出"上一个数加 1"——因为它不记得上一个数。要让电路有记忆,得引入时序逻辑(sequential logic):输出不仅看当前输入,还看之前的状态。靠两个东西实现:存储元件(触发器 / 锁存器)+ 时钟(clock)。
最干净的存储元件是 D 触发器(D flip-flop):在时钟的上升沿,把 D 上的值采样进 Q;在两个上升沿之间,不管 D 怎么变,Q 纹丝不动。
1 | ┌─────────┐ |
用 C 模拟一个寄存器(注意它和组合逻辑函数的区别——多了一个"时间"维度):
1 |
|
1 | 初始 q = 0 |
关键点:状态只在边沿更新的那个瞬间变化,之后保持到下一个边沿。这一下把"时间"正式引入了电路——所有时序逻辑都在同一个时钟的节拍下整齐地迈步。
3. Setup / Hold:采样不是瞬间完成的
教科书里常说"上升沿采样",好像边沿那一纳秒数据就被吃进去了。现实是触发器采样需要一小段时间窗口,于是有了两条铁律:
- Setup time(建立时间):时钟边沿之前,数据必须已经稳定下来的最短时间。
- Hold time(保持时间):时钟边沿之后,数据必须继续保持稳定的最短时间。
1 | CLK ──┐ ┌──┐ ┌── |
如果数据在窗口里抖动,触发器可能进入亚稳态(metastability)——输出在 0 和 1 之间悬停不确定,下游逻辑读到什么全看运气。这就是为什么高速电路里"跨时钟域"要专门做同步器。
记住 setup/hold 这个"时间窗口",下一篇讲冒险时你会发现:前一条指令写的结果要等它的写回(WB)完成、下一个时钟边沿才真正落到寄存器里,后面的指令在这个边沿之前读不到——这才是数据冒险的根。
4. 从"单周期"到"流水线":为什么要重叠
先打个比方。洗 3 桶衣服,每桶都要 洗 → 烘 → 叠 三步,每步 1 小时。
非流水线(串行):洗完第 1 桶的全部 3 步,再洗第 2 桶……总 9 小时。
1 | 时间 → |
流水线:第 1 桶洗完进烘干机,同时开始洗第 2 桶;第 1 桶烘完进叠、第 2 桶进烘、第 3 桶开洗……三步在不同桶上重叠。
1 | 时间 → |
CPU 一模一样:把一条指令拆成多段,让不同指令的不同段同时占用不同的硬件单元。单条指令没变快(还是那么多步),但单位时间里完成的指令数上去了——这就是吞吐(throughput) 的提升。
5. 经典 5 段 RISC-V 流水线
RISC-V 的一条整数指令经典拆成 5 段:
1 | ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ |
| 段 | 干啥 | 用到的硬件 |
|---|---|---|
| IF | 取指令,PC+4 | 指令存储器、PC |
| ID | 译码,读源寄存器 | 控制器、寄存器堆(读口) |
| EX | 算术/逻辑,或算访存地址 | ALU |
| MEM | 读/写数据存储器 | 数据存储器 |
| WB | 把结果写回寄存器堆 | 寄存器堆(写口) |
注意寄存器堆被 ID(读)和 WB(写)两端共用,硬件上必须有独立读口和写口,否则会撞车(这就是下面"结构冒险"的来源之一)。
6. 吞吐 vs 延迟:公式与实测
设段数 k=5,指令数 N。
- 非流水线(单周期):每条指令独占全部 5 段 =
N × k周期。 - 理想流水线:第一条占满 5 周期,之后每周期"吐"出一条 =
k + (N − 1)周期。 - 单条指令的延迟仍是
k周期——流水线提升的是吞吐,不是单条速度;而且稳态吞吐 ≈ 每周期 1 条。
用 C 把 N=5 算出来:
1 |
|
1 | 指令数 N = 5, 流水线段数 k = 5 |
5 条指令从 25 周期压到 9 周期,且 N 越大、加速越接近 k 倍。下面这张时序图就是"9 周期"的由来:
1 | c1 c2 c3 c4 c5 c6 c7 c8 c9 |
7. 流水线的代价:冒险(Hazards)
天下没有白拿的加速。重叠执行会引入"依赖冲突",统称冒险,分三类:
| 类型 | 冲突啥 | 典型对策 |
|---|---|---|
| 结构冒险 | 硬件资源不够分时(如单口存储器又要取指又要访存) | 复制/分离资源(哈佛结构:指令、数据存储器分开) |
| 数据冒险 | 一条指令要的数据,上一条还没写回 | 转发(forwarding/旁路)、必要时 stall 插入气泡 |
| 控制冒险 | 分支指令改了 PC,后面预取来的指令可能白取 | 分支预测 + 预测失败 flush 流水线 |
重点看数据冒险——它最直观地暴露第 3 节说的"写回要等边沿"问题:
1 | add x1, x2, x3 # x1 要到 WB 段(末尾)才写回寄存器堆 |
add 在 EX 算出 x1,但按规矩 x1 得等到 add 的 WB 结束、下一个边沿才落进寄存器堆;而 sub 的 EX 比那早了两个周期就到了,读到的还是旧值。
没有转发(forwarding) 时,硬件只能让 sub 停两拍,等 x1 真正写回:
1 | c1 c2 c3 c4 c5 c6 c7 c8 |
有转发(旁路 bypass) 时,硬件在 add 的 EX 一结束就把算出的 x1 直接从内部总线"旁路"给 sub 的 EX,根本不用等写回:
1 | c1 c2 c3 c4 c5 c6 |
用 C 把两种情形都列出来对照:
1 |
|
1 | == 无转发(forwarding)== |
两条指令省下 2 个周期。真实 CPU 里转发电路是第一道、也是用得最勤的险化解法;stall 是兜底。
8. 控制冒险与分支预测
分支指令(beq/bne)要到 EX 段才知道到底跳不跳,可 IF 段早把"下一条"取进来了。如果跳,那些已经取进流水线的指令全白做,得清空(flush)。
对策从笨到聪明:
- 停顿:硬等分支结果出来再取指,简单但慢。
- 静态预测:一律假设"不跳"(或"向后跳、向前不跳"),错了再 flush。
- 动态预测:用分支历史表(BHT)/ 分支目标缓冲(BTB)记住上次跳不跳,现代 CPU 预测命中率能到 95%+,把控制冒险几乎抹平。
一个常被忽略的点:流水越深(段数 k 越大),一次预测失败的惩罚越大——要flush的级数更多。这也是为什么"深流水线 + 烂预测"会互相放大损失。
9. 小结
| 维度 | 组合逻辑 | 时序逻辑 |
|---|---|---|
| 有无状态 | 无,输出只依赖当前输入 | 有,依赖历史 + 时钟边沿 |
| 代表 | 门、ALU、加法器 | 触发器、寄存器、计数器 |
| 关键参数 | 传播延迟 t_pd、关键路径 |
建立/保持时间、时钟频率 |
| 维度 | 非流水线 | 流水线 |
|---|---|---|
| 单条延迟 | k 周期 | k 周期(没变快) |
| N 条总周期 | N×k | k + (N−1),N 越大越接近 k 倍加速 |
| 代价 | 无 | 三类冒险 + forwarding/预测 硬件 |
| 冒险 | 根因 | 解法 |
|---|---|---|
| 结构 | 资源冲突 | 分离/复制硬件 |
| 数据 | 读早于写回 | 转发为主、stall 兜底 |
| 控制 | 分支改 PC | 预测 + flush |
把这一篇和前几篇串起来:C/汇编写出指令 → 指令被拆成 5 段 → 段与段之间靠触发器在时钟边沿传递状态 → 多条指令重叠以提升吞吐 → 重叠带来冒险、用转发和预测填坑。下一篇进存储器层次与 Cache,看"取指令/访存"这两段为什么又成了新的瓶颈。
🐾

