这篇把 ARM 汇编从「指令长什么样」到「函数怎么调用」串成一条线。目标不是背指令表,而是建立两个和 x86 完全不同的直觉:ARM 是 Load/Store 架构,以及返回地址在 LR 里而不是栈上。这两点决定了 ARM 逆向和 Pwn 的所有后续推演。
主线以 32 位 ARMv7-A 为主,ARM64(AArch64)的差异单独放在最后一节——现在 IoT 固件和 Android so 基本都是 ARM64 了,两套直觉不能混用。
零、先记住这几条
- ARM 是 Load/Store 架构:只有
LDR/STR碰内存,其余指令全在寄存器之间操作。想把内存里的值加 1,必须「加载 → 加 → 存回」三步。 - 指令格式
<opcode>{cond}{S} Rd, Rn, operand2:{S}决定是否更新标志位,{cond}决定这行在什么条件下才执行。 - 加
S后缀才更新标志位:ADD不改标志,ADDS才改。和 x86「算术指令总改标志」相反。 - 几乎每条指令都能条件执行:
MOVEQ、LDREQ、ADDNE,省掉大量跳转。这是 ARM 最独特的特征。 - R0–R3 传参,R4–R11 存局部变量(非易失),R12 临时,R13=SP,R14=LR,R15=PC。
- 返回地址默认在 LR,不在栈上:
BL写 LR,BX LR返回。这是 ARM 溢出利用与 x86 最大的直觉差异。 - CPSR/SPSR 不能直接读写,必须经
MRS读、MSR按域写。 - 堆栈是满递减:
STMFD SP!, {...}/LDMFD SP!, {...},!别漏。
学习路线总览
阶段 1 指令集全景:ARM 与 Thumb 的关系
↓
阶段 2 指令格式与寻址方式:一条指令长什么样
↓
阶段 3 六大类指令:数据处理、跳转、状态寄存器、Load/Store、协处理器、异常
↓
阶段 4 寄存器体系:工作模式、R0–R15、CPSR/SPSR
↓
阶段 5 条件执行与条件码:ARM 最独特的设计
↓
阶段 6 函数调用:LR、SP、栈帧与调用约定
↓
阶段 7 汇编伪指令与混合编程:从写代码到控制链接器
这七层是层层递进的:不懂 Load/Store 就看不懂为什么 ARM 代码里全是 LDR/STR;不懂 LR 就看不懂函数为什么能返回;不懂栈帧就看不懂溢出点在哪。
一、阶段 1:指令集全景——ARM 与 Thumb
1.1 根本差异:RISC vs CISC
| 维度 | ARM(RISC) | x86(CISC) |
|---|---|---|
| 指令集复杂度 | 精简,指令条数少、格式规整 | 复杂,指令丰富、变长编码 |
| 寄存器数量 | 更多通用寄存器(16 个可见) | 相对较少(x86-64 有 16 个) |
| 内存操作 | 只有 LDR/STR 能访问内存 | 大部分指令可直接操作内存 |
| 一条"内存加法" | 至少三条指令(加载 → 加法 → 存储) | 一条指令可能完成 |
| 指令长度 | 固定 32 位(ARM)/ 16 或 32 位(Thumb) | 变长(1–15 字节) |
| 条件执行 | 几乎每条指令都带条件域 | 只有跳转指令有条件 |
最直观的体现:x86 的 MOV 可以直接把数据从内存加载到寄存器,而 ARM 的 MOV 只能在寄存器之间移动数据,访问内存必须走 LDR/STR。
逆向时的体感差异:
- x86 一条
add [rax], 1就能完成的事,ARM 要三条; - 但 ARM 的每条指令几乎都能条件执行,反而省掉了大量跳转。
1.2 ARM 与 Thumb 的长度与定位
| ARM 指令 | Thumb 指令 | |
|---|---|---|
| 长度 | 32 位(4 字节) | 16 位(2 字节),Thumb-2 可混合 32 位 |
| 完整性 | 完整指令集 | ARM 的子集,不完整 |
| 代码密度 | 较低 | 高(1KB 空间可放 64 条 Thumb,ARM 只能放 32 条) |
| 功能限制 | 无 | 部分操作无法完成,需借助 ARM 指令 |
Thumb 不是独立体系,处理器不能只执行 Thumb 而不支持 ARM。部分特定功能(如 PSR 指令、协处理器指令)必须用 ARM 指令实现。
1.3 Thumb-1 与 Thumb-2
- Thumb-1:纯 16 位,不完整,简单操作体积小;
- Thumb-2:对 Thumb-1 的扩展,部分指令使用 32 位。编译器会混合使用两种宽度——能用 16 位完成的用 16 位省空间,需要 32 位的用 32 位保功能。
编译时默认使用 Thumb 以减小体积,-mthumb / -marm 可显式切换。
1.4 如何区分 ARM 与 Thumb
| 方法 | 判据 |
|---|---|
| IDA | Options → General → number of opcode bytes 设为 4,如果 opcode 长度混合出现 2 字节和 4 字节,就是 Thumb;全是 4 字节则是 ARM |
| 指令后缀 | 带 .w 后缀的是 Thumb-2 特有的 32 位指令 |
| 目标地址 | BX/BLX 目标地址是奇数(bit[0]=1)→ 跳转后进 Thumb |
| inline hook | Thumb 下偏移需要 +1(因为 bit[0] 用于状态切换) |
⚠️ Cortex-M 只有 Thumb。 上面说的「处理器可以执行 ARM 指令」适用于 ARMv7-A/R 这类经典核。Cortex-M 系列(M0/M3/M4/M7,绝大多数 MCU 用的就是它)根本不支持 ARM 状态,只有 Thumb/Thumb-2。所以分析 STM32 这类固件时,看到的全部是 Thumb 代码;在 IDA 里选错处理器模式会导致反汇编整个错乱,这是固件逆向最常见的开局失误。
二、阶段 2:指令格式与寻址方式
2.1 ARM 指令的基本格式
一条典型的 ARM 指令形如:
<opcode>{<cond>}{S} <Rd>, <Rn>, <operand2>
| 字段 | 含义 |
|---|---|
opcode | 操作码,如 MOV、ADD、LDR |
{cond} | 可选条件码,决定指令在什么条件下执行(见阶段 5) |
{S} | 可选后缀,决定是否更新 CPSR 中的条件标志位。有 S 才更新,没有则不更新 |
Rd | 目标寄存器 |
Rn | 第一个操作数寄存器(部分指令需要) |
operand2 | 第二个操作数,可以是立即数、寄存器、被移位的寄存器 |
移位操作数是 ARM 的特色:
MOV R1, R0, LSL #3 @ R0 左移 3 位(即乘 8)后送入 R1
ADD R2, R3, R4, LSR #2 @ R2 = R3 + (R4 >> 2)
一条指令同时完成移位和运算,不需要单独的移位指令。这也是 ARM 指令数虽少但表达力不弱的原因。
2.2 常见寻址方式
| 寻址方式 | 示例 | 说明 |
|---|---|---|
| 立即寻址 | MOV R0, #0xFF | 操作数直接写在指令里 |
| 寄存器寻址 | MOV R0, R1 | 操作数在寄存器中 |
| 寄存器间接寻址 | LDR R0, [R1] | 寄存器里存的是地址 |
| 基址加偏移 | LDR R0, [R1, #4] | 地址 = 基址 + 偏移 |
| 基址加变址 | LDR R0, [R1, R2] | 地址 = 基址寄存器 + 索引寄存器 |
| 缩放变址 | LDR R0, [R1, R2, LSL #2] | 地址 = R1 + (R2 « 2),数组下标常用 |
| 相对寻址 | B Label | 跳转指令使用,偏移相对当前 PC |
[] 的含义是内存访问:取寄存器中的值作为地址,再访问该地址处的数据。
2.3 立即数的限制
ARM 的立即数只有 12 位编码空间,实际形式是「8 位数据 + 4 位循环右移」,所以不是所有 32 位常量都能直接写成 #imm:
MOV R0, #0xFF @ OK
MOV R0, #0xFF00 @ OK(8 位值 0xFF 左移 8 位)
MOV R0, #0x12345678 @ 编译报错:无法用 8 位 + 偶数位移表示
处理大常量有两种常见办法:
@ 方法一:用 MOVW / MOVT 拼接(ARMv6T2+)
MOVW R0, #0x5678 @ 低 16 位
MOVT R0, #0x1234 @ 高 16 位
@ 方法二:放进 literal pool,用 PC 相对寻址读
LDR R0, =0x12345678 @ 汇编器自动把常量放到附近的池子里,
@ 编译成 LDR R0, [PC, #offset]
逆向意义:反汇编里频繁出现的
LDR Rx, [PC, #0x...]不是诡异操作,而是在从 literal pool 读常量。用 IDA 或 Ghidra 时留意地址后面跟着的数据,那往往就是被引用的字符串或常量。⚠️ 注意 PC 读值的偏移修正:ARM 状态下读 PC 得到的是「当前指令地址 + 8」,Thumb 状态下是「+ 4」。算错这个偏移就会读错常量。
三、阶段 3:六大类指令
ARM 指令集分为六大类:数据处理、跳转、程序状态寄存器、加载/存储、协处理器、异常产生。
3.1 数据处理指令
只在寄存器之间操作,不直接访问内存。
| 指令 | 含义 | 关键点 |
|---|---|---|
MOV | 数据传送 | 只能寄存器→寄存器,或立即数→寄存器 |
MVN | 取反传送 | 传送前按位取反,MVN R0, #0 → R0 = -1 |
ADD / ADC | 加 / 带进位加 | 操作数 2 可带移位 |
SUB / SBC / RSB | 减 / 带借位减 / 反减 | RSB 是 operand2 - Rn |
CMP / CMN / TST / TEQ | 比较 / 比较取反 / 测试 / 测试等价 | 不保存结果,只更新标志位 |
AND / ORR / EOR / BIC | 与 / 或 / 异或 / 位清零 | BIC 是「按位清零」,逆向里很常见 |
MUL / MLA / UMULL / SMULL | 乘法系列 | 长乘结果占两个寄存器 |
3.2 跳转指令
ARM 跳转指令的寻址范围是前后 32MB(24 位有符号偏移左移 2 位)。
| 指令 | 行为 |
|---|---|
B | 无条件跳转(可加条件码变成 BEQ 等) |
BL | 跳转前把返回地址存入 LR(R14),用于子程序调用 |
BX | 跳转并根据目标地址 bit[0] 切换 ARM/Thumb 状态 |
BLX | 跳转 + 保存返回地址到 LR + 切换状态,用于跨指令集调用 |
BL 和 BX 是理解函数调用的钥匙——下一节会展开。
3.3 程序状态寄存器访问指令
CPSR/SPSR 不能直接当普通寄存器读写,必须通过 MRS / MSR 中转:
| 指令 | 方向 | 用途 |
|---|---|---|
MRS | CPSR/SPSR → 通用寄存器 | 读状态寄存器,用于保存现场或检查标志位 |
MSR | 通用寄存器/立即数 → CPSR/SPSR 的特定域 | 写状态寄存器,恢复现场或修改标志位 |
MSR 的域划分:
| 域 | 覆盖位 | 内容 |
|---|---|---|
f | bit[31:24] | 条件标志位(N/Z/C/V 等) |
s | bit[23:16] | 状态位 |
x | bit[15:8] | 扩展位 |
c | bit[7:0] | 控制位(模式、中断屏蔽、T 位等) |
例如 MSR CPSR_c, R0 只修改控制位域,不影响标志位。
3.4 加载/存储指令
这是 ARM 最核心的一类,因为只有它们能碰内存。
按宽度区分的变体(逆向时靠这个判断数据类型):
| 指令 | 宽度 | 符号 | 典型用途 |
|---|---|---|---|
LDR / STR | 32 位(字) | — | int、指针 |
LDRH / STRH | 16 位(半字) | 无符号 | short |
LDRSH | 16 位 | 有符号 | short |
LDRB / STRB | 8 位(字节) | 无符号 | char、bool |
LDRSB | 8 位 | 有符号 | signed char |
一行「内存值加 1」需要三条指令:
LDR R0, [R1] @ 把 R1 指向的内存值加载到 R0
ADD R0, R0, #1 @ R0 = R0 + 1
STR R0, [R1] @ 把 R0 存回 R1 指向的内存
3.5 三种寻址模式:只差一个 !
@ 偏移寻址(Offset):地址 = R0 + 4,R0 不变
LDR R1, [R0, #4]
@ 前变址(Pre-indexed):先算地址并写回 R0,再加载
LDR R1, [R0, #4]!
@ 后变址(Post-indexed):先用 R0 原值加载,再把 R0+4 写回 R0
LDR R1, [R0], #4
后变址在遍历数组时非常有用:每次访问后自动移动指针,一条指令完成「取值 + 指针递增」:
@ 遍历 10 个 int,累加到 R3
MOV R0, R1 @ R0 = 数组基址
MOV R2, #10 @ 循环计数
MOV R3, #0
loop:
LDR R4, [R0], #4 @ 取一个 int,R0 自动 += 4
ADD R3, R3, R4
SUBS R2, R2, #1 @ 计数减 1 并更新标志位
BNE loop @ R2 != 0 则继续
这段代码把 ARM 的几个特征都用上了:后变址自增、SUBS 更新标志、BNE 条件跳转。
3.6 批量加载/存储:LDM / STM
LDM 和 STM 一次在连续内存和多个寄存器之间搬移数据,是函数保存/恢复现场的核心指令:
LDM R0, {R4, R5} @ 从 R0 指向的内存加载两个 32 位值到 R4、R5
STM R1, {R4, R5} @ 把 R4、R5 的值依次存到 R1 指向的内存
变种后缀控制地址增长方向:
| 后缀 | 全称 | 含义 |
|---|---|---|
IA | Increment After | 每次传输后地址增加 |
IB | Increment Before | 每次传输前地址增加 |
DA | Decrement After | 每次传输后地址减少 |
DB | Decrement Before | 每次传输前地址减少 |
实践中 LDM 与 LDMIA 等价(默认就是 IA)。
3.7 堆栈寻址:满递减栈
ARM 的标准栈是满递减(Full Descending):
- 满(Full):SP 指向最后一个已入栈的数据(不是下一个空位);
- 递减(Descending):栈向低地址方向增长。
对应到 LDM/STM 后缀就是 DB/IA,所以有了专用别名:
STMFD SP!, {R1-R7, LR} @ 入栈(= STMDB SP!, {...})
LDMFD SP!, {R1-R7, LR} @ 出栈(= LDMIA SP!, {...})
! 表示写回 SP——没有 !,SP 就不会更新,整个栈立即失衡。这是手写汇编时最容易漏的地方。
现代汇编器还接受
PUSH {R4, LR}/POP {R4, PC}这两个别名,分别等价于STMFD SP!/LDMFD SP!。注意POP {..., PC}是一种特殊的返回方式——出栈时直接把返回地址弹进 PC,等价于BX LR。反汇编里看到POP {R4, PC}就是在返回。
3.8 协处理器与异常产生指令
| 指令 | 用途 |
|---|---|
MCR / MRC | 在通用寄存器和协处理器寄存器之间传输数据(常用于浮点单元) |
LDC / STC | 在内存和协处理器之间传输数据 |
SWI | 软件中断,用于触发系统调用(Linux 上 SWI 0 即 syscall) |
BKPT | 断点中断,用于调试器下断 |
SWP / SWPB | 原子地交换寄存器和内存的值(多线程同步,ARMv8 已废弃) |
四、阶段 4:寄存器体系
4.1 工作模式:为什么寄存器"不是同一组"
ARM 有 7 种工作模式,不同模式下可访问的寄存器集合不同:
| 模式 | 类型 | 用途 |
|---|---|---|
| USR | 用户模式 | 普通程序执行 |
| SVC | 特权 / 异常 | 操作系统内核(软中断、复位) |
| IRQ | 异常 | 普通中断 |
| FIQ | 异常 | 快速中断 |
| ABT | 异常 | 内存访问中止 |
| UND | 异常 | 未定义指令 |
| SYS | 特权 | 系统任务(与 USR 共享寄存器,但可访问特权资源) |
关键点:
- USR 是唯一非特权模式,其余 6 种都是特权模式;
- 除去 USR 和 SYS 的 5 种是异常模式(SVC、IRQ、FIQ、ABT、UND);
- 每种异常模式都有自己的 banked 寄存器(私有 SP、LR、SPSR),所以切换模式后「同名的 R13」指向不同物理寄存器。
这就是为什么说寄存器"不是同一组"——USR 模式的 SP 和 SVC 模式的 SP 是两个东西。中断发生时硬件自动切到 IRQ 模式的 banked SP/LR,所以中断处理代码能立刻用栈而不破坏用户态的栈。
4.2 R0–R15 的分工(AAPCS 约定)
这是整条路线中最需要背下来的一张表:
| 寄存器 | 别名 | 角色 | 调用时是否需保存 |
|---|---|---|---|
| R0–R3 | A1–A4 | 参数 / 返回值(前 4 个参数) | 否(易失) |
| R4–R11 | V1–V8 | 通用变量 | 是(非易失) |
| R12 | IP | 临时过渡(intra-procedure-call scratch) | 否(易失) |
| R13 | SP | 栈指针,指向满递减栈 | 是 |
| R14 | LR | 链接寄存器,保存返回地址 | 是(若非叶子函数) |
| R15 | PC | 程序计数器 | — |
几条实践结论:
- 超过 4 个参数时,多出的参数放在栈上,所以带 4 个或更少参数的函数效率明显更高;
- R4–R11 是"非易失"的,被调用函数如果要用,必须先用
STMFD保存、返回前恢复。这就是函数序言/尾声里那串寄存器列表的由来; - R12 留给链接器做 trampoline,你的代码里可以随便用,但不要指望它在函数调用后还保留。
备注:老文档里这套约定叫 ATPCS,现在统一叫 AAPCS(ARM Architecture Procedure Call Standard),内容基本一致。
4.3 CPSR 与 SPSR:状态寄存器
CPSR(Current Program Status Register)保存:
- 条件标志位:N(Negative)、Z(Zero)、C(Carry)、V(oVerflow)——条件执行和条件跳转依赖它们;
- 中断控制位(I、F 分别屏蔽 IRQ、FIQ);
- 当前处理器模式(低 5 位);
- 当前工作状态(ARM / Thumb,T 位)等。
SPSR 是 CPSR 的备份(影子寄存器),每种异常模式各有一个。异常发生时,硬件自动把 CPSR 复制到对应异常模式的 SPSR,异常返回时再恢复回去。
一句话理解:CPSR 是"当前状态",SPSR 是"出事前的状态快照"。
和 x86 的 EFLAGS 对比:x86 的标志位混在一堆状态位里,ARM 只把 N/Z/C/V 四个标志拎出来做条件判断,语义更干净。写 shellcode 时,ARM 的条件执行能让逻辑分支完全不依赖跳转。
4.4 加 S 后缀才更新标志位
这是从 x86 转过来最容易搞错的一点:
ADD R0, R1, R2 @ R0 = R1 + R2,不改标志位
ADDS R0, R1, R2 @ R0 = R1 + R2,同时更新 N/Z/C/V
SUBS R0, R1, #1 @ 减 1 并更新标志位(循环计数常用)
CMP R0, R1 @ 本质是 SUBS,只更新标志位,不保存结果
x86 里 ADD 总是改标志位,所以你不能在两条算术指令之间保留上次的比较结果;ARM 里默认不改,必须显式加 S。
逆向意义:看到
ADDS/SUBS/ANDS/MOVS就知道「这行后面大概率跟着条件跳转」,看到不带S的就知道只是纯计算。
4.5 LR 与 PC:函数调用的寄存器基础
- LR(R14):执行
BL(Branch with Link)时,硬件自动把返回地址存入 LR; - PC(R15):指向当前指令。注意是当前,不是下一条。
⚠️ 由于流水线,读 PC 得到的是「当前指令地址 + 8」(ARM 状态)或「+ 4」(Thumb 状态),而不是「下一条指令地址」。这个细节在做 PC 相对寻址(
LDR R0, [PC, #imm],即 literal pool 取值)时至关重要,算错 8 字节就会读错常量。
函数返回时执行 BX LR 即可跳回调用点。BX 同时会用 LR 的 bit[0] 决定返回后进入 ARM 还是 Thumb 状态——所以同一个函数可以被 ARM 和 Thumb 两种代码调用。
五、阶段 5:条件执行——ARM 最独特的设计
5.1 几乎所有指令都可以带条件码
x86 中只有跳转指令能条件执行,而 ARM 中几乎所有的指令都可以,只需在助记符后附加两位条件码后缀。
条件码位于 ARM 指令的最高 4 位(bit[31:28]),决定指令是否执行。
CMP R0, #0
MOVEQ R1, #1 @ 仅当 R0 == 0 时执行
ADDNE R2, R2, #1 @ 仅当 R0 != 0 时执行
LDREQ R3, [R4] @ 仅当 R0 == 0 时才加载
5.2 常用条件码
| 条件码 | 含义 | 标志条件 |
|---|---|---|
| EQ / NE | 相等 / 不等 | Z=1 / Z=0 |
| CS (HS) / CC (LO) | 无符号 ≥ / < | C=1 / C=0 |
| MI / PL | 负数 / 非负 | N=1 / N=0 |
| VS / VC | 溢出 / 未溢出 | V=1 / V=0 |
| HI / LS | 无符号 > / ≤ | C=1 且 Z=0 / C=0 或 Z=1 |
| GE / LT | 有符号 ≥ / < | N=V / N≠V |
| GT / LE | 有符号 > / ≤ | Z=0 且 N=V / Z=1 或 N≠V |
| AL | 无条件(默认) | 总是执行 |
| NV | 从不执行 | (ARMv7 前有特殊用途,现不推荐) |
5.3 条件执行的实际意义
CMP R0, #0 @ 比较 R0 和 0
ADDEQ R1, R1, #1 @ 只有相等时才执行加法
这避免了一次跳转,在现代流水线 CPU 上可以减少分支预测失败的开销。
但 Thumb 只有跳转指令支持条件执行(Thumb-2 部分恢复了这个能力)。所以同一段逻辑,ARM 版本可能是一串 xxxEQ,Thumb 版本就变成了一堆 Bxx 跳转。
逆向意义:看到一长串没有跳转的
xxxEQ/xxxNE指令,不要以为是顺序执行的死代码——它们是条件块。这是 ARM 反汇编里最常见的误读来源。
六、阶段 6:函数调用在 ARM 上是怎么发生的
6.1 典型调用流程
; 调用者
MOV R0, #arg1 @ 第 1 个参数
MOV R1, #arg2 @ 第 2 个参数
BL func @ 跳转并把返回地址存入 LR
; 被调用者
func:
STMFD SP!, {R4-R11, LR} @ 保存现场(非易失寄存器 + LR)
... @ 函数体
LDMFD SP!, {R4-R11, LR} @ 恢复现场
BX LR @ 返回调用者
逐条拆解栈的变化:
① BL func → LR = 下一条指令地址,PC = func,栈不动
② STMFD SP!, {...} → SP -= N*4,把 R4-R11 和 LR 压到栈上
③ ... 函数体 ...
④ LDMFD SP!, {...} → 从栈上恢复 R4-R11 和 LR,SP += N*4
⑤ BX LR → PC = LR,回到调用点
关键:BL 只写 LR,不碰栈。所以返回地址默认不在栈上——它是在第 ② 步被函数自己用 STMFD 存进去的。
6.2 栈帧布局
高地址
+------------------------+
| 调用者的栈帧 |
+------------------------+
| 参数 5、6、7... | <- 超过 4 个的参数从右往左压栈
+------------------------+
| saved LR | <- STMFD SP!, {..., LR} 压入
| saved R11 |
| ...其他保存的寄存器... |
+------------------------+ <- SP(函数执行中)
| 局部变量 |
+------------------------+
低地址
6.3 与 x86 的关键对比
| 环节 | ARM(32 位) | x86-64 |
|---|---|---|
| 参数传递 | R0–R3,多出的放栈 | RDI、RSI、RDX、RCX、R8、R9,多出的放栈 |
| 返回地址 | LR 寄存器(BL 写入) | 栈(call 压栈) |
| 函数返回 | BX LR 或 POP {..., PC} | ret(pop rip) |
| 栈指针 | SP(R13) | RSP |
| 栈帧指针 | R11(FP,可选) | RBP |
| 标志更新 | 需显式 S 后缀 | 算术指令自动更新 |
| 条件执行 | 几乎每条指令 | 只有跳转 |
这是做 ARM 栈溢出 / ROP 时最需要调整直觉的地方:
- x86 上溢出的第一目标是「覆盖栈上的返回地址」;
- ARM 上返回地址在 LR 里,不动栈根本影响不到它。
6.4 这对溢出利用意味着什么
ARM 上要劫持控制流,必须想办法让返回地址「落到你能写到的地方」。常见路径有三条:
① 利用函数序言把 LR 存到栈上
绝大多数非叶子函数都会执行 STMFD SP!, {..., LR}。一旦 LR 落到栈上,它就变成了和 x86 返回地址一样的东西——溢出栈缓冲区就能覆盖它。
user_func:
STMFD SP!, {R11, LR} @ ← LR 在此刻进入栈
SUB SP, SP, #0x40
...
ADD SP, SP, #0x40
LDMFD SP!, {R11, PC} @ ← 从栈上恢复 PC,即被劫持的返回地址
注意最后一句是 LDMFD SP!, {R11, PC} 而不是 BX LR——很多编译器在尾声直接弹回 PC,这就等于把「栈上的值」直接写给了 PC。
② 直接控制 PC
如果漏洞允许你写任意地址(比如格式化字符串),直接改 PC 更省事。
③ 控制函数指针
结构体里的回调函数指针、GOT 表项等,本质都是「被间接调用」,和返回地址同等重要。
结论:ARM 逆向看到
STMFD SP!, {..., LR}就要提高警惕——这是 LR 落栈的位置,也是溢出点分析的起点。之后偏移的算法和 x86 一样:缓冲区大小 + 保存的寄存器数 × 4,其中 LR 的位置由寄存器列表的顺序决定。
6.5 叶子函数与非叶子函数
| 类型 | 是否调用其他函数 | 是否需要保存 LR | 序言 |
|---|---|---|---|
| 叶子函数 | 否 | 不需要(LR 没被覆盖) | 可能只有 SUB SP, SP, #N |
| 非叶子函数 | 是 | 需要 | STMFD SP!, {..., LR} |
这个区分对逆向很有用:叶子函数通常没有 STMFD ... LR,也就没有「栈上的返回地址」可覆盖。当你在找一个溢出点却发现目标函数是叶子函数时,要么换个函数,要么考虑 6.4 里的其他路径。
七、阶段 7:汇编伪指令与混合编程
7.1 伪指令不是 CPU 指令
伪指令是给汇编器/链接器看的,不生成机器码,负责组织代码结构、定义符号、控制对齐等。
7.2 GNU 风格常用伪指令
| 伪指令 | 作用 | 类比 C |
|---|---|---|
.section | 开始一个新段(.text 代码 / .data 已初始化数据 / .bss 未初始化数据) | — |
.global | 声明符号全局可见,链接器可引用 | extern |
.equ / .set | 为符号赋常量值,不分配空间 | #define |
.byte / .short / .long / .quad | 定义 1/2/4/8 字节数据 | — |
.ascii / .asciz / .string | 定义字符串,.asciz 自动加 \0 | — |
.align | 对齐到指定字节边界 | — |
.word | 定义 4 字节数据(等同 .long) | — |
关键区别:段内标号地址在汇编时确定,段外标号地址在链接时确定。.global 让链接器能看到符号,.equ 只在编译阶段存在,没有物理地址。
7.3 ARM 与 Thumb 混合编程
| 伪指令 | 作用 |
|---|---|
CODE32 | 通知汇编器其后的指令按 32 位 ARM 编码 |
CODE16 | 通知汇编器其后的指令按 16 位 Thumb 编码 |
⚠️
CODE16/CODE32只是告诉汇编器怎么编码,不会改变处理器运行状态。 真正的状态切换要通过BX跳转,利用目标地址的 bit[0] 决定进入 ARM 还是 Thumb。
典型模式:
CODE32
LDR R0, =thumb_func + 1 @ +1 设置 bit[0],表示切换到 Thumb
BX R0 @ 跳转并切换状态
CODE16
thumb_func:
...
八、ARM64(AArch64)的关键差异
现在 IoT 固件、Android so、iOS 二进制基本都是 ARM64 了。ARM64 是一次大改,但它把 ARM32 的很多「怪」设计简化掉了。
| 维度 | ARM32(AArch32) | ARM64(AArch64) |
|---|---|---|
| 通用寄存器 | R0–R15 | X0–X30(64 位)/ W0–W30(32 位视图) |
| 零寄存器 | 无 | XZR / WZR(读恒为 0,写丢弃) |
| 参数寄存器 | R0–R3 | X0–X7(最多 8 个) |
| 返回地址 | LR(R14) | X30 |
| 栈指针 | R13 | SP(独立,不是通用寄存器) |
| 函数返回 | BX LR | RET(= RET X30) |
| 条件执行 | 几乎每条指令 | 取消,只剩条件跳转(B.EQ 等) |
| 标志位 | CPSR 的 N/Z/C/V | PSTATE 的 N/Z/C/V |
| 指令长度 | 32 / 16 位混合 | 固定 32 位 |
| 栈操作 | STMFD / LDMFD | STP / LDP(成对操作) |
| 立即数 | 8 位 + 旋转 | 支持 MOVZ/MOVK 拼接任意 64 位常量 |
几个必须知道的点:
① 条件执行没了。 AArch64 取消了指令级条件域,所有条件判断都要靠 B.EQ/B.NE 或 CSEL(条件选择)。所以 ARM64 的反汇编比 ARM32 更接近 x86 的形态,跳转更多。
② 栈操作改成了 STP/LDP。 不再有 LDM/STM,统一用「成对」的 STP/LDP:
; 函数序言
STP X29, X30, [SP, #-0x20]! @ 保存 FP 和 LR,SP 下移
MOV X29, SP @ 建立栈帧
; 函数尾声
LDP X29, X30, [SP], #0x20 @ 恢复,SP 上移
RET @ 跳到 X30
注意这里 STP X29, X30 把 X30(LR)和 X29(FP)一起存到栈上——所以 ARM64 上返回地址几乎总在栈上,反而不像 ARM32 那样需要额外找。
③ 返回值在 X0。 参数最多 8 个(X0–X7),超出走栈。
④ 没有了 PC 相对寻址的 +8 偏移问题。 AArch64 用 ADR/ADRP 专门做相对寻址,语义更直观。
一句话:ARM32 的核心考点是「返回地址在 LR」,ARM64 把这个问题消解了(返回地址默认进栈),但引入了「条件执行消失、判断改成跳转」这个新的阅读负担。
九、动手验证:用 gdb 建立肌肉记忆
光看不够,下面这几个操作建议至少走一遍(需要 gdb-multiarch 或 qemu 环境):
# 1. 编译一个最小 ARM 程序(需要交叉工具链)
arm-linux-gnueabihf-gcc -g -O0 test.c -o test
# 2. 用 qemu 跑起来并挂 gdb
qemu-arm -g 1234 ./test &
gdb-multiarch ./test -ex "target remote :1234"
在 gdb 里验证四件事:
# ① 看寄存器(R0-R15 + CPSR)
info registers
# ② 单步一条 BL,观察 LR 变了什么
stepi
# ③ 看栈上到底存了什么
x/20wx $sp
# ④ 验证寻址模式:执行前后对比基址寄存器
p $r0
stepi
p $r0
重点观察:
- 执行
BL前后,LR 从多少变成多少(应该是BL的下一条指令地址); - 进入函数、执行
STMFD之后,SP 减了多少,x/20wx $sp能看到 LR 被压在哪个位置; - 执行
LDMFD ... {..., PC}的瞬间,PC 跳到哪。
把这三件事看明白,ARM 的函数调用就从"书上说的"变成"我见过的"。
十、从浅到深的自学顺序
第 1 遍:区分 ARM 与 Thumb 的长度、完整性、代码密度,记住默认编译用 Thumb
└─ 能用 IDA 的 opcode 长度判断当前是 ARM 还是 Thumb
第 2 遍:背熟指令格式 <opcode>{cond}{S} Rd, Rn, operand2
└─ 能说出 {cond} 和 {S} 各自的作用
第 3 遍:过一遍六大类指令,重点掌握数据处理、跳转、Load/Store
└─ 能写出 MOV / ADD / CMP / B / BL / BX / LDR / STR / LDM / STM 的典型用法
第 4 遍:背熟 R0–R15 的 AAPCS 分工
└─ 能画出「4 个参数以内 / 超过 4 个参数」的传参图
第 5 遍:理解条件执行和条件码,能手写 CMP + ADDEQ 这样的条件指令对
└─ 对比 x86「只有跳转能条件执行」的差异
第 6 遍:手写偏移 / 前变址 / 后变址三种写法
└─ 在 gdb 里用 x/wx 验证每次寻址后的寄存器变化
第 7 遍:手写一个 BL / BX LR 的函数对
└─ 用 gdb 观察 BL 之后 LR 的值、BX LR 之后 PC 的变化
第 8 遍:写一个带 STMFD/LDMFD 的叶子函数和非叶子函数
└─ 观察栈上保存了哪些寄存器、LR 是否在栈上
第 9 遍:读 GNU 伪指令,手写带 .section / .global / .equ 的汇编文件
└─ 理解 CODE16/CODE32 只影响编码,状态切换靠 BX bit[0]
第 10 遍:把同一个程序编译成 ARM64,对比 STP/LDP 与 STMFD/LDMFD
└─ 体会条件执行消失后,反汇编里跳转变为啥变多
十一、关键记忆点
- ARM = 32 位完整指令集,Thumb = 16 位子集,Thumb 代码密度高但不完整;异常自动进入 ARM 状态(Cortex-M 例外)。
- ARM 是 Load/Store 架构:只有
LDR/STR碰内存,数据处理全在寄存器之间完成。 - 指令格式
<opcode>{cond}{S} Rd, Rn, operand2:{S}控制是否更新标志位,{cond}实现条件执行。 - 加
S后缀才更新标志位:ADD不改,ADDS才改。和 x86 相反。 - 六大类指令:数据处理、跳转、PSR 访问、Load/Store、协处理器、异常产生。
BL保存返回地址到 LR,BX负责状态切换,BLX两者兼有;跳转范围前后 32MB。- CPSR/SPSR 不能直接读写,必须经
MRS读出、MSR按域(f/s/x/c)写回。 - R0–R3 传参,R4–R11 存局部变量(非易失),R12 临时,R13=SP,R14=LR,R15=PC。
- 返回地址在 LR,不在栈上;
BL写 LR,BX LR返回。这是 ARM 溢出利用与 x86 最大的直觉差异。 - 非叶子函数用
STMFD SP!, {..., LR}把 LR 存到栈上——这一步让返回地址变成"可覆盖"的,是溢出分析的关键锚点。 - ARM 几乎每条指令都可条件执行(
EQ/NE/GT等后缀),这是与 x86 最大的指令级差异;Thumb 只有跳转支持。 - 堆栈是满递减,
STMFD SP!, {...}/LDMFD SP!, {...}是保存/恢复现场的标准写法;!别漏。 CODE16/CODE32只告诉汇编器按什么宽度编码,真正切换状态靠BX的目标地址 bit[0]。- ARM64 取消了条件执行、改用
STP/LDP、返回地址默认进栈——和 ARM32 的直觉不能混用。
附:常见误读速查
| 你看到的 | 别误读成 | 实际含义 |
|---|---|---|
MOV R0, #0 | 普通的赋值 | ARM 里这是清零的标准写法 |
一串 xxxEQ 指令 | 顺序执行的死代码 | 条件执行块,只有 Z=1 时才执行 |
LDR R0, [PC, #0x24] | 诡异的 PC 操作 | 从 literal pool 读常量,偏移要 +8 修正 |
POP {R4, PC} | 弹栈到普通寄存器 | 这是一种返回方式,等价 BX LR |
BLX 0x1001 | 地址写错了 | 跳到 0x1000 并按 Thumb 执行 |
函数里没有 STMFD ... LR | 函数不完整 | 叶子函数,不需要保存 LR |
ADDS 而不是 ADD | 笔误 | 有意更新标志位,后面大概率跟条件跳转 |
LDR R0, =0x12345678 | 一条真实指令 | 伪指令,汇编器会展开成 literal pool 读取 |
CODE16 后面就变成 Thumb 了 | 状态切换了 | 只是编码方式变了,运行时状态靠 BX 切换 |

说些什么吧!