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)。

核心规则就几条:

  1. 参数传递:X0-X7传前8个参数,多余的压栈
  2. 返回值:X0返回整数/指针,X8返回大结构体地址
  3. 调用者保存:X0-X15(调用者自己备份)
  4. 被调用者保存:X19-X29(被调用者负责恢复)
  5. 栈对齐:SP必须16字节对齐
  6. 帧指针: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寄存器体系结构图 ARM64寄存器体系总览 通用寄存器 X0-X30 X0-X7 : 参数/返回值(调用者保存) X8 : 间接结果寄存器 X9-X15 : 临时寄存器(调用者保存) X16-X17: IP0/IP1 过程内调用暂存 X18 : 平台保留(TLS基址) X19-X28: 被调用者保存 X29(FP): 帧指针 / X30(LR): 链接寄存器 特殊寄存器 SP : 栈指针(必须16字节对齐) PC : 程序计数器(只读) NZCV: 条件标志位 SCTLR_EL1: 系统控制 TCR_EL1: 翻译控制 TTBR0/1_EL1: 页表基址 VBAR_EL1: 异常向量表 ABI核心规则(AAPCS64) ① X0-X7传参,X0返回整数/指针 ② 调用者保存X0-X15 ③ 被调用者保存X19-X29 ④ SP必须16字节对齐 ⑤ X29(FP)指向栈帧底部 ⑥ X30(LR)存返回地址 典型栈帧结构 高地址 ← [调用者栈帧] [保存的X29(FP)] ← X29指向这里 低地址 ← [保存的X30(LR)] ← SP指向这里

这张图把ARM64寄存器体系分成了三大块:通用寄存器、特殊寄存器、ABI规则。你写汇编时,脑子里要有这张图。哪个寄存器该谁保存,哪个寄存器有什么特殊用途,一目了然。

我的习惯:在代码开头用注释写明每个寄存器的用途。比如:// X19: loop counter。这样三个月后回来看代码,不用重新猜。