cpu - instruction - execution
04. CPU 与指令执行
0. 本章先解决什么问题
CPU 执行程序时,并不是“理解一整段代码”,而是不断重复一个很小的循环:
拿到下一条指令
看懂它
执行它
保存结果
决定下一条指令在哪里
本章要解决:
- 指令是什么?
- 程序计数器、寄存器、内存如何配合?
- 取指、译码、执行、访存、写回各做什么?
- 分支、循环、函数调用在 CPU 眼里是什么?
- 流水线、分支预测为什么会影响性能?
- 为什么同样的代码结构可能让 CPU 执行效率不同?
学完这一章,你应该能把一段高级程序想象成 CPU 指令流,而不只停留在源码表面。
读这张图时要抓住一句话:CPU 不执行“业务”,只是在不断取指、译码、执行、访存、写回,并更新下一条指令地址。高级语言里的循环、分支、函数调用,最终都会落成这个小循环里的状态变化。
这张图怎么读
这张图可以按“指令生命周期”读:
| 阶段 | 问题 |
|---|---|
| 取指 | 下一条指令地址在哪里,Cache 是否命中 |
| 译码 | opcode 和操作数字段是什么意思 |
| 执行 | ALU、分支单元或地址生成单元做什么 |
| 访存 | 是否需要从 Cache/内存读写数据 |
| 写回 | 结果写到哪个寄存器或状态位 |
| 更新 PC | 下一条指令是顺序执行还是跳转 |
如果一段代码慢,图里的任何阶段都可能是瓶颈:指令取不到、分支猜错、数据访存慢、写回依赖前一条指令。
1. 指令是什么
指令是 CPU 能直接执行的命令。
一条指令通常包含:
| 部分 | 含义 |
|---|---|
| 操作码 | 要做什么,比如加法、比较、跳转、加载 |
| 操作数 | 对谁做,比如寄存器、立即数、内存地址 |
例如抽象表示:
ADD R1, R2, R3
可以理解为:
R1 = R2 + R3
常见指令类型:
| 类型 | 作用 |
|---|---|
| 数据搬运 | 从内存到寄存器,从寄存器到内存 |
| 算术运算 | 加、减、乘、除 |
| 逻辑运算 | 与、或、异或、取反 |
| 比较 | 判断大小、相等 |
| 跳转 | 改变下一条指令位置 |
| 调用/返回 | 进入函数和回到调用点 |
| 特权指令 | 操作系统内核使用的特殊指令 |
高级语言里的复杂语句,最终会被拆成这些基本指令的组合。
2. CPU 执行需要哪些核心部件
简化 CPU 模型:
关键部件:
| 部件 | 作用 |
|---|---|
| 程序计数器 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
分支时:
代码块收起展开
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 可以在取指阶段
示意:
代码块收起展开
周期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 的结果。如果结果还没写回,流水线要等待或使用转发机制。
控制冒险例子:
代码块收起展开
if condition:
path A
else:
path BCPU 在条件结果出来前,不知道该提前取哪条路径。
机制深挖:load-use 依赖为什么让流水线停顿
看两条很常见的指令:
代码块收起展开
I1: LOAD R1, [addr]
I2: ADD R2, R1, R3I2 立刻使用 I1 从内存加载的结果。问题是:LOAD 的结果要等访存阶段结束才知道,如果数据还不在 L1 Cache,等待会更久。
流水线视角:
| 时刻 | I1 | I2 |
|---|---|---|
| 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 流水线可能空转 |
| 多线程共享写 | 缓存一致性和原子指令成本 |
分析时可以问:
- 热点代码在哪里?
- CPU 是否在等内存?
- 分支是否难预测?
- 数据是否连续?
- 是否频繁跨函数、跨层、跨边界?
- 是否触发系统调用或 IO?
练习卡:把一行代码翻译成取指、访存和分支
选一句很普通的逻辑,例如“如果计数器小于上限,就读取数组当前位置并累加”。不要急着写代码,先拆成 CPU 关心的动作:
- 从内存或缓存取到下一条指令。
- 把计数器和上限放入寄存器。
- 做比较,产生条件结果。
- 根据条件选择下一条指令地址。
- 计算数组元素地址。
- 从内存层次中取数据。
- 执行加法并写回寄存器或内存。
然后问三个性能问题:分支是否容易预测?数组访问是否连续?累加是否形成依赖链?同样的“大 O”,在 CPU 眼里可能完全不同,这就是本章应该带走的直觉。
14. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 指令是什么,为什么高级语句会被拆成多条指令?
- PC、寄存器、ALU、控制单元分别做什么?
- 取指、译码、执行、访存、写回是什么流程?
- 分支、循环、函数调用在 CPU 层如何实现?
- 流水线为什么能提高吞吐,又为什么会遇到冒险?
- 分支预测失败为什么会影响性能?
- 为什么 CPU 很快,但程序仍可能慢在内存和数据访问?
- 用户态和内核态为什么需要硬件权限支持?
你不需要把 CPU 当成黑盒。它就是不断执行指令、移动数据、更新状态的机器。