第 6 章

一条指令的一生

一条指令的一生

2020 年,一位名叫 Travis Downs 的工程师发了一篇博客文章,标题大意是"这条指令凭什么比加法慢 40 倍?"他分析了 x86 的某条特殊指令,发现在某些情况下它的延迟是普通加法的 40 倍,原因是 CPU 内部的一个特殊处理路径。

这篇文章引发了大量工程师的共鸣,因为它触及了一个问题:CPU 到底在执行一条指令时做了什么?为什么有些指令快、有些慢?

想要回答这个问题,我们需要跟踪一条指令的完整生命周期。

Level 1:建立直觉

指令的旅程:五个车站

一条指令从诞生到完成,要经过 CPU 内部的多个"车站"。最经典的五级流水线是这样的:

取指(Fetch) → 解码(Decode) → 执行(Execute) → 内存访问(Memory) → 写回(Writeback)

用餐厅类比:

  1. 取指:服务员去取菜单(从内存取出指令的二进制码)
  2. 解码:服务员看懂菜单(CPU 解析这串数字是什么操作、用到哪些寄存器)
  3. 执行:厨师烹饪(ALU 做加法、移位等实际计算)
  4. 内存访问:端菜(如果指令需要访问内存,现在去做)
  5. 写回:把菜放到桌上(把结果写回寄存器)

在五级流水线里,CPU 不等前一条指令走完整个流程再开始下一条。就像餐厅里,厨师不等客人 A 吃完再处理客人 B 的订单——各自有各自的节奏,多个订单同时在流水线上处理。

这就是下一章要深入讲的"流水线"概念。这一章,我们先细看每个阶段的内部机制。

阶段 1:取指(Instruction Fetch)

CPU 有一个程序计数器(PC),指向下一条要执行的指令在内存中的地址。

取指阶段:

  1. 把 PC 里的地址发送给内存(或者更快的缓存)
  2. 内存返回该地址的数据(通常是 4-16 字节的指令字)
  3. 指令存入指令寄存器(IR)
  4. PC 更新为下一条指令的地址(通常是当前地址 + 指令长度)

对于定长指令集(如 RISC-V 的 32 位指令):PC + 4 就是下一条指令地址,非常简单。

对于变长指令集(如 x86):指令长度 1-15 字节不等,CPU 必须先解析才能知道当前指令有多长,这增加了取指的复杂性。

关键延迟:如果指令在 L1 缓存(指令缓存,L1-I),取指只需要 4-5 个时钟周期。如果不在 L1 但在 L2,需要 12 个周期。如果需要从主内存取,需要 100-200 个周期——这时候 CPU 会"饿死"(Instruction Starvation),流水线因等待指令而空转。

阶段 2:解码(Decode)

取来的指令是一串二进制,就像食谱的密码。解码阶段把它翻译成 CPU 内部能理解的操作信号。

x86 的解码特别复杂

x86 指令长度可变(1-15字节),有几百种不同的操作码,还有前缀修饰符……现代 x86 处理器的解码器设计非常复杂,往往包含多个解码单元并行工作,但仍是 x86 处理器的一个"负担"。

解码的输出是微操作(μop):更简单、更规整的内部指令。一条 x86 指令可能被解码成 1 到几十个 μop。

例如,x86 的 push rax 指令被解码成两个 μop:

μop 1: rsp = rsp - 8           (更新栈指针)
μop 2: mem[rsp] = rax          (把 rax 写到新栈顶)

ARM 和 RISC-V 的解码

ARM/RISC-V 指令格式规整,解码简单得多,通常一条指令对应一两个 μop。这是 RISC 在实现效率上的优势之一。

阶段 3:执行(Execute)

解码完成后,指令被送到对应的执行单元(Execution Unit)

现代 CPU 有多套执行单元,可以同时执行多条不相关的指令(超标量执行)。

执行结果通常放在临时缓冲区,不立即写入寄存器——这样如果后来发现这条指令应该被取消(比如分支预测错误),可以丢弃结果,不会破坏寄存器状态。

阶段 4:内存访问(Memory Access)

并非所有指令都有这个阶段。只有加载(Load)和存储(Store)指令需要在这里访问内存。

