register - alu - control
05. 寄存器、ALU 与控制器
0. 本章先解决什么问题
上一章讲 CPU 怎样执行一条指令。本章进一步看 CPU 内部最核心的三个角色:
- 寄存器:暂存数据
- ALU:执行计算
- 控制器:指挥数据往哪里走、做什么操作
你需要理解:
- 为什么 CPU 需要寄存器?
- ALU 能做哪些基本操作?
- 控制器如何把指令变成控制信号?
- 标志位、比较、条件跳转如何配合?
- 为什么寄存器数量、数据通路、控制方式都会影响性能?
这不是为了背硬件结构图,而是为了理解“计算”在机器里到底发生在哪里。
先看 CPU 内部的数据通路:指令控制数据从寄存器流向 ALU,再把结果写回。
这张图怎么读
读数据通路图时,顺着一次计算看:
操作数在哪里
-> 如何进入 ALU
-> ALU 做什么
-> 标志位如何更新
-> 结果写回哪里
这张图里有三类信号:
| 信号 | 作用 |
|---|---|
| 数据信号 | 寄存器值、立即数、ALU 结果 |
| 控制信号 | 选择哪个输入、执行哪种运算、是否写回 |
| 状态信号 | Zero、Carry、Sign、Overflow 等标志 |
CPU 执行不是“直接算表达式”,而是在控制器指挥下,让位模式沿着数据通路移动和变换。
1. 寄存器:CPU 里的高速临时存储
寄存器是 CPU 内部非常小、非常快的存储单元。
CPU 做计算时,通常不是直接对内存里的值计算,而是:
内存 -> 寄存器 -> ALU -> 寄存器 -> 内存
原因:
访问寄存器远快于访问内存。
常见寄存器类型:
| 类型 | 作用 |
|---|---|
| 通用寄存器 | 保存普通计算数据 |
| 程序计数器 PC | 保存下一条指令地址 |
| 栈指针 SP | 指向当前栈顶 |
| 帧指针 FP | 帮助定位当前函数栈帧 |
| 状态/标志寄存器 | 保存比较、进位、溢出等状态 |
| 特殊控制寄存器 | 保存系统级控制信息 |
寄存器越多,编译器越容易把热数据留在 CPU 内部,减少内存访问。
2. 寄存器和内存的差别
| 对比 | 寄存器 | 内存 |
|---|---|---|
| 位置 | CPU 内部 | CPU 外部主存 |
| 速度 | 极快 | 相对慢 |
| 容量 | 很小 | 大得多 |
| 使用方式 | 指令直接指定 | 通过地址访问 |
| 典型用途 | 临时计算、参数、返回值 | 程序数据、堆、栈、缓存内容 |
很多性能优化的底层目标是:
减少慢内存访问
尽量让关键数据停留在寄存器或 Cache
3. ALU:算术逻辑单元
ALU 是 CPU 里执行基本运算的部件。
常见 ALU 操作:
| 操作 | 例子 |
|---|---|
| 加法 | a + b |
| 减法 | a - b |
| 比较 | a < b、a == b |
| 按位与 | 掩码 |
| 按位或 | 打开标志位 |
| 异或 | 翻转、校验、哈希 |
| 移位 | 乘除 2 的幂、字段提取 |
乘法、除法、浮点运算可能由专门单元完成,但核心思想仍是:硬件电路根据输入位模式产生输出位模式。
4. 加法器:从 bit 到整数计算
二进制加法从最小位开始:
0 + 0 = 0
0 + 1 = 1
1 + 0 = 1
1 + 1 = 0,进位 1
全加器处理:
输入: bit A, bit B, carry in
输出: sum, carry out
多个全加器串起来,就能做多 bit 加法。
这解释了补码为什么重要:
补码让减法也能变成加法。
同一个加法电路可以处理正数、负数和减法。
5. 标志位:计算后的状态记录
ALU 运算后,CPU 会记录一些状态。
常见标志:
| 标志 | 含义 |
|---|---|
| Zero | 结果是否为 0 |
| Carry | 无符号运算是否产生进位/借位 |
| Sign | 结果符号位 |
| Overflow | 有符号运算是否溢出 |
比较指令常常本质上是一次减法,只是不保存普通结果,而是更新标志位。
例如:
比较 a 和 b
-> 计算 a - b
-> 根据结果更新 Zero/Sign/Overflow
条件跳转再根据标志位决定是否跳转。
机制深挖:同一次减法,signed 和 unsigned 看不同标志
比较通常通过减法设置标志位,但“谁大谁小”的解释依赖 signed/unsigned 规则。
假设 8 bit:
a = 11111111
b = 00000001
如果按 unsigned:
a = 255
b = 1
a > b
如果按 signed 补码:
a = -1
b = 1
a < b
位模式完全一样,比较结果相反。原因是 CPU 不会自动知道你的业务语义,它只保存计算后的状态标志。不同条件跳转指令会读取不同组合:
| 比较语义 | 关注的标志直觉 |
|---|---|
| unsigned 小于/大于 | Carry / Zero 等无符号进位借位信息 |
| signed 小于/大于 | Sign 和 Overflow 的组合 |
| 相等/不等 | Zero |
这能解释一个底层事实:
同一条减法结果
配上不同条件跳转
就会得到 signed 或 unsigned 的不同分支语义
所以排查“比较为什么走错分支”时,不能只看二进制减法,还要看这段代码最终使用的是哪种比较语义。
6. 比较和条件跳转如何配合
高级代码:
代码块收起展开
if a < b:
do X
else:
do Y底层可能像:
CMP a, b
JL label_X
JMP label_Y
含义:
先比较 a 和 b
根据标志位判断是否跳到 label_X
否则走 label_Y
所以分支不是抽象魔法,而是:
比较 -> 更新标志 -> 条件跳转 -> 改变 PC
7. 控制器:把指令变成动作
控制器读取指令后,要发出控制信号。
例如一条加法指令:
ADD R1, R2, R3
控制器要安排:
读取 R2
读取 R3
让 ALU 执行加法
把结果写入 R1
更新 PC
一条 load 指令:
LOAD R1, [addr]
控制器要安排:
生成地址
访问 Cache/内存
等待数据返回
写入 R1
控制器本质是 CPU 内部的指挥系统。
7.1 一条指令可以拆成微操作
从硬件视角看,一条指令不是一个不可拆的魔法动作,而是由更小的微操作组成。
以加法指令为例:
ADD R1, R2, R3
可以拆成:
| 微操作 | 说明 |
|---|---|
| 读寄存器 R2 | 把第一个操作数送到 ALU 输入 |
| 读寄存器 R3 | 把第二个操作数送到 ALU 输入 |
| 设置 ALU 控制信号 | 选择加法 |
| ALU 产生结果 | 输出 sum 和标志位 |
| 写回 R1 | 把结果保存到目标寄存器 |
| 更新 PC | 指向下一条指令 |
load 指令更复杂:
LOAD R1, [base + offset]
它至少包含:
读 base
ALU 计算有效地址
访问 Cache / 内存
等待数据
写回 R1
这解释了为什么不同指令耗时不同。寄存器加法只在 CPU 内部流动;load 可能要等 Cache 或内存。
8. 数据通路:数据如何在 CPU 内流动
CPU 内部有数据通路,把寄存器、ALU、Cache 接口连接起来。
简化:
再写回:
结果寄存器 -> 寄存器堆
如果要访存:
寄存器/立即数 -> 地址生成 -> Cache/内存 -> 寄存器
数据通路宽度影响一次能处理多少 bit。32 bit、64 bit 的区别之一就在于寄存器和数据通路宽度。
8.1 数据相关:后面的指令等前面的结果
数据通路里还有一个重要问题:后一条指令可能依赖前一条指令的结果。
ADD R1, R2, R3
MUL R4, R1, R5
第二条指令要用 R1,但 R1 是第一条指令刚算出来的。如果第一条还没写回,第二条就不能随便读取旧值。
这叫数据相关。它会影响流水线:
| 相关类型 | 直觉 |
|---|---|
| read after write | 后面要读前面刚写的结果 |
| write after read | 后面写的目标可能影响前面未完成的读 |
| write after write | 两条指令写同一目标,最终顺序要正确 |
硬件可能用转发、停顿、乱序执行、寄存器重命名等方式处理。你不需要现在掌握全部细节,但要知道:指令之间的数据依赖会让 CPU 不能无限并行。
9. 立即数、寄存器寻址和内存寻址
指令里的操作数来源不同:
| 来源 | 例子 | 含义 |
|---|---|---|
| 立即数 | ADD R1, R2, 5 | 5 直接写在指令里 |
| 寄存器 | ADD R1, R2, R3 | 数据在寄存器里 |
| 内存地址 | LOAD R1, [addr] | 从内存地址取数据 |
| 基址+偏移 | [base + offset] | 数组、结构字段常见 |
基址+偏移解释了数组和结构字段访问:
数组元素地址 = base + index * element_size
结构字段地址 = object_base + field_offset
9.1 地址生成本身也是计算
访问数组或结构字段时,CPU 不只是“拿到值”,还要先算地址。
例如:
a[i]
底层地址类似:
address = base + i * element_size
如果是二维数组:
a[row][col]
address = base + (row * column_count + col) * element_size
这说明数据布局会影响成本。连续数组让地址生成和预取更友好;指针链表则需要一次次读取下一个地址,CPU 很难提前知道未来要访问哪里。
10. 栈指针和函数栈帧
函数调用需要保存临时信息。
栈指针指向当前栈顶。函数调用时可能会:
保存返回地址
保存旧寄存器
为局部变量留空间
调整栈指针
函数返回时:
恢复寄存器
释放栈帧
跳回返回地址
这解释了:
- 局部变量为什么有生命周期。
- 递归为什么消耗栈空间。
- 栈溢出为什么会发生。
- 调用栈为什么能显示函数调用路径。
10.1 调用约定:调用者和被调用者如何分工
函数调用还需要约定:
参数放哪里?
返回值放哪里?
哪些寄存器由调用者保存?
哪些寄存器由被调用者恢复?
栈如何对齐?
这叫调用约定。不同平台细节不同,但目的相同:让独立编译的函数也能互相调用。
如果调用约定被破坏,就会出现非常诡异的问题:
| 破坏点 | 可能现象 |
|---|---|
| 返回地址损坏 | 函数返回到错误位置 |
| 栈指针未恢复 | 后续局部变量或返回值错乱 |
| 该保存的寄存器没保存 | 调用前的值被意外改掉 |
| 栈未对齐 | 某些指令或 ABI 要求失败 |
这也是为什么栈溢出、越界写、错误的底层调用会造成崩溃甚至安全问题。
11. 编译器和寄存器分配
高级程序里的变量很多,但寄存器数量有限。
编译器要决定:
哪些变量放寄存器?
哪些变量临时放内存?
什么时候保存?
什么时候恢复?
如果寄存器不够,可能发生 spill:
把本来想放寄存器的数据临时写到内存
需要时再读回来
这会增加内存访问成本。
所以变量生命周期、函数复杂度、循环结构都会影响编译后的寄存器使用。
11.1 活跃变量越多,寄存器压力越大
编译器做寄存器分配时,关心一个变量在某段代码里是否还会被用到。还会被用到的变量叫活跃变量。
如果同一时间活跃变量太多,寄存器放不下,就会 spill 到内存。
例如循环里同时保留:
多个数组指针
多个索引
多个中间结果
多个函数调用参数
寄存器压力就会上升。spill 带来的成本不是语义层能直接看到的,但会体现在更多 load/store 上。
这也解释了为什么某些“看起来只加了几个临时变量”的改动,会让热点循环变慢。
12. 联系实际:从寄存器和 ALU 看程序成本
当你分析一段热点代码时,可以这样想:
- 它主要在做算术,还是在搬数据?
- 关键数据能否留在寄存器?
- 是否频繁访问内存?
- 是否有大量条件分支?
- 是否有复杂函数调用导致寄存器保存恢复?
- 是否存在溢出、进位、符号解释问题?
- 是否使用位运算打包了状态,需要正确掩码?
很多底层优化的方向都是:
减少内存访问
减少不必要分支
让数据更适合寄存器和 ALU 处理
练习卡:从寄存器视角看一次表达式计算
把表达式 x = (a + b) * (c - d) 当作寄存器分配练习:
| 步骤 | 需要的临时值 | 可能占用的寄存器 |
|---|---|---|
读取 a、b | 两个输入 | R1、R2 |
计算 a + b | 一个中间结果 | R1 |
读取 c、d | 两个输入 | R2、R3 |
计算 c - d | 一个中间结果 | R2 |
| 相乘 | 两个中间结果 | R1、R2 |
写回 x | 最终结果 | 内存或寄存器 |
如果寄存器不够,中间结果就可能被临时放回内存,这叫溢出到内存。理解这一点后,你看“寄存器比内存快”就不会停在口号上,而能知道复杂表达式、函数调用、循环变量为什么会改变真实成本。
问题:为什么一个小函数内联后变快或变慢?
学完本章,可以分析一个真实编译优化问题:
把小函数内联进循环后,程序有时变快,有时反而变慢。
可能变快:
| 原因 | 解释 |
|---|---|
| 少一次调用/返回 | 保存恢复现场少了 |
| 参数传递更简单 | 变量可能直接留在寄存器 |
| 优化视野变大 | 编译器能消除多余计算 |
也可能变慢:
| 原因 | 解释 |
|---|---|
| 代码变大 | 指令 Cache 压力增加 |
| 活跃变量变多 | 寄存器不够,spill 增加 |
| 分支路径变复杂 | 分支预测和流水线压力增加 |
这说明“函数调用开销”只是表层。真正要看的是寄存器压力、数据通路、指令 Cache 和控制流。
反例:比较指令一样,跳转条件错了结果就错
底层比较经常通过减法设置标志位:
cmp a, b // 本质上观察 a - b 的标志
但后面的条件跳转必须选择正确解释:
| 语义 | 应看什么 |
|---|---|
| unsigned 大小 | carry/borrow 相关标志 |
| signed 大小 | sign 与 overflow 的组合 |
| 相等 | zero 标志 |
如果把 unsigned 数据当 signed 跳转,边界值会出错。
例如 8 bit:
a = 200 -> bit: 11001000
b = 100 -> bit: 01100100
按 unsigned:
200 > 100
按 signed 8 bit 补码:
11001000 表示 -56
01100100 表示 100
=> -56 < 100
同一串 bit、同一次比较,后续跳转条件不同,控制流就会走到相反方向。典型失败指纹是:
小正数比较正常;
一旦值超过最高位边界,例如 127/128、32767/32768,就出现排序或范围判断异常。
这类 bug 不是 ALU 算错,而是“标志位解释规则”和数据语义不匹配。
手推:寄存器 spill 为什么会把热点循环拖慢
假设一个循环每次迭代需要同时保留 10 个活跃值,但可用通用寄存器只有 8 个。编译器必须把部分值临时放回内存:
迭代开始
load spilled value
计算
store spilled value
迭代结束
如果循环执行 1000 万次,多出来的 load/store 也会被执行 1000 万次。它们不改变算法复杂度,却改变每轮成本。
这说明:
寄存器压力是隐藏常数项;
热点循环里常数项会被放大成真实瓶颈。
常见诱因:
| 诱因 | 为什么增加压力 |
|---|---|
| 循环体过大 | 同时活跃的中间值更多 |
| 内联过多 | 调用消失,但变量和路径变多 |
| 多个数组并行处理 | 指针、索引、中间结果都要保留 |
| 复杂分支 | 不同路径需要保留不同状态 |
所以底层优化不是盲目“多写临时变量”或“全部内联”。要看寄存器是否装得下当前工作集。
13. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 寄存器为什么比内存快,为什么 CPU 计算离不开寄存器?
- ALU 做哪些基本计算?
- 比较和条件跳转如何通过标志位实现?
- 控制器如何把指令变成 CPU 内部动作?
- 数据通路是什么,为什么 32 bit/64 bit 会影响表示和处理宽度?
- 栈指针、返回地址、栈帧如何支撑函数调用?
- 编译器为什么要做寄存器分配,寄存器不够会发生什么?
CPU 内部不是神秘空间。它是在控制器指挥下,让数据在寄存器、ALU、内存接口之间流动。