
多个小应用共用一片 ESP32 Flash听起来是个存储规划问题实际上是个数据安全和管理边界的问题。一个不小心App A 写入的配置覆盖了 App B 的关键参数或者一次擦写操作把另一个应用的固件区域给抹了这种事在开发调试阶段尤其容易出现。本文把我实际项目里踩过的坑、验证过的方案全部拆开讲清楚从分区表设计到运行时的读写机制一步步说透。1. 先看清楚问题到底出在哪ES32 的 Flash 是板子上那颗 SPI Nor Flash 芯片通常 4MB、8MB 甚至 16MB。芯片本身只有一套地址空间所有代码、配置、文件系统、OTA 升级包、日志全都挤在这一个物理介质上。多个小应用共用这块 Flash说的就是多个功能模块或者多个独立固件同时往这片存储里塞数据。数据“串门”的路径归根结底只有三条。第一条分区表混乱导致的地址覆盖。ESP32 的启动和存储一切以分区表Partition Table为准。如果分区表里划分的地址范围有重叠或者两个应用的存储分区分到了同一个物理地址区那写入时就会互踩。这个属于规划层面的根本问题配置错了后面的保护全白搭。第二条运行时没有做写入边界约束。即使分区表分好了代码里如果直接拿着某个固定的 Flash 偏移地址去写数据而不经过分区表查询那一旦固件升级后分区布局调整偏移地址就错位了数据直接写进别的地盘。很多老项目中直接从语音下载或者外挂 flash 的例子都喜欢硬编码地址这是最典型的雷。第三条文件系统或者 NVS 的命名冲突。ESP32 自带的 NVS非易失性存储虽然按 namespace 区分但同一个分区里如果两个应用用了相同的 key后写的数据就会覆盖先写的数据。LittleFS 这类文件系统则更直接文件路径冲突、目录结构混乱都会导致数据混在一起。一句话总结串门不可怕可怕的是你不知道它是什么时候串的、从哪条路串的。所以要根治这个问题必须从规划、机制、代码习惯三个方向同时下手缺一不可。2. 分区表让每块地都有自己的主人ESP32 的 Flash 布局是由partitions.csv文件定义的编译时会被烧录到 Flash 的特定位置偏移 0x8000 处。每次上电Bootloader 按这张表来加载 App、查找存储区域。分区表就是所有数据归属权的法律文件。2.1 分区表的基本结构和类型一个典型的分区表长这样# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, app0, app, ota_0, 0x10000, 0x200000, app1, app, ota_1, 0x210000, 0x200000, spiffs, data, spiffs, 0x410000, 0x40000, flash_log,data, unknown, 0x450000, 0x40000,每一列的含义Name分区的名字随便起但最好有意义。Type分区类型。app是应用程序固件data是数据存储。除此之外还有app和data之外的保留类型但绝大多数项目用这两个就够。SubType子类型。对 app 来说常见的是factory工厂初始固件和ota_0/ota_1用于 OTA 双备份对 data 来说有nvs、spiffs、littlefs、fat、unknown等。Offset分区在 Flash 上的起始地址。Size分区大小。Flags通常是空着特殊用途时填encrypted。当你有多个独立小应用时规划分区表的核心思路是每个应用的数据区域必须有独立的物理空间且各空间互不重叠。如果有两个应用都需要保存配置那就建两个独立的 data 分区分别命名app1_conf、app2_conf类型可以都用unknown或者littlefs看数据格式需求来定。2.2 分区大小如何估算拿到一片 Flash 之后首要任务是想清楚怎么把总容量切好。比如 4MB Flash典型分配方案Bootloader 和分区表固定占据头部约 0x9000 之前实际 Bootloader 从 0x1000 开始到 0x8000 是分区表。主 App 固件会占掉一大块。如果你的固件编译出来 600KB那么留 1.5MB 给 app 区域才算舒服因为 OTA 场景下要放两个副本。NVS 给 24KB 或 32KB够存 Wi-Fi 配置、设备参数这些小数据。剩余空间按可预期需求分给文件系统。计算原则就一条宁可让每个分区稍微富余一点也不要让数据靠近边界。很多数据串门事故并不是代码写错了而是分区大小和实际数据量刚好卡在临界点日志或配置写满了文件系统继续往边缘写越界进入别的分区。我实测过一个项目日志分区设了 256KB结果某个异常场景下疯狂打日志单文件写入量超过预期LittleFS 可能因为碎片化把新文件写到了分区边界附近虽然没有立刻覆盖到隔壁 NVS但已经能看出危险趋势。后来统一把日志分区分到 512KB并在代码里做了循环覆盖才彻底安心。2.3 多应用共存的推荐分区方案在编译时启用自定义分区表需要在menuconfig里把Partition Table设置为Custom partition table CSV然后指定 CSV 文件路径。推荐方案如下# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app0, app, ota_0, 0x10000, 0x1F0000, app1, app, ota_1, 0x200000, 0x1F0000, app2_data, data, littlefs, 0x3F0000, 0x80000, app3_data, data, littlefs, 0x470000, 0x80000, temp_log, data, unknown, 0x4F0000, 0x100000,在这个方案中两个固件副本各占约 1.9MB后续如果 OTA 失败还可以回滚数据区互相独立。temp_log是给多个应用共用的一个日志暂存区因为它的内容只被写入和定期清空不会长期驻留关键信息所以大家共用风险相对可控。但即便是这样日志分区里的文件名也要加前缀区分“app1_bootlog.txt”、“app2_errorlog.txt”避免文件系统层面互相覆盖。3. NVS最容易“串门”的地方NVS 是 ESP32 上默认的键值对存储IDF 封装得很舒服读写几行代码就能搞定。但恰恰是这份舒服让很多人忽略了它的边界。3.1 NVS 的 namespace 机制NVS 以 namespace 作为一级隔离在同一个 NVS 分区内可以创建多个 namespace。比如esp_err_t err nvs_open(app1_config, NVS_READWRITE, handle1); esp_err_t err nvs_open(app2_config, NVS_READWRITE, handle2);app1_config和app2_config是两个独立的命名空间内部 key 可以重名而不冲突。比如两个应用都可以存ssid、password互不干扰。但是注意NVS 的隔离边界只到 namespace它不负责物理地址的隔离。所有 namespace 都在同一个 NVS 分区内共享同一片物理区域。如果分区满了后面的写入会返回ESP_ERR_NVS_NO_FREE_PAGES不会越过边界去破坏别的分区这点是安全的。实操建议所有应用必须从创建开始就明确自己的 namespace 前缀比如app1_、app2_并在文档里登记。千万不要用settings、config这种泛化名字不然项目后期两个应用都不知道这个 namespace 是谁的。3.2 每个应用独立 NVS 分区的做法如果两个应用数据都很重要且写入频率高更稳妥的做法是给每个应用分配一个独立的 NVS 分区。在 IDF 里分区表定义好之后编译时会为所有 subtype 为nvs的分区生成对应的 label然后代码里用nvs_flash_init_partition(app1_nvs)来初始化指定分区而不是默认的nvs_flash_init()。esp_err_t ret nvs_flash_init_partition(app1_nvs); if (ret ESP_ERR_NVS_NO_FREE_PAGES || ret ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase_partition(app1_nvs); nvs_flash_init_partition(app1_nvs); } nvs_open_from_partition(app1_nvs, config, NVS_READWRITE, handle);这套接口的好处是应用 1 就算把 NVS 写满、甚至把分区擦掉重来也只影响app1_nvs这 16KB 或 24KB 空间应用 2 的 NVS 数据毫发无伤。这在做工厂测试和产线校准参数的场景下特别有意义。3.3 key 命名规范和写入习惯即使做了 namespace 隔离key 命名仍然要谨慎。最稳妥的规范是模块_参数_用途。比如wifi_ssid wifi_passwd ble_last_mac app1_uart_baud还有一个容易被忽视的点写入 NVS 后要做一下读回验证。NVS 在写入时内部有 CRC 校验正常情况下不会写错但如果 Flash 本身有问题老化或擦写次数耗尽就会出现写入成功但读出来不对的情况。我在项目中做过几万次擦写循环测试发现个别劣质 Flash 确实会出现这种隐性问题。所以涉及关键参数的写入建议写入后立即读取比对不一致就重试一次再失败就返回错误让上层逻辑重新处理。4. 文件系统LittleFS 和 SPIFFS 的多分区挂载如果是多个小应用共用 Flash文件系统的隔离比 NVS 更直观。因为文件系统是面向“目录和文件”的只要路径错开数据就不会混。但要注意挂载点Mount Point是逻辑层的隔离物理层的地址边界由分区表决定。4.1 LittleFS 多分区挂载实操这部分我直接给你们一个可复现的工程配置。假设分区表里有两个 data 分区app1_fs, data, littlefs, 0x3F0000, 0x80000, app2_fs, data, littlefs, 0x470000, 0x80000,在menuconfig里启用 LittleFS然后代码里#include esp_littlefs.h esp_vfs_littlefs_conf_t conf1 { .base_path /app1, .partition_label app1_fs, .format_if_mount_failed true, .dont_mount false, }; esp_vfs_littlefs_register(conf1); esp_vfs_littlefs_conf_t conf2 { .base_path /app2, .partition_label app2_fs, .format_if_mount_failed true, .dont_mount false, }; esp_vfs_littlefs_register(conf2);这样在代码里应用 1 只操作/app1/目录应用 2 只操作/app2/目录。如果某个应用企图打开/app2/config.txtVFS 会正常放行但如果你检查路径前缀就能在逻辑层拦住它。实际项目中我不仅靠 VFS 路径区分还会在公共代码层封装一层存储接口。所有上层应用只能通过 API 获取文件句柄不能直接拼路径FILE* app_storage_open(const char* app_name, const char* filename, const char* mode);API 内部会检查app_name是不是当前调用的应用自己防止跨应用访问。4.2 SPIFFS 的注意事项SPIFFS 是 ESP32 最早支持的文件系统但现在新项目我基本不用它了。原因是 SPIFFS 在掉电时容易产生文件系统损坏特别是写入过程中突然断电可能导致整个分区的目录结构崩溃。LittleFS 在掉电恢复方面表现好很多而且支持目录操作碎片管理也更合理。如果你接手老项目还在用 SPIFFS至少要做到两点每个应用挂载独立分区不要搞一个超大 SPIFFS 分区给所有应用共用。分区大小尽量留余量因为 SPIFFS 在接近满的时候性能会断崖式下降且更容易出现写入问题。5. 运行时存储保护手段分区表划好了、接口封装好了但代码里还是有办法绕过所有保护直接操作 Flash。ESP32 提供了 Flash 保护 API可以根据开发者需要冻结某些分区从硬件层面拒绝任何写入请求。5.1 Flash 写保护IDF 里有esp_flash_write_protect()接口可以对 SPI Flash 的某个扇区区域进行写保护。但这里有个坑如果你想写保护的是分区表里的一部分要注意保护粒度。芯片的写保护是按 Sector4KB或者 Block64KB来的如果你只保护某个分区的几个扇区粒度刚好没问题但如果分区边界不在扇区边界对齐上就会保护到邻居区域反而误伤。我的建议是分区规划阶段就把每个数据分区的起始地址和大小按 64KB 对齐。这样一是避免保护时误伤邻居二是方便以后做升级时整体替换。ESP32 的分区表本身也要求 offset 至少按 4KB 对齐64KB 对齐只是一个更保险的习惯。5.2 代码层的读写门禁在代码层最有效的保护是所有对 Flash 的直接读写统一收敛到一个模块里不允许业务代码直接调用esp_partition_read/write。即使多个团队或模块各自开发最终也要通过这一个 Gate 模块进行。esp_err_t storage_write(const char* app_name, const char* region, const void* data, size_t len);内部实现做三件事根据app_name和region查找对应的分区对象确保写的是自家地盘。检查写入偏移 长度是否超出分区边界越界直接拒绝。使能掉电保护机制控制器在esp_partition_write之前擦除需要的扇区。这个 Gate 模块还能自然地做日志埋点。谁在什么时间写了哪个区域、写了多少字节全部记录下来。出问题时打开日志串门的元凶一查一个准。5.3 禁止硬编码地址项目越大越要遵循这条铁律绝对不要硬编码 Flash 偏移地址。我遇到过设备厂商的旧固件直接写spi_flash_write(0x3F0000, buf, len)因为当年分区表恰好把配置放在这个位置。后来加了一个 OTA 功能分区表重新排布0x3F0000 变成了新固件的一级 boot 区域代码结果升级完成后设备直接变砖。这属于教科书级别的反面典型。正确的做法是运行期通过分区表查询得到地址const esp_partition_t* part esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_LITTLEFS, app1_fs); esp_err_t err esp_partition_write(part, offset_in_partition, data, len);这里的offset_in_partition是相对于分区起始的偏移不是 Flash 绝对地址。ESP-IDF 的内部实现会用分区表的地址来计算实际写入位置。6. 一个多应用共存的完整案例纸上谈兵没有用我直接分享一个做过的四应用共存项目智能网关设备主控 ESP324MB Flash四个独立应用模块——Wi‑Fi 配网模块、BLE 低功耗通信模块、Modbus 数据采集模块、Web 服务模块。6.1 项目背景看似是四个功能模块实际上它们的代码会分别编译成不同的固件或者通过不同的入口函数加载。这四个模块有一个共同需求都要保存自己的配置和运行参数。一开始的方案是用一个大 NVS 分区四个模块共用。结果开发到第三周就出问题了Web 服务模块在保存网页配置时顺手把 Modbus 模块的baudrate参数覆盖了因为两个模块用了同一个 keybaud。这就是典型的命名冲突。后来我们改成了 namespace 方式虽然避免了互相覆盖但不同模块的 data 增长很快NVS 分区大小不好评估总怕哪天写满。6.2 最终分区方案最终定稿的分区表如下# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app0, app, ota_0, 0x10000, 0x1F0000, app1, app, ota_1, 0x200000, 0x1F0000, wifi_conf, data, nvs, 0x3F0000, 0x2000, ble_conf, data, nvs, 0x3F2000, 0x2000, modbus_conf, data, nvs, 0x3F4000, 0x2000, web_fs, data, littlefs, 0x3F6000, 0x80000, log_fs, data, littlefs, 0x476000, 0x80000,四个应用的独立配置分区都是 8KB 到 16KB保存几百个键值对都没问题。web_fs放网页资源log_fs是四个应用共用的日志区靠路径前缀隔离。6.3 代码隔离经验为了让四个模块彼此独立我们做了两层封装第一层每个模块用自己的分区初始化接口以 BLE 模块为例void ble_config_init(void) { nvs_flash_init_partition(ble_conf); nvs_open_from_partition(ble_conf, ble, NVS_READWRITE, ble_handle); }第二层所有 Flash 擦写操作都路由到一个统一函数内部校验传入的 partition label 是否属于当前模块。不符合的直接返回错误。这样即使某个模块的代码有 bug也无法操作到其他模块的区域。6.4 实测数据这个设备在实验室连续运行 60 天四模块各写了数千次配置和日志没有发生一次交叉覆盖。期间故意做了极端测试模拟 Web 服务器崩溃前反复写日志和配置同时 BLE 模块大量更新连接参数最终 Flash 数据保持干净重启后各模块参数都正确加载。7. 常见问题速查表把几个容易踩的坑整理成表格方便对照自查。问题现象根因分析解决方案设备不断重启日志提示 bootloader 校验失败有代码直接写入 Flash 地址把 bootloader 或 app 区域覆盖了检查所有esp_flash_write、spi_flash_write调用确保全部走分区表查询两个应用的配置互相覆盖使用了相同的 NVS key或共用一个 NVS namespace按应用拆分 namespace或者给每个应用分配独立 NVS 分区文件系统挂载失败format_if_mount_failed触发导致数据丢失分区大小不足以容纳文件系统或上次掉电损坏增大分区改用 LittleFS在代码里做好掉电标志位和回滚策略写文件时出现ESP_ERR_NOT_ENOUGH_UNUSED_SIZE分区剩余空间不足检查分区是否分配过小及时清理旧日志或改用更大的 Flash升级固件后旧配置全部丢失分区表变更数据分区地址和大小变了旧的代码没跟上升级时保留数据分区的位置不变或者做一次配置迁移明明只写了一个字节Flash 却整片被擦除有些老 API 会按扇区擦写代码里手动调用了esp_flash_erase_region参数传错尽量使用esp_partition_write避免手动擦除NVS 初始化报ESP_ERR_NVS_NO_FREE_PAGES某个 NVS 分区空间用完了调大 NVS 分区或清理无用 key如果多个应用共用还是拆分区吧8. 调试和定位“串门”的手段数据串门最难的不是解决而是定位。因为大部分串门不是立刻爆发的而是写错之后放在那边等下一次读取时才出问题。8.1 分区表转储检查编译完固件后可以生成分区表的二进制文件直接把它和实际 Flash 内容对比esptool.py read_flash 0x8000 0x1000 partition_table_dump.bin otatool.py info partition_table_dump.bin这个工具会列出每个分区的名称、类型、偏移、大小方便你确认预期布局和实际烧写的是否一致。8.2 运行时数据巡检项目进入稳定期后可以做一轮“数据完整性巡检”。思路是开机自检时读取所有关键分区的前 64 字节计算 CRC 或哈希与出厂时记录的值比对。如果发现有分区的指纹变了但代码逻辑上并不应该写这些区域就说明有异常写入源。在巡检函数里打点记录最近一次成功写入的模块 ID 和时间输出到日志区。这个巡检逻辑不复杂但对多应用项目来说价值极高。它能运转着帮你找出那些在特定时序下才会触发的串门问题。8.3 Flash 内容 Hex 对比如果已经定位到两个分区疑似串门直接用esptool.py跑一遍全片读取再用二进制对比工具比如 Beyond Compare 或者xxd比较两个分区的数据块esptool.py read_flash 0x3F0000 0x2000 nvs1.bin esptool.py read_flash 0x3F2000 0x2000 nvs2.bin xxd nvs1.bin | head -n 50 xxd nvs2.bin | head -n 50对比内容后通常一眼就能看出串过来的数据长什么样、从哪个模块来的。比如看到一段 ASCII 字符串“web_timeout120”那目标源大概率是 Web 服务模块。8.4 日志中的关键证据除了主动巡检日志留存也很重要。建议所有写入操作都打一条带模块名的日志[storage] APP1 write partitionmodbus_conf keybaudrate size4 offset256一旦出问题翻日志就能看到完整写入序列定位过程会非常快。9. 最后再分享几个硬核经验项目做到后期你会发现真正决定数据安全的往往不是某个 API而是一整套习惯。第一个习惯是分区表变更必须走版本评审流程。哪怕只是移动了一个分区的大小也可能影响 OTA 兼容性、旧配置迁移等多个方面。我参与的一个项目就在发布前临时把日志分区从 256KB 改成 512KB结果老设备升级后日志分区吞掉了旧数据分区的位置配置全部丢失只能走一次恢复流程。第二个习惯是每个分区都要命名得有辨识度不要出现data1、data2这种不知道干什么的分区名。分区名是给开发者看的规范的命名本身就是一种文档。第三个习惯尤为重要所有共用 Flash 的项目都要把「最小写入单位」放在心上。Nor Flash 的特性是写 1 很容易写 0 需要擦除擦除是按 Sector 来的。如果你的某一个应用频繁写入小数据做原地更新时其实要经历“读出扇区内容 → 修改 → 整块擦除 → 写回”的完整过程这会放大 Flash 损耗和掉电风险。所以在设计时尽量把频繁写入的数据集中到同一个扇区或者同一个文件系统区域减少擦写次数。这不仅仅是寿命问题也是串门风险的来源——擦写操作比简单的按地址写入更容易出边界错误。第四个习惯是定期做做掉电测试。我每个月至少做一次随机断电测试在四个应用同时读写期间任意时刻按下电源开关。这个测试能暴露很多正常操作中看不出来的边界问题。比如某个模块在写入过程中断电开机后另一个模块发现自己的分区打不开了——这时就要考虑加一个启动时分区健康检查或者引入双备份机制。我踩过最疼的一次坑是一个应用跑得好好的另一个应用在 OTA 升级时把公共 otadata 分区的内容写坏了结果两个 app 都进入不了正确的启动分支反复回退设备成了一个砖。从那之后我所有多人协作项目都会在代码规范里强制要求一个铁律凡是跨模块的存储资源一定要通过统一接口访问并且接口里明确记录当前调用者任何人不得绕过接口直接写 Flash。这条规矩救了后面好几次项目值得写进你自己的研发规范里。