对于加载指令(如 mov rax, [rbx]):

对于存储指令(如 mov [rbx], rax):

这种"延迟写入"的设计是为了支持推测执行——如果后来发现这条 store 不该执行,可以取消。

阶段 5:写回(Writeback)

指令执行完成,结果被写回目标寄存器。

这个阶段相对简单,但有个重要概念:按序提交(In-Order Commit)

虽然指令在 CPU 内部可以乱序执行(后面的指令先执行完),但提交必须按顺序——指令必须按程序顺序被标记为"正式完成"。这确保了:

Level 2:原理剖析

数据危害:当指令之间相互依赖

流水线的理想状态是每个时钟周期有一条指令完成。但现实是,指令之间经常有依赖关系:

add rax, rbx    ; 第1条:rax = rax + rbx
add rcx, rax    ; 第2条:rcx = rcx + rax(依赖第1条的结果!)

第 2 条指令需要用到第 1 条指令写入的 rax 值。但在流水线里,第 2 条指令开始执行时,第 1 条指令可能还没写回 rax。这就是数据危害(Data Hazard)

解决方案:

方案一:流水线停顿(Stall)

最简单的方案:检测到数据依赖时,让后续指令等待,直到前面的指令完成写回。这会插入"气泡"(无操作的周期):

周期: 1  2  3  4  5  6  7  8
ADD  : F  D  E  M  W
ADD  :    F  D  停  停  E  M  W
              ↑ 检测到依赖,停顿2个周期

代价:每次停顿损失几个时钟周期,降低 IPC(每周期执行指令数)。

方案二:数据旁路(Forwarding/Bypassing)

更聪明的方案:在结果被写回寄存器之前,直接把它"旁路"给下一条需要它的指令的执行单元,不需要等待写回:

周期: 1  2  3  4  5  6  7
ADD1 : F  D  E  M  W
ADD2 :    F  D  E  M  W
              ↑ ADD1的执行结果直接旁路给ADD2的执行阶段

旁路让指令可以无停顿地连续执行,但需要额外的"旁路网络"(数据路径),增加了电路复杂性。

现代 CPU 有多种旁路路径,覆盖大多数依赖情况,使得停顿较为罕见。

控制危害:跳转指令的麻烦

流水线还有另一种危害:控制危害(Control Hazard)

当执行到条件跳转指令时:

cmp rax, 0
je  label      ; 如果 rax == 0 就跳转
; 如果不跳转,下一条指令在这里
add rbx, 1
...
label:
mov rcx, 5

CPU 在执行 je 的时候,下一条要执行的指令是哪里?取决于条件是否成立——而在 je 执行完之前,CPU 根本不知道结果!

如果 CPU 等待 je 执行完再取下一条指令,会浪费流水线里已经取进来的指令,损失好几个周期。

这就是为什么 CPU 需要分支预测:在知道结果之前,猜一个方向继续取指执行。如果猜对了,完全无损耗;猜错了,清空错误路径的指令(分支预测失败惩罚,通常 10-20 个时钟周期)。

分支预测是计算机体系结构里最重要的优化技术之一,我们在第 22 章详细讲。

乱序执行:不按牌理出牌的 CPU

现代高性能 CPU 有一个强大的技术:乱序执行(Out-of-Order Execution, OoO)

想象这样的指令序列:

mov rax, [address1]   ; 第1条:从内存加载,可能需要 200 个周期(缓存未命中)
add rbx, rcx          ; 第2条:加法,只需要 1 个周期
                      ; 第2条不依赖第1条的结果

在顺序执行中,第 2 条必须等第 1 条完成(200 周期后)才能执行。

在乱序执行中,CPU 发现第 2 条不依赖第 1 条,可以先执行第 2 条。等第 1 条的内存访问完成(200 周期后),再执行第 1 条的后续操作。第 2 条完全不需要等待。

乱序执行的关键组件:

Apple M4 的指令窗口据分析约有 3000 个 μop——这意味着 CPU 在任何时刻可以同时"在飞行"的指令有 3000 条,从中挑选可以执行的。相比之下,Intel/AMD 的窗口约 500-800 μop。这是 Apple M 系列芯片 IPC(每周期执行指令数)如此高的关键原因。

