4、ARM64寄存器详解:通用寄存器(X0-X30)、栈指针寄存器(SP)、程序计数器(PC)、零寄存器(XZR)、链接寄存器(LR/X30)、帧指针寄存器(FP/X29)

ARM64的寄存器体系,说白了就是CPU干活时用的“临时工作台”。你写的C代码、反汇编出来的指令,最终都是在这31个通用寄存器上倒腾数据。我刚开始做逆向时,光看这些X0、X30的编号就晕了,后来踩了几个坑才明白——每个寄存器都有它的脾气。

4.1 通用寄存器组:X0–X30

ARM64提供了31个64位通用寄存器,编号从X0到X30。每个寄存器都能存64位数据,但如果你只想用32位,可以用W0到W30来访问低32位。举个例子:

MOV X0, #0x123456789ABCDEF   // 64位操作
MOV W1, #0xDEADBEEF          // 32位操作,高32位自动清零

这里有个容易忽略的细节:写W寄存器会清零高32位。我见过不少新手在反汇编时看到STR W5, [SP],以为只存了32位,结果读回来发现高32位没了——嗯,这就是ARM64的设计哲学,简化硬件,但你得记住它。

4.1.1 参数传递与返回值(X0–X7)

函数调用时,前8个参数依次放入X0到X7。返回值放在X0。这是ARM64的调用约定(AAPCS64)规定的。看个例子:

// C代码:int add(int a, int b, int c) { return a + b + c; }
// 反汇编后:
ADD W0, W0, W1      // a + b
ADD W0, W0, W2      // (a+b) + c
RET

X0既是第一个参数,也是返回值。我个人习惯在调试时重点关注X0的变化——它往往藏着函数调用的“答案”。

4.1.2 被调用者保存寄存器(X19–X28)

这些寄存器比较特殊。函数如果要用它们,必须先保存旧值,返回前再恢复。为什么?因为调用者(caller)指望这些值在函数调用后不变。我曾经在调试一个递归函数时,发现X19的值莫名其妙变了,查了半天才发现是内层函数没保存X19——典型的“被调用者保存”违规。

寄存器 角色 谁负责保存
X0–X7 参数/返回值 调用者(caller)
X8 间接结果寄存器 调用者
X9–X15 临时寄存器 调用者
X16–X17 IP0、IP1(内部过程调用) 调用者
X18 平台寄存器(一般不碰) —
X19–X28 被调用者保存 被调用者(callee)
X29(FP) 帧指针 被调用者
X30(LR) 链接寄存器 被调用者
调试小技巧:在GDB或LLDB中,用info registers x19-x28可以快速查看这些“被调用者保存”寄存器。如果它们的值在函数返回后变了,那八成是bug。

4.2 栈指针寄存器(SP)

SP指向当前栈顶。ARM64里SP必须保持16字节对齐——这是硬性要求。违反对齐的后果?直接触发异常。我记得有一次在嵌入式板子上调试,一个驱动函数里手动修改了SP,结果没对齐,系统直接挂掉。从那以后,我写汇编代码时都会加一句:

AND SP, SP, #~0xF   // 强制16字节对齐

