1.4 处理器体系结构:从 Y86-64 到乱序执行
一条指令从取指到写回要经过哪些阶段?流水线为什么会让「下一条指令」读到旧值?转发、暂停、分支预测各自解决什么问题?现代 CPU 为什么要乱序执行,又是如何保证结果和顺序执行一样?
读懂 Y86-64 的指令编码与 HCL;画出五级流水线并判断数据冒险;用 CPI、IPC、分支缺失率估算性能;解释寄存器重命名与 ROB 的作用;用 perf stat 判断程序是前端、后端还是分支受限。
第 1.3 章把 C 翻译成指令,这一章把指令放进硬件。视角从「程序怎么写」切换到「CPU 怎么执行」:一条 addq 在哪个周期读寄存器、哪个周期算、哪个周期写回;当下一条指令依赖它的结果时,硬件是等待、绕过,还是猜?
教材通常用 Y86-64 讲这条路径。它是一个教学 ISA:指令少、编码规整、没有延迟槽和复杂寻址,适合把数据通路拆开看。Y86-64 不追求性能,也不能直接跑 Linux;它的价值是让你在没有历史包袱的模型上理解「流水线」「冒险」「控制」这些概念,再把它们映射到 x86-64 与 ARM64。
先给结论:现代高性能 CPU 的顺序执行模型是假象。指令在硬件里乱序、并发、推测地执行,但提交(commit)按程序顺序进行,所以软件看到的寄存器与内存状态仍像一条一条执行的。理解这个假象如何维持,是理解 Spectre、内存屏障、volatile、锁的前提。
1.4.1 ISA 与微架构:Y86-64 的定位
ISA 是软件与硬件的契约:有哪些寄存器、指令如何编码、每条指令对可见状态(寄存器、内存、标志位、PC)做什么、异常如何报告。微架构(microarchitecture)是实现 ISA 的具体电路:流水线级数、发射宽度、缓存大小、预测器算法。同一 ISA 可以有多种微架构(如 x86-64 的 Skylake 与 Zen),同一微架构也可以实现多种 ISA。
这个区分解释了一个常见困惑:为什么 Intel 和 AMD 的 CPU 都能跑同样的二进制?因为它们实现同一个 ISA(x86-64),但微架构完全不同。为什么 ARM 程序不能直接跑在 x86 上?因为 ISA 不同,指令编码、寄存器、内存模型都不同,需要翻译层(模拟器、二进制翻译)或重新编译。
Y86-64 是 CSAPP 设计的教学 ISA, borrowed x86-64 的寄存器名和部分指令形式,但做了大幅简化:
- 只有 15 条指令(按操作码计),没有浮点、没有 SIMD、没有特权指令;
- 8 个通用寄存器(
%rax、%rcx、%rdx、%rbx、%rsp、%rbp、%rsi、%rdi),没有%r8–%r15; - 指令长度 1–10 字节,操作码 1 字节,寄存器字节 1 字节,立即数/地址 8 字节小端;
- 只有 4 个条件码(ZF、SF、OF),没有 CF;
- 内存访问只有
mrmovq/rmmovq,没有复杂的寻址模式(只有D(Rb)); - 没有异常/中断的完整机制,只有 4 种状态码(AOK、HLT、ADR、INS)。
Y86-64 不能运行真实程序,它没有系统调用、没有页表、没有设备。它的作用是让你能在纸上或模拟器里跟踪每条指令的五个阶段。读完这一章,你应当能把 Y86-64 的 addq 与 x86-64 的 addq、ARM64 的 add 对应起来,并知道后两者多了哪些复杂性。
1.4.2 Y86-64 指令集与编码
Y86-64 的指令编码规则:第一个字节的高 4 位是操作码(icode),低 4 位是功能码(ifun);第二个字节的高 4 位是源寄存器 rA,低 4 位是目的寄存器 rB;后面跟 8 字节立即数或地址(小端)。寄存器编号:
| 编号 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | F |
|---|---|---|---|---|---|---|---|---|---|
| 寄存器 | %rax | %rcx | %rdx | %rbx | %rsp | %rbp | %rsi | %rdi | 无 |
主要指令:
| 指令 | icode | 编码示例 | 字节数 | 效果 |
|---|---|---|---|---|
halt | 0 | 00 | 1 | 停止 |
nop | 1 | 10 | 1 | 空操作 |
rrmovq rA,rB | 2 | 20 03 | 2 | rB = rA |
irmovq V,rB | 3 | 30 F0 01 00 00 00 00 00 00 00 | 10 | rB = V |
rmmovq rA,D(rB) | 4 | 40 01 08 00 ... | 10 | M[rB+D] = rA |
mrmovq D(rB),rA | 5 | 50 01 08 00 ... | 10 | rA = M[rB+D] |
addq rA,rB | 6 | 60 03 | 2 | rB += rA,设标志 |
subq rA,rB | 6/1 | 61 03 | 2 | rB -= rA,设标志 |
andq/xorq | 6/2、6/3 | 62 03、63 03 | 2 | 位运算,设标志 |
jXX dest | 7 | 70 <8B>、73 <8B> | 9 | 条件跳转 |
call dest | 8 | 80 <8B> | 9 | 压返回地址并跳转 |
ret | 9 | 90 | 1 | 弹出返回地址 |
pushq rA | A | A0 0F | 2 | rsp -= 8; M[rsp] = rA |
popq rA | B | B0 0F | 2 | rA = M[rsp]; rsp += 8 |
irmovq $1, %rax 的编码是 30 F0 01 00 00 00 00 00 00 00:0x30 表示 irmovq,F0 表示源寄存器为无(F)、目的 %rax(0),后 8 字节是 1 的小端。Y86-64 的立即数总是 8 字节,不像 x86-64 有 1/2/4 字节的紧凑形式;这是教学 ISA 为简化译码付出的空间代价。
指令语义用汇编写出来:
# long sum(long *a, long n)
# a in %rdi, n in %rsi, result in %rax
sum:
xorq %rax, %rax # s = 0
testq %rsi, %rsi # n ? 0
jle done
loop:
mrmovq (%rdi), %rdx # rdx = *a
addq %rdx, %rax # s += rdx
irmovq $8, %rcx
addq %rcx, %rdi # a += 8
irmovq $1, %rcx
subq %rcx, %rsi # n -= 1
jne loop
done:
ret
注意 Y86-64 没有 testq 指令(这里是为了可读性借用了 x86 助记符),实际要用 andq %rsi,%rsi 或 subq $0 来设标志;也没有 leaq,地址计算只能靠 addq/irmovq。这个「缺指令」的体验很重要:你会意识到高级语言里的每个操作,最终都要落到 ISA 提供的有限指令上。
把这段循环按五级流水线展开,注意 mrmovq 与 addq 之间的 load-use 冒险:
周期 C1 C2 C3 C4 C5 C6 C7 C8
mrmovq F D E M W
addq F D D* E M W
irmovq F F* D E M W
↑
暂停一周期:I2 在 D 等 load 数据
F* 表示取指被冻结,重复取同一条
I2 在 C4 仍停在 D,因为 mrmovq 的数据要到 C4 末(M 阶段)才可用;C5 通过 M→E 转发拿到 %rdx。这一周期的气泡让 3 条指令用了 8 个周期,而不是理想的 7 个。循环里每次迭代都有一条 load,如果每次都触发暂停,CPI 会明显高于 1。编译器可以把 addq 后面不依赖 load 的指令移到前面,填掉这个气泡。
- 「Y86-64 是 x86-64 的子集」——不是。它借用了寄存器名和部分指令形式,但编码、指令集、异常模型都不同;x86-64 的二进制不能在 Y86-64 上运行。
- 「Y86-64 的 8 字节立即数说明它落后」——这是教学简化。真实 x86-64 有变长指令(1–15 字节),译码复杂度高得多;Y86-64 用固定 8 字节让译码器简单到可以在纸上画。
- 「学 Y86-64 没用,反正不生产」——它训练的「取指—译码—执行—访存—写回」模型适用于所有 CPU。换成 ARM64 只是阶段名和寄存器名不同。
1.4.3 HCL 与顺序实现
硬件控制语言(HCL, Hardware Control Language)用布尔表达式描述「某个控制信号在什么条件下为真」。它不是 Verilog,不描述时序,只描述组合逻辑。Y86-64 的顺序实现(SEQ)把每条指令拆成六个阶段,每个阶段用一个 HCL 块描述。
六个阶段:
| 阶段 | 做什么 | 关键信号 |
|---|---|---|
| 取指(Fetch) | 从 PC 读指令字节,解析 icode/ifun/rA/rB/valC | imem_error、instr_valid |
| 译码(Decode) | 读寄存器堆,得到 valA/valB | srcA、srcB、dstE、dstM |
| 执行(Execute) | ALU 计算,设条件码 | aluA、aluB、alufun、set_cc |
| 访存(Memory) | 读/写数据内存 | mem_addr、mem_data、mem_read、mem_write |
| 写回(Writeback) | 把结果写回寄存器堆 | dstE、dstM |
| 更新 PC | 计算下一条指令地址 | new_pc |
控制信号用 HCL 写出来,形式是「条件 : 表达式;」:
# 读哪个寄存器作为 ALU 的 A 输入
word aluA = [
icode in { IRRMOVQ, IOPQ } : valA;
icode in { IIRMOVQ, IRMMOVQ, IMRMOVQ } : valC;
icode in { ICALL, IPUSHQ } : -8;
icode in { IRET, IPOPQ } : 8;
];
这段的意思是:rrmovq/OPq 用 valA;irmovq/rmmovq/mrmovq 用立即数 valC;call/pushq 要让 rsp 减 8;ret/popq 要让 rsp 加 8。HCL 的 [ ] 是优先级选择,第一个匹配的条件生效。
顺序实现的时序是:一个时钟周期内完成六个阶段,下一个周期更新 PC。CPI 接近 1(忽略访存延迟),但时钟周期必须容纳最慢的那条路径(取指 + 译码 + ALU + 访存 + 写回),所以频率上不去。流水线把这条长路径切成五段,每段一个周期,吞吐率提高,但引入了冒险。
CPI = 1 只说明「每条指令一个周期」,没说明「周期有多长」。顺序实现的一个周期要串行完成取指、译码、执行、访存、写回,时钟周期由最慢的组合路径决定,可能是流水线的 5 倍。性能 = 指令数 × CPI × 周期时间;流水线把周期时间降到 1/5,即使 CPI 略大于 1,整体仍快得多。
1.4.4 五级流水线
把六个阶段合并成五个流水级(写回与更新 PC 合并到 W 或单独处理),每级之间放流水寄存器(pipeline register),保存上一级的中间结果:
指令 i ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ F │→│ D │→│ E │→│ M │→│ W │
└────┘ └────┘ └────┘ └────┘ └────┘
取指 译码 执行 访存 写回
PC 寄存器 ALU 数据内存 寄存器堆
时间 → C1 C2 C3 C4 C5 C6 C7
指令 i F D E M W
指令 i+1 F D E M W
指令 i+2 F D E M W
指令 i+3 F D E M W
指令 i+4 F D E M W
理想情况下,每个周期完成一条指令(吞吐率 1/cycle),单条指令的延迟是 5 个周期。流水线的收益来自「同时执行 5 条指令的不同阶段」,不是让单条指令变快。
流水线寄存器保存什么:
| 寄存器 | 保存的内容 |
|---|---|
F→D | 预测的下一条 PC、当前指令的 icode/ifun/rA/rB/valC/valP |
D→E | valA、valB、dstE、dstM、srcA、srcB、valC |
E→M | ALU 结果 valE、条件码、dstE、dstM、valA、valB |
M→W | 内存读出的 valM、ALU 结果 valE、dstE、dstM |
每个寄存器在每个时钟上升沿更新;组合逻辑在周期内传播。控制信号(aluA、dstE 等)由当前级的 icode 译码得到,随流水寄存器一起传递。
流水线带来的三类冒险:
| 冒险 | 原因 | 典型例子 | 解决 |
|---|---|---|---|
| 数据冒险 | 后一条指令需要前一条还没写回的结果 | addq %rax,%rbx 后 mrmovq (%rbx),%rcx | 转发、暂停 |
| 控制冒险 | 取指时不知道下一条 PC | 条件跳转、ret | 预测、延迟、冲刷 |
| 结构冒险 | 两条指令同时要用一个硬件资源 | 同时访存与取指 | 分离 I-cache/D-cache、增加端口 |
1.4.5 数据冒险:转发与暂停
考虑两条相邻指令:
addq %rax, %rbx # I1: rbx = rax + rbx,结果在 E 阶段末产生
mrmovq (%rbx), %rcx # I2: 需要 rbx 作为地址
I2 在 D 阶段读寄存器堆时,I1 还在 E 阶段,结果尚未写回。如果不处理,I2 读到的是旧的 %rbx。
转发(forwarding/bypassing) 把 I1 在 E 阶段末的 ALU 结果直接送到 I2 的 E 阶段输入,不等写回。数据通路增加多路选择器:
E 阶段 ALU 输入 A 的选择:
1. 来自 D→E 寄存器的 valA(正常)
2. 来自 E→M 寄存器的 valE(上一条指令的 ALU 结果)
3. 来自 M→W 寄存器的 valE 或 valM(上上条指令的结果)
优先级:E→M 高于 M→W(越新越优先)
load-use 冒险 是转发解决不了的:mrmovq (%rax),%rbx 后紧跟 addq %rbx,%rcx。加载的数据在 M 阶段末才从内存出来,而 I2 的 E 阶段在下一个周期就要用,来不及。硬件必须暂停(stall) 一个周期:冻结 PC 和 F/D 寄存器,往 E 阶段插入一个 nop(气泡),等数据从 M→W 转发过来。
周期 C1 C2 C3 C4 C5 C6
mrmovq F D E M W
addq F D E* E M W
↑
暂停一周期,插入气泡;E* 等待
转发路径:M 阶段末的 valM → W 阶段 → 下一条的 E 阶段
暂停的代价是 CPI 上升。若程序中 load 指令占 20%,其中 30% 紧跟使用其结果的指令,则每条 load 平均多 0.3 周期,整体 CPI 增加 。看起来不大,但在 tight loop 里可能成为瓶颈;编译器会通过指令调度(把无关指令填进 load 延迟槽)来减少暂停。
转发也有优先级:如果 I1 和 I2 都写同一个寄存器,I3 应该拿 I2 的结果(更新的),而不是 I1 的。硬件用「目的寄存器编号匹配 + 非零」做旁路比较,优先级从新到旧。
流水线中两条指令存在数据依赖,且后一条在前一条的结果写回之前就要读它,导致读到旧值。按依赖类型分 RAW(读后写,真正的冒险)、WAR(写后读,乱序执行时出现)、WAW(写后写,乱序执行时出现)。顺序五级流水线只会有 RAW;乱序执行三种都有,靠寄存器重命名消除 WAR/WAW。
1.4.6 控制冒险与分支预测
取指阶段要知道下一条 PC。对顺序指令,valP = PC + 指令长度;对 jXX、call、ret,下一条 PC 取决于条件、寄存器或内存。流水线不能在取指时就算出这些,于是先预测一个方向,错了再冲刷(flush)。
最简单的预测是「总是跳转」或「总是不跳转」。现代 CPU 用两位饱和计数器(2-bit saturating counter):每个分支地址维护一个 2 位状态,00/01 倾向不跳,10/11 倾向跳;连续两次同向才改变预测。它对循环特别有效:循环体重复执行时,分支几乎总是同一方向。
两位饱和计数器: 00 ──taken──> 01 ──taken──> 11 ──taken──> 11 ↑ │ │ └──not taken───┴──not taken──┘ 预测:高位为 1 则跳转(10/11),为 0 则不跳(00/01)
更复杂的预测器用分支历史寄存器(BHR)、Tournament(局部/全局结合)、TAGE(长历史)等。预测器越准,流水线越少断。预测失败的代价是多少?假设流水线 15 级,分支在 E 阶段解析,预测失败要冲刷 12–15 条指令,代价 12–15 周期。
用 CPI 估算收益:设分支指令占 20%,预测准确率 90%,失败代价 15 周期,基础 CPI 为 1:
若预测准确率提高到 99%,CPI 降到 。预测准确率从 90% 到 99%,性能提升约 26%,这就是现代 CPU 在预测器上投入大量晶体管的原因。
分支是否可预测,对真实代码的影响可以实测。下面的程序对同一组数据做两种求和:一种用 if,一种用无分支的掩码。数据前半段全小于 128、后半段全大于等于 128 时,分支高度可预测;用 xorshift 打乱后,分支变得随机:
#include <stdio.h>
#include <stdint.h>
#include <time.h>
#define N (1u << 24)
static uint8_t data[N];
static volatile uint64_t sink;
static uint64_t now_ns(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
}
static uint32_t xs_state = 123456789u;
static uint32_t xs(void)
{
uint32_t x = xs_state;
x ^= x << 13; x ^= x >> 17; x ^= x << 5;
return xs_state = x;
}
int main(void)
{
for (uint32_t i = 0; i < N; i++)
data[i] = (uint8_t)(xs() >> 24); /* 0..255 随机 */
uint64_t t0 = now_ns();
uint64_t s1 = 0;
for (uint32_t i = 0; i < N; i++)
if (data[i] < 128) s1 += data[i]; /* 难预测的分支 */
uint64_t t1 = now_ns();
uint64_t s2 = 0;
for (uint32_t i = 0; i < N; i++) {
uint8_t v = data[i];
s2 += (v < 128) ? v : 0; /* 可能编译成 cmov */
}
uint64_t t2 = now_ns();
sink = s1 + s2;
printf("branchy: %.2f ns/iter, branchless: %.2f ns/iter\n",
(double)(t1 - t0) / N, (double)(t2 - t1) / N);
printf("sink=%llu\n", (unsigned long long)sink);
return 0;
}
在 x86-64 上,branchy 版本遇到随机分支时会有大量 branch-misses,branchless 版本则稳定在较低 CPI。把数据改成「前一半 0、后一半 255」,branchy 会突然变快——分支变得可预测。同一个分支,数据分布不同,性能可能差一个数量级,这是基准测试必须用真实数据的原因。
ret 是另一种控制冒险:返回地址在运行时才知道。CPU 用返回地址栈(return address stack, RAS)预测:call 时把 valP 压入 RAS,ret 时弹出。递归深度超过 RAS 容量时预测失败,但通常 RAS 有 16–32 项,足够覆盖大多数调用链。
- 「分支预测是猜,所以不可靠」——现代预测器在多数整数/服务负载上准确率超过 95%,循环分支接近 100%。它是性能的关键,不是可有可无的优化。
- 「把分支改成
cmov一定更快」——cmov把控制依赖变成数据依赖,若两个分支的计算都很贵,或源操作数可能 fault,反而更慢。要看具体代码。 - 「分支预测失败只浪费一个周期」——现代深流水线失败代价 12–20 周期,且会污染缓存与前端。
perf stat的branch-misses能直接看到。 - 「预测器可以被软件完全控制」——不能。你可以用
__builtin_expect、likely/unlikely影响布局,但最终预测由硬件历史决定;安全场景还要考虑 Spectre 的推测执行副作用。
1.4.7 现代乱序执行:重命名、ROB 与超标量
五级顺序流水线每周期最多 1 条指令,遇到长延迟操作(缓存缺失、除法)就整条流水线停顿。现代 CPU 用乱序执行(out-of-order, OoO) 把「执行顺序」与「提交顺序」解耦:
前端 后端
┌────────┐ ┌────────┐ ┌──────────┐ ┌──────────────┐ ┌────────┐
│ 取指 │→│ 译码 │→│ 重命名 │→│ 发射/调度 │→│ 执行 │
│ I-cache│ │ 微操作 │ │ 寄存器 │ │ 保留站 │ │ ALU/LS │
└────────┘ └────────┘ │ 映射表 │ │ 乱序选择 │ └────┬───┘
└──────────┘ └──────────────┘ │
↓
┌──────────┐ ┌──────────────┐ ┌────────┐
│ 提交 │←│ ROB │←│ 写回 │
│ 按序 │ │ 重排序缓冲区 │ │ 广播 │
└──────────┘ └──────────────┘ └────────┘
关键部件:
- 寄存器重命名(register renaming):把架构寄存器(
%rax等 16 个)映射到物理寄存器(可能有 180–300 个)。每条写指令分配一个新的物理寄存器,消除 WAW/WAR 假依赖,只保留真正的 RAW。 - 保留站(reservation station)/发射队列:指令译码后进入队列,等待操作数就绪;一旦就绪就发射到执行单元,不按程序顺序。
- 重排序缓冲区(ROB, reorder buffer):按程序顺序记录所有未提交的指令。执行可以乱序,但提交必须按序;只有提交时,寄存器堆和内存才对软件可见。
- 超标量(superscalar):每周期取指/译码/发射多条(4–8 条)。宽度受限于译码器、寄存器端口、执行单元数量。
- 推测执行(speculative execution):分支预测后继续执行,结果先放 ROB;预测失败就丢弃。Spectre 利用的正是「推测执行留下的缓存副作用」。
乱序执行让 CPI 可以小于 1(IPC > 1)。例如 4 宽 CPU,理想 IPC 4;实际受依赖链、缓存缺失、分支影响,常见 IPC 1–3。perf stat 的 instructions per cycle 就是 IPC。
一个具体例子:三个相互独立的加法,和一个依赖链的三个加法。乱序 CPU 可以同时发射前三个(若端口够),但依赖链必须串行:
独立: a = b + c; d = e + f; g = h + i; // 3 条可并行,IPC 接近 3
依赖: a = b + c; d = a + e; g = d + f; // 只能串行,IPC 接近 1
编译器可以通过循环展开、多累加器把依赖链拆开(1.5);硬件可以通过重命名消除假依赖,但真正的数据依赖无法消除。
用一张调度表看乱序的收益。程序是「load → 依赖 add → 两条独立 add」:
| 指令 | 顺序执行的开始 | 乱序执行的开始 | 说明 |
|---|---|---|---|
load r1,[r0] | 1 | 1 | 长延迟,4 周期 |
add r2,r1,#1 | 5 | 5 | 必须等 load |
add r3,r4,#5 | 6 | 2 | 与 load 无关,提前发射 |
add r5,r6,#7 | 7 | 2 | 同上 |
顺序执行在 load 的 4 个周期里什么都不做;乱序执行把两条独立加法填进去,总时间从 7 周期降到 5 周期。如果独立指令足够多,乱序可以把长延迟操作「藏」在其他工作后面——这就是为什么 OoO 对缓存缺失、除法、分支失败特别有价值。
OoO CPU 还会乱序执行 load/store,只要不违反单核的地址依赖。多核之间需要内存屏障(mfence、lock 前缀、ARM 的 dmb)来约束可见顺序。C11 的 atomic_thread_fence 和 memory_order 就是把这些硬件语义暴露给程序员。第 1.12 章讲并发时会展开。
一个物理核可以同时维护两份架构状态(寄存器、PC),共享执行单元、缓存、分支预测器,这叫同时多线程(simultaneous multithreading, SMT),Intel 称 Hyper-Threading。对访存密集型负载,SMT 能用另一个线程填满执行单元的空隙;对纯计算、缓存友好的负载,两个线程会争抢执行端口,单线程性能可能下降。/sys/devices/system/cpu/cpu*/topology/thread_siblings_list 可以看到哪些逻辑 CPU 共享物理核;lscpu -e 更直观。
1.4.8 ARM Cortex-A 对照与 perf 观测
ARM Cortex-A 系列(如 A76、A78、X1)是典型的高性能 OoO 核,流水线深度 8–13 级,译码宽度 4–6,支持乱序执行与 speculation。与 x86-64 的差别主要在 ISA 与前端:
| 维度 | x86-64(Skylake 级) | ARM Cortex-A76 级 |
|---|---|---|
| 指令长度 | 变长 1–15 字节 | 定长 4 字节 |
| 译码 | 复杂,需预译码 | 规整,易并行译码 |
| 流水线深度 | 14–19 级 | 8–13 级 |
| 发射宽度 | 4–6 | 4 |
| 分支预测 | TAGE 类,很准 | 类似,稍简单 |
| 内存模型 | TSO(较强) | 弱内存序,需屏障 |
| 寄存器 | 16 GPR | 31 GPR |
ARM64 的弱内存模型意味着:两个核看到的写顺序可能不同,必须用 dmb ish、ldar/stlr 或 C11 原子操作来同步。x86-64 的 TSO 较强,普通 load/store 不会重排(但 store 之间仍可能缓冲),所以很多 x86 程序「碰巧能跑」,移植到 ARM 才暴露竞态。
流水线深度是频率与失败代价的权衡:级数越多,每级组合逻辑越短,频率越高;但分支失败要冲刷的指令也越多,且每级寄存器开销(setup/hold、时钟偏斜)占比上升。2000 年代 Intel Pentium 4 曾把流水线推到 31 级,结果分支失败代价过高、功耗失控;现代高性能核回到 14–19 级,靠更宽的发射和更好的预测提升 IPC,而不是一味加深流水线。这也是「主频竞赛」在 2005 年前后结束的技术原因之一。
用 perf stat 判断瓶颈:
perf stat -e cycles,instructions,branches,branch-misses,\
cache-references,cache-misses,stalled-cycles-frontend ./prog
读法:
IPC = instructions / cycles。小于 1 说明流水线经常停;大于 1 说明超标量生效。branch-misses / branches高(>5%)说明分支预测是瓶颈,检查热循环里的不可预测分支。cache-misses / cache-references高说明访存是瓶颈,进入 1.6。stalled-cycles-frontend高说明取指/译码供不上,可能是 I-cache 缺失或指令过长。
一次真实的 perf stat 输出(数值因机器而异):
$ perf stat -e cycles,instructions,branches,branch-misses ./prog
1,234,567,890 cycles
987,654,321 instructions # 0.80 insn per cycle
12,345,678 branches # 10.00 M/sec
345,678 branch-misses # 2.80% of all branches
IPC=0.80 说明每周期不到一条指令,流水线有闲置;branch-misses=2.8% 不算高,不是首要矛盾;若 cycles 远大于 instructions 且缓存缺失率低,接下来该看 stalled-cycles-backend 与具体端口的利用率(perf stat -e cycles,uops_dispatched.port_* 或 perf stat -dd)。
一个依赖链基准,用来观察 IPC 的变化:
#include <stdio.h>
#include <stdint.h>
#include <time.h>
#define N (1u << 26)
static volatile uint64_t sink;
static uint64_t now_ns(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
}
int main(void)
{
uint64_t a = 0, b = 0, c = 0, d = 0;
uint64_t t0 = now_ns();
for (uint32_t i = 0; i < N; i++) {
a += i; /* 一条依赖链:每次迭代依赖上一次 a */
b += i + 1;
c += i + 2;
d += i + 3;
}
uint64_t t1 = now_ns();
sink = a + b + c + d;
printf("4 chains: %.2f ns/iter, sink=%llu\n",
(double)(t1 - t0) / N, (unsigned long long)sink);
return 0;
}
四条独立链可以让 CPU 同时推进;如果把四条合成一条 a += i; a += i+1; ...,IPC 会明显下降,因为每步都依赖上一步。这个实验不需要特殊工具,clock_gettime 就够;用 perf stat 看两种写法的 IPC 差异更直观。
1.4.9 Y86-64 编码全表与手写机器码
这一节回答:Y86-64 的每条指令在内存里到底占几个字节、字段怎么排?手写一遍机器码,流水线各阶段「取到什么、算出什么」才有具体对象,而不是停留在框图。
Y86-64 是一个教学用的 RISC 式 ISA:定长字段、load/store 分离、15 个寄存器、只有 ZF/SF/OF 三个条件码。它故意做得比 x86 简单,但保留了「取指—译码—执行—访存—写回—更新 PC」的完整链路。
指令格式
每条指令的第一个字节是 icode:ifun(高 4 位 icode,低 4 位 ifun)。需要寄存器操作数的指令再跟一个 rA:rB 字节;需要常量的指令跟 8 字节小端常量。
| icode | 指令 | 格式 | 长度 |
|---|---|---|---|
| 0 | halt | 0:0 | 1 |
| 1 | nop | 1:0 | 1 |
| 2 | rrmovq rA,rB / cmovXX rA,rB | 2:fn rA:rB | 2 |
| 3 | irmovq V,rB | 3:0 F:rB V(8) | 10 |
| 4 | rmmovq rA,D(rB) | 4:0 rA:rB D(8) | 10 |
| 5 | mrmovq D(rB),rA | 5:0 rA:rB D(8) | 10 |
| 6 | OPq rA,rB | 6:fn rA:rB | 2 |
| 7 | jXX Dest | 7:fn Dest(8) | 9 |
| 8 | call Dest | 8:0 Dest(8) | 9 |
| 9 | ret | 9:0 | 1 |
| A | pushq rA | A:0 rA:F | 2 |
| B | popq rA | B:0 rA:F | 2 |
OPq 的 fn:0 add、1 sub、2 and、3 xor。jXX 的 fn:0 jmp、1 jle、2 jl、3 je、4 jne、5 jge、6 jg。cmovXX 复用 icode 2,fn 与 jXX 相同(0 表示无条件 rrmovq)。
寄存器编号与状态码
| 编号 | 寄存器 | 编号 | 寄存器 |
|---|---|---|---|
| 0 | %rax | 8–14 | %r8–%r14 |
| 1 | %rcx | 15 (0xF) | 无(RNONE) |
| 2 | %rdx | ||
| 3 | %rbx | ||
| 4 | %rsp | ||
| 5 | %rbp | ||
| 6 | %rsi | ||
| 7 | %rdi |
RNONE 用在「该字段不使用」的位置:irmovq 的 rA 必须是 0xF,popq 的 rB 必须是 0xF。状态码有四个:AOK=1、HLT=2、ADR=3(地址越界)、INS=4(非法指令)。
手写一段机器码
目标是 sum = 1 + 2 + ... + 10:
0x000: irmovq $10, %rcx 30 f1 0a 00 00 00 00 00 00 00
0x00a: irmovq $0, %rax 30 f0 00 00 00 00 00 00 00 00
0x014: addq %rcx, %rax 60 10
0x016: irmovq $1, %rdx 30 f2 01 00 00 00 00 00 00 00
0x020: subq %rdx, %rcx 61 21
0x022: jne 0x014 74 14 00 00 00 00 00 00 00
0x02b: halt 00
逐条核对:
irmovq $10,%rcx:icode=3、ifun=0 →0x30;rA=0xF、rB=1 →0xF1;常量 10 的 8 字节小端。addq %rcx,%rax:icode=6、fn=0 →0x60;rA=rcx=1、rB=rax=0 →0x10。Y86 的语义是rB = rB OP rA,所以「加到 rax」要把 rax 放在 rB。subq %rdx,%rcx:icode=6、fn=1 →0x61;rA=rdx=2、rB=rcx=1 →0x21。把0x61写成0x60是手写机器码最常见的错误:指令变成addq,循环永不结束,寄存器一路累加。jne 0x014:icode=7、fn=4 →0x74;后面直接跟 8 字节绝对地址(没有rA:rB字节)。halt:0x00。
注意 jXX 和 call 的常量从第 2 个字节开始,而 irmovq/rmmovq 从第 3 个字节开始。译码器必须先看 icode 决定有没有寄存器字节,再决定常量偏移——这是「变长指令取指」的第一个难点,也是 1.4.13 解释器里最容易写错的地方。
执行到 0x020 的 subq 时,valP 是多少?指令 2 字节,valP = 0x020 + 2 = 0x022。而 0x022 的 jne 长度 9,valP = 0x02b;若条件成立,PC 被改成 valC = 0x014,否则用 valP。Y86 的顺序实现里,「预测 PC」默认是 valP,跳转成立时再覆盖——这正是流水线里「分支预测」要解决的矛盾。
1.4.10 HCL:把控制逻辑写成表达式
HCL(Hardware Control Language)是 CSAPP 用来描述处理器控制信号的 DSL。它不是 Verilog,也不是 C,而是一组「组合逻辑等式」:左边是信号名,右边是由输入信号构成的表达式,每个时钟周期重新求值。
三种类型
| 类型 | 含义 | 例 |
|---|---|---|
bool | 单根线 | bool need_regids = icode in { IRRMOVQ, IOPQ }; |
int | 64 位字(地址、数据) | int srcA = ...; |
word | 同 int,多用于数据通路 | word aluA = ...; |
HCL 的表达式只有几类:&&、||、!、==、!=、in { ... }、算术、比较,以及选择表达式 [ cond : expr; ...; 1 : default; ]。选择表达式按顺序匹配,第一个为真的分支胜出,1 : 是兜底(相当于 C 的 else)。
一段真实的控制逻辑
取指阶段要决定「有没有寄存器字节」「读哪两个寄存器」:
bool need_regids =
icode in { IRRMOVQ, IOPQ, IPUSHQ, IPOPQ,
IIRMOVQ, IRMMOVQ, IMRMOVQ };
int srcA = [
icode in { IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ } : rA;
icode in { IPOPQ, IRET } : RESP;
1 : RNONE;
];
int srcB = [
icode in { IOPQ, IRMMOVQ, IMRMOVQ } : rB;
icode in { ICALL, IPUSHQ, IPOPQ, IRET } : RESP;
1 : RNONE;
];
srcA/srcB 直接驱动寄存器堆的读端口;RNONE 表示「不读」。RESP 是 %rsp 的编号 4,用于隐式使用栈指针的指令。
执行阶段要决定 ALU 的两个输入和是否写回:
word aluA = [
icode in { IRRMOVQ, IOPQ } : rvalA;
icode in { IIRMOVQ, IRMMOVQ, IMRMOVQ } : valC;
icode in { ICALL, IPUSHQ } : -8;
icode in { IRET, IPOPQ } : 8;
1 : 0;
];
word aluB = [
icode in { IRMMOVQ, IMRMOVQ, IOPQ,
ICALL, IPUSHQ, IPOPQ, IRET } : rvalB;
1 : 0;
];
bool set_cc = icode in { IOPQ };
读这段代码的窍门:每一行就是一个多路选择器(mux)。aluA 在「寄存器值 / 常量 / ±8」之间选,选择信号是 icode。综合工具会把它变成与或门;软件模拟器会把它变成 switch 或查表。
HCL 与 C、Verilog 的对照
| 概念 | HCL | C | Verilog |
|---|---|---|---|
| 组合逻辑 | 等式,每周期求值 | 函数调用 | assign / always @(*) |
| 选择 | [c1:e1; 1:e2;] | c1 ? e1 : e2 | c1 ? e1 : e2 |
| 集合判断 | icode in {A,B} | `icode==A | |
| 状态 | 无(时钟边沿更新寄存器) | 变量 | always @(posedge clk) |
| 位宽 | 隐含 64 位 | 由类型决定 | 显式 [63:0] |
HCL 里最容易犯的错是把组合逻辑写成「有状态」:x = x + 1 在 HCL 里不是自增,而是「x 等于 x+1」的循环定义,综合会报组合环。状态必须放在时钟更新的寄存器里(PC、CC、寄存器堆、内存)。
另一类错误是漏掉默认分支。[ cond : expr; ] 在 cond 为假时没有定义,综合出锁存器(latch)或仿真出 X;HCL 的惯例是永远写 1 : default;。这跟 C 的 switch 漏 default 不同:C 里漏掉只是不执行,硬件里漏掉会产生意外的存储元件。
组合逻辑没有「先算谁后算谁」:所有信号在同一时刻根据输入稳定下来。HCL 强制你用等式描述「输出 = f(输入)」,正好对应硬件的并联本质。写成 C 的 if/else 序列也能模拟,但会掩盖「同一个周期内所有 mux 同时选择」这个关键事实,也就很难理解流水线里为什么会出现「转发路径冲突」。
1.4.11 流水线手算:转发、暂停与气泡
这一节把五级流水线(F/D/E/M/W)跑在一个 6 条指令的小程序上,逐周期填表,算出一个真实的 CPI。所有规则都可以在纸上复现。
程序(接 1.4.9 的机器码;为了演示 load-use,把 I4 换成一条 load,地址仅作示意):
I1 0x000 irmovq $10, %rcx
I2 0x00a irmovq $0, %rax
I3 0x014 addq %rcx, %rax
I4 0x016 mrmovq 0(%rax), %rdx # load,假设 rax 指向可读内存
I5 0x020 addq %rdx, %rbx
I6 0x022 halt
假设:分支预测「不跳转」;ALU 结果可以从 E/M/W 转发;load 的数据只在 M 末尾可用;寄存器堆写口在 W 前半周期写、后半周期读(同一周期内可转发)。
无冒险时的理想表
指令 1 2 3 4 5 6 7 8 9 10
I1 irmovq F D E M W
I2 irmovq F D E M W
I3 addq F D E M W
I4 mrmovq F D E M W
I5 addq F D E M W
I6 halt F D E M W
6 条指令、5 级流水线,理想周期数是 ,CPI 。流水线填满后每周期完成一条,长期 CPI 趋近 1。
加入 load-use 冒险
I5 在 D 阶段要读 %rdx,而 I4 是 load,数据要到 M 阶段末尾才回来。E 阶段开始时要用的值还没有,只能暂停一拍:
指令 1 2 3 4 5 6 7 8 9 10 11
I1 irmovq F D E M W
I2 irmovq F D E M W
I3 addq F D E M W
I4 mrmovq F D E M W
I5 addq F D D E M W
I6 halt F - D E M W
第 7 周期:I5 停在 D(表中第二个 D),E 阶段插入一个气泡(bubble),F 阶段的 I6 原地等待。总周期 11,CPI 。代价是一拍,不是三拍——因为 load 的数据在 M 末尾可用,下一拍就能转发进 E。
load-use 的精确条件是:
E_icode == IMRMOVQ && (E_dstM == D_srcA || E_dstM == D_srcB)
满足时:F 保持 PC 不变、D 保持当前指令、E 插入气泡(把 E 的控制信号全部清零,等效于 nop)。
转发条件
E 阶段的两个源操作数 e_valA、e_valB 按优先级选择:
| 优先级 | 来源 | 条件 |
|---|---|---|
| 1 | W 阶段写回值 | W_dstE == E_srcA && W_dstE != RNONE |
| 2 | M 阶段 ALU 结果 | M_dstE == E_srcA && M_dstE != RNONE |
| 3 | 寄存器堆读出的值 | 默认 |
e_valB 同理,但还要考虑 M_dstM(load 的目的寄存器)——load 的结果要到 M 末尾才有,不能从 M 转发,只能暂停。这就是为什么「load-use 必须停一拍,而普通 ALU 依赖不用停」。
控制冒险
| 情况 | 代价(CSAPP 五级) | 处理 |
|---|---|---|
| 条件跳转预测错误 | 2 个气泡 | 在 E 阶段算出条件后,把 PC 改到 valC,清空 D/E |
ret | 3 个气泡 | 返回地址在 W 阶段才写回,D 必须停到那时 |
| 预测「不跳转」但实际跳转 | 2 个气泡 | 同上;现代处理器用 BTB + 方向预测把代价降到接近 0 |
| 预测「跳转」但实际不跳 | 2 个气泡 | 需要能取消已取入的错误路径指令 |
现代处理器的分支预测早已不是「一律不跳」:BTB 记住跳转目标,两位饱和计数器记住方向,返回地址栈预测 ret,TAGE 一类预测器用长历史做索引。预测正确的代价是 0,预测错误才付 15–20 周期的流水线冲刷(深度越大越贵)。
上表 11 个周期、6 条指令,CPI = 1.83。若循环执行 10 次(每次 5 条指令 + 1 条 jne),且 jne 预测正确、无 load-use,则每次迭代 5 个周期(I1–I5 各占一拍)加 jne 一拍,共 6 拍/迭代;10 次迭代 60 拍,加填充/排空约 5 拍,总计约 65 拍完成 60 条指令,CPI ≈ 1.08。
1.4.12 乱序执行的量化模型:ROB、窗口与 Little 定律
1.4.7 讲了重命名、ROB 与超标量的机制。这一节换一个角度:把乱序执行当成一个排队系统,用几个数字解释「为什么 IPC 上不去」。这比记住「ROB 是重排序缓冲」更有用,因为 perf 报出来的瓶颈最终都能落到这几个量上。
前端与后端
| 部分 | 做什么 | 关键结构 | 典型瓶颈 |
|---|---|---|---|
| 前端 | 取指、译码、送入 uop | I-cache、BTB、uop cache、译码器 | I-cache 缺失、译码带宽、分支预测失败 |
| 重命名 | 架构寄存器 → 物理寄存器 | RAT、空闲物理寄存器表 | 物理寄存器耗尽 |
| 调度 | 唤醒、选择、发射 | 保留站、发射端口 | 端口冲突、依赖链 |
| 执行 | ALU/load/store/FP | 执行单元、load/store 缓冲 | 延迟、带宽、miss |
| 退休 | 按序提交、精确异常 | ROB | ROB 满、长延迟指令堵头 |
前端每周期最多取 16 字节(x86)、译码 4–6 条;后端每周期最多发射 4–8 个 uop。任何一段满了,IPC 就停在那里。
重命名解决什么、不解决什么
重命名消除的是假依赖(WAR/WAW):mov %rax,%rbx; mov %rcx,%rax 里第二条写 %rax 不必等第一条读完,因为两者被映射到不同物理寄存器。它不消除真依赖(RAW):add %rax,%rbx 仍要等 %rax 的上一个生产者。
内存依赖更麻烦:mov (%rdi),%rax; mov %rax,(%rsi) 有寄存器真依赖,容易处理;但 mov %rax,(%rdi); mov (%rsi),%rbx 是否有依赖,取决于 %rdi 与 %rsi 是否相等,直到地址算出来才知道。处理器用 load/store 队列做内存消歧(memory disambiguation):允许 load 先于未知地址的 store 执行(推测),发现别名再冲刷重放。这就是「4K 别名」一类性能异常的来源。
ROB 与 Little 定律
把 ROB 看成一个容量为 的队列,指令在队列里停留的平均时间为 (从取指到退休),吞吐为 (即 IPC)。排队论的基本关系是:
反过来,给定 ROB 容量与平均停留时间,IPC 上限是 。举例:
| 场景 | IPC 上限 | ||
|---|---|---|---|
| 全部命中 L1 | 224 | 30 周期 | 7.5(受发射宽度限制,实际 4–6) |
| 1% 指令 L3 缺失(200 周期) | 224 | 7.0(仍不是瓶颈) | |
| 10% 指令 DRAM 缺失(300 周期) | 224 | 3.7 | |
| 每个 load 都 miss,MLP=1 | 224 | 330 | 0.68 |
最后一行说明:ROB 再大,也救不了「一次只有一个 miss 在飞」的代码。要提高 IPC,要么减少 miss(分块、预取),要么提高 MLP(同时发多个独立 load)。ROB 的作用是让这些独立 load 能同时在飞——它提供的是「并发度预算」,不是「延迟消除」。
ROB 每一项都要参与唤醒、选择、比较(最多 6 个发射口 × 每项),面积与功耗随容量超线性增长;而且越大的窗口越难做时序收敛。现代桌面 CPU 的 ROB 在 224–512 项,Apple M 系列更大(约 600+),但都不是无限。窗口大小是「平均 IPC」与「芯片成本」的折中,不是越大越好。
公开的微架构参数(量级)
| 处理器 | 发射宽度 | ROB 项数 | L1D | 备注 |
|---|---|---|---|---|
| Intel Skylake | 4 | 224 | 32 KB | 2015–2019 主力 |
| Intel Golden Cove | 6 | 512 | 48 KB | 12 代酷睿 |
| AMD Zen 4 | 6 | 320 | 32 KB | 5nm |
| Apple Firestorm | 8 | ~630 | 128 KB | M1 性能核 |
数字来自厂商公开资料与第三方逆向,不同步进略有差异;用 perf stat 测出来的是实际行为,不要拿表格当保证。表格的用途是建立量级感:宽度从 4 到 8 只翻倍,而 L1 容量翻四倍,说明现代 CPU 更依赖「少 miss」而不是「更宽」。
1.4.13 完整程序一:Y86-64 解释器
这个程序实现 1.4.9 的 ISA 子集:取指、译码、执行、访存、写回、更新 PC。它不模拟流水线,只模拟指令集语义——先用它确认手写的机器码是对的,再去关心流水线为什么慢。
/* y86.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 y86.c */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
#define MEMSZ 4096
enum { RAX, RCX, RDX, RBX, RSP, RBP, RSI, RDI, R8, R9, R10, R11, R12, R13, R14, RNONE = 15 };
enum { AOK = 1, HLT = 2, ADR = 3, INS = 4 };
static uint8_t mem[MEMSZ];
static uint64_t reg[16];
static uint64_t pc;
static int ZF, SF, OF;
static int status = AOK;
static uint64_t rd64(size_t at)
{
uint64_t v = 0;
for (int i = 7; i >= 0; i--)
v = (v << 8) | mem[at + (size_t)i];
return v;
}
static void wr64(size_t at, uint64_t v)
{
for (int i = 0; i < 8; i++)
mem[at + (size_t)i] = (uint8_t)(v >> (8 * i));
}
static void set_cc(uint64_t a, uint64_t b, uint64_t r, int sub)
{
ZF = (r == 0);
SF = (r >> 63) & 1;
if (sub)
OF = ((a >> 63) != (b >> 63)) && ((r >> 63) != (a >> 63));
else
OF = ((a >> 63) == (b >> 63)) && ((r >> 63) != (a >> 63));
}
static int cond(int ifun)
{
switch (ifun) {
case 0: return 1; /* jmp / rrmovq */
case 1: return (SF ^ OF) | ZF; /* le */
case 2: return SF ^ OF; /* l */
case 3: return ZF; /* e */
case 4: return !ZF; /* ne */
case 5: return !(SF ^ OF); /* ge */
case 6: return !(SF ^ OF) && !ZF; /* g */
default: return 0;
}
}
static void step(void)
{
if (pc >= MEMSZ) { status = ADR; return; }
uint8_t byte = mem[pc];
int icode = byte >> 4, ifun = byte & 0xF;
uint8_t regbyte = (pc + 1 < MEMSZ) ? mem[pc + 1] : 0;
int rA = regbyte >> 4, rB = regbyte & 0xF;
uint64_t valC = 0, valP = pc + 2;
int use_c = 0, has_reg = 0;
switch (icode) {
case 0x0: status = HLT; return; /* halt */
case 0x1: break; /* nop */
case 0x2: has_reg = 1; break; /* rrmovq/cmov */
case 0x3: has_reg = 1; use_c = 1; break; /* irmovq */
case 0x4: has_reg = 1; use_c = 1; break; /* rmmovq */
case 0x5: has_reg = 1; use_c = 1; break; /* mrmovq */
case 0x6: has_reg = 1; break; /* OPq */
case 0x7: use_c = 1; break; /* jXX */
case 0x8: use_c = 1; break; /* call */
case 0x9: break; /* ret */
case 0xA: has_reg = 1; break; /* pushq */
case 0xB: has_reg = 1; break; /* popq */
default: status = INS; return;
}
if (use_c) valC = rd64(pc + (has_reg ? 2 : 1));
valP = pc + (has_reg ? 2 : 1) + (use_c ? 8 : 0);
uint64_t aluA = 0, aluB = 0, dst = 0;
int write_reg = 0, write_mem = 0, setcc = 0, dst_reg = RNONE;
uint64_t mem_addr = 0, mem_data = 0;
switch (icode) {
case 0x2: /* rrmovq / cmovXX */
aluA = reg[rA]; aluB = 0; dst = aluA + aluB;
if (cond(ifun)) { write_reg = 1; dst_reg = rB; }
break;
case 0x3: /* irmovq */
dst = valC; write_reg = 1; dst_reg = rB;
break;
case 0x4: /* rmmovq rA, D(rB) */
mem_addr = reg[rB] + valC; mem_data = reg[rA]; write_mem = 1;
break;
case 0x5: /* mrmovq D(rB), rA */
mem_addr = reg[rB] + valC; write_reg = 1; dst_reg = rA;
break;
case 0x6: /* OPq */
aluA = reg[rA]; aluB = reg[rB];
if (ifun == 0) dst = aluB + aluA;
else if (ifun == 1) dst = aluB - aluA;
else if (ifun == 2) dst = aluB & aluA;
else if (ifun == 3) dst = aluB ^ aluA;
else { status = INS; return; }
write_reg = 1; dst_reg = rB; setcc = (ifun <= 1);
break;
case 0x7: /* jXX */
if (cond(ifun)) { pc = valC; return; }
break;
case 0x8: /* call */
reg[RSP] -= 8; wr64(reg[RSP], valP); pc = valC; return;
case 0x9: /* ret */
pc = rd64(reg[RSP]); reg[RSP] += 8; return;
case 0xA: /* pushq */
reg[RSP] -= 8; wr64(reg[RSP], reg[rA]); break;
case 0xB: /* popq */
write_reg = 1; dst_reg = rA; break;
case 0x1: break;
}
if (setcc) set_cc(aluB, aluA, dst, ifun == 1);
if (write_mem) {
if (mem_addr + 7 >= MEMSZ) { status = ADR; return; }
wr64(mem_addr, mem_data);
}
if (icode == 0x5) {
if (mem_addr + 7 >= MEMSZ) { status = ADR; return; }
dst = rd64(mem_addr);
}
if (icode == 0xB) {
if (reg[RSP] + 7 >= MEMSZ) { status = ADR; return; }
dst = rd64(reg[RSP]); reg[RSP] += 8;
}
if (write_reg && dst_reg != RNONE) reg[dst_reg] = dst;
pc = valP;
}
int main(void)
{
static const uint8_t prog[] = {
0x30, 0xf1, 0x0a, 0, 0, 0, 0, 0, 0, 0, /* 0x000: irmovq $10,%rcx */
0x30, 0xf0, 0x00, 0, 0, 0, 0, 0, 0, 0, /* 0x00a: irmovq $0,%rax */
0x60, 0x10, /* 0x014: addq %rcx,%rax */
0x30, 0xf2, 0x01, 0, 0, 0, 0, 0, 0, 0, /* 0x016: irmovq $1,%rdx */
0x61, 0x21, /* 0x020: subq %rdx,%rcx */
0x74, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, /* 0x022: jne 0x014 */
0x00 /* 0x02b: halt */
};
memcpy(mem, prog, sizeof prog);
int steps = 0;
while (status == AOK && steps < 1000) { step(); steps++; }
printf("status=%d steps=%d pc=0x%llx ZF=%d SF=%d OF=%d\n",
status, steps, (unsigned long long)pc, ZF, SF, OF);
printf("rax=%llu rcx=%llu rdx=%llu\n",
(unsigned long long)reg[RAX], (unsigned long long)reg[RCX],
(unsigned long long)reg[RDX]);
return status == HLT ? 0 : 1;
}
预期输出:
status=2 steps=43 pc=0x2b ZF=1 SF=0 OF=0
rax=55 rcx=0 rdx=1
status=2 是 HLT,steps=43 可以手算核对:2 条 irmovq + 10 次循环 ×(addq、irmovq、subq、jne)+ 最后 1 条 halt = 2 + 40 + 1 = 43。rcx 从 10 减到 0,rax 累加 10+9+…+1 = 55。
关键实现细节:
has_reg决定常量从第 2 还是第 3 字节开始。jXX/call没有寄存器字节,这个分支写错会让所有跳转目标变成 0——第一次运行时的现象是「PC 回到 0,程序从头再来」。OPq的语义是rB = rB OP rA,所以subq %rdx,%rcx的字节是61 21(rA=2、rB=1),不是61 12。set_cc只对addq/subq更新 ZF/SF/OF;and/xor在 Y86 中不设条件码(与 x86 不同,x86 的逻辑运算会设 ZF/SF 并清 CF/OF)。popq的目的寄存器是 rA,而pushq的源寄存器也是 rA;两者的 rB 都是RNONE。
1.4.14 完整程序二:依赖链、ILP 与 IPC 的上界
流水线的 CPI 能不能小于 1,取决于「同一时刻有多少条独立指令在飞」。这个程序用一条 LCG 递推链和四条独立链做对比:前者受乘法延迟限制,后者受发射宽度/端口吞吐限制。
/* ilp.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 ilp.c
* 运行:./ilp 20000000 0 (0=一条依赖链,1=四条独立链)
*/
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
volatile unsigned long sink;
static double now(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}
int main(int argc, char **argv)
{
unsigned long n = (argc > 1) ? strtoul(argv[1], NULL, 0) : 50000000UL;
int mode = (argc > 2) ? atoi(argv[2]) : 0;
unsigned long a = 1, b = 1, c = 1, d = 1;
const unsigned long A = 1664525UL, C = 1013904223UL;
double t0 = now();
if (mode == 0) {
for (unsigned long i = 0; i < n; i++) {
a = a * A + C;
__asm__ volatile("" : "+r"(a)); /* 阻止封闭形式优化 */
}
} else {
for (unsigned long i = 0; i < n; i++) {
a = a * A + C;
b = b * A + C;
c = c * A + C;
d = d * A + C;
__asm__ volatile("" : "+r"(a), "+r"(b), "+r"(c), "+r"(d));
}
}
double dt = now() - t0;
sink = a + b + c + d;
unsigned long updates = (mode == 0) ? n : 4UL * n;
printf("mode=%s updates=%lu time=%.3f s per-update=%.3f ns sink=%lu\n",
mode == 0 ? "dependent" : "independent", updates, dt,
dt * 1e9 / (double)updates, (unsigned long)sink);
return 0;
}
在一台 Apple M1(arm64,clang 15,-O2)上的输出:
mode=dependent updates=20000000 time=0.034 s per-update=1.710 ns sink=7662709483399116036
mode=independent updates=80000000 time=0.034 s per-update=0.425 ns sink=12204093859886912516
四条独立链在同样的墙钟时间里做了四倍的更新,每次更新的成本从 1.71 ns 降到 0.425 ns(约 4 倍)。原因不是「独立链的乘法更快」,而是:
- 依赖链里每条
imul必须等上一条的结果,延迟约 3–4 周期,OoO 窗口再大也无能为力(真依赖)。 - 四条链之间没有依赖,CPU 可以每周期发射多条
imul,把乘法器的吞吐吃满;限制从「延迟」变成「端口数量 + 前端宽度」。
这个实验也解释了为什么「循环展开 + 多累加器」能提速(1.5.5):它把一条长依赖链拆成几条短链,让延迟被并行掩盖。判断标准不是「展开几路」,而是「链数 × 单链延迟 ≥ 总周期」,或者用 perf stat 看 IPC 是否接近发射宽度。
把 a 声明为 volatile 会让每次读写都落到内存,测到的是 store-to-load 延迟,不是 ALU 延迟。__asm__ volatile("" : "+r"(a)) 告诉编译器「a 的值可能被这段空代码改变」,强制它把 a 留在寄存器里并逐次计算,同时不生成任何指令。这是微基准最常用的「优化屏障」。
1.4.15 实验:用 perf stat 读 IPC 与分支预测
perf stat 把「程序为什么慢」拆成可加的计数。先测 1.4.14 的两条路径:
$ cc -O2 -o ilp ilp.c
$ perf stat -e cycles,instructions,branches,branch-misses ./ilp 200000000 0
$ perf stat -e cycles,instructions,branches,branch-misses ./ilp 50000000 1
x86-64 上的输出形如:
Performance counter stats for './ilp 200000000 0':
601,234,567 cycles
201,345,678 instructions # 0.33 insn per cycle
1,234,567 branches
12,345 branch-misses # 1.00% of all branches
0.201 seconds time elapsed
读法:
- IPC = instructions / cycles。依赖链版本约 0.33,意味着平均每 3 个周期才完成一条指令——正好对应乘法延迟;独立链版本 IPC 会接近 2–3,受发射宽度限制。
branch-misses的百分比要与branches一起看。循环的jne每 10 次迭代错一次,miss 率约 10%,但绝对数很小;真正致命的是「每次迭代都随机跳」的代码。cycles与time elapsed的比值给出实际频率。若频率远低于标称值,先查功耗/温度限制,再谈 IPC。
第二个实验量化分支预测。程序把 100 万个随机字节与 128 比较,排序后同样的比较会变成「一长串连续 taken / 连续 not-taken」,两位预测器几乎全中:
/* bpred.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 bpred.c
* 运行:./bpred 0(随机) ./bpred 1(排序后)
*/
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define N (1u << 20)
static int cmp(const void *x, const void *y)
{
return (int)*(const unsigned char *)x - (int)*(const unsigned char *)y;
}
static double now(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}
int main(int argc, char **argv)
{
static unsigned char a[N];
unsigned seed = 1;
for (unsigned i = 0; i < N; i++) {
seed = seed * 1103515245u + 12345u;
a[i] = (unsigned char)(seed >> 16);
}
int sorted = (argc > 1) ? atoi(argv[1]) : 0;
if (sorted)
qsort(a, N, 1, cmp);
long sum = 0;
double t0 = now();
for (unsigned i = 0; i < N; i++) {
if (a[i] >= 128) {
sum += a[i];
__asm__ volatile("" : "+r"(sum)); /* 阻止向量化,保留真分支 */
}
}
double dt = now() - t0;
printf("sorted=%d sum=%ld time=%.3f ms\n", sorted, sum, dt * 1e3);
return 0;
}
$ cc -O2 -o bpred bpred.c
$ ./bpred 0
sorted=0 sum=100450803 time=5.770 ms
$ ./bpred 1
sorted=1 sum=100450803 time=0.518 ms
$ perf stat -e branches,branch-misses ./bpred 0
$ perf stat -e branches,branch-misses ./bpred 1
预期:随机版本的分支 miss 率约 50%(每次比较都像抛硬币),排序后降到 0.1% 以下;时间差约 10 倍。这个实验说明:
- 分支预测器的输入是历史,不是数据分布;随机数据没有可利用的历史。
- 把「数据排序」这种算法层改动带来越来越大的收益,是因为它把「控制冒险」变成了「可预测的控制流」。
- 用
cmov把分支改成数据依赖(1.3.4)也能消除 miss,但会把两条路径都算一遍;数据随机时通常划算,分支高度可预测时反而多做事。
- 先看
instructions与cycles的比值(IPC),与预期上限比较。 - IPC 低:看
branch-misses(控制冒险)、stalled-cycles-frontend(取指/译码)、stalled-cycles-backend(执行/访存)。 - IPC 高但慢:看频率、
cache-misses、dTLB-load-misses,以及是否在做无用功(指令数本身太多)。 - 换
-e列表细化到具体端口或缓存层,例如perf stat -e L1-dcache-load-misses,LLC-load-misses。
1.4.16 三种 ISA 上的流水线:同一套五级,不同的前端
1.4.11 的五级流水线是通用模型,但把它落到 x86-64、AArch64、RISC-V 上,前端的复杂度差别很大。这一节回答:为什么 x86 的译码器那么贵、为什么 RISC-V 适合做教学 ISA、同一段 C 在三种架构上「流水线行为」差在哪。后面附一个 C/Rust 双版本的 IPC 实验。
与流水线相关的 ISA 差异
| 维度 | x86-64 | AArch64 | RV64GC |
|---|---|---|---|
| 指令长度 | 1–15 字节,变长 | 固定 4 字节 | 固定 4 字节;C 扩展混入 2 字节 |
| 取指 | 需先定界,易跨取指块 | 按 4 字节对齐切分 | 需处理 2/4 字节混合对齐 |
| 译码 | 译码成 uop,复杂指令拆多条 | 一条指令一个(或少数)uop | 一条指令一个 uop |
| 条件码 | RFLAGS,算术指令隐式写 | NZCV,仅 *S 指令写 | 无,分支直接比较 |
| 分支 | cmp/test + jcc | cmp + b.cond,或 cbz/tbnz | beq/bne/blt/bge 直接比较 |
| 条件传送 | cmov | csel/csinc | 无,需分支或位运算 |
| 内存操作数 | 算术指令可直接读内存 | load/store 分离 | load/store 分离 |
| 通用寄存器 | 16 | 31(+xzr) | 32(x0 恒 0) |
| 典型前端宽度 | 4–6 uop/周期 | 4–8 | 4–8(视实现) |
| 典型分支惩罚 | 15–20 周期 | 10–15 周期 | 5–10 周期(依流水深度) |
这张表解释了三件事:
- x86 的前端最贵。变长指令要先确定边界,复杂指令要拆成多个 uop;现代 x86 用 uop cache 把「译码一次」的结果缓存起来,热代码直接取 uop,绕开译码器。ARM64/RISC-V 的定长编码让取指与译码简单得多,代价是代码密度通常更低(同样逻辑占更多 I-cache)。
- x86 的标志寄存器是一个「隐式目的地」。每条写标志的指令都在写一个所有分支共享的寄存器,重命名需要额外处理;RISC-V 没有这个问题,分支自带比较,不存在「标志位假依赖」。AArch64 折中:只有带
S后缀的指令写 NZCV。 - RISC-V 最适合讲五级流水线。没有标志位、没有内存操作数、没有条件传送、寄存器多、编码定长,控制逻辑几乎是 1.4.10 的 HCL 直译。CSAPP 用 Y86-64 也是同样的思路:先把 ISA 简化到「每条指令都能在一个阶段做完」,再讨论冒险。
同一段循环在三种架构上的形态
以 s += i; i++ 为例(i 在寄存器、n 在另一个寄存器):
| 架构 | 指令序列 | 说明 |
|---|---|---|
| x86-64 | add %rcx,%rax;inc %rcx;cmp %rcx,%rdi;jge | 4 条,cmp 设标志 |
| AArch64 | add x0,x0,x2;add x2,x2,#1;cmp x1,x2;b.ge | 4 条,cmp 设 NZCV |
| RISC-V | add a0,a0,t0;addi t0,t0,1;bge a1,t0 | 3 条,分支自带比较 |
流水线视角下,三条序列的关键路径都是「i 的增量 → 下一次比较 → 分支」,长度 1–2 周期;差别在 uop 数与前端压力。x86 的 cmp+jge 在译码后可能融合(macro-fusion)成一个比较分支 uop,实际发射数与 RISC-V 接近。不要用源码行数推断性能,要用 perf stat 的 instructions 与 uops_issued(若可用)。
分支解析在哪一级
| 架构 | 分支条件何时可用 | 典型解析级 | 预测错误代价 |
|---|---|---|---|
| 经典五级(Y86/RISC-V 教学) | E 阶段 ALU 后 | E | 2 个气泡 |
| 现代 x86 | 专用分支单元,早期计算 | 前端/整数执行 | 15–20 周期 |
| 现代 AArch64 | 专用分支单元 + 条件标志前递 | 前端/执行 | 10–15 周期 |
| 现代 RISC-V(如 BOOM) | 执行级或更早 | E/前端 | 5–10 周期 |
流水线越深、频率越高,预测错误越贵。这就是为什么现代处理器把大量面积花在分支预测(BTB、TAGE、返回栈)而不是 ALU 上:预测正确时,分支在流水线里几乎「消失」;预测错误时,再快的 ALU 也救不回来。
CPU 组成的三架构对照
| 部件 | x86-64 | AArch64 | RV64GC |
|---|---|---|---|
| PC | RIP,不能直接读 | PC,不能直接读 | pc,auipc 可读高位 |
| 指令寄存器/预译码 | 变长,需预译码 | 固定 4 字节 | 4 字节 + 2 字节压缩 |
| 译码器 | 复杂,微码/多 uop | 规则,单 uop 为主 | 规则,单 uop 为主 |
| 寄存器堆 | 16×64,支持 8/16/32 位别名 | 31×64 + 零寄存器 | 32×64,x0 硬连线为 0 |
| ALU/标志 | ALU + RFLAGS | ALU + 可选 NZCV | 纯 ALU,无标志 |
| 分支 | 条件码驱动 | 条件码或 cbz/tbnz | 比较器直接驱动 |
| 访存 | 复杂寻址、内存操作数 | 基址+偏移,load/store | 基址+偏移,load/store |
| 特权/异常 | IDT、MSR、CPL | 异常向量、系统寄存器 | CSR、异常向量 |
C 与 Rust 双版本 IPC 实验
同一段依赖链/独立链基准,C 与 Rust 各写一遍。Rust 版本用 std::hint::black_box 阻止常量折叠,编译产物与 C 版本一样是普通 ELF,perf stat 的用法完全相同。
C17(1.4.14 的 ilp.c,节选主循环)
for (unsigned long i = 0; i < n; i++) {
a = a * A + C;
b = b * A + C;
c = c * A + C;
d = d * A + C;
__asm__ volatile("" : "+r"(a), "+r"(b), "+r"(c), "+r"(d));
}
Rust 2021(`rustc -O ilp.rs`,`black_box` 稳定于 1.66)
// ilp.rs — rustc -O ilp.rs -o ilp_rs
use std::hint::black_box;
use std::time::Instant;
fn main() {
let args: Vec<String> = std::env::args().collect();
let n: u64 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(50_000_000);
let mode: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
let (mut a, mut b, mut c, mut d) = (1u64, 1u64, 1u64, 1u64);
const A: u64 = 1664525;
const C: u64 = 1013904223;
let t0 = Instant::now();
if mode == 0 {
for _ in 0..n {
a = a.wrapping_mul(A).wrapping_add(C);
black_box(a);
}
} else {
for _ in 0..n {
a = a.wrapping_mul(A).wrapping_add(C);
b = b.wrapping_mul(A).wrapping_add(C);
c = c.wrapping_mul(A).wrapping_add(C);
d = d.wrapping_mul(A).wrapping_add(C);
black_box((a, b, c, d));
}
}
let dt = t0.elapsed().as_secs_f64();
let updates = if mode == 0 { n } else { 4 * n };
println!(
"mode={} updates={} time={:.3} s per-update={:.3} ns",
if mode == 0 { "dependent" } else { "independent" },
updates,
dt,
dt * 1e9 / updates as f64
);
black_box((a, b, c, d));
}
运行与观测:
$ rustc -O ilp.rs -o ilp_rs
$ ./ilp_rs 20000000 0
$ ./ilp_rs 20000000 1
$ perf stat -e cycles,instructions,branches,branch-misses ./ilp_rs 200000000 0
Rust 版本的行为与 C 版本一致:依赖链版本每次更新约 1.5–2 ns,独立链版本约 0.4–0.5 ns(具体数值取决于机器)。Rust 特有的两个干扰项要排除:
- 溢出检查:
rustc -O默认关闭溢出检查;若用cargo build --release且 profile 里开了overflow-checks = true,每次乘法后会多一条溢出分支,测到的就不是纯 ALU 延迟。 - 边界检查:如果基准用
Vec下标而不是固定变量,每次访问都可能有panic_bounds_check分支;用迭代器或get_unchecked才能与 C 对齐。
Rust 不是「替代 C」,而是与 C 共用同一套 ABI、同一套 ELF、同一套系统调用。Linux 内核从 6.1 起支持 Rust(CONFIG_RUST),用户态则有 rustix、nix、io_uring 绑定等库。对本课程而言,重要的是:机器码层面的知识是共通的,寄存器、栈帧、调用约定、缓存行不会因为语言而改变;语言只改变「谁负责检查」。
1.4.17 十二条处理器与流水线陷阱
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | HCL 综合出锁存器/仿真出 X | 选择表达式漏了默认分支 | 永远写 1 : default; |
| 2 | 控制信号随输入变化但下游不更新 | 把组合逻辑当成了时序逻辑 | 状态只在时钟边沿更新;HCL 等式无记忆 |
| 3 | load 后紧跟使用,结果错 | 只检查了 dstE,没检查 load 的 dstM | 加 load-use 暂停条件 |
| 4 | 转发拿到旧值 | 优先级写反(M 覆盖了 W) | W 阶段的值最新,优先级最高 |
| 5 | 分支预测错误后寄存器被污染 | 错误路径的指令没有被清空 | 冲刷 D/E,禁止错误路径写回 |
| 6 | ret 之后取到错误指令 | 返回地址在 W 才可用,D 没停 | ret 在 D 暂停到 W,或加返回栈预测 |
| 7 | 流水线寄存器初值是随机数 | 复位时没把控制信号清零 | 气泡 = 所有控制信号置为 nop 的编码 |
| 8 | 认为 CPI 不可能小于 1 | 只见过单发射五级流水线 | 超标量每周期可完成多条;CPI 的下界是 1/宽度 |
| 9 | 用主频估算性能 | 忽略了 IPC 与缓存 | 时间 = 指令数 × CPI / 频率,三项都要看 |
| 10 | perf 的 IPC 与手算不符 | instructions 含内核/中断,或被多线程分摊 | 用 -e instructions:u 限定用户态,绑定 CPU |
| 11 | Y86 跳转目标总是 0 | 以为 jXX 也有 rA:rB 字节 | jXX/call 的常量从第 2 字节开始 |
| 12 | Y86 循环不终止 | subq 写成 0x60(addq) | OPq 的 fn 在低 4 位:sub 是 0x61 |
- 「流水线加深一定更快」——深度增加会提高分支预测失败的代价(15–20 周期),也会增加流水线寄存器开销;Pentium 4 的 31 级流水线就是反例。
- 「乱序执行会改变程序结果」——ISA 保证按序语义;乱序只影响时序,退休仍按程序顺序,异常也是精确的。
- 「IPC 越高程序越快」——IPC 高可能只是指令多而简单;比较两个程序要看「完成同一任务的时间」,不是 IPC。
1.4.18 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 区分 ISA 与微架构 | 能各举一例 | 1.4.1 |
写出 Y86-64 的 irmovq 编码 | 能解释每个字节 | 1.4.2 |
| 画出五级流水线时间图 | 标出 F/D/E/M/W | 1.4.4 |
| 判断转发能否解决某个冒险 | 能识别 load-use | 1.4.5 |
| 用 CPI 公式估算分支代价 | 会算 90%/99% 的差别 | 1.4.6 |
| 解释重命名与 ROB 的分工 | 能说出 WAW/WAR 如何消失 | 1.4.7 |
用 perf stat 判断瓶颈 | 能读 IPC 与 branch-misses | 1.4.8 |
Q1 五级流水线中,addq %rax,%rbx 后紧跟 mrmovq (%rbx),%rcx,需要暂停吗?为什么?
Q2 分支指令占 15%,预测准确率 95%,失败代价 12 周期,基础 CPI 1。求实际 CPI。若准确率提高到 99%,提升多少?
Q3 寄存器重命名消除的是哪类冒险?RAW 冒险能消除吗?
Q4 一个程序 perf stat 显示 IPC=0.4、branch-misses=18%、cache-misses=2%。最可能的瓶颈是什么?先查什么?
答案
A1 不需要暂停。ALU 结果在 E 阶段末产生,mrmovq 在下一个周期的 E 阶段才用它,可以通过 E→M 转发路径把 %rbx 的新值送过去。只有 load-use(mrmovq 后紧跟使用其结果)才需要暂停,因为数据在 M 阶段末才出来。
A2 ;99% 时 。加速比 ,约 7%。分支占比越高、失败代价越大,收益越明显。
A3 重命名消除 WAW 与 WAR(假依赖),因为它们只是「复用同一个架构寄存器名」造成的;RAW 是真依赖,必须靠转发/暂停/等待,不能消除。
A4 IPC 低且分支缺失率高,最可能是分支预测受限(热循环里有不可预测的分支)。先看 perf record 的热点函数与 perf annotate 的分支指令,再考虑把分支改成无分支或用查表;缓存缺失率只有 2%,不是首要矛盾。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| C(CSAPP) | 第 4 章 4.1 Y86-64 指令集体系结构 | 1.4.1、1.4.2 | 完整:ISA、编码、指令语义 |
| C(CSAPP) | 第 4 章 4.2 逻辑设计与 HCL | 1.4.3 | 完整:HCL 组合逻辑 |
| C(CSAPP) | 第 4 章 4.3 Y86-64 的顺序实现 | 1.4.3 | 完整:六阶段与 SEQ |
| C(CSAPP) | 第 4 章 4.4 流水线通用原理 | 1.4.4 | 完整:吞吐、延迟、流水寄存器 |
| C(CSAPP) | 第 4 章 4.5 Y86-64 流水线实现 | 1.4.4–1.4.6 | 完整:冒险、转发、暂停、预测 |
| R(奔跑吧) | 第 1 章 处理器体系结构 | 1.4.7、1.4.8 | 精选:ARM 流水线、OoO、内存模型 |
| L(李治军) | 第 4 章 线程切换与调度 | 1.4.8 | 背景:上下文切换保存的硬件状态 |
| O(OSTEP) | 第 6 章 受限直接执行 | 1.4.8 | 背景:用户态/内核态切换的硬件代价 |
| K(Kerrisk) | 第 3 章 系统编程概念 | 1.4.8 | 背景:系统调用的流水线代价 |
下一站:知道了 CPU 怎么执行,下一步是让它执行得更快。哪些优化编译器能做、哪些必须你来写?循环展开、多累加器、分块为什么有效?进入 1.5 程序性能优化。