尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

ESP32多应用Flash隔离实战:分区表、NVS与安全启动防数据串门

发布时间:2026/9/30 1:39:37

资讯中心
01
ARTICLE

ESP32多应用Flash隔离实战:分区表、NVS与安全启动防数据串门

ESP32多应用Flash隔离实战:分区表、NVS与安全启动防数据串门
前阵子帮人救了一台 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引导程序不在分区表里启动和加载应用系统层nvsdata/nvsWi-Fi 校准、系统配置、用户参数全局配置区otadatadata/ota记录哪个 OTA 分区是当前启动分区OTA 状态phy_initdata/phy射频校准数据系统私有factoryapp/factory出厂应用A 固件应用镜像ota_0app/ota_0OTA 升级后的 B 固件入口应用镜像ota_1app/ota_1OTA 升级后的 C 固件入口应用镜像fs_app_adata/spiffs 或 data/littlefsApp A 的资源文件、日志业务数据fs_app_bdata/spiffs 或 data/littlefsApp 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 分app0x00和data0x01两大类自定义业务数据通常用 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 A0xA0000002 表示 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_valuenamespace 也都叫storage。A 模块往storage/config_value写了80B 模块也往同一个位置写了80然后互相读两边的行为全乱。排查链路是先打开日志看谁的nvs_get返回了错误码再查源码里nvs_open的 namespace 和 key。结果发现两个模块的文件都是从同一个模板复制来的模板里写死了一样的 namespace。修复也很简单App A 改用nvs_app_a分区加app_a_confignamespaceApp 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.csvArduino 这边在 Tools - Partition Scheme 里选择 “Custom”文件内容完全复用 IDF 工程里那份。之后交叉烧录再也没出过问题。5.3 案例三OTA 升级后冷启动进不了主程序重新烧写又好了这个案例的“串门”不是数据而是固件。设备上有 A 固件运行在 factoryOTA 升级后切到 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 时的“串门”问题会少掉八成。希望这篇能把你在类似项目里踩的坑也填平。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。