做嵌入式这些年,我见过太多次“程序逻辑都对,但跑着跑着数据就串了”的闹心事。前阵子帮朋友排查一个ESP32网关设备,一块4MB的Flash上同时跑了三个小应用——温湿度采集、两路继电器控制、一块OLED面板显示。三个功能模块代码编进同一个固件,共用同一个Flash芯片。结果跑了一周,怪事接二连三:温度传感器的校准值隔两天就被重置一次,继电器的时控规则偶尔莫名其妙少几条,有一次OTA升级完,整个设备的行为像是换了个固件。排查到最后,问题基本都出在同一个地方:多个应用共用Flash时,没有把“地盘”和“命名空间”真正划清楚。
很多人觉得Flash就是个“大U盘”,数据写进去天然各占各的位置,怎么会串门?实际上ESP32的Flash比想象中敏感得多:它不像电脑硬盘那样有操作系统帮忙维护全局文件目录,很多配置数据的存储位置直接由代码指定;分区表就是Flash的“房产证”,谁在哪一段地址、谁有多大空间,全写在里面。没把这张证理清楚,任何模块都有可能踩进邻居家。这篇文章我从实际踩坑经验出发,把多个小应用共用ESP32 Flash时最核心的隔离方案讲透,包括分区表怎么配、NVS命名空间怎么用、LittleFS怎么挂、OTA升级和业务数据怎么隔离,以及线上问题的排查思路。
1. 先理解“共用Flash”到底是怎么个共用法
1.1 ESP32里的小应用,实际存在形态
要把数据隔离做好,得先承认一个现实:ESP32不是类似PC或手机的操作系统式MCU,不能同时在内存里跑多个独立进程的“真多任务”。我们常说的“多个小应用”,在工程上通常是三种形态。
第一种是单固件多模块。这是最常见的形态,温湿度采集、继电器控制、显示面板这些功能模块的代码全部编进同一个二进制固件,在main函数里统一调度或交给FreeRTOS分任务跑。这种形态下,所有模块的代码区共享同一个app分区,但它们各自的配置、日志、用户数据都存在Flash的数据区域里。如果代码里随手用固定偏移写Flash、随手用同一个NVS key读配置,数据串门几乎是必然结果。
第二种是OTA多固件切换。设备出厂烧录固件A,后续通过OTA下载固件B到另一个app分区,重启后切换运行。这时候,不同固件之间共享同一块物理Flash,但代码区被分配到了不同的分区。用户可以升级固件,但业务数据必须保留,而且新固件还要能读懂旧固件写下的数据格式。这属于更高层级的数据隔离问题,处理的不好,就会出现“升级后设备变砖”或者“升级后旧配置全部丢失”。
第三种是把Flash当“裸存储”用。有些应用不依赖文件系统,直接在自定义偏移地址上往Flash里写采集日志、标定参数、波形样本。多个模块各自定义偏移,但如果没有在分区层或代码层做好边界控制,地址一旦重叠,后写的数据就会覆盖先写的数据,这种串门最隐蔽,通常只在特定擦写顺序下才暴露。
我遇到的大多数问题都出在第一和第三种形态。而一个常见的误区是:很多人第一反应是“加个文件系统不就好了”——文件系统只是管文件的,它并不能决定“你的数据该存在哪个分区哪个目录”。如果所有模块共享一个文件系统根目录,文件名再撞车,照样互相踩。
1.2 数据串门的典型征兆与根因
先把症状说清楚,方便你对号入座。以下现象我全都实际碰到过:
- 设备重启后,某个模块的配置偶尔恢复出厂值。
- A模块写入的数据,在某个时刻被B模块读到一半,比如半条日志、半个结构体。
- 两个模块写同一个文件路径,后启动的模块覆盖先启动模块的数据,但读数据的老模块却毫不知情。
- OTA升级后,旧版本的业务日志出现在新版本功能里,或者新固件一启动就崩溃——原因多半是新固件以为自己独占的地址被旧数据写乱了。
- Flash明明显示还有空间,但文件系统写不进去,NVS报“not enough space”。这往往是分区分配不合理,某个模块的数据占满了别人的区域。
追根溯源,根因不外乎四类:
没有统一的分区规划。所有人最初都对着出厂默认分区表写代码,默认表只有一个app区和一个很小的NVS区。多个模块都要写数据,NVS空间不够用,有人就开始用esp_partition_write直接往“看起来空闲”的地址塞数据,这不串门才怪。
没有命名空间意识。ESP32的NVS是键值存储,本身支持namespace概念,类似数据库里的“表”。很多人的代码习惯是nvs_open("storage"),然后用一个通用的"mode"、“delay”当key,另一个模块也这么写,key一旦相同,值就互相覆盖,且毫无提示。
文件系统挂载混乱。图省事的话,把所有模块的数据分区都挂到同一个路径下,文件名也没有模块前缀。A模块写“/data/config.bin”,B模块也写“/data/config.bin”,后写覆盖先写,再正常不过。
地址计算错误或越界。自定义分区时偏移量算错、没按扇区对齐、size填错,或者代码里用一个16位变量保存偏移地址导致在64KB处溢出。这类问题单模块时偶尔能跑,多模块时概率被放大,而且只在特定擦写顺序下触发,极难排查。
2. 分区表:给Flash画好红线
2.1 Flash布局与分区表原理
先看ESP32的Flash整体布局,以最常见的4MB Flash为例。地址从0x000000开始:
- 0x000000附近是烧录元信息。
- 0x001000位置通常放一级引导程序(bootloader)。
- 0x008000位置默认放分区表(partition table)本体,一般占用最多0x001000字节。
- 0x010000之后,才是不同的app分区和数据分区。
分区表就是一条条32字节的记录,每条记录关键字段包括:type(app还是data)、subtype(如data/nvs、data/spiffs、app/factory、app/ota_0)、offset(起始物理地址)、size(分区大小)、label(自定义名称)。
为什么必须有这张“房产证”?因为ESP32本身不知道某个地址属于哪个功能,一切由分区表告诉bootloader和运行时API。上电时bootloader读分区表去找app/factory或ota_0,把代码加载执行;NVS初始化时读data/nvs分区;LittleFS挂载时按label找到data/spiffs分区。分区表一旦和实际写入不一致,代码访问到的分区就可能根本不是自己以为的那一块。
分区表设计有两个硬性纪律:
偏移量必须按4KB对齐。ESP32内部Flash的擦除最小单位通常是4KB扇区(部分外部Flash可能是8KB甚至更大)。分区起始地址如果不是4KB整数倍,读写时就会跨扇区边界擦写,轻则效率下降,重则逻辑错乱。
分区之间不能重叠,也不能超过Flash总容量。4MB就是0x400000,所有分区的offset加size,不能越过这个上限。我见过太多人算错十六进制加法,明明写到最后已经超出Flash容量,编译烧录居然还能过——因为工具并不强制检查所有场景。
2.2 手写custom partition,给每个应用留专属空间
在ESP-IDF里,通过menuconfig的“Partition Table”选项选择“Custom partition table CSV”,并指定partitions.csv路径。Arduino环境则在Tools->Partition Scheme里选Custom,原理相同,只是配置入口简化了。
下面是一个实际项目用的分区表(4MB Flash,支持OTA升级,数据分区独立):
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, ota_0, app, ota_0, 0x10000, 0x1C0000, ota_1, app, ota_1, 0x1D0000, 0x1C0000, storage, data, spiffs, 0x390000, 0x70000,这个表里值得说明的细节:
nvs分区从0x9000开始,大小0x4000即16KB。对多数小应用来说足够,三个模块各自开namespace,数据量都不大,16KB绰绰有余。如果数据量大,可以给到24KB,但注意别挤占后续分区。
otadata分区从0xd000开始,大小8KB,专门给OTA切换状态用。phy_init存放WiFi/BT射频校准数据,一般固定4KB,放在0xf000。
两个OTA app分区各1.75MB。对多数业务固件够用,如果固件超过这个体积,就要考虑使用8MB Flash,否则别硬塞。
storage数据分区从0x390000开始,大小448KB,给文件系统使用。这里设计了0x390000+0x70000=0x400000,恰好用满4MB Flash,既没有浪费,也没有越界。
我在自己的项目里通常额外预留一个小的自定义数据分区,比如叫calib,给产线标定数据或者需要直接读写的业务数据。不过要记住:分区表文件越多分区越要核对,每加一个分区都要重新验算一遍offset、size和总容量,绝不能靠眼睛目测。
注意:ESP-IDF较新版本里,分区表甚至可以放多个表(multi-table),但常规项目用默认单表就够了。复杂方案容易引入新坑,能用简单方案解决就不要人为增加复杂度。
2.3 分区表怎么验证装没装对
改过分区表的朋友应该都有过这种经历:代码里明明把分区表换成custom了,烧录时却忘记烧新分区表,运行时读到的还是老表。所以验证这一步绝对不能省。
第一种验证方法是烧录后用esptool读回Flash内容:
esptool.py -p /dev/ttyUSB0 read_flash 0x8000 0x1000 partition_table.bin python {IDF_PATH}/components/partition_table/gen_esp32part.py partition_table.bin如果打印出来的条目和你CSV里一致,说明分区表已经生效。如果发现还是默认老表,说明烧录步骤有问题,比如只烧了app没烧partition-table。
第二种验证方法是代码里动态读取分区信息:
const esp_partition_t *part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "storage"); if (part == NULL) { ESP_LOGE(TAG, "storage partition not found, check partition table!"); } else { ESP_LOGI(TAG, "storage at 0x%x, size 0x%x", part->address, part->size); }如果打印NULL,要么是label写错,要么是分区表压根没烧进去。
在Arduino环境里尤其要留意:Arduino的烧录流程不一定每次都会把新分区表写入Flash。我遇到过“编译显示Custom分区,烧录成功,但运行还是老表”的情况。解决方法是先执行一次esptool的erase_flash把整片擦除,再重新烧bootloader、分区表和应用,能解决绝大多数旧表残留问题。
3. 三种数据隔离方案,按场景选
3.1 NVS命名空间:最省事、改代码就行
NVS(Non-Volatile Storage)是乐鑫提供的一套键值存储,内置磨损均衡,适合存小数据量的配置项。它最重要的隔离机制就是namespace。
来看两种不同的模块怎么写自己的数据。先看温湿度采集模块:
#include "nvs_flash.h" #include "nvs.h" // main函数里统一初始化一次 esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { // 这里才允许擦掉重建,而且要打日志 ESP_LOGW(TAG, "NVS partition faulty, erase and re-init"); nvs_flash_erase(); nvs_flash_init(); } // 温湿度模块写入自己的参数 nvs_handle_t temp_h; nvs_open("temp_humi", NVS_READWRITE, &temp_h); nvs_set_i32(temp_h, "calib_offset", 5); nvs_set_i32(temp_h, "sample_period", 60); nvs_commit(temp_h); nvs_close(temp_h);再看继电器模块:
nvs_handle_t relay_h; nvs_open("relay_ctrl", NVS_READWRITE, &relay_h); nvs_set_i32(relay_h, "delay_ms", 300); nvs_set_i32(relay_h, "default_state", 1); nvs_commit(relay_h); nvs_close(relay_h);两个模块的namespace不同,哪怕key都叫“mode”,在NVS内部也是两个独立键域,互不影响。这是最简单的一层隔离,也是我强烈建议项目一开始就定好的规范:
- 每个模块一个namespace,名字用模块缩写,比如temp_humi、relay_ctrl、disp_ui。
- key名统一小写加下划线。
- nvs_flash_init在整个项目中只调用一次,放在main里,不要让每个模块自己乱调,否则多个任务同时初始化NVS容易出内部状态错乱。
NVS有一些隐形的限制,写代码前必须知道。
namespace和key的实际可用长度都是15个字符(不含结尾的空字符),别起太长。NVS适合小块稀疏键值,不适合几千字节以上的连续数据和日志流,日志会带来大量磨损和碎片。NVS的写入只是单键原子,如果一个业务配置涉及多个key,需要组合更新时,建议在业务层加一个版本号或者先写“预备区”,避免断电后出现“半套配置”的怪状态。
3.2 文件系统分区:LittleFS/SPIFFS 按块隔离
数据量一大、结构变复杂,比如采集历史日志、规则文件、UI图片,NVS就力不从心了。这时候应该用文件系统。ESP32上常用的是SPIFFS和LittleFS。
我的选择倾向是LittleFS,原因有三个:目录语义更正常,损坏后恢复能力更强,对路径层级和文件管理的开销也更合理。SPIFFS其实是扁平文件系统,所谓的“目录”只存在于路径字符串里,文件本质上全是平铺的,一旦文件多了性能下降很明显。LittleFS需要额外引入esp_littlefs组件,Arduino环境通常自带LittleFS库,配置也方便。
自定义分区storage对应的挂载代码长这样:
#include "esp_littlefs.h" esp_vfs_littlefs_conf_t conf = { .base_path = "/data", .partition_label = "storage", .format_if_mount_failed = true, .dont_mount = false, }; esp_err_t ret = esp_vfs_littlefs_register(&conf); if (ret != ESP_OK) { ESP_LOGE(TAG, "LittleFS mount failed"); return; }这里有一个非常重要的开关:format_if_mount_failed。开发期设成true确实省事,挂载失败自动格式化。但量产固件必须设成false,否则一旦文件系统元数据损坏,系统会把整个分区格式化,等于把所有模块的数据全部清空。我的建议是量产固件里挂载失败时只打印明确错误,并对受影响的模块做降级处理,而不是自动格式化。
多应用共用同一个文件系统分区时,怎么避免串门?我的办法是按目录分域,并且把目录名作为项目文档的硬性约定:
- /data/temp/ 温湿度模块的采集记录、曲线缓存
- /data/relay/ 继电器模块的时控规则
- /data/ui/ 显示模块的字体、图标资源
- /data/common/ 公共模块的版本信息、设备状态
除了目录,文件名也别太通用。“data.bin”这种名字在同一个目录里出现两次就是灾难。规范做法是文件名带模块前缀,比如temp_latest.bin、relay_policy.json。
另外要注意多任务并发写文件的问题。ESP32上的多个FreeRTOS任务同时操作LittleFS,容易出现文件内容交错。我的项目里用一个互斥锁保护文件操作区:
static SemaphoreHandle_t fs_lock; void write_data_file(const char *path, const char *buf, size_t len) { xSemaphoreTake(fs_lock, portMAX_DELAY); // 执行文件打开、写入、关闭 FILE *f = fopen(path, "wb"); if (f) { fwrite(buf, 1, len, f); fclose(f); } xSemaphoreGive(fs_lock); }加锁之后,曾经出现过的“文件内容互相穿插”问题再也没有复发过。如果项目里文件写入很频繁,可以把锁细化到单文件级别,但项目初期全局文件锁是最不容易出错的方案。
3.3 自定义分区直写Flash:要快还要稳的挑战
有些场景里文件系统开销太大,或者数据格式特殊,比如高频采集的传感器波形、产线标定数据,需要直接按地址读写Flash。这时应该通过esp_partition API操作一个专门划分出来的分区。
先拿到目标分区的句柄:
const esp_partition_t *part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_UNDEFINED, "calib"); if (part == NULL) { ESP_LOGE(TAG, "calib partition not found"); return; }对应的分区表条目可以是这样:
calib, data, undefined, 0x390000, 0x10000,然后按偏移读写:
esp_err_t err = esp_partition_erase_range(part, 0, 0x10000); if (err == ESP_OK) { err = esp_partition_write(part, 0, calib_buf, sizeof(calib_buf)); }直接操作分区有三个致命坑,每个我都踩过。
第一,写入前必须先擦除。Flash写入只能把1写成0,要写0x00到一个地址,必须先erase把整块恢复成0xFF,否则数据会变成逻辑“或”的结果。擦除粒度和Flash型号强相关,多数是4KB扇区,务必按4KB对齐操作。
第二,没有坏块管理和磨损均衡。NVS和文件系统内部都有磨损均衡逻辑,直接用esp_partition写等于裸奔。如果高频写同一个扇区,Flash寿命会急剧下降。我的经验法则是:直接写只适合低频一次性数据,比如标定、产测、配置备份;高频写数据要么设计双缓冲轮换扇区,要么回到文件系统。
第三,读改写不原子。一个结构体如果跨越了两个扇区边界,写入中途断电,可能造成两个扇区数据互相交叉损坏。为了解决这个问题,我在生产代码里用“双备份+提交标志”的做法:
- 在两个独立扇区写同一份数据。
- 启动时先读备份区,校验CRC,正常则使用,并尝试同步主区。
- 写入顺序是:先写备份区,再写主区,最后写一个提交标志到另一块位置的固定偏移。
- 读数据时校验失败就自动切换另一份备份。
这套方案在工业设备里很常见,代价是多花一倍空间和一部分启动校验时间。但在可靠性优先的场景,这点代价完全值得。
4. 固件级隔离:OTA改了App怎么不误伤数据
4.1 OTA分区设计与App版本隔离
前边讲的都是“一个固件内多个模块的数据隔离”。支持OTA的设备还要考虑更高一层的问题:固件本身会被替换,不同版本的固件读写数据的格式可能不一样,如何处理数据兼容性?
先看OTA的基础布局。OTA方案的基础是有至少两个app分区:ota_0、ota_1。bootloader根据otadata分区里的信息决定从哪个分区启动。比如设备当前在ota_0跑,新固件下载到ota_1,校验通过后更新otadata状态,重启后从ota_1启动。
两个版本固体的代码区天然是分开的,因为它们在两个不同的app分区。真正的“串门”风险都出在数据区。
最常见的坑就是业务数据结构不兼容。旧固件把一条配置存成结构体A,新固件改成结构体B,字段顺序变了、长度变了,但新固件去读同一个NVS key或同一个文件时,直接按新结构解析老数据,读出来就是乱码或残缺。很多所谓“OTA后设备变砖”的案例,其实是“OTA后数据解读不一致”。
我的做法是在每个持久化数据结构里固定放一个version字段。无论是NVS的某个整型,还是文件开头的4字节,都先存数据版本号。新固件启动后先读version:
- 版本相同,正常读取。
- 版本低,执行迁移脚本,把老数据转换为新结构,写完升级版本号。
- 版本比自己高,说明发生了回滚,此时代码不能写数据,只能只读或者提示升级。
这个思路不复杂,但很多团队没有坚持,结果就是OTA越做越多,数据越炸越频繁。我的经验是:业务数据结构的版本号要跟固件版本号分开管理,因为业务数据结构完全可能跨多个OTA版本长期不变,也可能在某个小版本里就改了一次。数据结构改了,就应该更新数据版本号,而不是等固件版本号变化。
4.2 回滚与数据兼容
OTA引入的另一个风险是回滚。新固件跑起来后可能发现稳定性有问题,系统OTA策略触发回滚到旧固件。此时旧固件面对的是新固件写过的新格式数据。
处理回滚我分三步:
第一,发布前标注数据兼容范围。比如固件1.2可以读1.0和1.1写的数据,但1.3改了配置结构,就不再兼容1.2。这些信息写进发布说明,避免后续维护的人两眼一抹黑。
第二,升级时采用“影子迁移”思路。不直接修改旧数据结构,而是先写一份新数据,比如用新的NVS namespace或新的文件后缀存起来。等新固件稳定运行一段时间后,再启用删除旧数据的逻辑。这样做的好处是回滚时旧固件还能找到自己的旧数据。
第三,回滚时旧固件遇到高版本数据必须只读。不允许旧代码把高版本数据强行改写成低版本格式,因为这种降级转换几乎是必然出错的。
我实际遇到过的一个惨痛教训来自智能插座产品:固件从1.0升到2.0时,继电器时控规则从NVS迁移到了LittleFS文件,跑了一周正常。结果后来产品经理要求灰度回滚,1.0固件一上线,发现LittleFS里全是2.0写的文件,但1.0的代码根本不知道有这些文件,于是它执行了一段“首次启动初始化文件系统失败就格式化”的老逻辑——用户设置全部清零。所以说到底还是一句话:升级代码里的数据迁移逻辑,必须配套回滚时的降级保护逻辑,出厂初始化代码里绝不能保留自动格式化路径。
5. 排查实录与防串门清单
5.1 我踩过的坑与排查过程
一次真实的环境监测项目里,三个模块共用4MB Flash。症状是A模块每天固定时间写日志,B模块偶尔读文件失败,设备重启后配置偶尔消失。
我的排查步骤是:
第一步,先看复位原因。ESP32的reset reason能省掉一半误判。调用esp_reset_reason(),如果发现故障后的复位类型是“power-on reset”而不是“software reset”,说明设备运行中崩溃并触发复位,比如看门狗超时。如果复位原因是brownout,先怀疑供电再谈数据问题。
第二步,读回分区表。用esptool把0x8000位置读出来反解析,发现实际烧录的分区表居然是默认表,根本没有storage分区。原来代码用了自定义partition.csv,但烧录脚本只烧写了app,没有烧写partition-table,esp_partition_find_first("storage")返回NULL,代码里又没有判空,导致数据被写进了flash上空闲区域,表面看就是“数据跑到了不该去的地方”。
第三步,解析NVS内容。把NVS分区读出来,用乐鑫的nvs_partition_gen.py工具解析,查看所有namespace和key。结果发现两个模块居然都用了一个通用namespace“storage”,只是key不一样,这才没有立刻大规模串数据,但隐患已经摆在那里了。
第四步,给文件系统加互斥锁,日志交错问题消失。第五步,重构分区表并重新烧写,NVS namespace规范化,文件路径全部分域,问题才算彻底根除。
另一种排查场景是“数据疑似被擦除”。我的做法是把整个Flash读回来,重点检查可疑地址的pattern。如果某个分区前4KB全是0xFF,但后续老数据还在,说明发生过局部擦除。我曾经定位到一个bug:代码用uint16_t保存偏移量,超过65535字节后溢出成负数,擦除range变成擦整个分区,于是把其它模块的数据全清了。修法很简单,地址和偏移量一律改用uint32_t。
这个案例让我养成一个规矩:凡是Flash地址、大小、偏移量,一律用uint32_t或更大类型,永远不要用16位类型。
5.2 防串门设计清单
现在每开一个新项目,我都会在方案文档第一页放一份清单,照着逐项打勾:
- 分区表评审:所有分区是否4KB对齐;分区之间是否重叠;总容量是否超过Flash大小;Custom分区表有没有单独备份CSV文件。
- NVS命名规范:每个模块是否有独立namespace;namespace和key是否在15个字符限制内;整个工程是否只在main里初始化一次NVS。
- 文件系统分域:每个模块是否有自己的目录/文件名前缀;format_if_mount_failed是否在量产固件里设为false;文件操作有没有加互斥锁。
- 数据版本管理:每个持久化数据结构是否有version字段;升级迁移脚本是否存在;回滚时旧代码对高版本数据的只读策略是否明确。
- 代码防御:所有esp_partition和文件操作的返回值是否都判断;erase前是否确认过分区范围;Flash地址变量是不是uint32_t;有没有禁用无条件的nvs_flash_erase。
- 烧录与验证:自定义分区表是否单独烧录过;是否用esptool读回0x8000验证过;生产固件里有没有隐藏的自动格式化逻辑。
这套清单里的第5、6项,是最容易被偷懒跳过的,但偏偏是线上事故的重灾区。很多团队评审固件时只关心功能代码,从不审视对Flash的写操作安全,这是很危险的。
我个人做多应用共用Flash的项目,现在的习惯是拿到Flash容量后第一件事先把分区表画出来,而不是先写代码。把每个模块的存储需求列成一张表——数据量、写入频率、生命周期——然后按需分配分区大小,再决定用NVS、文件系统还是直接读写。等代码写完再去考虑数据放哪,十有八九要返工。另外,我最常说的一句话是:在嵌入式世界,数据能稳定恢复,远比数据写得快更重要。希望大家少踩几个我踩过的坑,把Flash分区和数据隔离方案在设计阶段就定下来。