前言
下面讨论的是 Apple 平台目前常见的 ARM64 寄存器,不同 CPU 架构的寄存器名称和调用规则不同。
寄存器是什么
寄存器 是 CPU 内部用来临时保存数据和执行状态的高速存储单元。
程序中的数据通常保存在内存里,但 ARM64 的大多数计算指令不能直接拿两块普通内存做运算,而是先把数据放入寄存器,再让 CPU 对寄存器进行计算。例如:
swift
ldr w0, [x1] ; 从 x1 指向的内存读取数据,放进 w0
add w0, w0, #1 ; 让 w0 加 1
str w0, [x1] ; 把结果写回内存
过程:
内存 -> 寄存器 -> CPU 计算 -> 寄存器 -> 内存
因此,寄存器主要承担三类工作:
- 保存正在参与计算的数据
- 保存内存地址
- 保存程序当前执行到哪里、函数返回到哪里等控制信息
寄存器数量很少,不能替代内存。编译器会决定哪些值暂时放在寄存器里,哪些值放在栈中。如果寄存器不够,或者某个值必须长期保存,编译器可能把它临时写入栈内存,这叫做寄存器溢出(register spill)。
保存的内容
寄存器保存的是二进制。
寄存器本身并不知道里面保存的是整数、对象还是地址,它只保存一串二进制位。
假设 x0 中保存:
0x0000000100123456
这个值可能是:
- 一个整数
- 某个对象的内存地址
- 某条机器指令的地址
- 一组标志位
- 计算过程中的中间结果
它的含义由当前指令和程序上下文决定。
所以在调试器中看到一个寄存器值时,不能仅仅因为它看起来像地址,就认为它一定是指针。
ARM64 中的通用寄存器
ARM64 提供了 31 个 64 位通用寄存器:
swift
x0、x1、x2......x30
它们还可以使用 32 位名称:
swift
w0、w1、w2......w30
x0 和 w0 不是两个独立寄存器,而是同一个寄存器的不同宽度:
swift
x0:完整的 64 位
w0:x0 的低 32 位
比如:
swift
add x0, x1, x2 ; 进行 64 位加法
add w0, w1, w2 ; 进行 32 位加法
不同的寄存器有不同的作用
从硬件能力来说,大多数通用寄存器都可以保存普通整数或地址。
但是如果不同编译器、不同函数随意使用寄存器,它们就无法互相调用,因为如果规则不同,那么同样的寄存器会被解释成不同的东西。因此 ARM64 定义了一套函数调用约定,也就是 ABI。
ABI 可以理解成机器代码之间共同遵守的规则,它规定:
- 参数放在哪里
- 返回值放在哪里
- 哪些寄存器允许函数随意修改
- 哪些寄存器修改前必须保存
- 栈应该怎样组织
在一次函数调用中:
- 发起调用的函数叫
caller,调用者 - 被调用的函数叫
callee,被调用者
在理解寄存器分类前,还需要理解两个概念:
- Caller-saved:被调用函数可以修改。调用者如果还想保留原值,就要在调用前自己保存
- Callee-saved:被调用函数如果要修改,就必须先保存旧值,并在返回前恢复
这不是说某一类寄存器更重要,而是在规定由谁负责保护原值。
常见寄存器
| 寄存器 | 常见作用 |
|---|---|
x0~x7 |
传递前 8 个整数或指针参数,也用于返回结果 |
x8 |
某些间接返回结果使用的地址,也可以作为临时寄存器 |
x9~x15 |
Caller-saved 临时寄存器 |
x16、x17 |
链接器、跳板代码和函数内部使用的临时寄存器 |
x18 |
平台保留寄存器;Apple 不允许普通代码使用 |
x19~x28 |
Callee-saved,需要跨函数调用保留的值 |
x29 / fp |
Frame Pointer,栈帧指针 |
x30 / lr |
Link Register,链接寄存器,通常保存函数返回地址 |
sp |
Stack Pointer,栈指针 |
pc |
Program Counter,程序计数器 |
xzr / wzr |
Zero Register,零寄存器 |
NZCV |
条件标志寄存器 |
v0~v31 |
浮点和 SIMD 向量寄存器 |
这些名字描述的是调用约定中的常见职责,不代表寄存器在函数内部只能做这一件事。
x0~x7:参数和返回值寄存器
对于整数、指针等普通参数,函数调用时通常使用 x0~x7 传递前 8 个参数。
比如:
swift
int add(int a, int b) {
return a + b;
}
int result = add(10, 20);
可以用下面的简化 ARM64 汇编表示:
swift
mov w0, #10 ; 第一个参数 a
mov w1, #20 ; 第二个参数 b
bl _add ; 调用 add
; 返回后,w0 保存结果 30
_add:
add w0, w0, w1
ret
进入 add() 时:
swift
w0 = 10
w1 = 20
执行:
add w0, w0, w1
意思是:
w0 = w0 + w1
所以执行后:
w0 = 30
函数返回时,调用者从 w0 中取得返回值。
但 x0 ~ x7 是 caller-saved,函数一旦继续执行,编译器可以重新使用它们。因此在函数中看到 x0,不能保证里面仍然是最初的第一个参数。
x19~x28:需要保留的寄存器
x19 ~ x28 属于 callee-saved。
假设 main() 把一个需要长期保存的值放进 x19,然后调用 foo():
swift
main
x19 = 某个重要值
调用 foo
返回后仍需使用 x19
如果 foo() 也想使用 x19,它必须:
swift
进入 foo
把旧 x19 保存到栈中
使用 x19
从栈中恢复旧 x19
返回 main
这样 main() 可以相信调用结束后,x19 仍然保持原值。
这类寄存器适合保存需要跨越多个函数调用继续使用的数据。
sp:栈指针
sp 是 Stack Pointer,指向当前线程栈正在使用的位置。
函数需要保存返回地址、旧寄存器或者局部数据时,可以先调整 sp,在栈中开辟空间:
swift
sub sp, sp, #32
表示让 sp 向低地址移动 32 字节,为当前函数分配栈空间。
函数返回前再恢复:
swift
add sp, sp, #32
需要注意,sp 指向的是当前栈边界,不是固定指向某个局部变量。随着函数进入、退出以及临时空间分配,sp 会不断变化。
Apple ARM64 ABI 要求 sp 保持 16 字节对齐。
fp / x29:栈帧指针
fp 是 Frame Pointer,也是 x29 的别名,是同一个寄存器。
sp 会随着函数内部的空间分配不断变化,因此函数有时需要一个相对稳定的位置作为当前栈帧的参考,这就是 fp。
典型的函数入口可能执行:
swift
stp x29, x30, [sp, #-16]!
mov x29, sp
第一条指令把旧 x29 和返回地址 x30 保存到栈中,第二条指令让新的 x29 指向当前 Frame Record。
于是栈中形成:
swift
当前 x29
│
▼
┌──────────────────┐
│ 调用者的 x29 │
├──────────────────┤
│ 返回地址 │
└──────────────────┘
通过保存的旧 x29,可以继续找到上一层函数的 Frame Record。
不过,不是每个函数都会创建新的 Frame Record。叶子函数,也就是不再调用其他函数的函数,可能直接沿用调用者的 x29。
Apple 要求 x29 始终指向一条有效的 Frame Record 链,但允许叶子函数、尾调用等不为自己增加新的记录。
lr / x30:返回地址
lr 是 Link Register,也是 x30 的别名。
ARM64 使用 bl 调用函数:
swift
bl _add
这条指令同时做两件事:
- 把下一条指令的地址写入
lr - 跳转到
_add
因此,lr 中保存的是:
被调用函数执行结束后,应该回到调用者的哪个位置。
函数执行 ret 时,默认跳转到 lr 中的地址。
但是如果当前函数又调用了另一个函数,新的 bl 会覆盖 lr。因此非叶子函数通常需要在调用其他函数前,把自己的返回地址保存到栈中。
这也是为什么 fp 和 lr 经常成对保存:
swift
stp x29, x30, [sp, #-16]!
pc:当前执行位置
pc 是 Program Counter,程序计数器,用来表示 CPU 当前执行到了哪条机器指令。
可以把程序想象成一系列带地址的指令:
swift
0x1000 mov w0, #10
0x1004 mov w1, #20
0x1008 bl _add
0x100c ...
当 CPU 执行到 0x1008 附近时,pc 就反映相应的指令位置。
不过在 ARM64 中,pc 不是像 x0 那样可以随意参与普通运算的通用寄存器。跳转、函数调用、函数返回等指令会改变程序的执行位置,调试器和系统保存的线程状态也会显示 pc。
xzr / wzr:永远为零的寄存器
xzr 和 wzr 是 Zero Register:
- 读取它,永远得到 0
- 向它写入数据,结果会被丢弃
例如:
swift
mov x0, xzr
可以把 x0 清零。
NZCV:条件标志
N:Negative,结果为负Z:Zero,结果为零C:Carry,产生无符号进位或借位相关状态V:Overflow,发生有符号溢出
比如:
swift
cmp w0, w1
b.eq equal
cmp 比较 w0 和 w1,更新 NZCV,b.eq 检查其中的 Z 标志,如果结果为零,说明两个值相等,于是跳转到 equal。
v0 ~ v31:浮点和向量寄存器
除了通用寄存器,ARM64 还有 32 个 128 位的浮点 / SIMD 寄存器:
v0 ~ v31
同一个寄存器可以按照不同宽度访问。例如 v0 可以使用:
b0:低 8 位h0:低 16 位s0:低 32 位,常用于floatd0:低 64 位,常用于doubleq0:完整 128 位
比如:
swift
fadd s0, s0, s1
表示两个 32 位单精度浮点数相加。
swift
fadd d0, d0, d1
表示两个 64 位双精度浮点数相加。
SIMD 是 "单条指令处理多份数据"。一个 128 位向量寄存器可以同时保存多个较小的数据,一条指令便可以并行处理它们。
CPU 寄存器和线程寄存器
CPU 拥有真正用于执行指令的寄存器,线程拥有的是一套需要被保存和恢复的 "寄存器状态"。
真正的寄存器是 CPU 核心的那一套物理寄存器,线程的只是 "逻辑寄存器"。
假设一个进程里有两个线程:
swift
线程 A:
pc = 0x100012340
sp = 0x16fd00000
x0 = 10
x1 = 20
线程 B:
pc = 0x100056780
sp = 0x16fc00000
x0 = 某个对象地址
x1 = 0
线程 A 和 线程 B 都有自己的:
pc:自己执行到了哪里sp:自己的栈在哪里x0~x30:自己当前使用的数据- 处理器状态和浮点 / 向量状态
否则线程 A 被暂停后,线程 B 一运行就会覆盖寄存器,线程 A 就无法继续执行。
也就是它们拥有自己的一套独立逻辑状态。
线程运行的时候,状态进入 CPU 寄存器,当线程 A 被调度到 CPU 核心运行时,可以简化理解为:
swift
线程 A 保存的寄存器状态
↓ 恢复
CPU 核心的 x0、x1、sp、pc......
↓
CPU 从线程 A 的 pc 位置继续执行
此时:
swift
CPU 的 x0 = 线程 A 的 x0
CPU 的 sp = 线程 A 的 sp
CPU 的 pc = 线程 A 的 pc
此时如果需要从 A 切到 B,操作系统需要进行上下文切换:
swift
1. 暂停线程 A
2. 保存线程 A 恢复执行所需的寄存器状态
x0~x30
sp
pc
处理器状态
必要的浮点/向量状态
3. 恢复线程 B 之前保存的寄存器状态
4. CPU 从线程 B 的 pc 继续执行
也就是:
swift
线程 A 的上下文 线程 B 的上下文
x0 = 10 x0 = 100
sp = 0x16fd00000 sp = 0x16fc00000
pc = functionA pc = functionB
│ ▲
│ 装入 CPU │ 保存/恢复
▼ │
┌──────────────────────────────────────────────┐
│ CPU 核心当前的体系结构寄存器 │
│ x0、x1......x30、sp、pc、状态寄存器 │
└──────────────────────────────────────────────┘
多核 CPU
如果设备有多个 CPU 核心,多个线程可以同时运行,每个核心都有当前正在生效的寄存器状态。
线程也不一定永远运行在同一个核心上,线程 A 这次可能运行在核心 0,下次可能被调度到核心 2,只要它的体系结构状态被正确保存和恢复,线程就能继续执行。