CPU 可以执行算术运算、比较数值、执行分支,也可以进行各种位操作。
但是,大多数真正有用的程序,仅靠寄存器和 ALU 是远远不够的。
程序还需要大量数据。
这些数据可能包括变量、数组、程序指令、图像、正在处理的文件、网络缓冲区,以及许多其他类型的信息。
绝大多数信息都不可能一直保存在 CPU 寄存器中。
它们必须存放在其他地方。
这就是内存存在的意义。
CPU 会不断与内存通信,从内存中读取数据,也会把计算结果重新写回内存。
在指令层面,其中最重要的两类操作就是:
Load,加载
以及
Store,存储
Load 把数据从内存送向 CPU。
Store 则把数据从 CPU 写向内存。
看起来只是两种简单的操作,但它们背后实际上涉及地址、缓存、总线、片上互连、内存控制器和 DRAM 等一整套硬件系统。
理解这条数据路径,是理解 CPU 如何连接整个计算机系统的重要一步。
1. 寄存器很快,但容量很小
CPU 内部包含寄存器,用来保存当前指令正在使用的数据。
例如,假设程序需要把两个数字相加。
CPU 可能执行一条类似下面这样的指令:
|
1 2 |
add r1, r2, r3 |
它的含义大致是:
|
1 2 |
r1 = r2 + r3 |
因为两个输入数据都已经位于 CPU 内部的寄存器中,所以 ALU 可以非常快速地完成这个加法。
但是,寄存器数量非常有限。
不同指令集架构可能拥有几十个,甚至上百个架构可见寄存器。
而一个程序可能需要处理几 MB、几 GB,甚至更多的数据。
显然,这些信息不可能全部装进寄存器。
因此,大多数程序数据最终都需要存放在内存中。
CPU 在需要这些数据时,再把它们从内存取到寄存器;计算完成后,也可能再把结果写回内存。
2. 内存通过地址进行组织
主内存可以简单理解成一个巨大的存储空间,其中包含大量存储位置。
每一个位置都有自己的地址。
例如,一个非常简化的内存可以表示成:
|
1 2 3 4 5 6 7 |
Address Data 0x1000 42 0x1004 17 0x1008 93 0x100C 21 |
CPU 通常不会对内存说:
“把变量 counter 给我。”
在硬件层面,CPU 使用的是地址。
CPU 真正发出的请求更接近:
|
1 2 |
读取地址 0x1000 中的数据 |
或者:
|
1 2 |
把这个值写入地址 0x1000 |
因此,地址是连接软件世界和硬件世界最基础的机制之一。
3. Load 从内存读取数据
Load 指令的作用,是把数据从内存传送到 CPU 寄存器。
例如:
|
1 2 |
load r1, [0x1000] |
从概念上来说,它表示:
|
1 2 |
r1 = memory[0x1000] |
如果内存地址 0x1000 中保存的值是:
|
1 2 |
42 |
那么执行 Load 之后:
|
1 2 |
r1 = 42 |
CPU 接下来就可以使用这个值进行算术、比较或其他运算。
不过,在真正的处理器中,内存地址通常不是直接完整地写在指令里的,而是通过寄存器和偏移量计算出来。
例如,一条 MIPS 风格的 Load 指令可能是:
|
1 2 |
lw $t0, 8($t1) |
CPU 首先计算地址:
|
1 2 |
address = $t1 + 8 |
然后读取这个地址中的一个 word,并把它放入 $t0。
因此,一条 Load 指令通常包含两个重要步骤:
地址计算
以及
内存访问
4. Store 把数据写入内存
Store 指令执行的方向正好相反。
它会把 CPU 寄存器中的数据写入内存。
例如:
|
1 2 |
store r1, [0x1000] |
概念上表示:
|
1 2 |
memory[0x1000] = r1 |
如果 r1 中保存的是:
|
1 2 |
42 |
CPU 就会把 42 写入指定的内存地址。
一条 MIPS 风格的 Store 指令可能是:
|
1 2 |
sw $t0, 8($t1) |
CPU 同样先计算:
|
1 2 |
address = $t1 + 8 |
然后把 $t0 中的数据写入这个地址。
因此,Load 和 Store 就构成了 CPU 寄存器与内存之间最基本的数据桥梁:
|
1 2 3 4 |
CPU Registers ↕ Memory |
5. ALU 经常负责计算内存地址
ALU 并不仅仅用来执行加法、减法之类的算术运算。
在内存访问过程中,ALU 也承担着非常重要的作用。
例如:
|
1 2 |
lw $t0, 8($t1) |
假设:
|
1 2 |
$t1 = 0x1000 |
CPU 就必须计算:
|
1 2 |
0x1000 + 8 |
结果为:
|
1 2 |
0x1008 |
这个结果就是实际要访问的内存地址。
因此,数据通路可以简单理解为:
|
1 2 3 4 5 6 7 8 9 10 |
Base Register + Offset ↓ ALU ↓ Memory Address ↓ Memory |
这也解释了为什么 Load 和 Store 指令同样需要经过 ALU。
虽然这些指令的主要目标并不是执行普通算术计算,但 CPU 仍然需要 ALU 来计算有效地址,也就是 Effective Address。
6. 地址和数据是两种不同的信息
CPU 与内存通信时,需要传输不同类型的信息。
第一种是:
Address,地址
它告诉内存系统:
CPU 想访问哪里。
第二种是:
Data,数据
它才是真正被读取或写入的信息。
对于 Load:
|
1 2 3 4 |
CPU → Address → Memory CPU ← Data ← Memory |
CPU 把地址送给内存,然后从内存得到数据。
对于 Store:
|
1 2 3 4 |
CPU → Address → Memory CPU → Data → Memory |
CPU 不但发送地址,还要把准备写入的数据一起发送出去。
除此之外,CPU 还必须告诉硬件:
这次操作究竟是读,还是写。
所以,CPU 与内存通信通常可以归纳为三个最基本的概念:
|
1 2 3 4 |
Address Data Control |
传统计算机体系结构中,它们通常对应:
|
1 2 3 4 |
Address Bus Data Bus Control Bus |
也就是:
地址总线
数据总线
控制总线
7. 什么是内存总线?
传统意义上的 Bus,总线,可以理解为一组共享的电气连接,用于在不同硬件组件之间传递信号。
一个非常简化的 CPU 与内存系统可以表示成:
|
1 2 3 4 5 6 7 8 |
CPU │ ├── Address ├── Data └── Control │ Memory |
地址信号负责指出 CPU 想访问哪个位置。
数据信号负责传输真正的内容。
控制信号则告诉系统当前要执行什么操作。
例如:
|
1 2 3 |
READ WRITE |
也就是读和写。
在早期计算机架构中,这些总线往往表现得更加直接和明显。
但现代处理器已经复杂得多。
今天的 CPU 内部可能包含:
多级缓存、集成式内存控制器、高速片上互连,以及专用的内存通道。
所以,“内存总线”仍然是学习计算机体系结构时非常重要的基础概念。
但现代 CPU 并不一定真的存在一条简单的共享总线,直接把 CPU 核心和 DRAM 连在一起。
8. CPU 通常不会直接与 DRAM 通信
最简单的计算机结构图经常画成:
|
1 2 |
CPU ↔ RAM |
这非常适合入门学习。
但真正的现代计算机通常并不是这样。
CPU 核心与主内存之间,实际上存在很多层结构。
更接近现实的路径大致是:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
CPU Core ↓ L1 Cache ↓ L2 Cache ↓ L3 Cache ↓ Memory Controller ↓ DRAM |
当 CPU 执行一条 Load 指令时,它通常不会立刻跑到 DRAM 中寻找数据。
处理器首先会检查:
需要的数据是不是已经存在于 Cache,也就是缓存中。
如果数据已经在缓存中,CPU 就可以非常快地获得它。
只有当缓存中找不到所需数据时,请求才需要继续向更低层传递,最终有可能访问 DRAM。
9. 为什么 Cache 如此重要?
现代 CPU 执行指令的速度非常快。
而主内存相对慢得多。
如果每一条 Load 指令都必须等待 DRAM 返回数据,那么 CPU 大量时间都会浪费在等待上。
Cache 就是为了解决这个问题。
缓存会把最近使用过的数据,或者很可能马上会使用的数据,暂时保存到距离 CPU 更近的位置。
典型的存储层次结构可以表示成:
|
1 2 3 4 5 6 7 8 9 10 |
Registers ↓ L1 Cache ↓ L2 Cache ↓ L3 Cache ↓ Main Memory |
一般来说:
越接近 CPU,速度越快。
但是容量也越小。
如果 CPU 请求的数据正好可以在缓存中找到,这叫做:
Cache Hit,缓存命中
如果找不到,则称为:
Cache Miss,缓存未命中
出现 Cache Miss 后,请求就需要继续访问下一层缓存。
如果所有缓存都没有,最终才会访问主内存。
10. 内存通常以数据块为单位传输
CPU 有时候可能只需要几个字节。
但现代内存系统通常不会每次只从 DRAM 中取一个孤立的字节。
Cache 会把内存组织成一个个数据块。
这些块通常称为:
Cache Line,缓存行
在很多现代处理器中,一个常见的 Cache Line 大小是:
64 字节
当然,不同处理器架构可能有所不同。
假设 CPU 想读取地址:
|
1 2 |
0x1008 |
内存系统可能并不会只读取这个地址对应的几个字节。
它可能会把包含 0x1008 在内的整个 Cache Line 一次性加载进缓存。
这样做非常有用,因为程序通常会连续访问附近的数据。
例如:
|
1 2 3 |
for (int i = 0; i < 1000; i++) sum += array[i]; |
程序读取完 array[0] 之后,很可能马上会读取:
|
1 2 3 4 |
array[1] array[2] array[3] |
所以,把附近的数据一起装进 Cache,往往可以提高后续访问速度。
这种现象称为:
Spatial Locality,空间局部性
11. 内存控制器负责与 DRAM 通信
如果 CPU 请求的数据无法在 Cache 中找到,请求最终就会到达:
Memory Controller,内存控制器
现代 CPU 通常会把内存控制器直接集成在处理器内部,或者集成到处理器芯片中。
内存控制器负责把 CPU 发出的内存请求转换成 DRAM 可以理解的命令。
而 DRAM 本身并不是一整块简单的线性存储空间。
从硬件组织角度看,它通常还包括:
|
1 2 3 4 5 6 |
channels ranks banks rows columns |
也就是:
通道、Rank、Bank、行和列。
内存控制器负责决定如何访问这些结构,并处理 DRAM 所要求的各种时序。
因此,CPU 核心本身并不会直接控制某一个 DRAM Row 或 Column。
CPU 只需要提出内存请求。
更底层的 DRAM 通信工作,则交给内存控制器完成。
12. 一条 Load 指令到底发生了什么?
现在我们可以把一条 Load 指令完整地跟踪一遍。
假设 CPU 执行:
|
1 2 |
load r1, [address] |
一个简化的执行过程可能是:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
1. 解码 Load 指令 2. 如果需要,读取基址寄存器 3. 计算有效地址 4. 把内存请求送入缓存层次结构 5. 检查 L1 Cache 6. 如果需要,继续检查更低层缓存 7. 如果缓存中没有数据,请求主内存 8. 内存控制器访问 DRAM 9. 包含所需数据的 Cache Line 返回 CPU 10. CPU 从 Cache Line 中取出真正需要的数据 11. 把这个值写入目标寄存器 |
从概念上看:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
Instruction ↓ Address Calculation ↓ Cache Lookup ↓ Memory if Necessary ↓ Data Returns ↓ Register |
所以,一条看起来非常简单的 Load 指令,背后实际上可能触发大量硬件活动。
13. 一条 Store 指令到底发生了什么?
Store 的过程与 Load 类似,但数据传输方向不同。
假设:
|
1 2 |
store r1, [address] |
CPU 必须确定两件事情:
|
1 2 |
数据写到哪里 |
以及:
|
1 2 |
写入什么数据 |
一个简化过程大致是:
|
1 2 3 4 5 6 7 8 9 10 |
1. 解码 Store 指令 2. 读取源寄存器 3. 计算有效地址 4. 更新对应的 Cache 5. 在之后适当的时候,把修改后的数据继续写向更低层内存 |
现代 CPU 经常使用一种技术:
Write-Back Cache,回写式缓存
使用 Write-Back Cache 时,一条 Store 指令执行后,并不一定马上把数据写进 DRAM。
CPU 可以先修改 Cache 中的副本。
被修改过的 Cache Line 会被标记为:
Dirty,脏数据
等到之后需要替换这个 Cache Line,或者系统认为应该同步时,再把数据写回更低层缓存或者 DRAM。
这种机制可以避免每执行一条 Store 指令,CPU 都必须停下来等待速度较慢的 DRAM。
14. Load 和 Store 把程序连接到内存
考虑一条非常简单的 C 语言代码:
|
1 2 |
c = a + b; |
从程序员角度看,它就是一个加法。
但假设:
a
b
和
c
都保存在内存中。
处理器内部可能需要执行类似这样的操作:
|
1 2 3 4 5 6 7 8 |
load r1, [a] load r2, [b] add r3, r1, r2 store r3, [c] |
当然,实际生成什么指令,还取决于:
编译器、处理器架构、优化级别、寄存器分配,以及数据是否已经存在于 Cache 或寄存器中。
但最重要的基本模式是不变的:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
Memory ↓ Load ↓ Registers ↓ ALU ↓ Register ↓ Store ↓ Memory |
这是一台计算机内部最重要的数据流之一。
程序中的数据通过 Load 进入 CPU。
CPU 使用寄存器和 ALU 进行计算。
然后通过 Store 把结果重新送回内存。
15. 内存访问的代价远高于寄存器访问
从程序员角度看,读取一个变量似乎只是一件很简单的事情。
但是,从 CPU 的角度看,访问这个变量的成本可能完全不同。
如果数据已经在寄存器中,那么执行单元几乎可以立即使用。
如果数据在 L1 Cache 中,需要稍微多一点时间。
如果在 L2 或 L3 Cache 中,则需要更长时间。
如果最终必须访问 DRAM,那么代价就会明显增加。
因此,两条看起来几乎完全相同的 Load 指令:
|
1 2 3 4 |
load r1, [address A] load r2, [address B] |
实际执行时间可能差别很大。
原因就在于:
一个地址的数据可能正好位于 L1 Cache。
另一个地址的数据可能必须从 DRAM 中读取。
这就是为什么内存访问模式对程序性能有如此巨大的影响。
|
1 2 3 4 5 6 7 8 |
caches prefetchers memory controllers load/store units store buffers memory scheduling high-speed interconnects |
真正困难的问题并不仅仅是:
如何让 CPU 算得更快。
还有另一个同样重要的问题:
如何不断给 CPU 提供足够快的数据。
16. 简单总线模型与现代 CPU
学习计算机体系结构时,一个非常好的起点是下面这个简单模型:
|
1 2 3 4 5 6 7 8 |
CPU ↕ Address Bus Data Bus Control Bus ↕ Memory |
这个模型非常清楚地解释了几个基本概念。
CPU 选择一个地址。
控制信号说明执行 Read 还是 Write。
数据则在 CPU 和内存之间传输。
但是,现代 CPU 在这套基础逻辑之上增加了许多层结构:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
CPU Core ↓ Load/Store Unit ↓ L1 Cache ↓ L2 Cache ↓ Shared Cache ↓ Internal Interconnect ↓ Memory Controller ↓ Memory Channel ↓ DRAM |
今天的内存系统已经比早期计算机复杂得多。
但是最核心的原则并没有改变。
CPU 必须:
确定数据在哪里。
提出正确的读写请求。
然后通过内存系统完成数据传输。
发生变化的只是:
实现这件事情的硬件越来越复杂,也越来越高效。
17. 总结
CPU 不能只依靠寄存器完成真正有用的计算。
程序中包含的数据远远超过寄存器能够保存的数量。
因此,CPU 必须不断和内存通信。
Load 把数据从内存送到 CPU 寄存器。
Store 把数据从 CPU 寄存器送回内存。
CPU 首先计算地址。
内存系统找到对应的位置。
然后数据通过整个内存层次结构进行传输。
在最简单的模型中,这种通信可以理解成:
|
1 2 3 4 |
Address Data Control |
也就是传统意义上的:
|
1 2 3 4 |
Address Bus Data Bus Control Bus |
现代计算机又在 CPU 核心和物理内存之间加入了:
Cache、Load/Store Unit、片上互连、Memory Controller,以及 DRAM Channel 等大量结构。
但是最根本的逻辑仍然很简单:
CPU 负责计算。
内存负责保存程序更大的工作状态。
而 Load 和 Store,把两者连接在一起。
理解了 CPU 如何与内存通信之后,我们就可以继续向计算机系统的更高一层前进。
接下来的问题已经不再只是:
硬件如何访问数据?
而是:
究竟是谁负责管理 CPU、内存、设备、文件和正在运行的程序?
答案就是:
操作系统。
