尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ESP32多应用Flash数据隔离:分区表与NVS命名空间实践

ESP32多应用Flash数据隔离:分区表与NVS命名空间实践 1. 先说清楚多个应用共用 Flash到底“串门”在哪一层前几年我有一阵特别喜欢在 ESP32 上一板多用今天挂个温湿度采集明天再加个网页配网后天还要记日志。直到有一回两个模块同时往 Flash 里写数据把对方的参数整个覆盖掉重启之后设备不仅没进正常模式还疯狂报校验失败。那一刻我才意识到ESP32 这种“一块 Flash 多个应用”的做法不提前把数据隔离想清楚后面所有功能都会在非常隐蔽的 bug 里来回折腾。这个问题看起来玄乎但本质上就是一件事你的数据到底写进了 Flash 的哪一块区域以及你的应用能不能“点到为止”地只操作自己那一块。1.1 两种截然不同的“多应用”场景先别急着撸代码。得先搞清楚“多个小应用”到底指哪种形态因为两者的隔离思路完全不同。第一种形态是同一个固件里塞了好几个逻辑功能模块。比如一个设备既有传感器采集模块又有 Wi-Fi 配网模块还有日志上报模块。这些模块在编译时会被揉进同一个 app 固件里跑在同一个 CPU 上但它们各自需要保存不同的配置、状态和业务数据。这种场景下“串门”通常不是 Flash 地址冲突而是键名冲突、文件路径冲突、存储区域写串。隔离的关键是给每个模块划好命名空间或独立数据分区。第二种形态是同一块 Flash 里放了不止一份固件映像。典型做法是留一个 bootloader由它来决定启动哪一份固件或者通过 OTA 方式把新固件写到备用分区再切换过去。这种场景下除了每份固件自己的运行空间要隔开连它们各自的数据区也得跟着隔开。否则 A 固件把自己运行时产生的配置写到 Flash 地址 0x300000B 固件以为这个地址装的是它的代码启动后轻则功能异常重则直接变砖。这两类场景会同时出现在同一个项目中。所以下文讨论的“隔离”既要解决代码映像的分区也要解决数据存储的分区还不能把 NVS、OTA 这类系统分区搅在一起。1.2 数据串门的四个典型现场我在实际项目里见过的“串门”翻来覆去就是下面四种。第一种键名撞车。ESP32 原生的 NVS 是非易失存储以键值对形式保存数据。如果“温度采集”模块用了一个叫status的键“配网”模块也用了同一个键或者两个模块使用同一个 NVS 命名空间那后写的就会把先写的覆盖掉。这是最隐蔽的一类因为代码看起来完全正常读写函数都执行成功但数据就是莫名其妙地变掉。第二种地址重叠。自己手动往 Flash 写数据的时候地址算差了。比如模块 A 的分区是 0x9000 到 0xF000模块 B 却从 0xE000 开始写两个区域有一部分叠在一起。一旦 B 写进去A 的数据就被破坏。这类问题往往不是立即爆发的要做很多次读写之后才偶发一次排查起来极其痛苦。第三种擦除越界。Flash 的特性是“写之前必须先擦”而且擦除的最小单位通常是 4KB。很多人只记得自己要写 2KB 的数据没意识到往前擦了一个 block结果把隔壁分区一起擦掉了。相比地址重叠擦除越界对数据的破坏是一次性的、大面积的基本没有回旋余地。第四种OTA 覆盖。做 OTA 升级的时候bootloader 会把新固件放到备用 app 分区然后切换启动分区。如果分区表设计得不合理比如把一个数据分区放在了 app 分区后面而 app 固件的大小又超过了预期容量那升级包在解压写入时就可能把数据分区吃掉。升级完成之后数据“消失”实际上是被新固件覆盖了。把这四种现场对上号之后你会发现一个规律绝大多数串门根源都在于“没有一套清晰可控的分区规划”。所以下面的正戏是从分区表开始讲。2. 先把“地契”签好分区表是隔离的第一道防线2.1 分区表里到底写了什么ESP32 的 Flash 布局并不是你写好地址就能保证的真正说了算的是一张分区表。这片分区表位于 Flash 的固定位置典型情况下是地址 0x8000占用一个 4KB sector 的空间Boot ROM 和二级 bootloader 启动时会读取它应用运行时也会通过它来查找自己需要的存储区域。分区表本质上是一个数组每个条目 32 字节包含这些字段名称、类型、子类型、起始偏移、大小、标志位。CSV 文件里常见的写法是这样的# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x1F0000,一行就是一个分区。Type决定这块区域是 app存放固件还是 data存放数据SubType进一步细化比如 app 里区分 factory、ota_0、ota_1data 里区分 nvs、phy、spiffs 等Offset和Size决定这块区域在 Flash 里的具体位置和大小。一个非常重要的细节是这些偏移和大小并不是你随手写的必须满足对齐要求。Flash 擦除的最小单位是 4KB所以偏移至少要是 4KB 的整数倍而 app 分区因为涉及 bootloader 跳转和安全启动ESP-IDF 也倾向于强制使用 64KB 对齐。分区之间不能重叠也不应该留出零碎到无法使用的空白。说白了分区表就是把一块大存储“切成地块”每个地块写清楚所有者签好“地契”。2.2 设计一张适合“多应用”的分区表我之前在一个 16MB Flash 的项目里做过这样的布局主固件支持 OTA轮换运行两个 app 版本同时还要给两个逻辑子应用分别提供独立的文件存储区。CSV 长这样# ESP-IDF Partition Table, 16MB Flash # 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, 0x300000, ota_0, app, ota_0, 0x312000, 0x300000, ota_1, app, ota_1, 0x612000, 0x300000, app1_data, data, spiffs, 0x912000, 0x180000, app2_data, data, spiffs, 0xA92000, 0x180000,来验证一下这个布局的合理性。第一个 app 分区从 0x12000 开始长度 0x300000也就是 3MB所以它结束在 0x312000。ota_0 接着从 0x312000 开始同样 3MB结束在 0x612000。ota_1 接着从 0x612000 开始结束在 0x912000。两个数据分区各占 1.5MB分别从 0x912000 和 0xA92000 开始最后一个分区结束于 0xC12000距离 16MB Flash 的末尾还有约 3.9MB 的空间足够后续扩展或者加一个小日志分区。为什么每个分区都刚好接在下一个分区的起始处没有留空隙因为对齐规则是“分区边界对齐到 4KB”或“app 分区对齐到 64KB”在上面的布局里所有地址都是 0x10004KB的整数倍app 分区也恰好落在 64KB 对齐的位置上。这样烧录时不会因为对齐问题报警也能最大限度避免“两个分区互相咬合”的情况。如果你的项目不大用的还是最常见的 4MB Flash那也没关系。一个默认分区表往往就够用NVS 区域专门存键值对factory 放一份固件。多个逻辑子应用如果数据量不大直接在同一个 NVS 分区分命名空间即可不用改表。这个我放到后面讲。2.3 让自定义分区表生效如果你用的是 ESP-IDF操作并不复杂。先把上面这段 CSV 保存成项目根目录下的partitions.csv然后执行idf.py menuconfig找到Partition Table分类把模式改成Custom partition table CSV然后在弹出的输入框里填partitions.csv。保存退出后正常编译烧录即可。编译系统在生成 bin 文件时会自动处理这些偏移你不用手工计算烧录地址。重点是你随时可以用一行命令验证当前分区表到底是什么样idf.py partition_table这个命令会把当前构建产物中的分区表解析出来打印成可读的表格。我在调试时几乎必用这个命令因为它能直接看出分区偏移是不是预期值是否发生了重叠。如果用的是 Arduino IDE,事情会稍微繁琐一点。Arduino 的 ESP32 核心在“工具”菜单里有预置的Partition Scheme比如Default、Huge APP、No OTA、Custom。选Custom时需要用户自己提供一个custom.csv并且要把这个文件放到 Arduino 核心对应的 boards 目录里再修改 boards.txt 才能生效。这个操作你得有一定耐心所以如果你要做复杂的多应用隔离我更建议直接用 ESP-IDF 来管理分区表。分区表这件事谋定而后动比后面出 bug 再返工省事得多。3. 键值数据命名空间隔离 vs 分区隔离3.1 NVS 内部是怎么隔离的先讲最常用也是最便宜的隔离NVS 命名空间。NVS 是 ESP32 官方提供的一个轻量键值存储组件。它不会被编译进代码里而是占用一个独立分区负责把数据分成一页一页地管理每页对应 Flash 的一个擦写块。它内部实现了磨损均衡、修改合并、写入失败回滚等机制比你自己手动写 Flash 可靠得多。理解 NVS 的命名空间最好的类比是衣柜里的抽屉。整个 NVS 分区是一个大衣柜命名空间则是一个个抽屉。你可以在抽屉 A 里放一个叫count的东西也可以在抽屉 B 里放一个叫count的东西彼此互不干扰。读取的时候必须写清楚“我去哪个抽屉找什么”只给键名不给抽屉名系统是找不到的。在nvs.h的接口里打开抽屉的函数就是nvs_opennvs_handle_t handle; esp_err_t err nvs_open(app1, NVS_READWRITE, handle);第一个参数就是命名空间名。同一个命名空间下不允许重复键名不同命名空间之间则可以放心使用相同键名。这是官方设计的硬隔离不会出现“我明明只写了一个键另一个应用的同名键却被覆盖”的尴尬。3.2 用命名空间实现多应用隔离假设你的固件里有两个逻辑子应用一个负责温湿度采集一个负责配网信息管理。代码如下// 温湿度模块 void sensor_save_state(uint32_t uptime) { nvs_handle_t h; nvs_open(sensor, NVS_READWRITE, h); nvs_set_u32(h, uptime, uptime); nvs_commit(h); nvs_close(h); } // 配网模块 void wifi_save_config(const char *ssid) { nvs_handle_t h; nvs_open(wifi_cfg, NVS_READWRITE, h); nvs_set_str(h, ssid, ssid); nvs_commit(h); nvs_close(h); }即便两个模块都存了一个ssid因为命名空间不同读写互不影响。这样做的成本非常低不需要自定义分区表不需要烧录额外的数据分区甚至连 NVS 初始化代码都不用改。我测试过一个默认的 0x6000 大小的 NVS 分区足够容纳几十个命名空间前提是每个命名空间下的键不多、数据量不大。需要提醒几个限制。命名空间名最长 15 个字符键名同样有长度上限。别取那种一个单词恨不得排到 20 个字符的名字编译不会报错运行时会返回ESP_ERR_NVS_KEY_TOO_LONG。数据长度也要控制单个字符串值的上限受 NVS 页大小影响超过限制会直接写入失败。3.3 何时必须升级为分区隔离命名空间虽好但有极限。当你的子应用要存大量数据时NVS 会显得窘迫比如保存几百 KB 的日志、批量采集曲线、离线图片资源或者需要一个能随时“整个丢弃重来”的存储区域。NVS 的设计目标是小而快不是让你拿它当文件系统用。这时候就该上分区隔离了。所谓分区隔离就是在分区表里为每个子应用分配一个独立的 data 分区子应用拥有这片区域的绝对控制权。它可以自己决定用 SPIFFS、LittleFS、FAT甚至直接裸读写。子系统 A 把分区格式化、擦空、重写和子系统 B 一点关系都没有。判断标准我可以给得很实在如果你的数据是“结构化的小型键值对”比如配置项、计数值、开关状态用 NVS 命名空间如果你要处理的是“大块文件或频繁批量擦写”就单独划数据分区。把两种隔离方案混用来管理不同粒度的数据才是完整的隔离策略。4. 数据文件给每个应用圈一块自己的地盘4.1 用 SPIFFS / LittleFS 分区隔离文件文件型数据的隔离直观的做法就是给每个子应用挂载不同的分区。回到上面那个 16MB 分区表app1_data和app2_data就是两个独立的文件系统分区。在 ESP-IDF 里可以用esp_vfs_spiffs_register把它们分别挂载到不同路径下#include esp_spiffs.h void mount_app1_storage(void) { esp_vfs_spiffs_conf_t conf { .base_path /app1, .partition_label app1_data, .max_files 8, .format_if_mount_failed true, }; esp_vfs_spiffs_register(conf); } void mount_app2_storage(void) { esp_vfs_spiffs_conf_t conf { .base_path /app2, .partition_label app2_data, .max_files 8, .format_if_mount_failed true, }; esp_vfs_spiffs_register(conf); }之后模块 A 往/app1/config.ini写文件模块 B 往/app2/config.ini写文件。虽然都是config.ini但底层对应的是两块完全独立的分区。文件系统层根本看不见对方的东西连“决定要不要互相覆盖”的机会都没有。我在这里踩过一个坑.base_path千万不要设成根路径/否则文件系统会试图接管整个 VFS 根和日志输出、控制台之类的子系统撞在一起。一般用一个有辨识度的子目录路径比如/app1、/logs。另外ESP-IDF 新版本对文件系统有新的偏好。老的esp_spiffs组件一直可用但社区和官方都在往 LittleFS 迁移。LittleFS 的掉电恢复能力和目录操作体验都比 SPIFFS 好在 ESP-IDF 里可以用esp_littlefs组件接口几乎一样。建议新项目直接上 LittleFS。4.2 直接操作分区如果你不想要文件系统的开销也可以直接用分区 API 做裸读写。流程分三步先找到分区再擦除再读写。#include esp_partition.h const esp_partition_t *part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, app1_data); if (part NULL) { ESP_LOGE(TAG, partition not found); return ESP_FAIL; } // 先擦后写 ESP_ERROR_CHECK(esp_partition_erase_range(part, 0, part-size)); ESP_ERROR_CHECK(esp_partition_write(part, 0, payload, payload_len));这里最关键的一点是esp_partition_write的偏移参数是分区内部的偏移不是 Flash 的绝对地址。比如app1_data分区起始于 Flash 地址 0x912000你从分区内偏移 0 开始写实际擦写的是绝对地址 0x912000但你完全不需要关心这个绝对地址直接在 API 层面传相对偏移就行了。这个抽象是刻意的因为分区表一旦调整分区起始地址会变但应用代码的逻辑不需要跟着变。如果有人习惯拿绝对地址直接操作 Flash那么一旦分区表改动或者引入 OTA 切换代码就很容易把数据写飞到其他分区去。esp_partition_erase_range也有同样的特性它的size参数是分区内连续擦除的长度。千万要保证start size不超过part-size否则会返回错误。ESP-IDF 内部有校验但你不是每次都会检查返回值所以最好在调用前自己做一个边界断言。4.3 错误做法裸读写跨分区不少初学者会犯一个经典错误不去调用分区 API而是手动计算 Flash 绝对地址直接操作 MMU 映射或者底层 flash 驱动。在“刚好只有一个应用、分区表万年不变”的小 demo 里这种做法偶尔能跑通。可一旦多做几个应用、加一个 OTA绝对地址就会变成大坑。举个例子。你以为 0x912000 是app1_data的起点但如果哪天新增了一个log分区把app1_data的偏移推到了 0xA12000而你的代码里还写死着 0x912000那写入就会直接砸到别的分区上可能把 app 分区的一部分覆盖掉。这类问题在常规功能测试里几乎发现不了直到用户升级固件或者特定条件下触发才以“随机重启”“配置丢失”的形式爆发出来。所以铁律只有一条在 ESP32 上访问 Flash永远不会直接写死绝对地址要么通过esp_partition_find_first按名称找分区要么通过esp_ota_get_running_partition找当前运行固件所在分区。一切访问都带上分区上下文绝不裸奔。5. 实操从 CSV 到可运行的隔离工程5.1 准备工作直接套用上面 16MB 分区表我会把一个双逻辑应用 OTA 的验证工程跑通。你需要准备一块 Flash 不小于 16MB 的 ESP32 开发板以及安装了 ESP-IDF 的开发环境。如果你是第一次配置建议先跑一遍idf.py set-target esp32把 toolchain 和依赖拉齐。把之前写好的partitions.csv放进项目根目录然后在sdkconfig或者menuconfig里指定分区表为 Custom。我比较推荐直接用menuconfig改因为它会校验文件路径避免你手动改写sdkconfig时把变量拼错。5.2 编译和烧录idf.py build构建完成后先别急着烧。先验证分区表的解析结果idf.py partition_table如果输出里能看到nvs、otadata、factory、ota_0、ota_1、app1_data、app2_data这些名字并且偏移跟你 CSV 里写的完全一致说明分区表已经正确编入固件包。接着烧录idf.py -p /dev/ttyUSB0 flash monitor如果你的闪存容量和分区表不匹配或者 Flash 实际小于 16MB烧录工具会在写 Flash 时报警。别忽视这种报警我碰到过一例开发板标称 16MB 但实际只焊了 8MB Flash烧录时看似全部成功结果系统一访问超出 8MB 地址的区域就 panic。升级固件时这种问题更致命所以每次换板子第一件事就是确认 Flash 容量。5.3 运行验证验证思路是“写 A、读 B、互不可见”。在 main 函数里依次挂载两个文件分区然后让模块 A 往/app1/hello.txt写入一段文本模块 B 尝试读取同一个路径。如果按分区表配置正确执行模块 B 在/app1下找不到文件因为它挂载的根路径是/app2。NVS 的验证也如前面代码所示两个子应用各开一个命名空间写入同名同值的键重启后互相读取对方的值必然得到ESP_ERR_NVS_NOT_FOUND。这个现象恰恰说明隔离生效了——两个抽屉里的东西互不串门。为了更直观我习惯在启动日志里加上分区信息打印const esp_partition_t *part esp_partition_find_first( ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL);打印出当前 app 分区的 label 和 offset以及正在使用的数据分区的 offset。这样即使固件后来发生了 OTA 切换我也能从日志里一眼看出当前到底是哪份固件在跑它的数据该落在哪块区域。日志逻辑哪怕写得简单点这个打印别省排查问题时能省一个小时。6. 常见问题与排查实录数据“串门”第一现场6.1 NVS 键读取时好时坏症状同一个键有时能读到有时返回ESP_ERR_NVS_NOT_FOUND断电重启后数据仿佛随机丢失。我见到最常见的两个原因。其一是两个模块打开了同一个命名空间且键名相同后写的覆盖了先写的导致逻辑上“数据丢了”。这种情况不是 NVS 故障是应用层命名空间没规划好。修复很简单给每个模块一个独立命名空间命名规则要稳定不要今天叫app1明天叫app2_config_v2。其二是 NVS 分区太小运行过程中空间用尽新增写入失败。NVS 的机制是页内没有空间时会合并、淘汰无效条目但如果数据量超过分区容量写入就会报ESP_ERR_NVS_NO_FREE_PAGES。默认 NVS 分区一般是 0x6000也就是 24KB。如果你的子应用多、键也多建议在分区表里把 NVS 扩大到 0x10000 或者更大甚至可以为不同模块划分不同的 NVS 分区。6.2 OTA 后数据全部消失症状OTA 升级成功新固件跑起来了但老固件时代写入的配置、文件全部丢了。有三个常见原因。第一新固件使用的分区表和老固件不一致。如果你的升级包里的分区表被改成 app 区域更大导致后续数据分区偏移发生了变化那么老固件的数据已经不在新固件所认知的位置上自然就读不到。这就是我反复强调“分区表一旦发布就不要随意改 offset”的原因。第二升级前你擦除了 app 分区但顺手把相邻数据分区也擦掉了。部分 OTA 工具或者脚本在烧录前会执行全片擦除如果命令行里写的起始地址和长度覆盖了其他区域数据就没了。写脚本时先打印一遍将要擦除的地址范围再动手执行。第三新固件代码里没有正确调用nvs_flash_init或者挂载文件系统时没有打开正确的分区 label。检查日志看part-size有没有打印为 0。6.3 分区重叠导致“鬼魅串门”症状某一个子应用正常写入数据另一个子应用的配置却隔三差五被改掉查看代码逻辑完全没问题。这是我最喜欢排查的一类问题因为答案几乎永远在分区表里。打开idf.py partition_table检查两个分区的 offset 和 size 是否首尾相接而没有重叠。比如 app1_data 从 0x912000 开始大小 0x180000那么它的合法结束地址是 0xA92000如果 app2_data 从 0xA92000 开始就刚好但如果手误写成 0x900000就会覆盖 app1_data 的前 0x12000 个字节。这种重叠不会在编译时直接报错只有运行时写入才会触发。预防办法是我前面说的每个分区起始地址 上一个分区起始 上一个分区大小。我在写 CSV 时会专门用一个现成的脚本计算避免人工手算十六进制加法。你如果不写脚本至少也在表格里留一列“本分区结束地址”检查时一目了然。6.4 排查工具与命令想快速知道 Flash 里到底发生了什么几个命令我说一下。用 esptool 直接读回分区表区域python -m esptool -p /dev/ttyUSB0 read_flash 0x8000 0x2000 partition_table.bin读回来后可以用 ESP-IDF 的parttool.py解析python -m espsecure --help如果只是开发阶段用idf.py partition_table就够了它会直接打印解析结果。注意你读到的分区表能不能反映真实烧录情况取决于烧录时 Flash 是否成功写入、烧录后有没有被其他程序覆盖。如果你觉得 Flash 里的表和代码里的 CSV 对不上优先用 esptool 读原始字节再手动比对字段而不是盲目重烧。运行时排查还有一个大招打开CONFIG_LOG_DEFAULT_LEVEL_VERBOSE把 bootloader 日志打开。bootloader 在启动时会把读到的分区表打印出来你能在串口日志里直接看到 boot 使用的分区布局和运行时应用层通过esp_partition_find_first拿到的结果做交叉验证。7. 实际操作中的几点经验最后分享几点我每次做 ESP32 多应用 Flash 隔离都会坚持的实践。第一默认 NVS 分区不要省。很多人图省事把 NVS 分区设成最小结果新功能加了两三个命名空间就空间告急。在不影响 app 分区大小的前提下NVS 给到 0x10000 是更安逸的选择多出来的钱不过 64KB Flash换来的是不再为NO_FREE_PAGES焦头烂额。第二能不改分区表就不改。分区表不是普通的代码文件它是硬件布局的“宪法”。一旦设备已经出货或者 OTA 通道已经建立老固件和服务器端的分区表必须保持一致。新版本固件想调整分区大小只能靠先做一次迁移逻辑把老分区数据搬过去否则数据全丢。我自己维护过一个产品因为升级时改了一次数据分区偏移导致用户设备升级后全部配置被清空后来花了整整一周写迁移脚本才救回来。第三文件系统分区优先选 LittleFS别再用 SPIFFS 开新项目。SPIFFS 在目录多、文件频繁增删的情况下性能很一般掉电恢复也不够健壮。现在的 ESP-IDF 组件仓库里 LittleFS 很好用API 和 SPIFFS 基本兼容切换成本很低。第四每个数据分区的用途要在代码里用宏或者枚举定义清楚不要散落在各个模块里写find_first时随手传不同的 label 字符串。字符串拼错是小概率但一旦云端配置下发了一个新 label远端的模块却用旧 label 查找分区查找失败后所有读写静默失败你就得在设备现场抓日志了。多应用共用一块 Flash 这件事做起来并不复杂核心就两个字边界。分区表划定边界命名空间细化边界文件系统路径固化边界OTA 机制守护边界。把边界想清楚数据不会串门设备也不会在深夜里莫名其妙重启。而在你底气十足地开始写第一个分区表 CSV 之前不妨先把你项目里所有要存的数据列一张清单看看它们分别属于哪个模块、大概多大、需要多长生命周期然后再决定哪一层隔离就够了。
返回列表