3、分区表结构详解:分区表在Flash中的存储位置与二进制格式

上一章我们聊了ESP32的启动流程,从ROM Bootloader一路跑到APP入口。但有个关键问题我一直没细说——Bootloader是怎么知道Flash里有哪些分区、每个分区在什么位置的?

答案就是分区表。它就像一张地图,告诉系统:「你的固件在0x10000,文件系统在0x200000,NVS在0x9000...」。没有这张表,CPU就是睁眼瞎。

我个人习惯把分区表称为「固件的文件分配表」。你想想看,一个嵌入式设备里存着代码、配置、日志、证书、OTA备份……这么多东西,总得有个目录来管理吧?分区表就是这个目录。

3.1 分区表在Flash中的物理位置

ESP32的Flash地址空间从0x00000000开始。分区表默认存放在0x8000偏移处。为什么是0x8000?因为前面0x0000~0x7FFF被Bootloader占用了(通常是0x1000~0x7FFF,0x0000~0x0FFF是安全启动等保留区域)。

这个位置不是死的。你可以通过menuconfig修改:

Partition Table → Offset of partition table (0x8000 default)

但我不建议你改。除非你有特殊需求(比如Bootloader特别大),否则保持默认就好。我在项目里见过有人把分区表挪到0x10000,结果忘了更新Bootloader里的偏移量,板子直接变砖——嗯,这种坑踩一次就够了。

关键事实:分区表总是从Flash的某个固定偏移开始,且必须对齐到4KB边界。Bootloader在启动时会读取这个位置的二进制数据来解析分区布局。

3.2 分区表的二进制格式

分区表在Flash里不是文本文件,而是一个紧凑的二进制结构。每个分区占用32字节,整个分区表就是多个32字节记录的数组。

来看这个结构体的定义(来自ESP-IDF源码):

typedef struct {
    uint16_t magic;          // 魔数,固定为0xAA50
    uint8_t  type;           // 分区类型(app/data)
    uint8_t  subtype;        // 子类型(factory/ota_0/nvs等)
    uint32_t offset;         // 分区在Flash中的起始地址
    uint32_t size;           // 分区大小(字节)
    char     label[16];      // 分区名称(字符串,不足补0)
    uint32_t flags;          // 标志位(加密等)
} esp_partition_info_t;

32字节,一个不多一个不少。我刚开始逆向ESP32固件时,就是靠这个结构体在二进制文件里定位分区信息的。你可以在Hex编辑器里搜索0xAA50,找到魔数后就能逐个解析分区了。

3.2.1 魔数校验

魔数0xAA50是分区表开始的标志。Bootloader读到这个值,就知道「嗯,分区表从这里开始」。如果魔数不对,Bootloader会报错并进入下载模式。

小技巧:在二进制文件中搜索AA 50(小端序)可以快速定位分区表。注意ESP32使用小端字节序,所以0xAA50在文件里显示为50 AA。

3.2.2 type和subtype字段

type字段只有两个有效值:

type值 含义 说明
0x00 APP 应用程序分区(可执行代码)
0x01 DATA 数据分区(NVS、OTA、文件系统等)

subtype则进一步细分。比如APP类型下:

  • 0x00:factory(出厂固件)
  • 0x10:ota_0
  • 0x11:ota_1
  • 0x12:ota_2……最多到ota_15
  • 0x20:test(测试分区)

DATA类型下:

  • 0x01:phy(WiFi PHY校准数据)
  • 0x02:nvs(非易失性存储)
  • 0x03:coredump(崩溃转储)
  • 0x04:nvs_keys(NVS加密密钥)
  • 0x05:efuse(eFuse模拟)
  • 0x10:undefined(未定义,通常用于文件系统)
  • 0x81:esphttpd(HTTP服务器数据)
  • 0x82:fat(FAT文件系统)
  • 0x83:spiffs(SPIFFS文件系统)

这里有个坑:subtype 0x10在APP和DATA下含义完全不同。APP下是ota_0,DATA下是undefined。我曾经在逆向一个第三方固件时搞混了,以为0x10是OTA分区,结果发现是个文件系统——折腾了半天才反应过来。

3.2.3 offset和size

offset是分区在Flash中的起始地址,必须对齐到4KB(0x1000)。size是分区大小,同样需要4KB对齐。

为什么是4KB?因为ESP32的Flash擦除操作以4KB为最小单位。不对齐的话,擦除时会擦到隔壁分区的内容——后果你懂的。

注意:分区之间不能重叠。Bootloader会检查所有分区的offset+size是否超出下一个分区的offset。如果重叠,启动会失败。我见过有人手动修改分区表时算错了size,结果OTA分区和文件系统叠在一起,升级一次就死机一次。

3.2.4 label字段

label是一个16字节的字符串,用来给分区起名字。比如"nvs"、"factory"、"ota_0"、"spiffs"等。不足16字节的部分用0填充。

这个名字在代码里通过esp_partition_find_first等API来引用。你可以随便起名,但建议保持可读性——毕竟几个月后你自己回来看代码,总不希望看到"part1"、"part2"这种名字吧?

3.2.5 flags字段

flags目前主要用到位0:加密标志。如果该位为1,表示这个分区的内容在Flash里是加密存储的。ESP32的Flash加密功能就是靠这个标志来识别哪些分区需要加解密。

其他位保留,必须为0。如果你在逆向时看到flags有非0值,先看看是不是加密分区——我曾经在分析一个智能家居固件时,发现APP分区flags=1,一开始以为是自定义标志,后来才意识到是加密了,白忙活一晚上。

