在嵌入式项目里,“多应用共用一个ESP32”这件事我见过太多人踩坑了。很多朋友一开始只是想在同一个板子上跑一个传感器上报、一个蓝牙控制、一个OLED菜单,觉得“反正都是往Flash里写点东西,有什么关系”,结果过了一两个星期,数据就开始串门:蓝牙模块存的WiFi密码把传感器模块的校准数据覆盖了,OTA升级之后另一个应用的配置全丢了,甚至开机直接reboot loop。这些问题的根源,几乎都指向同一个东西——Flash上的分区和隔离没做好。
这篇文章我尽量用一个完整的工程视角来聊:从分区表怎么设计,到NVS命名空间怎么用,再到文件系统、自定义存储协议怎么做到真正“井水不犯河水”。如果你想在自己的ESP32项目里让多个功能模块长期稳定共存,这篇文章应该能帮上不少忙。
1. 先搞清楚数据为什么会“串门”
很多刚接触ESP32的开发者会把它当成一个“性能更强的Arduino”,但ESP32和Arduino有个本质区别:它内置的Flash不仅要放代码,还要放配置、证书、OTA镜像、文件系统。如果你不管这些,系统自然按照默认方式把Flash分成几个固定区域,所有应用都往同一个NVS分区里写数据,冲突只是时间问题。
1.1 一切从分区表说起
ESP32的Flash布局完全由分区表(partition table)决定。分区表处于Flash某个固定偏移位置,通常由partition_table.csv定义,然后随固件一起烧录。板子上电后,bootloader会读这张表,知道哪个地址是应用、哪个地址是NVS、哪个地址是文件系统。
分区表默认长这样:
# 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, 0x200000,注意最后那一行,factory分区占了2MB。如果固件只有几百KB,剩余空间就白白浪费了,更麻烦的是,所有应用共用同一个nvs分区和同一个factory分区,数据自然全堆在一起。
1.2 数据串门的典型现场
根据我对大量项目的观察,数据“串门”通常表现为三种情况:
- 键名冲突:两个模块都往NVS里写了
ssid,A模块改WiFi配置,B模块读到的是A模块写入的值。这不是玄学,是NVS里所有键都服从同一个命名空间,键名相同就会被覆盖。 - 分区爆满:某个模块无节制地写日志,把文件系统分区占满,另一个模块的配置写不进去,程序崩溃后配置丢失,主功能直接停摆。
- OTA导致配置丢失:升级固件时,OTA应用分区被新固件替代,但某些开发者图省事,把配置也放在app分区里。固件一更新,配置跟着被冲掉。
这三种情况处理不好,系统在量产阶段会非常不可控。我见过两类人:一类是项目跑在demo阶段,怎么弄都不出问题;另一类是上了量产、现场部署了上百台设备,被数据问题折磨得天天远程排查。
设备一旦在客户现场出问题,你能做的往往只有远程升级,但远程升级能不能成功、升级后数据还在不在,都取决于最开始的分区设计。这个事没有后悔药,设计阶段必须想清楚。
2. 第一步:用分区表做物理隔离
数据隔离最彻底的方式是物理隔离:每一个小应用拥有独立的Flash分区,彼此地址互不重叠,谁也不能跨分区访问别人的空间(除非你主动用底层Flash API去越界操作)。在ESP32上,这通过自定义分区表实现。
2.1 分区表语法与关键参数
这里给一个更实际的自定义分区表示例:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, app_factory, app, factory, 0x10000, 0x100000, app_ota_0, app, ota_0, 0x110000, 0x100000, app_ota_1, app, ota_1, 0x210000, 0x100000, nvs_ble, data, nvs, 0x310000, 0x4000, nvs_sensor, data, nvs, 0x314000, 0x4000, storage_ui, data, fatfs, 0x318000, 0x40000, storage_log, data, fatfs, 0x358000, 0x40000,这只是一个示例,不要直接复制到项目里。一个关键点是:手动指定Offset时,每个分区起始地址必须按照0x10000(64KB)对齐,这是ESP32 Flash映射和加密的要求。而NVS这类data分区的偏移,在不追求极端省空间的情况下,也要尽量对齐,否则后续维护分区表时很容易算错。
在修改分区表之前,先看一眼芯片的Flash实际大小,比如4MB还是8MB。很多开发板标称4MB,实际可用可能是3.9MB左右,因为有一部分被bootloader和分区表占用了。在设计时,总大小不要顶着上限,留一点余量。
2.2 各分区的职责划分
以我的经验,把每一个小应用看作“一个人”,分配给它一块“私有土地”:
- nvs_ble:蓝牙模块的MAC地址、绑定信息、配对状态;
- nvs_sensor:传感器校准参数、采样阈值、上报间隔;
- storage_ui:用户配置、屏保图片、字库文件;
- storage_log:运行日志、异常记录。
这样,蓝牙模块哪怕把NVS写烂了,也不影响传感器模块的数据。更重要的是,每个模块的代码里,初始化和读写的地方,都要显式指定自己的分区名。
在ESP-IDF里,NVS的打开方式从默认命名空间换成自定义分区:
esp_err_t err = nvs_flash_init_partition("nvs_sensor"); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase_partition("nvs_sensor"); nvs_flash_init_partition("nvs_sensor"); } nvs_handle_t handle; nvs_open_from_partition("nvs_sensor", "calib", NVS_READWRITE, &handle);注意nvs_open_from_partition的第一个参数是分区名,第二个参数才是命名空间。如果你在代码里用了nvs_flash_init()而不是nvs_flash_init_partition(),那仍然初始化的是默认分区,隔离也就形同虚设。
2.3 烧录分区表的正确姿势
在Arduino IDE中,有对应的Partition Scheme选项,比如“Default 4MB with spiffs”或“Huge APP”。但选项里的自定义程度有限,工程复杂后我更推荐用ESP-IDF配合idf.py构建,然后在menuconfig中指定分区表CSV文件。
如果你已经用Arduino IDE做开发,也不是没有办法:tools/partitions/目录下可以放自定义CSV,但每次切换项目要确认选中的是哪一个。烧录时有一个我常犯的错:只点了Flash固件,没烧分区表,结果新分区表没有生效。这会导致bootloader按旧分区表解析地址,轻则找不到应用,重则启动异常。所以,工程里最好写一个烧录脚本,把分区表、bootloader、应用固件一次性烧进去:
esptool.py --port /dev/ttyUSB0 erase_flash idf.py flasherase_flash是不是必须的?不一定,但第一次切到新分区表时,强烈建议做一次全片擦除,把旧的NVS和文件系统残留清干净。
3. 第二步:分区内部再加一道“软隔离”
物理分区隔离解决的是不同应用之间的空间冲突,但同一个分区内部,如果多个逻辑模块共用,仍然会互相踩。尤其是NVS,每个分区内部还有命名空间和键名两层结构,用不好照样乱。
3.1 命名空间给你的数据加一层“文件目录”
NVS的内部结构类似一个小的键值数据库,每个键都属于一个命名空间。如果你打开时传入的命名空间不同,那么即使键名相同,也不会互相覆盖。这相当于在同一个分区里,给每个模块一个独立的“文件夹”。
我这里强烈建议一个团队规范:命名空间命名格式统一为模块名_功能域,比如:
wifi_configble_bondsensor_calibui_prefs
只要命名空间不同,同名的键也没事。我见过某些项目里,开发者为了省事,用一个万能命名空间,所有键都堆在一起。当一个模块删键时,如果手滑删了别的模块的键,问题就很难排查,因为键名可能只在某一次提交里出现过,删完就没了。
3.2 键名规范:用前缀标明所有者
即使有了独立的命名空间,我还是建议键名带上前缀,比如传感器模块用sen_前缀,蓝牙模块用ble_前缀。这个不是技术强制,但会极大降低多人协作时的沟通成本。试想一下,你在读代码时看到nvs_get_i32(handle, "offset", &val),你完全不知道这个offset是哪个模块的;但如果是nvs_get_i32(handle, "sen_offset", &val),一眼就能判断归属。
键名长度在NVS中有限制(不超过15个字符),所以前缀尽量短,2~4个字符比较合适。命名空间和键名是两级限制,就算命名空间不同,键名雷同也会让日志分析变得混乱,统一规范后,各种读取工具显示的数据一目了然。
3.3 文件系统同样需要划分
如果要在ESP32上跑LittleFS或FatFs,最怕的就是多个模块共享一个文件系统根目录。A模块生成了一个config.json,B模块因为某种原因也写了一个config.json,后者覆盖前者,数据就丢了。
推荐做法是每个分区固定属于特定模块,并且在分区表里就定义好,比如上面例子中storage_ui、storage_log两个分区,一个放资源配置,一个放日志。如果两个模块确实需要共享文件,那就约定一个中间层目录,比如/shared/,或者通过一个统一的文件管理接口来读写,避免直接在业务代码里拼接路径、瞎写文件。
在ESP-IDF里用LittleFS时,需要为每个分区单独挂载:
esp_vfs_littlefs_conf_t conf = { .base_path = "/ui", .partition_label = "storage_ui", .format_if_mount_failed = true, .dont_mount = false, }; esp_vfs_littlefs_register(&conf); esp_vfs_littlefs_conf_t log_conf = { .base_path = "/log", .partition_label = "storage_log", .format_if_mount_failed = true, .dont_mount = false, }; esp_vfs_littlefs_register(&log_conf);这样在模块代码里,UI相关代码只访问/ui/,日志相关代码只访问/log/,谁也不会误删对方的内容。
3.4 自研存储协议:别忽略魔数和长度字段
如果你的应用需要保存一些结构体数据(比如校准参数数组、自定义配置结构),自己定义二进制协议时,我强烈建议至少加两个字段:
- 魔数(Magic Number):用于判断这段数据是否有效,比如
0xA5A5开头; - 版本号(Version):数据格式升级时,如果不兼容旧数据,可以据此做迁移。
这一条做不做好不好,产品上线后差别很大——数据损坏时,没有魔数你就只能“盲猜”,有魔数一套校验逻辑就能恢复出厂或自适应迁移。从工程角度说,这也是对“数据隔离”的低成本加码:即使分区没做好,至少应用层能识别出“这数据不是我的”。
4. 实操:三种典型场景下的完整配置
理论讲了一堆,还是结合实际场景来说更直观。我按平时项目里见到的三种情况,分别给出配置思路和关键代码。
4.1 场景一:一个固件里跑三个业务模块
假设你的设备同时包含:定时器上报、按键交互、LED状态指示(逻辑上算三个模块)。三者都要存少量配置。
这种情况下,物理分区可以合并成一个NVS分区,但每个模块用独立命名空间。分区表可以简化为:
nvs, data, nvs, 0x9000, 0x6000,代码里:
// 模块A:定时器上报 nvs_open_from_partition("nvs", "timer_mod", NVS_READWRITE, &handle_a); // 模块B:按键交互 nvs_open_from_partition("nvs", "key_mod", NVS_READWRITE, &handle_b); // 模块C:LED状态 nvs_open_from_partition("nvs", "led_mod", NVS_READWRITE, &handle_c);这样三个模块虽然都在nvs分区里,但命名空间隔离后,互相不干扰。这个方案物理上不推荐但逻辑上够用;优点是简单、省空间,缺点是排查时还是要靠命名空间区分。
如果业务后续可能OTA升级,其中一个模块需要单独升级,我更倾向给这个模块划独立分区,否则升级过程中整个NVS分区都有可能被重新格式化。
4.2 场景二:蓝牙配网应用与主控应用并存
这种场景很典型:设备上电后先进入配网模式,手机通过蓝牙把WiFi账号密码发给设备,设备存好之后重启,主控应用开始工作,期间还要通过同一块Flash保存配网状态。
这里需要两个数据分区:
nvs_net:配网状态、WiFi凭据;nvs_app:主控应用的业务参数。
配网模块的代码里初始化时,只操作nvs_net;主控模块只操作nvs_app。这样配网出错重来,也不会损害已经跑起来的主控业务数据。
独立分区最大的好处是,你可以安全地“复位某个模块”。例如,让设备重新配网,只要擦除nvs_net分区,而不影响其他数据:
nvs_flash_erase_partition("nvs_net"); nvs_flash_init_partition("nvs_net");4.3 场景三:OTA升级与回滚时的数据保护
OTA升级时,最有风险的不是应用代码写不进Flash,而是新固件把老固件的配置数据覆盖。OTA分区设计上,老固件和新固件分别跑在不同OTA分区,数据分区如果共用一套,很容易在新版本固件首次启动时就把旧配置格式重写,导致无法回滚。
推荐的分区表结构是:
otadata, data, ota, 0xd000, 0x2000, app_ota_0, app, ota_0, 0x10000, 0x180000, app_ota_1, app, ota_1, 0x190000, 0x180000, nvs, data, nvs, 0x310000, 0x6000, my_data, data, nvs, 0x316000, 0x4000,为了保证回滚后数据兼容,新版本固件写入NVS之前,一定要先检查数据版本。如果分区里已有旧版本数据,要么保留,要么备份,不要直接格式化。很多生产环境下的回滚失败,不是应用固件本身的问题,而是新固件把旧数据覆盖了,回滚后旧固件无法识别新格式的数据。
我在实际项目中是这样处理的:
- 升级前,把NVS关键数据备份到
my_data分区; - 升级后,新固件读取数据,尝试解析,如果版本不匹配,先从备份分区恢复;
- 系统确认稳定运行24小时后,自动清除备份。
这样最坏情况也就是丢最近一天的数据,不至于全盘归零。
5. 常见问题与排查技巧实录
多应用共用Flash后,故障现象千奇百怪,但根因往往就那么几种。我把项目里遇过的问题集中整理一下,顺便说说我的排查路径。
5.1 启动时反复重启(Reboot Loop)
现象:上电后Bootloader正常,但主固件启动几秒后崩溃,不断重启。
排查思路:
- 第一步,看串口日志。ESP-IDF会输出类似
E (xxx) partition: Partition invalid或E (xxx) spiffs: mount failed的信息。 - 第二步,确认分区表是否烧录成功。用
esptool.py read_flash读回分区表区域,看内容是否和自己设计的一致。 - 第三步,确认每个分区大小是否够用。有时候NVS初始化时空间不够,就会一直报
ESP_ERR_NVS_NO_FREE_PAGES。
如果发现是OTA升级后导致的loop,优先做回滚检查。很多情况下不是代码逻辑问题,而是NVS数据格式不兼容,导致新固件初始化失败。
5.2 NVS读写出现奇怪的错误码
现象:数据偶尔写不进,读出来是乱码,或者nvs_get_str返回ESP_ERR_NVS_NOT_FOUND,但明明之前写过。
这类问题最常见的原因是存储空间不足。NVS每次写入都会消耗一个Page,如果分区太小且频繁写入,很快就满了。解决方案有两个方向:
- 调整分区大小,把NVS分区增大到至少16KB,如果有大量字符串类型数据,建议32KB甚至更大;
- 降低写入频率,把短时间多次写入合并成一次,或者定期刷写。
还有一个小技巧:NVS分区里存大字符串尽量不要超过几十个字节,超过的话会占用多个Entry,消耗速度成倍增加。如果数据量确实大,请改用文件系统分区。
5.3 文件系统文件不翼而飞
现象:某个模块写了一个配置文件到LittleFS,过几天发现文件变成0字节或者直接不存在。
这种问题我遇到过最隐蔽的根因是:两个模块同时挂载了同一个分区,A模块在格式化时B模块还在写文件,文件系统被搞坏了。即使不同的模块挂载不同分区,如果没做重新挂载检查,掉电时也可能导致目录项损坏。
排查时要确认两点:
- 每个挂载点对应的分区是否唯一;
- 掉电保护是否有做——esp_littlefs在
format_if_mount_failed = true时会自动格式化,如果设备经常掉电导致挂载失败,数据分区就会被频繁格式化,文件自然“消失”。
解决方式是定期执行esp_littlefs_check,或者每次启动时检查文件系统状态,如果发现异常,先把数据复制到另一个临时分区,再重新挂载。
5.4 我的排查工具链
数据隔离问题,最怕的就是“玄学”。排查时我会用一套固定流程,可复用性很高:
- 读取分区表实际状态,确认当前Flash上确实烧着预期的分区结构;
- 读取NVS内容,用一个简单的Python工具把整个NVS分区dump出来,检查每个命名空间里的键和值;
- 比对时间戳,如果两个模块都写日志,对比日志时间能反推出谁先覆盖了谁;
- 强制复现,人为制造分区满、掉电、写入失败三种情况,看系统能否自恢复。
这套流程看起来简单,但每次都能快速定位问题,比我一开始“逐行看代码”高效得多。因为数据串门往往不是某一行代码的问题,而是系统整体设计的问题。
5.5 关于Flash磨损的一个提醒
多应用反复擦写同一块Flash,还会引入一个平时不太注意的问题:Flash的擦写寿命。ESP32内置Flash按P/E周期算,通常几千到上万次之间(具体取决于Flash颗粒)。如果你某几个模块高频写入日志,那块区域的寿命会被迅速消耗。
数据隔离做得细一点,本身就能缓解磨损:因为写压力会被分散到多个分区,不会集中在某一个NVS页上。另外,为日志类数据使用环形覆盖策略,不要无限追加;为配置类数据尽量只在变化时写入,不要每次启动都格式化。
写在最后
从分区表到NVS命名空间,再到文件系统挂载和自研协议,多应用共用Flash这个问题,本质上是一个“资源边界”管理问题。边界放得越清楚,系统就越稳。我自己现在做新项目,第一件事就是根据业务模块画出分区表,而不是等代码写完了再回填,这个习惯帮我省了无数次远程救火的麻烦。
最后一个小建议:在项目早期,就用脚本固化一套完整的烧录和分区检查流程。数据隔离靠的不只是某个函数写得好,而是整个构建、烧录、升级链路都把分区边界当作一等公民对待。你可以先从最简单的命名空间隔离开始,慢慢过渡到独立分区,再考虑OTA和文件系统。别急着追求一步到位的复杂方案,关键是让每一次改动都有据可查,数据归属清清楚楚。