ARTICLE DETAIL

资讯详情

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

ESP32多应用共用Flash的分区表规划与数据隔离实战

ESP32多应用共用Flash的分区表规划与数据隔离实战 1. 先搞清楚一块 Flash 上到底能放什么1.1 物理层为什么 Flash 必须“分区后用”很多人拿到 ESP32 的第一个项目就是往 Flash 里写点配置、存点日志感觉这东西就是一个大号的 U 盘。这种理解在“一个应用独占一颗芯片”的时候问题不大可一旦多个小应用共用一块 Flash麻烦立刻就来了。先说物理本质。ESP32 使用的 SPI Flash 芯片读可以按字节来但写不是。Flash 在写入之前目标区域必须先处于擦除状态而擦除的最小单位是 sector也就是 4KB有些芯片是 64KB 的 block。这意味着你不能像改文件一样只改那一个字节你得把整个 4KB 区域读出来、改掉其中某些字节、整块擦除、再整块写回去。只要有两个应用的数据落在同一个 4KB sector 里任何一个应用做一次数据更新都必须连带处理另一个应用的数据。用大白话说这就是“串门”的物理根源。我经常打一个比方Flash 就是一块大白板分成很多行写谁的数据就像往行里填字。你想改其中某个字不能拿橡皮擦掉一个字必须把整行擦掉重写。如果这行里有别人的东西你擦的时候就把别人一起擦没了。所以正确的思路是在上层先按“应用”把 Flash 的物理区域切好每个应用只在自己的区域里折腾物理上不相邻自然就不存在误擦的问题。这个“切区域”的动作就是 ESP32 的分区表机制。1.2 分区表ESP32 的“土地规划局”ESP32 在 Flash 的固定位置默认 0x8000存放一张分区表Partition Table它本质上是一个数组每条记录描述一个分区的名字、类型、子类型、偏移地址和大小。芯片上电后bootloader 就靠这张表知道去哪里找固件、去哪里找配置。默认分区表大致长这样名称类型子类型偏移大小nvsdatanvs0x90000x6000phy_initdataphy0xf0000x1000factoryappfactory0x100001Mspiffsdataspiffs0x1100000x300000这张表有两个特点值得注意。第一所有分区的大小、偏移都是 4KB 的整数倍因为擦除粒度就是 4KB。第二除了 factory其他分区都是开放的会编译代码的人都能读写。ESP32 并没有像 PC 那样的内存管理单元去限制某个 App 只能访问某个地址段也就是说任何一个代码里写了一句“往 0x200000 地址写数据”Flash 物理上就会照做分区表拦不住。这里就必须破除一个常见误区分区表不是安全机制它是规划工具。它解决的问题是“让不同功能区域不重叠”而不是“禁止代码访问别人的区域”。真正防止串门要靠分区表规划和应用层约定双管齐下。后面我会详细展开这句话的意思。1.3 “多应用共用 Flash”的两种常见形态“多个小应用”这个词在不同项目里指的东西完全不一样我做了这么多 ESP32 项目基本可以归成两类。第一种形态多个独立固件映像。一片 Flash 里放了两个以上可以独立启动的固件比如一个采集传感器数据的程序和一个跑 Web 配网的程序通过 bootloader 或运行时的跳转代码选择启动哪个。这种形态下每个固件本体必须放在不同的 app 分区里数据区也要分开否则 App A 升级的时候会把 App B 的代码区擦掉。第二种形态一个固件内多个功能模块。最典型的就是一个 ESP32 工程里同时做了传感器采集、蓝牙遥控、WiFi 配网、日志记录这几个“逻辑上独立”的小模块。它们其实是同一个 App但数据要分开存。比如配网参数放一个区域传感器日志放另一个区域运行时如果没有隔离一个模块的 bug 就会把另一个模块的数据搞坏。不管哪种形态结论是一样的先在分区表层面把物理空间切清楚再在应用层面通过命名空间、文件系统实例、自定义分区 API 把逻辑空间隔离起来。下面先讲清楚“串门”是怎么发生的你才知道为什么要这么绕。2. “串门”是怎么发生的三大典型事故2.1 事故一NVS 里的键名撞车ESP32 官方提供了一套非易失存储 NVSNon-Volatile Storage默认挂在 0x9000 偏移的 nvs 分区大小 24KB。很多教程告诉你往里面存 WiFi 密码、设备配置用起来确实方便。但问题在于默认情况下这个 nvs 分区是全局唯一的所有代码共用同一个键值存储空间。我做过一个实际项目一个固件里有两个模块模块 A 负责 WiFi 连接模块 B 负责 MQTT 连接。两个模块在 NVS 里存配置时都觉得“ssid”这个键名理所当然归自己用。于是模块 B 写入操作后模块 A 读到的 ssid 变成了 B 写进去的值WiFi 断开设备掉线。查了大半天最后发现是两个模块在同一个 NVS 空间里用了同一个 key。很多人不知道的是NVS 内部其实是有“命名空间namespace”这一层的每个键都是挂在某个命名空间下的。你如果调nvs_open(app_a, ...)和nvs_open(app_b, ...)各开各的命名空间就算两个应用都用ssid这个键名也不会互相覆盖。这正是 NVS 官方推荐的做法。我后面在应用层隔离那一节会给完整代码。2.2 事故二越界读写把邻居擦掉分区表规划好了不代表代码就一定会守规矩。最容易出事的是两种情况。第一种直接拿绝对地址操作 Flash。有些经验不太足的开发者会直接用底层 SPI Flash 指令往某个地址写数据比如0x200000然后第二天发现另一个应用的数据全没了。因为他根本不知道0x200000正好落在别人家的文件系统分区里。更危险的是Flash 擦除的最小单位是 4KB你哪怕只是写一个字节前面的“擦除整个 sector”动作也可能已经把旁边应用的数据抹平了。第二种调用esp_partition_write时长参数超界。这个 API 其实是安全设计的它知道当前分区的边界理论上不会跨区但难点在于你拿到的 offset 和 size 得自己算。如果代码里写的是esp_partition_write(partition, offset, data, len)而offset len一不小心超过了分区大小这个 API 会返回错误码可如果代码没检查返回值错误就被吞掉了数据写到哪去了没人知道。我见过最典型的越界事故是一个应用做日志轮转每次往文件系统追加日志文件系统满了之后代码没有检查错误继续往底层驱动写最后把同一个文件系统分区的超级块写坏了另一个共用该文件系统的应用读不出任何数据。说白了越界读写的根源大多是“没有边界意识 不检查返回值”不是 API 本身的问题。2.3 事故三OTA 把邻居家地址冲了OTAOver-The-Air升级是 ESP32 的重头戏但它也是数据串门的重灾区。默认的 OTA 机制是分区表里要预留otadata、ota_0、ota_1三个分区。升级时新固件被写入ota_0或ota_1轮流然后 bootloader 根据otadata里的标志位决定下次从哪个分区启动。这一切本来没有大问题但如果你的 Flash 里放了多个小应用每个应用都对应自己的 app 分区OTA 逻辑就会变得复杂。举个真实案例。我的一个项目在 Flash 里放了 A、B 两个固件分区A 负责业务逻辑B 负责引导设置。当时图省事把 B 的代码烧到了 factory 分区A 的代码烧到了 ota_0。然后有一天需要远程升级 AOTA 模块启动把新固件写到了 ota_1并在 otadata 里把启动目标指向 ota_1。结果重启后设备竟然跑起了 B 的备份逻辑因为 A 的启动入口在 make 配置里写错了。更惨的一种情况是有的开发者把ota_0当成普通 app 分区直接烧录了 A之后 OTA 升级时系统去擦除ota_1发现空间不够直接把ota_0也覆盖了两个应用一起报废。这类问题的本质是OTA 不是一个简单的“写入新固件”动作它涉及擦除、写入、校验、切换标志位、重启确认多个步骤任何一步踩到别人的分区都会引发连锁反应。3. 动手规划分区表一份可复用的空间计算法3.1 先算账固件、数据、日志各占多少规划分区表的第一步不是打开分区表文件而是先坐下来算账。你需要知道每一类东西大概占多少空间。先说固件。一个编译出来的 ESP32 应用 bin 文件空工程大概 200KB 左右加了 WiFi、蓝牙、文件系统、JSON 解析之后通常会涨到 500KB 到 900KB 之间。分区表里给每个固件分区算大小时我一般用“编译产物大小加上 30% 余量再向上取整到 4KB 的整数倍”。原因很简单固件功能只增不减等代码写多了才发现分区不够改分区表可不是改一行文字的事需要全片擦除重烧设备上所有数据都没了。NVS 配置区默认 24KB 够存几十个键值对。如果某个应用要存大量数据比如一组设备参数表建议至少给 64KB。NVS 是带磨损均衡的给足空间能显著延长 Flash 寿命。文件系统区按实际需要的文件总大小翻一倍。比如日志文件上限 1MB那文件系统分区至少给 2MB。日志缓冲池如果日志以循环覆盖方式写我会按“保留最近 7 天日志”来估算。单条日志平均 100 字节每分钟 10 条一天 1.44MB七天约 10MB。这个量一般用单独分区处理比较稳。可以做一张速算表方便参考数据种类估算方式推荐分区大小4MB Flash 场景固件 A编译 bin 大小 30%1MB固件 B编译 bin 大小 30%1MBNVS 配置键值对数量 x 100B加余量64KB文件系统 A需要存储文件上限 x 2512KB文件系统 B需要存储文件上限 x 2512KB日志单条大小 x 条数 x 保留天数256KB3.2 写自定义 partition.csv 的要点ESP32 的分区表可以用一款 CSV 格式文件定义然后编译成一个二进制分区表 bin 烧入 Flash。一个典型的多应用自定义表格长这样# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x10000, otadata, data, ota, 0x19000, 0x2000, phy_init, data, phy, 0x1b000, 0x1000, app_a, app, ota_0, 0x20000, 0x100000, app_b, app, ota_1, 0x120000, 0x100000, data_a, data, 0x40, 0x220000, 0x80000, data_b, data, 0x41, 0x2a0000, 0x80000, log_a, data, 0x42, 0x320000, 0x40000,有几个关键点必须说清楚。第一偏移量必须对齐到 4KB通常建议直接对齐到 0x1000064KB这是 OTA 友好的对齐方式。偏移没对齐不会立刻报错但后续某些 flash 操作会出奇怪问题排查起来非常痛苦。第二Type 和 SubType 决定了分区的作用。app类型的子类型可以填factory、ota_0、ota_1data类型有很多内置子类型nvs、phy、spiffs、littlefs、ota如果不想用内置类型可以从0x40开始自定义数值比如0x40、0x41表里就是这么干的。官方文档明确说 0x40 到 0xFE 是合法自定义范围。第三分区表本身占用了 Flash 的 0x8000 地址一段空间所以第一个分区不能从 0x0000 开始排。通常第一个分区放在 0x9000把 0x8000 到 0x9000 之间的空间留给分区表本身和别的元数据。3.3 实例4MB Flash 放 3 个应用 独立数据区上一节的 CSV 是 2 个固件 2 个数据分区的小规模场景。我实际项目中更常用的是 3 个“应用”的场景其中一个做主业务一个做监测一个做配置管理三者之间数据完全隔离。我最近一个项目用的分区表是这样的分区名类型/子类型偏移大小用途nvsdata/nvs0x90000x10000所有应用的键值配置otadatadata/ota0x190000x2000OTA 切换标志位phy_initdata/phy0x1b0000x1000WiFi 射频校准main_appapp/ota_00x200000x100000主业务固件monitor_appapp/ota_10x1200000x100000监测固件config_appapp/factory0x2200000x100000配置管理固件启动入口data_maindata/0x400x3200000x80000主业务独立数据区data_monitordata/0x410x3a00000x40000监测数据独立区这里的关键设计是config_app用的是factory子类型也就是说设备冷启动后先进配置管理固件由它决定是直接跑配置流程还是跳转到main_app或monitor_app。main_app和monitor_app分属ota_0/ota_1可以各自独立 OTA。每个应用的数据区也都是独立分区互不干扰。4MB Flash 被规划成了 6 个分区剩余空间不多但胜在清晰。你也可以发现这种方案对 Flash 容量要求比较高。如果你的 ESP32 是 2MB Flash那我建议老老实实缩成 2 个固件或者干脆采用“一个固件多模块”的方案别硬堆独立的固件映像。3.4 烧录与校验的正确姿势分区表文件改完之后不是直接编译固件就行。你需要用下面命令生成分区表 bin并确保烧录时把它写进去idf.py partition-table生成的文件在build/partition_table/partition-table.bin。正常烧录时idf.py flash会自动把分区表烧到 0x8000。如果你手动用 esptool 烧录命令里必须加上分区表esptool.py --chip esp32 --port /dev/ttyUSB0 write_flash 0x8000 build/partition_table/partition-table.bin我最常踩的坑是改完分区表后只烧了 app没烧分区表结果设备还用着旧分区表新代码里用到的分区在表里根本不存在运行时esp_partition_find返回空指针各种诡异 bug 冒出来。所以改分区表后最稳妥的做法是全片擦除然后完整烧录esptool.py --chip esp32 --port /dev/ttyUSB0 erase_flash擦掉以后bootloader、分区表、所有固件、所有数据区全部归零重新按新表烧录。这一步麻烦但能避免 90% 的“串门后遗症”。4. 应用层隔离手法让每个 App 学会“关门”4.1 NVS 命名空间最容易被忽视的“分账本”NVS 的命名空间机制是防止键值对互相覆盖的最直接手段。看这段代码#include nvs_flash.h #include nvs.h // 模块 A 初始化 nvs_handle_t handle_a; nvs_open(app_a, NVS_READWRITE, handle_a); nvs_set_str(handle_a, ssid, my_wifi_a); nvs_commit(handle_a); nvs_close(handle_a); // 模块 B 初始化 nvs_handle_t handle_b; nvs_open(app_b, NVS_READWRITE, handle_b); nvs_set_str(handle_b, ssid, my_wifi_b); nvs_commit(handle_b); nvs_close(handle_b);这两个模块都写了键ssid但因为用了不同的命名空间它们存进 NVS 后的实际键是app_a:ssid和app_b:ssid互不干扰。读取时也要用相同的命名空间nvs_open(app_a, NVS_READONLY, handle_a); nvs_get_str(handle_a, ssid, buf, len); nvs_close(handle_a);关于 NVS 有两个细节必须提醒。命名空间长度限制 15 个字符键名长度限制 15 个字符超了会返回错误。设计的存储结构时先想好命名别用“app_module_config_ssid”这种超长键否则编译能过运行时报错查半天。另外nvs_commit一定要调用因为nvs_set_xxx只是写进内存缓存掉电会丢nvs_commit才真正落盘。4.2 独立文件系统实例LittleFS 比 SPIFFS 更值得选如果几个应用都要存文件比如一个存图片一个存日志最合理的方式是给它们各分配一个独立的文件系统分区。ESP32 官方组件里SPIFFS 和 LittleFS 都支持我强烈建议新项目直接上 LittleFS。原因有二第一LittleFS 掉电一致性更好文件更新是事务式的写一半掉电不会把整个文件系统搞坏第二LittleFS 原生支持目录SPIFFS 虽然也能做扁平文件系统但目录支持不是它的强项。SPIFFS 还有一个特性问题它不支持实时追加写入要么一次性写要么读改写日志场景下很别扭。挂载代码示例#include esp_littlefs.h // 挂载 data_main 分区为 /spiflash esp_vfs_littlefs_conf_t conf { .base_path /data_main, .partition_label data_main, .format_if_mount_failed true, .dont_mount false, }; esp_err_t ret esp_vfs_littlefs_register(conf); if (ret ! ESP_OK) { ESP_LOGE(MAIN, Failed to mount data_main); }另一个应用挂载自己的分区esp_vfs_littlefs_conf_t conf_monitor { .base_path /data_monitor, .partition_label data_monitor, .format_if_mount_failed true, .dont_mount false, }; esp_vfs_littlefs_register(conf_monitor);两个应用的文件路径一个在/data_main一个在/data_monitor物理分区也不同。就算某个应用发疯把文件系统写满、格式化、甚至整个分区擦掉另一个应用的数据依然毫发无损。这就是“物理分区 逻辑路径”双隔离的效果。4.3 自定义数据分区与分区 API 的规矩有些数据既不适合放 NVS太大也不太适合放文件系统想要更底层的控制这时候可以用非标准子类型自定义分区然后用esp_partitionAPI 直接读写。这是灵活性最高的方式也是出事概率最高的方式因为一切边界都要自己管。先看如何拿到一个分区的句柄#include esp_partition.h const esp_partition_t *part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, // 类型data (esp_partition_subtype_t)0x40, // 子类型自定义 0x40 data_main); // 名字和分区表里的名字一致 if (part NULL) { ESP_LOGE(MAIN, Partition not found); return ESP_FAIL; }拿到句柄后最关键的一步是校验读写范围。esp_partition_read/write的入参里通常没有长度上限是靠调用方保证的所以我会封装一层安全函数每次都做边界检查esp_err_t safe_partition_write(const esp_partition_t *part, size_t offset, const void *buf, size_t len) { // 核心offset len 不能越过分区末尾 if (offset len part-size) { ESP_LOGE(MAIN, Write exceeds partition boundary); return ESP_ERR_INVALID_ARG; } return esp_partition_write(part, offset, buf, len); }同理读操作也要做同样的检查。这样即使代码别处计算偏移算错了返回的是一个明确错误而不是把数据写进邻居家。4.4 为什么没有“硬件门锁”Flash 是全局可写的这是最容易被项目新人误解的一点。ESP32 的 Flash 芯片本身是挂在 SPI 总线上的外部芯片任何代码在任何时刻都可以发起对这个芯片的读写指令。ESP32 内部的某个应用把 Flash 映射到了内存地址空间这部分是全局只读映射而真正写 Flash 的路径是绕过缓存、直接通过 SPI 控制器进行的同样是给所有代码开放的。换句话说分区表只是一个“行政划分”不是一个“物理锁”。它靠的是每个人自觉按划分区域操作而不是硬件强制隔离。这就是为什么我每次带新人做多应用项目都要把这句话写在文档最前面所有对 Flash 的写操作必须经过分区 API严禁用绝对地址裸写。某种意义上这一条代码规范比分区表本身更重要。5. 升级、日志与掉电安全容易“串门”的灰色地带5.1 多应用 OTA两种现实方案前面提到 OTA 是串门事故高发区这里给两个具体可落地的方案。方案一是整体升级。把多个“小应用”在源码层面合并成一个固件也就是一个 App 里多个模块物理上只占一个 app 分区升级时整个固件一起替换。这是最稳的方案不会出现分区槽对不上、启动入口混乱的问题缺点是需要一块足够大的 Flash 容纳合并后的固件。对绝大多数场景来说这是首选。方案二是多固件分区 分槽升级。在分区表里同时放ota_0和ota_1两个 app 槽每个槽里放一个独立应用。升级时要注意OTA 写入的目标分区必须和当前应用所在的槽位对应启动后还要把otadata的标志位清掉否则第二次重启会反复进错应用。实现上主应用负责下载新固件写入ota_1然后修改otadata指向ota_1重启后ota_1里的应用启动。但这里有一个深坑如果新固件写入后发现应用 A 在ota_0运行而 bootloader 因为某种原因选择从ota_1启动你会直接跑进 B 应用整个系统行为就乱了。所以我个人建议除非你的业务真的需要“两个应用交替启动”否则老老实实用方案一。5.2 日志与滚动策略日志是最容易被忽视的“串门”源头。很多开发者觉得日志嘛写到哪不行于是往 NVS 里写日志结果 NVS 写满了WiFi 配置存不进去或者往文件系统里写文件系统满了不检查继续写把文件系统搞坏。正确的思路是把日志当成一种“有独立分区的数据”来设计。我会在分区表里分配一个专门的日志分区比如叫log_a然后写一个简单的滚动逻辑每写一条日志先检查当前写入日志文件的大小。如果当前文件加上新日志超过分区容量就把最老的一份日志删掉再重写。删除和追加都通过 LittleFS 的文件 API 完成但每次操作前都检查esp_vfs_littlefs_info拿到的剩余空间剩余不足 4KB 时拒绝写入宁可丢新日志也不能把分区写爆。日志写入还有一个掉电问题断电瞬间可能留下半行不完整的日志。我习惯每一条日志都以换行符结尾启动时扫描文件如果最后一行没有完整的换行符就把这半行删掉。这个逻辑很简单但能避免下次解析日志时读出一堆乱码。5.3 掉了电怎么办写 Flash 的事务思维Flash 写入不是原子的。一次“修改配置”的动作往往需要先读出旧数据、修改内存里的新值、擦除整个 sector、再把新数据写回。这四个步骤中间任何一次掉电都可能留下半擦状态也就是所谓的“半写”。如果这半写发生在别的应用的分区边界附近那就可能把别人家数据也搞坏。最好的办法是尽量避免裸分区自定义写入直接使用 NVS 和 LittleFS因为这两者内部已经做了事务和磨损均衡。如果非要裸分区写我分享一个最简单的双备份策略一个数据对象存两份A 份和 B 份各占一个 sector。每次写入时先写 B 份再写 A 份每次启动读时先读 A 份如果 CRC 校验失败再用 B 份恢复。这样即使写 B 份时掉电A 份还是好的写 A 份时掉电B 份也还是好的。这套方案很简单但能覆盖 95% 的掉电数据损坏场景。多应用场景下尤其适合“设备参数互相独立”的模块各自维护自己的 A/B 份互不打扰。6. 一次真实排查复盘分区表写错后的一切最后分享一个真实的排查案例因为这种事属于“不亲身踩一次永远不会真正警惕”。项目里有一块 4MB 的 Flash计划放 A、B 两个应用。A 负责主业务B 负责蓝牙配置。同事写了一个自定义分区表把 A 放在offset 0x20000B 放在offset 0x120000数据分区data_a放在0x220000看起来完全正常。但设备跑起来之后诡异的现象出现了A 应用每次重启后配置数据偶尔会恢复成出厂值B 应用一旦启动A 的某个 IP 配置就丢失。排查链路我一步步说。第一步先看日志。A 应用启动时会打印它找到的分区信息结果发现 A 应用读到的data_a分区偏移竟然是0x20000而不是应该的0x220000。这说明运行时拿到的分区表跟我以为的分区表完全不一样。第二步用 esptool 读回 Flash 里的实际分区表esptool.py --chip esp32 --port /dev/ttyUSB0 read_flash 0x8000 0x1000 partition_table_dump.bin然后用工具解析这个 bin发现分区表里data_a的偏移就是0x20000和 A 的 app 分区重叠了。也就是说烧录到 Flash 里的这张表不是我写的 CSV 编译出来的那张。第三步查烧录日志。发现同事当天只是执行了idf.py flash这个命令会自动把 app、分区表一起烧录。但问题在于他的工程里 menuconfig 配置的“Partition Table”选项一直指向官方默认分区表factoryspiffs那张虽然项目目录下放了自定义 CSV但 menuconfig 里没有改成“Custom partition table CSV”并指定文件路径。第四步修复。在 menuconfig 里把 Partition Table 切换成自定义 CSV重新idf.py partition-table然后erase_flash全片擦除再重新完整烧录。之后 A、B 各自的数据分区终于归位串门现象消失。复盘完这个案例我想留两条经验。第一条分区表是不可见但决定全局的元数据每次烧录都必须确认它有没有跟着 flash 一起进设备。程序乱了还可以烧固件救分区表错乱很可能让你怀疑人生。第二条数据“串门”这种事绝大多数不是玄学 bug而是分区表没生效、偏移重叠、NVS 命名空间共用这几类根因造成的排查时优先检查这三处。我做过的 ESP32 多应用项目越多越觉得“多应用共用一块 Flash”的核心不是什么高深技术而是两件事一是在分区表层面把物理空间划清楚二是在应用层面通过命名空间、文件系统实例、封装后的分区 API 把逻辑空间隔离清楚。前者是骨架后者是门锁缺一不可。假如你现在正要开工一个多应用共存的 ESP32 项目我的建议是先把分区表打印出来贴在工位上然后给所有 Flash 操作套上边界检查的封装函数最后在启动日志里打印出当前运行态下实际生效的分区列表。这三步做完你会发现“串门”这个词从你的待办列表里彻底消失了。
返回列表