第4章 ARM64寻址模式:从基础到实战
寻址模式,说白了就是CPU怎么找到它要操作的数据。我刚开始做逆向时,经常被各种寻址方式绕晕——明明指令里写的是同一个寄存器,跑出来的结果却天差地别。这一章咱们就把ARM64的五种寻址模式彻底讲透。
核心要点:ARM64的寻址模式决定了数据从哪里来、往哪里去。理解它们,你才能读懂反汇编代码的每一行。
4.1 立即数寻址:数据就在指令里
立即数寻址是最直观的一种——操作数直接编码在指令中。你不需要去内存或寄存器里找,数据就在眼前。
MOV X0, #0x42 // 把立即数0x42加载到X0
ADD X1, X2, #0x10 // X2 + 0x10 → X1
MOVZ X3, #0xABCD, LSL #16 // 带移位加载
嗯,这里要注意:ARM64的立即数不是随便什么值都能用的。它有一个编码规则——必须是可编码立即数。我踩过这个坑:写了个#0x12345678,结果汇编器报错,说这个值无法编码。
我的经验:ARM64的立即数编码采用“8位值+4位移位”的模式。简单说,只有那些能通过循环移位得到的值才是合法的。调试时如果遇到非法立即数错误,先检查这个。
4.2 寄存器寻址:最常用的方式
寄存器寻址,操作数就在寄存器里。这是ARM64中最常见的寻址方式,没有之一。
ADD X0, X1, X2 // X1 + X2 → X0
SUB W3, W4, W5 // W4 - W5 → W3
MUL X6, X7, X8 // X7 * X8 → X6
我个人习惯把寄存器寻址分为三类:
- 直接寄存器寻址:操作数就是寄存器内容,上面例子都是
- 寄存器移位寻址:操作前先对寄存器做移位
- 寄存器扩展寻址:把32位寄存器扩展到64位再操作
// 寄存器移位寻址
ADD X0, X1, X2, LSL #3 // X1 + (X2 << 3) → X0
SUB X3, X4, X5, ASR #2 // X4 - (X5 >> 2) → X3
// 寄存器扩展寻址
ADD X0, X1, W2, UXTW // X1 + 零扩展的W2 → X0
你想想看,为什么需要移位寻址?我在分析一个数组遍历的代码时,看到LSL #3就知道这是在算索引乘以8——因为数组元素是8字节的。这种模式在逆向中非常常见。
4.3 基址偏移寻址:访问内存的基石
基址偏移寻址是ARM64访问内存的主力。它由一个基址寄存器加上一个偏移量组成,偏移量可以是立即数、寄存器或者移位后的寄存器。
// 立即数偏移
LDR X0, [X1, #8] // 从 X1+8 地址加载到 X0
STR W2, [X3, #-4] // 把 W2 存储到 X3-4 地址
// 寄存器偏移
LDR X4, [X5, X6] // 从 X5+X6 地址加载
STR X7, [X8, X9, LSL #3] // 从 X8 + (X9<<3) 地址存储
避坑指南:我曾经在调试一个崩溃问题时,发现LDR X0, [X1, #8]这条指令执行后X1的值没变。很多新手以为基址偏移会修改基址寄存器——不会!它只是临时计算地址,基址寄存器保持不变。要修改基址,得用前变址或后变址。
4.4 前变址与后变址:自动更新基址
这两个模式是基址偏移的“升级版”——它们会在访问内存的同时更新基址寄存器。这在遍历数组、栈操作时特别有用。
| 模式 | 语法 | 执行顺序 | 典型用途 |
|---|---|---|---|
| 前变址 | LDR X0, [X1, #8]! |
先更新X1 += 8,再访问新地址 | 栈帧分配、指针前进 |
| 后变址 | LDR X0, [X1], #8 |
先访问原地址,再更新X1 += 8 | 数组遍历、栈弹出 |
// 前变址:先改指针,再访问
LDR X0, [SP, #-16]! // SP -= 16,然后从新SP加载
// 等价于:SP = SP - 16; X0 = *SP;
// 后变址:先访问,再改指针
LDR X0, [X1], #8 // 从X1加载,然后X1 += 8
// 等价于:X0 = *X1; X1 = X1 + 8;
我记得有一次分析一个递归函数的反汇编代码,看到连续好几个STP X29, X30, [SP, #-32]!——这就是典型的栈帧创建。前变址的!后缀是关键标志,逆向时看到它就要意识到:基址寄存器被修改了。
调试技巧:在GDB中单步执行前变址/后变址指令时,记得同时观察基址寄存器的变化。我习惯用display/8gx $sp和display $sp同时监控栈内容和栈指针。
4.5 PC相对寻址:位置无关代码的核心
PC相对寻址是ARM64里最“聪明”的寻址方式——它使用程序计数器(PC)作为基址,加上一个偏移量来计算目标地址。这意味着代码可以在内存中任意位置运行,不需要重定位。
ADR X0, label // 将label的地址加载到X0(小范围)
ADRP X1, label // 将label所在页的基址加载到X1(大范围)
B label // 跳转到label(PC相对跳转)
BL func // 调用func(PC相对调用)
为什么说它重要?你想想看,现代操作系统都使用ASLR(地址空间随机化),每次加载程序时基址都不一样。如果没有PC相对寻址,所有全局变量和函数调用都得在加载时修正——那效率得多低。
// 实际反汇编中常见的PC相对寻址模式
ADRP X0, .LC0 // 获取字符串常量所在页
ADD X0, X0, :lo12:.LC0 // 加上页内偏移,得到完整地址
BL printf // 调用printf
我在逆向一个位置无关的可执行文件(PIE)时,看到大量ADRP+ADD的组合。这就是ARM64访问全局数据的标准套路——先取页基址,再加页内偏移。
关键区别:PC相对寻址计算的是当前指令地址+偏移,而不是某个固定地址。所以同样的指令,在不同内存位置执行时,访问的目标地址也不同。这正是位置无关代码的精髓。
4.6 寻址模式对比与实战选择
说了这么多,咱们来张图总结一下。下面这张SVG图展示了五种寻址模式的数据流向关系:
在实际逆向中,我总结了一套选择策略:
- 访问常量:用立即数寻址,直接编码在指令里
- 局部变量:用基址偏移寻址,SP/FP加偏移
- 遍历数组:用后变址寻址,访问完自动前进
- 栈操作:用前变址寻址,先分配空间再使用
- 全局变量/函数调用:用PC相对寻址,保证位置无关
常见陷阱:我曾经在分析一个O3优化后的代码时,看到LDR X0, [X0, #8]!——前变址同时修改了源和目标寄存器。这种代码在单步调试时要特别小心,因为X0的值在指令执行前后完全不同。建议在GDB中用si单步,并配合info registers x0观察变化。
4.7 实战:从反汇编代码识别寻址模式
光说不练假把式。咱们来看一段真实的反汇编代码,我标注了每条指令的寻址模式:
// 函数入口:栈帧创建
STP X29, X30, [SP, #-32]! // 前变址:SP-=32,再存储
MOV X29, SP // 寄存器寻址
// 访问局部变量
STR W0, [X29, #28] // 基址偏移:X29+28
LDR W1, [X29, #28] // 基址偏移:X29+28
// 调用子函数
ADRP X0, .LC0 // PC相对寻址:获取页基址
ADD X0, X0, :lo12:.LC0 // PC相对寻址:加页内偏移
BL printf // PC相对寻址:函数调用
// 遍历数组
MOV X2, #0 // 立即数寻址
.L2:
LDR W3, [X1, X2, LSL #2] // 基址偏移+寄存器移位
ADD W4, W4, W3 // 寄存器寻址
ADD X2, X2, #1 // 立即数寻址
CMP X2, X5 // 寄存器寻址
B.LT .L2 // PC相对寻址:条件跳转
// 函数返回
LDP X29, X30, [SP], #32 // 后变址:先加载,SP+=32
RET // 寄存器寻址(隐式使用X30)
你看,一个简单的函数里就包含了所有五种寻址模式。我刚开始学逆向时,就是靠这样逐行标注来训练的。坚持一个月,看到任何反汇编代码都能条件反射地识别出寻址模式。
我的训练方法:找10个不同功能的二进制文件,用objdump反汇编,然后逐行标注寻址模式。标注完再对照ARM手册验证。这个方法虽然笨,但效果极好——肌肉记忆比死记硬背靠谱多了。
好了,这一章的内容就到这里。寻址模式是ARM64汇编的基础中的基础,理解透了,后面的逆向分析才能事半功倍。记住:看到指令先问自己三个问题——数据从哪来?到哪去?基址寄存器变不变?