☰
ESP32自定义分区表:实现Flash数据隔离与多应用安全共存的完整实践
2026/10/1 1:30:25 网站建设 项目流程

手头有ESP32设备,一个芯片要跑多个功能:固件本体、配网参数、传感器校准数据、运行日志、OTA升级缓存。所有内容全堆在模块上的那一颗SPI Flash里。数据串门的问题我非常熟悉:要么新固件把老配置覆盖,要么日志写满之后擦除了别的应用数据,要么两个小应用读出来的配置一模一样。这篇内容就是写给准备在ESP32上做多分区开发、OTA升级、文件系统共存的同学,用一次完整的自定义分区表实践,把Flash数据隔离的核心机制讲透。

1. Flash 共用的本质:没有边界就是串门的前提

1.1 一颗 SPI Flash 是如何被多个程序共用的

SPI Flash 本质上是外挂在 ESP32 的 SPI 控制器下的一颗存储芯片。32Mbit、64Mbit 甚至更大容量型号非常常见。MCU 内部 RAM 断电就丢,Flash 断电不丢,所以代码和数据都必须落到 Flash 上。它的物理特性决定了读写方式比较别扭:读可以按字节随意读,但写之前必须先把目标区域擦成 0xFF,而擦除最小单位不是字节,而是一个扇区(常见 4KB,部分厂商是 64KB 的 block)。这意味着在规划时必须考虑对齐,否则一次擦除就会波及不该擦的区域。

更重要的是地址空间。ESP32 的 bootloader、分区表、用户应用代码、NVS 参数、文件系统镜像,全都映射到同一个 Flash 的线性地址空间里。说共用一块 Flash,本质上是所有数据在一个地址轴上排队。芯片不会自动知道哪一段地址属于谁,只提供了统一的读写接口。如果没有人提前划定边界,A 应用写配置时把地址算错,覆盖的很可能就是 B 应用的固件。分区表的作用,就是把这个"地址轴"人为切成一格一格的隔离房间,让每个小应用只准进自己的房间。

打个比方:Flash 相当于一个大抽屉,里面放了很多小盒子。盒子之间没有物理隔板,能区分你我的只有标签和位置。分区表就是这个标签系统。理解了这一点,后面对齐、偏移的事情就都好解释了。

1.2 "串门"到底长什么样:三种我真实遇到的事故

第一种事故是配置覆盖固件。曾经有个项目里,同事在应用里直接写 Flash:spi_flash_write(0x12000, ...),当时他认为这个地址是预留的数据区,后来固件升级变大之后 app 区延伸到了 0x12000,一次 OTA 就把自己写没了。这种硬编码地址的写法,在没有任何分区保护的情况下特别危险。

第二种事故是 OTA 失败后数据区被初始化。如果两个分区在 CSV 里边界重叠了,升级时写入新固件的过程会按扇区擦除,很可能把相邻的 NVS 或者文件系统擦掉。结果就是新固件没起来,旧版本的数据也没了。

第三种事故最常见,很多人会忽略:多个小应用都调用nvs_open("default", NVS_READWRITE, &h)去读写配置。默认归一化之后所有数据都集中在同一个命名空间里。A 应用的 "volume" 存的是音量,B 应用的 "volume" 存的是水温,两边读出来都是同一个 key,互相覆盖。这种属于逻辑层面的串门,不会崩溃,但数据一定会错乱。

这三种类型分别对应地址冲突、分区重叠、命名空间冲突,后面我会分别给出解决方案。

2. 分区表机制:官方提供的那堵"隔离墙"

2.1 分区表在 Flash 里的位置和内部结构

分区表是引导过程中最先被读取的结构之一。ESP32 上电后,一级 bootloader 先把二级 bootloader 加载到内部 RAM,二级 bootloader 会去偏移 0x8000 的位置读取分区表。默认情况下,偏移 0x8000 之后连续 0x1000(4KB)字节都是分区表的位置。它由一条条记录组成,每条固定 32 字节,记录了分区的类型、子类型、偏移地址、长度、名字和标志位。

这些字段里最关键的是 offset 和 size,它们直接决定了某一区域在 Flash 上的物理位置和占用长度。ESP32 启动应用固件时,bootloader 就是靠这张表找到 factory 或者 ota_N 分区,读取里面的镜像并校验执行。数据分区比如 NVS、SPIFFS 也会根据这张表去找自己的挂载位置。所以分区表是"一切隔离的基础"。

