3、ARM64指令集入门:数据处理指令与立即数编码
各位好,欢迎来到第三章。前两章我们把ARM64的寄存器体系和内存模型理清了。从这章开始,咱们真正上手——学指令。
ARM64的指令集,说多不多,说少不少。但核心的数据处理指令,其实就那么几类。我当年刚转ARM64时,看着指令手册上千页,心里也发怵。后来发现,日常反汇编和调优中,80%的代码都在用这章要讲的指令。先把这些吃透,后面就顺了。
本章核心脉络:算术运算 → 逻辑运算 → 移位操作 → 比较指令 → 立即数编码。最后一张SVG图帮你串起整个知识体系。
3.1 算术运算指令:ADD / SUB / MUL
算术指令是CPU最基础的肌肉。ARM64的ADD、SUB、MUL,用法上跟x86有相似之处,但寄存器模型完全不同。
3.1.1 ADD — 加法指令
基本格式:ADD Xd, Xn, Xm。把Xn和Xm相加,结果存入Xd。
// 简单加法
ADD X0, X1, X2 // X0 = X1 + X2
// 加立即数
ADD X0, X1, #42 // X0 = X1 + 42
// 带移位(后面细讲)
ADD X0, X1, X2, LSL #2 // X0 = X1 + (X2 << 2)
我个人习惯,在反汇编时看到ADD指令,第一反应是看第三个操作数是不是立即数。如果是立即数,要留意它的编码范围——这个后面3.5节专门讲。
小技巧:ADD指令的第三个操作数可以是寄存器、立即数,也可以是「寄存器+移位」的组合。但注意,ARM64里没有x86那种「ADD mem, reg」的格式——内存操作必须通过LOAD/STORE指令。
3.1.2 SUB — 减法指令
格式跟ADD完全对称:SUB Xd, Xn, Xm。
SUB X0, X1, X2 // X0 = X1 - X2
SUB X0, X1, #100 // X0 = X1 - 100
SUBS X0, X1, X2 // 带标志位更新的减法
这里有个关键点:SUBS(带S后缀)会更新NZCV标志寄存器。普通SUB不更新。我曾在调试一个循环边界条件时,发现标志位总不对,查了半天才发现用的是SUB而不是SUBS——嗯,这种坑踩过一次就记住了。
3.1.3 MUL — 乘法指令
乘法稍微特殊一点:MUL Xd, Xn, Xm。结果只保留低64位。
MUL X0, X1, X2 // X0 = X1 * X2 (低64位)
// 乘加指令(非常实用)
MADD X0, X1, X2, X3 // X0 = X1 * X2 + X3
MADD是我个人非常喜欢的一条指令。很多算法里都有「乘积累加」的操作,一条MADD搞定,比先MUL再ADD省一条指令,还少一次寄存器写回。
| 指令 | 格式 | 说明 |
|---|---|---|
| ADD | ADD Xd, Xn, Xm |
Xn + Xm → Xd |
| SUB | SUB Xd, Xn, Xm |
Xn - Xm → Xd |
| MUL | MUL Xd, Xn, Xm |
Xn * Xm (低64位) → Xd |
| MADD | MADD Xd, Xn, Xm, Xa |
Xn * Xm + Xa → Xd |
3.2 逻辑运算指令:AND / ORR / EOR
逻辑指令在底层编程中出场率极高。位掩码、标志位操作、权限检查,都离不开它们。
3.2.1 AND — 按位与
AND X0, X1, X2 // X0 = X1 & X2
AND X0, X1, #0xFF // X0 = X1 & 0xFF (取低8位)
AND最常见的用途是位掩码提取。比如你想取一个寄存器的低8位,用AND X0, X1, #0xFF就行。我在做网络协议解析时,经常用AND来提取包头里的标志位字段。
3.2.2 ORR — 按位或
ORR X0, X1, X2 // X0 = X1 | X2
ORR X0, X1, #0x3 // X0 = X1 | 0x3 (置低2位为1)
ORR用来置位。你想把某个寄存器的第3位和第5位置1,用ORR X0, X0, #(1<<3 | 1<<5)就行。
3.2.3 EOR — 按位异或
EOR X0, X1, X2 // X0 = X1 ^ X2
EOR X0, X1, #-1 // X0 = ~X1 (按位取反)
EOR有个经典用法:寄存器清零。EOR X0, X0, X0 比 MOV X0, #0 在某些微架构上更快。为什么?因为EOR不依赖立即数生成器,硬件上更简单。
注意:ARM64里没有专门的NOT指令。按位取反用 EOR Xd, Xn, #-1 实现。或者用 MVN Xd, Xn(按位取反移动)。我个人更习惯用MVN,语义更清晰。
3.3 移位指令:LSL / LSR / ASR
移位操作在ARM64里不是独立的指令,而是作为操作数修饰符出现的。你可以在ADD、SUB、AND等指令的第三个操作数后面加移位。
3.3.1 LSL — 逻辑左移
左移n位,低位补0。等价于乘以2ⁿ。
ADD X0, X1, X2, LSL #3 // X0 = X1 + (X2 << 3)
LSL X0, X1, #4 // X0 = X1 << 4 (独立移位指令)
3.3.2 LSR — 逻辑右移
右移n位,高位补0。等价于无符号除以2ⁿ。
AND X0, X1, X2, LSR #2 // X0 = X1 & (X2 >> 2)
LSR X0, X1, #8 // X0 = X1 >> 8 (高位补0)
3.3.3 ASR — 算术右移
右移n位,高位补符号位。用于有符号数的除法。
ASR X0, X1, #3 // X0 = X1 >> 3 (高位补符号位)
| 移位类型 | 高位补什么 | 等价操作 | 适用场景 |
|---|---|---|---|
| LSL | 补0 | 乘以2ⁿ | 无符号/有符号左移 |
| LSR | 补0 | 无符号除以2ⁿ | 无符号右移 |
| ASR | 补符号位 | 有符号除以2ⁿ | 有符号右移 |
经验之谈:反汇编时看到 LSL #3 或 LSR #2,基本可以断定是在做乘以8或除以4的优化。编译器特别喜欢用移位代替乘除法——因为移位只要1个周期,乘法要3-4个周期,除法更慢。
3.4 比较指令:CMP / CMN
比较指令本质上是减法,但不保存结果,只更新标志位。
3.4.1 CMP — 比较
CMP Xn, Xm 等价于 SUBS XZR, Xn, Xm。用Xn减Xm,结果丢弃,只更新NZCV。
CMP X0, X1 // 比较X0和X1
CMP X0, #100 // 比较X0和100
比较之后,通常跟着条件分支指令(B.EQ、B.NE、B.LT等)。
3.4.2 CMN — 负数比较
CMN Xn, Xm 等价于 ADDS XZR, Xn, Xm。用Xn加Xm,结果丢弃,只更新NZCV。
CMN X0, X1 // 比较X0和 -X1 (等价于 CMP X0, -X1)
CMN用得不多,但当你需要判断「两个数相加是否为零」时,它比CMP更高效——因为CMP做减法,CMN做加法,硬件路径不同。
关键区别:CMP做减法,CMN做加法。CMP X0, X1 判断 X0 > X1?CMN X0, X1 判断 X0 + X1 == 0?
3.5 立即数编码规则
这是ARM64里最容易让人困惑的地方。为什么MOV X0, #0x12345678 会报错?为什么有些立即数能用,有些不行?
ARM64的立即数编码,说白了就是:不是所有32位或64位数都能直接编码。它有一套特殊的编码规则。
3.5.1 立即数编码格式
ARM64的算术指令(ADD、SUB、AND等)中的立即数,占12位。但这12位不是直接表示数值,而是编码成:
- 4位移位量(0-15,表示右移位数,每次移2位)
- 8位核心值(0-255)
最终数值 = 核心值 循环右移 (移位量 × 2) 位。
举个例子:
// 立即数 0xFF
// 核心值 = 0xFF, 移位量 = 0
// 结果 = 0xFF 循环右移 0 位 = 0xFF
// 立即数 0xFC000000
// 核心值 = 0xFF, 移位量 = 6
// 结果 = 0xFF 循环右移 12 位 = 0xFC000000
3.5.2 哪些立即数能用?
简单判断:
- 0-255(0x00-0xFF):都能用
- 256-65535:大部分不能用,除非符合循环右移模式
- 大于65535:极少数能用
我当年在写一个驱动时,想用 ADD X0, X1, #0x10000,结果汇编器报错。查了半天手册才明白——0x10000不符合编码规则。最后改成 ADD X0, X1, #0x1, LSL #16 才通过。
避坑指南:如果你需要加载一个无法直接编码的立即数,用以下方法:
- 小数值(0-65535):用
MOV Xd, #imm(MOV有专门的立即数编码,范围更大) - 大数值:用
MOVZ+MOVK组合,或者从内存加载 - 或者用
LDR Xd, =imm让汇编器帮你处理
3.6 知识体系总览
下面这张SVG图,把本章所有指令类型和它们的关系串起来了。建议你仔细看看,理解每类指令在什么场景下用。
3.7 实战要点总结
最后,我把自己在实际项目中积累的几个要点列出来,希望能帮你少走弯路:
- 能用移位就别用乘除:编译器会自动优化,但手写汇编时也要有这个意识。
- 注意SUBS和SUB的区别:需要更新标志位时用SUBS,否则用SUB。用错了,条件分支就全乱了。
- 立即数编码是ARM64的「特色」:写代码时如果遇到「invalid operand」错误,先检查立即数是否符合编码规则。
- EOR X0, X0, X0 比 MOV X0, #0 快:在性能敏感的代码段里,用EOR清零。
- MADD是性能利器:乘积累加场景,一条MADD顶两条指令。
推荐练习:打开一个编译好的ARM64二进制文件(比如Android的libc.so),用objdump反汇编,找找看里面用了哪些数据处理指令。我当年就是这么学的——看真实代码比看手册管用多了。