ARTICLE DETAIL

资讯详情

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

ESP32多应用共享Flash防数据串门:分区表、NVS隔离与OTA升级实践

ESP32多应用共享Flash防数据串门:分区表、NVS隔离与OTA升级实践 最近调试一个 IoT 项目时把温湿度采集、网页配置页、BLE 广播三个“小应用”塞进了同一个 ESP32 开发板还顺手加了 OTA 升级。第一次升级完就翻车配置页里的设备昵称变成乱码日志文件里多了一堆别的模块写入的键值连蓝牙设备的名称都被冲掉了。查到最后业务代码一条没改错问题出在我把整片 4MB Flash 当成一个大仓库随便用没有做数据隔离。如果你也准备在同一个 ESP32 里跑多个小应用或者让它们共用一份 Flash 存储空间那这篇文章多少能帮你少踩几个坑。我会先讲清楚 Flash 的物理特性决定了哪些地方容易“串门”再给出分区表设计、代码层隔离、OTA 升级保护以及烧录时常见的坑最后用一个完整示例收尾。1. 数据串门的根源Flash 的分区表才是真正的“界墙”1.1 擦除操作决定了“串门”的上限ESP32 内置 Flash 和普通硬盘不一样它不支持“我改一个字节就写一个字节”。Flash 的读取可以按字节、按字来进行但写入之前必须先擦除而擦除的最小单位是扇区一般是 4KB有些驱动还会按 32KB 甚至 64KB 的大块来擦。这就像你在一面大黑板上写满了内容想改其中某个角落的公式最好的办法是把一整块区域整体擦掉再重新写上你需要的部分。问题就在这里如果擦除范围算大了就会把相邻区域的数据一块儿抹掉。我把“串门”理解为两种情况第一种是自己的程序越界写到了本不该写的位置把别人的数据覆盖了第二种是擦除范围过大把邻近区域的合法数据物理性清空。这两种情况都不是“程序代码拼错了”而是没有用分区表划定清晰的物理边界。Flash 内部没有一个“文件权限系统”来告诉你哪个区域归谁所有。一切隔离都得靠地址来维护你访问了地址 0x10000那这块区域就是你的如果你访问了地址 0x9000那就算你本意是写配置实际上一不小心就会覆盖底层系统数据。ESP32 里真正的“界墙”是分区表partition table它把 Flash 物理地址空间切成若干逻辑区域每个区域有起始偏移和大小超过这个范围就是“非法访问”。1.2 分区表如何成为逻辑界墙ESP-IDF 和 Arduino ESP32 都会在固定位置放一张分区表引导程序 bootloader 在启动时扫描这张表才知道哪一段地址放应用代码、哪一段放 NVS 数据、哪一段放文件系统。分区表的基本格式类似于# name, type, subtype, offset, size, flags nvs, data, nvs, 0x9000, 0x4000, phy_init, data, phy, 0xd000, 0x1000, factory, app, factory, 0x10000, 0x100000, storage, data, spiffs, 0x110000, 0x100000,每一行就是一个逻辑分区。name 是你给这个区域起的名字type 决定它是 app、data 还是其他类型subtype 进一步细化具体用途offset 是起始地址size 是容量。编译时工具会根据这张表生成分区表镜像烧录时 bootloader 也会依据它来决定从哪个地址加载应用。1.3 典型的 4MB Flash 分区布局很多开发板默认是 4MB Flash常见的布局大概是下面这样分区名称类型起始偏移大小主要作用bootloaderboot0x10000x8000引导程序不能乱动partition tablept0x80000x1000分区表本身nvsdata/nvs0x90000x4000键值型参数存储phy_initdata/phy0xd0000x1000WiFi 射频校准数据factoryapp/factory0x100000x100000主应用也就是你编译出来的固件storagedata/spiffs0x1100000x100000文件型数据存储这里每个区域的偏移和大小必须满足硬件对齐要求。分区表本身在 0x8000bootloader 在 0x1000nvs 紧跟其后然后才是应用代码区。如果你在同一片 Flash 里规划多个小应用的数据区、日志区、配置区第一步就是把这些物理边界在设计阶段定清楚后续代码里谁都不允许越界。1.4 串门发生的常见触发方式我梳理了实际项目里最容易引发数据串门的几种情况应用 A 计算存储偏移时直接写死地址没有通过分区 API 获取真实偏移。某个模块调用擦除 API 时给了一个大范围比如想清 4KB结果传参传成了 64KB。OTA 升级新固件后新固件使用的分区布局和旧布局不一致导致旧数据地址全部错位。文件系统、NVS、日志放在同一个分区里混用互相抢占空间后出现脏数据。烧录时选择了错误的 Flash 大小或错误分区表把整片 Flash 清空或错误覆盖。这些问题表面上看是各种偶发的数据异常但底层都是同一个原因让应用直接接触裸地址而不是通过分区表这层“界墙”来访问。2. 多个小应用共享 Flash 时的分区分配策略2.1 先分清要隔离哪些数据在写分区表之前我要先想清楚这一整片 Flash 上到底要放哪几类数据固件代码至少一个 app 分区如果要做 A/B 升级就是两个 app 分区。配置参数适合放 NVS很适合保存设备昵称、阈值、开关状态这类键值型数据。日志文件适合放独立的文件系统分区比如 spiffs 或 littlefs。网页资源比如内嵌 Web 页面、证书、固件升级包需要较大且独立的存储区。校准数据WiFi 的 phy_init放错位置会让无线性能异常。逻辑上可以给每个“小应用”分配自己的 NVS 命名空间也可以给每个小应用分配独立的文件分区。到底选哪种取决于数据量和访问方式。如果你的每个小应用只需要几十个键值对共享一个 NVS 分区用命名空间隔离最省空间如果某个小应用要写文件、存日志那最好单独分一个文件系统区域。2.2 两种共享布局示例如果你把多个小应用编译成一个 ESP32 固件只是在业务上分模块常见布局是# name, type, subtype, offset, size nvs, data, nvs, 0x9000, 0x4000, phy_init, data, phy, 0xd000, 0x1000, factory, app, factory, 0x10000, 0x200000, storage, data, spiffs, 0x210000, 0x180000,这种方案适合一个固件里跑多个功能模块各模块的文件都放在 storage 分区里通过目录名区分。它的好处是部署简单坏处是任何一个模块写文件时如果目录管理不严还是可能污染其他模块的数据。另一种方案是真正的“多个独立固件”引导程序按需选择启动哪个分区。这种情况下 app 分区需要有多个# name, type, subtype, offset, size nvs, data, nvs, 0x9000, 0x4000, phy_init, data, phy, 0xd000, 0x1000, app_a, app, factory, 0x10000, 0xF0000, app_b, app, ota_0, 0x100000, 0xF0000, shared, data, spiffs, 0x1F0000, 0x200000,具体大小可以根据固件体积调整但我要提醒一点多 app 分区会显著压缩可用数据空间。一块 4MB Flash 要放两个各 512KB 的固件再留 256KB 给系统数据剩下给业务存储的也就只剩 2MB 左右。规划前先把每条“小应用”的固件 size 查清楚否则布局设计到最后一定会空间不够。2.3 分区大小和偏移的计算习惯我自己设计分区表时会遵循几个经验规则app 分区大小尽量按 64KB 对齐至少不低于实际固件体积加余量。NVS 分区一般给 16KB 到 32KB除非你要存大量键值否则足够用。文件系统分区至少 256KB太小会被日志或网页资源迅速填满。所有偏移严格按照分区表工具的计算确认不要凭感觉写。如果某个分区需要擦除先通过 esp_partition_erase_range 限制在本分区范围内。关于到底需不需要手动算偏移我的习惯是第一个 app 分区明确写 0x10000后续非 app 分区尽量让分区表工具自动连续排列避免手动算错。只有当你需要刻意留洞洞给升级备份时才必须手动设计偏移。3. 代码层隔离分区 API、NVS 命名空间、文件目录三层防线3.1 用 esp_partition API 防止越界ESP-IDF 提供了一整套分区访问 API比直接操作裸地址安全得多。核心是先用名字找到分区再在分区范围内做擦写。示例代码const esp_partition_t *part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, storage); if (part NULL) { ESP_LOGE(MAIN, partition not found); return ESP_FAIL; } esp_partition_erase_range(part, 0, part-size); esp_partition_write(part, 0, buffer, buffer_len);这个 API 最大的价值在于底层会根据 part 的 offset 自动加上分区起始地址你传入的偏移是“分区内偏移”。只要别把 size 或偏移传成超过 part-size它就不可能跑到邻居分区里去。我在项目里要求所有模块一律通过这个 API 读写分区禁止直接定义裸地址宏。为什么必须强调这一步很多人图省事直接在源码里写死 0x210000 这类物理地址。一旦你调整分区表这些硬编码地址全部失效而编译器和链接器根本不会报错运行期数据串门就成了非常隐蔽的 bug。用分区 API 之后分区表调整只需要同步编译代码里的逻辑完全不用换。3.2 NVS 命名空间隔离NVS 区域是 ESP32 里非常常用的键值存储它的数据格式本身并不带所有者信息所以跨模块的键名冲突非常常见。ESP-IDF 提供的命名空间机制就可以保证“同一个键名不同模块读到的值完全隔离”。打开命名空间的代码很简单nvs_handle_t handle_a; esp_err_t err1 nvs_open(app_a, NVS_READWRITE, handle_a); nvs_set_u32(handle_a, counter, 42); nvs_handle_t handle_b; esp_err_t err2 nvs_open(app_b, NVS_READWRITE, handle_b); nvs_set_u32(handle_b, counter, 128);两个模块都用了键名 “counter”但因为命名空间不同数据物理存储位置和查找逻辑都互不干扰。我建议命名空间命名规则跟模块名严格对应比如 ble_app、web_app、sensor_app。在代码层尽量封装成统一读写函数每个模块只允许打开自己的命名空间。这里有两个容易踩的细节命名空间名称长度有限制尽量不要超过 15 个字符太长会初始化失败。NVS 分区只有一个但命名空间可以很多不要在同一个 handle 里混用多个业务键否则就失去了隔离效果。3.3 文件系统目录隔离文件系统分区的隔离没有 NVS 那么自动。open 文件时如果都用根目录A 模块写了 web.htmlB 模块也写 web.html后写的就会覆盖前一个。所以即使只有一个 storage 分区也要在上层约定目录结构/storage/ /config/ device.json /sensor/ data_20240101.csv /log/ ble_log.txt这样每个小应用访问自己的子目录即使底层是同一个 SPIFFS 或 LittleFS 分区“串门”的概率也大大降低。要注意的是文件系统本身没有权限控制任何代码只要知道路径都能读都写。所以更重要的是项目规范而不是指望文件系统自动保护。3.4 数据写入的原子性和一致性检查共享 Flash 最常见的脏数据场景是写到一半发生了掉电或重启导致目标文件/键值只写入了一部分。这种情况比“越界覆盖”更隐蔽需要自己加保护。一种通用做法是写数据时先写入一个临时键/临时文件完成后再做一次“提交”动作。比如用 NVS 写配置时先写 key_temp全部写入成功后再写 key_done 并赋值为当前版本。读取时以 key_done 为准。如果是文件系统则先把完整内容写到 tmp 文件fsync 之后 rename 成正式文件名。更严谨一点可以加校验字段。每段重要的数据记录里都包含魔数、版本号、长度和 CRC32读取时先校验校验失败就回退到上一个有效版本。这类做法在 OTA 升级和日志记录场景里尤其重要。4. OTA 升级时最容易“串门”的地方升级槽和残留数据4.1 A/B 升级需要两个 app 分区OTA 是数据串门事故的重灾区。原因很简单升级过程会让系统重新读写整片 Flash而且新固件很可能和旧固件使用不同的分区布局。ESP-IDF 的 OTA 机制里如果要做经典的 A/B 回滚需要至少两个 app 分区。例如# name, type, subtype, offset, size nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x100000, ota_0, app, ota_0, 0x110000, 0x100000, ota_1, app, ota_1, 0x210000, 0x100000, storage, data, spiffs, 0x310000, 0xF0000,otadata 分区用来记录当前应该从哪个槽启动。bootloader 启动时会先读 otadata再选择 factory、ota_0 或 ota_1。这样如果一个新固件启动失败还能回滚到另一个槽。4.2 升级过程中的写串门点我踩过的具体坑是这样的旧固件把日志写在某个偏移位置新固件启动后在同一物理位置写配置。看起来日志文件消失了实际上并不是消失而是新固件把这块区域当成自己的配置区写了一遍。要避免这种问题最核心的一点是任何一次 OTA 升级都要保证新旧固件的分区表布局完全一致。新固件的 partitions.csv 如果改变了某个分区的偏移那么旧数据留在原地新代码去新地址找结果就是读出来的全是旧分区里的残留数据。这不叫数据串门但表现比串门更迷惑人。另外OTA 升级过程中下载的升级包如果和 storage 分区共用同一文件下载一半时系统重启下一次升级可能就会把半截升级包当成完整包来解析。我的做法是固件升级包单独留一个update分区并且写入完成后立刻做 CRC 校验确认无误再跳转到 OTA 槽。4.3 半包数据和残留数据的识别机制为了保护关键数据我会给每段需要持久保存的数据加一个很简单的头部结构typedef struct { uint32_t magic; // 固定魔数比如 0xA5A5A5A5 uint16_t version; // 数据版本号 uint16_t length; // 有效数据长度 uint32_t crc32; // 数据区 CRC 校验 } data_header_t;读数据时先检查 magic如果不等于预设值说明这块区域还没有合法数据再检查 version版本太低说明数据来自旧固件需要迁移或丢弃最后校验 CRC32长度对不上直接把整条数据当作无效数据。这个套路在多个小应用共享 Flash 的场景下特别实用。比如 BLE 模块的配置和 Wi-Fi 模块的配置都放在同一个 NVS 分区时使用版本号能帮你快速识别哪份数据是哪个固件版本写的。只要所有模块都按这个约定读数据即使某次升级改变过键结构也不至于让整台设备进入不可用状态。4.4 升级回滚的验证流程每次 OTA 升级完成之后我的习惯是让新固件启动后先做一次自检检查分区表是否和自己编译时一致检查 NVS 里每个命名空间能否正常读到预期的版本号检查 storage 分区的目录结构是否完整。自检通过后再把 otadata 标记为“确认成功”否则在后台自动回滚。这套流程虽然多写几百行代码但能有效避免升级后“看起来能跑数据却已经错乱”的尴尬局面。5. 实测中遇到最多的“串门”事故烧录、擦写和外置 Flash5.1 烧录工具和整片擦除的坑ESP32 的烧录方式主要有两种直接用 esptool 烧录或者在 Arduino 环境里点上传按钮。最隐蔽的问题出在“整片擦除”。很多人为了方便会先执行esptool.py --port COM3 erase_flash这个命令会把整片 Flash 都清空包括 bootloader、分区表、NVS、WiFi 校准数据。紧接着上传新固件底板看起来正常但设备里面所有配置都没了需要重新配网、重新设置参数。如果你的项目里还有多个小应用整片擦除之后它们各自的数据也全部归零。我建议日常开发优先使用write_flash只写修改到的区域保留其他分区数据。如果必须整片擦除至少要提前备份 NVS 和关键配置文件否则现场一旦出现掉线配置恢复就很麻烦。另外烧录时经常见到的flash download failed、cortex-m3这类的报错多数不是代码问题而是串口电平不稳、开发板供电不足、GPIO0 没有正确拉低进入下载模式。这种情况下表现为烧录失败但如果你强行重复烧录某些工具的擦除逻辑会连带破坏别的分区。稳妥做法是检查 USB 供电能力、用更短的杜邦线、手动按住 BOOT 键再上电。5.2 Arduino 开发环境里的分区和烧录偏移问题Arduino ESP32 使用分区表的方式和 ESP-IDF 类似但很多人在 Arduino 菜单里只改了Flash Size为 4MB 或 16MB没注意分区方案。不同板卡配置会默认生成不同的偏移量例如默认 app 起始偏移可能是 0xe000也可能是 0x10000。如果手动修改过 bootloader烧录时用的 offset 没对齐就会出现启动卡死、串口输出乱码、WiFi 校准数据被覆盖各种奇怪现象。减少这种问题的方法有两个一是使用 Arduino 自带的Tools Partition Scheme菜单选择一个合适的分区方案二是自己写 partitions.csv 文件并在编译选项中指定使用。用环境管理器安装 ESP32 开发包的时候也尽量选择官方或国内镜像源避免旧版本工具生成过时的分区结构。5.3 外挂 SPI Flash 与多块存储设备的“串门”ESP32 除了内置 Flash很多人还会外挂一片 SPI Flash比如常见的 W25Q64。这种外挂 Flash 一般用于存大文件、图片资源、录音文件。默认没有任何保护读写地址全由你说了算。我对外挂 Flash 的处理方式是先做一次地址分配把 Flash 划分成几个固定区域比如 0x000000 到 0x0FFFFF 给图片资源0x100000 到 0x1FFFFF 给日志0x200000 到 0x3FFFFF 给升级包暂存。每个模块只允许在分配到的区间里操作任何越界请求直接在驱动层拒绝。还要注意一点外挂 SPI Flash 和内置 Flash 的擦除单位可能要单独确认。有些型号是 4KB 扇区有些是 64KB 块用错擦除粒度很容易把相邻区域也擦掉。驱动开发完做个简单的边界写读测试写完 A 区结尾 4K再写 B 区起始 4K回读检查是否有覆盖这个测试批量跑一遍能省很多现场排查时间。5.4 排查数据串门的观察技巧真的遇到数据串门时不要闷头改代码。先把现场逮捕下来esptool.py --port COM3 read_flash 0x9000 0x4000 nvs_dump.bin esptool.py --port COM3 read_flash 0x210000 0x100000 storage_dump.bin然后对比 dump 出来的数据看看被污染区域的实际内容是什么。如果日志区里出现了另一个模块的键名那基本可以判断是 NVS 命名空间或分区偏移出了问题。如果文件系统的根目录里出现非本模块创建的文件那就要检查文件路径是否越过了约定目录。我自己最常用的一招是给每个小应用的写入操作加上独立日志打印记录“分区名 偏移 长度 CRC 结果”哪块区域被写进去了马上就能在串口日志里看出来。没有日志系统的情况下光靠眼睛盯地址效率会低很多。6. 一个完整示例BLE 采集 Wi-Fi 上传 网页配置共用 4MB Flash6.1 定制分区表假设我的设备有三个功能子模块BLE 数据采集模块、Wi-Fi 数据上传模块、Web 配置模块。三者编译进同一个固件但各自有独立配置和日志。4MB Flash 的分区表我按下面这样设计# name, type, subtype, offset, size nvs, data, nvs, 0x9000, 0x4000, phy_init, data, phy, 0xd000, 0x1000, factory, app, factory, 0x10000, 0x200000, storage, data, spiffs, 0x210000, 0x180000,nvs 分区放设备基础参数。factory 分区放主固件整体二进制控制在 2MB 以内。storage 分区放网页静态资源和日志文件容量 1.5MB 左右。剩余空间作为预留方便后续扩展升级日志或加一个 coredump 分区。6.2 三个小应用的数据隔离设计固件内部代码结构如下BLE 模块只使用nvs_open(ble_app)命名空间存储连接参数和采集周期。Wi-Fi 上传模块只使用nvs_open(wifi_app)命名空间存储服务器地址和上传间隔。Web 配置模块使用nvs_open(web_app)命名空间存储管理员密码和页面主题配置。storage 分区内约定目录/web/ index.html config.js /ble/ sensor_cache.csv /wifi/ upload_queue.txt /log/ ble_log.log wifi_log.log每个模块在初始化时先检查自己目录是否存在不存在就创建。如果有模块需要读取其他模块的数据必须通过统一的接口函数不允许直接 open 对方目录下的文件这样能保证数据访问有迹可循。6.3 升级时的预留我为这个项目保留了两个可选的 OTA 升级路径。如果后续需要 A/B 升级就把 factory 分区改成 ota_0 和 ota_1 两个各占 1MB 的槽storage 分区缩到 1MB。如果不做 A/B 升级可以在升级前把当前配置备份到 storage 分区升级后自动恢复。有一点我特别在意每次发布新固件之前我都会用脚本比对新旧 partitions.csv确认业务数据分区偏移没有变化。只要这一步变过旧数据基本就会全部错位现场设备升级十有八九会丢配置。把这个检查写进发布流程里比在代码里不断打补丁要高效得多。6.4 发布前的检查清单最后分享一个我自己固定在仓库里的检查清单分区表里的所有 offset 和 size 是否满足 4KB 扇区对齐。NVS 命名空间是否跟模块名一一对应。文件系统里是否已经建立/web/、/ble/、/wifi/等独立目录。新固件和旧固件的分区表布局是否一致。固件 bin 文件大小是否小于目标 app 分区大小并留有至少 10% 空间余量。OTA 升级包写入后是否做了 CRC 校验失败时是否允许回滚。烧录时是否只写了目标区域而不是每次整片全擦。是否能通过 dump Flash 快速定位具体写了哪个地址。说实话数据串门这类问题不会在编译期暴露往往要等设备跑起来、数据混进了错误的地方才被发现。我的体会是提前用分区表把边界画清楚再用命名空间、目录、校验头三层保护绝大多数串门坑都能绕开。奉劝一句别觉得手写几个地址宏省事那些宏最后都会变成你凌晨三点的排查噩梦。
返回列表