第 8 章

寄存器:CPU 的口袋

寄存器:CPU 的口袋

CPU 执行计算,需要数据。数据从哪来?绝大多数情况下,来自内存。但内存访问速度比 CPU 慢几百倍——如果 CPU 每次计算都要去内存取数据,流水线就会一直饿着。

于是,CPU 有了"口袋":寄存器

寄存器是 CPU 内部最快、最小的存储单元,访问速度与 CPU 运算速度相同(0 等待周期)。代价是数量极少:x86-64 只有 16 个通用寄存器,ARM64 有 31 个。

如何用这么少的"口袋"完成复杂的计算?这就是本章要回答的问题。

Level 1:建立直觉

寄存器是什么

想象你是一个会计,手边有一张白纸,上面画了几个格子(寄存器):

┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ RAX   │ │ RBX   │ │ RCX   │ │ RDX   │
│  42   │ │  17   │ │  0    │ │  100  │
└───────┘ └───────┘ └───────┘ └───────┘

计算 42 × 17 + 100:

  1. 看 RAX(42)和 RBX(17),计算乘积,结果放到 RAX:714
  2. 看 RDX(100),加到 RAX:814

整个过程中,你的眼睛和手只在这张纸上的格子里移动——不需要去文件柜(内存)翻找。计算极快。

这就是寄存器的价值:把最近要用的数据放在"手边"

x86-64 的寄存器全家福

x86-64 架构有 16 个 64 位通用寄存器:

64位名称  32位  16位  8位  用途(习惯约定)
RAX      EAX   AX   AL   累加器,函数返回值
RBX      EBX   BX   BL   基址寄存器(被调用者保存)
RCX      ECX   CX   CL   计数器,第4个函数参数
RDX      EDX   DX   DL   数据寄存器,第3个函数参数
RSI      ESI   SI   SIL  源索引,第2个函数参数
RDI      EDI   DI   DIL  目标索引,第1个函数参数
RBP      EBP   BP   BPL  基指针,栈帧基址
RSP      ESP   SP   SPL  栈指针,永远指向当前栈顶
R8-R15               -   通用(第5-8个函数参数,及其他)

还有一些特殊寄存器:

寄存器的大小嵌套

x86-64 的寄存器有个有趣特性:低位的小寄存器嵌套在大寄存器里。

RAX 的 64 位:
┌────────────────────────────────────────────┐
│   高32位(写 EAX 时自动清零)                │   低32位(EAX)
│                                            ├────────────────────┐
│                                            │      低16位(AX)  │
│                                            │    ├──────┬──────┤ │
│                                            │    │ AH   │ AL   │ │
│                                            │    │(高8位)│(低8位)│ │
└────────────────────────────────────────────┘

当你写 EAX(32位),高 32 位自动清零;当你写 AX(16位),高位保持不变。这是一个历史遗留设计,来自 x86 的 16→32→64 位升级过程,有时会造成微妙的 bug。

ARM64 的寄存器

ARM64(AArch64)更干净:31 个通用寄存器(X0-X30),每个都是 64 位。对应的 32 位视图是 W0-W30(写 W 寄存器不会清零高 32 位——与 x86 不同!)。

X0-X7:    函数参数(最多8个参数直接用寄存器)
X0:       函数返回值
X8:       间接结果寄存器(大结构体返回)
X9-X15:   临时寄存器(调用者保存)
X16-X17:  跨过程调用的临时寄存器(过程链接表)
X18:      平台保留(iOS 用于线程局部存储)
X19-X28:  被调用者保存寄存器
X29(FP):  帧指针
X30(LR):  链接寄存器(函数返回地址)
SP:       栈指针
PC:       程序计数器

ARM64 有 31 个通用整数寄存器(比 x86-64 多 15 个!)加上 32 个 128 位 SIMD 寄存器(V0-V31),更宽的寄存器接口是 ARM 编译器更容易生成高效代码的原因之一。

Level 2:原理剖析

物理寄存器 vs 逻辑寄存器

程序员看到的 16 个 x86-64 寄存器叫逻辑寄存器(Architectural Registers)——这是 ISA 规定的接口。