旧版 ESP-IDF 在分区表末尾有 magic 0xAA55 做校验,烧错分区表会导致 bootloader 报错拒绝启动。你可以把它理解成整个地址规划的"图纸",图纸错了,施工一定错。

2.2 默认分区表为什么不够用

ESP-IDF 和 Arduino-ESP32 里默认提供的分区表一般有两类。single app 的表大致是:bootloader 占开头一段,0x9000 处放 NVS,0xF000 放 phy_init,从 0x10000 开始放一个很大的 factory 应用分区。two OTA 的表会在 0x10000 附近放 ota_0 和 ota_1 两个应用分区,中间夹一个 0x2000 的 otadata 分区。

这些默认表的优点是省事,硬件初始化就能跑。缺点是当你真的要做"多个小应用"时,它们没有给独立的数据应用分区留出合理的位置。比如传感器校准数据、日志文件、用户多媒体文件,这些数据如果统统塞进同一个 NVS 或者跟固件挤在同一个分区,第一个写入超限就会污染隔壁。默认分区最多只能保证"能启动",不能保证"隔离得优雅"。

所以自定义分区表是绕不开的一步。掌握了它,你就能按照 Flash 总容量,把代码区和数据区像切蛋糕一样切出来。

3. 手把手配置自定义分区表:完整示例

3.1 CSV 分区表规则:每一列是什么意思

先把语法写清楚。分区表 CSV 的每一行代表一个分区,格式是:

# 名称, 类型, 子类型, 偏移, 大小, 标志 nvs, data, nvs, , 0x6000,

名字(Name)最长 16 字节,用来给分区命名,后面代码里esp_partition_find_first会用到。类型(Type)分两大类:app表示可执行固件,data表示普通数据。子类型(SubType)在 app 类里有factory、ota_0、ota_1等;data 类里有nvs、phy、otadata、spiffs、littlefs、fat等。偏移(Offset)可以省略,省略时工具会自动分配从上一个分区末尾开始的下一个空闲地址,并且自动对齐。大小(Size)是必须的,支持十进制或 0x 十六进制,也支持 1K、1M、1G 这样的后缀。

需要注意几个规则。第一,app 类分区的起始地址应该对齐到 0x10000(64KB),否则部分型号的 bootloader 会拒绝加载。第二,data 类分区一般要求至少对齐到 0x1000(4KB),因为这是 SPI Flash 最常见的扇区擦除尺寸,没对齐就可能出现擦除越界。第三,第一个数据分区如果省略偏移,工具会自动从 0x9000 开始,为前面 0x8000 的分区表留出位置。第四,相同类型和子类型可以建立多个实例,用不同的 Name 区分,这点对"多个小应用"非常关键。

3.2 一个能直接套用的多应用分区规划

假设我们的固件、配网配置、传感器校准参数、日志文件系统、OTA 升级缓存都要共存,Flash 容量假设 16MB。我给出一个实测跑通的分配方案:

# Name, Type, SubType, Offset, Size nvs, data, nvs, , 0x6000 otadata, data, ota, , 0x2000 phy_init, data, phy, , 0x1000 factory, app, factory, , 0x300000 app_cfg, data, nvs, , 0x8000 fs_data, data, spiffs, , 0x200000 fs_log, data, littlefs, , 0x100000 ota_0, app, ota_0, , 0x300000 ota_1, app, ota_1, , 0x300000

这里每个分区都没有写偏移,让工具自动排列,反而更容易避免手动算错。这些分区含义是:nvs放系统级的小参数,otadata记录 OTA 当前运行的是哪个应用,phy_init是射频校准数据,factory是出厂主固件,app_cfg放业务应用的自定义参数,fs_data放主数据文件,fs_log放运行日志,ota_0和ota_1是一对 OTA 应用区。

说明一下为什么日志单独划分。日志文件写入频繁,Flash 会不断进行擦写均衡,如果和主数据放在一起,某一天日志写满触发清理,可能会影响旁边的目录索引;单独划分后,即使日志区损坏,也不影响主数据文件系统。这种"故障隔离"在很多物联网设备上比想象的更重要。

3.3 烧录、验证和代码读取

在 ESP-IDF 环境中,先用idf.py menuconfig进入Serial flasher config -> Partition Table,选择Custom partition table CSV,然后把上面的 CSV 内容存成partitions.csv放到项目目录,并设置CONFIG_PARTITION_TABLE_CUSTOM_FILENAME指向它。重新编译,idf.py flash monitor时会自动把分区表二进制写入 0x8000。

