3、ARM Cortex-M反汇编基础:Thumb/Thumb-2指令集、函数序言与尾声识别、向量表解析
做RA系列固件逆向,说白了就是跟ARM Cortex-M内核打交道。而Cortex-M只支持Thumb和Thumb-2指令集,这一点跟Linux上的ARM Linux完全不同。我刚开始从A系列转到M系列时,第一反应是「这反汇编出来的代码怎么这么短?」——嗯,习惯了32位ARM指令的人,看到16位Thumb指令确实会有点不适应。
这一章我们就把反汇编的基础打牢。你不需要背指令表,但得知道怎么看懂反汇编输出,能快速定位函数边界,能解析向量表找到入口点。这些都是逆向的「基本功」,绕不过去。
3.1 Thumb与Thumb-2指令集概览
Cortex-M内核支持两种指令宽度:16位的Thumb指令和32位的Thumb-2指令。Thumb-2并不是一个独立的指令集,而是Thumb的扩展——它允许在Thumb模式下混用16位和32位指令。
核心要点:RA系列使用的Cortex-M33/M23/M4等内核,都支持Thumb-2。这意味着你在反汇编时会看到大量16位指令,偶尔穿插32位指令。IDA Pro或Ghidra会自动识别指令宽度,但手动分析时要注意地址对齐——32位Thumb指令必须从偶数地址开始。
我个人习惯把Thumb指令分成几类来记忆:
- 数据处理指令:MOV、ADD、SUB、AND、ORR、LSL、LSR等。这些指令通常操作低寄存器(R0-R7),高寄存器(R8-R15)的操作受限。
- 加载/存储指令:LDR、STR、LDM、STM、PUSH、POP。这些是逆向中最常见的指令,尤其是PUSH/POP直接对应函数序言和尾声。
- 分支指令:B、BL、BX、BLX。BL用于函数调用,BX用于返回或跳转。
- 其他:CPS(修改处理器状态)、SVC(系统调用)、BKPT(断点)等。
举个例子,下面是一段典型的Thumb反汇编代码:
ROM:00001234 PUSH {R4-R7,LR} ; 函数序言:保存寄存器
ROM:00001236 MOV R5, R0 ; 参数传递
ROM:00001238 LDR R6, =0x40020000 ; 加载外设基地址
ROM:0000123A LDR R7, [R6,#0x10] ; 读取外设寄存器
ROM:0000123C ADDS R7, #1 ; 自增
ROM:0000123E STR R7, [R6,#0x10] ; 写回
ROM:00001240 POP {R4-R7,PC} ; 函数尾声:恢复并返回
注意最后一条POP指令——它把LR的值弹到了PC中,实现了函数返回。这是Cortex-M特有的优化,省去了BX LR指令。
避坑指南:我曾经在分析一个RA2A1的固件时,看到函数尾声是POP {R4-R7,PC},但PC的值被修改了。后来才发现,这个函数通过修改栈上的PC值来实现跳转表——这是一种常见的状态机实现方式。如果你看到POP {...,PC}后紧接着不是返回,而是执行了其他代码,别慌,先检查栈上的数据。
3.2 函数序言与尾声识别
逆向固件的第一步,就是找到函数的边界。函数序言(Prologue)和尾声(Epilogue)是识别函数最可靠的标志。
典型的Cortex-M函数序言模式:
PUSH {R4-R7,LR} ; 保存被调用者保存的寄存器和返回地址
SUB SP, #0x20 ; 分配局部变量空间(可选)
; ... 函数体 ...
典型的函数尾声模式:
ADD SP, #0x20 ; 释放局部变量空间(可选)
POP {R4-R7,PC} ; 恢复寄存器并返回
为什么是PUSH/POP?因为ARM过程调用标准(AAPCS)规定,R4-R11是被调用者保存的寄存器。如果函数要用这些寄存器,就必须在入口保存、出口恢复。LR(R14)也必须保存,因为函数返回时需要它。
识别技巧:在IDA Pro中,函数边界通常会自动识别。但手动分析时,你可以通过搜索PUSH {...,LR}来定位函数入口,搜索POP {...,PC}来定位函数出口。如果遇到没有序言的函数(叶子函数),它的入口可能就是一条MOV或LDR指令。
我整理了一个常见的序言/尾声模式表:
| 模式类型 | 序言示例 | 尾声示例 | 说明 |
|---|---|---|---|
| 标准函数 | PUSH {R4-R7,LR} | POP {R4-R7,PC} | 最常用,保存4个寄存器+LR |
| 叶子函数 | 无PUSH | BX LR 或 无 | 不调用其他函数,无需保存LR |
| 中断服务函数 | PUSH {R0-R3,R12,LR} | POP {R0-R3,R12,PC} | 硬件自动保存R0-R3,R12,LR,PC,xPSR |
| 大局部变量函数 | PUSH {...,LR} + SUB SP,#N | ADD SP,#N + POP {...,PC} | 局部变量超过4个寄存器时使用 |
注意:有些编译器(如IAR)会生成非标准的序言。我曾经遇到过一个RA4M1的固件,函数序言是PUSH {R4-R6,LR} + MOV R7, SP——它用R7作为帧指针。这种情况下,函数尾声会先恢复SP,再POP。不要死板地只认PUSH/POP模式,要结合上下文判断。
3.3 向量表解析
向量表是固件逆向的起点。Cortex-M的向量表固定在地址0x00000000(或者通过VTOR寄存器重映射),前16个是系统异常,后面是外设中断。
向量表的结构很简单:
地址偏移 内容
0x00000000 初始栈指针(MSP)
0x00000004 复位向量(Reset_Handler)
0x00000008 NMI_Handler
0x0000000C HardFault_Handler
0x00000010 MemManage_Handler
0x00000014 BusFault_Handler
0x00000018 UsageFault_Handler
... ...
0x0000003C SVC_Handler
0x00000040 DebugMon_Handler
0x00000044 PendSV_Handler
0x00000048 SysTick_Handler
0x0000004C 第一个外设中断(IRQ0)
... ...
每个向量占用4字节,存储的是处理函数的地址。注意:Cortex-M的向量地址必须是奇数(LSB=1),表示Thumb模式。如果看到偶数地址,说明固件有问题或者你解析错了。
实战技巧:我习惯用二进制编辑器打开固件,直接看前64字节。前4字节是栈指针,通常指向RAM区域(如0x20000000附近)。第5-8字节是复位向量,指向固件的入口函数。这两个值就能告诉我固件是否正常加载——如果栈指针是0xFFFFFFFF或0x00000000,那固件可能被加密或压缩了。
下面是一个RA6M5固件的向量表示例:
地址 值 说明
0x00000000 0x20001000 初始栈指针(指向RAM)
0x00000004 0x00001235 复位向量(地址0x00001234,LSB=1)
0x00000008 0x00001245 NMI处理函数
0x0000000C 0x00001255 HardFault处理函数
0x00000010 0x00001265 MemManage处理函数
0x00000014 0x00001275 BusFault处理函数
0x00000018 0x00001285 UsageFault处理函数
0x0000001C 0x00000000 保留
0x00000020 0x00000000 保留
0x00000024 0x00000000 保留
0x00000028 0x00000000 保留
0x0000002C 0x00001295 SVC处理函数
0x00000030 0x00000000 保留
0x00000034 0x00000000 保留
0x00000038 0x000012A5 PendSV处理函数
0x0000003C 0x000012B5 SysTick处理函数
0x00000040 0x000012C5 IRQ0(第一个外设中断)
解析向量表时,有几个关键点:
- 复位向量:这是固件的入口点。逆向时从这里开始分析,通常能看到初始化代码(如设置时钟、初始化BSS段、调用main)。
- HardFault_Handler:如果固件崩溃,会进入这个函数。逆向时找到它,可以分析错误原因。
- 外设中断向量:RA系列的外设中断号从IRQ0开始,对应具体的硬件模块。比如RA6M5的IRQ0可能是GPT0(通用定时器),IRQ1可能是GPT1,等等。你需要参考RA系列的硬件手册来确认映射关系。
重要提醒:有些固件会使用VTOR寄存器重映射向量表。比如RA系列在启动时,可能会把向量表从Flash复制到RAM,然后修改VTOR指向RAM。这种情况下,地址0x00000000处的向量表可能是空的或者只有跳转指令。如果你在0x00000000看到的是0xFFFFFFFF,别急着放弃——搜索VTOR的写入指令,找到真正的向量表位置。
3.4 本章知识体系
下面这张图总结了本章的核心知识点和它们之间的关系:
这三个模块是递进关系。先理解指令集,才能看懂反汇编代码;看懂反汇编,才能识别函数边界;识别了函数边界,才能通过向量表找到入口点。说白了,这就是逆向分析的「三板斧」。
我个人建议,初学者可以先花一周时间,找几个RA系列的官方示例固件,手动反汇编并标注函数边界。不用多,10个函数就够了。做完这一步,你对Thumb指令的感觉会完全不一样。