但 CPU 内部,实际上有多得多物理寄存器(Physical Registers)

这种差异叫做寄存器重命名(Register Renaming),是超标量 CPU 实现乱序执行的关键技术。

为什么需要寄存器重命名?

考虑这段汇编:

mov rax, [addr1]    ; 第1条:rax = memory[addr1](长延迟)
add rbx, rax        ; 第2条:真数据依赖 rax,必须等
mov rax, [addr2]    ; 第3条:重写 rax = memory[addr2]
add rcx, rax        ; 第4条:依赖第3条的 rax,不依赖第1条!

第 4 条指令名义上依赖 rax,但实际上它只需要第 3 条写的那个 rax,与第 1 条的 rax 没有关系。这是一种写后写(WAW)写后读(WAR)假依赖

通过寄存器重命名,CPU 把:

现在第 1、2 条(P43 依赖链)和第 3、4 条(P71 依赖链)完全独立,可以同时执行!

寄存器重命名消除了假依赖,使更多指令可以并行执行,这是现代 CPU 高 IPC 的重要来源。

标志寄存器(RFLAGS):比较的幕后英雄

每次比较或算术运算,都会更新 RFLAGS 中的几个标志位:

ZF(零标志):  运算结果为 0 时置 1
CF(进位标志):无符号运算溢出时置 1
SF(符号标志):结果为负(最高位为 1)时置 1
OF(溢出标志):有符号运算溢出时置 1
PF(奇偶标志):结果低8位中 1 的个数为偶数时置 1

条件跳转指令就是检查这些标志位:

cmp rax, rbx    ; 计算 rax - rbx,设置标志(但不保存差值)
je  equal       ; 跳转如果 ZF=1(相等)
jl  less        ; 跳转如果 SF!=OF(有符号小于)
jb  below       ; 跳转如果 CF=1(无符号小于)
jg  greater     ; 跳转如果 ZF=0 且 SF=OF(有符号大于)

有个微妙之处:很多指令都会修改标志位。如果你的代码在 cmp 后、je 前不小心执行了一条修改标志位的指令,比较结果就丢失了。这是汇编编程的一个常见坑。

浮点寄存器和 SIMD 寄存器

x86-64 有 SSE/AVX 的浮点/向量寄存器:

每次使用宽寄存器,就可以对多个数据同时进行同样的操作:

XMM 操作(128位,4个float):
[f0, f1, f2, f3] + [g0, g1, g2, g3] = [f0+g0, f1+g1, f2+g2, f3+g3]

一条指令,4 次加法。这就是 SIMD(Single Instruction, Multiple Data)。

ARM 的对应物是 NEON/SVE

SVE 是 ARM 的一个创新:不把向量长度写死在代码里,代码在任何 SVE 宽度的处理器上都能正确运行,自动利用更宽的寄存器。

调用约定:寄存器的社会契约

当函数 A 调用函数 B 时,寄存器里的值怎么处理?A 不能让 B 破坏它正在使用的寄存器。

**调用约定(Calling Convention)**规定了:

  1. 参数传递:函数参数放在哪些寄存器里
  2. 返回值:放在哪个寄存器
  3. 调用者保存(Caller-saved):调用者如果需要,在调用前保存,被调用者可以自由使用
  4. 被调用者保存(Callee-saved):被调用者如果要使用,必须先保存,退出前恢复

x86-64 System V ABI(Linux/macOS)

参数:     RDI, RSI, RDX, RCX, R8, R9(前6个整数/指针参数)
返回值:   RAX(整数/指针),RAX+RDX(128位返回值)
调用者保存:RAX, RCX, RDX, RSI, RDI, R8-R11,XMM0-XMM7
被调用者保存:RBX, RBP, R12-R15

ARM64 AAPCS(Apple/Linux/Windows)

参数:     X0-X7(前8个参数,比x86多2个!)
返回值:   X0(或 X0:X1 for 128位)
调用者保存:X0-X18
被调用者保存:X19-X28, X29(FP), X30(LR)