推测执行:提前押注

乱序执行 + 分支预测 = 推测执行(Speculative Execution)

CPU 不只是乱序执行已确定的指令,它还会推测某个分支会走哪个方向,然后沿着预测路径提前执行后续指令。如果预测正确,这些结果直接被用;如果错误,已执行的结果被丢弃,重新从正确路径执行。

推测执行大大提高了 CPU 的"利用率"——它不再傻傻等待,而是提前工作。

但推测执行也带来了安全问题。2018 年 1 月,研究人员发现了 SpectreMeltdown 漏洞:

修复这些漏洞需要操作系统补丁和 CPU 微码更新,代价是 5-30% 的性能损失(特别是在需要大量内核调用的服务器工作负载上)。

这是一个经典的权衡:为了性能而引入的推测执行,带来了安全隐患。

Level 3 · 规范怎么定义的(资深)

指令执行的形式化模型

指令执行流程的形式化描述最早来自 冯·诺依曼架构的原始论文(1945 年,John von Neumann 的 "First Draft of a Report on the EDVAC")。该模型定义了"取指-解码-执行"的顺序循环,是所有现代 CPU 的理论基础。

在微架构层面,指令执行的行为由 CPU 厂商的 优化手册 精确文档化。Intel 的 "Intel 64 and IA-32 Architectures Optimization Reference Manual" 定义了每条指令在特定微架构上的延迟(latency)和吞吐量(throughput)。例如在 Alder Lake 微架构上,整数加法的延迟是 1 个周期、吞吐量是每周期 4 条;整数除法的延迟是 26 个周期、吞吐量是每 6 周期 1 条。Agner Fog 维护的指令性能表(agner.org/optimize)是业界最权威的第三方参考。

数据危害的分类(RAW/WAR/WAW)由 1966 年 Robert Tomasulo 在 IBM 360/91 上首次形式化定义,其"Tomasulo 算法"至今仍是所有乱序执行 CPU 的理论基础。该算法通过保留站(Reservation Station)和公共数据总线(Common Data Bus)解决了数据依赖问题,实现了硬件级的寄存器重命名和动态调度。

Level 4 · 边界与陷阱(所有人)

陷阱 1:指令延迟 ≠ 指令吞吐量

很多程序员误以为"一条指令需要 N 个周期"就意味着每 N 个周期才能执行一条这样的指令。实际上,由于流水线化,一条延迟为 4 个周期的指令,可能每个周期都能启动一条新的(吞吐量为 1/cycle)。只有当后续指令依赖前一条的结果时,延迟才会成为瓶颈。这意味着优化热循环时,应关注"关键路径上的延迟总和"而非"所有指令延迟之和"——后者可能高估实际运行时间数倍。

陷阱 2:推测执行的副作用不可撤回

CPU 在分支预测后会推测执行一条路径的指令。如果预测错误,CPU 会回滚寄存器和内存写入。但推测执行期间对 Cache 的影响(缓存行的加载和驱逐)不会被回滚——这就是 2018 年曝光的 Spectre 漏洞 的根源。攻击者可以构造特殊的代码序列,让 CPU 在推测执行期间访问越权的内存地址,虽然推测结果被丢弃了,但 Cache 中留下了可被侧信道检测的痕迹。这个漏洞影响了几乎所有现代 CPU(Intel、AMD、ARM),修补方案(retpoline、IBRS)普遍带来 5-30% 的性能损失。

陷阱 3:内存访问阶段的 Store Buffer 可能导致可见性延迟

指令的"写回"阶段并不意味着数据立即对其他核心可见。现代 CPU 使用 Store Buffer 暂存写操作,只有当 Store Buffer 中的写操作被刷入 L1 Cache 时,其他核心才能看到。这意味着在多核环境下,一个核心的写操作可能在数十甚至数百个周期后才对另一个核心可见。如果不使用内存屏障(memory barrier / fence instruction),两个核心看到的内存写入顺序可能不同——这就是内存模型(如 x86-TSO、ARM 弱序模型)要解决的问题。

本章评分
4.7  / 5  (56 评分)

💬 留言讨论