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 了半天。

立即数合法范围:只有 8 位常数(0-255)通过循环右移偶数位(0,2,4,...,30)能表示的数值才合法。否则需要用 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 时尽量避开除法——用移位和乘法替代。

避坑指南:我曾经在 ARMv5 平台上用 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 就搞定了。

实战技巧:在 Shellcode 里构造特殊地址时,我经常用 MVN 配合 ADD 来生成那些难以直接加载的常数。比如你想得到 0xFFFF8000,可以 MVN R0, #0x7FFF 得到 0xFFFF8000——因为 ~0x7FFF = 0xFFFF8000。

3.4 知识体系总览

下面这张图把本章三类指令的关系和典型应用场景串起来了。你可以把它当作快速参考——写 Shellcode 时忘了哪个指令,瞄一眼就行。

ARM 汇编基础指令体系(上) 数据传输指令 MOV LDR/STR LDM/STM PUSH/POP 算术运算指令 ADD/SUB RSB MUL/MLA UMULL 逻辑运算指令 AND/ORR EOR/BIC MVN 移位 典型应用场景:地址计算、标志位设置、掩码操作、Shellcode 构造与解码 指令速查表 数据传输 MOV Rd, #imm — 加载立即数 LDR Rd, [Rn] — 从内存加载 STR Rd, [Rn] — 存储到内存 LDM/STM — 批量传输 算术运算 ADD/SUB Rd, Rn, Op2 — 加减 RSB Rd, Rn, Op2 — 反向减 MUL Rd, Rn, Rm — 乘法 MLA Rd, Rn, Rm, Ra — 乘加 逻辑运算 AND/ORR/EOR — 与或异或 BIC Rd, Rn, Op2 — 位清除 MVN Rd, Op2 — 按位取反 LSL/LSR/ASR/ROR — 移位

3.5 避坑与实战建议

写 ARM 汇编 Shellcode 时,有几个坑我踩过,你最好提前知道:

  • 立即数合法性:不是所有常数都能直接 MOV。遇到非法立即数,用 LDR Rd, =const 伪指令——汇编器会帮你拆成 LDR 从文字池加载。
  • 乘法目标寄存器限制:ARMv5 及更早版本,MUL 的目标寄存器不能跟源寄存器相同。写兼容性代码时注意。
  • 回写后缀 !:LDR Rd, [Rn, #offset]! 会修改 Rn。如果你还想保留原地址,别加 !。
  • 条件后缀:所有指令都可以加条件后缀(EQ、NE、GT 等)。这在 Shellcode 里用来实现条件分支,不用跳转——省指令、省空间。
我的习惯:写 Shellcode 时,我优先用 MOV 和 MVN 加载常数,实在不行才用 LDR 从文字池加载。因为文字池在代码段里,容易被溢出覆盖。能省则省,这是 Shellcode 的生存法则。