1. 串门到底是怎么发生的:先看两个小应用的真实悲剧
先讲一个我实际遇到过的情况。团队里两个人同时在做独立功能,A 同学负责设备端采集模块,B 同学负责配置管理模块,硬件上共用同一颗 ESP32、同一颗外部 SPI Flash。两个人各自都觉得“我只在我的地址范围里读写”,A 从0x10000开始写数据,B 也从0x10000开始写参数,结果烧录完 A 的固件,B 的参数全没了;反过来烧 B 的固件,A 的采集记录又变成乱码。查了一下午,最后发现两个人写到了同一个物理偏移地址上。
这就是标题里说的“串门”。所谓串门,并不是 Flash 芯片自己出了问题,而是多个应用在读写时没有显式的边界约束,各自凭感觉用偏移地址,结果写操作互相踩踏。Flash 不像内存那样可以随意随机访问后覆盖任意字节,它的写入粒度、擦除粒度、生命周期都不一样,一旦地址规划混乱,轻则数据互相覆盖,重则把分区表擦掉,整板变砖重烧。
更麻烦的是,这个问题在开发阶段很难发现。因为只要两个应用不是同一时刻高频写同一个扇区,错误可能一两个星期都暴露不出来。等到现场设备跑了两三天,某一次 A 正好把 B 的某个关键参数覆盖了,设备才出现诡异行为——有些按钮失灵,有些历史记录丢失,有些干脆重启后恢复出厂。到这一步,你才知道是老早就埋下的地址冲突雷。
所以,真正要解决的从来不是“小心一点,别写到一块去”,而是一个系统性的问题:在 ESP32 上,多应用到底靠什么机制来保证 Flash 数据的边界感?在我实际做的多个项目里,答案是明确统一的:不要自己管理偏移地址,用 ESP32 的分区表机制做数据隔离。这篇文章我会把背后的原理、具体落地方法、以及烧录和 OTA 过程中容易翻车的细节全部整理出来,直接给你一个可以照抄的方案。适合正在做多应用共板、或者打算把存储逻辑从单应用改成多模块共存的开发者,尤其适合已经踩过“数据被覆盖”坑但不知道怎么根治的人。
2. 根治思路:把 Flash 切成分区,而不是让应用各自记住一个偏移
要理解为什么分区表方案是对的,先得理解 Flash 本身的两个特性,很多串门问题的根源其实是对这两个特性不够敏感。
第一个特性是擦除粒度远大于写入粒度。ESP32 常用的 NOR Flash,读可以按字节,写按 256 字节一页,但擦除最小单位是一个扇区(常见 4KB),有些操作甚至要求按 64KB 的块来擦。如果你的应用 A 和应用 B 各自的数据恰好落在同一个扇区里,A 要擦除自己的数据,就必须把整个扇区擦掉,B 的数据自然被连带清空。这就是最典型的物理层串门。就算你心里有“我 A 用 0x10000 到 0x12000,B 用 0x12000 到 0x14000”的规划,只要 0x10000 和 0x12000 挨得太近,很可能被同一个擦除块覆盖,依然是隐形的雷。
第二个特性是断电写不保证原子性。写入过程中如果掉电,可能出现部分页有效、部分页无效的中间状态。这时如果两个应用的数据区间相临,A 在做页重写时把 B 所在存储单元擦掉都不是不可能。所以真正的隔离,不光是地址上的“不重叠”,还要求擦除操作的作用域必须完全独立——一个应用做 Flash 维护时,绝不允许物理上触及另一个应用使用的块。
分区表解决的就是这件事。ESP32 的Partition Table(分区表)是一个存放在 Flash 特定位置的结构化表格,默认放在0x8000偏移处。它把整颗 Flash 预先把划分为若干分区(partition),每个分区都登记了自己的偏移、大小、类型、子类型和标签。当应用想存数据时,不是直接去计算“我该写哪个地址”,而是通过esp_partition_find/esp_partition_get_esp_partition找到自己那个分区的信息,然后只在这个分区范围内读写。
这带来的好处非常实在:
- 每个分区对应一个 label,代码里用 label 找分区,物理偏移对应用不可见。A 应用只认识
storage_a这个 label,B 应用只认识storage_b,两者之间没有任何共享的绝对地址知识。 - 擦除操作被限制在分区范围内,即使两个分区相邻,也不会互相影响,因为底层驱动知道本分区的起始和结束地址。
- 分区表本身也是固件的一部分,烧录时和 bootloader、app 一起写入,Flash 的布局从此是“声明式”的,而不是“约定式”的。
我打个比方。没有分区表的时候,两个合租室友共用一个储藏间,谁也不锁门,你敢往里面放东西,我也敢往里面放东西,放不下就往里挤,最后谁的东西坏了根本说不清。有了分区表,相当于把储藏间直接分成两个带锁的专属小隔间,每个人只能在隔间里活动,别人进不来,你自己也出不去。
这也是 ESP-IDF 官方默认所有工程都带一个分区表文件的原因。默认的partitions_singleapp.csv里已经分出了 nvs、otadata、app、spiffs 等分区,只是很多人写简单 Demo 时根本没注意。而当你开始做多应用共板时,分区表从“可选优化”直接升级成“必须设施”。
那具体怎么配置?下一节我会直接把三种最常用的隔离布局方案摆出来,每一种都给到可用的分区表写法和代码调用方式,你可以按自己的项目情况直接选。
3. 三种隔离方案的实际布局:从轻量参数到文件系统多实例
3.1 方案一:每个应用一块私有的 raw data 分区
如果你的多个应用都是轻量级参数存储,不涉及文件系统,那么最简单粗暴的方式是给每个应用分配一个独立的 raw data 分区。所谓 raw data,就是不经过文件系统格式化,直接以自定义数据结构读写的一块裸存储空间。
一个典型的partitions.csv可以这样写:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, app0, app, ota_0, 0x10000, 0x200000, storage_a,data, 0x40, 0x210000, 0x10000, storage_b,data, 0x41, 0x220000, 0x10000,注意这里storage_a和storage_b我用了自定义的 SubType0x40和0x41。ESP-IDF 规定data类型下 0x40~0xFE 是用户自定义子类型,可以用来区分不同用途的数据分区。在代码里,寻找和读写就变成了这样(以 C 举例):
const esp_partition_t* part_a = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, 0x40, "storage_a"); ESP_ERROR_CHECK(part_a == NULL ? ESP_FAIL : ESP_OK); char buf[64] = {0}; esp_partition_read(part_a, 0, buf, sizeof(buf)); // 写入前先擦除,再写,防止旧数据尾部残留 esp_partition_erase_range(part_a, 0, part_a->size); esp_partition_write(part_a, 0, new_param, param_len);这个方案最大的优点是代码路径清晰:每个应用自己在初始化时拿到自己的esp_partition_t*,后续所有读写都是基于这个句柄,完全规避了对绝对偏移的手动管理。缺点也明显:自己负责数据结构设计和坏块管理,如果参数频繁改写,好要考虑磨损均衡,通常建议使用 NVS 而不是自己裸写。
3.2 方案二:基于 NVS 的键名前缀隔离
如果多个应用只是存一些 int/string/blob 小参数,那更优雅的做法是共用同一个 NVS 分区,但在键名前加上应用专属前缀。NVS 本身是 ESP-IDF 自带的键值存储,内部做了 CRC 校验、磨损均衡、事务保护,非常省心。
我见过很多人一开始就往 NVS 里写类似param1、param2的键,两个应用都用同一个nvs_open("storage", NVS_READWRITE)句柄,结果自然是键互相覆盖,谁后写谁赢。稍微好一点的做法是这样隔离:
// A 应用 nvs_handle_t handle_a; nvs_open("app_a", NVS_READWRITE, &handle_a); nvs_set_i32(handle_a, "brightness", 80); // B 应用 nvs_handle_t handle_b; nvs_open("app_b", NVS_READWRITE, &handle_b); nvs_set_i32(handle_b, "brightness", 30);注意第一参数是namespace(命名空间),ESP-IDF 的 NVS 在同一个分区里支持最多 256 个不同 namespace,不同 namespace 之间键名完全不冲突。上面例子中,即使两个应用都用了brightness这个键,由于 namespace 分别是app_a和app_b,底层会映射到不同的存储 slot,互不干扰。
更进一步的,你可以在键名层级再加一级前缀做细分,比如cfg_xxx、stat_xxx,方便同一应用内部的模块之间也保持干净。NVS 分区不用做任何其他配置,直接用默认nvs分区即可。这是我最推荐的小参数隔离方式,原因无他:存储本身已经帮你做了错误校验和磨损处理,你再也不用手动擦除、写扇区了。
3.3 方案三:每个应用独立一个 LittleFS/SPIFFS 文件系统实例
如果你的多应用需要各自存文件——比如日志、图片、升级包、配置文件——那 NVS 和 raw data 都不合适,因为它们不是文件系统。这时候正确的做法是给每个应用分配独立的文件系统分区,然后在代码中为每个分区挂载不同的文件系统实例。
在较新的 ESP-IDF 中,推荐使用 LittleFS。分区表大致这样:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, app0, app, ota_0, 0x10000, 0x200000, lfs_a, data, spiffs, 0x210000, 0x60000, lfs_b, data, spiffs, 0x270000, 0x60000,有人会问:为什么 SubType 用spiffs而名字叫lfs_a?因为 ESP-IDF 的分区类型中,spiffs子类型并不强制是真正的 SPIFFS 文件系统,它可以被 LittleFS 复用,毕竟两者都是嵌入式文件系统分区。你只要在代码里为它们分别初始化 LittleFS 即可。
挂载代码核心部分是这样:
static void mount_lfs(const char* label, const char* mount_path) { esp_vfs_lfs_conf_t conf = { .base_path = mount_path, .partition_label = label, .format_if_mount_failed = true, .dont_mount = false, }; esp_vfs_lfs_register(&conf); } // 初始化阶段分别挂载 mount_lfs("lfs_a", "/app_a"); mount_lfs("lfs_b", "/app_b");之后 A 应用直接操作/app_a/config.ini,B 应用直接操作/app_b/cache.bin,两个路径根不同,物理分区也不同,彻底物理隔离。这个方案的好处是上层全是标准 POSIX 文件接口,open/read/write/close人人都会,调试也方便;缺点是每个文件系统分区会有一定的格式元数据开销,4KB 扇区对齐后的可用率要提前算好。
4. 分区表怎么烧进 Flash:别把边界烧没了
分区表的定义和烧录,是整个数据隔离方案里最容易被忽略的一环。很多人代码写得很对,esp_partition_find_first也确实找到了正确的分区,但烧录固件时手一抖,把整个 Flash 直接擦掉重烧,又没带--partition-table参数,结果 bootloader 还是新的,app 也是新的,但分区表却是旧的,于是所有 offset 全部错位——这属于更高层级的串门。
先理顺烧录的核心过程。ESP32 完整烧录包含四块内容:
| 内容 | 默认地址 | 说明 |
|---|---|---|
| bootloader | 0x1000 | 二级引导 |
| partition table | 0x8000 | 分区布局的元数据 |
| app | 0x10000(或分区表定义) | 主应用 |
| NVS | 0x9000 | 键值存储初始内容 |
在 ESP-IDF 工程里,执行idf.py flash会自动读取分区表文件并把它烧到正确位置,前提是你通过CONFIG_PARTITION_TABLE_CUSTOM指定了自定义分区表文件。如果你用的是CONFIG_PARTITION_TABLE_SINGLEAPP_LARGE或别的预置选项,实际生效的是对应预置 CSV。要确认当前到底用的哪个分区表,看编译日志里的这一行:
Partition table binary generated. (partition_table.bin)然后在build/partition_table.bin旁边通常还有一个partition_table.csv,编译系统会把它转换为二进制格式。关键点来了:运行时 bootloader 是根据二进制分区表去定位 app 和其他分区的。你就算把partitions.csv改了,如果没有重新编译并把新分区表烧进去,修改是不生效的。
多应用项目里,我最常建议的流程是:
- 在
partitions.csv里定义好所有分区,并写清楚 offset 和 size。 - 让 offset 尽量按 0x10000(64KB) 对齐,避免两个分区之间因为擦除块边界问题产生隐形牵扯。
- 第一次烧录时,用完整擦除指令做一次干净布局:
idf.py erase-flash flash,这样分区表、app、nvs 全部重置到一致状态。 - 后续迭代如果只改了 app 代码,分区大小没有变化,可以直接
idf.py flash,不会动分区表。
如果你用的是 esptool.py 手动烧录,最常见的一个致命错误是这种写法:
esptool.py --chip esp32 write_flash 0x10000 app.bin这条命令只烧 app,但对于那些已经烧过分区表的板子,只要 app 的地址还和旧分区表对得上,也能跑。可要是一开始没烧过分区表,或者板子是从别的项目里拿来的,里面可能根本没有和你 app 匹配的分区表,烧进去大概率 boot loop。
所以手动烧录时,三件套一定要齐:
esptool.py --chip esp32 write_flash 0x1000 bootloader.bin 0x8000 partition_table.bin 0x10000 app.bin记住一个原则:烧录永远以分区表为准,app 放哪个地址,不由你拍脑袋,而是查看分区表里 app 分区的 offset。多应用项目尤其得养成这个习惯,因为应用数多了,人的记忆是不可靠的。
5. 升级和 OTA 场景下的隔离防线:动态分区信息校验
多应用共 Flash 的项目,一旦上了 OTA,串门的风险会换一副面孔。原因很简单:OTA 的本质是把新固件写入另一个 app 分区,再通过标记切换启动。如果你的自定义数据分区位置离 app 分区太近,或者 OTA 分区设计得不合理,升级过程中就可能擦到别人的边界。
ESP32 默认的 OTA 分区方案,分区表一般长这样:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, app0, app, ota_0, 0x10000, 0x200000, app1, app, ota_1, 0x210000, 0x200000, storage_a, data, 0x40, 0x410000, 0x10000, storage_b, data, 0x41, 0x420000, 0x10000,app0和app1各占 2MB。OTA 升级时,新固件写入空闲的那个 app 分区,写入完成后把 otadata 里的引导标记指向新分区。这里要注意:app0 和 app1 的大小必须完全一致,否则运行时esp_ota_get_next_update_partition找出来的候选分区可能装不下新固件。
升级之外,数据的持续隔离取决于一件事:应用每次读写数据前,都校验自己手里的分区描述符和分区表里的一致。这是因为在 OTA 之后,分区表可能被整体替换——如果你在 OTA 前改了分区表布局,新固件带着新布局启动,旧分区里的数据可能落在新分区的“界外”。
这种场景下我做过的有效防御是三步:
- 在 bootloader 阶段不用动,但 app 启动时用
esp_partition_table_verify校验分区表的 CRC,不合法就直接回退。 - 每个应用在首次读写自定义分区前,不只是查一次 label,还要比对
esp_partition_t->size和编译期宏PARTITION_SIZE_xxx是否一致。 - 每次写入关键数据后在末尾附加一个 CRC32 字段,读取时校验,一旦发现校验失败不自动覆盖,先保留现场并输出错误日志。
第 3 条特别重要。我再强调一遍:宁可让应用报错,也不要默默覆盖别人的数据。我在实际项目里见过太多“看起来一切正常,重启后数据全乱”的案例,最后定位下来都是某一次启动时读到了 CRC 错乱的数据,应用为了“恢复”而做了全量擦写,结果把另一个应用的数据一起清了。加了 CRC 校验之后,至少能明确归因,不会做无意识破坏。
升级期间还有一个很少有人注意的细节:otadata分区自身是一份关键资源,它记录的是当前应该从哪个 app 启动。如果多个应用中的任何一个误用esp_partition_erase_range并且范围越界,把 otadata 擦掉,板子会一直 OTA 回滚,表现为“每次都能启动,但每次启动都像是刚升级完”。排查起来很迷惑,其实就是有人在数据擦除时越界动了 otadata。这再次说明:每个应用只允许操作自己通过 label 拿到的分区句柄,不允许知道任何其它分区的绝对地址。
6. 当串门诡异出现时:一套复盘排查链路
如果你的项目已经上了分区表,但数据还是出现了类似串门的现象,下面的排查链路是我自己压箱底的方法,可以帮你快速定位到底是谁在越界。
第一步:备份整颗 Flash。不要急着改代码,先给当前现场拍个照:
esptool.py --chip esp32 read_flash 0x00000 0x400000 full_dump.bin第二步:根据分区表,用 Python 把可疑分区的原始内容提取出来。假如你的storage_a在 0x410000,大小 0x10000,那么用dd或 Python 切片从 full_dump.bin 中截取这一段:
with open("full_dump.bin", "rb") as f: data = f.read() storage_a = data[0x410000:0x410000+0x10000] with open("storage_a.bin", "wb") as f: f.write(storage_a)第三步:对照写日志。这一步需要你代码里有写操作日志。如果 A 应用在t1时刻写入了某段预期数据,B 应用在t2时刻也执行了写操作,你就去查 B 的esp_partition_t句柄是否确实指向storage_b。我遇到过一种很经典的情况:不是 B 的代码越界,而是B 的固件是旧版本,旧版本的分区表上没有storage_b分区,于是它调用esp_partition_find_first返回了 NULL,而 B 的容错逻辑写得很烂,拿到 NULL 后直接用了默认偏移 0,这一写就把整个 Flash 头部给干了。
所以排查链路中必须包含“确认运行的固件版本和分区表版本是否匹配”这一步。可以用每个 app 里编译时的PROJECT_VER和分区表 Hash 做一个联合标记,启动时打印对比,现场日志拉出来一眼就能发现是旧固件把分区表搞失配了。
第四步:如果上面的东西都是对的,仍然有串门迹象,那就要怀疑 Flash 本身的擦除块边界。比如你两个分区的 offset 分别是 0x410000 和 0x420000,大小都是 0x10000,但 ESP32 的底层 NVS 或某些三方库做擦除时可能按 64KB 或更粗粒度对齐,实际擦除范围从 0x400000 开始覆盖到了 0x420000 的边缘。处理方式是干脆把分区边界拉开一点,中间加一个 0x10000 的空洞分区做保护区,物理上彻底隔绝相邻分区互相影响。
还有一个我特别想强调的排查技巧:优先怀疑你自己最自信的那部分代码。很多串门问题最后发现都是写代码的人对某个 API 的理解有偏差。比如esp_partition_write的第二个参数 offset 是分区内偏移,不是 Flash 绝对地址,有些人传成了绝对地址,写的时候报错倒是小事,最怕的是某次正好落在别人的分区内。所以排查流程里再加一条:审查所有写操作的入口参数,确认 offset 是否被限制在[0, partition->size)内。
7. 一点工具箱:分区表调试的常用命令和参数速查
最后给你一个我平时反复用的一组命令和参数表,全是从实战里攒下的,直接存好就行。
查看当前设备上的分区表内容:
esptool.py --chip esp32 read_flash 0x8000 0x1000 partition_table_dump.bin解析分区表可读输出(需要安装esp-idf的gen_esp32part.py工具):
python esp-idf/components/partition_table/gen_esp32part.py partition_table_dump.bin查看指定的 app 分区 SHA256 校验和(防止现场固件和本地不一致):
esptool.py --chip esp32 read_flash 0x10000 0x200000 app0_dump.bin然后本地对 build 出来的 app.bin 做 sha256 对比,就能确定设备里的固件是不是你最新编译的那一版。
关于 erase-flash,我要特别提醒:不要在有多应用数据需要保留的时候随手 erase-flash。idf.py erase-flash会整颗 Flash 清零,包括所有数据分区和 NVS,现场设备如果已经跑了很久,这一步相当于把积累的历史数据全部抹掉。正确做法是针对单个分区擦除:
esptool.py --chip esp32 erase_region 0x410000 0x10000这条命令只擦掉storage_a对应区域,其他分区保持不动。不过擦之前想清楚,目标分区里的数据也没了,同样要做好备份。
分区表文件本身也可以直接用gen_esp32part.py生成二进制:
python gen_esp32part.py --offset 0x8000 partitions.csv partition_table.bin生成后你可以自己用 hexdump 查看字节内容,能直观看到每个分区的 offset/size/type 信息。看多了你自然会对分区表的二进制结构有感觉,后续排查反而更快。
我个人的习惯是把分区表 CSV 纳入 git 管理,并且在每次改动分区表时提交一个独立的 commit,commit message 里写清楚变更原因。因为这个文件一旦改错,是能让整板所有应用全部失效的元级配置,再怎么小心都不为过。
8. 最后的最后,一个必须内化的习惯
我现在做多应用 ESP32 项目,第一件事永远是打开partitions.csv,把每个应用的存储边界画出来,确认 app 分区、数据分区、NVS 分区之间的物理关系,然后才让各个应用去写代码。代码写完之后,也不会让应用直接依赖任何绝对 Flash 地址,只会传 label 和类型进去,让esp_partition这套机制把地址翻译动作替我完成。
这个习惯看起来简单,但真的能救命的。它把串门问题从“靠约定、靠记忆、靠人品”变成“靠声明、靠分区表、靠系统机制”。就像合租屋里装上了带锁的隔间,每个人都知道自己那块地方在哪,也碰不到别人的地方,散落在房间里的物品不会再乱成一团。
如果你现在正被多个应用共用 Flash 的数据覆盖问题折磨,先不要急着去改业务逻辑。停下手里的事,打开你的分区表,重画布局,把每个应用的存储边界落到位。跑通了之后你会发现,数据串门这个词,从你的项目里彻底消失。