Arduino-ESP32 则稍微绕一点:Tools -> Partition Scheme里可以选择custom,但更常用的做法是在自己的 platformio.ini 里配置:

board_build.partitions = my_partitions.csv

验证分区表是否生效,最直接的方法有两个。一个是看启动日志,它会打印出每个分区的起始地址、长度和标签;另一个是用 esptool.py 把 0x8000 处的 4KB 数据读出来,再用gen_esp32part.py解析成文本对比,确认和 CSV 一致。

代码里读取分区时,我非常不建议硬编码地址,而是用 esp_partition API 按名字查找:

const esp_partition_t* part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, "app_cfg"); if (part) { ESP_LOGI("app", "cfg partition: offset=0x%x size=0x%x", part->address, part->size); uint8_t buf[64]; esp_partition_read(part, 0, buf, sizeof(buf)); }

这样的好处是,即使以后调整了 CSV 里的偏移和大小,代码不用改。分区表是唯一的真相来源,代码只是查询。

4. 应用之间实现数据隔离的操作细节

4.1 NVS 命名空间隔离的实测用法

NVS(Non-Volatile Storage)是 ESP-IDF 提供的最常用的键值存储。它的隔离机制分两层:第一层是物理分区,通过 Label 区分;第二层是同一分区内的命名空间(namespace),通过字符串区分。我之前遇到串读问题,就是因为只用了第二层,而且用的还是默认 namespace。

最安全的做法是两层都用。比如上面的分区表里,系统参数在nvs分区,业务参数在app_cfg分区。一个应用需要访问业务参数时,用带分区名字的接口:

