
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 到 0x12000B 用 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这个 labelB 应用只认识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.iniB 应用直接操作/app_b/cache.bin两个路径根不同物理分区也不同彻底物理隔离。这个方案的好处是上层全是标准 POSIX 文件接口open/read/write/close人人都会调试也方便缺点是每个文件系统分区会有一定的格式元数据开销4KB 扇区对齐后的可用率要提前算好。4. 分区表怎么烧进 Flash别把边界烧没了分区表的定义和烧录是整个数据隔离方案里最容易被忽略的一环。很多人代码写得很对esp_partition_find_first也确实找到了正确的分区但烧录固件时手一抖把整个 Flash 直接擦掉重烧又没带--partition-table参数结果 bootloader 还是新的app 也是新的但分区表却是旧的于是所有 offset 全部错位——这属于更高层级的串门。先理顺烧录的核心过程。ESP32 完整烧录包含四块内容内容默认地址说明bootloader0x1000二级引导partition table0x8000分区布局的元数据app0x10000或分区表定义主应用NVS0x9000键值存储初始内容在 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 尽量按 0x1000064KB 对齐避免两个分区之间因为擦除块边界问题产生隐形牵扯。第一次烧录时用完整擦除指令做一次干净布局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:0x4100000x10000] 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 管理并且在每次改动分区表时提交一个独立的 commitcommit message 里写清楚变更原因。因为这个文件一旦改错是能让整板所有应用全部失效的元级配置再怎么小心都不为过。8. 最后的最后一个必须内化的习惯我现在做多应用 ESP32 项目第一件事永远是打开partitions.csv把每个应用的存储边界画出来确认 app 分区、数据分区、NVS 分区之间的物理关系然后才让各个应用去写代码。代码写完之后也不会让应用直接依赖任何绝对 Flash 地址只会传 label 和类型进去让esp_partition这套机制把地址翻译动作替我完成。这个习惯看起来简单但真的能救命的。它把串门问题从“靠约定、靠记忆、靠人品”变成“靠声明、靠分区表、靠系统机制”。就像合租屋里装上了带锁的隔间每个人都知道自己那块地方在哪也碰不到别人的地方散落在房间里的物品不会再乱成一团。如果你现在正被多个应用共用 Flash 的数据覆盖问题折磨先不要急着去改业务逻辑。停下手里的事打开你的分区表重画布局把每个应用的存储边界落到位。跑通了之后你会发现数据串门这个词从你的项目里彻底消失。