前两篇我们把 C 翻译成了 RISC-V、把函数调用拆成了栈帧。但还有一个更底层的问题没回答:一条指令从"二进制"变成"动作",在硬件里到底经历了什么? 这一篇往下钻到门电路和时钟,再往上拉到流水线——看同一颗 CPU 是怎么靠"让多条指令重叠执行"把吞吐翻几倍的,以及翻倍的代价(冒险)。

一句话定位:组合逻辑管"算",时序逻辑管"记",流水线管"快",冒险管"坑"。

1. 组合逻辑:没有记忆的电路

组合逻辑(combinational logic)的定义是——输出只取决于当前输入,和过去发生过什么无关。给它同一组输入,永远得到同一组输出,它肚子里不存任何状态。

最小例子是半加器(half adder):两个 1 比特相加。

1
2
3
a ──┬── XOR ── sum

b ──┴── AND ── carry
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
2
3
4
5
// 半加器:纯函数,无状态、无记忆
void half_adder(int a, int b, int *sum, int *carry) {
*sum = a ^ b; // 异或得本位
*carry = a & b; // 与得进位
}

只要 ab 给定,sumcarry 就唯一确定。这就是组合逻辑的本质——一个布尔函数 y = f(输入)。真值表 → 布尔表达式 → 门电路,三者等价。

组合逻辑是有"速度上限"的。每个门从输入变化到输出稳定需要传播延迟 t_pd,一条链路上延迟最长那条路径叫关键路径(critical path),它决定了这段逻辑能被时钟驱动的最快频率。

1
2
3
4
5
6
        ┌──────────────────────┐
inputs ─┤ 组合逻辑(门电路) ├── outputs
│ y = f(a,b,c,...) │
└──────────────────────┘
t_pd ↑(关键路径决定最快时钟)
输出只依赖当前输入,内部零状态

2. 时序逻辑:靠时钟"记住"东西

组合逻辑算得再快,也算不出"上一个数加 1"——因为它不记得上一个数。要让电路有记忆,得引入时序逻辑(sequential logic):输出不仅看当前输入,还看之前的状态。靠两个东西实现:存储元件(触发器 / 锁存器)+ 时钟(clock)

最干净的存储元件是 D 触发器(D flip-flop):在时钟的上升沿,把 D 上的值采样进 Q;在两个上升沿之间,不管 D 怎么变,Q 纹丝不动。

1
2
3
4
5
6
      ┌─────────┐
D ────┤ │
│ D FF ├──── Q
CLK ──┤ (↑边沿)│
└─────────┘
上升沿把 D 采样进 Q;两次边沿之间 Q 保持不变

用 C 模拟一个寄存器(注意它和组合逻辑函数的区别——多了一个"时间"维度):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

typedef struct { int q; } DFF; // 一个 1 比特寄存器

// 只在时钟上升沿调用:把 d 采样进 q
void posedge(DFF *r, int d) { r->q = d; }

int main(void) {
DFF r = { .q = 0 };
printf("初始 q = %d\n", r.q); // 0

posedge(&r, 1); // 上升沿①:写 1
printf("边沿①后 q = %d\n", r.q); // 1
(void)0; // 边沿之间 D 变了也不影响
posedge(&r, 0); // 上升沿②:写 0
printf("边沿②后 q = %d\n", r.q); // 0
return 0;
}
1
2
3
初始 q = 0
边沿①后 q = 1
边沿②后 q = 0

关键点:状态只在边沿更新的那个瞬间变化,之后保持到下一个边沿。这一下把"时间"正式引入了电路——所有时序逻辑都在同一个时钟的节拍下整齐地迈步。

3. Setup / Hold:采样不是瞬间完成的

教科书里常说"上升沿采样",好像边沿那一纳秒数据就被吃进去了。现实是触发器采样需要一小段时间窗口,于是有了两条铁律:

  • Setup time(建立时间):时钟边沿之前,数据必须已经稳定下来的最短时间。
  • Hold time(保持时间):时钟边沿之后,数据必须继续保持稳定的最短时间。
1
2
3
4
5
6
7
8
CLK  ──┐  ┌──┐  ┌──
│ │ │ │
└──┘ ┘ └── 上升沿发生在 t0

D ────────┬────────── 数据必须在 t0−t_setup 前稳定

└────────── 并保持到 t0+t_hold 之后
[ setup 区 ] t0 [ hold 区 ]

如果数据在窗口里抖动,触发器可能进入亚稳态(metastability)——输出在 0 和 1 之间悬停不确定,下游逻辑读到什么全看运气。这就是为什么高速电路里"跨时钟域"要专门做同步器。

记住 setup/hold 这个"时间窗口",下一篇讲冒险时你会发现:前一条指令写的结果要等它的写回(WB)完成、下一个时钟边沿才真正落到寄存器里,后面的指令在这个边沿之前读不到——这才是数据冒险的根。

4. 从"单周期"到"流水线":为什么要重叠

先打个比方。洗 3 桶衣服,每桶都要 洗 → 烘 → 叠 三步,每步 1 小时。

非流水线(串行):洗完第 1 桶的全部 3 步,再洗第 2 桶……总 9 小时。

1
2
3
4
时间 →
桶1: 洗 烘 叠
桶2: 洗 烘 叠
桶3: 洗 烘 叠

流水线:第 1 桶洗完进烘干机,同时开始洗第 2 桶;第 1 桶烘完进叠、第 2 桶进烘、第 3 桶开洗……三步在不同桶上重叠。

1
2
3
4
时间 →
桶1: 洗 烘 叠
桶2: 洗 烘 叠
桶3: 洗 烘 叠

