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) | 链接寄存器 | 被调用者 |
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
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图把本章的核心寄存器关系画出来了。你可以看到参数传递、栈帧构建、返回机制是如何串联的。
- X0–X30 是通用寄存器,但各有分工——参数、临时、保存、特殊用途
- SP 必须16字节对齐,PC 不能直接写,XZR 读写都有特殊行为
- LR (X30) 存返回地址,FP (X29) 建栈帧——两者配合实现函数调用链回溯
- 调试时优先检查 X0(返回值)、X30(返回地址)、SP(栈对齐)