第3章 ARM64内存访问与寻址:加载存储指令与栈操作

内存访问,说白了就是CPU和内存之间怎么打交道。ARM64架构里,所有数据操作都得先加载到寄存器,处理完再存回去——这就是所谓的“加载-存储”架构。我刚开始接触ARM64时,总觉得这限制太多,后来做逆向分析才发现,这种设计反而让指令行为变得非常清晰,调试起来特别痛快。

3.1 加载存储指令:LDR与STR

LDR(Load Register)和STR(Store Register)是ARM64里最常用的指令。它们负责在内存和寄存器之间搬运数据。

核心规则:ARM64不允许内存到内存的直接操作。所有运算必须在寄存器中完成。

3.1.1 LDR指令

LDR从内存地址加载数据到寄存器。格式很简单:

LDR X0, [X1]      // 从X1指向的地址加载8字节到X0
LDR W0, [X1]      // 从X1指向的地址加载4字节到W0
LDRB W0, [X1]     // 加载1字节
LDRH W0, [X1]     // 加载2字节(半字)
LDRSW X0, [X1]    // 加载4字节并符号扩展到64位

我在逆向一个iOS应用时,遇到过一段代码反复用LDRB从字符串指针逐字节读取,配合循环做校验。当时我盯着反汇编看了半天才反应过来——原来是在做字符串比较。嗯,这种逐字节操作在逆向分析中很常见,你得习惯。

3.1.2 STR指令

STR把寄存器数据存到内存地址:

STR X0, [X1]      // 把X0的8字节存到X1指向的地址
STR W0, [X1]      // 存4字节
STRB W0, [X1]     // 存1字节
STRH W0, [X1]     // 存2字节

个人习惯:我写调试脚本时,经常用LDR/STR配合断点来监控某个内存地址的变化。比如在可疑地址设个写断点,STR指令触发时就能抓到是谁在改数据。

3.2 寻址模式:三种核心方式

ARM64的寻址模式比x86简洁得多。我个人把它归纳为三种:基址偏移、前变址、后变址。你想想看,这三种模式基本覆盖了所有内存访问场景。