3.3 分区表在Flash中的完整布局

一个典型的分区表在Flash里长这样(以4MB Flash为例):

偏移量    | 内容
----------|----------------------------------------
0x0000    | Bootloader (0x1000 ~ 0x7FFF)
0x8000    | 分区表开始
0x8000    | 魔数 AA 50
0x8002    | type=0x00 (APP), subtype=0x00 (factory)
0x8004    | offset=0x10000
0x8008    | size=0x140000 (1.25MB)
0x800C    | label="factory"
0x801C    | flags=0x00000000
0x8020    | 第二个分区开始
0x8020    | 魔数 AA 50
0x8022    | type=0x01 (DATA), subtype=0x02 (nvs)
0x8024    | offset=0x9000
0x8028    | size=0x6000 (24KB)
0x802C    | label="nvs"
0x803C    | flags=0x00000000
...       | 更多分区...
0x80E0    | 分区表结束标志 (魔数0xAA50 + type=0xFF)

分区表以一条type=0xFF的记录结束。这条记录不表示实际分区,只是告诉Bootloader「分区表到此为止」。

3.4 用Python解析分区表

逆向分析时,我经常需要从固件镜像里提取分区表。下面这个脚本可以帮你快速解析:

import struct

def parse_partition_table(data, offset=0x8000):
    partitions = []
    pos = offset
    
    while pos + 32 <= len(data):
        magic = struct.unpack_from('<H', data, pos)[0]
        if magic != 0xAA50:
            break
        
        type_ = data[pos + 2]
        subtype = data[pos + 3]
        off, size = struct.unpack_from('<II', data, pos + 4)
        label = data[pos + 12:pos + 28].rstrip(b'\x00').decode('ascii', errors='ignore')
        flags = struct.unpack_from('<I', data, pos + 28)[0]
        
        if type_ == 0xFF:  # 结束标志
            break
            
        partitions.append({
            'type': 'APP' if type_ == 0 else 'DATA',
            'subtype': subtype,
            'offset': hex(off),
            'size': hex(size),
            'label': label,
            'flags': flags
        })
        pos += 32
    
    return partitions

# 使用示例
with open('firmware.bin', 'rb') as f:
    fw_data = f.read()
parts = parse_partition_table(fw_data)
for p in parts:
    print(f"{p['label']:16s} {p['type']:4s} offset={p['offset']} size={p['size']}")

这个脚本我用了好几年,从ESP32到ESP32-S3都没出过问题。你拿去改改就能用。

3.5 分区表与启动流程的关系

说了这么多,分区表到底怎么影响启动?看这张图就明白了:

分区表在启动流程中的角色 1. Bootloader启动 2. 读取Flash 0x8000分区表 3. 解析32字节分区记录 4. 查找type=APP的分区 5. 跳转到APP分区offset执行 关键点 • 分区表在0x8000 • 每个分区32字节 • 魔数0xAA50校验 启动决策 • 优先找ota_0~ota_15 • 没有OTA则用factory • 都没有就进下载模式

流程其实很简单:Bootloader先读分区表,找到type=APP且subtype符合启动策略的分区(比如ota_0优先于factory),然后跳转到该分区的offset处执行。如果找不到任何APP分区,就进入下载模式等用户烧录。

这里有个细节:Bootloader不会校验分区表里所有字段的合法性。它只检查魔数、type、offset和size。label和flags基本不管——所以你在逆向时,label字段经常被厂商用来存一些自定义信息,别太当真。

3.6 常见分区表布局示例

不同Flash大小,分区表布局也不同。我整理了几种常见配置:

Flash大小 典型布局 说明
2MB nvs(24K) + phy(4K) + factory(1.2M) + spiffs(剩余) 无OTA,适合小项目
4MB nvs(24K) + phy(4K) + factory(1M) + ota_0(1M) + ota_1(1M) + spiffs(剩余) 双OTA,最常用
8MB nvs(24K) + phy(4K) + factory(1M) + ota_0(1.5M) + ota_1(1.5M) + spiffs(剩余) 大Flash,OTA空间更足
16MB nvs(24K) + phy(4K) + factory(2M) + ota_0(2M) + ota_1(2M) + fat(剩余) 适合需要大文件系统的场景

注意看,nvs和phy分区的大小几乎是固定的,不管Flash多大都只有几十KB。这是因为它们存的是小数据——WiFi校准参数、设备配置等。真正占地方的是固件和文件系统。

经验之谈:如果你在做OTA升级,ota_0和ota_1的大小必须一致,且至少能装下最大的固件镜像。我见过有人把ota_0设成1MB,ota_1设成512KB——结果升级一次就失败一次,因为新固件比ota_1还大。

3.7 逆向分析时的实用技巧

最后分享几个我在逆向ESP32固件时用到的技巧:

  1. 用binwalk提取分区表:binwalk -R "\x50\xAA" firmware.bin可以快速定位所有魔数位置。
  2. 检查分区表完整性:如果解析出来的分区offset和size看起来不合理(比如offset=0x0000),说明分区表可能被破坏了。
  3. 注意加密分区:如果flags=1,这个分区的内容是加密的,直接读出来是乱码。需要先解密才能分析。
  4. label字段可能藏信息:有些厂商会在label里写版本号、编译时间等元数据。别只看分区名,多看看label的ASCII内容。

嗯,分区表这块内容就这么多。说白了它就是一张32字节一行的表格,结构简单但至关重要。搞懂了它,你就能在固件里找到所有分区的准确位置,后续的逆向分析也就有了地图。