2、ARM64寄存器体系详解:通用寄存器X0-X30、栈指针SP与程序计数器PC、状态寄存器NZCV、系统寄存器与特殊寄存器、寄存器使用约定(ABI)
寄存器,说白了就是CPU内部最快的那一小块存储。你写汇编也好,看反汇编也好,大部分时间都在跟寄存器打交道。ARM64有31个通用寄存器,外加一堆特殊寄存器。今天我把它们掰开揉碎了讲清楚。
2.1 通用寄存器:X0到X30
ARM64提供了31个通用寄存器,编号从X0到X30。每个都是64位宽。你可以用X形式访问全部64位,也可以用W形式访问低32位。
核心规则:写W寄存器会自动清零高32位。这是ARM64跟ARM32最大的区别之一。我当年刚转ARM64时,在这个坑上浪费了半天。
| 寄存器 | 64位名 | 32位名 | ABI角色 |
|---|---|---|---|
| X0-X7 | X0-X7 | W0-W7 | 参数传递、返回值 |
| X8 | X8 | W8 | 间接结果寄存器 |
| X9-X15 | X9-X15 | W9-W15 | 临时寄存器(调用者保存) |
| X16-X17 | X16-X17 | W16-W17 | IP0、IP1(过程内调用暂存) |
| X18 | X18 | W18 | 平台保留(如TLS基址) |
| X19-X28 | X19-X28 | W19-W28 | 被调用者保存 |
| X29 | X29 | W29 | 帧指针FP |
| X30 | X30 | W30 | 链接寄存器LR |
你想想看,X0到X7用来传参数,最多8个。超过8个就得压栈了。X8比较特殊,它用来返回大结构体——比如函数要返回一个结构体,编译器会把结构体地址塞进X8。
X9到X15是临时寄存器,调用者自己负责保存。X19到X28是被调用者保存的——也就是说,如果你在函数里用了它们,必须恢复原值。我个人习惯把频繁使用的循环计数器放在X19-X21里,省得每次调用子函数都要重新加载。
2.2 栈指针SP与程序计数器PC
SP和PC是两个特殊寄存器。它们不能像通用寄存器那样直接用MOV访问,得用专门的指令。
栈指针SP
SP就是栈指针,指向当前栈顶。ARM64的栈是向下增长的——压栈时SP减小,出栈时SP增大。有个硬性要求:SP必须16字节对齐。违反这个规则,访问内存时会触发异常。
我曾经在写启动代码时,忘了在切换栈之前对齐SP。结果一跳转到C代码就崩。查了两天才发现是SP没对齐。从那以后,我每次设置SP都会加一句:and sp, sp, #~0xf。
ARM64没有PUSH/POP指令。压栈出栈得用STP/LDP。比如:
// 压栈:保存X29和X30
stp x29, x30, [sp, #-16]!
// 出栈:恢复X29和X30
ldp x29, x30, [sp], #16
注意那个感叹号!它表示先更新SP再访问内存。没有感叹号就是先访问再更新——这是ARM64寻址模式的精髓。
程序计数器PC
PC指向当前正在执行的指令地址。ARM64里你不能直接写PC,但可以读。比如计算PC相对地址:
adr x0, label // 将label的PC相对地址加载到X0
PC在分支指令里自动更新。BL指令会把返回地址存到X30,然后跳转。RET指令则从X30跳回来。这就是函数调用的基本机制。
2.3 状态寄存器NZCV
NZCV是PSTATE(处理器状态)里的四个条件标志位。它们由算术和逻辑指令自动更新。
| 标志 | 全称 | 含义 |
|---|---|---|
| N | Negative | 结果为负(最高位为1) |
| Z | Zero | 结果为零 |
| C | Carry | 加法进位/减法借位 |
| V | Overflow | 有符号溢出 |
条件分支指令就是靠这些标志做判断的。比如:
cmp x0, x1 // 比较X0和X1,更新NZCV
b.eq label // 如果相等(Z=1),跳转
b.gt label // 如果大于(N=V且Z=0),跳转
嗯,这里要注意:CMP指令本质上是SUBS,只是丢弃了结果。所以它会影响所有标志位。
调试技巧:在GDB里用info registers看NZCV。或者用p $cpsr看整个PSTATE。我经常在反汇编里看到条件分支,然后对照NZCV的值判断程序走哪条路。
2.4 系统寄存器与特殊寄存器
ARM64有大量系统寄存器,用来控制MMU、中断、调试、性能监控等。它们只能通过MSR和MRS指令访问。
// 读系统寄存器
mrs x0, sctlr_el1 // 读取系统控制寄存器
// 写系统寄存器
msr sctlr_el1, x0 // 写入系统控制寄存器
常用的系统寄存器包括:
- SCTLR_EL1:系统控制寄存器,控制MMU、对齐检查、缓存等
- TCR_EL1:翻译控制寄存器,配置页表属性
- TTBR0_EL1/TTBR1_EL1:页表基址寄存器
- VBAR_EL1:异常向量表基址
- CNTPCT_EL0:物理计数器(性能调优常用)
我在做性能分析时,经常用CNTPCT_EL0来测量代码段的精确周期数。比perf还准。
2.5 寄存器使用约定(ABI)
ABI就是大家约定好的规则。不遵守ABI,你的代码跟别人的库就接不上。ARM64的ABI叫AAPCS64(Procedure Call Standard for the ARM 64-bit Architecture)。
核心规则就几条:
- 参数传递:X0-X7传前8个参数,多余的压栈
- 返回值:X0返回整数/指针,X8返回大结构体地址
- 调用者保存:X0-X15(调用者自己备份)
- 被调用者保存:X19-X29(被调用者负责恢复)
- 栈对齐:SP必须16字节对齐
- 帧指针:X29是FP,指向当前栈帧底部
实战建议:写汇编函数时,开头先STP X29, X30保存FP和LR,然后MOV X29, SP建立帧指针。结尾LDP X29, X30恢复,然后RET。这是标准套路,别偷懒。
举个例子,一个标准的函数序言和结语:
my_function:
// 序言
stp x29, x30, [sp, #-32]! // 保存FP、LR,分配32字节栈空间
mov x29, sp // 建立帧指针
// 函数体
// ... 用X0-X7传参,用X0返回结果 ...
// 结语
ldp x29, x30, [sp], #32 // 恢复FP、LR,回收栈空间
ret
你可能会问:为什么非要保存X29和X30?因为X29是帧指针,X30是返回地址。不保存它们,函数就没法正确返回。调试器也靠帧指针回溯调用栈。
2.6 寄存器体系总览
下面这张图把ARM64寄存器体系串起来了。我建议你把它存下来,写汇编时对照着看。
这张图把ARM64寄存器体系分成了三大块:通用寄存器、特殊寄存器、ABI规则。你写汇编时,脑子里要有这张图。哪个寄存器该谁保存,哪个寄存器有什么特殊用途,一目了然。
我的习惯:在代码开头用注释写明每个寄存器的用途。比如:// X19: loop counter。这样三个月后回来看代码,不用重新猜。