寻址模式 语法示例 地址计算 典型用途
基址偏移 LDR X0, [X1, #8] X1 + 8 结构体成员访问
基址偏移(寄存器) LDR X0, [X1, X2] X1 + X2 数组索引
前变址 LDR X0, [X1, #8]! X1 = X1 + 8; 再访问 栈指针调整+访问
后变址 LDR X0, [X1], #8 先访问X1; 再X1 = X1 + 8 遍历数组后自增

3.2.1 基址偏移模式

这是最常用的模式。基址寄存器加上一个偏移量,得到最终地址。偏移量可以是立即数,也可以是另一个寄存器。

// 立即数偏移
LDR X0, [X29, #24]     // 从栈帧偏移24字节处加载
STR X1, [SP, #-16]     // 向SP-16处存储(分配栈空间)

// 寄存器偏移
LDR X0, [X1, X2]       // X1 + X2
LDR X0, [X1, X2, LSL #3] // X1 + (X2 << 3),常用于数组

我在分析一个游戏外挂时,看到大量LDR X0, [X1, X2, LSL #3]这种指令。当时就判断出X1是数组基址,X2是索引,LSL #3是因为元素是8字节。这种模式在逆向中一抓一个准。

3.2.2 前变址模式

前变址会先修改基址寄存器,再用新地址访问。注意那个感叹号——它表示“写回”。

LDR X0, [SP, #-16]!    // SP = SP - 16; 然后从新SP加载
STR X0, [SP, #16]!     // SP = SP + 16; 然后存储

前变址常用于函数入口和出口的栈帧操作。你想想看,进入函数时先减SP再存寄存器,退出时先加SP再恢复——这就是栈帧的建立和销毁。

3.2.3 后变址模式

后变址先访问当前地址,再修改基址寄存器。没有感叹号,偏移量写在后面。

LDR X0, [X1], #8      // 从X1加载; 然后X1 = X1 + 8
STR X0, [X1], #8      // 存到X1; 然后X1 = X1 + 8

后变址在遍历数组时特别方便。我记得有一次调试一个网络协议解析器,代码里全是LDR后变址——每次加载4字节,地址自动加4,循环处理整个数据包。简洁,高效。

我曾经踩过的坑:前变址和后变址都会修改基址寄存器。如果你在循环中不小心用了前变址,基址寄存器被改了,下次循环地址就全乱了。调试时这种bug特别隐蔽,因为单步执行看不出问题,跑起来就崩。

3.3 栈操作:STP与LDP

STP(Store Pair)和LDP(Load Pair)是ARM64的特色指令。它们一次操作两个寄存器,效率翻倍。

3.3.1 STP指令

STP X29, X30, [SP, #-16]!   // 把X29和X30压栈,SP减16
STP X0, X1, [X2]             // 把X0存到X2,X1存到X2+8

STP的典型用途是保存调用者保存的寄存器对。函数入口处,通常用STP保存FP(X29)和LR(X30)。

3.3.2 LDP指令

LDP X29, X30, [SP], #16     // 从栈恢复X29和X30,SP加16
LDP X0, X1, [X2]             // 从X2加载到X0,从X2+8加载到X1

LDP是STP的逆操作。函数返回前,用LDP恢复寄存器对。

栈帧标准模式:

// 函数入口
STP X29, X30, [SP, #-32]!   // 保存FP/LR,分配32字节栈空间
MOV X29, SP                  // 设置帧指针

// 函数体...
// ...

// 函数出口
LDP X29, X30, [SP], #32     // 恢复FP/LR,回收栈空间
RET                          // 返回

我在逆向分析中,只要看到STP X29, X30, [SP, #-N]!这种模式,基本就能确定函数边界。这是ARM64编译器的标准行为,GCC和LLVM都这么干。

3.4 内存屏障指令

内存屏障,说白了就是给CPU的内存访问顺序加个“围栏”。多核环境下,CPU会乱序执行指令,内存访问也可能被重排。没有屏障,你永远不知道数据什么时候真正写到了内存。

指令 全称 作用
DMB Data Memory Barrier 保证屏障前的内存访问在屏障后之前完成
DSB Data Synchronization Barrier 等待所有内存访问完成,还会阻塞后续指令
ISB Instruction Synchronization Barrier 清空流水线,确保后续指令从内存重新读取

3.4.1 DMB指令

DMB SY      // 全系统数据内存屏障
DMB ISH     // 内部可共享域(多核间)
DMB NSH     // 非共享域(单核内)
DMB OSH     // 外部可共享域(多处理器间)

DMB保证内存访问的顺序。比如你写了一个标志位,然后DMB,再写数据——其他核看到标志位时,数据一定已经写好了。

3.4.2 DSB指令

DSB SY      // 全系统同步屏障

DSB比DMB更严格。它不仅保证顺序,还等待所有内存操作完成,然后才执行下一条指令。我一般在操作外设寄存器后加DSB,确保写操作真的到达了硬件。

3.4.3 ISB指令

ISB         // 指令同步屏障

ISB清空CPU流水线,强制重新取指。修改了代码段或者切换了页表后,必须加ISB。否则CPU可能还在执行旧指令。

我曾经踩过的坑:在调试一个自修改代码(SMC)的逆向样本时,代码改了自身指令后没加ISB,结果CPU继续执行旧指令,整个逻辑全乱了。后来我花了整整一天才定位到问题——就是少了一条ISB。从那以后,我只要看到代码段被修改,第一反应就是检查有没有ISB。

3.5 知识结构总览

下面这张图把本章的核心知识点串起来了。你可以把它当作一个快速参考。

ARM64内存访问与寻址知识结构 ARM64内存访问与寻址知识结构 内存访问与寻址 加载存储指令 寻址模式 栈操作 LDR STR 基址偏移 前变址 后变址 STP LDP 内存屏障指令 DMB DSB ISB 核心要点 • LDR/STR是ARM64唯一访问内存的方式,所有运算必须在寄存器中完成 • 前变址/后变址会修改基址寄存器,调试时注意寄存器值的变化 • 多核环境下必须使用内存屏障保证数据一致性

3.6 实战建议

学完这些指令,我建议你动手做两件事:

  1. 反汇编练习:随便找个ARM64的二进制文件,用objdump或IDA反汇编,找到函数入口和出口,识别STP/LDP模式。我刚开始就是这么练的,看多了自然就熟了。
  2. 断点调试:在GDB或LLDB中设个内存访问断点,观察LDR/STR触发时的寄存器状态。特别是前变址和后变址,单步执行看基址寄存器怎么变。

我的调试小技巧:在GDB中可以用watch *0x地址监控内存变化。配合continue,每次内存被访问都会停下来,显示是哪条指令干的。逆向分析时,这招比静态分析快多了。

嗯,这一章的内容就到这里。记住,内存访问是ARM64的基石,搞懂了LDR/STR和寻址模式,后面的逆向分析就会顺畅很多。