3. ARM 汇编基础(上):数据传输、算术运算与逻辑运算
ARM 汇编,说白了就是跟芯片底层打交道的语言。写 Shellcode 的时候,你没法指望高级语言帮你打理一切——你得亲手把数据搬来搬去,算地址,做掩码。这一章我们先啃最基础的三类指令:数据传输、算术运算、逻辑运算。别小看它们,我见过不少人在这些基础指令上翻车。
3.1 数据传输指令:搬砖是门手艺
数据传输指令,就是负责在寄存器、内存、立即数之间倒腾数据。ARM 是典型的 Load/Store 架构——只有 LDR 和 STR 能碰内存,其他指令只能在寄存器里玩。
3.1.1 MOV 指令:最直接的赋值
MOV 把源操作数复制到目标寄存器。源可以是另一个寄存器,也可以是立即数。
MOV R0, #0x100 ; R0 = 0x100
MOV R1, R0 ; R1 = R0 (R1 = 0x100)
MOV R2, R1, LSL #2 ; R2 = R1 << 2 (R2 = 0x400)
注意,ARM 的立即数不是随便写的。它得是 8 位常数循环右移偶数位的结果。比如 #0xFF 可以,#0x101 就不行——编译器会报错或者自动拆成多条指令。我刚开始写 ARM 汇编时,就因为这个坑 debug 了半天。
LDR Rd, =const 伪指令加载。
3.1.2 LDR 与 STR:内存访问的命脉
LDR 从内存读数据到寄存器,STR 把寄存器数据写回内存。这是 Shellcode 里最常用的指令——你总得读写栈、读写 payload 缓冲区吧?
LDR R0, [R1] ; R0 = *(uint32_t*)R1
LDR R0, [R1, #4] ; R0 = *(uint32_t*)(R1 + 4)
LDR R0, [R1, R2] ; R0 = *(uint32_t*)(R1 + R2)
STR R0, [R1] ; *(uint32_t*)R1 = R0
STR R0, [R1, #-4]! ; R1 = R1 - 4, then *(uint32_t*)R1 = R0
那个 ! 叫回写后缀——地址计算完后,把新地址写回基址寄存器。我在写栈操作时特别喜欢用这个,一条指令顶两条。
3.1.3 LDM 与 STM:批量传输
当你要一口气搬多个寄存器时,LDM/STM 就派上用场了。它们常用于函数 prologue/epilogue 和上下文切换。
STMIA R0!, {R1-R4, LR} ; 依次存储 R1,R2,R3,R4,LR 到 R0 指向的内存,地址递增
LDMDB R0!, {R1-R4, PC} ; 依次加载到 R1,R2,R3,R4,PC,地址递减
后缀 IA(Increment After)、DB(Decrement Before)控制地址变化方向。我个人习惯用 STMDB 压栈、LDMIA 出栈——跟 ARM 的 ATPCS 调用约定一致。
| 指令 | 含义 | 典型用途 |
|---|---|---|
| MOV | 寄存器/立即数赋值 | 初始化、常量加载 |
| LDR | 从内存加载到寄存器 | 读取变量、payload 数据 |
| STR | 从寄存器存储到内存 | 写变量、保存上下文 |
| LDM | 批量加载 | 函数返回、恢复现场 |
| STM | 批量存储 | 函数入口、保存现场 |
3.2 算术运算指令:Shellcode 里的算数
算术运算在 Shellcode 里主要干两件事:算地址、算长度。你想想看,一个缓冲区地址加上偏移量,不就是 ADD 的事吗?
3.2.1 ADD 与 SUB:加减法
ADD R0, R1, R2 ; R0 = R1 + R2
ADD R0, R1, #0x10 ; R0 = R1 + 16
SUB R0, R1, R2 ; R0 = R1 - R2
SUB R0, R1, #0x20 ; R0 = R1 - 32
ARM 的 ADD/SUB 支持三种操作数形式:寄存器、立即数、移位后的寄存器。最后一种特别有用——比如 ADD R0, R1, R2, LSL #2 相当于 R0 = R1 + R2 * 4,一条指令搞定乘法和加法。
3.2.2 RSB:反向减法
RSB(Reverse Subtract)是 ARM 的特色——它把源操作数和目标操作数反过来减。
RSB R0, R1, #0x100 ; R0 = 0x100 - R1
RSB R0, R1, R2 ; R0 = R2 - R1
为什么要这个?有时候你想算一个数的补数,或者做边界检查。比如判断 R1 是否小于 256,可以用 RSBS R0, R1, #0x100,然后看进位标志——嗯,这里埋个伏笔,后面讲条件执行时会细说。
3.2.3 MUL 与乘除法
ARM 的乘法指令有点特殊——结果寄存器不能跟源寄存器相同(早期 ARM 的限制)。
MUL R0, R1, R2 ; R0 = R1 * R2 (R0 不能等于 R1 或 R2)
MLA R0, R1, R2, R3 ; R0 = R1 * R2 + R3 (乘加)
UMULL R0, R1, R2, R3 ; R0 = (R2*R3) 低32位, R1 = 高32位 (无符号)
除法?ARM 没有原生除法指令(Cortex-M 除外)。你得自己写循环减法,或者用编译器内置函数。我在写 Shellcode 时尽量避开除法——用移位和乘法替代。
MUL R0, R0, R1,结果汇编器没报错,运行结果全错。查了半天手册才发现——早期 ARM 乘法要求目标寄存器不能是源之一。ARMv6 以后才放宽这个限制。
3.3 逻辑运算指令:位操作的艺术
逻辑运算在 Shellcode 里太常用了——设置标志位、清除某些位、构造特殊值。ARM 的逻辑运算指令跟算术指令一样,支持灵活的第二个操作数。
3.3.1 AND、ORR、EOR、BIC
AND R0, R1, R2 ; R0 = R1 & R2
ORR R0, R1, R2 ; R0 = R1 | R2
EOR R0, R1, R2 ; R0 = R1 ^ R2
BIC R0, R1, R2 ; R0 = R1 & ~R2 (位清除)
BIC 是 ARM 的特色指令——位清除。它把第二个操作数取反后再做 AND。比如你想把 R1 的低 4 位清零:BIC R0, R1, #0xF。一条指令搞定,不用先取反再 AND。
3.3.2 移位指令:LSL、LSR、ASR、ROR
ARM 的移位可以单独用指令,也可以作为其他指令的第二个操作数修饰符。
LSL R0, R1, #2 ; R0 = R1 << 2 (逻辑左移)
LSR R0, R1, #2 ; R0 = R1 >> 2 (逻辑右移)
ASR R0, R1, #2 ; R0 = R1 >> 2 (算术右移,符号位扩展)
ROR R0, R1, #2 ; R0 = R1 循环右移 2 位
作为修饰符使用时,写法更紧凑:
ADD R0, R1, R2, LSL #4 ; R0 = R1 + (R2 << 4)
SUB R0, R1, R2, ASR #2 ; R0 = R1 - (R2 >> 2 带符号扩展)
这种写法在数组索引计算时特别高效——一条指令完成下标乘元素大小再加基址。
3.3.3 MVN:按位取反
MVN 把源操作数按位取反后存入目标寄存器。它跟 MOV 的区别就是多了个取反。
MVN R0, R1 ; R0 = ~R1
MVN R0, #0xFF ; R0 = 0xFFFFFF00
为什么需要 MVN?有时候你想加载一个立即数,但它不合法——比如 #0xFFFFFF00 不能直接用 MOV 加载,但它的取反 #0xFF 是合法的。所以 MVN R0, #0xFF 就搞定了。
MVN 配合 ADD 来生成那些难以直接加载的常数。比如你想得到 0xFFFF8000,可以 MVN R0, #0x7FFF 得到 0xFFFF8000——因为 ~0x7FFF = 0xFFFF8000。
3.4 知识体系总览
下面这张图把本章三类指令的关系和典型应用场景串起来了。你可以把它当作快速参考——写 Shellcode 时忘了哪个指令,瞄一眼就行。
3.5 避坑与实战建议
写 ARM 汇编 Shellcode 时,有几个坑我踩过,你最好提前知道:
- 立即数合法性:不是所有常数都能直接
MOV。遇到非法立即数,用LDR Rd, =const伪指令——汇编器会帮你拆成LDR从文字池加载。 - 乘法目标寄存器限制:ARMv5 及更早版本,
MUL的目标寄存器不能跟源寄存器相同。写兼容性代码时注意。 - 回写后缀 !:
LDR Rd, [Rn, #offset]!会修改 Rn。如果你还想保留原地址,别加!。 - 条件后缀:所有指令都可以加条件后缀(EQ、NE、GT 等)。这在 Shellcode 里用来实现条件分支,不用跳转——省指令、省空间。
MOV 和 MVN 加载常数,实在不行才用 LDR 从文字池加载。因为文字池在代码段里,容易被溢出覆盖。能省则省,这是 Shellcode 的生存法则。