ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ESP32多应用共用Flash,分区表与数据隔离实操指南

ESP32多应用共用Flash,分区表与数据隔离实操指南 做嵌入式最烦的一件事就是明明在帮客户做方案最后却被一句“几个小应用共用一块 ESP32 Flash数据会不会串门”问住。这个问题看起来基础实际上踩过坑的人都知道它牵扯到分区表、NVS 命名空间、OTA 升级方式甚至应用之间的启动切换逻辑。你如果只是把两个应用随便塞进 Flash跑起来可能没问题但只要有一次升级、一次异常重启、一次日志写满数据就会莫名其妙对不上那种排查过程真的让人头秃。我这两年用 ESP32 做了不少多应用共存的设备从刚开始的“反正 Flash 够大随便放”到后来一上来先画分区表中间交的学费不少。这篇就把我实际沉淀下来的方案、踩坑经验、以及可复用的分区模板写出来。适合三类人看一是刚把 ESP32 产品原型做出来、准备扩展功能的开发者二是要用一块 Flash 同时跑多套逻辑、还要各自保住数据的工程师三是正在做OTA升级但这块还没理清楚怕升级把数据带飞的新手。我会从最底层的分区表讲起讲到 NVS 和文件系统最后给一份完整的分区规划案例希望能让你少踩几个坑。1. 先把问题说清楚什么场景下会“共用一块 Flash”1.1 不是只有“多固件”才叫共用很多人听到“多个小应用共用 Flash”第一反应是“那我直接给每个应用烧一个固件不就好了”。但实际碰到的场景远比这个复杂。最常见的是单固件里有多个业务模块比如一个传感器节点里既跑温湿度采集又跑配网 Web 服务还跑 OTA 升级逻辑它们虽然编译在一个固件里却各自要用 Flash 保存数据。另一种场景才更像标题描述的那样一套板子上通过分区规划放了几个独立的固件比如一个负责数据采集一个负责本地策略甚至还有出厂自检程序通过跳转或 OTA 机制切换运行。无论是哪种情况它们共用的都是同一块物理 SPI Flash。平时大家只把它当成“存代码的地方”但代码、参数、日志、OTA 临时数据、出厂校准数据全都在里头。一旦多个模块或固件使用同一套存储 API、同一个 NVS 命名空间、同一个文件系统根目录就会出现“串门”现象。换句话说数据串门不只是地址越界或者指针错误这种低级 bug更多时候是逻辑设计上没把存储边界划清楚导致 A 应用把 B 应用的配置覆盖了或者 A 的日志把 B 的数据区写穿了。1.2 “数据串门”到底串的是什么我在实际调试中把“串门”归结成三类方便对症下药。第一类是逻辑串门两个应用不约而同使用了同一个 NVS key比如都用sensor_cal保存校准值一个写进去另一个读出来就是错的。这类问题最隐蔽因为不报错但数据就是不对。第二类是物理串门分区表设计不合理两个分区有重叠或者一个应用通过 OTA 写入另一个应用所在的 flash 区间直接覆盖了别人的代码或数据。这类问题通常在升级和异常重启后才暴露排查时要看 flash 二进制比较痛苦。第三类是运行态串门文件系统缓存没 flush日志缓冲区频繁写入导致扇区提前老化某个分区磨损严重后出现整块数据读出来都是0xFF。所以“保证数据不串门”真正要做的事是在物理层面划好分区在逻辑层面定好命名空间和 key 规则在运行层面控制好写入频率与校验机制。这三件事缺一不可顺序也不能乱。2. 分区表隔离的第一道物理边界2.1 分区表是 Map不是口号ESP32 的 Flash 里不是只有代码bootloader、分区表、应用固件、NVS 数据、日志文件系统都要塞进去。分区表就是一张地图告诉 bootloader、应用代码以及烧录工具每一块区域叫什么名字、是什么类型、从哪里开始、有多长。你用 ESP-IDF 编译时看到partitions.csv就是这张地图的文本格式编译后它被烧写到 Flash 的固定偏移地址默认是0x8000附近。很多新手以为分区表只是给烧录工具看的这是误解。代码运行起来后esp_partition_find_first、esp_ota_get_running_partition这些 API 全是读分区表来定位存储区域的。你如果用一个没有在分区表里定义的地址去读 Flash等于在没有正常道路的地方开车轻则读到别的分区残留数据重则写入时破坏其他分区。所以分区表是 Flash 数据隔离的第一道物理边界设计得越清楚后续应用层隔离压力越小。2.2 手写一份可落地的 partition.csvESP32 烧录工具支持自定义分区表文件格式很简单每一行是名称,类型,子类型,偏移量,大小,标志。偏移量可以留空让工具自动排但我个人习惯显式写出来方便核对上下边界。类型分为app和dataapp 类型用于存放可执行固件data 类型用于存数据。记住一点分区表里分区大小必须是 4KB 的整数倍偏移量也要以 4KB 对齐因为 Flash 擦除最小单位就是 4KB sector。下面是我经常用的一份 4MB Flash 分区模板适合一个产品里跑两个独立 app 固件同时还要给它们各自保存数据外加一块公共日志区# 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, 0x12000, 0x180000, app_a, app, ota_0, 0x192000, 0x180000, app_b, app, ota_1, 0x312000, 0x180000, nvs_a, data, nvs, 0x492000, 0x10000, nvs_b, data, nvs, 0x4A2000, 0x10000, logs, data, spiffs, 0x4B2000, 0x14E000,看到这里你可能觉得奇怪为什么 app 分区用ota_0和ota_1而不是随便起个名字。这是有意为之ESP32 的标准 bootloader 只认factory和ota_x这些预定义子类型作为可启动分区。你如果自定义一个app_a_subtypebootloader 根本不会管它甚至可能导致无法启动。用ota_0和ota_1这类标准子类型既能被 bootloader 识别也能用 OTA 接口来切换启动目标多应用切换就很自然了。2.3 设计分区时的三条铁律第一数据区和代码区必须物理分离。你不能把应用放在 0x12000 到 0x200000然后数据区从 0x1F0000 开始这样代码区尾部就重叠了。可能暂时跑得起来但只要应用体积增大、烧录时写满边界数据区就被覆盖。第二NVS 分区尽量给每个应用单独划一块。因为 NVS 是一个带版本管理和磨损均衡的 KV 存储共享一个 NVS 分区并不是不行但它的命名空间机制只能做到逻辑隔离一旦某个应用的操作异常导致分区元数据损坏其他应用也跟着遭殃。第三OTA 升级时不会自动保护你的数据你要在代码里确认升级目标分区是不是 app 类型。用esp_ota_begin之前最好调用esp_partition_find_first校验一下目标分区不要直接拿了一个 data 分区就当 OTA 分区用。3. 多应用隔离的三种常用架构3.1 单固件多模块逻辑命名空间隔离如果几个小应用本质上是一个固件里的不同任务最简单的做法就是“一个固件、多个模块”物理上只占用一个 app 分区数据隔离靠 NVS 命名空间和文件系统子目录实现。优点是开发简单、调试方便所有代码都在同一个程序里内存和 Flash 资源容易共享缺点是隔离强度有限一个模块写一个全局数组越界可能踩到另一个模块的数据而且任何模块的崩溃都可能牵连整个固件。这个架构适合模块之间关系紧密、版本同步发布的场景。我在做智能家居网关时配网服务、设备发现、本地规则引擎这三个模块就放在同一个固件里通过不同的 NVS 命名空间保存参数。这里有个很容易踩的坑NVS 命名空间名称限制 15 个字符key 也限制 15 个字符而且 key 不能包含一些特殊字符。你如果起名太随缘比如a、b一旦命名空间相同key 还是会冲突。我的习惯是命名空间用app_加模块标识key 全部带模块缩写前缀比如app_wifi、app_rule等于做了两重保险。3.2 多固件独立分区OTA 切换实现硬隔离如果你的“多个小应用”是几套完全独立的固件希望它们互不干扰甚至能独立升级那就要走多固件独立分区路线。每个固件占用一个独立的 app 分区运行时通过 OTA API 把下一次启动的目标设置成另一个分区重启后 bootloader 就会跳到对应固件。这在物理上做到了代码区不重叠、数据区也可以各自独立。我建议把其中一个分区留作factory把它当成不可覆盖的“救砖”入口。比如设备出厂时烧录一个最小的诊断固件用户升级到 A 应用或 B 应用后如果哪天 A/B 都坏了bootloader 可以根据条件回退到 factory。不过要注意一点如果你用esp_ota_set_boot_partition把启动目标从 A 切到 B一定要先确保 B 分区里已经有完整可用的固件否则切过去之后 bootloader 校验失败可能陷入启动失败循环。最稳妥的做法是先在 B 分区写完固件并校验成功再调用设置启动分区的接口。3.3 数据区拆分谁的数据就是谁的独立 app 分区解决了代码隔离数据隔离还要再单独设计。我之前做过一个设备A 固件负责传感器采集B 固件负责联网上报两个固件各自运行都要保存自己的配置和校准数据。当时我偷懒让两个固件都用系统默认的nvs分区结果 A 固件在某个版本的代码里写入了一个 key 叫stateB 固件旧版本也用过这个 key两边配置互相覆盖设备行为变得非常诡异。后来我把分区表改成nvs_a和nvs_b两块独立分区每个固件只访问自己的分区问题才彻底消失。这种物理拆分看起来很笨但非常有效。Flash 分区的粒度是 4KB一块 64KB 的 NVS 分区足够存放几千个 key-value成本很低。如果你觉得每个应用一个 NVS 分区太碎至少也要做到“一个数据域一个分区”把强相关的数据放一起和弱相关的数据隔开。这个原则和代码里模块化是一个逻辑高内聚、低耦合。3.4 三种方案怎么选我做了个对比表格方便你快速决策方案隔离强度开发复杂度适用场景单固件多模块逻辑隔离为主低模块关系紧密、同步发布多固件独立分区物理隔离中独立功能、独立升级数据分区拆分物理隔离低任何一个多应用方案都建议配合在实际产品上我经常是“多固件独立分区 数据分区拆分”一起上。代码上互相独立数据上各自占坑升级互不影响排查问题也容易定位到具体模块。唯一要付出的代价是分区表规划和启动切换逻辑要花心思但这些工作一次做好后面能省很多事。4. NVS 和文件系统串门高发区4.1 NVS 命名空间不是保险箱NVSNon-Volatile Storage是 ESP-IDF 提供的 KV 存储组件很多人以为开了不同的命名空间就万事大吉其实不然。NVS 的命名空间更像是一个目录分隔符它的作用是把 key 归到不同组里避免 key 字符串直接冲突但它不提供越界保护。你在代码里如果调用nvs_open(AppA, NVS_READWRITE, handle)然后拿到 handle 后准备写入 1KB 的数据而某个命名空间下已经存了大量数据NVS 分区空间不足写入就会失败严重的还会出现分区元数据问题。所以我的观点是命名空间是逻辑隔离的第一道关但你不能只靠它。关键数据要有版本号每次读取后先核对版本再使用写入nvs_set_*之后一定要调nvs_commit否则数据只停留在内存缓存里断电就丢。如果你发现 NVS 写入偶尔成功偶尔失败优先检查分区大小和剩余空间而不是怀疑 Flash 坏了。我踩过的坑是给日志计数这种高频变量设置了同步写入三分钟就能把 NVS 分区写废后来改成批次写入才解决。4.2 给每个应用单独划一个 NVS 分区如果你想做到“数据不串门”我强烈建议在分区表里为每个独立应用划一个专属 NVS 分区。分区表里 NVS 类型有专门的子类型nvs应用代码通过分区名称找到它。示例里nvs_a和nvs_b就是两块独立的 NVS 区域A 固件访问nvs_aB 固件访问nvs_b从物理上杜绝了交叉写入的可能。代码里查找自定义 NVS 分区需要两步。第一步调用esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, nvs_a)拿到esp_partition_t*第二步调用nvs_flash_init_partition(nvs_a)初始化。这里有一个细节默认 NVS 分区可以直接用nvs_flash_init()初始化但自定义分区必须用nvs_flash_init_partition()很多初学者在这里写错结果nvs_open一直返回错误。初始化之后nvs_open的namespace参数仍然要保持每个模块独立相当于“物理分区隔离 逻辑命名空间隔离”双保险。4.3 共享文件系统的目录边界除了 NVS很多设备还会用 SPIFFS 或 LittleFS 存放文件比如 Web 页面、配置文件、日志文本。多个应用如果共用同一个文件系统分区问题比 NVS 更棘手因为文件系统本身就有一个全局的根目录你不用路径区分的话文件就会互相覆盖。常见低级错误是 A 应用写一份config.jsonB 应用也写一份config.json文件系统分区只有一个后者就会覆盖前者。解决思路有三种。第一种是在分区表里给每个应用单独分配文件系统分区这是最省心的适合文件量大的场景。第二种是共用分区但强制使用子目录约定比如 A 应用只允许写/app_a/B 应用只允许写/app_b/文件名以外层目录做隔离代码里插入一层路径校验防止误用根目录。第三种是做成只读共享资源区加可写数据区分离把公共的 Web 页面放在一个只读分区应用私有数据放在各自分区。复杂度依次递增但隔离效果也是递增的。我自己的偏好是如果 Flash 空间够能用独立分区就不用共享目录因为你无法控制团队里每个人都严格遵守目录约定。4.4 日志写入对 Flash 磨损比你想象的大多应用共用一个 Flash 时日志系统最容易变成“隐形杀手”。单独看几条日志数据量不大但如果你每秒钟写一条日志到 SPIFFS按一条 64 字节算一天下来就是 5MB 左右的写入量而普通 SPI Flash 的擦写寿命大都在 1 万到 10 万次之间按 4KB 扇区擦除的粒度计算一个扇区很快会被写废。更可怕的是日志文件系统的磨损均衡算法如果覆盖不到整个分区某些扇区会被反复擦写最后整块数据区提前退休。我建议日志不要直接写到文件系统至少不要高频同步写。先在内存里做环形缓冲区攒满 4KB 再落盘或者把日志频率降到一个小时一批。如果确实需要高频日志那就把日志单独放到一个分区并选用带磨损均衡的文件系统。LittleFS 在这方面比 SPIFFS 做得好一些。总之日志写入策略要和 Flash 寿命一起考虑不能只看功能能不能跑通。5. OTA 与分区表约束升级时最容易把邻居家拆了5.1 OTA 怎么读懂分区表OTA 升级本质上就是“往某个 app 分区写入新固件然后设置下次启动指向它”。ESP32 的 OTA API 运行时会读取分区表找到目标分区然后流式写入。你需要显式确定目标分区不能用随机偏移。常用做法是esp_ota_get_next_update_partition(NULL)自动选择一个与当前运行分区不同的 OTA 分区或者在代码里通过名称找到指定分区再写。这里最需要注意的是 OTA 目标分区的大小必须能容纳整个固件镜像。如果固件编译出来 1.2MB而你给 OTA 分区只留了 1MB写入会在中途失败。分区表里的Size字段不只是一个建议值它是一个硬边界超过边界的写入会被 flash 驱动拦截或直接破坏相邻分区。我认识的工程师里至少有两个人因为压缩了 app 分区大小导致新功能编译后 OTA 失败排查半天才发现是分区不够装。5.2 升级到底会不会清掉数据区这个问题的答案是“取决于你写的代码”。OTA 本身只会写目标 app 分区不会主动去擦除数据分区。但以下两种行为会让数据区遭殃第一种是你在 OTA 流程里偷懒直接对指定地址执行一整块擦除结果把相邻的 NVS 或文件系统分区也擦了第二种是应用为了“干净升级”主动调用esp_partition_erase_range擦除整个 Flash这种一刀切做法在单应用设备上可能没问题但在多应用共存的设备上就是灾难。我的建议是永远使用官方 OTA 接口不要直接操作底层 flash 地址。如果你需要在升级后清掉某个数据区比如恢复出厂设置那也要明确指定你要擦除的分区对象用分区名称从分区表查出来再擦而不是凭记忆写偏移量。这个习惯能避免很多“升级后所有配置都没了”“升级后另一个应用起不来了”之类的疑难杂症。5.3 多应用切换下的回滚边界多应用使用独立分区后切换启动目标相当于一次“人为 OTA 切换”。你从 A 切到 B只是改了 otadata 里的启动索引A 分区里的固件不会被删除。这既是好事也是坏事好事是可以随时切回 A坏事是两个应用之间如果状态不同步数据版本可能对不上。比如 A 固件某次运行写入了 v2 格式的配置B 固件还停留在 v1 逻辑切回 B 后可能读不了配置这时你就要设计好兼容策略。我在实际产品里会在切换前做一个简单的数据握手A 要切到 B 时先把公共状态标记为“切换中”B 启动后读取该标记知道这是一个受控切换可以做数据迁移如果 B 连续启动失败bootloader 会根据策略回退到 A。这些逻辑不算复杂但要提前设计不能等到切不过去再临时加。回滚边界本质上是状态管理问题flash 分区只是给你提供了物理操作空间逻辑上的过渡必须有明确规则。6. 真实案例三合一传感器节点的分区规划6.1 需求与 Flash 预算我这里用一个很常见的案例来讲透一个三合一传感器节点功能包括温湿度采集、配网服务、本地规则引擎还希望能支持 OTA 升级同时保存出厂校准数据、用户配置和运行日志。硬件用的是 4MB Flash 的 ESP32 模组。一开始我打算默认分区表直接跑后来发现三个模块的数据互相打架日志也没地方放才下定决心重新规划分区。需求拆解需要两个可启动固件一个是出厂自检固件一个是正式运行固件正式运行固件内部有三个模块但数据要按模块隔离还需要一块日志区以及至少两块 NVS 分区保存校准值和用户配置。把 4MB 空间做预算app 固件按 1.5MB 估算NVS 各 64KB日志区 320KB 左右剩余空间留给分区表、ota 数据、phy init 数据。这个预算只多不少实际编译后固件一般不到 800KB余量留给自己心里踏实。6.2 分区表这样写基于上面的预算分区表我写成下面这样强调可读性和可维护性# 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, 0x12000, 0x160000, ota_0, app, ota_0, 0x172000, 0x160000, nvs_cal, data, nvs, 0x2D2000, 0x10000, nvs_user, data, nvs, 0x2E2000, 0x10000, logs, data, littlefs, 0x2F2000, 0x14E000,这里我没有划ota_1因为产品只跑一个正式 app再加一个出厂恢复分区已经完全够用。如果我有两个独立业务固件要互相切换再增加ota_1并把factory作为一个很小的诊断入口。要点是nvs_cal保存传感器校准参数nvs_user保存用户配置两个数据分区互相独立logs使用 LittleFS 而不是 SPIFFS因为它带更好的磨损均衡。校准数据和用户配置分开是因为它们的写入频率差异很大校准数据几乎只写一次用户配置可能频繁修改。分开之后即使用户配置分区磨损严重也不会连累校准数据。6.3 代码里怎么固定分区分区表定了代码里就要“按名取地”。拿 NVS 举例读取校准值的代码不要直接写死地址而是先查分区表const esp_partition_t* cal_part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, nvs_cal ); if (cal_part) { nvs_flash_init_partition(nvs_cal); }文件系统的挂载也一样我叫它logs代码里就只挂载logs分区不碰其他分区。另一个容易出现的问题是 flash 擦除操作很多示例代码会教你spi_flash_erase_sector直接擦但在多应用环境下我强烈不建议这么做。因为 Flash 分区表是唯一的你手动擦除的 sector 不一定落在哪个分区里。我一般通过esp_partition_t指针来做擦除因为它已经为你限制好了可操作范围自带边界保护。6.4 实测验证有没有“串门”分区规划完我会做三个验证动作确认数据真的不会串门。第一个是最简单的编译两版固件A 版应用往nvs_cal写入一个有特征的数值B 版应用启动后读取nvs_cal能原样读出来同时往nvs_user写另一个值读取nvs_cal时不受影响。第二个是压力测试循环 1000 次读写用户配置监视nvs_user分区剩余空间变化确认没有数据溢出到相邻日志区。第三个是升级验证用新固件 OTA 升级ota_0分区升级完成后检查nvs_cal和logs里的历史数据仍然可读。这三个动作写起来不复杂但能把“串门”问题暴露得比较充分。尤其要注意验证时别只测当前固件自己写自己读还要跨固件验证因为串门往往发生在“别人写的你来读”的场景。如果你能做到读数据前先校验版本写数据前先确认分区归属那基本可以放心地把这个分区规划交给后续开发了。7. 踩坑记录与排查思路7.1 改完分区表旧固件打不开了这是我遇到最多的问题。不少人图方便直接用新的分区表覆盖烧录到一块已经有旧应用的 Flash 上结果重启后 bootloader 找不到有效 app串口打印invalid partition table或直接循环重启。原因是旧固件的代码区偏移量和大小是基于旧分区表编译的新分区表改变了布局bootloader 去新偏移找固件找不到有效镜像头。解决思路很简单改分区表之后不要再保留旧分区里的固件直接擦除整块 Flash重新烧 bootloader、分区表和新固件。如果你不想擦整块至少要把旧 app 分区对应区域擦掉再烧新固件。这里提醒一点擦除 Flash 是危险操作执行前务必先通过分区表确认目标范围不要把 NVS 数据区也顺手擦没了。我在开发板上用esptool.py erase_flash擦过太多次每次都默念“这次之后重新烧”。不过生产线上千万别轻易整片擦除很容易把出厂校准数据抹掉。7.2 读出来的数据不对先查缓存层当你的应用读出某个配置值发现和上次写入的不一样第一反应别急着怪 Flash 坏掉。先想想有没有缓存层在搞鬼。ESP-IDF 的 flash 驱动默认有读写缓存再加上 NVS 组件自己的缓存、文件系统驱动的缓存数据在多个层级之间流转。如果你用两个不同模块同时访问同一份数据一个模块改了底层数据另一个模块还在读缓存里的旧值就会出现“数据没串门但读出来是串门”的假象。我一般用三步排查先调用nvs_flash_init_partition重新初始化目标分区看读出的值是否变化再直接调用esp_partition_read_raw绕过文件系统和 NVS读取原始字节比对最后再确认代码里有没有另一个模块悄悄改了同一个命名空间。大多数情况下都能定位到是缓存或多写者问题而不是物理 Flash 出错。7.3 擦除 Flash 之后分区表也丢了的误会有朋友问我为什么erase_flash后设备完全无法启动是不是 Flash 坏了。其实不是是擦除后连分区表也一起消失了。ESP32 上电第一步是运行 ROM 里的 bootloader它要去 Flash 固定偏移读取分区表你整片擦除后那里全是0xFFbootloader 当然什么都找不到。这时候你必须先用烧录工具重新写入 bootloader、分区表和 app设备才能再次正常启动。这个“误会”之所以常发生是因为很多人以为分区表存于 ROM 或芯片内部但 ESP32 的 BootROM 只是第一级引导分区表和应用都在外部 Flash 里。设计分区表时也别忘了给 bootloader 和分区表本身留出固定区域。默认布局是 bootloader 在0x1000分区表在0x8000附近自定义分区表时尽量沿用这个基础布局不要为了省空间把分区表挪到奇怪的位置那样只会给自己增加不必要的麻烦。7.4 最后补两句心里话说实话ESP32 的 Flash 分区设计花一个下午认真规划比以后花一周排查数据错乱要划算得多。我现在拿到一个新项目第一件事不是写功能代码而是根据业务模块、升级策略、数据写入频率把分区表画出来再让团队按这个表去开发。你不用把每个分区弄得特别大够用、清晰、有冗余就行。如果你现在手里已经有设备在跑而且遇到了类似的数据串门问题我的建议是先别急着改代码。先把产品拆成“哪些数据是哪个业务写的”、“哪个业务可以启动”、“升级要不要切换固件”然后重新设计分区表一步一步迁移验证。这个内容后续还可以扩展出很多玩法比如用安全启动保护分区、在多个 app 之间做安全的数据交换但前提始终是物理边界清楚、逻辑边界明确、写入过程可控。做好这三点数据就真的不会串门了。
返回列表