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

资讯详情

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

ESP32 Flash 分区表详解:多应用共用存储如何避免数据串门

ESP32 Flash 分区表详解:多应用共用存储如何避免数据串门 先说结论多应用共用 ESP32 Flash 这件事核心就是分区表partition table。我在实际项目里见过太多次“数据串门”的排查了现象千奇百怪某个设备重启后 WiFi 配置变成另一台设备的、日志文件里出现乱码、OTA 升级后旧的数据被截断——最后定位下来基本都是大家把同一块 Flash 当成一个大文件系统随手写。这篇文章把分区表的规划思路和实操方式完整拆一遍适合正在做多业务功能整合、或者想搞明白 NVS、OTA、Web 静态资源到底怎么共存的开发者。1. Flash 存储模型这块芯片为什么“不听话”1.1 扇区、块和擦除特性ESP32 的 Flash 用的是 SPI NOR Flash常见容量 4MB 或 16MB。它和你电脑上的 NVMe 硬盘最大的区别在于写数据之前必须先擦除而擦除的最小单位是扇区通常 4KB。你不可能像改文件一样只改一个字节想要修改某个区域里的 10 个字节就得把这个扇区里其他内容先读出来整个擦除再原样写回去。这个特性直接决定了如果两个小应用共用同一个扇区哪怕它们逻辑上“各管各的地址”只要有一个应用做了整扇区擦除另外一个写在同扇区的数据就全没了。这不是代码 bug是 Flash 物理特性和软件设计冲突的结果。所以所有 ESP32 的存储方案包括官方 NVS、自定义数据分区、OTA App 分区都在软件层面对“擦除粒度”做了隔离。另外要注意NOR Flash 还有抹除次数限制。虽然 ESP32 用的芯片一般在 10 万次擦写以上但如果你把高频日志写到和系统配置同一个扇区日志跑几天就可能把配置扇区“磨”到寿命极限整个设备开始出现随机重启。这也是很多人忽略的地方。1.2 数据“串门”的三种典型表现我归纳一下实际踩坑后最常见的三种串门方式配置覆盖应用 A 通过 NVS 存储 API 写了一批 key应用 B 也在同一个 NVS 分区里写了一批 key。你以为两个应用之间互不知道但只要 key 相同就会互相覆盖。更隐蔽的是NVS 分区一旦某次崩溃后 handle 失效系统自动重初始化A 的配置就清零了。地址越界自建的数据存储文件比如把传感器历史数据顺序写到 Flash 的 0x300000 地址结果跑了三个月地址越过了分区的边界写到下一个应用的分区数据区里轻则覆盖,重则直接导致下个应用启动校验失败。日志和固件互相踩常见于 OTA 升级场景下载的新固件写到 A 区结果日志数据缓存区也放在附近擦除日志时把固件区擦了升级包校验必然失败。这三个表现看似不同根源就一个缺少明确的分区边界和访问控制。1.3 NVS 和 Preferences 都是“分区公民”在 ESP32 的软件体系里NVSNon-Volatile Storage不是一块神秘的内存它本质上就是一个特殊格式的数据分区。Arduino 环境下用 Preferences 库底层最终调用的是 NVS APIESP-IDF 工程里的 nvs_flash_init() 也是操作同一个数据分区。默认 partition table 里会有一个 nvs 分区、一个 phy_init 分区、一个 factory 分区。很多人会问我的 Arduino 项目没有手动配置过分区表为什么也能存配置、OTA因为 ESP32 Arduino 在烧录时会自动烧入一份默认分区表。默认表里只有 nvs、otadata、app0、app1。如果你的项目需要多个小应用、多个功能模块独立存取数据就必须自己设计分区表。这一点在 Arduino 里最容易被忽视因为 IDE 没那么直观默认配置不报错就不管了。2. 分区表把 Flash 切成独立房间2.1 分区表是什么分区表是一段烧录在 Flash 中固定位置的元数据。ESP32 启动时Bootloader 会按照这个表来确定每个分区的名称、类型、偏移量和大小。表本身会被 sdmmc 或者其他外设驱动忽略但 bootloader 和 App 都会用它。在 ESP-IDF 里分区表默认由工程里的 partitions.csv 文件生成也可以直接用一个二进制分区表。Arduino 环境同样支持 CSV在菜单 Tools 里可以选不同分区方案或自己指定 CSV 文件。配置分区表的入口ESP-IDF 下菜单 Component config - Partition Table 可以选择“Single factory app (large)”“Factory app, two OTA definitions”等预置方案或者选 custom 指定 CSV 路径。Arduino-ESP32 下Tools - Partition Scheme 选择比如“HUGE APP”“No OTA (Large APP)”等也可以自定义 CSV 并放到 tools 菜单目录里但通常建议直接用 IDF 方式管理。分区表的固定结构每个分区一行字段包括 name、type、subtype、offset、size 和 flags。2.2 CSV 字段逐个拆解先用一个实际的 CSV 片段说明# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x300000,name分区名代码里通过 esp_partition_find_first 查分区时用这个名字。typeapp 或 data。app 表示存放固件data 表示数据区。subtype进一步细分类型。app 有 factory、ota_0、ota_1 等data 有 nvs、ota、phy、spiffs、fatfs 等。offset分区在 Flash 中的起始地址偏移必须按 4KB 对齐。size分区大小二进制字节数通常按扇区对齐。flags只读、可加密等属性一般留空。注意otadata 分区不是“OTA 下载的数据”而是存放当前启动哪个 slot 的标记数据。很多人第一次接触分区表时会把 otadata 理解成“固件内容存储区”这就错得离谱。2.3 多个小应用的分区规划原则在设计多个小应用的分区方案前先问三个问题需要几个固件镜像真正可启动的 app 分区至少要留一个。有没有 OTA 需求有 OTA 就要额外划分一个或者两个 slot每个 slot 大小要能容纳最大固件。每种数据分区需要多大空间WiFi 配置、蓝牙绑定信息通常只需要几十 KB日志可能需要几百 KBWeb 静态资源、证书、算法模型可能需要按 MB 算。常见错误是“随便拍一个偏移量”。最稳妥的做法是让每个分区都从地址 0x10000 开始手动规划并预留对齐空间。一个典型的多小应用分区方案比如 4MB Flash分区名类型/子类型偏移大小用途nvsdata / nvs0x90000x4000系统配置、WiFi 配置、各小应用自己的参数otadatadata / ota0xd0000x2000OTA 启动标记phy_initdata / phy0xf0000x1000射频校准数据factoryapp / factory0x100000x1C0000主固件第一版业务webdata / spiffs0x1D00000x100000Web 页面、静态资源logdata / fatfs0x2D00000x100000日志、历史数据ota_0app / ota_00x3D00000x1C0000OTA 升级备分区这种方案把“小应用”拆成一个可执行固件factory/ota_0 多个数据资源分区。每个数据分区都是独立地址空间各自通过 API 操作谁也不会越界。3. 实操一个真实的“多小应用共用 Flash”分区方案3.1 场景背景假设我在做一个智能网关主逻辑负责 Modbus 采集蓝牙模块负责本地配网内嵌 Web 服务器显示状态和历史曲线还要跑 OTA 升级。这个项目如果只用一个分区蓝牙配网时把 WiFi 配置写进 NVSWeb 模块又把日志写进一个“data.bin”一段时间后NVS 由于高频日志擦写损坏Web 资源文件被覆盖设备变成一个只会启动、不能联网的砖头。我的规划目标蓝牙配网数据单独存主业务参数用一个 NVS 分区Web 静态资源独立分区日志独立分区预留一个 OTA slot。3.2 分区表设计实际项目我用的分区表如下4MB Flash# 4MB Flash, multi-app demo nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x180000, bluetooth, data, nvs, 0x190000, 0x4000, web, data, spiffs, 0x194000, 0x100000, log, data, fatfs, 0x294000, 0x80000, ota_0, app, ota_0, 0x314000, 0x180000,这里我自己额外定义了一个 bluetooth 数据分区子类型也是 nvs。注意不要试图把两个 NVS 分区都叫 nvs 名字因为 esp_partition_find_first 会按 name 查同名会出问题。我把蓝牙参数分区命名为 bluetooth但它本质上还是一个 NVS 格式分区。使用 NVS API 时要指定分区标签nvs_flash_init_partition(bluetooth);这样蓝牙模块产生的绑定信息和主业务配置完全隔离。对于 web 分区由于里面是静态 html、js、css 文件我用 spiffs 类型。ESP-IDF 4.4 以后 spiffs 仍然被支持但注意部分版本已经默认推荐使用 esp_vfs_spiffs 封装。你也可以用 littlefs类型依旧是 data / spiffs 或自定义。重点是所有数据分区操作都通过分区 API而不是自己计算绝对地址。3.3 生成、编译与烧录确认ESP-IDF 工程里确认idf.py menuconfig里的 Partition Table 选项设置为自定义 CSV分区表生成命令idf.py partition-table查看生成结果idf.py partition-table --help烧录时Bootloader、分区表、App 分别有独立 binidf.py flash如果只用 esptool.py 手工烧录需要分别指定esptool.py --port COM10 write_flash 0x1000 build/bootloader.bin 0x8000 build/partition-table.bin 0x10000 build/your_app.bin强烈建议新人在第一次定义自定义分区表后先运行idf.py flash monitor看启动日志里的 Partition Table 输出。启动日志里会打印出每个分区的名字和范围这样你可以第一时间发现 offset 或者 size 写错。3.4 分区 API按名字读写数据不碰绝对地址在应用代码里我坚持一个原则不用绝对地址访问 Flash全部通过分区 API 获取地址和长度。尤其是在多小应用环境下任何人硬编码像0x200000这种地址都等于制造定时炸弹。ESP-IDF 下的核心 API 用法#include esp_partition.h const esp_partition_t *partition esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, web); if (partition NULL) { ESP_LOGE(APP, web partition not found); return; } // 读取 web 分区开头 16 字节 uint8_t header[16]; esp_partition_read(partition, 0, header, sizeof(header)); // 擦除并从偏移 0x100 写入一块内容每次写之前最好按扇区擦除 esp_partition_erase_range(partition, 0, partition-size); esp_partition_write(partition, 0x100, header, sizeof(header));Arduino 用户如果想要跨分区操作可以直接用 SPIFFS/LittleFS 的对象指定分区名// 打开指定分区 if (!SPIFFS.begin(true, /spiffs, 0, web)) { Serial.println(Mount web partition failed); }3.5 防止“串门”的四个硬性习惯分区表设计好后代码层面的隔离同样关键。我从项目里总结出四个习惯建议直接抄每个数据分区在代码初始化阶段先检查分区大小和剩余空间不要假设 Flash 永远够用。所有写操作用esp_partition_write而非直接memcpy到某个映射地址。NVS 分区只存小数据高频写入数据一律走日志分区。固件内保存一个魔法数magic number和版本号每次读取分区数据先校验防止读到半截旧数据。其中最容易被忽视的是“写操作中途掉电”。如果某分区正在擦除设备突然断电这个分区可能处于未定义状态。数据分区可以通过在开头写入一个“本轮事务状态”字段启动时判断是否需要回滚。应用数据分区用 SPIFFS/LittleFS 时文件系统自身有日志机制崩溃后相对安全NVS 分区也有双页保护。所以高可靠性项目尽量别用裸地址写数据直接使用文件系统或 NVS。4. 多个 app 共存与 OTA 到底是“一套房还是两套房”4.1 OTA 双 slot 的意义传统理解的“多个小应用共用一块 Flash”如果是想让设备上同时跑两个独立固件并能在运行时自由切换那要泼一盆冷水ESP32 的分区表机制不支持多个 app 分区同时运行。CPU 在同一时刻只能从某个映射区域执行代码而 app 分区在启动时由 bootloader 选择。除非自己做很复杂的二次 bootloader否则所谓“多应用共存”其实就是一个主固件 多块数据分区。OV 场景中真正的双槽位是指 ota_0 和 ota_1 两个 app 分区。bootloader 根据 otadata 分区里的标记决定启动哪一个。这个双槽设计是为了升级安全而不是为了同时运行。你在系统里只能看到当前活跃 app 分区对应的代码。设计分区时每一个 app slot 大小要足够容纳最大固件。如果主固件已经占 1.5MB你却给 ota_0 留了 1MB升级必然失败而且失败后 bootloader 会回退到 factory这不是“数据串门”但也是最常见的 Flash 分区配置错误之一。4.2 标准多小应用架构的正确姿势如果你确实有蓝牙配置、Web Server、传感器记录等“多个小应用”模块架构上应该这样设计固件镜像只有一个按需编译宏裁剪功能开关每个功能模块的数据落点不同分区模块之间通过 NVS/文件系统共享配置但通过分区命名隔离 namespace共享数据如果要跨分区读用现成协议JSON、CBOR不要拼接地址访问。很多人听到“共用一块 Flash”就以为要做“每个 app 一个独立固件”实际上这既增加 bootloader 复杂度也让升级和调试变得困难。我在一个语音识别网关项目里最初拆了 3 个固件主控、语音前端、配置后台。结果每次版本联动升级都要同时烧 3 个镜像。后来我把语音前端模型和配置页面放进数据分区用一个固件加载直接把维护成本砍掉一半。4.3 “OTA 升级到另一个 app”的常见误区还有一个小众但必须提醒的情况当你做 OTA 下载固件时下载的数据会被写入 ota_0 或 ota_1 分区。如果这个分区和某个数据分区重叠轻则固件写不进去重则把另一个应用的数据清空。所以检查 OTA 升级失败时先看分区日志里写入目标地址是否符合分区表定义。排查方法很简单在esp_ota_begin()前后打印目标分区信息const esp_partition_t *update_partition esp_ota_get_next_update_partition(NULL); ESP_LOGI(OTA, Writing to partition subtype %d at offset 0x%x, update_partition-subtype, update_partition-address);如果打印的地址落在了数据分区的范围说明分区表里 app 和 data 重叠了。5. 常见问题与排查记录5.1 烧录后启动失败找不到 nvs 分区现象烧录新固件后日志提示nvs_flash_init failed或者partition nvs not found。原因自定义分区表里 nvs 分区类型或者名称写错了最常见的是把 nvs 写成了data / spiffs。更诡异的情况是手动烧录时分区表 bin 没烧进去只烧了 app导致 bootloader 按默认分区表找 NVS而你的 app 又按自定义分区名找。查启动日志先确认日志里展示的当前分区表是不是你想要的。5.2 蓝牙配置和 WiFi 配置互相覆盖现象配网流程一切正常但是配网后设备重启WiFi 密码变成了别的设备配置。原因如果蓝牙和 WiFi 都用了默认 NVS 分区而且双方使用同样的 key我见过很多工程直接用相同的 namespace key就会互相覆盖。解决方法是隔离分区或者一个分区里用不同的 NVS namespacenvs_handle_t my_handle; nvs_open(bt_bind, NVS_READWRITE, my_handle);但更稳妥的是把蓝牙配置放到独立分区。注意ESP-IDF 里nvs_flash_init()默认初始化名字为 “nvs” 的分区其他分区要单独调用对应函数。5.3 日志分区突然全是 0xFF现象设备跑一段时间后log 分区里读取的数据出现大片 0xFF历史曲线缺失。原因大概率是日志分区被外部擦除了或者自己代码里擦除范围超出了分区边界。我喜欢在生产代码里做双重保护写日志前先判断start size partition-size并且启动时读分区开头标志判断是否完整。5.4 分区表 offset 不按 4KB 对齐ESP32 分区表要求 offset 对齐到 4KB否则 bootloader 可能直接报错或忽略分区。手动编辑 CSV 时用十六进制写 offset 比较直观避免十进制换算错位。5.5 app 分区过大导致空间不够调试时经常会想到反正 Flash 有 4MB我把 app 分区设成 3MBWeb 分区 500KB日志 500KB。等要加 OTA 时发现没有第二个 slot 的位置。所以分区规划要预留扩展口别把整个 Flash 份额全分完。至少留出 4KB 对齐的空白区域方便后续调整。6. 个人实操心得分区是设计问题不只是配置问题说了这么多最后分享几个我自己的习惯算不上标准流程但确实让我少踩很多坑。第一每次改完分区表我不急着写功能代码先写一个分区自检例程。例程启动后把每个分区的名称、类型、偏移、大小打印出来然后往各写一个魔法数字再读回来。这一步能在一分钟内发现 90% 的地址冲突问题。第二我习惯把自定义分区表纳入 git 管理并且每次改动版本都打 tag。分区表出错往往不是立刻爆发的可能是三个月后的某次 OTA 才出现。没有版本记录排查起来犹如大海捞针。第三对于日志和高频数据我尽量使用支持掉电保护的文件系统而不是裸分区读写。裸分区虽然性能高但多应用共用 Flash 时一次越界擦除的后果几乎不可恢复。第四不要把“多个小应用”理解成“多个固件”。真正的模块化应该是分区隔离、接口解耦、单一固件。这个思路在资源受限的嵌入式设备上既简单又可靠。如果你正在做类似项目建议直接从一个最小分区表开始跑通 NVS/SPIFFS/OTA 三个模块后再扩展数据分区。这样无论 Flash 最终怎么分每个模块的访问边界都清清楚楚。
返回列表