SP不能用于普通的数据运算(比如ADD X0, SP, #8是合法的,但ADD SP, SP, X0不行)。ARM64对SP的使用限制很严格,你想想看,这其实是为了防止栈指针被意外破坏。

4.3 程序计数器(PC)

PC指向当前正在执行的指令地址。在ARM64里,PC不能直接读写——没有MOV PC, X0这种指令。想跳转?用B、BL、RET这些分支指令。为什么这么设计?说白了,是为了保证指令流水线的稳定性。

反汇编时,PC的值经常出现在相对寻址中。比如:

ADR X0, label   // 将 label 的地址(基于PC)加载到X0

这里ADR计算的是当前PC加上偏移量。我在逆向位置无关代码(PIC)时,经常靠PC相对寻址来定位全局变量——这是逆向工程的核心技能之一。

4.4 零寄存器(XZR/WZR)

XZR是个“假”寄存器。读它永远返回0,写它则丢弃结果。有什么用?两个场景:

  • 清零:MOV X0, XZR 等价于 MOV X0, #0
  • 占位:比如 STR X0, [SP, #8] 如果不想存数据,可以写成 STR XZR, [SP, #8]——写了个寂寞,但指令格式合法

我见过有人用SUB X0, XZR, X1来实现取负操作,这比NEG X0, X1更直观。嗯,个人口味问题。

4.5 链接寄存器(LR/X30)

执行BL func时,CPU会自动把返回地址存入X30(LR)。函数返回时,RET指令默认跳转到X30指向的地址。看个典型流程:

main:
    BL  func       // 将下一条指令地址存入X30,然后跳转到func
    // 返回到这里继续执行

func:
    // 函数体
    RET            // 跳转到X30

如果函数内部还要调用其他函数,就必须先保存X30。否则内层BL会覆盖X30,返回时就找不到回家的路了。常见的做法是:

func:
    STP X29, X30, [SP, #-16]!   // 保存FP和LR到栈上
    // ... 函数体,可以安全地调用其他函数 ...
    LDP X29, X30, [SP], #16     // 恢复FP和LR
    RET
避坑指南:我曾经在逆向一个崩溃的固件时,发现栈回溯(backtrace)全是乱码。原因就是某个内联函数没保存X30,导致LR被覆盖。调试这类问题,第一步就是检查X30的值是否合理——如果它指向一个奇怪的地址,那大概率是LR被踩了。

4.6 帧指针寄存器(FP/X29)

FP(X29)指向当前函数的栈帧基址。配合LR,可以构建出完整的调用链。典型的函数序言(prologue)和尾声(epilogue)是这样的:

func:
    STP X29, X30, [SP, #-16]!   // 保存FP和LR,SP -= 16
    MOV X29, SP                  // FP = SP(建立新帧)
    // ... 函数体 ...
    LDP X29, X30, [SP], #16     // 恢复FP和LR,SP += 16
    RET

有了FP,调试器就能通过链式回溯找到每个调用者的地址。我经常在GDB里用bt命令看调用栈——底层就是靠FP和LR串起来的。

不过,编译优化(比如-O2)可能会省略FP。这时候栈回溯就靠DWARF调试信息了。逆向无调试信息的固件时,我习惯手动在反汇编里找STP X29, X30, [SP, #-16]!这个模式——它往往是函数的入口标志。

4.7 寄存器关系全景图

下面这张SVG图把本章的核心寄存器关系画出来了。你可以看到参数传递、栈帧构建、返回机制是如何串联的。

ARM64 核心寄存器关系图 通用寄存器 X0–X30 X0–X7: 参数/返回值 X8: 间接结果 X9–X15: 临时 X16–X17: IP0/IP1 X18: 平台保留 X19–X28: 被调用者保存 X29(FP): 帧指针 X30(LR): 链接寄存器 特殊寄存器 SP: 栈指针 PC: 程序计数器 XZR: 零寄存器 栈帧结构 高地址 调用者栈帧 保存的FP (X29) 保存的LR (X30) 局部变量 低地址 ← SP 参数传递 SP/FP管理 LR → 返回地址 XZR: 恒为0 核心流程说明 1. 函数调用时,参数通过 X0–X7 传入,返回值通过 X0 传出 2. BL 指令自动将返回地址存入 X30 (LR),然后跳转到目标函数 3. 函数序言保存 FP 和 LR 到栈上,建立新栈帧 4. 函数尾声恢复 FP 和 LR,RET 指令跳回 LR 指向的地址 5. SP 始终指向栈顶,PC 指向当前指令,XZR 提供常量 0
本章要点:
  • X0–X30 是通用寄存器,但各有分工——参数、临时、保存、特殊用途
  • SP 必须16字节对齐,PC 不能直接写,XZR 读写都有特殊行为
  • LR (X30) 存返回地址,FP (X29) 建栈帧——两者配合实现函数调用链回溯
  • 调试时优先检查 X0(返回值)、X30(返回地址)、SP(栈对齐)