1. 一块 Flash 上跑多个应用,问题到底出在哪
ESP32 这类芯片的玩法,玩到一定阶段必然会走到"多个小应用共用一块 Flash"这一步。原因很朴素:一块 4MB 或 8MB 的 SPI Flash,既要放 bootloader、分区表、主固件,又要放文件系统、配置参数、日志、OTA 备份,甚至还要塞一个 WASM 运行时去跑第三方小应用。资源就这么多,谁都想分一杯羹。
我最早踩这个坑是在一个带 Web 配置页的项目上。主固件用 NVS 存 WiFi 配置,同时挂了一个 SPIFFS 分区放前端静态资源,后来又加了一个小应用模块,它自己也想存点状态。结果某次升级之后,用户反馈"配置莫名其妙被重置了",查了半天发现是小应用写自己的数据时,把 NVS 的命名空间给覆盖了。这就是典型的"数据串门"——多个应用共用同一块 Flash,但彼此之间没有边界,一个应用的手抖就能把另一个应用的数据抹掉。
所以这篇东西要聊的核心就一件事:在 ESP32 上,多个小应用共用一块 Flash 时,怎么从分区、命名空间、访问权限、运行时沙箱这几个层面,把数据隔离做扎实,保证谁也不碰谁。涉及的关键词包括 ESP32、Flash、NVS、WASM、存储隔离。适合已经能跑通基础固件、开始做多应用架构或者插件化设计的开发者,也适合正在被"数据莫名丢失"折磨的同行。
先把结论摆前面:隔离不是靠一个开关搞定的,它是分区表设计、NVS 命名空间规划、文件系统挂载点划分、以及运行时权限控制四件事叠加起来的结果。少任何一层,都会留下串门的口子。
2. 先搞清楚 ESP32 的 Flash 到底是怎么被切开的
2.1 Flash 物理结构和分区表的对应关系
ESP32 外挂的通常是 SPI NOR Flash,容量常见 4MB、8MB、16MB。物理上它就是一块按扇区(sector,一般 4KB)擦除、按页(page,一般 256 字节)写入的存储。擦除的最小单位是扇区,写入前必须先擦除,这是 NOR Flash 的铁律,也是很多"写不进去"问题的根源。
逻辑上,ESP-IDF 用一张**分区表(partition table)**把这块物理空间切成若干块,每块有名字、类型、子类型、偏移地址和大小。典型的分区表长这样:
| 名称 | 类型 | 子类型 | 偏移 | 大小 | 用途 |
|---|---|---|---|---|---|
| nvs | data | nvs | 0x9000 | 0x6000 | 键值对存储 |
| otadata | data | ota | 0xF000 | 0x2000 | OTA 状态 |
| phy_init | data | phy | 0x11000 | 0x1000 | 射频校准 |
| ota_0 | app | ota_0 | 0x20000 | 0x140000 | 主固件 |
| ota_1 | app | ota_1 | 0x160000 | 0x140000 | OTA 备份 |
| spiffs | data | spiffs | 0x2A0000 | 0x100000 | 文件系统 |
| nvs_app1 | data | nvs | 0x3A0000 | 0x10000 | 小应用1专用 |
| nvs_app2 | data | nvs | 0x3B0000 | 0x10000 | 小应用2专用 |
这张表就是隔离的第一道防线。每个应用如果拥有自己独立的分区,物理上就不可能写到别人的地盘去,因为分区的偏移和大小是编译期固定的,越界写入会直接触发错误而不是静默覆盖。
2.2 为什么很多人一开始不做分区隔离
我见过太多项目图省事,所有数据都往默认的nvs分区里塞,靠命名空间(namespace)区分。这在应用少、团队小的时候没问题,但一旦应用数量上来,或者引入了第三方 WASM 模块,风险就指数级上升。
NVS 的命名空间隔离是逻辑隔离,不是物理隔离。它靠的是键名前面的 namespace 前缀,底层还是同一块 Flash 区域。如果某个应用拿到的是原始的 NVS 句柄,或者代码里 namespace 拼错、用了空字符串,就可能读到别人的数据。更麻烦的是 NVS 有容量上限,默认 0x6000 也就是 24KB,多个应用一起写,很容易写满,写满之后的行为是返回ESP_ERR_NVS_NOT_ENOUGH_SPACE,但如果代码没处理这个错误,就可能出现"写了一半"的脏数据。
所以我的建议很明确:应用数量超过两个,或者有第三方代码参与,就一定要做物理分区隔离。多花的那点 Flash 空间,换来的是排查问题时不用怀疑人生。
2.3 分区大小的估算方法
分区给多大不是拍脑袋。以 NVS 为例,它内部按 32 字节的条目(entry)组织,每个键值对至少占一个条目,加上页头、位图等开销,实际可用容量大约是分区大小的 60% 到 70%。假设一个小应用要存 50 个配置项,平均每个键名 16 字节、值 32 字节,那么:
- 单个条目约 32 字节(键+值+元数据)
- 50 个条目约 1600 字节
- 加上页管理和冗余,预留 3 到 4 倍,约 6KB
- 再考虑未来扩展,给 16KB(0x4000)比较稳妥
文件系统分区则要看实际文件大小。SPIFFS 和 LittleFS 都有元数据开销,LittleFS 在小文件场景下表现更好,磨损均衡也更均匀。如果只是放几个几 KB 的配置文件,1MB 分区绰绰有余;如果要放前端资源包,就得按实际体积乘以 1.3 左右来估。
3. NVS 命名空间隔离:最容易被忽视的细节
3.1 命名空间不是万能的,但用好了很省事
NVS 的命名空间机制,本质是给键名加了一层前缀。你调用nvs_open("app1", NVS_READWRITE, &handle)之后,所有通过这个 handle 读写的键,实际存储时都会带上app1这个前缀。不同命名空间下的同名键互不干扰,这是它最实用的地方。
但有几个坑必须说清楚:
- 命名空间名字长度限制是 15 个字符,超了会返回错误。我见过有人用
application_one_config这种名字,直接编译不过。 - 命名空间不能为空字符串,空字符串在部分 IDF 版本里行为未定义,可能读到默认命名空间的数据。
- 删除命名空间要用
nvs_erase_all而不是逐个删键,后者在断电时可能留下残留条目。
3.2 一个应用一个命名空间的落地写法
下面这段是我在项目里常用的封装,核心思路是每个应用模块只拿到自己的命名空间句柄,绝不暴露全局 NVS 操作:
typedef struct { nvs_handle_t handle; const char *ns; } app_storage_t; esp_err_t app_storage_init(app_storage_t *st, const char *ns) { if (st == NULL || ns == NULL || strlen(ns) == 0 || strlen(ns) > 15) { return ESP_ERR_INVALID_ARG; } st->ns = ns; esp_err_t err = nvs_open(ns, NVS_READWRITE, &st->handle); if (err != ESP_OK) { return err; } return ESP_OK; } esp_err_t app_storage_set_i32(app_storage_t *st, const char *key, int32_t val) { if (st == NULL || st->handle == 0) { return ESP_ERR_INVALID_STATE; } return nvs_set_i32(st->handle, key, val); }这样每个应用模块初始化时传入自己的命名空间,比如app_storage_init(&st, "app1"),它就只能操作app1下的键。即使代码里写错了键名,也只会影响自己,不会串到别人家。
3.3 命名空间和物理分区的组合拳
如果你的应用数量多、数据量大,光靠命名空间不够,这时候就要把命名空间和独立分区结合起来。做法是在分区表里给每个应用划一块 NVS 分区,然后在代码里用nvs_open_from_partition指定分区名:
nvs_handle_t handle; esp_err_t err = nvs_open_from_partition("nvs_app1", "config", NVS_READWRITE, &handle);注意第一个参数是分区名,第二个才是命名空间。这样即使两个应用用了相同的命名空间名config,因为分区不同,数据也是完全隔离的。这是我最推荐的方案,物理隔离加逻辑隔离双保险。
提示:使用
nvs_open_from_partition时,分区表里对应的分区类型必须是data、子类型必须是nvs,否则会返回ESP_ERR_NVS_PART_NOT_FOUND。
4. 文件系统层面的隔离:SPIFFS 和 LittleFS 怎么选怎么分
4.1 挂载点就是隔离边界
文件系统的隔离比 NVS 更直观——每个分区挂载到不同的路径,路径就是边界。比如:
/spiffs挂主应用资源/app1挂小应用1的数据/app2挂小应用2的数据
挂载的时候用esp_vfs_spiffs_register或esp_vfs_littlefs_register,传入对应的分区标签和挂载点。挂载之后,应用只能通过自己的挂载点访问文件,跨挂载点的路径访问需要显式写全路径,这就形成了一道天然的屏障。
esp_vfs_littlefs_conf_t conf = { .base_path = "/app1", .partition_label = "app1_fs", .format_if_mount_failed = true, .dont_mount = false, }; esp_err_t err = esp_vfs_littlefs_register(&conf);4.2 SPIFFS 和 LittleFS 的取舍
这两个是 ESP32 上最常用的轻量文件系统,选哪个要看场景:
| 对比项 | SPIFFS | LittleFS |
|---|---|---|
| 断电安全性 | 一般,写时断电易损坏 | 较好,有掉电保护 |
| 磨损均衡 | 静态均衡 | 动态均衡,更均匀 |
| 小文件性能 | 一般 | 较好 |
| 目录支持 | 扁平,无真正目录 | 支持目录 |
| 社区维护 | 基本停止 | 活跃 |
| 适用场景 | 老项目兼容 | 新项目首选 |
我的经验是:新项目一律上 LittleFS。SPIFFS 在频繁写入场景下,用久了会出现挂载失败,需要格式化才能恢复,这在现场设备上是灾难。LittleFS 虽然也有磨损,但它的动态均衡让寿命长不少,而且掉电后恢复能力强。
4.3 文件系统隔离的实操要点
挂载多个文件系统时,有几个细节容易翻车:
- 挂载点不能重叠,
/app1和/app1/data这种嵌套挂载在 ESP-IDF 里行为不确定,尽量避免。 - 每个分区的
partition_label必须和分区表里的名字完全一致,大小写敏感。 format_if_mount_failed要谨慎使用,生产环境建议设为 false,挂载失败时上报而不是直接格式化,否则一次误操作就把用户数据清了。- 文件句柄要及时关闭,LittleFS 对同时打开的文件数有限制,默认配置下大概 4 到 8 个,泄漏句柄会导致后续打开失败。
5. WASM 运行时下的存储隔离:最难啃的一块
5.1 为什么 WASM 让隔离变复杂
WASM 的卖点是"沙箱执行",但它的沙箱默认只隔离内存,不隔离存储。一个 WASM 模块如果被赋予了文件读写或者 NVS 访问的宿主函数(host function),它就能通过这层接口碰到真实存储。如果多个 WASM 模块共用同一套宿主函数,又没有做参数校验,那隔离就是纸糊的。
我见过一个设计:宿主给 WASM 暴露了一个write_file(path, data)函数,path 直接透传给文件系统。结果某个模块传了../app2/config.json,直接把隔壁应用的数据覆盖了。这就是典型的路径穿越,和 Web 安全里的目录穿越是一个道理。
5.2 宿主函数层面的权限收口
正确的做法是每个 WASM 模块实例绑定一个存储上下文,宿主函数不接受任意路径,只接受相对路径,并且强制拼接到该模块自己的根目录下:
// 伪代码示意 esp_err_t wasm_host_write_file(wasm_ctx_t *ctx, const char *rel_path, const uint8_t *data, size_t len) { if (ctx == NULL || rel_path == NULL) return ESP_ERR_INVALID_ARG; // 拒绝包含 .. 的路径 if (strstr(rel_path, "..") != NULL) return ESP_ERR_INVALID_ARG; // 拒绝绝对路径 if (rel_path[0] == '/') return ESP_ERR_INVALID_ARG; char full_path[128]; snprintf(full_path, sizeof(full_path), "%s/%s", ctx->mount_point, rel_path); return write_file_impl(full_path, data, len); }这里ctx->mount_point是模块初始化时分配的,比如/wasm/app1。这样无论模块怎么传路径,都出不了自己的目录。NVS 访问同理,宿主函数内部固定用模块自己的命名空间句柄,不暴露原始句柄给 WASM。
5.3 资源配额:防止一个模块吃光所有空间
隔离不只是"不串门",还包括"不抢占"。一个失控的 WASM 模块可能疯狂写日志,把分区写满,导致其他模块写不进去。所以每个模块要有配额:
- 文件系统配额:定期统计模块目录占用,超过阈值就拒绝写入并上报。
- NVS 条目配额:NVS 没有原生的配额机制,需要自己在宿主层维护计数,每次写入前检查。
- 写入频率限制:Flash 擦写寿命有限(一般 10 万次左右),高频写入会加速磨损。对日志类写入做限流,比如每秒最多一次。
注意:Flash 擦写寿命是按扇区算的,不是整块。如果某个扇区被反复擦写,它会先坏。所以日志这种高频写入,最好用环形缓冲加批量落盘,而不是每条都写。
6. 完整实操:从分区表到多应用隔离的落地流程
6.1 第一步:设计分区表
假设我们有主应用加两个小应用,Flash 是 4MB,规划如下:
# 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, 0x180000, spiffs, data, spiffs, 0x190000, 0x80000, nvs_app1, data, nvs, 0x210000, 0x4000, nvs_app2, data, nvs, 0x214000, 0x4000, fs_app1, data, spiffs, 0x218000, 0x40000, fs_app2, data, spiffs, 0x258000, 0x40000,这里主应用占 1.5MB,两个小应用各占 256KB 文件系统加 16KB NVS,剩下的留给未来扩展。注意偏移地址要 4KB 对齐,这是 Flash 扇区的要求。
6.2 第二步:初始化各应用的存储上下文
在主应用启动时,为每个小应用初始化独立的存储上下文,然后通过接口传递,而不是让它们自己去 open:
typedef struct { nvs_handle_t nvs; const char *fs_root; size_t quota_bytes; size_t used_bytes; } app_ctx_t; static app_ctx_t g_app1_ctx; static app_ctx_t g_app2_ctx; void apps_storage_init(void) { nvs_open_from_partition("nvs_app1", "cfg", NVS_READWRITE, &g_app1_ctx.nvs); g_app1_ctx.fs_root = "/fs_app1"; g_app1_ctx.quota_bytes = 200 * 1024; // app2 同理 }6.3 第三步:挂载文件系统
void mount_app_fs(const char *label, const char *base_path) { esp_vfs_spiffs_conf_t conf = { .base_path = base_path, .partition_label = label, .max_files = 4, .format_if_mount_failed = false, }; esp_err_t err = esp_vfs_spiffs_register(&conf); if (err != ESP_OK) { ESP_LOGE(TAG, "mount %s failed: %s", label, esp_err_to_name(err)); } }调用mount_app_fs("fs_app1", "/fs_app1")和mount_app_fs("fs_app2", "/fs_app2"),两个应用的文件系统就各自独立了。
6.4 第四步:验证隔离效果
写完代码一定要验证,不能想当然。我的验证清单:
- 在 app1 里写一个键,然后 app2 用相同键名读,应该读不到。
- 在 app1 里写文件
/fs_app1/test.txt,然后尝试从 app2 访问/fs_app1/test.txt,应该失败或读不到。 - 把 app1 的 NVS 分区写满,确认 app2 的写入不受影响。
- 模拟断电(直接拔电),重启后确认两个应用的数据都完整。
这四步做完,基本能确认隔离是有效的。
7. 常见问题与排查技巧实录
7.1 问题速查表
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 配置莫名重置 | 命名空间冲突或分区被覆盖 | 打印各应用 NVS 句柄和分区名 | 改用独立分区 |
| 挂载失败 | 分区标签拼错或分区表未烧录 | 用esp_partition_find查分区 | 核对标签,重烧分区表 |
| 写入返回空间不足 | 分区太小或碎片化 | 统计实际占用 | 扩大分区或整理数据 |
| 断电后文件损坏 | 用了 SPIFFS 且写入中掉电 | 检查文件系统类型 | 换 LittleFS |
| WASM 模块读到别人数据 | 宿主函数未做路径校验 | 审计所有 host function | 加路径白名单和前缀拼接 |
| 频繁写入后 Flash 报错 | 扇区磨损 | 查写入频率 | 加限流和批量落盘 |
7.2 几个我踩过的坑
坑一:分区表改了但没重新烧录。改了partitions.csv之后,只烧应用固件是不够的,分区表本身也要烧。用idf.py flash会一起烧,但如果用其他工具单独烧 app,就会漏掉。表现是nvs_open_from_partition返回找不到分区。
坑二:NVS 句柄跨任务使用。NVS 句柄本身不是线程安全的,多个任务同时用同一个句柄读写会出问题。要么加互斥锁,要么每个任务自己 open 一个句柄。我一般用互斥锁封装,简单可靠。
坑三:LittleFS 的max_files设太小。默认值可能只有 4,如果应用同时打开多个文件就会失败。根据实际需要调大,但别调太大,每个打开的文件都占内存。
坑四:WASM 模块的路径校验漏了符号链接。如果文件系统支持符号链接,光过滤..不够,还要检查解析后的真实路径是否在允许范围内。ESP32 上的轻量文件系统一般不支持符号链接,但如果你用的是其他方案,这点要注意。
7.3 独家避坑技巧
- 给每个分区加一个"魔数"头。在分区开头写一个固定的标识,应用启动时先校验,确认自己拿到的是正确的分区。这能防止分区表配错导致的错位访问。
- NVS 写入加版本号。每个应用的配置结构体带一个 version 字段,升级时根据版本做迁移,避免新旧格式混读。
- 日志分区单独划。日志是写入最频繁的,单独给它一块分区,即使写坏了也不影响配置数据。
- 定期做隔离自检。在固件里加一个自检任务,启动时随机读写各应用的存储,确认边界有效。这在量产前特别有用。
8. 关于隔离粒度的一点个人体会
隔离做到什么程度,其实是个权衡。分区划得太细,Flash 利用率低,管理复杂度高;划得太粗,隔离不彻底,出问题难排查。我的经验是按"故障域"来划:如果一个应用崩溃或数据损坏,会不会影响其他应用?会,就必须物理隔离;不会,逻辑隔离就够。
另外,隔离不是一劳永逸的。应用在迭代,数据在增长,今天够用的分区明天可能就满了。所以分区表设计时要留余量,至少留 20% 的空白区域,方便后续调整。我一般会在分区表末尾留一块reserved分区,需要的时候再切给某个应用。
最后说个实际的:如果你现在正在做多应用共用 Flash 的架构,先把分区表画出来,把每个应用的数据边界标清楚,再动手写代码。这一步花半小时,能省后面几天的调试时间。数据串门这种事,防住了就是没发生,防不住就是无穷无尽的玄学问题。