cpu - instruction - execution

04. CPU 与指令执行

0. 本章先解决什么问题

CPU 执行程序时,并不是“理解一整段代码”,而是不断重复一个很小的循环:

拿到下一条指令
看懂它
执行它
保存结果
决定下一条指令在哪里

本章要解决:

  • 指令是什么?
  • 程序计数器、寄存器、内存如何配合?
  • 取指、译码、执行、访存、写回各做什么?
  • 分支、循环、函数调用在 CPU 眼里是什么?
  • 流水线、分支预测为什么会影响性能?
  • 为什么同样的代码结构可能让 CPU 执行效率不同?

学完这一章,你应该能把一段高级程序想象成 CPU 指令流,而不只停留在源码表面。

一条指令如何被 CPU 执行

读这张图时要抓住一句话:CPU 不执行“业务”,只是在不断取指、译码、执行、访存、写回,并更新下一条指令地址。高级语言里的循环、分支、函数调用,最终都会落成这个小循环里的状态变化。

这张图怎么读

这张图可以按“指令生命周期”读:

阶段问题
取指下一条指令地址在哪里,Cache 是否命中
译码opcode 和操作数字段是什么意思
执行ALU、分支单元或地址生成单元做什么
访存是否需要从 Cache/内存读写数据
写回结果写到哪个寄存器或状态位
更新 PC下一条指令是顺序执行还是跳转

如果一段代码慢,图里的任何阶段都可能是瓶颈:指令取不到、分支猜错、数据访存慢、写回依赖前一条指令。

1. 指令是什么

指令是 CPU 能直接执行的命令。

一条指令通常包含:

部分含义
操作码要做什么,比如加法、比较、跳转、加载
操作数对谁做,比如寄存器、立即数、内存地址

例如抽象表示:

ADD R1, R2, R3

可以理解为:

R1 = R2 + R3

常见指令类型:

类型作用
数据搬运从内存到寄存器,从寄存器到内存
算术运算加、减、乘、除
逻辑运算与、或、异或、取反
比较判断大小、相等
跳转改变下一条指令位置
调用/返回进入函数和回到调用点
特权指令操作系统内核使用的特殊指令

高级语言里的复杂语句,最终会被拆成这些基本指令的组合。

2. CPU 执行需要哪些核心部件

简化 CPU 模型:

简化 CPU 模型:控制单元、寄存器、ALU 与存储

关键部件:

部件作用
程序计数器 PC保存下一条要执行的指令地址
寄存器CPU 内部最快的临时存储
ALU做算术和逻辑运算
控制单元解析指令并发出控制信号
Cache临时保存最近使用的指令和数据
内存接口和主内存交换数据

CPU 很快,内存相对慢,所以 CPU 会尽量把正在用的数据放在寄存器和 Cache 里。

3. 一条指令的生命周期

经典模型可以拆成五步:

Fetch
-> Decode
-> Execute
-> Memory
-> Write Back

阶段中文做什么
Fetch取指根据 PC 从指令缓存/内存拿指令
Decode译码解析指令类型和操作数
Execute执行ALU 计算、比较、生成地址
Memory访存如果需要,读写内存或 Cache
Write Back写回把结果写回寄存器

不是每条指令都完整使用五个阶段。比如纯寄存器加法可能不需要访问内存;加载指令会访存;跳转指令会改变 PC。

4. 程序计数器:下一步执行哪里

程序计数器保存下一条指令地址。

顺序执行时:

PC -> 指令 A
执行后 PC -> 指令 B
执行后 PC -> 指令 C

分支时:

代码块PLAINTEXT · 4 行收起展开
if 条件成立:
  PC = 目标地址 X
else:
  PC = 下一条地址

循环时:

执行循环体
判断条件
如果继续,PC 跳回循环开头
如果结束,PC 跳到循环后面

函数调用时:

保存返回地址
PC 跳到函数入口
函数结束后 PC 回到返回地址

所以控制流底层就是:

PC 如何变化

5. load/store:CPU 和内存的分工

很多现代体系结构强调:

计算主要在寄存器里做。
内存访问靠专门的 load/store 指令。

示意:

LOAD R1, [addr_a]
LOAD R2, [addr_b]
ADD R3, R1, R2
STORE [addr_c], R3

含义:

从内存读 a 到 R1
从内存读 b 到 R2
R1 + R2 放到 R3
把 R3 写回内存 c

这解释了为什么数据访问成本很重要:

ALU 计算可能很快
但从内存拿数据可能慢得多

很多程序慢,不是因为“不会算”,而是因为数据没有及时到寄存器。

6. 指令和数据都可能被缓存

CPU 需要取指令,也需要取数据。

所以通常会有:

  • 指令缓存:缓存即将执行的指令。
  • 数据缓存:缓存近期访问的数据。

如果指令或数据在 Cache 中:

cache hit -> 快

如果不在:

cache miss -> 去更慢的下一级拿

这会影响:

  • 大函数和小函数的局部性。
  • 热点代码是否集中。
  • 数据结构是否连续。
  • 分支跳转是否频繁打乱指令流。

7. 流水线:让不同指令的不同阶段重叠

如果一条指令必须完整走完五个阶段,下一条才开始,CPU 会浪费很多时间。

流水线的思想是:

指令 1 在执行阶段时
指令 2 可以在译码阶段
指令 3 可以在取指阶段

示意:

代码块PLAINTEXT · 5 行收起展开
周期1: I1 Fetch
周期2: I1 Decode   I2 Fetch
周期3: I1 Execute  I2 Decode   I3 Fetch
周期4: I1 Memory   I2 Execute  I3 Decode
周期5: I1 Write    I2 Memory   I3 Execute

流水线提高吞吐量,但不一定减少单条指令延迟。它像工厂流水线:每个产品走完全程仍要时间,但连续生产时产出更快。

8. 流水线冒险:为什么流水线会卡住

流水线会遇到冒险。

冒险含义例子
结构冒险多条指令争同一个硬件资源同时访问同一端口
数据冒险后一条指令依赖前一条结果先加法再立刻用结果
控制冒险分支导致不知道下一条取谁if/else、循环跳转

数据冒险例子:

I1: R1 = R2 + R3
I2: R4 = R1 + R5

I2 需要 I1 的结果。如果结果还没写回,流水线要等待或使用转发机制。

控制冒险例子:

代码块PLAINTEXT · 4 行收起展开
if condition:
  path A
else:
  path B

CPU 在条件结果出来前,不知道该提前取哪条路径。

机制深挖:load-use 依赖为什么让流水线停顿

看两条很常见的指令:

代码块PLAINTEXT · 2 行收起展开
I1: LOAD R1, [addr]
I2: ADD  R2, R1, R3

I2 立刻使用 I1 从内存加载的结果。问题是:LOAD 的结果要等访存阶段结束才知道,如果数据还不在 L1 Cache,等待会更久。

流水线视角:

时刻I1I2
t1取指
t2译码取指
t3执行/算地址译码,发现需要 R1
t4访存,还没拿到数据等待
t5写回 R1才能继续执行

编译器和 CPU 会尝试调度独立指令填空,但如果下一条就是强依赖,就可能停顿。理解这个例子后,再看“内存访问慢拖住计算”会更具体。

9. 分支预测:CPU 猜下一步

为了不让流水线空等,CPU 会预测分支走向。

预测正确:
提前做的工作继续使用

预测错误:
丢弃错误路径上的流水线结果
回到正确路径重新执行

这就是分支预测失败成本。

容易预测的分支:

循环 1000 次,前 999 次继续,最后一次退出

不容易预测的分支:

条件结果接近随机

所以数据分布也会影响性能。

反例:同一段 if,换一批数据就变慢

假设循环里有条件分支:

if value > threshold:
count += 1

如果数据已经排序:

小 小 小 小 大 大 大 大

分支模式很稳定,CPU 容易预测。
如果数据接近随机:

大 小 大 大 小 小 大 小