CPU 一模一样:把一条指令拆成多段,让不同指令的不同段同时占用不同的硬件单元。单条指令没变快(还是那么多步),但单位时间里完成的指令数上去了——这就是吞吐(throughput) 的提升。

5. 经典 5 段 RISC-V 流水线

RISC-V 的一条整数指令经典拆成 5 段:

1
2
3
4
5
6
┌────┐   ┌────┐   ┌────┐   ┌────┐   ┌────┐
│ IF │ → │ ID │ → │ EX │ → │ MEM│ → │ WB │
└────┘ └──┬─┘ └────┘ └────┘ └──┬─┘
取指 译码+读寄存器 执行/算地址 访存 写回寄存器
↑__________________________│
寄存器堆被 ID 读、被 WB 写(要解决好读好写)
干啥 用到的硬件
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#define STAGES 5

int non_pipelined(int n) { return n * STAGES; }
int pipelined(int n) { return STAGES + (n - 1); }

int main(void) {
int n = 5;
int np = non_pipelined(n);
int pp = pipelined(n);
printf("指令数 N = %d, 流水线段数 k = %d\n", n, STAGES);
printf("非流水线总周期 : %d\n", np);
printf("理想流水线总周期: %d\n", pp);
printf("加速比 : %.2fx\n", (double)np / pp);
return 0;
}
1
2
3
4
指令数 N = 5, 流水线段数 k = 5
非流水线总周期 : 25
理想流水线总周期: 9
加速比 : 2.78x

5 条指令从 25 周期压到 9 周期,且 N 越大、加速越接近 k 倍。下面这张时序图就是"9 周期"的由来:

1
2
3
4
5
6
      c1 c2 c3 c4 c5 c6 c7 c8 c9
i1: IF ID EX ME WB
i2: IF ID EX ME WB
i3: IF ID EX ME WB
i4: IF ID EX ME WB
i5: IF ID EX ME WB

7. 流水线的代价:冒险(Hazards)

天下没有白拿的加速。重叠执行会引入"依赖冲突",统称冒险,分三类:

类型 冲突啥 典型对策
结构冒险 硬件资源不够分时(如单口存储器又要取指又要访存) 复制/分离资源(哈佛结构:指令、数据存储器分开)
数据冒险 一条指令要的数据,上一条还没写回 转发(forwarding/旁路)、必要时 stall 插入气泡
控制冒险 分支指令改了 PC,后面预取来的指令可能白取 分支预测 + 预测失败 flush 流水线

重点看数据冒险——它最直观地暴露第 3 节说的"写回要等边沿"问题:

1
2
add  x1, x2, x3     # x1 要到 WB 段(末尾)才写回寄存器堆
sub x4, x1, x5 # sub 在 EX 段就要读 x1

add 在 EX 算出 x1,但按规矩 x1 得等到 add 的 WB 结束、下一个边沿才落进寄存器堆;而 sub 的 EX 比那早了两个周期就到了,读到的还是旧值。

没有转发(forwarding) 时,硬件只能让 sub 停两拍,等 x1 真正写回:

1
2
3
4
5
       c1 c2 c3 c4 c5 c6 c7 c8
add: IF ID EX ME WB
sub: IF ID 气泡 气泡 EX ME WB
↑ 等 x1 在 c5 末写回,sub 的 EX 才敢在 c6 开始
总周期: 8

有转发(旁路 bypass) 时,硬件在 add 的 EX 一结束就把算出的 x1 直接从内部总线"旁路"给 sub 的 EX,根本不用等写回:

1
2
3
4
5
       c1 c2 c3 c4 c5 c6
add: IF ID EX ME WB
sub: IF ID EX ME WB
↑ add 在 c3(EX末)产出 x1,旁路到 sub 的 EX(c4)
总周期: 6

用 C 把两种情形都列出来对照:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

void show(const char *name, int ifc, int idc, int exc, int memc, int wbc) {
printf("%-14s IF@%d ID@%d EX@%d MEM@%d WB@%d\n",
name, ifc, idc, exc, memc, wbc);
}

int main(void) {
printf("== 无转发(forwarding)==\n");
show("add x1,x2,x3", 1, 2, 3, 4, 5);
show("sub x4,x1,x5", 2, 3, 6, 7, 8); /* 停 2 拍等 x1 写回 */
printf("总周期: 8\n\n");

printf("== 有转发(旁路 bypass)==\n");
show("add x1,x2,x3", 1, 2, 3, 4, 5);
show("sub x4,x1,x5", 2, 3, 4, 5, 6); /* x1 在 EX 末产出, 直接旁路 */
printf("总周期: 6\n");
return 0;
}
1
2
3
4
5
6
7
8
9
== 无转发(forwarding)==
add x1,x2,x3 IF@1 ID@2 EX@3 MEM@4 WB@5
sub x4,x1,x5 IF@2 ID@3 EX@6 MEM@7 WB@8
总周期: 8

== 有转发(旁路 bypass)==
add x1,x2,x3 IF@1 ID@2 EX@3 MEM@4 WB@5
sub x4,x1,x5 IF@2 ID@3 EX@4 MEM@5 WB@6
总周期: 6

两条指令省下 2 个周期。真实 CPU 里转发电路是第一道、也是用得最勤的险化解法;stall 是兜底。

8. 控制冒险与分支预测

分支指令(beq/bne)要到 EX 段才知道到底跳不跳,可 IF 段早把"下一条"取进来了。如果跳,那些已经取进流水线的指令全白做,得清空(flush)

对策从笨到聪明:

  1. 停顿:硬等分支结果出来再取指,简单但慢。
  2. 静态预测:一律假设"不跳"(或"向后跳、向前不跳"),错了再 flush。
  3. 动态预测:用分支历史表(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,看"取指令/访存"这两段为什么又成了新的瓶颈。

🐾