审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
CPU 一直在赌,而且经常赌赢——Spectre 是"赌输了没擦干净"的代价。
2026-09-26 · Pipeline, Branch Prediction and Spectre

第 16 关 · 流水线、分支预测与 Spectre

定位

这一关回答 CPU 是如何"提前做题"的。答案很激进:现代 CPU 从不按程序顺序执行——分支预测器下注、乱序执行抢跑、退休(retire)时才按序提交。赌赢是常态,赌输就丢弃结果、零代价——Spectre 之前,所有人都这么以为。但它证明了一件事:架构状态可以丢弃,微架构状态(缓存)丢不掉。"赌输了没擦干净",就是侧信道的全部秘密。

四个机制支点:投机执行三件套(预测器/BTB/RSB、乱序执行、缓存侧信道)、V1/V2/Meltdown 的三分法、内核四道防线(nospec、retpoline、IBRS/IBPB、KPTI)、代价总账。链回:M15(侧信道的"信道"就是缓存命中/未命中的时间差)、M07(KPTI/IBPB/RSB stuffing 全在上下文切换路径上)、M04(KPTI 是两套页表)。

核心机制深讲

16.1 投机执行:三重赌博

流水线让取指/译码/执行/访存/写回重叠——但遇到分支时,下一条指令在哪还不知道,流水线不能停。于是 CPU 下注,赌具是三套预测器:

下注之后是指令的乱序执行:指令按"数据就绪"程度发射,不按程序顺序;退休时才按序提交——架构状态(寄存器、内存)只在退休时更新。赌输的指令,结果丢弃,仿佛没执行过。这套"仿佛没执行过"的保证,就是 Spectre 打碎的东西。

16.2 缓存侧信道:Flush+Reload

攻击分三步,教科书式的干净:

关键洞察:侧信道读的是"访问模式",不是数据——secret 从未被"架构性地"读取过,只是它"路过"了缓存。量化的部分是 M15 的遗产:命中/未命中的时间差几十纳秒,rdtsc 随便量——上关的"两个数量级",在这里变成了攻击原语。

16.3 三分法:V1、V2、Meltdown 是三种不同的赌

三个 CVE,三张脸(Documentation/admin-guide/hw-vuln/spectre.rst):

区分铁证:V1/V2 赌的是"往哪跳",Meltdown 赌的是"能不能读"。前者修预测器(或困住投机),后者只能把数据搬走——缓解策略的分叉点就在这里。

16.4 retpoline:让投机"猜错"

经典序列三句话:call 把 pause 循环的地址压栈 → 把真实目标 mov 到栈顶 → ret。架构上 ret 跳到栈顶的真实目标;投机上 RSB 预测"ret 回到 call 的下一条"——掉进 pause; lfence; jmp 死循环出不来。spectre.rst 的原话:return trampoline 把投机路径"trap"住,不让它跳进 gadget。

内核侧(arch/x86/kernel/cpu/bugs.c):spectre_v2_select_retpoline(),SPECTRE_V2_RETPOLINE = "Mitigation: Retpolines",编译期由 CONFIG_MITIGATION_RETPOLINE 门控。代价:每个间接调用/跳转多几条指令——于是 spectre.rst 写明:有 Enhanced IBRS 的 CPU 应该用它,"more efficient than retpoline"。软件 trick vs 硬件能力,选便宜的。

16.5 KPTI:釜底抽薪

arch/x86/mm/pti.c,文件头注释写明血统:基于 KAISER——"Kernel/User page tables isolation"。机制粗暴有效:两套页表。用户态运行时 CR3 指向 user 页表——只映射用户空间加少量入口跳板,内核地址空间在页表里根本不存在。Meltdown 的投机读连页表项都找不到,直接 fault。

门控:X86_BUG_CPU_MELTDOWN + pti=on/off/auto(pti.c 的 pti_mode 三态)。代价同样粗暴:每次 syscall/中断进出内核多一次 CR3 切换(M04 的页表切换)+ TLB 压力——syscall 密集型负载的回归可达两位数百分比。这就是 mitigations=off 存在的理由:威胁模型允许时,拿回性能。

16.6 IBPB/STIBP/RSB:上下文切换时的打扫

前两道防线管"跳去哪"和"读不读",这一道管"上一个进程留下的预测器状态":