这些约定是隐形的"社会契约",让不同编译器编译的代码可以互相调用。违反约定会导致神秘的 bug(数据被意外覆盖)。

Level 3 · 规范怎么定义的(资深)

寄存器的 ISA 规范与 ABI 约定

寄存器的数量、宽度和用途由 ISA 规范严格定义。x86-64 的 16 个通用寄存器(RAX-R15)在 AMD64 ABI(System V AMD64 ABI,被 Linux/macOS 采用)中被分为三类:调用者保存(caller-saved,RAX/RCX/RDX/RSI/RDI/R8-R11)、被调用者保存(callee-saved,RBX/RBP/R12-R15)和特殊用途(RSP 为栈指针)。函数调用时前 6 个整数参数通过 RDI/RSI/RDX/RCX/R8/R9 传递,浮点参数通过 XMM0-XMM7 传递。Windows 的 x64 ABI 不同:只用 RCX/RDX/R8/R9 传递前 4 个参数。

ARM64(AArch64)定义了 31 个通用寄存器(X0-X30),比 x86-64 多近一倍。ARM64 的 AAPCS64(Procedure Call Standard for the Arm 64-bit Architecture)规定 X0-X7 传递参数、X19-X28 为 callee-saved、X29 为帧指针、X30 为链接寄存器(存放返回地址)。更多的寄存器意味着更少的栈溢出(register spill),这是 ARM64 代码通常比 x86-64 少执行内存访问指令的原因之一。

物理寄存器文件的规模远大于架构寄存器数量。Intel Golden Cove 微架构有约 280 个整数物理寄存器和 332 个向量物理寄存器,用于支持乱序执行的寄存器重命名。物理寄存器文件的设计遵循 SRAM 的时序约束,需要在一个时钟周期内完成多端口读写——这是芯片设计中最关键的时序路径之一。

Level 4 · 边界与陷阱(所有人)

陷阱 1:寄存器部分写入导致性能惩罚

在 x86 上,写入 32 位寄存器(如 MOV EAX, 1)会自动将高 32 位清零(这是 AMD64 的设计决定)。但写入 16 位或 8 位寄存器(如 MOV AX, 1MOV AL, 1不会清零高位,CPU 必须将新写入的低位与旧的高位合并。这个"部分寄存器合并"操作在某些微架构上会引入额外的延迟或流水线停顿。Intel 的 Skylake 之前的架构在检测到部分寄存器写入后需要插入一个"合并微操作",导致额外 1 个周期的延迟。编译器通常会用 MOVZX 代替 MOV 来避免这个问题,但手写汇编中这是常见的性能陷阱。

陷阱 2:ABI 不匹配导致跨语言调用崩溃

如果你用 C 调用一个用汇编编写的函数,但汇编代码没有保存 callee-saved 寄存器(如 RBX),调用者在函数返回后可能发现自己的局部变量被破坏了——因为编译器假设这些寄存器在函数调用后不变。这类 bug 极难调试:程序可能在毫不相关的地方崩溃,且只在特定优化级别下出现(因为优化级别影响编译器使用哪些寄存器存放变量)。FFI(Foreign Function Interface)框架如 Python 的 ctypes 也可能因 ABI 不匹配而出现类似问题。

陷阱 3:SIMD 寄存器的 VEX/EVEX 编码混用导致性能悬崖

x86 的 SSE 指令使用 128 位 XMM 寄存器,AVX 指令使用 256 位 YMM 寄存器。如果代码混用旧的 SSE 编码和新的 VEX 编码(AVX),CPU 可能在每次切换时插入一个昂贵的"SSE-AVX 过渡惩罚",在 Sandy Bridge/Ivy Bridge 上这个惩罚约 70 个周期。解决方案是在调用 SSE 代码前插入 VZEROUPPER 指令清零 YMM 的高 128 位。编译器通常会自动插入,但当你链接了使用不同编译选项的第三方库时,这个问题就会浮出水面——表现为函数调用后莫名其妙的性能下降。

本章评分
4.8  / 5  (44 评分)

💬 留言讨论