2. 嵌入式系统基础:常见嵌入式架构、启动流程、内存映射与外设

做固件逆向分析,说白了你得先懂硬件是怎么醒过来的。我见过不少搞软件安全的兄弟,一上来就盯着二进制看,结果连 Bootloader 在哪个地址跑都搞不清楚。嗯,这章咱们就把地基打牢。

2.1 常见嵌入式架构:ARM、MIPS、RISC-V

目前市面上主流的嵌入式处理器架构就这三家。我个人的习惯是,拿到一个固件先看它是哪种指令集,这决定了后面反汇编工具的选择和分析思路。

架构 指令集特点 字节序 典型应用 逆向难度
ARM 32/64位,Thumb/Thumb-2混合指令 小端为主(可切换) 手机SoC、路由器、IoT 中等(资料多)
MIPS 32/64位,延迟槽、固定指令长度 大端/小端均有 路由器、光猫、交换机 较高(延迟槽坑多)
RISC-V 32/64位,模块化扩展 小端 新兴IoT、AI加速器 中等(生态在完善)
避坑指南: 我曾经在分析一个MIPS路由器固件时,没注意它是大端序,反汇编出来的代码全是乱码。浪费了整整一天。所以拿到固件第一件事——确认字节序。

2.2 ARM 架构要点

ARM 是我接触最多的架构。它有两种主要执行状态:ARM 状态(4字节指令)和 Thumb 状态(2字节指令)。你想想看,一个函数可能前半段是 ARM,后半段切到 Thumb,这在逆向时特别容易跟丢。

我习惯用 objdump -d 配合 -M force-thumb 来强制反汇编。但更靠谱的方法是看函数地址的最低位:如果地址是奇数,说明是 Thumb 代码。

// 示例:ARM 与 Thumb 切换检测
// 地址 0x8000 是 ARM 代码
// 地址 0x8001 是 Thumb 代码(最低位为1)
void (*func_ptr)(void) = (void*)0x8001;
func_ptr();  // 处理器自动切换到 Thumb 状态
关键寄存器: R0-R3(参数传递)、R4-R11(局部变量)、R12(IP)、R13(SP)、R14(LR)、R15(PC)。逆向时重点关注 LR 和 PC,它们决定了函数调用链。

2.3 MIPS 架构要点

MIPS 有个让新手抓狂的特性——延迟槽。就是说分支指令后面的那条指令,不管分支跳不跳,都会先执行完。我在分析一个交换机固件时,就因为没理解延迟槽,把控制流图画错了。

// MIPS 延迟槽示例
beq $t0, $t1, target  // 如果相等则跳转
addi $t2, $t2, 1      // 这条指令在跳转前一定会执行!
// target:
nop

MIPS 的寄存器命名也跟 ARM 不同:$a0-$a3 是参数,$v0-$v1 是返回值,$ra 是返回地址。嗯,这里要注意,MIPS 没有硬件堆栈,函数返回全靠 $ra。

2.4 RISC-V 架构要点

RISC-V 是后起之秀,我最近两年在 IoT 设备里见到它的频率越来越高。它的指令集是模块化的:RV32I 是基础整数指令集,RV32M 是乘除法扩展,RV32F 是单精度浮点。

逆向 RISC-V 时有个好消息:它的指令编码非常规整,不像 ARM 那样有 Thumb 切换的烦恼。但坏消息是,目前商业反汇编工具对 RISC-V 的支持还不够完善。

注意: RISC-V 的调用约定中,ra(x1)是返回地址寄存器,sp(x2)是栈指针。但有些编译器会优化掉 ra 的保存,导致调用栈回溯困难。

2.5 启动流程:从复位到操作系统

嵌入式系统的启动流程,说白了就是处理器从复位向量开始,一步步把控制权交给操作系统。我画了一张图,把整个过程串起来。

嵌入式系统启动流程 1. 复位向量 2. Boot ROM 3. Bootloader 4a. 升级模式 4b. 加载内核 5a. 固件接收/校验 5b. 操作系统启动 逆向分析重点:Boot ROM 不可修改,Bootloader 是攻击面,固件校验是关键

启动流程大致分这几步:

  1. 复位向量: 处理器上电后,从固定地址(通常是 0x00000000 或 0xFFFF0000)取第一条指令。
  2. Boot ROM: 芯片内部固化的只读代码,负责初始化时钟、内存控制器,然后加载 Bootloader。
  3. Bootloader: 比如 U-Boot、Barebox,负责初始化外设、加载操作系统。这是逆向分析的重灾区。
  4. 分支决策: Bootloader 检查按键或标志位,决定进入升级模式还是正常启动。
  5. 最终加载: 升级模式下接收新固件;正常模式下加载内核并跳转。
逆向切入点: Bootloader 阶段通常会打印串口日志。我习惯用逻辑分析仪抓串口数据,很多时候能直接拿到内存地址、分区表等关键信息。

2.6 内存映射与外设

内存映射,说白了就是给每个硬件模块分配一个地址范围。处理器通过访问这些地址来读写外设寄存器。我见过有人把外设寄存器当成普通内存来分析,结果死活看不懂数据变化。

地址范围 用途 说明
0x00000000 - 0x0FFFFFFF DDR 内存 程序运行空间,可读写
0x10000000 - 0x1FFFFFFF 外设寄存器 UART、GPIO、Timer 等
0x20000000 - 0x2FFFFFFF Flash 映射 只读,存放固件
0x30000000 - 0x3FFFFFFF SRAM 快速暂存区

逆向分析时,我通常会先找外设寄存器的基地址。比如 UART 的收发寄存器,如果看到代码在往某个固定地址写数据,那八成就是串口输出。我曾经靠这个技巧,在没有文档的情况下逆向出了一个路由器的调试接口。

实用技巧: 在 IDA Pro 或 Ghidra 中,把外设寄存器地址范围标记为 MMIO 类型。这样反汇编时,对外设的读写操作会高亮显示,方便快速定位驱动代码。

2.7 实战:从固件中提取内存映射信息

拿到一个固件文件,怎么知道它的内存映射?我一般这么做:

  1. 用 binwalk 扫描固件,看有没有文件系统。
  2. 解压出内核或 Bootloader,用 readelf -l 查看段信息。
  3. 搜索字符串,比如 U-Boot、Barebox、DDR、base address。
  4. 如果找到 Bootloader 源码,直接看 include/configs/ 目录下的头文件。
# 示例:从 U-Boot 镜像中提取内存配置
strings u-boot.bin | grep -i "dram"
# 输出可能类似:
# DRAM:  128 MiB
# dram base: 0x80000000
# dram size: 0x08000000
注意: 有些厂商会故意混淆固件中的字符串,或者把内存配置放在加密的配置块里。这时候就需要动态调试了——用 JTAG 或串口中断 Bootloader,直接读寄存器。