代价总账:retpoline 慢间接分支、KPTI 慢 syscall、IBPB/RSB 慢上下文切换。M07 的"切换代价"在本关之后必须重算——切换不再只是 CR3+寄存器,还有预测器状态的打扫费。

为什么这样设计

为什么 Meltdown 用"两套页表"这种笨办法,而不是修 CPU?

因为已出货的 CPU 改不了微架构。KPTI 是纯软件方案:老 CPU 用页表隔离续命,新 CPU 在硬件里堵住投机越权。双轨制——软件为存量买单,硬件为增量负责。

为什么 retpoline 不干脆在所有间接分支前加 lfence?

lfence 是"别投机",retpoline 是"让你猜错、但架构正确"——前者杀死该分支上的一切投机收益,后者只为难攻击者。缓解设计的核心 trade-off:在"堵住攻击"和"保留性能"之间,选刚好够用的那个。这也是 EIBRS 替代 retpoline 的逻辑。

为什么 IBPB/STIBP 默认只保护"需要保护"的进程(prctl),而不是全开?

因为清 BTB、关超线程推测是全局性能税。内核的威胁模型是分层的(bugs.c 的 attack vector controls):user→kernel 默认防,user→user 按需(prctl 自选),由管理员和程序自己定。安全不是免费的,分层是为了让不需要的人不付税。

与已学模块的关联

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。

Q1. 一句话区分 V1、V2、Meltdown:它们赌的分别是什么?

V1 赌条件分支的方向(bounds check 被投机越过);V2 赌间接分支的目标(BTB 被投毒,跳进 gadget);Meltdown 赌"权限检查追不上乱序执行"(和分支预测无关,直接读内核内存)。记住"往哪跳 vs 能不能读"的分叉点。

Q2. 投机执行"赌输"后,架构状态(寄存器/内存)毫无变化,为什么还能泄漏数据?

因为退休(retire)只丢弃架构状态,不丢弃微架构状态。投机期间访问过的缓存行还留在 L1 里——"结果可以撤销,缓存擦不掉"。Flush+Reload 读的正是这些残留:secret 的值被编码成了"哪一行在缓存里"。

Q3. Flush+Reload 的三步,每一步的目的是什么?攻击者最终"读"到的是什么?

(1) Flush:清空探测数组的缓存行,建立干净的基线;(2) 诱使受害者投机执行 probe[secret*64]——把 secret 编码为"哪一行被载入缓存";(3) Reload 并计时,命中的行下标即 secret。最终"读"到的是访问模式,不是数据本身——secret 从未被架构性地读取。

Q4. retpoline 里,ret 在架构上跳对、在投机上跳错,靠的是哪个预测器?它为什么会被骗?

靠 RSB(Return Stack Buffer)。RSB 的预测逻辑是"ret 回到最近一次 call 的下一条"——retpoline 的 call 恰好把 pause 循环地址压了进去,RSB 忠实地预测跳回那里。而架构执行时 ret 弹的是被 mov 改写过的栈顶=真实目标。预测器按"惯例"猜,retpoline 给了它一个精心布置的惯例。

Q5. KPTI 为什么能防 Meltdown,却防不住 Spectre V1?

KPTI 防的是"读到本不该读的地址空间"——用户页表里没有内核映射,Meltdown 的投机读直接 fault。但 V1 读的是进程自己的内存(越界下标访问同一地址空间内的数据),页表隔离对此毫无作用。V1 要靠 nospec 宏截断投机依赖链——不同的赌,需要不同的封堵点。

Q6. BTB 为什么必须在上下文切换时清空?spectre.rst 给出的硬件理由是什么?

因为 BTB 预测"does not fully disambiguate"——它按地址偏移做预测,不区分进程、不区分特权级。上一个进程投的毒(特定偏移的 BTB 项),下一个进程执行到相同偏移的间接分支时会被直接采用。不清=把攻击者的预测器状态继承给受害者。所以 switch_mm_cond_ibpb 长在上下文切换路径上。

Q7. 有 Enhanced IBRS 的机器,内核为什么 prefer EIBRS 而不是 retpoline?

spectre.rst 原话:Enhanced IBRS "more efficient than retpoline"。retpoline 是软件 trick——每个间接分支多几条指令(call/mov/ret + pause 循环);EIBRS 是硬件能力——置一下 SPEC_CTRL,CPU 自己限制推测,指令流零改动。同样的安全,硬件方案税更低,所以 spectre_v2_select_mitigation() 有 EIBRS 就优先选它。

