1. 一块 Flash 上跑多个应用,问题到底出在哪
ESP32 这类芯片通常只挂一颗外部 SPI NOR Flash,容量从 2MB 到 16MB 不等,程序固件、文件系统、NVS 键值区、OTA 备份区全都挤在这一颗芯片里。很多人做项目时习惯把功能拆成多个"小应用"——比如一个负责传感器采集,一个负责蓝牙配网,一个负责屏幕 UI,甚至有人尝试在 ESP32 上跑 WASM 虚拟机来动态加载模块。这时候一个绕不开的问题就冒出来了:多个应用共用同一块 Flash,怎么保证 A 应用写的数据不会被 B 应用读到、改掉或者覆盖?
这个问题的本质不是"能不能存",而是"怎么隔离"。Flash 是线性地址空间,物理上没有任何进程边界,谁拿到地址谁就能读写。如果没有一套约定和机制,两个应用各自往 0x100000 写数据,后写的直接把先写的冲掉,读的时候还会读到对方残留的字节,这就是俗称的"数据串门"。
我见过太多项目栽在这上面:OTA 升级后配网信息丢失、日志区和参数区地址重叠导致设备反复重启、WASM 模块加载时把主固件的 NVS 命名空间覆盖掉。这些问题在开发阶段往往不明显,一旦量产批次多了、应用数量上去了,就会集中爆发。
这篇文章面向的是正在用 ESP32 做多应用/多模块共存开发的工程师,尤其是涉及 NVS、文件系统、OTA、甚至 WASM 动态加载场景的朋友。我会把 Flash 分区、NVS 命名空间、文件系统挂载点、WASM 沙箱这几层隔离手段拆开讲清楚,给出可以直接抄的分区表和代码,也会分享几个我踩过的坑。读完你应该能自己设计一套"多应用共用一块 Flash 但互不干扰"的方案。
2. 先搞清楚 ESP32 的 Flash 是怎么被瓜分的
2.1 Flash 物理结构与分区表的角色
ESP32 外挂的通常是 NOR Flash,通过 SPI/QSPI 接口访问,读写的最小单位是页(一般 256 字节),擦除的最小单位是扇区(4KB)。这意味着你不能像操作 RAM 那样随便改一个字节——想改一个字节,得先把整个 4KB 扇区读出来、改掉、擦回去。这个特性直接决定了所有上层存储方案的设计逻辑。
ESP-IDF 用分区表(Partition Table)来管理这颗 Flash。分区表本身也是一块数据,默认烧录在 0x8000 偏移处,长度 0xC00,最多容纳 95 个分区条目。每个条目记录了分区名、类型、子类型、偏移地址和大小。芯片启动时,二级引导程序会读取分区表,然后根据factory或ota_0等标签决定从哪加载固件。
分区表就是"多应用共用 Flash"的第一道隔离墙。只要每个应用的数据区在分区表里被划到不同的偏移和长度,物理上就不会重叠。听起来很简单,但实际项目里出问题的往往是"分区表设计时没预留够"或者"两个应用偷偷用了同一个分区"。
2.2 常见分区类型与它们的隔离能力
ESP-IDF 里分区类型主要有这么几类,隔离强度差别很大:
| 分区类型 | 子类型示例 | 隔离方式 | 适合场景 |
|---|---|---|---|
| app | factory / ota_0 | 物理偏移隔离 | 固件本体 |
| data | nvs / nvs_keys | 命名空间隔离 | 键值配置 |
| data | spiffs / fat / littlefs | 挂载点隔离 | 文件存储 |
| data | coredump | 独立分区 | 崩溃日志 |
| data | phy / nvs_net80211 | 系统保留 | 射频校准 |
关键点在于:app 类型分区靠物理偏移天然隔离,data 类型分区如果多个应用共用一个分区,就必须靠分区内部的逻辑隔离机制。NVS 靠命名空间(namespace),文件系统靠挂载点(mount point),这两者是完全不同的隔离思路,后面会分别展开。
2.3 一个典型的多应用分区表长什么样
假设我们有一颗 8MB Flash,要跑主固件、一个 WASM 模块区、两套应用各自的 NVS、一个共享文件系统、一个 OTA 备份区。分区表大概是这样:
# 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, 0x20000, 0x180000, ota_0, app, ota_0, 0x1A0000, 0x180000, nvs_app_a,data, nvs, 0x320000, 0x10000, nvs_app_b,data, nvs, 0x330000, 0x10000, wasm_fs, data, spiffs, 0x340000, 0x200000, shared_fs,data, littlefs,0x540000, 0x200000, coredump, data, coredump,0x740000, 0x10000,这里有几个设计取舍值得说。nvs_app_a和nvs_app_b各自独立成区,而不是共用一个 nvs 分区靠命名空间隔离——原因是物理分区隔离比命名空间隔离更硬,即使某个应用代码写错了命名空间,也越不过分区边界。代价是每个分区最小要占一个 4KB 扇区,两个应用就多花 8KB,对 8MB 的 Flash 来说完全可以接受。
wasm_fs和shared_fs用不同的文件系统,是因为 WASM 模块加载对随机读性能敏感,SPIFFS 在小文件场景下更轻量;而共享文件系统要支持目录和追加写,littlefs 的掉电恢复能力更强。这个选择不是绝对的,后面会讲怎么根据实际读写模式来定。
3. NVS 命名空间:最容易被忽视的隔离层
3.1 NVS 的工作原理与命名空间机制
NVS(Non-Volatile Storage)是 ESP-IDF 提供的一套键值存储,底层把整个分区切成若干 4KB 页,每页再分成 32 字节的条目。它用一套日志式结构来管理写入,支持掉电恢复和磨损均衡。你调用nvs_set_str、nvs_get_i32这些 API 时,实际是在操作某个命名空间下的键值对。
命名空间是 NVS 的逻辑隔离单位。打开 NVS 时你要指定命名空间名:
nvs_handle_t handle; esp_err_t err = nvs_open("app_a_cfg", NVS_READWRITE, &handle); if (err != ESP_OK) { ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err)); return; } nvs_set_i32(handle, "sample_rate", 100); nvs_commit(handle); nvs_close(handle);不同命名空间下的同名键互不影响。app_a_cfg里的sample_rate和app_b_cfg里的sample_rate是两个完全独立的条目。这就是 NVS 层面的"数据不串门"。
但要注意,命名空间隔离是逻辑隔离,不是物理隔离。两个命名空间仍然共享同一个 NVS 分区的页和条目池。如果分区满了,A 应用写不进去,B 应用也会受影响。所以如果两个应用的数据量都比较大,还是建议像前面分区表那样各给一个独立 nvs 分区。
3.2 命名空间命名规范与冲突排查
命名空间名最长 15 个字符,只允许字母、数字和下划线。我建议用"应用名_用途"的格式,比如ui_cfg、sensor_cal、net_prov。不要用config、data这种通用词,多个应用一撞就乱。
排查命名空间冲突有个实用技巧:NVS 提供了一个遍历 API,可以把分区里所有命名空间和键都列出来。
nvs_iterator_t it = NULL; esp_err_t res = nvs_entry_find("nvs", NULL, NVS_TYPE_ANY, &it); while (res == ESP_OK) { nvs_entry_info_t info; nvs_entry_info(it, &info); ESP_LOGI(TAG, "ns=%s key=%s type=%d", info.namespace_name, info.key, info.type); res = nvs_entry_next(&it); } nvs_release_iterator(it);设备调试阶段跑一遍这个,就能看到当前 NVS 里到底有哪些命名空间,谁写了什么。我遇到过好几次"数据莫名其妙被改",最后用这个 API 一查,发现是某个第三方组件偷偷用了nvs这个默认命名空间写校准数据,和主应用的键撞了。
提示:ESP-IDF 的 Wi-Fi 驱动、蓝牙协议栈会自己往 NVS 里写数据,命名空间通常是
nvs.net80211之类。设计分区大小时一定要给系统预留空间,别把 nvs 分区卡得刚刚好。
3.3 多应用共用 NVS 分区的实操配置
如果确实想让多个应用共用一个 nvs 分区(比如 Flash 实在紧张),那就要严格约定命名空间前缀。我一般会在项目里建一个头文件统一管理:
// storage_keys.h #define NS_APP_UI "ui_cfg" #define NS_APP_SENSOR "sensor_cal" #define NS_APP_NET "net_prov" #define NS_SYS_RESERVED "sys_rsvd"然后在代码审查阶段强制检查:任何nvs_open调用必须使用这些宏,不允许出现字符串字面量。这样即使后面加了新应用,也能一眼看出命名空间有没有撞。
另外,NVS 分区大小要按"条目数 × 32 字节 + 页开销"来估算。一个 0x6000(24KB)的 nvs 分区大约能存 700 多个条目,听起来很多,但 Wi-Fi 校准数据、蓝牙绑定信息一占,实际可用可能只剩一半。我的经验是:每个应用至少预留 8KB,系统保留 16KB,低于这个数就容易在量产阶段出问题。
4. 文件系统挂载点:共享 Flash 上的第二道墙
4.1 SPIFFS、LittleFS、FAT 的隔离差异
当应用需要存的不只是键值对,而是文件、日志、模型数据时,就得上文件系统。ESP-IDF 支持 SPIFFS、LittleFS、FATFS 三种,它们都通过esp_vfs_register注册到 VFS(虚拟文件系统)层,用挂载点路径来区分。
挂载点就是隔离的关键。比如:
esp_vfs_spiffs_conf_t conf_a = { .base_path = "/app_a", .partition_label = "wasm_fs", .max_files = 5, .format_if_mount_failed = false, }; esp_vfs_spiffs_register(&conf_a); esp_vfs_littlefs_conf_t conf_b = { .base_path = "/shared", .partition_label = "shared_fs", .format_if_mount_failed = false, }; esp_vfs_littlefs_register(&conf_b);注册之后,/app_a/xxx走的是wasm_fs分区,/shared/xxx走的是shared_fs分区。应用 A 只要不去碰/shared路径,就物理上访问不到 B 的数据。这比 NVS 命名空间更硬,因为分区不同,连底层块设备都是分开的。
三种文件系统的隔离能力其实一样(都靠挂载点),差别在性能和可靠性:
| 文件系统 | 掉电恢复 | 小文件性能 | 目录支持 | 磨损均衡 |
|---|---|---|---|---|
| SPIFFS | 一般 | 好 | 不支持 | 有 |
| LittleFS | 强 | 中 | 支持 | 有 |
| FATFS | 弱 | 好 | 支持 | 依赖底层 |
WASM 模块加载场景我推荐 SPIFFS,因为模块文件通常不大、只读为主,SPIFFS 的扁平结构反而更快。日志和用户数据用 LittleFS,掉电不容易丢。
4.2 挂载点冲突与路径穿越的坑
挂载点本身不会冲突,但路径穿越会。如果应用 A 的代码里有fopen("/app_a/../shared/secret.txt", "r")这种写法,而 VFS 层没有做路径规范化,就可能读到 B 的数据。ESP-IDF 的 VFS 默认会做一定的路径处理,但不同文件系统实现不一致,不能完全依赖。
我的做法是在应用层加一层路径校验:
static bool path_is_safe(const char *path, const char *allowed_prefix) { if (strstr(path, "..") != NULL) return false; return strncmp(path, allowed_prefix, strlen(allowed_prefix)) == 0; }所有文件操作前先过一遍这个检查。看起来有点笨,但在多应用共存的环境里,这种"笨办法"最可靠。
还有一个坑是格式化。如果应用 A 在挂载失败时调用了format_if_mount_failed = true,而它误挂到了 B 的分区标签上,就会把 B 的数据全格掉。所以这个选项在生产固件里一定要设成false,格式化只在工厂测试阶段手动触发。
4.3 共享文件系统的读写权限约定
有些场景确实需要多个应用读同一份数据,比如共享的配置文件、公共资源。这时候不能靠物理隔离,得靠约定。我一般会这样设计:
- 共享区只允许一个"所有者"应用写,其他应用只读
- 写入用临时文件 + 原子重命名的方式,避免读到半截数据
- 关键文件加一个简单的校验头(magic + 版本 + CRC)
typedef struct { uint32_t magic; // 0x53484152 "SHAR" uint16_t version; uint16_t reserved; uint32_t crc32; uint32_t length; } shared_header_t;读的时候先校验 magic 和 CRC,不通过就当文件不存在。这样即使写入过程中掉电,也不会让读方拿到脏数据。这套机制我在一个多模块共存的网关项目里用了两年,没出过数据串门的事故。
5. WASM 沙箱:动态加载场景下的隔离新思路
5.1 为什么 ESP32 上会用到 WASM
最近两年在 ESP32 上跑 WASM 的玩法越来越多,原因很实际:固件一旦烧录就很难改,但业务逻辑经常要更新。把业务逻辑编译成 WASM 模块,存到 Flash 的独立分区里,运行时用解释器加载执行,就能做到"固件不动、逻辑热更新"。
但这也带来了新的隔离问题:WASM 模块运行时要访问存储,如果它直接调用宿主提供的文件 API,就可能越权访问其他应用的数据。所以 WASM 场景下的隔离,核心是给每个模块划定它能访问的存储边界。
5.2 宿主 API 的权限裁剪
WASM 本身是沙箱化的,它不能直接访问内存和系统调用,所有外部能力都靠宿主(也就是 ESP32 上的固件)通过导入函数提供。所以隔离的关键在宿主这一侧:你给 WASM 模块导入哪些函数,它就能做哪些事。
一个安全的做法是给每个模块分配独立的存储句柄,而不是直接暴露路径:
// 宿主侧:为模块 A 创建受限存储上下文 typedef struct { const char *mount_point; // 只允许访问这个挂载点 nvs_handle_t nvs_handle; // 只允许访问这个命名空间 size_t quota_bytes; // 写入配额 } wasm_storage_ctx_t; // 导入给 WASM 的读取函数 int32_t host_storage_read(wasm_exec_env_t exec_env, uint32_t offset, uint8_t *buf, uint32_t len) { wasm_storage_ctx_t *ctx = get_current_module_ctx(exec_env); if (offset + len > ctx->quota_bytes) return -1; // 只从 ctx->mount_point 下读 ... }这样 WASM 模块拿到的只是一个偏移量和长度,根本不知道底层是哪个分区、哪个文件。它想越权也没有入口。这就是"能力最小化"原则在嵌入式场景的落地。
5.3 模块存储配额与越界检测
光有句柄还不够,还得防模块把配额写爆。我一般会在宿主侧维护一个写入计数器,每次写操作累加,超过配额就返回错误:
static size_t g_module_write_bytes[MAX_MODULES]; int32_t host_storage_write(wasm_exec_env_t exec_env, uint32_t offset, const uint8_t *buf, uint32_t len) { int mod_id = get_module_id(exec_env); if (g_module_write_bytes[mod_id] + len > MODULE_QUOTA) { ESP_LOGW(TAG, "module %d quota exceeded", mod_id); return -1; } g_module_write_bytes[mod_id] += len; ... }配额大小按模块实际需求定,一般给 64KB 到 256KB。这个数字不是拍脑袋来的:WASM 模块的配置数据通常就几 KB,日志类数据可能到几十 KB,超过 256KB 的基本说明设计有问题,应该考虑放到共享文件系统里由宿主代理写入。
越界检测还有个实用手段:在模块加载时记录它的合法访问范围,运行时每次访问都做一次范围检查。虽然有一点性能开销,但在 ESP32 这种资源受限的平台上,安全比那点性能重要得多。
6. 分区表设计实战:从需求到落地
6.1 按应用数量反推分区大小
设计分区表最忌讳"先画表再想需求"。正确的顺序是:先列出所有应用、每个应用要存什么、数据量多大、增长预期多少,再反推分区大小。
我一般用这样一张表来梳理:
| 应用 | 存储类型 | 数据量估算 | 增长预留 | 分区大小 |
|---|---|---|---|---|
| 主固件 | app | 1.2MB | 20% | 1.5MB |
| OTA 备份 | app | 1.2MB | 20% | 1.5MB |
| 应用A配置 | nvs | 4KB | 100% | 8KB |
| 应用B配置 | nvs | 4KB | 100% | 8KB |
| WASM 模块 | spiffs | 512KB | 50% | 768KB |
| 共享数据 | littlefs | 1MB | 50% | 1.5MB |
| 崩溃日志 | coredump | 16KB | 0 | 16KB |
算下来 8MB 的 Flash 用掉约 5.3MB,留 2.7MB 余量。这个余量很重要,因为后面加功能、加应用是常态,分区表一旦烧录到量产设备上,改起来非常麻烦——OTA 只能改 app 分区,data 分区的布局改了会导致老设备数据全丢。
6.2 偏移对齐与 4KB 边界
分区偏移必须按 4KB 对齐,这是 Flash 扇区擦除单位的硬性要求。ESP-IDF 在编译时会检查,不对齐直接报错。但有个隐蔽的坑:分区大小也建议按 4KB 对齐,否则相邻分区可能共享一个扇区,擦除时会互相影响。
比如一个分区从 0x9000 开始、大小 0x5000,结束在 0xE000,下一个分区从 0xE000 开始,这是对齐的。但如果大小是 0x4800,结束在 0xD800,下一个分区从 0xD800 开始,虽然偏移不是 4KB 对齐,编译会报错,但如果你手动改过偏移绕过检查,就会出现两个分区共享扇区的问题。
我的习惯是所有偏移和大小都用 0x1000 的整数倍,写分区表时用计算器算一遍,别偷懒。
6.3 生成与烧录分区表的完整流程
分区表是个 CSV 文件,放在项目根目录,默认叫partitions.csv。在menuconfig里指定:
Partition Table ---> Partition Table (Custom partition table CSV) ---> (partitions.csv) Custom partition CSV file编译时 ESP-IDF 会调用gen_esp32part.py把它转成二进制,烧录到 0x8000。你也可以手动转换来检查:
python $IDF_PATH/components/partition_table/gen_esp32part.py partitions.csv输出会列出每个分区的最终偏移和大小,我每次改完分区表都会跑一遍,确认没有重叠、没有越界。烧录用标准的idf.py flash就行,分区表会跟着固件一起写进去。
注意:如果设备已经量产,改分区表后必须整片擦除再烧录,否则老数据和新分区布局对不上,会出现各种诡异问题。OTA 升级不会更新分区表,这点一定要记住。
7. 常见问题与排查技巧实录
7.1 数据串门的典型症状与定位方法
数据串门的表现往往很隐蔽,我总结了几种典型症状:
- 配置项莫名变成另一个应用的值:多半是 NVS 命名空间撞了
- 文件读出来是乱码或半截:可能是两个应用共用了同一个挂载点,或者写入时掉电
- 设备反复重启,日志显示分区校验失败:分区表重叠或越界
- OTA 后配置丢失:OTA 分区和 data 分区地址冲突
定位方法我一般按这个顺序来:先看分区表有没有重叠,再用前面说的 NVS 遍历 API 看命名空间,最后检查文件系统挂载点。九成问题在前两步就能找到。
7.2 分区表冲突速查表
| 症状 | 可能原因 | 排查命令/方法 |
|---|---|---|
| 启动报 partition table invalid | CSV 格式错误或偏移重叠 | gen_esp32part.py检查 |
| nvs_open 返回 ESP_ERR_NVS_NOT_FOUND | 分区标签写错或分区太小 | 查分区表 label |
| 文件系统挂载失败 | 分区未格式化或标签不匹配 | 串口日志看 mount 报错 |
| 写入后读回不一致 | 扇区未擦除或跨分区写 | 检查写入地址范围 |
| OTA 后数据丢失 | 分区表变更未整片擦除 | 重新全片烧录 |
7.3 我踩过的三个真实坑
第一个坑:NVS 分区给太小。早期项目我给 nvs 只留了 0x4000(16KB),开发阶段够用,量产时 Wi-Fi 校准数据一写就满了,导致应用配置写不进去,设备配网失败。后来改成 0x6000 并给每个应用独立分区,问题消失。教训是:系统保留的 NVS 空间永远比你想象的大。
第二个坑:SPIFFS 和 LittleFS 混用同一个分区。有次为了省空间,让两个应用共用一个分区,一个用 SPIFFS 挂载,一个用 LittleFS 挂载。结果 LittleFS 把 SPIFFS 的元数据当成自己的格式解析,直接挂载失败,还破坏了原有数据。一个分区只能被一种文件系统格式化,这是铁律。
第三个坑:WASM 模块越权写 NVS。早期版本的宿主 API 直接把nvs_handle传给了 WASM 模块,结果某个模块的 bug 把主应用的配置键覆盖了。后来改成句柄 + 配额 + 范围检查三重限制,再没出过问题。给动态加载的代码开放存储能力时,永远假设它会犯错。
8. 一套可复用的隔离方案模板
把前面这些串起来,我给出一套可以直接抄的方案。核心思路是"物理分区优先、逻辑隔离兜底、动态加载加沙箱"。
分区层面,每个应用至少一个独立 data 分区,能物理隔离就不靠命名空间。NVS 分区按应用数量 × 8KB + 系统 16KB 预留。文件系统分区按数据量 × 1.5 预留增长空间。所有偏移和大小 4KB 对齐。
代码层面,建一个统一的存储访问层,所有应用通过它读写,不允许直接调nvs_open或fopen。访问层里做命名空间校验、路径校验、配额检查。
// storage.h esp_err_t storage_get_i32(const char *app_id, const char *key, int32_t *out); esp_err_t storage_set_i32(const char *app_id, const char *key, int32_t val); esp_err_t storage_read_file(const char *app_id, const char *path, void *buf, size_t *len);app_id到命名空间和挂载点的映射在访问层内部维护,应用层拿不到底层细节。这样即使某个应用代码写错,也越不过访问层的边界。
WASM 场景再叠一层:宿主给每个模块分配独立的存储上下文,导入函数只暴露偏移量级的读写接口,配额和范围检查在宿主侧完成。
这套方案我在三个量产项目上用过,Flash 从 4MB 到 16MB 都有,应用数量从 2 个到 6 个,没再出现过数据串门。最后分享一个小技巧:在工厂测试固件里加一个存储自检流程,遍历所有分区、所有命名空间、所有挂载点,确认它们互相独立且可读写。这个自检跑一遍只要几秒,但能拦住绝大多数分区表设计错误,比量产后再返工划算得多。