ESP32 Flash分区表与NVS文件系统隔离:彻底解决数据串门
说句实在话ESP32 项目做到一定程度大家最后都会撞上同一个问题Flash 就那么大一块代码在里头住着配置数据也要住日志也要住OTA 升级还要腾地方。要是一个固件里塞了好几个“小应用”——比如一个模块管传感器记录一个模块管 Web 配网还有一个模块跑 OTA——彼此都想往 Flash 里写东西那“串门”就是早晚的事。所谓“串门”说白了一句话地址空间管理失控。Flash 是一个线性的大仓库ESP32 的分区表把仓库隔成一个个房间每个房间住什么、多大、从哪开始都是规定好的。可一旦有人不看房号、不按门牌乱写或者房间本身没隔好那数据轻则互相覆盖重则整片分区被擦掉系统直接起不来。这篇就把这个问题彻底讲透从 Flash 的物理特性到分区表怎么设计再到 NVS、文件系统这些上层存储怎么配合最后给一套可以直接抄的实操方案。无论你是刚入门用 Arduino 写 ESP32还是已经在 ESP-IDF 里折腾的老手照着这套思路走基本不会再被“数据串门”搞到头大。1. 先说清楚串门到底是怎么发生的1.1 三种最常见的串门场景我见过太多人踩坑归纳起来其实就是三种情况。第一种同一个 NVS 分区里多个模块抢着用同一个命名空间和键名。NVSNon-Volatile Storage是 ESP32 上最常用的掉电保存方案它本身有命名空间的概念很多人不知道或者知道但图省事所有模块都往默认的nvs分区里写命名空间还都叫storage键名都叫count。结果模块 A 写入的数据模块 B 一写就把它顶掉了。表面上看起来“数据莫名其妙丢了一部分”实际上是两个模块在同一个抽屉里抢同一个格子。第二种做了 OTA 双分区但新旧固件里的分区表不一致。这是升级场景里最阴的坑。出厂固件用的分区表分配了 2MB 给应用区数据区挤在后面你升级的新固件改了大分区、加了几个存储分区偏移地址全变了。升级完那一刻旧固件写在旧地址上的数据在新固件眼里完全是垃圾新固件以为自己往“自己的分区”写写进去的地址可能正处于旧固件的 OTA 应用区。轻则数据错乱重则直接把正在运行的新固件自己的代码区给擦了。第三种绕过所有上层 API直接拿底层 Flash 函数按绝对地址写数据。有些方案需要快速记录数据有人图方便直接算了一个地址比如0x310000然后用esp_partition_write或底层驱动写进去。要是这个地址恰好落在别的分区中间那就相当于别人家房子盖好了你进门先把墙砸了。1.2 串门的本质是“地址一致性”问题要真正防串门脑子里必须清楚一件事ESP32 的 Flash 是一个从0x000000到0x3FFFFF以 4MB 为例的连续地址空间。分区表本身也是一份存在 Flash 里的元数据它把整个地址空间切分成若干段每段给出名字、类型、起始地址和长度。所有上层存储手段——NVS、SPIFFS、LittleFS、FATFS——最终做的都是同一件事在分区表指定的那块地址范围内再组织自己的数据结构。只要分区范围划得清、上层工具用得对数据就不会串门。反过来只要有一个环节用了硬编码地址、跨分区操作或者分区表本身前后矛盾串门就不可避免。所以防串门这件事本质上是三个层面的配合物理层分区表把地址空间切好这是“地基”。逻辑层NVS 的命名空间、文件系统的挂载路径把同一块区域内部再隔离。使用层代码里绝不硬编码 Flash 绝对地址而是通过 API 查分区、写分区。2. 第一道门分区表是防串门的地基2.1 ESP32 默认分区表到底分了什么用 ESP-IDF 新建工程编译时系统会默认带一个 4MB Flash 的分区表。典型内容类似这样# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x300000,注意bootloader分区不在这里列出来它固定在0x1000分区表自身固定在0x8000这两个区域是芯片启动硬逻辑找的不能自己改至少在常规操作里别改。这个默认表有什么问题数据相关的区域只有 NVS 和 phy_init 两个小分区总共才 24KB 左右。如果你只是跑一个简单 BLE 灯控完全够用但你要是想同时存温湿度历史、设备配置、WEB 页面资源、OTA 升级备份这点空间连塞牙缝都不够更别提多应用隔离了。2.2 自定义分区表的规则和写法在 ESP-IDF 里自定义分区表是一个 CSV 文件在menuconfig的Partition Table-Custom partition CSV file里指定路径或者直接在 CMake 里通过PARTITION_TABLE_CUSTOM指定。CSV 每一行一个分区列分别是列含义约束Name分区名代码里靠它找分区最长 16 字符只能用数字字母下划线Type分区类型app或data也可以是自定义数字SubType子类型见下方表格Offset起始地址必须是 4KB0x1000整数倍Size分区大小必须是 4KB 整数倍且不能超 Flash 末尾Flags加密等标记一般留空常用的 SubType 大概这么几类TypeSubType用途appfactory出厂应用引导程序默认启动它appota_0 / ota_1OTA 双区里的两个应用槽位datanvsNVS 存储可建多个dataotadataOTA 元数据记录当前该启动哪个 ota_ 区dataphy射频校准数据dataspiffsSPIFFS 文件系统分区datafatfsFATFS 文件系统分区datalittlefsLittleFS 文件系统分区提示ota这种子类型只能有一个。包含ota_0、ota_1的分区表在外层结构上和没有 OTA 的表有本质区别一旦选定了就不能在升级时随便改。写分区表时最忌惮的一件事是“地址重叠”。CSV 解析工具不会帮你检查所有未显式写 Offset 的分区会不会重叠需要自己心算。我的习惯是先把 Bootloader0x1000占 0x1000 字节、分区表0x8000占 0x1000 字节放一边从0x9000开始逐个往下排每个分区算清楚结束地址下一个分区接着写。2.3 单应用多模块时分区表该怎么挑 Size假设你有一个 4MB Flash 模组固件整个编译出来大概 700KBOTA 不做但想放两个独立存储区。那表格可以直接这么来# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, phy_init, data, phy, 0xe000, 0x1000, factory, app, factory, 0xf000, 0x1f1000, storage_a, data, nvs, 0x210000, 0x8000, storage_b, data, spiffs, 0x218000, 0x1e8000,这里我把应用区从0xf000开始占0x1f1000约 1.94MB到0x210000结束。接着storage_a是 32KB 的 NVS 分区storage_b是 1.94MB 的 SPIFFS 分区。总账算下来正好填满 0x400000。注意如果开了 OTA应用区至少要留两个槽位每个槽位要大于等于固件编译出来大小的一个余量。分区表会挤压数据区空间这是必然代价别指望鱼与熊掌兼得。3. 第二道门NVS 的命名空间和独立分区3.1 NVS 的一级隔离namespaceNVS 是 ESP32 里面向 key-value 的非易失存储它在内部维护了页、条目、namespace 这些概念。所谓 namespace就是同一个 NVS 分区内部的一个“命名空间”打开时需要指定名字操作 key 只在当前 namespace 内有效。很多博主会强调“命名空间不一样就不会冲突”这话只对了一半。同一分区下namespace 可以隔离 key但如果两个模块只是 namespace 不同它们还是共享同一块物理区域的剩余空间。A 模块疯狂写入把空闲空间耗尽B 模块再调nvs_set_*就会报ESP_ERR_NO_FREE_PAGES。正常使用没问题但你得心里有数namespace 是逻辑隔离不是物理隔离。代码层面很简单。模块 A 打开自己的 namespacenvs_handle_t handle_a; ESP_ERROR_CHECK(nvs_open(module_a, NVS_READWRITE, handle_a)); nvs_set_i32(handle_a, count, 100); nvs_commit(handle_a); nvs_close(handle_a);模块 B 也用同样的 keynvs_handle_t handle_b; ESP_ERROR_CHECK(nvs_open(module_b, NVS_READWRITE, handle_b)); nvs_set_i32(handle_b, count, 200); nvs_commit(handle_b); nvs_close(handle_b);这两个操作彼此不影响读module_a的count依然是 100读module_b的count才是 200。这是 NVS 自带的第一层防护。3.2 第二级隔离独立 NVS 分区如果你的多个应用之间需要更强的隔离或者某个模块的数据量很大、操作频繁最好的做法是给它在分区表里单独划一个 NVS 分区。比如刚才给的storage_a。接下来打开 NVS 时要用专用函数nvs_open_from_partitionnvs_handle_t handle; esp_err_t err nvs_open_from_partition(storage_a, sensor_data, NVS_READWRITE, handle); if (err ESP_OK) { int32_t temp 0; nvs_get_i32(handle, last_temp, temp); temp read_sensor(); nvs_set_i32(handle, last_temp, temp); nvs_commit(handle); nvs_close(handle); }这里第一个参数传的是分区名storage_a第二个参数才是 namespace。这样即使有另一个模块也在同一个芯片上运行它只要不调nvs_open_from_partition(storage_a, ...)就永远不会碰到这个分区的任何数据。3.3 划 NVS 分区时的大小经验NVS 分区不是随便给个 4KB 就能用的。它内部以页为单位管理一页是 4KB正常工作至少需要 3 页才能完成初始化。也就是说一个 NVS 分区最小容量是 12KB0x3000。我一般会给每个需要独立 NVS 的模块分 16KB 到 32KB具体看它要存多少条 key-value。如果你发现nvs_open_from_partition返回ESP_ERR_NVS_NO_FREE_PAGES别急着怀疑别家先算算分区容量和写入频率。NVS 的磨损均衡机制会让每次写操作写到不同物理页同一个 key 如果频繁写入分区消耗得很快。提示NVS 分区的 page 数量会直接影响能容纳的 key 条数。32KB 分区大概能装数十到上百个 key-value与 key 名长度、value 大小相关如果你要频繁记录历史数据老老实实用文件系统别硬塞 NVS。4. 第三道门文件系统分区日志文件也不打架4.1 为什么文件系统比 NVS 更适合大数据如果单个模块要存几千条日志、几十 KB 的配置文件、网页静态资源NVS 的 key-value 模型就不太合适了。NVS 找 key、分配 value 都带额外元数据存储效率不高而且单个 value 有 4096 字节的上限实际上更推荐控制在几百字节内。这时候就用得上文件系统。ESP-IDF 环境里常见的选择是 SPIFFS、LittleFS 和 FATFS。它们三个的对比如下文件系统磨损均衡掉电保护微软/PC 兼容适合场景SPIFFS有但一般有不兼容简单数据记录、网页资源LittleFS好好不兼容更可靠的嵌入式存储FATFS需要额外组件依赖使用方式可直接读盘需要插电脑/读卡器导出数据4.2 两种文件系统分区隔离方案方案很简单需要独立的应用在分区表里各占一个文件系统分区。比如在前面那张自定义分区表里我给了storage_b一个 SPIFFS 分区专门给模块 B 存日志。模块 A 即使想捣乱它去挂载storage_b才能碰到这个分区如果模块 A 代码里压根没写partition_label相关的调用它物理上就不可能写进去。挂载代码用 ESP-IDF 自带接口esp_vfs_spiffs_conf_t conf { .base_path /store_b, .partition_label storage_b, .max_files 5, .format_if_mount_failed true, }; esp_err_t ret esp_vfs_spiffs_register(conf); if (ret ! ESP_OK) { ESP_LOGE(APP_B, Failed to mount SPIFFS: %s, esp_err_to_name(ret)); return; }模块 B 写日志FILE *fp fopen(/store_b/log.txt, a); if (fp) { fprintf(fp, temp25.3 humidity60.1 ts%lu\n, (unsigned long)time(NULL)); fclose(fp); }模块 A 或者第三个模块 C 如果也要存数据就再给它分配一个分区、换一个base_path。每个模块只认识自己的挂载路径和分区名这在代码上就杜绝了串门。4.3 格式化开关别乱开format_if_mount_failed true这个字段低版本 SDK 里叫format_if_mount_failed高版本里 SPIFFS 的配置结构体有变化但核心逻辑一样挂载失败时自动格式化。这个开关在生产环境里一定要谨慎。如果你本来在分区里有重要数据只是因为一次异常掉电导致挂载失败开这个开关的结果就是数据全没。调试阶段可以开着正式发布时我建议设为false挂载失败时记录日志、提示恢复操作不要让系统自作主张把数据清掉。5. 实操让两个小应用在同一块 Flash 上安稳共存5.1 场景设定现在来看一个完整的可落地例子。假设我有一个 4MB Flash 的 ESP32-C3 开发板同一个固件里跑两个“小应用”应用 A温湿度数据采集器周期性把读数存下来供以后查历史。应用 BWeb 配网模块提供一个小网页保存 Wi-Fi 配置顺便放几个静态资源。这两个应用如果都往默认 NVS 里写很可能因为命名空间乱用导致配置互踩。更稳妥的做法是分别给他们独立分区。5.2 第一步定制分区表新建partitions.csv# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x1f0000, sensor_db, data, nvs, 0x200000, 0x8000, web_fs, data, spiffs, 0x208000, 0x1f8000,这里factory应用区从0x10000到0x200000差不多 1.94MB对一个几十 KB 的小固件绰绰有余。sensor_db是 32KB 的 NVS 分区给应用 A 独立用。web_fs是 SPIFFS 分区从0x208000开始一直顶到0x400000约 1.94MB给应用 B 用。在menuconfig里选Partition Table-Custom partition table CSV填路径编出来的固件烧进去后分区就按这个布局走。也可以用 CMake 变量set(PARTITION_TABLE_CUSTOM partitions.csv)5.3 第二步应用 A 的代码逻辑应用 A 只通过sensor_db分区读写采集数据。核心逻辑如下#include nvs.h #include nvs_flash.h static void sensor_app_init(void) { nvs_handle_t handle; esp_err_t err nvs_open_from_partition(sensor_db, sensor, NVS_READWRITE, handle); if (err ! ESP_OK) { ESP_LOGE(SENSOR, open sensor_db failed: %s, esp_err_to_name(err)); return; } int32_t seq 0; nvs_get_i32(handle, seq, seq); seq; nvs_set_i32(handle, seq, seq); float temp read_temperature(); nvs_set_blob(handle, last_temp, temp, sizeof(temp)); nvs_commit(handle); nvs_close(handle); }这个模块永远不会去碰web_fs也不碰默认的nvs分区里除了系统初始化以外的内容。nvs_flash_init()还是要正常调用因为WIFI、phy_init这类系统组件依赖默认 NVS 分区存校准和网络参数。5.4 第三步应用 B 的代码逻辑应用 B 挂载web_fs分区做网页和配置存储#include esp_spiffs.h static void web_app_init(void) { esp_vfs_spiffs_conf_t conf { .base_path /web, .partition_label web_fs, .max_files 8, .format_if_mount_failed false, }; esp_err_t ret esp_vfs_spiffs_register(conf); if (ret ! ESP_OK) { ESP_LOGE(WEB, SPIFFS mount failed); return; } // 检查配置文件是否存在 struct stat st; if (stat(/web/config.json, st) ! 0) { FILE *fp fopen(/web/config.json, w); if (fp) { fputs({\ssid\:\\}, fp); fclose(fp); } } }Web 界面把 WiFi 配置写到这个 JSON 文件里应用 A 完全不知情。两个应用各写各的区域互不侵犯。5.5 第四步验证数据确实没串门烧完固件跑一会儿可以用 ESP-IDF 自带的parttool.py查看各分区使用情况python parttool.py --port /dev/ttyUSB0 get_partition_info --partition-namesensor_db --info type如果要看sensor_db里实际存了什么 NVS 条目可以用 NVS 工具nvs_partition_gen.py生成器逆向解析分区内容或者干脆写一段测试代码从sensor_db读回数据再往web_fs写一个巨大的测试文件写满后回去读sensor_db的数据确认完好无损。我在实际项目里就是这么干的把web_fs里塞入几个 100KB 的 dummy 文件然后让应用 A 反复重启读last_temp数据一直稳定正确。那一刻才能真正放心——分区表没有重叠API 也守规矩串门问题就算解决了。6. 常见问题与排查技巧实录6.1 现象nvs_open返回ESP_ERR_NO_FREE_PAGES原因你给这个 NVS 分区分配的大小不够或者某个模块疯狂写入把页耗尽。NVS 内部的 namespace 也是要占空间的每次 commit 都会消耗条目。排查先看分区表里给 NVS 分区的大小再看代码里有哪些地方反复nvs_set_*不 commit。很多人写了nvs_set_*忘掉nvs_commit数据其实没有持久化但日志看起来像在不停写入。正确做法是低频数据只做有限的 commit设计上避免高频 key 更新。6.2 现象分区表改了以后 OTA 升级变砖原因最常见的是新旧固件分区表不一致OTA 应用槽位的地址对不上。比如原来 ota_0 在0x10000升级包按新表把 ota_0 挪到0x20000引导程序按旧表到0x10000去执行那里写的是全新固件的数据而不是代码直接崩。排查定稿分区表后整个产品生命周期里不要轻易改。如果一定要改把升级固件做成一个大版本通过 USB 或串口方式重新烧录 bootloader 和分区表而不是走 OTA 应用升级流程。6.3 现象SPIFFS 挂载反复失败原因format_if_mount_failed true导致系统在文件系统异常时自动格式化如果 Flash 里残留的是另一个文件系统或全 0xFF格式化应该能解决但某些坏块会导致格式化中断。另外SPIFFS 分区大小必须是 4KB 的倍数过小小于 16KB也可能出问题。排查用esptool.py erase_region把对应分区擦除一遍再重新挂载。别急着怀疑是代码问题Flash 写穿、掉电导致元数据损坏也是常见原因。日志里打开CONFIG_SPIFFS_DBG_PRINT能看到更详细的信息。6.4 现象数据没串门但是重启后数据丢了原因如果应用是通过 OTA 槽位启动的它访问的存储分区可能和 factory 槽位时不同。尤其是分区表里用了ota_0、ota_1这种动态选择槽位如果代码里硬编码了当前应用所在分区地址来建文件系统很可能挂载的是另一个槽位的区域重启后启动顺序一变数据就不见了。排查代码不要硬编码分区地址或使用固定名称。对应用分区用esp_ota_get_running_partition()拿到当前分区对数据分区用esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, sensor_db)动态查找。这样即使分区表调整代码依然能正确找到位置。6.5 现象esp_partition_write返回ESP_ERR_INVALID_ARG原因写入地址越界或者写入长度跨出了当前分区边界。esp_partition_write会做边界检查这是保护机制不是 Bug。常见于有人把偏移量传成了全局 Flash 绝对地址而不是分区内偏移。排查仔细看 API 文档esp_partition_t结构体里有address和size写入函数要求的 offset 是相对于分区起始地址的偏移。如果非要从绝对地址换算用offset - partition-address。现象常见原因处理办法ESP_ERR_NO_FREE_PAGESNVS 分区太小或频繁写增大分区减少 commitSPIFFS 挂载失败分区被格式化为其他格式erase_region后重挂载OTA 后启动失败分区表不一致升级时不要改分区表写入越界偏移量用了绝对地址改用分区内偏移数据读取错乱硬编码地址跨分区用esp_partition_find动态查找7. 最后一点建议先把分区图画出来再写代码我个人做 ESP32 多应用项目的流程永远是先拿一张纸把 Flash 布局画清楚。哪怕只是一个人开发画出来的分区表也要当成“宪法”一样对待代码里用到的分区名、子类型、挂载路径全部以这张表为准。如果项目正在从单应用演变成多应用也不要直接往默认分区表上加新分区而是把整个数据区重新规划一遍。Flash 擦除是按扇区来的重新划分分区表之后旧数据基本都要格式化早发现早处理比上线之后再补救省太多事。最后再分享一个小习惯写完数据之后不管用 NVS 还是文件系统我都会在启动时做一次完整性自检——读一个固定的 magic 数如果不匹配就进入恢复流程。这层“最后一道保险”在防串门体系里不起决定性作用但它能让你在极端情况下第一时间发现问题而不是等到用户反馈才去查。