ARM架构基础:寄存器模型、指令集特点与栈帧结构
做异架构Pwn,ARM是绕不过去的坎。我最早接触ARM是在分析一个IoT设备的固件漏洞时,那时候我对ARM的寄存器模型还一知半解,调试器里看到一堆R0-R15直接懵了。后来踩了不少坑,才慢慢摸清楚这套体系的脾气。
这一节,咱们就把ARM的底裤扒干净。你搞懂了寄存器怎么用、指令怎么跑、栈帧怎么搭,后面做静态分析和动态调试才能心里有底。
2.1 ARM寄存器模型
ARM的寄存器,说白了就是CPU内部的一堆快速存储单元。跟x86不一样,ARM的寄存器数量多,而且大部分是通用寄存器——这意味着编译器有更多发挥空间,但也意味着逆向时你要盯的变量更多。
2.1.1 通用寄存器(R0-R12)
ARM有16个可见的寄存器,编号R0到R15。其中R0-R12是通用寄存器,理论上谁都能用。但在实际调用约定里,它们各有分工:
| 寄存器 | 别名 | 用途说明 | 调用者保存? |
|---|---|---|---|
| R0 | — | 参数1 / 返回值 | 否(被调用者不保存) |
| R1 | — | 参数2 | 否 |
| R2 | — | 参数3 | 否 |
| R3 | — | 参数4 | 否 |
| R4-R11 | — | 局部变量 / 临时值 | 是(被调用者保存) |
| R12 | IP | 过程内调用暂存 | 否 |
嗯,这里要注意:R0既是第一个参数,也是返回值。所以你在逆向时看到函数开头往R0里塞东西,那八成是在传参;函数结尾看到R0被赋值,那就是在准备返回值。
2.1.2 特殊寄存器(R13-R15)
这三个寄存器不是通用寄存器,它们有专门的硬件用途:
- R13(SP):栈指针。指向当前栈顶。ARM的栈是满递减栈——SP指向最后一个压入的数据,压栈时SP先减再存。
- R14(LR):链接寄存器。保存函数返回地址。调用子程序时,硬件自动把下一条指令的地址塞进LR。
- R15(PC):程序计数器。指向当前正在取指的指令地址。注意,ARM模式下PC值比当前指令地址大8字节(因为三级流水线)。
我曾经在调试一个ARM的栈溢出漏洞时,盯着PC值看了半天,总觉得它跟指令地址对不上。后来才想起来ARM的PC偏移——这个坑,新手十有八九会踩。
2.1.3 CPSR寄存器
CPSR(当前程序状态寄存器)存的是标志位和模式位。逆向时你最需要关注的是条件标志:
- N(Negative):结果为负
- Z(Zero):结果为零
- C(Carry):进位/借位
- V(Overflow):溢出
ARM指令可以条件执行——比如BEQ就是“如果Z=1则跳转”。这个特性在逆向时特别有用:看到条件指令,你就能反推前面的比较结果。
2.2 指令集特点:ARM模式 vs Thumb模式
ARM处理器支持两种指令集模式。这可能是ARM跟x86最大的区别之一。我刚开始做ARM逆向时,经常搞混当前是ARM还是Thumb,导致反汇编出来的代码完全不对。
2.2.1 ARM模式
ARM模式下,每条指令固定4字节(32位)。指令功能强大,一条指令能干很多事——比如LDMIA可以一次加载多个寄存器。
- 指令长度:固定4字节
- 寻址方式:丰富,支持移位、桶形移位器等
- 条件执行:几乎所有指令都可以带条件后缀
- 代码密度:较低(每条指令占4字节)
2.2.2 Thumb模式
Thumb模式是ARM的压缩指令集,每条指令2字节(部分Thumb-2指令是4字节)。它牺牲了一部分功能,换来了更高的代码密度。
- 指令长度:大部分2字节,Thumb-2支持混合长度
- 寻址方式:受限,没有桶形移位器
- 条件执行:只有分支指令可以条件执行
- 代码密度:高(适合嵌入式设备)
| 对比维度 | ARM模式 | Thumb模式 |
|---|---|---|
| 指令长度 | 固定4字节 | 2字节(Thumb-2可混合) |
| 寄存器访问 | 所有16个寄存器 | 受限(R0-R7为主) |
| 条件执行 | 几乎所有指令 | 仅分支指令 |
| 代码密度 | 低 | 高 |
| 性能 | 高 | 中等 |
| 典型用途 | 性能敏感代码 | 嵌入式/存储受限场景 |
2.2.3 模式切换
ARM和Thumb之间通过BX或BLX指令切换。切换的关键在于地址的最低位:
- 最低位为0:切换到ARM模式
- 最低位为1:切换到Thumb模式
举个例子:BX R0,如果R0=0x8000,则跳转到ARM模式;如果R0=0x8001,则跳转到Thumb模式(实际地址是0x8000,最低位被剥离)。
这个机制在Pwn里特别重要。你想想看,ROP攻击时如果能控制PC跳到一个Thumb模式的gadget,有时候能找到ARM模式下没有的利用原语。
2.3 栈帧结构
ARM的栈帧跟x86有相似之处,但也有自己的特色。搞懂栈帧,你才能理解函数调用时数据怎么布局、溢出时能覆盖什么。
2.3.1 函数调用约定(AAPCS)
ARM的调用约定叫AAPCS(ARM Architecture Procedure Call Standard)。核心规则如下:
- 参数传递:前4个参数用R0-R3,多余参数压栈
- 返回值:放在R0(32位)或R0-R1(64位)
- 被调用者保存:R4-R11、SP、LR必须恢复原值
- 调用者保存:R0-R3、R12可以被修改
嗯,这里有个细节:ARM没有x86的pusha/popa指令,寄存器保存和恢复全靠编译器生成的PUSH/POP或STM/LDM指令。
2.3.2 典型栈帧布局
一个ARM函数的典型栈帧长这样:
高地址
+------------------+
| 调用者栈帧 |
+------------------+
| 参数5+(如果有) | ← 调用者压入
+------------------+
| LR(返回地址) | ← BL指令自动压入
+------------------+
| R4-R11(保存的) | ← 被调用者压入
+------------------+
| 局部变量 | ← SP指向这里(函数执行中)
+------------------+ ← SP(低地址)
低地址
你注意看,ARM的栈帧里没有x86的EBP/RBP链。ARM用FP(R11,帧指针)来追踪栈帧基址,但编译器优化时经常省略FP——这时候你就只能靠SP加偏移来定位局部变量了。
SUB SP, SP, #0x20),这告诉你栈帧大小。然后看PUSH {R4-R11, LR},这告诉你保存了哪些寄存器。有了这两个信息,你就能画出完整的栈帧布局图。
2.3.3 栈溢出视角
从Pwn的角度看,ARM栈帧最关键的几个点:
- LR覆盖:栈溢出覆盖LR后,函数返回时PC会被劫持。这是ARM上最直接的利用方式。
- SP调整:函数结尾的
ADD SP, SP, #0x20或POP {R4-R11, PC}决定了栈指针的恢复。 - Thumb vs ARM的gadget:不同模式下,ROP gadget的地址和指令编码不同。
我记得有一次分析ARM上的堆溢出,调试了半天发现控制流劫持不成功。后来才发现,我覆盖的LR地址最低位是0,但目标函数是Thumb模式——跳过去直接崩了。加上1就对了。
2.4 知识体系总览
下面这张图把ARM架构的核心知识点串起来了。你可以把它当作后续章节的导航图:
这张图把寄存器、指令集、栈帧三个模块串在了一起。你顺着箭头看,从基础概念到Pwn应用,逻辑是通的。后面几章我们会逐一深入每个模块,到时候你可以随时回来看这张图,确认自己当前在哪个位置。