前阵子帮人救了一台 ESP32 智能网关,现象很典型:手机 App 里改完 A 房间灯的名字,B 房间的温湿度读数跟着乱;断电重启后,昨天写的 Wi-Fi 密码直接变成空白。拆开看,板上就一颗 4MB Flash,里面塞了三个小应用,配网、传感器采集、LED 灯效各占一块,数据也各自往 Flash 里写,结果就是两个模块互相踩。说白了,这是多个小应用共用一块 ESP32 Flash 时没做好数据隔离。
所谓隔离,不是靠大家“讲文明、懂礼貌”,而是有一套标准动作:先用分区表圈地,再用 NVS namespace、文件系统挂载和自描述数据格式做应用层隔离,最后用 Flash 加密和 Secure Boot 做物理级保护。本文就按这个顺序讲,最后补三个我自己踩过的“串门”案例。不管你是用 ESP-IDF 还是 Arduino IDE 开发,只要一个芯片上要装多个固件镜像或多种业务数据,这篇都值得看一看。
1. 串门事故现场:搞清一块 Flash 上到底住了几路“人马”
1.1 ESP32 的 Flash 不是一整块“内存”,而是一套有门牌号的公寓
长期写单片机的朋友最容易犯一个错:把 Flash 当成一个超级大的数组,拿着地址直接读和写。ESP32 的 Flash 虽然是挂在 SPI 上的存储芯片,但 CPU 运行程序时要靠 Cache 和 MMU 把 Flash 内容映射到内存地址空间,所以你能访问的地址范围并不是物理扇区地址。ESP-IDF 把这层映射又封装了一层,叫分区(partition)。每个分区有 offset、size、type、subtype、label,这五个信息就是门牌号。
如果应用程序不去查分区表,而是直接对某个“印象中的地址”进行擦写,一旦编译选项、OTA 流程或分区表改过,地址就漂了,数据就会串到邻居家。我见过最严重的事故,一个同事把 SPIFFS 分区的起始地址算错了一个扇区,每次写日志都把配置分区的头两个扇区擦了,整个设备恢复到出厂状态。所以隔离的第一原则不是“大家自觉”,而是所有存储访问都必须走分区句柄,地址只存在于分区表里。
1.2 常见住户清单:出厂固件、OTA固件和各种数据分区
要理解隔离,先得知道一块 Flash 里一般住着哪些“人”。我把最常见住户列成这样一张表,你对照自己项目就能明白个大概:
| 分区名 | Type/SubType | 典型用途 | 隔离角色 |
|---|---|---|---|
| bootloader | 引导程序,不在分区表里 | 启动和加载应用 | 系统层 |
| nvs | data/nvs | Wi-Fi 校准、系统配置、用户参数 | 全局配置区 |
| otadata | data/ota | 记录哪个 OTA 分区是当前启动分区 | OTA 状态 |
| phy_init | data/phy | 射频校准数据 | 系统私有 |
| factory | app/factory | 出厂应用,A 固件 | 应用镜像 |
| ota_0 | app/ota_0 | OTA 升级后的 B 固件入口 | 应用镜像 |
| ota_1 | app/ota_1 | OTA 升级后的 C 固件入口 | 应用镜像 |
| fs_app_a | data/spiffs 或 data/littlefs | App A 的资源文件、日志 | 业务数据 |
| fs_app_b | data/spiffs 或 data/littlefs | App B 的资源文件、日志 | 业务数据 |
如果你把“多个小应用”定义为同一颗 Flash 上同时存在多个可独立启动的固件镜像,那么 factory、ota_0、ota_1 就是最常见的“多住户”。如果你只是单个固件里有配网、传感器、灯效多个业务模块,那么它们之间的隔离主要靠 NVS namespace 和文件系统分区。
1.3 “串门”发生的三种典型姿势
数据串门不是玄学,我总结下来基本就三种姿势:
- 越界访问:擦写分区时 offset 或 size 算错,越过自己分区边界,把相邻分区的数据擦掉。比如
esp_partition_erase_range的 size 没按 4KB 扇区对齐,或者手滑写大了一位。 - 命名冲突:两个应用用了同一个 NVS namespace 和同一个 key,或者文件系统里用了同样的目录和文件名。这种最隐蔽,编译不报错,运行时数据互相覆盖。
- 句柄拿错:
esp_partition_find_first传错了 type/subtype/label,拿到的分区句柄指向别人的地盘。常见于代码复制粘贴,A 模块的代码里写死了查找 B 分区的 label,结果两个模块写进同一个区域。
记住这三个姿势,后面所有方案都是围绕“从机制上杜绝越界”和“从规范上杜绝命名冲突”展开的。
2. 用 partition table 圈地:让每个小应用只能碰到自己的区域
2.1 分区表是 Flash 的“地契”,不是一份装饰文档
ESP-IDF 的分区表就是一份 CSV 文件,每行定义一个分区,字段依次是Name, Type, SubType, Offset, Size, Flags。Type 分app(0x00)和data(0x01)两大类,自定义业务数据通常用 data 类型加自定义 subtype。SubType 里 nvs、phy、otadata、spiffs、littlefs 这些各有编号,app 类型里 factory、ota_0、ota_1 也都有对应值。
如果你在 Arduino IDE 里开发,别以为分区表和自己无关。Arduino 的 Tools 菜单里那个 “Partition Scheme” 就是在选不同的 CSV 文件,你甚至可以选 Custom,然后给项目根目录放一个partitions.csv。所以下面的思路两个平台通用。
分区表为什么是隔离的基础?因为分区表会被编译进固件,烧录时放在 Flash 的固定位置(默认 0x8000),启动时 Bootloader 会解析它,之后所有上层存储 API 都以它为准。分区的 offset 和 size 一旦写死,就相当于给每户人家画了红线。
2.2 一个能容纳多个独立小应用的 4MB 分区表示例
以一颗 4MB Flash 为例,我常用下面这种布局:开头放系统服务分区,中间放两个独立业务数据分区,后面放一个出厂固件和一个 OTA 固件。这样不仅两个应用镜像不串,各自的 NVS 也不串。
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, phy_init, data, phy, 0x11000, 0x1000, nvs_app_a, data, nvs, 0x12000, 0x12000, encrypted nvs_app_b, data, nvs, 0x24000, 0x12000, encrypted fs_app_a, data, spiffs, 0x36000, 0x20000, fs_app_b, data, spiffs, 0x56000, 0x20000, factory, app, factory, 0x100000, 0x180000, ota_0, app, ota_0, 0x280000, 0x180000,几点解释:
nvs是 ESP-IDF 默认全局 NVS 分区,放系统层的 Wi-Fi、蓝牙校准数据。nvs_app_a和nvs_app_b是给两个业务应用准备的独立 NVS 分区,两边用nvs_flash_init_partition和nvs_open_from_partition分别打开,天然隔离。fs_app_a和fs_app_b是两个独立的文件系统分区,分别挂载到/app_a和/app_b,比“同一个文件系统里分目录”更硬核。factory和ota_0是两个应用镜像分区,中间留了空隙,方便以后调整。如果要做 A/B 双 OTA,可以再加一个ota_1。
改完 CSV 后,用idf.py partition-table或esptool.py partition_table先检查一遍,确认没有重叠。很多“串门”在你烧录前就能被这种检查拦下来。
2.3 抓住分区句柄,别碰裸地址
有了分区表,代码里要用官方 API 去拿分区句柄,而不是自己拼地址。最常用的查找方式是按 label 找:
const esp_partition_t *part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_NVS, "nvs_app_a"); if (part == NULL) { ESP_LOGE("APP", "partition nvs_app_a not found"); abort(); } uint8_t buf[64]; esp_err_t err = esp_partition_read(part, 0, buf, sizeof(buf)); if (err != ESP_OK) { ESP_LOGE("APP", "read failed: %s", esp_err_to_name(err)); }esp_partition_read、esp_partition_write、esp_partition_erase_range这些 API 都会校验偏移和长度是否落在分区范围内,越界会返回ESP_ERR_INVALID_ARG。这个边界检查就是防串门的第一道防线。
我见过很多项目直接在代码里写0x210000这种魔法地址,理由是需要“高性能”。但除非你明确知道自己在做什么,否则别这么干。分区表一变,整个项目就废了。你要做的是先esp_partition_find_first拿句柄,然后在这个句柄的范围内操作。
3. 应用层隔离:命名空间、目录、自描述数据格式的实操细节
3.1 NVS:同分区不同 namespace,不如直接拆分区
NVS 本身提供了“分区 + namespace + key”三级结构。在同一个 NVS 分区里,只要 namespace 不同,key 就是独立空间:
// App A nvs_handle_t h_a; nvs_open("app_a_config", NVS_READWRITE, &h_a); nvs_set_i32(h_a, "temp_alarm", 30); // App B nvs_handle_t h_b; nvs_open("app_b_config", NVS_READWRITE, &h_b); nvs_set_i32(h_b, "temp_alarm", 26);这两个temp_alarm不会互相覆盖,因为 namespace 不同。命名空间的要点是全局统一,别一会儿叫app,一会儿叫appA,不然还是串。
但如果两个小应用是独立固件镜像,甚至可能是不同团队开发的,我建议直接给它们拆独立 NVS 分区。原因是 namespace 约束要靠人记,独立分区是物理级的,代码里拿到的分区句柄不同,天然隔离。用nvs_flash_init_partition初始化的例子:
ESP_ERROR_CHECK(nvs_flash_init_partition("nvs_app_a")); nvs_handle_t h; ESP_ERROR_CHECK(nvs_open_from_partition("nvs_app_a", "config", NVS_READWRITE, &h));这个组合拳在实际项目里最稳。无论哪个应用,只要 label 传错,立刻拿不到句柄,不会出现“能用但用得是别人家数据”的糊涂账。
3.2 文件系统:独立分区比“目录约定”更可靠
很多人喜欢共用一个文件系统分区,然后约定“App A 只读写/app_a/,App B 只读写/app_b/”。这在人数少、规则真的时候没问题,但文件系统本身没有权限概念,任何代码只要知道路径就能越界。等项目的 C 应用也塞进来,没人记得清所有约定,文件碰撞是迟早的事。
更可靠的做法是在分区表里给每个应用一个独立文件系统分区,然后分别挂载:
esp_vfs_littlefs_conf_t conf_a = { .partition_label = "fs_app_a", .base_path = "/app_a", .format_if_mount_failed = true, }; esp_vfs_littlefs_register(&conf_a); esp_vfs_littlefs_conf_t conf_b = { .partition_label = "fs_app_b", .base_path = "/app_b", .format_if_mount_failed = true, }; esp_vfs_littlefs_register(&conf_b);这样 App A 看不到/app_b底下的文件,App B 也拿不到/app_a的内容,路径撞车的问题从根上消失。顺便说一句,新项目我推荐 LittleFS 而不是 SPIFFS,目录支持好一点,掉电恢复也稳一些。分区表里的 subtype 可以写成0x81,具体名称以你手里 IDF 版本为准。
3.3 自定义裸数据分区:魔数、长度和 CRC 是最后的自救手段
有些场景不适合 NVS 和文件系统,比如要高频、大块地记录传感器日志,很多同学就自己在 Flash 里开一块地裸写。裸写不是不行,但一定要做“自描述数据”。所谓自描述,就是每一条记录或每一块数据前面都要放头信息,至少包含魔数、长度、CRC 和拥有者 ID。
我用一个简化结构体给你感觉:
typedef struct { uint32_t magic; // 固定值,比如 0x4A415041 "JAPA" uint32_t owner; // 0xA0000001 表示 App A,0xA0000002 表示 App B uint32_t version; uint32_t data_len; uint32_t crc32; uint8_t data[]; // 真正的内容 } flash_record_t;读取时,先读头部,校验magic,校验owner,再按data_len读数据,最后用 CRC32 校验。只要任何一步不对,就当成无效数据丢弃,不往业务层传。这样即使两个应用不小心写到了同一个区域,至少你的逻辑层能识别出“这数据不是我的”,而不是把别人的温度值当成湿度去控制空调。
给你一个写裸分区的参考片段:
const esp_partition_t *part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_UNDEFINED, "raw_app_a"); uint8_t *buf = malloc(sizeof(flash_record_t) + data_len); flash_record_t *rec = (flash_record_t *)buf; rec->magic = 0x4A415041; rec->owner = 0xA0000001; rec->data_len = data_len; rec->crc32 = esp_crc32_le(UINT32_MAX, data, data_len); esp_partition_erase_range(part, 0, part->size); esp_partition_write(part, 0, buf, sizeof(*rec) + data_len);注意:裸分区写之前一定要esp_partition_erase_range,并且擦除范围尽量按整个分区来,不要擦一半留一半,否则旧数据残留很容易造成逻辑混乱。另外,频繁写同一个扇区会磨损 Flash,小数据量的配置还是交给 NVS 或 LittleFS,它们内部有磨损均衡。
3.4 跨应用通信:不要通过“读对方存储区”来拿数据
最后一条是很多人忽略的:多个小应用之间需要交换数据,不要靠直接读对方分区,而是走运行时通信。
比如配网模块拿到了 Wi-Fi 密码,另一个应用需要这个密码去连接服务器。最差的做法是配网模块把密码写进某个文件,另一个应用去读那个文件。一旦路径、格式、命名有一处没对好,“串门”就来了。更稳的做法是:
- 用 FreeRTOS 消息队列
xQueueSend/xQueueReceive,谁要数据谁申请队列; - 用
esp_event事件循环广播“配置已更新”,需要的人自己来取; - 如果数据必须持久化,那就写入一个约定好的共享 NVS 分区,但只有配网模块有写权限,其他模块只能通过接口来请求。
我这里说的“写权限”不是嵌入式 OS 的强权限,而是代码层面的纪律。EPS32 的 ESP-IDF 到目前为止没有给用户提供类似“进程 A 不能读文件 B”的强制权限体系,所以应用层的隔离更多靠分区、命名和团队规范一起兜底。
4. Flash 加密与 Secure Boot:隔离到“物理级”也算一种防线
4.1 逻辑隔离挡不住“上编程器直接读”
分区表和命名空间解决的是“正常运行时别串门”,但如果有物理读出 Flash 的操作,比如用烧录器把整片 Flash dump 出来,那所有业务数据就等于裸奔。尤其是多应用共用一个 Flash 时,A 应用的密钥、B 应用的用户隐私,全部在一个文件里,一锅端。
Flash 加密就是把“物理读”这件事堵住。ESP32 的 Flash 加密用硬件对 Flash 内容做 AES 加密,CPU 运行到对应地址时自动解密,应用层无感。但对外的物理 Flash 引脚上读到的是密文,没有密钥读不出真实数据。
4.2 开启 Flash 加密前的几个关键选择和常见坑
如果你想在产线开启 Flash 加密,务必先在开发板上走通全流程,因为发布模式一旦烧入 eFuse,是不可逆的。要在菜单中打开 “Enable flash encryption on boot”,开发阶段选 Development 模式,量产选 Release 模式。
有几个坑我替你们提前踩了:
- 开启加密后,如果改了分区表但没把整颗 Flash 擦干净,旧分区的加密数据会出现在新分区偏移上,可能被解析成乱码。每次改分区表,必须全擦 Flash 再重新烧录。
- 数据分区不一定默认加密。分区表 CSV 里给需要保护的数据分区加
encrypted标志,比如前面示例里nvs_app_a那行后面的encrypted。不写这个标志,私密信息可能仍然是明文存储。 - OTA 升级时,新固件镜像也要按加密格式处理。IDF 的构建系统通常会自动识别,但如果你是手动用
esptool.py write_flash烧写,不要拿未加密的 bin 直接灌进去。
4.3 Secure Boot v2 防的是“李鬼”固件
Flash 加密解决的是“被偷看”,Secure Boot 解决的是“被篡改”。Secure Boot v2 会在 Bootloader 里内置公钥哈希,每次启动都校验固件签名。如果有人把 OTA 分区换成一个恶意小应用,签名对不上,系统拒绝启动。
这对数据隔离也有间接帮助:防止有人在 A 应用里植入后门,然后通过合法权限把 B 应用的数据读走。虽然权限模型的根本问题没变,但至少把从外部注入恶意固件的路堵住了。
4.4 想给每个小应用单独设置密钥?目前不太现实
有朋友问:“既然要做隔离,能不能让 A 应用和 B 应用用两把不同的 Flash 加密密钥?”在 ESP32 系列上基本不现实,Flash 加密密钥体系是全盘一把 eFuse 密钥,系统启动后统一加解密。要做到应用级强隔离,得靠上层设计,比如每个应用使用不同的业务层加密密钥,把敏感数据再做一层应用层加密。
这也是我一直强调“分区 + 命名空间 + 自描述格式”才是第一道防线的原因。Flash 加密和安全启动是第二道,它们补的是物理和信任链短板,但救不了逻辑混乱。
5. 三起真实“串门”事故的排查链路与修复过程
5.1 案例一:A 模块写的配置,被 B 模块当成自己的配置读走
症状是设备运行一段时间后,LED 灯效模式会自动变成传感器告警阈值。最开始怀疑是传感器数据异常,但读了日志发现,两个模块用的 NVS key 都叫config_value,namespace 也都叫storage。A 模块往storage/config_value写了80,B 模块也往同一个位置写了80,然后互相读,两边的行为全乱。
排查链路是:先打开日志看谁的nvs_get返回了错误码,再查源码里nvs_open的 namespace 和 key。结果发现两个模块的文件都是从同一个模板复制来的,模板里写死了一样的 namespace。修复也很简单,App A 改用nvs_app_a分区加app_a_confignamespace,App B 改用nvs_app_b分区加app_b_confignamespace,一劳永逸。
这个案例提醒我:复制粘贴代码时,存储句柄和 namespace 属于“需要立即重命名”的东西,不是注释里改改就行。
5.2 案例二:Arduino 和 ESP-IDF 来回烧录,Wi-Fi 参数乱套
朋友的项目很典型:开发时一半人用 Arduino IDE,一半人用 ESP-IDF,大家共用一块板子。结果 Arduino 烧录后,再用 IDF 烧回来,Wi-Fi 密码总是被清空,有时连启动都不正常。
排查时我第一反应就是分区表不一致。用 esptool 读一下 Flash 里的分区表:
esptool.py --port /dev/ttyUSB0 read_flash 0x8000 0x1000 partition_table.bin esptool.py partition_table partition_table.bin把读出来的分区表和 Arduino、IDF 两边的配置一对比,发现 app 分区的 offset 对不上。Arduino 的默认分区方案里 app 从 0x10000 开始,而 IDF 项目自定义成了另一个地址,导致 NVS 和 phy_init 的实际位置漂移,数据互相踩。修复方式很简单:两边统一使用同一份partitions.csv,Arduino 这边在 Tools -> Partition Scheme 里选择 “Custom”,文件内容完全复用 IDF 工程里那份。之后交叉烧录再也没出过问题。
5.3 案例三:OTA 升级后冷启动进不了主程序,重新烧写又好了
这个案例的“串门”不是数据,而是固件。设备上有 A 固件运行在 factory,OTA 升级后切到 ota_0。问题是升级后第一次启动正常,第二次冷启动就卡在启动日志里,反复重启,只有重新烧录才能恢复。
逐层排查后发现,OTA 升级固件里带了新分区表,新分区表把 otadata 分区的 size 改小了,又增加了两个数据分区。第一次启动时,Bootloader 从 ota_0 启动成功,但把 otadata 写入时越过了新分区边界,把后面的 phy_init 分区擦掉了一部分。第二次冷启动,Bootloader 读 otadata 时发现了数据异常,干脆不引导了。
这里的教训是:OTA 固件尽量不要变更分区表布局,尤其是 otadata、nvs、phy_init 这几个系统分区的 offset 和 size。如果需要扩容业务数据分区,建议把空间预留好,而不是升级时临时改。真要改,也得先设计兼容迁移流程,而不是让新分区表直接覆盖旧数据。
这三起事故放在一起看,你会发现所有“串门”最后都能回溯到同一个根因:要么地址被写死,要么命名被复用,要么分区布局变更没做全盘评估。机制上的隔离能挡掉大部分问题,但最终还是要靠开发时对分区表和命名空间的严格自律。
最后说一个我自己保留的习惯:每次改完partitions.csv,第一件事不是编译,而是全擦 Flash 再烧一版空固件,然后启动时打印一遍各分区 offset 和 size,确认和设计一致。生产固件里也尽量不出现绝对 Flash 地址,所有存储访问都走esp_partition_*。这两个习惯坚持下来,多应用共用 ESP32 Flash 时的“串门”问题会少掉八成。希望这篇能把你在类似项目里踩的坑也填平。