预测失败会增加,流水线要丢弃错误路径上的工作。于是同一段代码、同样 O(n),数据分布不同,运行时间也可能不同。

这类现象说明:CPU 性能不是只由代码文本决定,还由数据模式决定。

10. 乱序执行的直觉

现代 CPU 可能不完全按程序顺序执行底层操作。

目标是:

只要不破坏程序可观察结果
就尽量先执行已经准备好的指令

例如:

指令 A 等内存
指令 B 不依赖 A,可以先算

CPU 可能让 B 先执行,避免空等。

但最终对单线程程序来说,结果要像按顺序执行一样。这叫保持程序语义。

乱序执行会让性能更高,也让并发内存可见性更复杂。后面操作系统和并发会继续遇到这个问题。

11. 函数调用在 CPU 眼里是什么

函数调用不是魔法,它涉及:

  • 参数放到约定位置。
  • 保存返回地址。
  • 跳到函数入口。
  • 建立栈帧。
  • 执行函数体。
  • 返回值放到约定位置。
  • 恢复调用现场。
  • 跳回返回地址。

简化:

caller
-> 保存返回地址
-> 跳转到 callee
callee
-> 使用寄存器和栈
-> 写入返回值
-> 跳回 caller

调用成本通常比普通加法高。大量小函数调用在某些场景下可能被编译器内联,以减少调用开销。

12. 特权指令:为什么普通程序不能随便操作硬件

有些指令只有操作系统内核能执行,比如:

  • 修改页表。
  • 操作设备控制寄存器。
  • 关闭中断。
  • 切换 CPU 模式。

如果普通程序能随便执行这些指令,系统就无法保护其他程序。

所以 CPU 有权限级别。普通程序运行在用户态,操作系统内核运行在内核态。

当普通程序需要文件、网络、设备等能力时,会通过系统调用请求操作系统代办。

这连接到操作系统:

硬件权限机制
-> 用户态/内核态
-> 系统调用
-> 资源隔离

13. 联系实际:为什么同样逻辑性能差很多

两段代码理论复杂度一样,也可能性能不同。

原因可能是:

差异CPU 视角
数据连续 vs 分散cache hit 和 cache miss 差别
分支稳定 vs 随机分支预测成功率不同
小循环热代码集中指令缓存更友好
频繁函数调用调用和返回有开销
大量内存等待CPU 流水线可能空转
多线程共享写缓存一致性和原子指令成本

分析时可以问:

  1. 热点代码在哪里?
  2. CPU 是否在等内存?
  3. 分支是否难预测?
  4. 数据是否连续?
  5. 是否频繁跨函数、跨层、跨边界?
  6. 是否触发系统调用或 IO?

练习卡:把一行代码翻译成取指、访存和分支

选一句很普通的逻辑,例如“如果计数器小于上限,就读取数组当前位置并累加”。不要急着写代码,先拆成 CPU 关心的动作:

  1. 从内存或缓存取到下一条指令。
  2. 把计数器和上限放入寄存器。
  3. 做比较,产生条件结果。
  4. 根据条件选择下一条指令地址。
  5. 计算数组元素地址。
  6. 从内存层次中取数据。
  7. 执行加法并写回寄存器或内存。

然后问三个性能问题:分支是否容易预测?数组访问是否连续?累加是否形成依赖链?同样的“大 O”,在 CPU 眼里可能完全不同,这就是本章应该带走的直觉。

14. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. 指令是什么,为什么高级语句会被拆成多条指令?
  2. PC、寄存器、ALU、控制单元分别做什么?
  3. 取指、译码、执行、访存、写回是什么流程?
  4. 分支、循环、函数调用在 CPU 层如何实现?
  5. 流水线为什么能提高吞吐,又为什么会遇到冒险?
  6. 分支预测失败为什么会影响性能?
  7. 为什么 CPU 很快,但程序仍可能慢在内存和数据访问?
  8. 用户态和内核态为什么需要硬件权限支持?

你不需要把 CPU 当成黑盒。它就是不断执行指令、移动数据、更新状态的机器。

延伸阅读