第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 知识结构总览
下面这张图把本章的核心知识点串起来了。你可以把它当作一个快速参考。
3.6 实战建议
学完这些指令,我建议你动手做两件事:
- 反汇编练习:随便找个ARM64的二进制文件,用objdump或IDA反汇编,找到函数入口和出口,识别STP/LDP模式。我刚开始就是这么练的,看多了自然就熟了。
- 断点调试:在GDB或LLDB中设个内存访问断点,观察LDR/STR触发时的寄存器状态。特别是前变址和后变址,单步执行看基址寄存器怎么变。
我的调试小技巧:在GDB中可以用watch *0x地址监控内存变化。配合continue,每次内存被访问都会停下来,显示是哪条指令干的。逆向分析时,这招比静态分析快多了。
嗯,这一章的内容就到这里。记住,内存访问是ARM64的基石,搞懂了LDR/STR和寻址模式,后面的逆向分析就会顺畅很多。