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被赋值,那就是在准备返回值。

我的习惯:在IDA里看ARM函数时,我会先标出R0-R3的传入值,再关注R4-R11的保存和恢复。这样能快速理清函数的数据流向。

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为主)
条件执行 几乎所有指令 仅分支指令
代码密度 低 高
性能 高 中等
典型用途 性能敏感代码 嵌入式/存储受限场景
我曾经踩过的坑:在Ghidra里反汇编一个ARM固件时,默认设置是ARM模式。结果函数地址是奇数(Thumb模式标志),反汇编出来全是乱码。后来我养成了习惯——先看地址最低位,1就是Thumb,0就是ARM。

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)。核心规则如下:

  1. 参数传递:前4个参数用R0-R3,多余参数压栈
  2. 返回值:放在R0(32位)或R0-R1(64位)
  3. 被调用者保存:R4-R11、SP、LR必须恢复原值
  4. 调用者保存: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加偏移来定位局部变量了。

实战要点:在逆向ARM函数时,先找到函数开头对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架构的核心知识点串起来了。你可以把它当作后续章节的导航图:

ARM架构核心知识体系 寄存器模型 指令集特点 栈帧结构 R0-R12 通用 R13-SP 栈指针 R14-LR 返回地址 R15-PC 程序计数 CPSR 状态标志 ARM模式 4字节 Thumb模式 2字节 BX/BLX 模式切换 AAPCS 调用约定 参数传递 R0-R3 寄存器保存/恢复 局部变量布局 Pwn 应用:栈溢出 → LR覆盖 → PC劫持 💡 学习路径建议 1. 先掌握寄存器模型,特别是R0-R3、LR、PC的用途 2. 再区分ARM/Thumb模式,学会识别当前模式 3. 最后结合栈帧结构,理解函数调用和溢出利用

这张图把寄存器、指令集、栈帧三个模块串在了一起。你顺着箭头看,从基础概念到Pwn应用,逻辑是通的。后面几章我们会逐一深入每个模块,到时候你可以随时回来看这张图,确认自己当前在哪个位置。

我的建议:刚开始学ARM逆向时,别急着啃所有指令。先把寄存器模型和栈帧结构刻在脑子里,然后拿几个简单的ARM二进制文件练手。我当年就是靠反复分析一个只有几十KB的固件,才把ARM的底子打扎实的。