如果你同时维护过两个以上的ESP32小应用,早晚会遇到这个问题:设备上跑着A程序存配置、B程序写日志、C程序还要做OTA升级,整整4MB Flash看起来不算小,但大家各写各的,某天你会发现A存的参数突然变成了B的残留数据,或者升级一次固件后老版本的用户配置全没了。这就是典型的Flash数据隔离没做到位——多个小应用共用一块Flash,却没有明确划分各自的"地界",数据串门几乎是必然的。
这篇文章不打算只讲理论,我会从分区表设计、NVS和LittleFS的隔离机制、OTA升级时的保护策略,再到一次真实串门事故的完整排查过程,把ESP32多应用Flash隔离的整套做法拆开讲清楚。适合正在用ESP-IDF或Arduino做多业务固件的开发者,也适合想给现有项目增加OTA、日志、参数存储等功能的入门玩家。看完之后你应该能直接给项目设计出一张合规的分区表,并且知道自己写的每一笔数据落在哪块区域、为什么不会误伤旁边的应用。
1. Flash"串门"问题的本质:一个池子,不划地界必乱
ESP32的Flash和电脑硬盘不一样,它是一颗NOR Flash,直接挂在芯片的地址空间里,整个Flash就是一块连续编址的存储区。你写任何数据,本质上都是在把某个地址范围内的电信号从1变成0。这带来一个天然的特点:写入之前必须先擦除,而擦除的最小单位是扇区(通常是4KB)。也就是说,你不能像写文件一样任意修改某几个字节,想改一个字节,很可能要把整个扇区先擦掉。
再加上擦写寿命有限(NOR Flash常见标称1万到10万次),一套成熟的隔离方案不仅要解决"数据别串门",还得顺带解决"别把某一块区域写报废"。但很多项目最开始根本没有这个意识。
1.1 为什么"串门"几乎必然发生
三个原因叠在一起,串门就成了大概率事件。
第一,分区表缺失或者layout混乱。很多人拿到ESP32,用Arduino写了几个例程拼在一起,根本没有自定义分区表,所有应用代码、参数、日志都落在默认分区布局里。默认布局里只有一个factory app分区和一个nvs数据分区,你一旦把日志、配置、校准数据全塞进同一个nvs,互相覆盖只是时间问题。
第二,命名冲突。NVS分区里用namespace区分不同模块,文件系统里用文件名区分不同文件。大家开发时各写各的,A模块叫"cfg",B模块也叫"cfg",合并固件的时候没人发现,运行起来就互相踩。更麻烦的是,有些人为了省事直接调用全分区擦除接口,一键把旁边的模块数据也清了。
第三,整片烧录。开发阶段最常用的操作是esptool.py erase_flash再write_flash 0x0 all.bin,这等于把整个人房子推倒重建,管你里面住了几个应用,数据全部清零。一旦产品已经量产、设备里存着用户数据,这种操作就是灾难。
1.2 数据隔离的三个层次
做隔离之前,先想清楚你要隔离到什么程度。我一般把Flash数据隔离分成三个层次,从物理到逻辑,成本逐级降低,可靠性也逐级降低。
| 层次 | 手段 | 适用场景 | 隔离强度 |
|---|---|---|---|
| 物理隔离 | 分区表划分独立分区,各应用数据区物理不相邻 | 多固件、多业务模块、高可靠性产品 | 最强,互相无法访问 |
| 逻辑隔离 | 同一分区内用namespace/目录/key前缀区分 | 单固件内的多模块 | 中等,命名错了就串 |
| 协议隔离 | 自定义存储格式,记录数据归属ID和校验 | 裸分区、特殊存储结构 | 较弱,主要靠应用层自觉 |
我个人的习惯是:能物理隔离就物理隔离,物理隔离解决不了的,再用命名规范和校验兜底。物理隔离的好处是即使代码bug越界,也最多写坏自己的区域,不会波及邻居。逻辑隔离的好处是省Flash空间,不用给每个小应用都切一块独立分区。
1.3 先分清"应用边界",再谈隔离
这里说的"多个小应用"有两种典型形态,隔离方案完全不一样,一定先分清。
第一种形态是一个固件内的多个业务模块。比如同一个固件里同时有数据采集、继电器控制、Web配置页面。这种情况不需要为每个模块划分独立的app分区,因为它们共享同一份代码镜像,真正需要隔离的是各自的配置数据、日志文件。做法是给每个模块划独立数据分区,或者在同一数据分区内用namespace和文件路径隔开。
第二种形态是多个独立固件镜像切换运行。比如一个设备出厂时内置了两套固件,一套负责日常采集,一套负责诊断维护,启动时通过OTA机制选择从哪个app分区启动。这种情况必须给每个固件分配独立的app分区,并且用otadata记录当前应该启动哪个,隔离的重点是固件代码区,数据区反而可以共享。
我见过很多人没分清这个,给每个业务模块都分配了一块app分区,结果Flash被代码占满了,数据区反而没地方放,完全没有意义。先定形态,再画分区表,顺序不能反。
2. 分区表:一张"用地规划图"决定每个应用的边界
ESP32的分区表是启动时bootloader读取的第一份关键配置,它决定了Flash上每一块区域的类型、偏移和大小。多应用隔离的第一步,就是把你规划好的"地界"写进这张表里。很多串门事故追到根上,都是分区表画得不对或者根本没画。
2.1 分区表语法逐项拆解
分区表本质上是一个CSV文件,每一行描述一个分区。ESP-IDF工程里默认叫partitions.csv,Arduino环境可以在工具菜单里指定自定义分区表文件。每行最低限度长这样:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, phy_init, data, phy, 0x11000, 0x1000, factory, app, factory, 0x10000, 0x180000,逐个字段说。
Name:分区标签,代码里用esp_partition_find_first查分区时靠这个字符串匹配。它也是NVS文件系统、LittleFS挂载时识别的关键标识。命名建议带上模块名,比如storage_a、storage_b,避免之后望名生义。
Type:分为app和data两大类。app分区存放固件镜像,data分区存放数据。这是第一道红线:固件只能烧进app分区,数据只能写进data分区,ESP-IDF的API会校验类型,乱写会直接报错。
SubType:app类的子类型是factory或ota_0/ota_1,表示这个分区在启动链里的角色。data类的子类型常用nvs、ota、phy、spiffs、fat,也可以填自定义数字。子类型决定这个分区被哪个子系统接管。
Offset:分区起始地址。默认Flash映射从0x9000开始(前面0x0~0x8000是bootloader和分区表本身)。自定义布局时,每个分区偏移必须是4KB的整数倍,因为Flash擦除粒度是4KB。这是一个非常容易犯的错——偏移不对齐,启动阶段直接报错或诡异崩溃。
Size:分区大小,也按4KB对齐。这里最容易出问题的是分配过小——nvs分区如果只有默认的16KB(0x4000),多应用各自存参数很快就被写满,NVS内部频繁垃圾回收,极端情况下旧数据会提前被擦掉。我给多应用设备分配nvs,起步就是24KB(0x6000)。
2.2 多应用共存的三种分区布局
针对上面的两种应用形态,有三种常见的分区布局方案。
方案A:单固件多模块,给每个模块独立数据分区。
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, phy_init, data, phy, 0x11000, 0x1000, factory, app, factory, 0x10000, 0x180000, storage_mod_a,data, spiffs, 0x200000, 0x80000, storage_mod_b,data, spiffs, 0x280000, 0x80000, log_ring, data, spiffs, 0x300000, 0x80000,适用于一个固件里跑多个业务模块的情况。代码统一在factory分区里,每个模块的配置和日志各自占一个data分区。这个方案的好处是升级固件时完全不用碰数据分区,OTA只替换app,storage区纹丝不动。
方案B:多固件镜像,通过OTA机制切换启动。
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, phy_init, data, phy, 0x11000, 0x1000, ota_0, app, ota_0, 0x20000, 0x180000, ota_1, app, ota_1, 0x1A0000, 0x180000, storage, data, spiffs, 0x320000, 0xC0000,两个独立编译的固件分别烧到ota_0和ota_1,运行时通过esp_ota_set_boot_partition指定下次启动哪一个。切换的时候数据分区保持独立,两个固件可以共享storage,也可以各分一半,看你需求。
方案C:单固件模块化,用NVS namespace加文件目录逻辑隔离。
这种不额外占Flash空间,但要求所有模块遵循严格的命名规范。后面第3章会详细说机制。适合Flash容量非常紧张、或者模块数量多但单个数据量小的场景。
三种方案没有绝对优劣,我一般这样选:模块之间数据关联弱、离线调试多,用方案A;需要出厂内置多套固件,用方案B;Flash快满了只做简单功能,用方案C。但不管哪个方案,有一个原则必须坚持——app分区和数据分区绝对不能重叠,OTA分区和数据分区绝对不能相邻到易混淆的地步。
2.3 修改分区表后的烧录与验证
分区表改完后不能直接指望旧固件自动适配,必须重新烧录。
# ESP-IDF 环境 idf.py partition-table # 生成分区表二进制 idf.py partition-table-flash # 只烧分区表 idf.py flash # 烧固件和分区表 # 部分烧录,避免整片擦除 esptool.py write_flash 0x9000 build/partition_table/partition-table.bin烧完之后强烈建议读回来核对。esptool.py read_flash能导出指定地址区间的二进制,比对一下分区表内容是否和你设计的一致。
esptool.py read_flash 0x8000 0x1000 table_dump.bin这里要特别提醒一个坑:永远不要在量产阶段用erase_flash之后整片烧录。你确实能用write_flash 0x0 all.bin一次把bootloader、分区表、固件全写进去,但如果你手里的all.bin里分区表是旧的,正好把设备里已有的数据分区地址覆盖了,那么已经存到Flash里的用户数据就会全部丢失。我处理过好几个"升级后配置丢失"的工单,最后发现就是整片烧录惹的祸。
3. 存储机制的选择:NVS、文件系统与裸分区
分区表把地界画好了,接下来看每个数据分区里用什么机制读写。这一章说的三类存储机制各有脾气,用错了照样串门。
3.1 NVS:namespace 是天然的"房间号"
NVS(Non-Volatile Storage)是乐鑫提供的键值对存储,专门用来放小体积配置参数。它内部自己管理Flash擦除、磨损均衡、掉电恢复,是三个机制里最省心的一个。NVS的隔离单元是namespace,你可以把它理解成"房间号"——同一个NVS分区里可以开很多房间,每个房间里面可以放相同名字的钥匙。
#include "nvs.h" #include "nvs_flash.h" // 模块A nvs_handle_t handle_a; nvs_open("app_a_cfg", NVS_READWRITE, &handle_a); nvs_set_i32(handle_a, "threshold", 42); nvs_commit(handle_a); // 模块B,即使key同名也不冲突 nvs_handle_t handle_b; nvs_open("app_b_cfg", NVS_READWRITE, &handle_b); nvs_set_i32(handle_b, "threshold", 100); nvs_commit(handle_b);上面的代码里,模块A和模块B都存了一个叫threshold的key,但一个在app_a_cfg房间里,一个在app_b_cfg房间里,互不干扰。这是NVS最简单的隔离手段。
但正因为简单,很多人栽在命名上。NVS的namespace并没有全局注册机制,你在代码里写nvs_open("cfg", ...),编译器不会告诉你有另一个模块也用了cfg。等到运行时两个模块共用同一个房间,后写的覆盖先写的。排查这类问题特别费劲,因为表面上看NVS读写都没报错,数据却莫名其妙变了。我后面第5章会讲一个真实排查案例。
我现在的强制规范是:所有模块的namespace必须带模块名前缀,格式统一为<模块名>_<用途>。比如app_a_config、app_b_network。这不算技术手段,但能避免大量人为失误。
3.2 LittleFS/SPIFFS:用挂载路径把应用隔开
NVS只适合几十字节的小参数,日志、网页资源、批量采集数据就得靠文件系统。ESP32上最常用的是LittleFS,它是SPIFFS的后继者,掉电稳定性更好,我推荐新项目直接LittleFS。
文件系统在挂载的时候需要指定一个分区标签(partition label),挂载之后得到一个基础路径。关键隔离手段就是:不同模块用不同的分区标签,挂载到不同的基础路径。
#include "esp_littlefs.h" esp_vfs_littlefs_conf_t conf_a = { .base_path = "/app_a", .partition_label = "storage_mod_a", .format_if_mount_failed = true, .dont_mount = false, }; esp_vfs_littlefs_register(&conf_a); esp_vfs_littlefs_conf_t conf_b = { .base_path = "/app_b", .partition_label = "storage_mod_b", .format_if_mount_failed = true, .dont_mount = false, }; esp_vfs_littlefs_register(&conf_b);之后模块A写文件用/app_a/config.json,模块B写文件用/app_b/config.json。就算两个文件都叫config.json,物理上也不在一个分区,根本碰不到。
这里有一个非常关键的坑,藏在format_if_mount_failed = true这个选项里。它的意思是:如果挂载时发现文件系统损坏,就自动格式化整个分区,重新开始。听起来挺方便,但如果多个模块不小心用了同一个分区标签、同一个基础路径,那么模块A在挂载时触发格式化,就会把模块B的数据全部清零。我自己的开发期间会把这个选项临时设为true方便调试,但产品发布前一定改成false,并且开机挂载失败时进入专门的恢复流程,而不是无脑格式化。
还有一点,文件系统分区别分太小。LittleFS本身有元数据开销,一个128KB分区,实际可用可能只有100KB左右。如果存日志,按每天几十KB算,几天就写满了。所以我会单独给日志划一个分区,别和配置混在一起,原因第4章细说。
3.3 裸分区:没有保护机制,必须自己做边界检查
有些场景不适合套文件系统——比如按固定格式记录传感器原始数据、做环形覆盖的日志、存一帧一帧的图像数据。这时候直接用esp_partitionAPI操作裸分区,效率最高。
但裸分区没有任何隔离保护,读写的数据落点完全靠自己算偏移。esp_partition_write的API虽然会校验偏移和长度是否越界,但它校验的是"这个操作有没有超出我的分区",而不是"这个操作有没有超出我这个小应用的区域"。如果你在同一个裸分区里规划了两块区域给两个应用用,其中一个算错偏移,直接就把另一个的数据覆盖了。
我自己的做法是包一层带边界检查的写入函数:
#include "esp_partition.h" const esp_partition_t *part = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, "sensor_raw"); // 每个小应用在自己的区域内操作,区域边界由宏统一定义 #define APP_A_REGION_OFFSET 0x0000 #define APP_A_REGION_SIZE 0x10000 #define APP_B_REGION_OFFSET 0x10000 #define APP_B_REGION_SIZE 0x10000 esp_err_t safe_partition_write(uint32_t region_off, uint32_t region_size, uint32_t relay_off, const void *buf, size_t len) { // 越界直接拒绝,别等Flash烧坏了才后悔 if (region_off + relay_off + len > region_size) { return ESP_ERR_INVALID_ARG; } return esp_partition_write(part, region_off + relay_off, buf, len); }写入之后最好立刻读回来校验一致性,裸分区没有NVS那套CRC保障,写错就只能靠自己的校验逻辑兜底了。裸分区定位是"高性能但高风险",能用NVS和文件系统解决的需求,别硬上裸分区。
4. OTA升级、日志和掉电:串门事故的高发区
隔离方案做得再漂亮,OTA升级和日志这两个场景一冲击,漏洞就现形了。这是设备部署之后最容易出问题的两个地方,单独拿出来讲。
4.1 OTA升级如何做到"只换程序不碰数据"
OTA升级的本质是把新固件写到app类型的另一个分区里,然后切换启动分区。比如当前从ota_0启动,新固件写入ota_1,校验通过后设置boot partition指向ota_1,重启后从ota_1运行。
这里面的关键点是:OTA函数在整个过程中只碰app分区,不碰任何data分区。只要你的分区表把app和数据分得清清楚楚,OTA升级天然就带数据隔离属性——程序随便换,配置留着不动。
但有一个很容易被忽略的情况:有些项目为了省事,把用户数据直接打包进固件镜像,或者在app分区空闲区域塞了一些只读配置。一旦OTA,这些数据要么被新固件覆盖,要么因为分区大小变化而丢失。所以我有一条铁律:固件镜像里不允许附带任何运行期需要修改的数据,所有可变数据必须落在独立的data分区。
OTA还有个衍生问题——回滚。otadata分区记录当前活动固件和上次运行状态,新固件启动后如果健康检查失败,ESP-IDF会自动回滚到上一个app分区。回滚只影响app,数据分区里的配置不会因此变化。但要注意,如果新固件使用了新的数据格式,回滚后旧固件读新数据可能读出乱码。这种场景要在应用层做版本判断,别把数据兼容性指望在OTA机制上。
4.2 日志分区:高频写入下的磨损与环形覆盖
日志是所有数据里最"凶猛"的。一个频繁上报状态的设备,每秒钟写一条日志,一天就是数万次写入。Flash擦写寿命有限,如果不做环形覆盖,很快就会被写满,然后引发连锁反应——要么日志写入失败卡住业务,要么某个"聪明"的代码开始格式化整个分区,把配置也带走。
我的做法是给日志单独分一个区,并且设计成环形缓冲:头部固定区域放索引信息,尾部数据区按顺序写,写满之后回到开头覆盖最旧的日志。这要求日志区大小是扇区的整数倍,而且代码里不允许出现"日期间隔超过N天就格式化日志区"这种高危逻辑。很多人图省事,定期格式化日志,以为文件系统越空越安全,实际上一个format调用就能把同分区其他配置全灭了。所以日志区必须是独立分区,绝不能和配置放一起。
如果日志需要存到文件系统,建议用LittleFS而不是SPIFFS,并且在应用层实现文件轮转——按文件大小或日期自动切新文件,保留最近N份,删除最旧的。这样文件系统本身不需要做环形覆盖,磨损也更均匀。
4.3 掉电边界的处理:NVS安全、文件系统需冗余、裸分区最脆弱
设备掉电是常态,掉电瞬间正在写Flash的话,串门事故就来了。
NVS在内部实现了掉电安全机制:写操作按状态机分步执行,每个键值都带CRC校验,掉电时最多丢失最近一次未完成写入,不会损坏其他键。所以参数存NVS是最稳妥的。但NVS也有一个坑——它的垃圾回收机制在掉电时可能表现得比较激进,如果分区空间长期只剩不足20%,回收频率会急剧增加。我建议NVS分区至少要留出40%的空余空间,并且把频繁变更的参数和不频繁变更的参数放到不同namespace,减小回收时互相干扰的概率。
LittleFS和SPIFFS在掉电时也有保护,文件系统结构一般不会整体损坏,但正在写的那个文件可能变残缺。如果文件是配置,那么应用启动时读到的可能是一个写了一半的JSON。所以配置文件在文件系统里存储时,我习惯写两个副本(A和B),启动时先读A,校验失败就读B,两个都失败才用默认值。
裸分区最脆弱,掉电时写了一半,既没有事务概念也没有校验,下一个应用读这块区域时只能拿到垃圾数据。裸分区的掉电保护只能自己做:要么双缓冲交替写,要么每条记录带CRC和状态标志,启动时扫描并回滚到上一个完整记录。
5. 一次真实"串门"事故的完整排查链路
最后分享一个我实际排查过的案例。故障现象不复杂,但排查过程走完一遍,基本能覆盖前面所有章节的知识点。
5.1 事故现场:一次重启后配置"穿越"了
设备是ESP32-D4WD,4MB Flash,固件里集成了两个业务模块:模块A负责环境采集,定时把温湿度写入NVS;模块B负责网络配置,用户在Web页面上修改WiFi参数,也存进NVS。刚上线的头几天一切正常,第三天客户反馈:重启后WiFi参数时不时变成默认值,而且有些时候设备连的SSID看起来像是模块A采集任务里出现过的数据。
第一反应是NVS读写代码写错了key,打开代码检查,模块A用的是namespaceenv_data,键名temp、humi;模块B用的是namespacenet_cfg,键名ssid、passwd。看起来完全隔离,不像是NVS的锅。
5.2 沿着数据流逐层排查:从代码到Flash镜像
排查不能靠猜,我直接把整个NVS分区从Flash里导出来做分析。
esptool.py read_flash 0x9000 0x6000 nvs_dump.bin用NVS解析工具打开这个镜像,发现两个namespace都在,键值也都在。但有一个细节很奇怪:net_cfg这个namespace里除了ssid、passwd,还多了一个键叫humi,值正好是模块A最后一次采集到的湿度数据。也就是说,模块B的namespace里混入了模块A的key。
这就怪了。NVS的namespace设计上是不允许跨namespace写入的,除非代码里某个地方对net_cfg做了操作,而这个操作的数据源来自模块A。
继续翻代码,发现一个隐蔽的bug。模块A里有一段"恢复出厂"逻辑,它调用的不是nvs_erase_all,而是:
nvs_flash_erase(); // 擦除整个NVS分区 nvs_flash_init(); // 重新初始化这段代码本来只应该在调试期调用,结果模块A的运行逻辑里残留了这个调用。它每次执行都把整个NVS分区清空,等分区重新初始化后,模块B还没写入新配置,模块A的采集任务抢先写入了temp和humi,但因为某种时序问题,键被写进了默认的net_cfgnamespace匹配逻辑里。其实根本原因是:整区擦除破坏了NVS的namespace映射表,重启后部分键名解析混乱,看起来就像是串门了。
5.3 修复与预防:一份常驻检查清单
修复方案分两步。第一步,把模块A代码里的nvs_flash_erase()移除,恢复出厂逻辑改为只清理自己模块的namespace:
nvs_handle_t handle; nvs_open("env_data", NVS_READWRITE, &handle); nvs_erase_all(handle); // 只擦 env_data 这个namespace nvs_commit(handle); nvs_close(handle);第二步,把产品里所有NVS namespace重新列一遍,做成规范表:环境模块固定用env_data,网络模块固定用net_cfg,任何新模块加namespace之前必须先在规范表里登记,禁止出现无前缀的通用名cfg、config之类的。
经此一役,我把Flash多应用的隔离规则总结成了下面的检查清单,每次新项目启动前都会过一遍:
- 分区表第一:app代码区、NVS参数区、文件系统区、日志区,每类用途至少独立分区,偏移按4KB对齐。
- NVS命名唯一:所有namespace统一
模块名_用途前缀,加到代码评审强制检查项里。禁止用nvs_flash_erase()擦全分区,只能nvs_erase_all(handle)清自己的namespace。 - 文件系统分区隔离:每个需要文件存储的模块分配独立分区标签,挂载不同基础路径,发布前将
format_if_mount_failed设为false。 - 裸分区加边界:任何直接操作
esp_partition_write/read的代码,必须经过带区域边界检查的封装函数。 - OTA只动app:升级流程里只调用
esp_ota_write系列接口,永远不要在升级过程中擦写任何data分区。 - 日志单独放:日志分区独立,设计好环形覆盖逻辑,禁止格式化日志分区;配置文件采用双副本冗余。
- 烧录规范:量产热升级一律走OTA机制,调试期烧录也尽量用分区级写入,避免晕倒式整片烧录。
现在再看"多个小应用共用一块Flash怎样保证数据不会串门"这个问题,答案其实就两条主线:分区表画清楚每个应用的物理边界,存储机制用对每块分区的隔离手段。剩下的就是严格执行命名规范和操作规范,别让好机制被坏习惯破坏掉。
我在实际项目里吃过分区表偏移没对齐的亏,也吃过nvs_flash_erase清空全区的亏,最深的体会是:Flash隔离与其说是个技术问题,不如说是个纪律问题。技术方案不难,难的是所有人都遵守同一套规则。如果你正在设计新的多应用固件,我建议把第5章那份清单直接贴到测试用例里,让每次发版前都自动跑一遍分区边界检查,这种问题就不会再找上门了。