
这块 4MB 的 SPI Flash 里同时住着出厂固件、OTA 升级区、WiFi 配置项、日志缓冲和一个网页资源目录。设备跑了一周客户报障说 WiFi 密码自己变回了初始值。远程抓日志才发现日志模块写满后把整个日志分区擦了一遍而那个日志分区的偏移地址是从别人代码里复制来的实际落点正好压在 NVS 参数区上。标题问的是多个小应用共用 ESP32 的一块 Flash怎样保证数据不会串门。我自己在几个量产项目里反复踩过这类坑先给一个听起来太简单、但确实是唯一可靠答案的结论保证不串门的根本手段是设计好分区表Partition Table配套 esp_partition 系列 API。分区表决定 Flash 从哪个偏移到哪个偏移属于谁API 保证你只能在自家院子里读写。这句话容易理解但实际操作里把偏移地址写死、把整个 Flash 当一个大数组用的人我见了不止一两个。1. 分区表就是 Flash 的房产证先看懂 CSV 每一列ESP32 启动流程里Bootloader 是第一棒它跑完后要去找应用程序怎么找靠的不是魔法地址而是存放在 Flash 固定偏移 0x8000 处的分区表。分区表本质上是一段结构化的记录每一条都声明了一块区域的名称、类型、子类型、偏移和大小。你可以用 CSV 文件定义它也可以直接在 menuconfig 里选现成的模板。ESP-IDF 默认的 4MB 分区表长这样# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 1M, ota_0, app, ota_0, 0x110000, 1M, ota_1, app, ota_1, 0x210000, 1M,我习惯把这个表理解成房产证每一个逗号分隔的记录就是一户房子的产权边界。Name是户主名Type和SubType是房屋用途分类Offset是房产起始坐标Size是面积。Bootloader 启动时拿着这张证去核验找到一个合法的 app 分区就跳进去执行应用程序运行起来后也靠这张证去定位自己的存储空间。几个关键细节值得多说一句Offset 可以留空让工具自动计算。比如上面 ota_0 不写偏移是因为你知道 factory 占了 0x10000 到 0x10FFFF1MB 满了之后下一个可用位置就是 0x110000。但如果你加了别的分区手动填的偏移很容易把后面顶开所以我建议能用 auto 就 auto。每个分区大小必须是 4KB一个扇区的整数倍App 分区还额外要求从 0x1000064KB边界开始。这不是强迫症而是 ESP32 Flash MMU 按 64KB 页映射、程序跳转和 cache 刷新的硬件约束。你要是把 app 分区塞在 0x9000Bootloader 都不认。Type 只有两类app和data。SubType 才是细分的业务类别。app 下可以有 factory、ota_0、ota_1data 下可以有 nvs、otadata、phy_init、spiffs、fat以及自定义值。自定义数据分区用0x40~0xFE之间的任意值后面会举例。注意分区表本身位于 0x8000Bootloader 位于 0x1000。你执行esptool.py erase_flash后如果只烧了 app.bin 却没烧分区表.bin板子会一直提示找不到有效分区表俗称分区表丢了。这本身也是串门的一种——只是没串到数据直接把你关在门外。2. 三种共用Flash的场景分区表分别怎么设计多个小应用这个说法在不同人嘴里含义差别很大。做过几个项目后我把它们归纳成三种典型场景分区策略完全不同。2.1 OTA 双区升级两个 App 副本轮流住最经典的多应用共用 Flash就是 OTA 升级。出厂固件和空中升级固件不能互相踩踏否则升级到一半断电整个设备变砖。标准做法是规划两个或三个 app 槽位factory出厂恢复区永远不变。设备升级坏了还能回退到这。ota_0、ota_1OTA 的两个候选区。当前跑的版本在 ota_0新固件就会下载到 ota_1校验通过后再切换 otadata 指向 ota_1。otadata专门记录下一次该从哪个槽启动的小分区只有 8KB。它的存在让 Bootloader 知道该去 ota_0 还是 ota_1。如果你的产品没有特殊恢复需求可以把 factory 去掉省 1MB 空间Bootloader 会默认从 ota_0 启动。但这种方案有一个隐患如果当前在 ota_0 跑的固件挂了且 ota_1 里的旧版本也不完整就没有任何兜底。所以我一般建议量产设备至少保留 factory或者用双槽加回滚机制后面案例 4.3 再说。App 槽位多大原则只有一个槽位必须大于你历史上最大固件体积的 1.2 倍给 OTA 头部、签名和加密对齐留余地。固件 800KB你就分 1MB固件已经接近 1MB再写进去必然覆盖相邻分区——这是最常见的串门触发条件因为编译器不会报错只有在运行时写入失败或整机重启才能发现。2.2 一份固件里的多个功能模块NVS 命名空间先顶上很多情况下所谓多个小应用其实是同一份固件里的多个功能模块温湿度采集、Web 配置页、MQTT 上报、蓝牙配网、日志服务。它们共享同一个 app 分区真正的串门发生在数据存储层。这种场景第一选择是NVS 命名空间。NVS 是 ESP-IDF 提供的一个 key-value 存储库底层跑在专门划出来的 nvs 分区上。它有一个我自己非常欣赏的设计同一个 NVS 分区内部可以按命名空间隔离。两个模块只要不打开同一个 namespace就不会读到对方的 key。// 模块 A传感器配置 nvs_handle_t sensor_handle; nvs_open(sensor, NVS_READWRITE, sensor_handle); nvs_set_i32(sensor_handle, last_temp, 25); nvs_commit(sensor_handle); nvs_close(sensor_handle); // 模块 BWeb 服务配置 nvs_handle_t web_handle; nvs_open(web, NVS_READWRITE, web_handle); nvs_set_str(web_handle, ssid, myhome); nvs_commit(web_handle); nvs_close(web_handle);两个模块的 key 就算都叫 cfg只要 namespace 不同物理存储位置就不同。我见过把 NVS 当全局变量的团队几十个功能共用一个 namespace结果 A 模块升级后写入新 key 结构B 模块启动读到半新半旧的数据直接解析崩溃。规范做法是每个功能模块固定自己的 namespace命名格式模块名_用途并在代码评审时检查有没有人偷懒用同一个 namespace。NVS 适合小配置项不适合存日志、波形、大量文件。需要文件系统时就划一个独立的spiffs或littlefs数据分区。ESP-IDF 从 4.0 起推荐 LittleFS掉电鲁棒性比 SPIFFS 好很多FFS 文件目录损坏的概率小。分区表里一行storage, data, spiffs, , 512K,代码里用esp_vfs_spiffs_register挂载之后就像操作普通文件一样open/write/read系统会自动把它限制在storage分区内部。2.3 真正跑多个独立固件每个镜像都要有自己的窝如果多个小应用指的是多个可独立升级的固件镜像比如一个主业务固件、一个诊断维护固件、一个蓝牙 DFU 升级固件问题就热闹了。ESP32 官方 Bootloader 只认 factory、ota_0、ota_1 这三个 app 分区你想塞第 4 个独立固件是不被标准方案支持的。我在实际项目里见过两种解法第一种是把诊断固件也做成一个 OTA 槽位主业务往 ota_0 放、诊断往 ota_1 放靠esp_ota_set_boot_partition在启动时手动切换。代价是两个固件必须共用同一套 app 分区约束大小互相限制。第二种是维持单固件但把子应用做成独立任务或独立组件各自的数据用自定义 data 分区隔离。比如diag_bin, data, 0x40, , 256K, main_cfg, data, 0x41, , 128K,自定义 subtype 0x40 以上是给用户留的自由区esp_partition_find_first可以按这个类型找到。这种写法的好处是即使某个子应用的数据分区被误擦其他分区安然无恙。坏处是没有任何现成库帮你管理这些裸分区读、写、擦、CRC 都要自己写适合对数据可靠性有明确要求的场景。3. 代码里的隔离护栏用 esp_partition API 而不是拍脑袋算偏移分区表设计得再漂亮代码里如果还在用0x110000这种魔法数字迟早出事。因为你今天画的分区表明天可能因为功能调整重排偏移全变。真要保证数据不串门必须做到全代码零硬编码偏移地址只在启动时通过 API 查询一次获得分区指针。3.1 查找、擦除、读写的标准姿势#include esp_partition.h const esp_partition_t* part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, // 类型data ESP_PARTITION_SUBTYPE_DATA_SPIFFS, // 子类型spiffs storage); // 名称必须与分区表一致 if (part NULL) { ESP_LOGE(TAG, 找不到 storage 分区); return; } esp_partition_erase_range(part, 0, part-size); // 整块擦除 esp_partition_write(part, 0, buf, len); // 写数据 memset(buf, 0, len); esp_partition_read(part, 0, buf, len); // 读数据为什么必须这样做因为esp_partition_*系列函数内部会校验你传入的 offset 和 size 是否超出分区边界一旦越界直接返回ESP_ERR_INVALID_ARG。你再也不可能写出一个写到别人家的非法地址——这在源头就堵死了串门路径。还有一个细节从 API 拿到的esp_partition_t结构体里带address和size字段但你不应该手动加减后去调用更底层的spi_flash_write。一旦你绕过 API边界检查就失效了。我在别人的代码里见过用esp_partition_t拿地址、然后转手交给spi_flash_read的写法这种脱裤子放屁式绕行是串门事件的高发地段。3.2 自己管裸数据时必须加上身份头和校验自定义数据分区没有 NVS 那套管理机制你对它做的每一次写入都是裸的。这时候数据串门还有一种隐蔽形态不是写到别的分区而是同一个分区里旧版数据和新版数据格式混在一起。比如诊断固件升级后把原来的记录结构从{id, temp}改成了{id, temp, humi}老数据长度不匹配读出来全是垃圾。我的惯例是给每条记录加一个 8 字节头typedef struct { uint32_t magic; // 固定魔数比如 0xA5A5_5A5A uint16_t version; // 格式版本 uint16_t length; // 有效负载长度 uint32_t crc; // 对整个记录做 CRC32 } record_header_t;每次读的时候先校验 magic 和 CRC版本不匹配就跳过或重置。这个习惯让我省了无数排查时间——格式化升级之后至少能明确知道哪些老数据该放弃而不是怀疑新固件把数据写坏了。3.3 OTA 升级同样要交给专用 API如果直接在 Flash 上做 OTA 写入最危险的是你自己维护 otadata。otadata 有严格的格式和双重校验区一旦写错半个字节Bootloader 可能永远找不到可启动分区。所以 OTA 流程不要自己造轮子直接用esp_ota_ops#include esp_ota_ops.h esp_ota_handle_t ota_handle; const esp_partition_t* update_part esp_ota_get_next_update_partition(NULL); ESP_ERROR_CHECK(esp_ota_begin(update_part, OTA_SIZE_UNKNOWN, ota_handle)); // 分块写入固件数据 ESP_ERROR_CHECK(esp_ota_write(ota_handle, image_data, chunk_len)); ESP_ERROR_CHECK(esp_ota_end(ota_handle)); ESP_ERROR_CHECK(esp_ota_set_boot_partition(update_part));esp_ota_get_next_update_partition会自动算出当前运行槽位的下一个槽位你完全不用关心 ota_1 到底在哪个地址。这套 API 还会做镜像头校验image 长度不匹配、magic 错误都会在esp_ota_end阶段被发现。4. 串门事故现场三个真实问题的完整排查过程光讲 API 用法说服力不够下面这三个问题全是我在项目里真实遇到过的每个都花了不少时间定位。我把完整的排查思路写出来希望你能直接复现排查方法。4.1 日志写穿 NVS 分区WiFi 配置周期性被清空现象设备运行几天后 WiFi 账号密码丢一次恢复出厂后可复现但时间不确定。排查链路先看运行日志发现 NVS 相关报错nvs: NVS partition truncated。这是 NVS 区域被部分擦除的典型提示。用parttool.py拉取当前设备分区表确认 nvs 分区大小为 0x400016KB偏移 0x9000。再找代码里所有调用esp_partition_erase_range和spi_flash_erase_sector的地方发现日志模块的 wipe 函数用的是硬编码偏移 0x9000但size参数被写成了 0x6000——比 nvs 分区多出 8KB。把这多出的 8KB 换算一下正好落在otadata0xd000和phy_init0xf000之间。日志模块一触发整区清除就把邻居家的墙砸穿了。修复日志模块改回esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, log)动态取分区再把擦除范围严格限定为part-size。同时给日志分区分配独立区域和 NVS 物理隔离。这个项目之后我给自己定了一条规矩任何分区操作第一行代码必须是esp_partition_find_first拿到 NULL 就报错退出绝不允许出现裸偏移。4.2 硬编码地址写死换大容量 Flash 后数据写到邻居家现象产品从 4MB Flash 换到 8MB Flash新固件发布后一部分设备出现配置混乱有些甚至启动卡死。排查链路对比新旧分区表发现升级后新固件里新增了一个font数据分区导致原先放在 0x300000 的store分区被整体平移到了 0x500000。但历史代码里有一个旧模块是从早期版本一路带过来的里面写死了#define STORE_BASE_ADDR 0x300000。新设备一切正常因为新工厂烧录的是新分区表出问题的都是 OTA 升级上来的老设备老代码启动后按旧地址去写 store偏移 0x300000 现在落在font分区内部。于是 font 分区里的字形数据被周期性地覆盖成配置信息显示出现乱码校验失败后系统又去重置 store形成双重串门。修复把所有写死的宏全部替换成启动时缓存的分区指针。我当时做了一个全局struct app_partitions { const esp_partition_t* store; ... }在app_main开头一次查完后续所有模块直接引用结构体字段。保证同一份固件里只有一个地方知道分区在哪以后改分区表只动那一处。经验换 Flash 容量时不只是改menuconfig里的CONFIG_ESPTOOLPY_FLASHSIZE还应该顺带全仓搜索一遍0x开头的 Flash 偏移常量把所有spi_flash_*、esp_partition_*的调用点都审一遍。4.3 OTA 升级掉电otadata 半写状态导致两个 App 槽都起不来现象现场升级过程断电重启后设备既不能进 ota_1 的新固件也回不到 ota_0 的旧固件串口反复打印ota: invalid boot status。排查链路从串口日志看Bootloader 试图从 ota_1 读取镜像头校验失败回退到 otadata 的备份区发现备份区的状态也不是干净的ESP_OTA_IMG_UNDEFINED。otadata的两个 4KB 扇区里一个标记为ESP_OTA_IMG_NEW正在更新另一个被写了一半。这种状态意味着写入 otadata 的时机卡在了 Flash 扇区擦除和写入之间断电留下了一个不完整版本。打开编译宏CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE之后Bootloader 多了一重保护每次启动新固件前先记录尝试启动状态只有固件主动调用esp_ota_mark_app_valid_cancel_rollback()才认为升级成功。这样就断开了写完 otadata 就等于升级成功的错误假设。修复OTA 写完固件后必须调用esp_ota_mark_app_valid_cancel_rollback()标记有效否则下次启动会被自动回滚。同时保留 factory 分区让 Bootloader 在 otadata 两区都损坏时有一个最终兜底。从那以后工厂烧录方案都强制包含 factory 恢复区客户现场再遇到过断电升级至少还能拉回旧版本再远程复位。4.4 顺手清单这几个习惯几乎能杜绝所有串门总结一下我在多个产品里沉淀下来的执行清单你可以直接抄Flash 容量写进项目 README换芯片型号、改 Flash 容量时必须触发全组 review。所有分区地址查询只在启动时做一次之后全局共享指针禁止任何模块私自调spi_flash_*裸 API。NVS 命名空间按模块规范命名且一个 namespace 只允许一个 ownerowner 变更必须走代码评审。自定义数据分区写入自带 magic version CRC读端校验失败宁可丢弃数据也不要强行解析。OTA 流程只走 esp_ota_ops不要自己维护 otadata量产固件打开 rollback 选项。定期用parttool.py get_partition_info对比线上设备和仓库里的分区表很多串门事故是代码已改、设备还是老分区表造成的。我个人在这些项目里最深的体会是ESP32 的 Flash 管理天然就把分区表这个工具放在了你面前用好了它是隔离墙用不好它就是一张谁都能翻的户口本。绝大多数串门事件不是 ESP32 本身的设计缺陷而是开发者跳过了分区 API、用拍脑袋的偏移地址去挑战硬件。把这个习惯改掉后面能省下的排查时间远比你现在花在设计分区表上的那半小时多得多。