register - alu - control

05. 寄存器、ALU 与控制器

0. 本章先解决什么问题

上一章讲 CPU 怎样执行一条指令。本章进一步看 CPU 内部最核心的三个角色:

  • 寄存器:暂存数据
  • ALU:执行计算
  • 控制器:指挥数据往哪里走、做什么操作

你需要理解:

  • 为什么 CPU 需要寄存器?
  • ALU 能做哪些基本操作?
  • 控制器如何把指令变成控制信号?
  • 标志位、比较、条件跳转如何配合?
  • 为什么寄存器数量、数据通路、控制方式都会影响性能?

这不是为了背硬件结构图,而是为了理解“计算”在机器里到底发生在哪里。

先看 CPU 内部的数据通路:指令控制数据从寄存器流向 ALU,再把结果写回。

寄存器、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 < ba == 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. 比较和条件跳转如何配合

高级代码:

代码块PLAINTEXT · 4 行收起展开
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 接口连接起来。

简化:

两个寄存器操作数汇入 ALU 再写入结果寄存器

再写回:

结果寄存器 -> 寄存器堆

如果要访存:

寄存器/立即数 -> 地址生成 -> 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, 55 直接写在指令里
寄存器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 看程序成本

当你分析一段热点代码时,可以这样想:

  1. 它主要在做算术,还是在搬数据?
  2. 关键数据能否留在寄存器?
  3. 是否频繁访问内存?
  4. 是否有大量条件分支?
  5. 是否有复杂函数调用导致寄存器保存恢复?
  6. 是否存在溢出、进位、符号解释问题?
  7. 是否使用位运算打包了状态,需要正确掩码?

很多底层优化的方向都是:

减少内存访问
减少不必要分支
让数据更适合寄存器和 ALU 处理

练习卡:从寄存器视角看一次表达式计算

把表达式 x = (a + b) * (c - d) 当作寄存器分配练习:

步骤需要的临时值可能占用的寄存器
读取 ab两个输入R1、R2
计算 a + b一个中间结果R1
读取 cd两个输入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. 学完本章你能解决什么问题

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

  1. 寄存器为什么比内存快,为什么 CPU 计算离不开寄存器?
  2. ALU 做哪些基本计算?
  3. 比较和条件跳转如何通过标志位实现?
  4. 控制器如何把指令变成 CPU 内部动作?
  5. 数据通路是什么,为什么 32 bit/64 bit 会影响表示和处理宽度?
  6. 栈指针、返回地址、栈帧如何支撑函数调用?
  7. 编译器为什么要做寄存器分配,寄存器不够会发生什么?

CPU 内部不是神秘空间。它是在控制器指挥下,让数据在寄存器、ALU、内存接口之间流动。

延伸阅读