上周有个读者给我发来一段很典型的踩坑经历:一块 ESP32 开发板上同时跑了三个小应用,一个采集温湿度并上报,一个驱动 OLED 屏幕,还有一个专门负责 OTA 升级。Flash 就那一颗 4MB 的 NOR Flash,按理说三个应用各占一摊互不干扰,结果温度数据每隔几天就变成一串 0xFF,重启后又恢复正常。排查到最后,发现是 OTA 应用升级时按“整块尾部 Flash”擦了一遍,恰好把温度校准数据所在的区域一并擦掉了。
这不是个例。只要你的项目里存在“多个小应用共用 ESP32 一块 Flash”的情况,数据串门就是迟早的事。ESP32 的 Flash 虽然已经被抽象成了分区表、NVS、文件系统,但抽象层并不会自动帮你阻止越界。真正能挡住问题的,只有基于分区表的合理规划,外加一套严格的读写纪律。这篇就当我把这些年做多应用共享 Flash 项目的经验盘一遍,从分区表到文件系统,从 NVS 到 OTA,尽量把容易踩的坑都给翻出来。
1. 共享Flash的“合租”局面:三个最典型的串门姿势
1.1 串门姿势一:擦除粒度太粗,误伤邻居
NOR Flash 的物理特性决定了它不能像内存一样按字节覆写,必须先按扇区擦除,擦完才能重新写入。ESP32 里常见的最小擦除单位是 4KB,也就是说,你只想改某个 1KB 的配置结构体,也得先把那 4KB 所在扇区全部擦干净。这个特性本身就埋了雷:如果两个应用的数据区域在 Flash 地址上贴得太近,A 应用做了一次“按扇区擦除”,只要起始地址差一点点,B 应用的数据就可能跟着没了。
我在一个项目里就见过把配置结构体放在一个地址,紧挨着另一个应用的日志存储区,两个区域中间甚至没有留一整个扇区的间隔。结果每次配置写入都触发一次扇区擦除,日志区尾部频繁出现乱码。
1.2 串门姿势二:NVS 键名撞车
很多人的第一反应是:“数据串门?我用 NVS 不就行了吗?”实际上 NVS 就是事件重灾区。ESP32 的 NVS 是一个全局的键值存储,虽然你可以用不同的命名空间做逻辑隔离,但两个 30 人的团队做同一个产品时,谁也没法保证所有键名全局唯一。
最典型的情况是,传感器模块存了wifi_ssid,屏幕控制模块也存了自己的wifi_ssid。同一个键,大家各自nvs_set_str写入,最后读取出来的值就是最后一次写入的人赢。这种错误很难当场发现,因为两个应用往往都认为“我在写我自己的字段”,直到其中一方读到了对方的配置,才明白问题出在哪。
1.3 串门姿势三:文件系统分区被共用
比 NVS 撞车更可怕的,是多个应用共用同一个文件系统分区。SPIFFS、LittleFS 这类文件系统都有格式化操作,一旦某个模块为了“清理缓存”调用了格式化,整个分区的文件都没了。即便没有主动格式化,只要两个应用同时往同一个分区写文件,又共用一套目录结构,文件名稍微一样,也会互相覆盖。
更隐蔽的是,有些文件系统在挂载失败后会执行修复或格式化。如果你的代码里format_if_mount_failed = true,而其中一个应用异常掉电,下次上电时它就可能把整个分区格式化成干净状态,所有邻居应用的数据一起陪葬。
1.4 为什么“小心别越界”不靠谱
很多人的防御思路是“我在代码里注意一点,绝对不写超过自己区域的地址”。这个想法在工程上基本不可行。ESP32 的项目不是一个人维护,也不是一套代码永远不变。今天你写的裸地址偏移是0x1A0000,明天分区表调整后成了0x180000,你这条代码就变成了一颗延时炸弹。而且 Flash 的擦写还牵扯磨损均衡、OTA 分区偏移、bootloader 加载地址,任何一处硬编码地址出现偏差,都不会立刻报错,只会静静地把别人的数据盖掉。
所以,真正的解决方案只有一个:让每个应用都从分区表那里拿到自己的“房产证”地址,再通过框架提供的 API 来读写,而不是自己拿指针去戳 Flash。
2. 分区表是真正的“房产证”:把Flash切成独立的房间
2.1 看懂分区表 CSV 的关键字段
ESP32 的分区表在编译时通过 CSV 文件定义。你只需要理解五个字段:
| 字段 | 含义 | 作用 |
|---|---|---|
| Name | 分区名字 | 程序里用字符串查找分区,相当于房间号 |
| Type | 分区类型 | app 还是 data,决定用途 |
| SubType | 子类型 | 比如 nvs、ota、spiffs、littlefs |
| Offset | 起始地址 | 分区在 Flash 上的位置 |
| Size | 分区大小 | 房间面积 |
很多人的误区是只看 Size,不看 Offset。Offset 决定了这个房间从哪堵墙开始。如果两个分区的 Offset 和 Size 没有对齐好,哪怕两个分区名字不同,地址空间也可能重叠。
CSV 里也有默认的phy_init之类的保留分区,通常不需要动。真正要规划的是 app 分区、NVS 分区、OTA 分区,以及每个应用各自的数据分区。
2.2 多应用共存的推荐分区规划
假设你有一颗 4MB Flash,规划了两个逻辑模块:一个传感器模块要存校准参数和上报补传文件,一个显示模块要存字体和图片资源。分区表可以这样写:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app, app, factory, 0x10000, 0x1C0000, sensor_storage, data, littlefs,0x1D0000, 0x100000, display_storage, data, littlefs,0x2D0000, 0x80000, common_storage, data, littlefs,0x350000, 0x80000,这样划分后,sensor_storage、display_storage、common_storage是三个完全独立的文件系统分区。每个应用挂载自己的分区,格式化自己的分区,互不相干。即使传感器模块把整个sensor_storage分区格式化,显示模块的display_storage也毫发无损。
需要注意 Offset 要从 4KB 边界对齐。上表其实已经留了一些间隙,比如0x9000到0xE000之间留了 0x2000,给可能的配置分区或扩展留余地。
2.3 用分区 API 获取边界,而不是硬编码地址
代码里不要写0x1D0000这种魔法地址,一律通过分区 API 查找:
#include "esp_partition.h" const esp_partition_t* sensor_part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_LITTLEFS, "sensor_storage" ); if (sensor_part == NULL) { ESP_LOGE("MAIN", "sensor_storage partition not found"); return; } ESP_LOGI("MAIN", "sensor_storage: offset=0x%x, size=0x%x", sensor_part->address, sensor_part->size); // 读取第 0 块数据 uint8_t buf[4096]; esp_err_t err = esp_partition_read(sensor_part, 0, buf, sizeof(buf)); if (err != ESP_OK) { ESP_LOGE("MAIN", "read failed: %s", esp_err_to_name(err)); }esp_partition_read、esp_partition_write、esp_partition_erase_range这些 API 会严格检查传入的偏移和长度是否超过当前分区边界。一旦越界,直接返回ESP_ERR_INVALID_SIZE或者类似错误码,不会去默默破坏隔壁分区。
这是第一层保险:你的代码永远只通过分区句柄操作,而分区句柄是系统根据分区表生成的,天然有边界约束。
3. 文件系统分房方案:LittleFS多分区挂载,而不是共用
3.1 SPIFFS 与 LittleFS 的取舍
如果你要按模块隔离数据,文件系统的选型也很关键。SPIFFS 是 ESP32 早期常用的文件系统,实现简单,在极小容量下表现不错;但它不支持目录,坏块管理也比较弱,而且掉电时的一致性表现不如 LittleFS。LittleFS 在 ESP-IDF 里已经是推荐的方案,它支持目录、有掉电保护、磨损均衡,对多分区多实例的支持也更清爽。
所以现在新项目我基本直接选 LittleFS。除非你的分区小到只有几十 KB,否则 LittleFS 的优势在长期运行的多应用场景下非常明显。
3.2 一个应用一个分区的挂载配置
ESP-IDF 的 VFS 层支持同时挂载多个 LittleFS 分区,只要你给每个分区指定不同的base_path,它们就像独立的小硬盘挂到了不同目录下。挂载配置如下:
#include "esp_littlefs.h" static void mount_littlefs(const char* base_path, const char* label) { esp_vfs_littlefs_conf_t conf = { .base_path = base_path, .partition_label = label, .format_if_mount_failed = false, .dont_mount = false, }; esp_err_t ret = esp_vfs_littlefs_register(&conf); if (ret != ESP_OK) { if (ret == ESP_FAIL) { ESP_LOGE("FS", "Failed to mount or format filesystem"); } else if (ret == ESP_ERR_NOT_FOUND) { ESP_LOGE("FS", "Partition %s not found", label); } else { ESP_LOGE("FS", "Failed to initialize LittleFS (%s)", esp_err_to_name(ret)); } return; } ESP_LOGI("FS", "Mounted %s on %s", label, base_path); }初始化调用:
mount_littlefs("/sensor", "sensor_storage"); mount_littlefs("/display", "display_storage"); mount_littlefs("/common", "common_storage");挂载之后:
- 传感器模块只能操作
/sensor/下的文件; - 显示模块只能操作
/display/下的文件; - 公共配置放在
/common/。
这样即使某个模块写路径时少写了一层目录,或者文件名撞了,也因为它只能在对应的 base_path 下活动,不会影响到别的应用。
3.3 格式化策略里暗藏的“连锁爆炸”
format_if_mount_failed这个字段一定要谨慎。很多人的第一版代码图省事,直接设成true,想着挂载失败就自动格式化,上电就自愈。这个做法在单分区单应用项目里问题不大,但在多应用共用 Flash 的场景里,就是自爆装置。
设想一下:A 模块访问/sensor分区时因为断电发生了几块坏页,挂载失败,于是 LittleFS 自动把这个分区格式化;紧接着 B 模块上电访问/display,分区正常。看起来没问题。但如果 A、B 共用了同一个分区,或者分区表偏移搞错导致两个 base_path 指向同一个分区,自动格式化就会把全部数据清空。
更隐蔽的情况是,某个分区真的坏了,format_if_mount_failed = true会反复格式化一个无法挂载的坏分区,反而加速 Flash 磨损。我的习惯是把它设为false,由开机逻辑决定是提示用户还是从备份恢复。格式化这种操作,永远不能让程序在异常路径上自动执行。
4. NVS的隔离:不是靠新分区,而是靠“命名空间+纪律”
文件系统分区能隔离大块数据,但像设备 ID、用户设置、MQTT 参数这类小键值,大家还是习惯用 NVS。NVS 的隔离方式就完全不一样,它通常是整块 Flash 上的一个独立分区,所有模块共用一个 NVS,真正区分彼此的是命名空间和键名。
4.1nvs_open的命名空间到底是什么
很多人以为 NVS 键名形如sensor_calib、display_brightness就足够唯一了,其实 NVS 第一层隔离靠的是命名空间参数。nvs_open("sensor", NVS_READWRITE, &handle)里的"sensor"就是命名空间。两个应用分别用"sensor"和"display"打开句柄,然后各自写calib键,互不干扰,因为它们在底层存储的完整键名是sensor:calib和display:calib这样的组合。
因此,最基础的纪律是:每个逻辑模块必须使用自己独有的命名空间,而且命名空间的名字要提前在文档里登记,谁也不能随便占用别人的名字。
4.2 键名长度限制和前缀规范
ESP32 NVS 的键名有长度限制,实际使用时键名字符串不能超过 15 个字符。这意味着你不能写sensor_temperature_calibration_offset这种长名字,取键名时必须短而明确。命名空间本身也有长度限制,通常是 15 个字符左右,但那是单独的限制,不影响键长。
建议的键名规范是:
- 命名空间用模块名,例如
sensor、display、ota、mqtt; - 键名用功能缩写,例如
tcal、bright、ver、broker; - 同一个模块内键名不允许重复,跨模块靠命名空间区分。
如果你们是多人协作,还可以在命名空间前面加公司或产品缩写,比如prj_sensor,进一步降低撞车概率。
4.3 更硬核的隔离:给特定模块独立 NVS 分区
如果你觉得一个 NVS 分区里所有命名空间还在同一张哈希表里,不太放心,那 ESP-IDF 也支持把 NVS 拆成多个独立分区。这需要你在分区表里增加额外的 data 类型 nvs 分区,比如:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, nvs2, data, nvs, 0x40000, 0x5000,初始化时,默认分区的nvs_flash_init()只初始化命名为"nvs"的分区。想要操作nvs2,需要显式调用:
#include "nvs.h" #include "nvs_flash.h" esp_err_t err = nvs_flash_init_partition("nvs2"); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { // 如果出现 NVS 冲突或版本变化,需要先擦除重建 nvs_flash_erase_partition("nvs2"); nvs_flash_init_partition("nvs2"); } nvs_handle_t handle; err = nvs_open_from_partition("nvs2", "sensor", NVS_READWRITE, &handle);nvs_open_from_partition会强制从指定分区打开命名空间,这样传感器模块就完全接触不到另一个分区里的 NVS 数据。不过这种做法要权衡:NVS 分区越多,Flash 空间占用和磨损均衡复杂度越高。我通常在非常敏感的数据(比如密钥、校准值)上才用独立 NVS 分区,一般场景够用的话,一个 NVS 分区加命名空间纪律就足够了。
5. OTA升级时最容易踩的雷:跨区擦除怎么防
5.1 app 分区和数据分区的依赖关系
多应用共用 Flash 时,OTA 是最容易引发“跨区擦除”的环节。ESP32 的 OTA 原理是把新固件写入一个专门标记为ota_0或ota_1的 app 类型分区,写入完成后切换启动标记,重启后 bootloader 选择新分区启动。这个机制本身是安全的,前提是你对分区边界的理解足够清晰。
但有些人为了省空间,会把 app 分区和后面的数据分区“紧紧贴在一起”,比如 app 分区结束地址是0x390000,数据分区偏移就写0x390000。从分区表的数学上看,这没问题。问题出在 OTA 包的写入过程中:如果新版固件体积超过了原分区规划,或者写入代码里没有做边界判断,esp_ota_write就会用掉超出分区的字节,把隔壁数据分区的头部吞掉。
5.2 让 OTA 每次写入都检查剩余空间
所有 OTA 写操作都有两个铁律:
- 绝对不要让长度参数超过当前分区剩余空间;
- 每次都从
esp_ota_get_write_partition返回的分区句柄获取真实地址。
一个简洁的写法:
esp_ota_handle_t ota_handle; const esp_partition_t* update_part = esp_ota_get_next_update_partition(NULL); if (update_part == NULL) { ESP_LOGE("OTA", "No OTA partition found"); return; } esp_err_t err = esp_ota_begin(update_part, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle); if (err != ESP_OK) { ESP_LOGE("OTA", "esp_ota_begin failed: %s", esp_err_to_name(err)); return; } // 假设 buf 是每次从网络收到的一块数据,len 是本次实际长度 size_t written_total = 0; while (remaining > 0) { size_t len = MIN(chunk_size, update_part->size - written_total); if (len <= 0) { ESP_LOGE("OTA", "No space left in OTA partition"); esp_ota_abort(ota_handle); return; } err = esp_ota_write(ota_handle, buf, len); if (err != ESP_OK) { esp_ota_abort(ota_handle); return; } written_total += len; remaining -= len; }重点看update_part->size - written_total这个判断。只要每次写入前都检查剩余空间,就不会出现写入越界的问题。OTA 分区里留一点空闲空间并不浪费,它反而是你的缓冲垫。
5.3 构建脚本里的“分区表防呆检查”
人总会犯错,分区表 CSV 也是一样。我后来习惯在构建脚本里加一个简单的 Python 检查,专门用来检验所有分区是否重叠、是否有越界:
#!/usr/bin/env python3 import sys partitions = [] with open(sys.argv[1], 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue fields = [x.strip() for x in line.split(',')] if len(fields) < 5: continue name, type_, subtype, offset, size = fields[0], fields[1], fields[2], int(fields[3], 0), int(fields[4], 0) partitions.append((name, offset, offset + size)) partitions.sort(key=lambda x: x[1]) flash_size = int(sys.argv[2], 0) for i, (name, start, end) in enumerate(partitions): if start % 0x1000 != 0: print(f"WARN: {name} offset 0x{start:x} not 4KB aligned") if end > flash_size: print(f"ERROR: {name} ends at 0x{end:x}, exceeds flash size") sys.exit(1) if i > 0: prev_name, prev_start, prev_end = partitions[i - 1] if start < prev_end: print(f"ERROR: {name} overlaps {prev_name}") sys.exit(1) print("Partition table check passed")把这个脚本挂到 CI 或 Makefile 里,以后任何人动了分区表,构建阶段就能发现重叠问题。这比上线后数据串门再排查要省太多事。
6. 我做过多应用共享Flash之后总结的实践守则
6.1 一个可以直接抄的 4MB 分配模板
这里给一个我目前比较常用的 4MB Flash 多应用数据隔离模板。它适用于单固件内两个逻辑模块加 OTA 的场景:
# Name, Type, SubType, Offset, Size, nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, phy_init, data, phy, 0x10000, 0x1000, app, app, factory, 0x20000, 0x1C0000, sensor_storage, data, littlefs,0x1E0000, 0xC0000, display_storage, data, littlefs,0x2A0000, 0xC0000, common_storage, data, littlefs,0x360000, 0xA0000,注意我在app分区和第一个数据分区之间留了0x20000的间隙。这个间隙不是为了好看,而是为了应对未来固件膨胀导致的 OTA 越界,以及给 bootloader 一些冗余空间。多花 128KB 不会伤筋动骨,但能避免大量数据丢失事故。
6.2 多应用数据隔离自检清单
每次新项目合入提测前,我会按下面这个清单过一遍,你完全可以照着抄:
- 分区表里每个应用的数据分区是否有独立名称和独立类型;
- 分区表里所有 offset 是否 4KB 对齐,是否存在重叠;
- 代码里有没有裸地址
0x开头的 Flash 偏移常量; - 所有读写操作是否都通过
esp_partition_*、esp_vfs_littlefs_*这类带分区边界的 API; - NVS 命名空间是否在文档里登记过,键名是否遵守 15 字符限制;
- 所有
format_if_mount_failed是否都为false; - OTA 写入前是否检查剩余空间;
- 是否有一个分区表检查脚本跟着代码库走。
6.3 一条用血泪换来的建议
如果你只让我说一条,那就是:永远不要相信“这个地址是我自己的,别人不会碰”这种话。ESP32 的 Flash 是一整块物理存储,分区表才是唯一的分界规则。每个应用都把数据写入自己从系统那里“领”回来的分区,而不是自己编一个地址,这才是长期可维护的保证。
最后再分享一个土办法:我会在分区表规划完成后,故意把每个数据分区的头部写一串魔数,比如0xDEADBEEF,然后让每个模块上电时检查自己分区的魔数。如果某个分区被串门者写入过,魔数绝对会被破坏,开机就能立刻报警。这个办法不花多少代码,却能把“偷偷串门”变成“当场抓包”,很多隐藏问题都是靠它暴露出来的。