esp_err_t err = nvs_flash_init_partition("app_cfg"); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { // 空间不足或NVS版本更新时,需要擦除重建 nvs_flash_erase_partition("app_cfg"); nvs_flash_init_partition("app_cfg"); } nvs_handle_t handle; err = nvs_open_from_partition("app_cfg", "sensor", NVS_READWRITE, &handle); nvs_set_f32(handle, "callib", 1.23f); nvs_commit(handle); nvs_close(handle);

不同应用就把sensor换成各自的 namespace 前缀,比如network、display、device。这样即使两个应用都在同一个 NVS 分区里,由于 namespace 不同,也不会互相覆盖。想进一步隔离,就给每个应用单独分配一个 data/nvs 分区,用nvs_open_from_partition操作自己的分区,CSV 可以建多个 nvs 分区。

提醒一下,NVS 操作 commit 之后才算真正落盘。忘了nvs_commit而直接断电,数据会丢。这个坑我踩过很多次,尤其是调试的时候,读出来总是旧值。

4.2 文件系统多实例挂载实现数据分流

当数据量超过 NVS 的舒适区,就得用文件系统。ESP-IDF 下可以使用 SPIFFS、LittleFS、FAT。它们本质上都是把一个 data 分区格式化成文件系统,然后挂载到一个路径上。多个小应用想要不串门,做法就是:给每个应用一个独立分区,各自挂到不同 base_path。

比如把fs_data挂到/data,把fs_log挂到/log。初始化代码如下:

esp_vfs_spiffs_conf_t data_conf = { .base_path = "/data", .partition_label = "fs_data", .max_files = 10, .format_if_mount_failed = true }; ESP_ERROR_CHECK(esp_vfs_spiffs_register(&data_conf)); esp_vfs_spiffs_conf_t log_conf = { .base_path = "/log", .partition_label = "fs_log", .max_files = 5, .format_if_mount_failed = true }; ESP_ERROR_CHECK(esp_vfs_spiffs_register(&log_conf));

之后应用 A 写/data/user.db,应用 B 写/log/run.log,互不干扰。即使/log分区写满,最坏也只是日志丢失,不会损坏/data里的用户数据。这里有一个取舍:SPIFFS 不支持真正的目录,所有文件都是平铺的;LittleFS 支持目录且掉电保护更好,但组件体积稍大。简单日志场景我推荐 LittleFS,老项目维持 SPIFFS 也行。

文件系统挂载失败时,如果配置了format_if_mount_failed = true,库会自动格式化。这在量产阶段要小心,因为一旦意外断电导致文件系统损坏,设备可能自动格式化并丢失所有数据。更稳妥的生产方案是关闭自动格式化,上电先检查挂载结果,再根据业务决定是否需要恢复。

4.3 OTA 双应用与数据版本兼容

很多人做 OTA 时会犯一个错:只在分区表里保留一个 factory 分区,升级时直接覆盖原固件。这样一旦写入过程中断电或固件损坏,设备就变砖了。正确的做法是用 OTA 分区方案,也就是ota_0+ota_1+otadata。运行时固件在里面运行,另一个作为新版本写入目标,写完后把启动标记切到新分区,重启即切换。如果新版本反复启动失败,bootloader 会按标记回滚到旧分区。

数据串门在这个环节也有体现。新旧固件可能运行不同的代码逻辑,如果 NVS 里存的字段或文件格式变了,老固件读新数据会解析失败。我见过一个项目,刚好升级固件没改数据格式,但把某个字段从 int32 改成了 uint8,结果中控读出来全是乱码,为了定位查了好几天。

我建议在两个层面做保护。第一,分区表里要分别为新旧固件保留完整的数据区和工作参数区,不要共用同一片区域。第二,代码里在写入业务数据时,把数据版本号作为一个独立 namespace 或文件名的一部分,比如sensor_v2、app_cfg_v2。升级后程序先读版本号,不匹配就按旧格式迁移或重建,而不是直接拿旧数据硬解析。这个习惯能在 OTA 场景下避免大量隐形串门问题。

5. 数据"串门"排查实录与避坑清单

5.1 分区表重叠导致启动异常

先看一个典型的故障:烧录后上电,串口反复输出类似invalid partition table或magic number wrong。这种情况要么是分区表没有写到 0x8000,要么是分区表里某个分区的 offset + size 超出了 Flash 物理容量,要么是两份 CSV 互相覆盖了。我的排查顺序是:先看 bootloader 日志里是否读到分区表;再用 esptool.py 把 0x8000 处 4KB 数据读出来,用gen_esp32part.py解析,和源代码里的 CSV 做逐行对比;最后检查有没有把 app 的分区起始地址写到小于 0x10000 的位置。

5.2 两个应用读出来的配置一模一样

这类问题一般不是 Flash 地址重叠,而是逻辑层的 namespace 冲突。我建议全局搜索代码里所有nvs_open(和nvs_open_from_partition(,记录每处用的 namespace 字符串。如果两个功能的调用点用了同一个字符串,就是冲突点,改成前/后缀区分即可。还有一种隐蔽情况是nvs_flash_init()的默认分区只有一个,而你在页面上又定义了多个标签不同的 NVS 分区,有些代码还是用不带分区名的旧接口,读到的自然全是默认分区里的值。把接口统一成nvs_open_from_partition,并确认第一个参数指向正确的分区 label。

5.3 擦除边界和地址对齐问题

Flash 的擦除单位不能小看。分区大小、偏移一旦不对齐,即使分区表看起来都对了,运行中也可能出现边界处分区互相干扰。比如 A 分区结尾地址是 0x310F00,B 分区从 0x311000 开始,中间相差 0x100 字节,表面没事;但某些厂商 Flash 的最小擦除块是 64KB(block erase),一次跨 block 擦除就可能把邻居擦掉。实践里最稳妥的安排是:数据分区 offset 和 size 都为 0x1000 的整数倍;app 分区对齐 0x10000;不同分区之间留出至少一个扇区的安全空隙。

烧录时常见的flash download failed也不全是硬件坏了,多半是串口波特率不稳、分区偏移和大小越过物理容量、或者 PC 端工具和目标板通信超时。降低波特率、检查分区表总占用是否超过 Flash 容量,再重新试试。

5.4 排查工具速查表

工具主要用途提醒
esptool.py读写擦除 Flash、烧录整体镜像写地址一定要和分区表对应
gen_esp32part.py生成/解析分区表二进制常用来检查 dump 出的表是否和 CSV 一致
parttool.py按名称读取指定分区内容适合验证某个分区的边界数据
idf.py monitor查看启动日志和分区打印启动阶段信息最全,别错过
分区表 CSV唯一真相来源改完务必同步烧录,别只编译不烧表

最后还是想强调一件事:分区表这种设计约束,最好在项目一开始就定下来,不要等固件、配置、日志都堆起来后再去清理。定的时候多看一眼对齐和边界,后面能省一周的排查时间。我自己的习惯是每次改 CSV 之后,都会生成一张最终的 Flash 布局图注释进代码仓库的 README,并把启动日志里的分区打印截图留档。这样不管以后谁再去加小应用,都有据可查,不会凭感觉乱加分区。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询