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 分区表与启动流程的关系
说了这么多,分区表到底怎么影响启动?看这张图就明白了:
流程其实很简单: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固件时用到的技巧:
- 用binwalk提取分区表:
binwalk -R "\x50\xAA" firmware.bin可以快速定位所有魔数位置。 - 检查分区表完整性:如果解析出来的分区offset和size看起来不合理(比如offset=0x0000),说明分区表可能被破坏了。
- 注意加密分区:如果flags=1,这个分区的内容是加密的,直接读出来是乱码。需要先解密才能分析。
- label字段可能藏信息:有些厂商会在label里写版本号、编译时间等元数据。别只看分区名,多看看label的ASCII内容。
嗯,分区表这块内容就这么多。说白了它就是一张32字节一行的表格,结构简单但至关重要。搞懂了它,你就能在固件里找到所有分区的准确位置,后续的逆向分析也就有了地图。