2. STM32架构基础:Cortex-M内核架构、存储器映射、启动流程与向量表
做固件逆向,说白了就是跟芯片的「底裤」打交道。你如果不了解STM32怎么启动、怎么访问内存、中断怎么跳转,那拿到一个固件bin文件基本就是两眼一抹黑。我个人习惯,拿到一个陌生固件的第一件事,就是先看它的向量表——这玩意儿能告诉你芯片型号、启动模式,甚至能帮你定位入口函数。
这一章我们就把Cortex-M内核的几个核心概念掰开揉碎。嗯,都是后面做混淆识别和逻辑还原的硬基础。
2.1 Cortex-M内核架构概览
STM32全系列都基于ARM Cortex-M内核。从M0到M7,指令集和性能有差异,但内核架构的骨架是一样的。
| 特性 | Cortex-M0/M0+ | Cortex-M3 | Cortex-M4 | Cortex-M7 |
|---|---|---|---|---|
| 指令集 | ARMv6-M (Thumb/Thumb-2子集) | ARMv7-M (Thumb-2) | ARMv7E-M (Thumb-2 + DSP + FPU) | ARMv7E-M (Thumb-2 + 双精度FPU) |
| 流水线 | 3级 | 3级 + 分支预测 | 3级 + 分支预测 | 6级 + 分支预测 + 缓存 |
| 中断延迟 | 16周期 | 12周期 | 12周期 | 12周期 |
| 位带操作 | 不支持 | 支持 | 支持 | 支持 |
| MPU | 可选 | 标配 | 标配 | 标配 |
你想想看,M0和M7虽然都叫Cortex-M,但逆向分析时的关注点完全不同。M0的固件通常更小、更直接,没有复杂的缓存和分支预测干扰;M7的固件则可能包含大量浮点运算指令,反汇编时一眼就能看到VLDR、VADD这些指令。
VSQRT指令,一开始以为是数学库,后来才发现是攻击者用浮点运算做控制流混淆。所以看到FPU指令别急着跳过,它可能是混淆的一部分。
2.2 存储器映射——固件在哪儿,数据在哪儿
STM32的存储器映射是固定的。说白了,就是芯片出厂时已经把4GB地址空间划分好了,每个区域干什么用,写死在硬件里。你改不了,但可以利用这个规律来定位固件。
以STM32F4系列为例,典型的映射是这样的:
| 起始地址 | 大小 | 区域 | 说明 |
|---|---|---|---|
0x0000 0000 |
1GB | Code区 | 别名映射,实际指向Flash或SRAM |
0x2000 0000 |
512MB | SRAM区 | 运行时数据、堆栈 |
0x4000 0000 |
512MB | 外设区 | GPIO、USART、TIM等寄存器 |
0xE000 0000 |
512MB | 系统区 | NVIC、MPU、SysTick等内核外设 |
这里有个关键点:0x0000 0000开始的Code区其实是个别名映射。芯片上电后,它映射到Flash(通常是0x0800 0000)还是SRAM(0x2000 0000),由BOOT引脚决定。我见过不少逆向新手,看到固件里地址是0x0800 xxxx就以为是绝对地址,其实它和0x0000 xxxx是同一块物理存储。
0x0801 0000这样的地址。一开始以为是Flash基址偏移,后来才发现这是Bootloader跳转到App的入口。如果你不熟悉存储器映射,很容易把跳转地址当成普通函数调用。
2.3 启动流程——从复位到main()的三步走
Cortex-M的启动流程比传统ARM简单得多。没有复杂的Bootloader,没有汇编启动代码的噩梦。说白了,就是三步:
- 读取向量表:从
0x0000 0000取栈顶指针(MSP),从0x0000 0004取复位向量(Reset_Handler地址)。 - 执行Reset_Handler:初始化全局变量(.data段复制、.bss段清零),调用
SystemInit()配置时钟。 - 跳转到main():调用
__libc_init_array()(如果有C++全局构造),最后进入main()。
嗯,这里要注意:复位向量必须满足LSB=1,因为Cortex-M使用Thumb指令集,地址最低位必须是1表示Thumb状态。如果你在逆向时看到复位向量是偶数,那这个固件要么是损坏的,要么是被人为篡改过——我就在一个恶意固件里见过这种手法,攻击者把复位向量改成了奇数地址,导致芯片上电后直接跑飞。
0x0000 0004处的4字节。如果它指向0x0800 xxxx且LSB=1,那基本可以确定是标准STM32固件。如果指向0x2000 xxxx,说明是从SRAM启动,常见于调试或Bootloader场景。
2.4 向量表——固件的「目录页」
向量表是Cortex-M启动和中断响应的核心。它从0x0000 0000开始,每4字节一个入口,前16个是系统异常,后面是外设中断。
标准向量表布局如下:
| 偏移 | 异常/中断 | 说明 |
|---|---|---|
0x00 |
栈顶指针 (MSP) | 初始堆栈地址 |
0x04 |
复位 (Reset) | 上电/复位后第一条指令 |
0x08 |
NMI | 不可屏蔽中断 |
0x0C |
硬错误 (HardFault) | 未处理的异常都会落在这里 |
0x10 |
MemManage | MPU违规(M3/M4/M7) |
0x14 |
总线错误 (BusFault) | 指令预取或数据访问错误 |
0x18 |
用法错误 (UsageFault) | 未定义指令、对齐错误等 |
... |
... | ... |
0x40 |
SVCall | 系统调用(SVC指令触发) |
0x44 |
调试监视器 (DebugMon) | 调试断点/观察点 |
0x48 |
PendSV | 可挂起系统调用(常用于RTOS上下文切换) |
0x4C |
SysTick | 系统滴答定时器 |
0x50+ |
外设中断 (IRQ0~IRQn) | 芯片外设中断入口 |
我个人习惯,拿到固件后先提取向量表,看看HardFault_Handler和PendSV这两个入口。为什么?因为混淆代码经常故意触发异常来跳转,或者用PendSV做任务切换。如果HardFault入口指向一个非对齐地址,那基本可以断定固件被篡改过。
import struct
with open('firmware.bin', 'rb') as f:
data = f.read()
# 读取前16个异常向量
for i in range(16):
addr = struct.unpack('<I', data[i*4:(i+1)*4])[0]
print(f'Vector[{i}]: 0x{addr:08X}')
注意检查每个地址的LSB是否为1,如果不是,说明固件可能被混淆或损坏。
2.5 核心知识体系图
下面这张图把本章的核心知识点串起来了。你可以把它当作逆向分析的「地图」——从内核架构到存储器映射,再到启动流程和向量表,每一步都对应着固件分析的关键线索。
2.6 实战:从向量表识别固件类型
最后,我们用一个真实案例把知识串起来。我曾经拿到一个未知的.bin文件,大小64KB。按照上面的方法,先提取向量表:
偏移 0x00: 0x2000 1000 → 栈顶指针,指向SRAM,正常
偏移 0x04: 0x0800 01C5 → 复位向量,LSB=1,指向Flash
偏移 0x08: 0x0800 01C9 → NMI,LSB=1
偏移 0x0C: 0x0800 01CD → HardFault,LSB=1
...
偏移 0x4C: 0x0800 02A1 → SysTick,LSB=1
偏移 0x50: 0x0800 02A5 → IRQ0,LSB=1
从栈顶指针0x2000 1000可以推断SRAM大小为4KB以上(常见于STM32F0系列)。复位向量指向0x0800 01C5,说明固件基址在0x0800 0000,且代码段起始偏移约0x1C4。嗯,这里要注意:复位向量指向的地址减去1才是真正的指令地址(因为LSB=1表示Thumb状态)。所以实际入口是0x0800 01C4。
再结合向量表里只有16个系统异常和少量外设中断(IRQ只到IRQ10),基本可以确定这是一个STM32F030系列的固件,没有RTOS(因为PendSV和SysTick的入口很常规)。