Q8. pti=off / mitigations=off 在什么时候是合理的选择?给出威胁模型论证。

当威胁模型里"攻击者"不存在时:(1) 物理隔离的单租户机器,没有不可信代码执行;(2) 虚拟化层已经做了隔离,guest 内无攻击者;(3) 性能敏感且已评估风险(如 HPC 计算节点)。论证框架:缓解防的是"不可信代码与受害者共享 CPU"——如果这条前提不成立,KPTI/retpoline 就是纯税。关缓解不是"不安全",是"威胁模型不支持这笔开销"。

Q9. Spectre 为什么"修不完"?从"缓解分层"和"新变体"两个角度论证。

(1) 缓解分层:每个缓解只堵一个攻击面——nospec 堵 V1 的下标、retpoline 堵间接分支、KPTI 堵越权读、IBPB 堵 BTB 残留。攻击者换一条没堵的路(RSB 投毒→RSB stuffing、BHB→BHI_DIS_S),就又有新 CVE。bugs.c 里从 SPECTRE_V1 一路到 ITS、SRSO 的缓解名单,就是这部"打地鼠"史的化石记录。(2) 新变体:只要"投机执行 + 可观测的微架构状态"这两个前提还在,新的预测器结构(分支历史缓冲、返回栈、store buffer……)就是新的攻击面。Spectre 修不完,因为它不是 bug,是性能优化的固有属性。

Q10. 设计题:一个每请求约 100 次 syscall 的服务,KPTI 开启前后 syscall 路径代价如何变化?设计一个用 perf stat 验证的实验(说明计数哪些事件、对比哪几组)。

代价变化:每次 syscall 进出内核多一次 CR3 切换(写 CR3 本身几十周期)+ 用户/内核页表切换带来的 TLB 未命中上升(PCID 缓解了部分,但 entry trampoline 区域的局部性仍差)+ 中断路径同理。100 次 syscall/请求 ⇒ 每请求多 200 次 CR3 切换,约数千周期,syscall 密集型服务整体回归可达两位数百分比。实验:perf stat -e cycles,instructions,dtlb_load_misses.walk_completed,itlb_misses.walk_completed,context-switches,三组对比:(a) pti=on,(b) pti=off,(c) pti=on + PCID 确认开启——看 dTLB walk 次数与 cycles/request 的差值,差值即 KPTI 税。注意固定 CPU 频率、绑核,排除调度噪声。

延伸阅读

投机执行时间线:赌赢是常态,"擦干净"是谎言

  周期 →  取指 → 译码 → [分支! 预测器下注] → 乱序执行 → …
                                                    │
                              ┌─────────────────────┘
                              │ 投机窗口
                              ▼
                    ┌─────────────────────┐
                    │ 预测正确 → 退休提交  │  架构状态更新,一切正常
                    │ 预测错误 → 丢弃结果  │  架构状态无变化
                    └─────────────────────┘
                              │
                    但 L1 缓存里留下了"访问痕迹" ──→ Flush+Reload 读走

  一句话:CPU 保证"算错了当没算过",但没保证"当没来过"。 
retpoline:架构上跳对,投机上跳错

  call  set_up          ; 把 pause 循环地址压入 RSB
  capture:
      pause
      lfence
      jmp   capture     ; 投机掉进死循环,出不来
  set_up:
      mov   %reg, (%rsp); 栈顶改写为真实目标
      ret               ; 架构执行:跳真实目标 ✓
                        ; 投机执行:RSB 预测回 capture ✗ → 被困住

  一句话:用 RSB 的"惯例",给投机挖了个陷阱。 
内核四道防线对照表

  攻击面            缓解              位置/机制                  代价
  ─────────────────────────────────────────────────────────────
  V1 条件分支       nospec 宏+lfence  可疑下标先 sanitize        极小(局部)
  V2 间接分支       retpoline         间接调用变"猜不中的ret"    每个间接分支多几条指令
  V2 间接分支       Enhanced IBRS     SPEC_CTRL MSR(硬件)      比 retpoline 更便宜
  V2 残留 BTB       IBPB              switch_mm 时清 BTB         上下文切换变贵
  SMT 兄弟核        STIBP             switch_to 时置位           超线程推测受限
  Meltdown 越权读   KPTI              两套页表(pti.c)          每次进出内核多一次CR3切换

  总账:没有免费的缓解,只有"选哪种税"。 

在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。