ESP32多应用Flash隔离实战:分区、命名空间与WASM沙箱
1. 一块 Flash 塞进多个 App问题到底出在哪ESP32 上跑多个小应用共用同一块片内 Flash这件事在量产项目里非常常见。比如一个智能面板固件里同时塞了温湿度采集、蓝牙配网、本地 Web 配置页、OTA 升级模块甚至还有跑在 WASM 虚拟机里的第三方小插件。这些模块各自都要存点东西配网信息、用户偏好、校准参数、运行日志、插件状态。如果谁想写就写、谁想读就读迟早会出问题。我见过最典型的事故是这样的一个客户的项目里主应用把 Wi-Fi 配置存在 NVS 的wifi_config命名空间下后来加了一个第三方插件插件作者图省事直接用了默认命名空间nvs键名也叫ssid。结果插件一启动就把主应用的 SSID 覆盖了设备连不上网现场排查了两天才定位到。这不是段子是真实发生过的。所以标题里说的数据不会串门本质上是三个层面的隔离问题存储空间的物理隔离、命名空间的逻辑隔离、访问权限的运行时隔离。这三个层面缺一个早晚会翻车。下面我按实际项目里的处理顺序把这件事从头到尾拆一遍。提示本文讨论的是片内 Flash 上的数据分区与多应用共存问题不涉及任何网络访问工具或跨境内容纯粹是嵌入式存储管理的工程实践。2. 先搞清楚 ESP32 的 Flash 分区表是怎么切蛋糕的2.1 分区表不是随便画的它决定了隔离的物理边界ESP32 的 Flash 通常 4MB 起步通过分区表partition table划分成若干区域。默认的分区表长这样分区名类型子类型偏移大小用途nvsdatanvs0x90000x5000键值存储otadatadataota0xE0000x2000OTA 状态app0appota_00x100000x140000主固件app1appota_10x1500000x140000备用固件spiffsdataspiffs0x2900000x160000文件系统coredumpdatacoredump0x3F00000x10000崩溃转储这张表就是蛋糕切法。多个小应用如果都往nvs里塞数据那它们共享的是同一块 0x5000 字节的空间。默认 NVS 分区只有 20KB听起来不多但存几千个键值对绰绰有余。问题不在于空间够不够而在于谁都能访问谁的数据。我的做法是给每个需要独立存储的应用单独切一个 NVS 分区。比如# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, nvs_app1, data, nvs, 0xD000, 0x4000, nvs_app2, data, nvs, 0x11000, 0x4000, nvs_app3, data, nvs, 0x15000, 0x4000, phy_init, data, phy, 0x19000, 0x1000, factory, app, factory, 0x20000, 0x180000,这样每个应用拿到的是独立的物理分区从硬件层面就不可能互相覆盖。代价是每个分区有固定开销NVS 的页管理结构大约占几百字节4 个分区多花不到 2KB换来的是彻底的隔离这笔账很划算。2.2 分区偏移必须对齐否则烧录直接失败这里有个坑我踩过NVS 分区的偏移地址必须按 0x10004KB对齐。上面表格里 0x9000、0xD000、0x11000、0x15000 都是 4KB 对齐的。如果你写成 0x9500编译时可能不报错但烧录后运行会直接崩溃报nvs_flash_init失败。另一个坑是分区大小。NVS 分区最小不能小于 0x300012KB因为 NVS 至少需要 3 个扇区来运作1 个活跃页 1 个备用页 1 个擦除页。我一般给每个应用分 0x400016KB够用且留有余量。2.3 在代码里怎么指定用哪个分区默认的nvs_flash_init()用的是nvs分区。要用自定义分区得走nvs_flash_init_partition()// 初始化 app1 专属的 NVS 分区 esp_err_t err nvs_flash_init_partition(nvs_app1); if (err ESP_ERR_NVS_NO_FREE_PAGES || err ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase_partition(nvs_app1)); err nvs_flash_init_partition(nvs_app1); } ESP_ERROR_CHECK(err); // 打开该分区下的命名空间 nvs_handle_t handle; err nvs_open_from_partition(nvs_app1, app1_cfg, NVS_READWRITE, handle);注意nvs_open_from_partition这个 API它把分区和命名空间两个维度都锁死了。app1 的代码只能碰nvs_app1分区就算它想访问 app2 的数据也得先知道分区名而分区名是编译期写死的运行时改不了。这就形成了第一道防线。3. 命名空间和键名设计逻辑隔离的第二道墙3.1 命名空间不是可有可无的装饰即使多个应用共用同一个 NVS 分区命名空间namespace也能提供逻辑隔离。NVS 的命名空间上限是 15 个字符键名上限也是 15 个字符。这个限制很死设计时要提前规划。我的命名规范是这样的命名空间用应用名_模块名格式比如app1_wifi、app1_sensor、app2_ui、app2_log键名用功能_属性格式比如sta_ssid、sta_pass、temp_offset、log_level绝对禁止使用nvs、config、data这种通用名为什么因为通用名迟早会撞车。你想想如果 app1 用了config命名空间app2 也用了config它们虽然在不同分区里没事但一旦哪天合并到一个分区或者有人复制粘贴代码时忘了改分区名数据就串了。用带应用前缀的命名空间从命名上就杜绝了这种可能。3.2 键名冲突的隐蔽性比你想的严重键名冲突最恶心的地方在于它不会报错。NVS 的nvs_set_str如果键已存在会直接覆盖旧值返回ESP_OK。你根本不知道自己的数据被谁改了。我做过一个实验在同一个命名空间下app1 写入ssid MyWiFiapp2 写入ssid OtherWiFi然后 app1 再读读出来的是OtherWiFi。整个过程没有任何错误码两个应用都以为自己写成功了。所以除了命名规范我还加了一层运行时校验每个应用在初始化时往自己的命名空间写一个owner_id键值是应用的唯一标识比如编译时生成的 UUID 或版本号。每次读写前先校验owner_id是否匹配不匹配就拒绝操作并打日志。这多花几行代码但能在调试阶段快速暴露串门问题。#define APP1_OWNER_ID app1_v1.2.3 bool check_owner(nvs_handle_t handle) { char buf[32] {0}; size_t len sizeof(buf); esp_err_t err nvs_get_str(handle, owner_id, buf, len); if (err ! ESP_OK) { // 首次使用写入标识 nvs_set_str(handle, owner_id, APP1_OWNER_ID); nvs_commit(handle); return true; } return strcmp(buf, APP1_OWNER_ID) 0; }3.3 命名空间数量也有上限NVS 默认最多支持 16 个命名空间可配置到 64 个。如果你有 10 个小应用每个应用 3 个命名空间那就是 30 个超过默认上限了。这时候要么调大CONFIG_NVS_NAMESPACES要么合并命名空间。我一般建议每个应用最多用 2 个命名空间一个存配置读写频繁一个存日志或统计写入频繁但读取少。这样 10 个应用也就 20 个命名空间留有余量。4. 当应用跑在 WASM 虚拟机里隔离怎么做4.1 WASM 沙箱天然隔离内存但 Flash 访问要自己管现在有些 ESP32 项目会集成 WASM 虚拟机让第三方开发者用 C/Rust 编译成 WASM 字节码跑在设备上。WASM 本身提供了内存沙箱插件碰不到宿主的内存。但 Flash 访问不一样插件通常通过宿主暴露的 API 来读写 NVS。这时候隔离的关键在于宿主必须给每个插件分配独立的存储上下文。我的做法是每个插件加载时宿主生成一个唯一的app_id比如插件包的哈希值前 8 字节宿主维护一张app_id - nvs_partition namespace的映射表插件调用存储 API 时只能传键名和值分区和命名空间由宿主根据app_id自动填充插件无法指定分区名或命名空间从 API 设计上就堵死了越权访问// 宿主暴露给 WASM 的存储 API简化版 int wasm_kv_set(uint32_t app_id, const char* key, const void* value, size_t len) { app_ctx_t* ctx find_app_ctx(app_id); if (!ctx) return -1; // 未知 app_id拒绝 nvs_handle_t handle; esp_err_t err nvs_open_from_partition(ctx-partition, ctx-namespace, NVS_READWRITE, handle); if (err ! ESP_OK) return -2; err nvs_set_blob(handle, key, value, len); if (err ESP_OK) nvs_commit(handle); nvs_close(handle); return (err ESP_OK) ? 0 : -3; }注意这里app_id是宿主分配的插件无法伪造。就算插件反编译了 WASM 字节码它也拿不到别的app_id因为app_id是运行时注入的不在字节码里。4.2 插件存储配额要硬限制光隔离还不够还得防插件把 Flash 写满。NVS 分区写满后nvs_set_*会返回ESP_ERR_NVS_NOT_ENOUGH_SPACE但这时候可能已经影响到同分区的其他应用了。我的做法是给每个插件设一个键值对数量上限和单值大小上限。比如每个插件最多 64 个键单值最大 4KB。宿主在wasm_kv_set里先检查当前键数量超了就拒绝。这需要在宿主侧维护一个计数器或者定期扫描 NVS 统计。#define MAX_KEYS_PER_APP 64 #define MAX_VALUE_SIZE 4096 int wasm_kv_set(uint32_t app_id, const char* key, const void* value, size_t len) { if (len MAX_VALUE_SIZE) return -4; // 值太大 app_ctx_t* ctx find_app_ctx(app_id); if (!ctx) return -1; // 检查键数量简化实现实际可用 nvs_entry_find 遍历 if (count_keys(ctx) MAX_KEYS_PER_APP !key_exists(ctx, key)) { return -5; // 键数量超限 } // ... 后续写入逻辑 }4.3 WASM 插件的生命周期与数据清理插件被卸载或升级时它留下的数据怎么办如果不清理时间长了 Flash 里全是垃圾。我的策略是插件正常卸载时宿主调用nvs_erase_all清空该插件的命名空间插件升级时保留数据但更新owner_id里的版本号设备恢复出厂设置时遍历所有插件分区逐个擦除这里有个细节nvs_erase_all只擦除指定命名空间不影响同分区下的其他命名空间。所以即使多个插件共用一个分区也能精确清理。5. 多应用共用 Flash 时最容易踩的五个坑5.1 坑一OTA 升级把别人的分区覆盖了OTA 升级时新固件写入app1分区但如果你自定义了分区表把某个应用的数据分区放在了app1的偏移范围内升级就会把数据冲掉。我见过一个项目开发者把 NVS 分区放在了0x150000正好和默认的app1重叠OTA 一升级所有配置全丢。避坑方法画分区表时用乐鑫官方的gen_esp32part.py工具校验确保没有重叠。或者直接用idf.py partition-table命令查看最终布局。5.2 坑二NVS 写入频率过高导致 Flash 磨损Flash 有擦写寿命NVS 虽然做了磨损均衡但如果你每秒写一次日志到 NVS几个月就能把扇区写坏。我实测过一块普通 ESP32 模组的 NVS 分区每天写 1000 次大约 3 年会出现坏页。避坑方法高频写入的数据不要放 NVS放 RAM 里缓存定期比如每 5 分钟刷一次。或者用 SPIFFS/LittleFS 存日志它们对大文件写入更友好。5.3 坑三多任务并发访问 NVS 导致数据损坏NVS 本身是线程安全的但如果你在多个任务里同时打开同一个命名空间并写入可能会出现写覆盖——任务 A 读了旧值任务 B 也读了旧值A 先写B 后写A 的修改丢了。避坑方法对同一个命名空间的写操作加互斥锁。或者更简单每个应用只在自己的任务里访问自己的 NVS不跨任务共享。static SemaphoreHandle_t nvs_mutex NULL; void app1_save_config(const char* key, const char* value) { if (nvs_mutex NULL) nvs_mutex xSemaphoreCreateMutex(); xSemaphoreTake(nvs_mutex, portMAX_DELAY); nvs_handle_t handle; nvs_open_from_partition(nvs_app1, app1_cfg, NVS_READWRITE, handle); nvs_set_str(handle, key, value); nvs_commit(handle); nvs_close(handle); xSemaphoreGive(nvs_mutex); }5.4 坑四分区表改了但没擦除旧数据你改了分区表重新烧录固件但 Flash 里的旧数据还在。如果新分区表和旧数据布局不兼容nvs_flash_init可能返回ESP_ERR_NVS_NO_FREE_PAGES然后你就得擦除整个分区。避坑方法每次改分区表后执行idf.py erase-flash全片擦除再烧录。量产时可以在固件里加版本号检测到分区表版本变化就自动擦除相关分区。5.5 坑五不同应用用了相同的加密密钥如果启用了 NVS 加密所有分区共用同一套密钥。这意味着一个应用的密钥泄露所有应用的数据都能被解密。避坑方法对安全要求高的应用用独立的加密分区或者把敏感数据存在外部加密芯片里。ESP32 的 Flash 加密是整片生效的没法按分区区分所以只能从应用层做二次加密。6. 一套可复用的多应用 Flash 隔离方案6.1 分区规划模板基于上面的经验我整理了一套分区规划模板适合 3-5 个小应用共存的场景# Name, Type, SubType, Offset, Size, Flags nvs_sys, data, nvs, 0x9000, 0x4000, nvs_app1, data, nvs, 0xD000, 0x4000, nvs_app2, data, nvs, 0x11000, 0x4000, nvs_app3, data, nvs, 0x15000, 0x4000, nvs_wasm, data, nvs, 0x19000, 0x6000, otadata, data, ota, 0x1F000, 0x2000, phy_init, data, phy, 0x21000, 0x1000, factory, app, factory, 0x30000, 0x1C0000, ota_0, app, ota_0, 0x1F0000, 0x1C0000, storage, data, spiffs, 0x3B0000, 0x40000, coredump, data, coredump,0x3F0000, 0x10000,这个布局里nvs_sys存系统级配置Wi-Fi、设备名nvs_app1/2/3各存一个应用的数据nvs_wasm给 WASM 插件用稍大一点因为插件数量可能多。storage是 SPIFFS 分区存日志和文件。6.2 应用侧的统一封装每个应用不应该直接调nvs_open_from_partition而是用一层薄封装typedef struct { const char* partition; const char* namespace; const char* owner_id; } app_storage_t; esp_err_t app_storage_init(app_storage_t* st); esp_err_t app_storage_set_str(app_storage_t* st, const char* key, const char* value); esp_err_t app_storage_get_str(app_storage_t* st, const char* key, char* out, size_t* len); esp_err_t app_storage_erase_all(app_storage_t* st);这层封装里做三件事校验owner_id、加互斥锁、统一错误处理。应用代码只跟app_storage_t打交道不碰底层 API。这样即使以后换存储方案比如从 NVS 换成 LittleFS应用代码也不用改。6.3 调试阶段的串门检测开发阶段我会在app_storage_init里加一段检测逻辑遍历当前分区的所有命名空间如果发现不属于本应用的命名空间通过owner_id判断就打警告日志。void detect_cross_contamination(app_storage_t* st) { nvs_iterator_t it NULL; esp_err_t err nvs_entry_find(st-partition, NULL, NVS_TYPE_ANY, it); while (err ESP_OK) { nvs_entry_info_t info; nvs_entry_info(it, info); if (strncmp(info.namespace_name, st-namespace, 15) ! 0) { ESP_LOGW(STORAGE, 发现陌生命名空间: %s, info.namespace_name); } err nvs_entry_next(it); } nvs_release_iterator(it); }这段代码只在调试版本里编译量产版本去掉避免性能开销。7. 几个实际项目里的取舍经验7.1 分区多 vs 分区少怎么选分区多隔离好但每个分区有固定开销NVS 页头、磨损均衡元数据而且分区表本身占 Flash。分区少省空间但隔离差。我的经验是应用数量少于 3 个共用一个 NVS 分区 不同命名空间就够了超过 3 个或者有第三方插件就单独分区。第三方插件尤其要单独分区因为你控制不了它的代码质量万一它写个死循环疯狂写 NVS至少不会影响主应用。7.2 NVS vs LittleFS vs 自定义裸分区NVS 适合键值对LittleFS 适合文件裸分区适合大块连续数据。多应用共存时我一般这样分配配置参数、状态标志NVS日志、用户文件、插件资源LittleFS固件备份、大块校准数据裸分区LittleFS 也支持多分区每个应用可以挂载自己的 LittleFS 分区隔离效果和 NVS 类似。7.3 什么时候该上外部 Flash如果片内 4MB Flash 不够用可以外挂 SPI Flash。但外部 Flash 的访问速度比片内慢而且需要额外的 CS 引脚。我的建议是片内 Flash 优先给固件和 NVS外部 Flash 给大文件存储。外部 Flash 上也可以划多个分区隔离逻辑和片内一样。7.4 量产时的分区表锁定量产固件里分区表是编译期固定的运行时改不了。这既是限制也是保护——至少保证了出厂设备的存储布局一致。但 OTA 升级时如果改了分区表需要特别小心新分区表必须兼容旧数据布局否则升级后数据全丢。我的做法是分区表版本号写进nvs_sys的一个固定键里OTA 升级时先读版本号不匹配就触发数据迁移或擦除。迁移逻辑要提前写好不能等到出问题再补。8. 写在最后隔离的本质是不信任做了这么多项目我最大的体会是多应用共存时不要信任任何一个应用。哪怕是你自己写的代码也要假设它可能越界、可能写错键、可能疯狂写 Flash。隔离机制不是为了防黑客是为了防自己和队友的疏忽。分区隔离、命名空间隔离、API 层隔离这三层做下来代码量增加不多但能省掉大量现场排查的时间。我见过太多项目因为存储串门导致设备随机性故障最后查出来是某个不起眼的模块写错了键。这种问题在实验室很难复现到了现场就是灾难。最后分享一个小技巧在nvs_sys里存一个全局的存储布局版本号每次固件启动时校验。如果版本号不匹配说明分区表变了自动触发一次全片擦除量产设备慎用开发阶段很省事。这个习惯帮我省过好几次调试时间。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →