处理器架构与流水线
上一章我们看 gcc -Og -S 的输出,一条 C 语句常变成三五行汇编。但如果你以为 CPU 是"执行完一条再取一条",那所有性能直觉都会跑偏:现代处理器在同一时刻手里至少攥着五条指令。
最典型的一幕是这样两行:
1 | mrmovq 8(%rsp), %rax # 从内存读一个值 |
第二条在"执行"阶段就要用 %rax,可第一条的结果还卡在"访存"阶段。按最朴素的算法,CPU 得干等两拍;实际只停了一拍。这一章要拆的就是这个窟窿是怎么被填上的——顺带回答另一个问题:为什么整个流水线里最贵的指令是 jne。
1. ISA 是合同,微架构是实现
先把两个容易混的词分清:
- ISA(指令集架构):程序员看到的一切——有哪些指令、有哪些寄存器、内存怎么寻址、异常怎么触发。它是硬软件之间的合同。
- 微架构:这份合同的一种实现。同样的 x86-64 合同,Intel 用乱序超标量实现,AMD 用另一套,结果都能跑同一个二进制。
CSAPP 为了讲清微架构,自己造了一个教学 ISA —— Y86-64。它砍掉了 x86 里所有"历史包袱"(变长指令的极端情况、段寄存器、复杂的寻址模式),只留下讲流水线必需的部分。
flowchart LR
subgraph CPU["CPU:程序员可见状态"]
PC["PC<br/>下一条指令的地址"]
RF["寄存器文件(15 个)<br/>%rax %rcx %rdx %rbx<br/>%rsp %rbp %rsi %rdi<br/>%r8 … %r14"]
CC["条件码<br/>ZF SF OF"]
ST["Stat<br/>AOK / HLT / ADR / INS"]
end
MEM[("内存<br/>指令 · 数据 · 栈")]
PC -->|"取指:按 icode 读若干字节"| MEM
RF -->|"load:从内存读到寄存器"| MEM
MEM -->|"store:寄存器写回内存"| RF
ST -.->|"异常状态,决定是否继续"| PC
这张图就是全部"可见状态"。没画出来的东西——流水线寄存器、旁路网络、预测器——都是微架构的自由发挥空间,合同里一个字都没提。
2. Y86-64:一个"够用就好"的教学 ISA
指令不多,十四类,足以覆盖算术、访存、跳转、过程调用:
| icode | 指令 | 说明 | 长度 |
|---|---|---|---|
| 0x0 | halt |
停机 | 1 |
| 0x1 | nop |
空操作 | 1 |
| 0x2 | rrmovq(ifun=0)/ cmovXX(ifun=1~6) |
寄存器间传送 | 2 |
| 0x3 | irmovq |
立即数 → 寄存器 | 10 |
| 0x4 / 0x5 | rmmovq / mrmovq |
寄存器 ↔ 内存 | 10 |
| 0x6 | OPq |
addq subq andq xorq |
2 |
| 0x7 | jXX |
jmp je jne jl jge … |
9 |
| 0x8 | call |
调用 | 9 |
| 0x9 | ret(ifun=0) |
返回 | 1 |
| 0xA / 0xB | pushq / popq |
压栈 / 出栈 | 2 |
编码规则只有一句话:第 1 字节的高 4 位是 icode,低 4 位是 ifun;第 2 字节高 4 位是 rA、低 4 位是 rB;需要常数的指令再跟 8 字节小端立即数。
flowchart LR
subgraph B["irmovq $15, %rbx 的 10 个字节"]
direction LR
B0["第 1 字节 30<br/>icode=3(irmovq)<br/>ifun=0"]
B1["第 2 字节 f3<br/>rA=F(不用)<br/>rB=3(%rbx)"]
B2["第 3–10 字节<br/>0f 00 00 00 00 00 00 00<br/>立即数 15,小端"]
end
B0 --- B1 --- B2
把编码规则写成二十行 Python,就能亲手验证教材图 4-2:
1 | REG = {n: i for i, n in enumerate( |
1 | irmovq $15, %rbx 30 f3 0f 00 00 00 00 00 00 00 |
注意 irmovq $-1, %rax 那行:机器码里没有"负号",ff ff ff ff ff ff ff ff 就是 -1 的补码,而小端序让最低位字节排在最前面。上一章讲的两件事,在这里第一次落到真实字节上。
再补一个标签扫描,就有了一个迷你汇编器。加上 loop: 标签解析两遍(第一遍记地址,第二遍编码),把求和循环翻出来:
1 | # 承接上面的表定义:第一遍定位标签,第二遍编码;SRC 是带 loop: 标签的求和循环 |
1 | 地址 机器码 汇编 |
addq 只占 2 字节,jne 占 9 字节 —— 取指级每次要按 icode 决定读 1、2、9 还是 10 个字节。这个"变长取指"就是后面流水线里 split 逻辑存在的全部理由。
3. HCL:用布尔式描述一块电路
硬件怎么描述?画电路图太啰嗦,写 Verilog 又跑偏了。CSAPP 用 HCL(Hardware Control Language)——看着像 C 的布尔表达式,其实是组合逻辑的写法:
1 | # 这条指令合法吗?只有列出的 icode 组合才算合法 |
三个要点:in {} 是集合成员判断,综合出来是一堆与门/或门;[ ... ] 是字级多路复用器(MUX),靠后的分支优先级更高;1 : 是"其余情况"的默认分支。HCL 里没有循环、没有变量赋值——因为电路里也没有。
4. SEQ:一条指令,六个阶段
最直白的实现叫 SEQ:一个时钟周期执行完一条指令,内部拆成六个阶段。
flowchart LR
F["① 取指 Fetch<br/>按 PC 读最多 10 字节<br/>算出 valP = PC+1/2/9/10"] --> D["② 译码 Decode<br/>读 rA、rB 两个寄存器<br/>读出 valC 里的立即数"]
D --> E["③ 执行 Execute<br/>ALU 做算术、算访存地址<br/>更新 ZF SF OF"]
E --> M["④ 访存 Memory<br/>读或写内存"]
M --> W["⑤ 写回 Write back<br/>把 valE / valM 写进寄存器文件"]
W --> U["⑥ 更新 PC<br/>valP,或跳转目标"]
U -.->|"下一个时钟周期"| F
SEQ 的关键数字:时钟周期必须覆盖最慢的那个阶段。教材里的硬件参数是取指 20ps、译码 12ps、执行 15ps、访存 25ps、写回 15ps(其中还要加上寄存器延迟)——于是取指和访存成了瓶颈。
结果就是:每次只有一级在干活,另外五级闲着。一条指令 5 个周期,CPI = 5。 这不比"不做流水线"更糟,但也谈不上快。
5. 流水线:把六段叠起来
流水线的想法朴素到不像话:既然六个阶段用的是不同硬件,那就让六条指令各占一段,同时开工。
gantt
title 同样 5 条指令:SEQ 要 25 拍,理想 5 级流水线只要 9 拍
dateFormat X
axisFormat %s
section SEQ 一次一条
SEQ-I1 :0, 5
SEQ-I2 :5, 10
SEQ-I3 :10, 15
section PIPE 流水重叠
PIPE-I1 :0, 5
PIPE-I2 :1, 6
PIPE-I3 :2, 7
单条指令的延迟没变,还是 5 拍;变的是吞吐——稳态下每拍流出一条。 理想加速比是一条很干净的公式:
1 | S = n·k / (k + n - 1) n 条指令,k 级流水线 |
拿上面那 5 条有依赖的指令真跑一遍(转发开 / 关各一次):
1 | STAGES = ("F", "D", "E", "M", "W") |
1 | 转发 开:总周期 10,CPI = 2.00,相对 SEQ(25 拍)加速比 = 2.50x |
同一段程序,开转发 10 拍、关转发 15 拍。差的 5 拍就是"数据相关"的全部代价——下面展开。
顺便把理想公式也列一下,看看流水线的收益是怎么被"指令数"摊薄的:
1 | n SEQ 周期 流水周期 加速比 S |
流水线只对"指令流"有效。 一条孤零零的指令,5 级流水线白搭。
6. 三种冒险
重叠执行会撞车,撞的姿势只有三类:
flowchart TD
H["流水线冒险 Hazard"] --> S["结构冒险 Structure<br/>硬件资源只有一个"]
H --> D["数据冒险 Data<br/>下一条要用的值还没算出来"]
H --> C["控制冒险 Control<br/>还不知道下一条该取哪"]
S --> S1["例:同一拍既要取指令又要读内存<br/>PIPE 的解法:指令内存与数据内存分开"]
D --> D1["例:addq %rax,%rbx 紧跟 subq %rbx,%rcx<br/>PIPE 的解法:转发 + 少量停顿"]
C --> C1["例:jne 的方向要到 E 级才知道<br/>PIPE 的解法:预测 + 猜错时插气泡"]
结构冒险最好解决——加硬件。PIPE 的取指级和数据访存级各用一套内存端口,冲突就消失了。(真实 CPU 里对应的是分离的 L1I / L1D。)
数据冒险和控制冒险没法靠堆硬件消灭,只能靠转发、停顿、预测这三招。
7. 数据冒险:转发与停顿
核心洞察是:值其实早就有了,只是还没写回寄存器文件。 addq %rax, %rbx 在 E 级末尾就算出了 %rbx 的新值,只是要等到 W 级才写回。既然 ALU 的输出线就摆在那里,为什么不让下一条指令直接从线上取值?
flowchart TD
Q{"本条指令的源寄存器<br/>是不是前 1~2 条的目的寄存器?"}
Q -->|"不是"| N["不相关,照常推进"]
Q -->|"是,且生产者是 ALU 指令"| F["转发 forwarding<br/>把生产者 E 级刚算出的 valE<br/>直接接到 ALU 输入端 —— 零停顿"]
Q -->|"是,且生产者是 load"| L["加载/使用冒险<br/>load 的值要到 M 级末尾才有效<br/>只能插 1 个气泡,再走 M→E 转发"]
F --> P["优先级:E 级的值最优先<br/>其次 M 级,最后才是寄存器文件"]
L --> P
转发在 HCL 里就是一组多路复用器,把三个候选值按优先级选一个:
1 | word aluA = [ |
load 为什么特殊?因为它的值直到 M 级结束才从内存里出来,转发也追不上。这一拍停顿无法避免,只能插入一个 bubble(气泡,即一条 nop):
- 停顿(stall):让 F 级、D 级寄存器保持原值,PC 不更新 —— 相当于把当前两条指令"按住",等一拍。
- 气泡(bubble):把 D 级寄存器置成
nop,让它流进 E、M、W 级时什么都不做。
一句话总结:停顿是"按住上游",气泡是"给下游塞一个空指令"。 加载/使用冒险同时需要这两个动作,所以代码里那两行 HCL 是成对出现的。
8. 控制冒险:最贵的两拍
jne loop 这条指令,方向(跳不跳)要到 E 级才算出来,跳转目标地址也要到 E 级才知道。可 F 级不能闲着等——它必须每拍都取一条指令。
flowchart LR
F["F 级取指<br/>可我该取哪一条?"] --> P{"预测器:<br/>这条指令跳不跳?"}
P -->|"猜跳到 X"| A["按 X 继续取指"]
P -->|"猜顺序执行"| B["按 valP 继续取指"]
A --> E{"E 级算出真实方向"}
B --> E
E -->|"猜对了"| OK["什么也不用做<br/>性能白赚"]
E -->|"猜错了"| BAD["取消已取入的 2 条指令<br/>插入气泡,代价 2 拍"]
教材里的 PIPE 用最朴素的策略:跳转一律预测"要跳"(因为循环回边占绝大多数)。猜错的代价是 2 拍——而 ret 更麻烦,返回地址在内存里,必须等 M 级读出来才知道下一条取哪儿,所以在 ret 之后要停顿到确定为止。
这就是"分支预测"最初的样子:不是聪明,而是"猜错也要猜"。 现代 CPU 的预测器已经复杂到能记住几百条历史分支的模式,但本质没变——它们仍然会猜错,只是猜错的概率被压得很低。
9. 离真实 CPU 还有多远
PIPE 是教学模型。真实 CPU 在这条路上又走了三十年:
① 乱序执行。 PIPE 里必须停顿的地方,真实 CPU 会把后面的无关指令先调到前面执行。这需要寄存器重命名(把逻辑寄存器 %rax 映射到几十个物理寄存器上)和重排序缓冲(ROB)来保证"看起来还是按顺序执行"。
这一招直接解释了 C/C++ 里那些让人头大的内存序问题:
1 | // 两个线程各自写一个标志位,然后用另一个变量传递数据 |
volatile 解决不了这个问题——它只禁止编译器优化,管不住 CPU 的乱序和 Store Buffer。Java 的 volatile 则是另一回事:JMM 里它带 happens-before 语义,等于替你插了上面那对 release/acquire 屏障。
② 超标量。 每周期发射 4~8 条指令,所以真实 CPU 的 IPC 常态是 2~4,CPI 小于 1——这在 SEQ 的世界里根本无法想象。
③ 依赖链是新的瓶颈。 在乱序机器上,串行的累加是最伤的写法:
1 | // 每次迭代都依赖上一次的 sum,一条长长的依赖链 |
两段代码算的是同一个数,编译到 -O2 后第二段往往更快——因为第一段每条 add 都得等上一条的结果。GCC 的 -O2 自己就会做这件事(循环展开 + 多累加器 + 向量化),想看差异就用 gcc -O1 -fno-tree-vectorize 关掉优化再对比,或者 perf stat -e cycles,instructions 数周期。这也是"改写循环比换 CPU 更有效"这句话的出处。
④ 分支预测错误的代价涨了。 PIPE 猜错罚 2 拍,现代深度流水线要罚 15~20 拍,所以编译器宁可生成无分支代码(cmov)也不愿留一个难预测的 if——上一章讲的 cmov,在这里找到了它的动机。
10. 三档处理器对照
| 维度 | SEQ(单周期) | PIPE(5 级流水) | 真实超标量 CPU |
|---|---|---|---|
| 同时执行的指令数 | 1 | 5(每级 1 条) | 4~8 条发射 + 数百条在飞 |
| 时钟周期由谁决定 | 最慢的那一级 | 最慢的那一级 | 关键路径 + 乱序窗口 |
| 数据相关 | 天然无(顺序执行) | 转发 + 1 拍 load-use 停顿 | 转发 + 寄存器重命名 + 乱序隐藏 |
| 控制相关 | 天然无 | 预测 + 猜错罚 2 拍 | 深度预测器 + 投机执行,罚 15~20 拍 |
| CPI | 5 | 1.0~1.3 | < 1(IPC 2~4) |
| 实现复杂度 | 一个实验课的量 | 一个学期的量 | 数百人年 |
| 对应现实 | 早期简单处理器 | 教科书模型 | 你手上这台机器 |
小结
- ISA 是合同,微架构是实现。 流水线寄存器、旁路网络、预测器全在合同之外——所以同一份二进制在不同 CPU 上跑出不同速度,是合同的正常发挥空间,不是 Bug。
- Y86-64 的编码规则只有三条:高 4 位 icode、低 4 位 ifun,第二字节 rA:rB,需要常数的再跟 8 字节小端立即数。
jne占 9 字节、addq占 2 字节,取指级必须按 icode 决定读几个字节。 - HCL 是组合逻辑的写法:
in {}是与或门,[ ... ]是优先级多路复用器,没有循环也没有赋值——电路里本来就没有。 - SEQ 的毛病是"每拍只有一级在忙",CPI 固定为 5;流水线不改变单条指令的延迟,只把吞吐提到每拍一条。理想加速比
S = n·k/(k+n-1)——指令数越少,流水线越没用。 - 三类冒险里,结构冒险靠加硬件解决,数据冒险靠转发。转发能零停顿地喂饱 ALU,唯独
load例外:值要等 M 级末尾才出来,只能插一个气泡。 - 停顿是"按住上游",气泡是"向右塞一条 nop",加载/使用冒险两者都要。
- 控制冒险最贵,所以流水线从第一天起就得"猜"。猜错罚 2 拍(PIPE)到 20 拍(现代 CPU),这就是
cmov、无分支代码、以及现代预测器存在的理由。 - 真实 CPU 用乱序、重命名、超标量把 CPI 压到 1 以下,代价是暴露给程序员的内存序问题——
volatile管不住它,std::atomic的 release/acquire 和 Java 的happens-before才是对的语言级工具。
下一篇进入存储器层次与 Cache —— 流水线解决了"谁能同时开工",接下来要解决的是"数据从哪来才够快"。

