3. ARM64指令编码与反汇编原理
ARM64指令集,说白了就是一台精简计算机的"操作手册"。你写的每一行C代码,最终都会变成这些指令。我做了这么多年逆向,可以负责任地告诉你——指令集基础打不牢,后面分析崩溃日志、定位故障点,基本就是盲人摸象。
这一章我们聚焦三类最常用的指令:数据处理、内存访问、分支跳转。掌握了它们,你就能读懂90%的反汇编代码。
核心观点:ARM64是固定长度指令(4字节/条),所有指令都是32位编码。这意味着每条指令在内存中对齐到4字节边界。调试时看到PC值不是4的倍数?那一定是出了问题。
3.1 数据处理指令:加减与搬运
数据处理指令不访问内存,只在寄存器之间做运算。这是最基础的一类,也是反汇编里出现频率最高的。
3.1.1 ADD / SUB:加减运算
格式很简单:ADD Xd, Xn, Xm 表示 Xd = Xn + Xm。SUB同理。
// C源码
int a = 10, b = 20;
int c = a + b;
// 反汇编后(常见形式)
ADD W0, W1, W2 // W0 = W1 + W2
这里有个细节——操作数是W寄存器还是X寄存器,决定了是32位还是64位运算。我个人习惯在分析时先看寄存器宽度,这能快速判断函数处理的是int还是long。
实战技巧:ADD指令还可以带移位:ADD X0, X1, X2, LSL #3 表示 X0 = X1 + (X2 << 3)。这在访问数组时很常见——编译器用移位代替乘法,效率更高。
3.1.2 MOV:数据搬运
MOV是伪指令,实际会被汇编器翻译成其他指令。最常见的是 MOV X0, X1 被翻译成 ORR X0, X1, XZR(XZR是零寄存器)。
MOV X0, #0x100 // 加载立即数
// 实际可能变成:
MOVZ X0, #0x100 // 移动并清零其他位
嗯,这里要注意:ARM64没有直接的"MOV 大立即数"指令。如果立即数超过16位,编译器会用多条指令拼接。我在逆向一个加密算法时,看到一串MOVZ+MOVK的组合,就知道它在构造一个64位常量密钥。
3.1.3 CMP:比较指令
CMP本质上是SUBS,只是丢弃了结果,只更新标志位。反汇编里经常看到:
CMP W0, #10 // 比较W0是否等于10
B.GT label // 如果大于10,跳转
避坑指南:我曾经在调试一个死循环问题时,发现CMP后面跟的是B.NE,但标志位状态和预期相反。后来才意识到——CMP是"目标减源操作数",不是"源减目标"。搞反了条件码,调试一整天都是白费。
3.2 内存访问指令:数据的搬运工
ARM64是加载/存储架构——数据必须先从内存加载到寄存器,才能参与运算。运算完再存回去。没有"直接内存加减"这种操作。
3.2.1 LDR / STR:单寄存器加载/存储
这是最常用的内存指令。格式:LDR Xt, [Xn, #offset]。
// 加载一个64位值
LDR X0, [X1, #8] // X0 = *(X1 + 8)
// 存储一个32位值
STR W2, [SP, #4] // *(SP + 4) = W2
你想想看,为什么要有偏移量?因为结构体成员访问、数组索引、栈上局部变量,都需要通过"基址+偏移"来定位。我分析过的一个网络协议栈,里面全是这种模式:
LDR W0, [X19, #0x10] // 加载结构体第0x10偏移处的字段
ADD W0, W0, #1 // 字段值加1
STR W0, [X19, #0x10] // 存回去
3.2.2 LDP / STP:双寄存器加载/存储
这是ARM64的特色指令——一次加载或存储两个寄存器。效率高,代码密度好。
LDP X0, X1, [SP] // X0 = SP[0], X1 = SP[1]
STP X29, X30, [SP, #-16]! // 压栈:先减SP,再存X29和X30
注意那个"!"——这是预索引模式,表示先更新基址寄存器,再执行访问。函数开头几乎必有这条指令,用于保存帧指针和返回地址。
| 指令 | 操作 | 典型用途 |
|---|---|---|
| LDR Xt, [Xn] | 从Xn指向的地址加载Xt | 读取全局变量、堆内存 |
| STR Xt, [Xn] | 将Xt存储到Xn指向的地址 | 写入变量、修改内存 |
| LDP Xt1, Xt2, [Xn] | 从Xn加载两个寄存器 | 函数开头保存/恢复现场 |
| STP Xt1, Xt2, [Xn, #imm]! | 预索引存储两个寄存器 | 栈帧分配、压栈操作 |
| LDRB Wt, [Xn] | 加载一个字节(零扩展) | 处理字符串、字节数组 |
关键点:所有内存访问的地址必须对齐到访问宽度。LDR要求8字节对齐,LDRW要求4字节对齐。不对齐的访问会触发异常。我在调试一个嵌入式设备时,遇到过因为结构体成员未对齐导致总线错误——排查了整整两天才发现是编译器打包选项的问题。
3.3 分支指令:控制流的灵魂
没有分支指令,程序就只能顺序执行。分支指令改变了PC的值,实现了条件判断、函数调用、循环跳转。
3.3.1 B / B.cond:无条件/条件跳转
B label 是无条件跳转,B.EQ label 是条件跳转。条件码有十几种:EQ、NE、GT、LT、GE、LE、CS、CC等。
CMP W0, #0
B.EQ zero_case // 如果W0==0,跳转到zero_case
B.GT positive_case // 如果W0>0,跳转到positive_case
// 否则继续执行
B指令的跳转范围是±128MB,因为指令编码中只用了26位作为偏移。为什么是26位?因为指令是4字节对齐,所以偏移量是"以4为单位"的,实际寻址范围扩大了4倍。
3.3.2 BL:带链接的跳转(函数调用)
BL和B的区别在于——BL会把返回地址(当前PC+4)保存到X30寄存器(LR)。这样函数执行完,用RET指令就能回来。
BL func_a // 调用func_a,返回地址存入X30
// 执行完func_a后回到这里继续
func_a:
// 函数体
RET // 返回,跳转到X30指向的地址
我刚开始做逆向时,经常在反汇编列表里看到一堆BL指令,但不知道哪个是标准库调用、哪个是自定义函数。后来总结了一个规律——BL的目标地址如果在当前模块内,一般是自定义函数;如果跳到模块外(比如动态库),通常是外部函数调用。
3.3.3 BR / BLR:寄存器间接跳转
BR和BLR不直接编码目标地址,而是从寄存器中读取。这用于函数指针、虚函数调用、动态分发等场景。
// 函数指针调用
LDR X0, [X19, #0x20] // 从结构体加载函数指针
BLR X0 // 调用该函数
// 返回指令
RET // 本质上是 BR X30
调试技巧:当你看到BLR X0时,想知道它调用了哪个函数?在调试器中打断点,查看X0的值。如果X0指向的地址在代码段内,那就是一个已知函数;如果在堆上,那可能是JIT生成的代码——这种情况要特别小心,可能是恶意软件在动态执行。
3.3.4 RET:函数返回
RET就是 BR X30 的别名。它跳转到X30寄存器保存的地址。为什么不用B?因为B不能动态指定目标地址。
有一个常见陷阱:如果函数内部又调用了其他函数,X30会被覆盖。所以函数开头通常会用STP保存X30:
func_b:
STP X29, X30, [SP, #-16]! // 保存帧指针和返回地址
// ... 函数体,可能包含其他BL调用 ...
LDP X29, X30, [SP], #16 // 恢复
RET // 返回
避坑指南:我曾经分析一个崩溃问题,发现RET时X30的值指向了无效地址。原因是函数内部调用了BL,但没有保存X30。嵌套调用把返回地址覆盖了。记住——任何包含BL调用的函数,都必须先保存X30到栈上。这是ARM64调用约定的硬性要求。
3.4 指令编码速查表
下面这个表是我自己整理的,调试时贴在旁边很方便。你不需要背编码,但要知道怎么看。
| 指令 | 编码格式(31-24位) | 说明 |
|---|---|---|
| ADD | 0x0B | 寄存器加法,可带移位 |
| SUB | 0x4B | 寄存器减法 |
| MOV | 伪指令 | 实际翻译为ORR或MOVZ |
| LDR | 0xF9 | 64位加载,立即数偏移 |
| STR | 0xF8 | 64位存储,立即数偏移 |
| LDP | 0xA9 | 双寄存器加载 |
| STP | 0xA8 | 双寄存器存储 |
| B | 0x14 | 无条件跳转,偏移量编码 |
| BL | 0x94 | 带链接跳转,保存返回地址 |
| RET | 0xD6 | 返回,BR X30的别名 |
3.5 本章小结
数据处理指令负责运算,内存访问指令负责数据搬运,分支指令负责控制流。这三类指令覆盖了ARM64程序的全部行为。你拿到一份反汇编代码,先看函数开头有没有STP(保存现场),再看中间有没有BL(函数调用),最后看结尾的RET(返回)。这个流程走一遍,函数的大致逻辑就清楚了。