ARTICLE DETAIL

资讯详情

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

ESP32 Flash分区表:多应用数据隔离与OTA避坑指南

ESP32 Flash分区表:多应用数据隔离与OTA避坑指南 做 ESP32 项目的人十有八九都干过这么一件事功能越堆越多Flash 感觉不够用于是开始手动算地址让几个模块“共用一块 Flash”。结果某天你升级了其中一个模块的固件发现另一个模块的配置数据全被清空了——这还算轻的更惨的是连 bootloader 都被擦掉板子直接变砖。这个问题往专业了说就是 Flash 数据隔离没做好。多个小应用共用 ESP32 的同一块 Flash本质上不是空间挤不挤的问题而是责任边界没划清。乐鑫 ESP-IDF 里其实早就给了标准答案Flash 分区表Partition Table。把这套机制用对每个 APP、每类配置、每段日志都能在自己的地址区间里互不干扰地运行。这篇文章就围绕“数据隔离”这个话题把我在实际项目里用过的分区规划、代码写法和避坑清单一次性讲清楚。适合正在做多功能 ESP32 设备、OTA 升级、内嵌 Web 页面、日志存储的同学参考。1. 问题拆解为什么共用一个 Flash 会“串门”先别急着看方案搞清楚为什么会串数据后面踩坑的概率会小很多。1.1 先弄清 Flash 的工作机制擦除是按扇区来的ESP32 用的 SPI NOR Flash整片空间是一块连续编址的内存上电后 CPU 能直接映射执行。但 Flash 有个很反直觉的特性写入数据前必须先擦除而且擦除的最小单位通常是一个扇区4KB甚至更大的块。这意味着什么呢比如某个模块只想改自己那 100 字节的配置但只要它的擦除范围算大了一圈邻居扇区里的固件或者数据就会被整片抹掉。更麻烦的是Flash 编程操作只能把 1 写成 0如果旧数据还有残留 0写入新数据就会出现脏值所以“先擦后写”是铁的规矩。很多人升级一个 APP 的 bin 之后发现另一个小应用的参数全变了不是两个应用互相有仇而是擦除动作刚好覆盖了邻居的地址区间。这就是不划分边界、手动寻址的典型后果。1.2 “共用 Flash”的典型业务场景这种问题在实际项目里出现的频率比我预想的高得多常见这么几类内嵌 Web 页面 配网参数。这是 ESP32 设备的常见组合网页资源放在文件系统分区里WIFI SSID 和密码放在 NVS 里。如果两个东西堆在同一个数据区网页资源一升级配网参数就没了。多副本固件 OTA 升级。OTA0 和 OTA1 轮替写入由 otadata 分区记录当前引导状态。如果分区规划时没把两个 OTA 副本的空间留够升级一个新固件就可能越界踩到隔壁分区。日志循环记录 校准参数。日志模块需要高频写入校准参数希望稳定不丢。把日志和校准数据放在同一个裸分区里频繁擦写会把校准区域也拖下水甚至加速 Flash 磨损。蓝牙配置工具 主控逻辑。设备上多个“小应用”各自保存自己的状态同一个 NVS 分区里如果不用命名空间分开连 key 都会互相覆盖。这几个场景说明多个小应用共用一个 Flash 是省成本、省面积的自然选择但团队内部必须“分家”。而“分家”的唯一正解就是分区表。2. 正解用分区表给 Flash 画好“座位”分区表听起来是个很抽象的概念其实它就是一个坐标图规定 Flash 里每一块区域叫什么、干嘛用、从哪里开始、有多大。2.1 分区表的基本结构与字段含义ESP-IDF 的分区表是一个 CSV 文件默认叫 partitions.csv每一行代表一个分区。字段就几个Name、Type、SubType、Offset、Size。Name分区名字比如nvs、ota_0、web、logdata。这个名字也是代码里查找分区的依据大小写敏感。Type分区类型主要分app和data两大类。app表示固件镜像data表示运行数据。SubType进一步细分角色。app类型下有factory、ota_0、ota_1等data类型下有nvs、phy、otadata、spiffs、fatfs、undefined等。Offset分区在 Flash 里的起始地址。可以留空让工具自动分配但我建议关键分区都显式写出来方便人眼审查。Size分区大小。这个值必须考虑对齐常规是按 4KB 扇区对齐app 分区最好再预留一些余量避免固件体积膨胀后装不下。官方默认提供的表里bootloader 占 0x10004KB分区表本身放在 0x8000。所以第一个业务分区通常是 0x9000 的 NVS或者 0x10000 的 factory。这块前置区域不动它后面怎么折腾都有兜底。2.2 一份可以直接抄的“多小应用 OTA 内嵌网页 日志”分区表我在不少项目里用的是下面这个模板基于 4MB Flash 设计。如果你的板子是 8MB Flash直接把后面的数据分区放大即可。# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x6000 phy_init, data, phy, 0xf000, 0x1000 otadata, data, ota, 0x10000, 0x2000 ota_0, app, ota_0, 0x12000, 0x1C0000 ota_1, app, ota_1, 0x1D2000, 0x1C0000 web, data, spiffs, 0x392000, 0x40000 logdata, data, undefined, 0x3D2000, 0x2E000几个关键设计点nvs分区固定在 0x9000大小 24KB。放设备序列号、校准参数、配网信息这些键值数据。phy_init是 WiFi/BLE 射频初始化数据一般不用动。otadata是 OTA 状态记录区8KB 够了不能省。ota_0和ota_1各 1.75MB。双副本轮替保证升级过程中至少有一个完整可启动的固件。如果固件很小也可以把两个 OTA 分区缩小把空间让给数据分区。web分区类型是spiffs用来放 Web 网页、字体、证书这类静态资源。logdata用undefined子类型代表“我自己管理的数据区”跑日志循环写入。为什么不把日志直接放spiffs因为 SPIFFS 的实现在断电恢复上还是有点小脾气日志这种高频覆盖的场景我更愿意用裸分区自己在代码里控制写指针。2.3 编译配置与烧录要点分区表写好后要告诉编译系统使用它。菜单路径是Component config → ESP32-specific → Partition Table选 Custom partition table CSV然后填上你的 CSV 文件路径。编译时执行idf.py partition-table会生成partition-table.bin。烧录时idf.py flash会自动把分区表烧到 0x8000。如果你是在用 esptool 手动烧录记得单独烧一次分区表esptool.py write_flash 0x8000 build/partition_table/partition-table.bin想确认 Flash 里当前存的分区表到底是什么样可以用esptool.py read_flash 0x8000 0x1000 pt.bin parttool.py get_partition_info --partition-type app这一步在排查疑难问题时特别有用因为很多人烧录的是旧分区表代码却是新逻辑现象极其迷惑。3. 代码层面读写前先“验明正身”分区表只是画好了座位真正落地还要靠代码规范。否则哪怕分区表写得天衣无缝代码里一个越界写照样把邻居数据冲掉。3.1 用 esp_partition API别碰裸地址ESP-IDF 提供了一套专门的分区操作接口核心思想是你拿到一个esp_partition_t结构体然后所有读写都基于它进行API 内部会做边界检查。常用函数大概是这样的#include esp_partition.h const esp_partition_t *part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, web); if (part NULL) { ESP_LOGE(APP, web partition not found); return; } esp_err_t ret esp_partition_write(part, 0, write_buf, sizeof(write_buf));关键点来了esp_partition_write和esp_partition_read的第二个参数是“分区内部偏移”不是 Flash 全地址。API 会自己加上分区起始地址并且校验offset size不能超过分区大小。一旦超过直接返回错误码。这种设计从底层挡住了越界想串门都难。实际项目里我还见过有人把esp_flash_write和esp_partition_write混着用。esp_flash_*系列操作的是全局物理地址稍不注意就会把老地址当成新分区的内部偏移数据直接写进固件区。牢记应用层代码只要操作 Flash一律走esp_partition_*。3.2 不同模块按 label 认领自己的分区分区表里可以同时存在多个同类分区。如果只用 Type 和 SubType 查找分区拿到的往往是第一个匹配项很容易张冠李戴。我的做法是每个模块在设计阶段就固定一个 label代码里统一通过 label 查分区并且封装成一个小函数const esp_partition_t *get_partition_by_label(const char *label) { esp_partition_iterator_t it esp_partition_find( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, label); if (it NULL) { return NULL; } const esp_partition_t *part esp_partition_get(it); esp_partition_iterator_release(it); return part; }这样 Web 模块永远拿web分区日志模块永远拿logdata分区蓝牙模块拿自己的命名空间。即使以后重新排布分区表只要 label 和大小从逻辑上没有冲突代码基本不用大改。如果多个小应用确实需要共享一组数据最稳妥的方式不是让它们直接读写同一片区域而是定义一个公共的sharedlabel 分区双方只通过约定的结构体指针来访问。每次改结构体都要同步修改版本号否则新老固件混跑很容易把记录头写乱。3.3 NVS 内部的命名空间再隔离分区可以隔离大方向NVS 内部还有更细的隔离粒度命名空间。nvs_handle_t web_handle; nvs_open(webcfg, NVS_READWRITE, web_handle); nvs_set_str(web_handle, mode, auto); nvs_commit(web_handle); nvs_handle_t calib_handle; nvs_open(calib, NVS_READWRITE, calib_handle); nvs_set_i32(calib_handle, offset, 158); nvs_commit(calib_handle);同样是mode这个 key放在webcfg和calib两个 namespace 里互不干扰。这个特性对多个小应用非常适合比如 A 应用存modeautoB 应用也存modemanual只要 namespace 不同就不会踩。但要注意NVS 分区本身只有 24KB放不了大块数据。而且 NVS 虽然做了磨损均衡也不适合做日志这类高频写入。日志、网页这类大块数据还是交给独立分区更合理。4. 完整实操案例多功能设备 OTA 内嵌网页 日志纸上谈兵结束上完整案例。下面这个设备我做过一个实际版本功能包括Web 配网页面、蓝牙小工具配置、OTA 升级、传感器校准、运行日志。全部挤在一块 4MB Flash 上。4.1 先把需求分门别类动手写分区表前我会把设备里所有需要持久化的东西列一个清单再按数据类型归类数据归属模块存储策略分区类型WIFI SSID、密码、设备序列号系统配网键值存储nvs namespace: sys蓝牙配对信息蓝牙工具键值存储nvs namespace: bt传感器校准系数校准模块键值存储nvs namespace: calib内嵌网页、字体、证书Web 界面文件系统spiffs 分区运行日志、告警记录日志模块环形裸分区undefined 分区固件本体OTA 升级双副本ota_0 / ota_1这样分完类分区表就清楚了。最怕的是需求都没想明白就先把分区表填了后面加功能时只能东拆西补很容易把分区弄成一团乱麻。4.2 按 Flash 容量和对齐规则算地址先看板子实际 Flash 大小官方工具可以查esptool.py flash_id然后从 0x0000 开始往下排布。bootloader 占 0x0000~0x1000实际文件烧在 0x1000分区表放 0x8000。我通常把第一个分区 NVS 放在 0x9000因为分区表本身最多也就几百字节0x8000~0x9000 的缝隙足够余量。接着phy_init放 0xf000otadata放 0x10000。之后ota_0从 0x12000 开始给 1.75MBota_1紧随其后。剩下区域按比例切给web和logdata。对齐规则很简单每个分区偏移和大小都必须是 4KB 扇区的整数倍。app 分区从 64KB 对齐的位置开始更稳妥因为一些 Flash 芯片的大块擦除是按 64KB 进行的。理论上不 64KB 对齐也能跑但我不想在量产时去赌这个。最终的分区表就和我 2.2 节给的一样。这套排布在 ESP32、ESP32-S3、ESP32-C3 上跑过稳定。4.3 文件系统挂载与日志分区的环形写实现Web 资源挂载 SPIFFS 的代码是这样esp_vfs_spiffs_conf_t conf { .base_path /web, .partition_label web, .max_files 8, .format_if_mount_failed true, }; esp_vfs_spiffs_register(conf);format_if_mount_failed这个开关要小心。正式产品里如果 mount 失败就直接格式化可能掩盖底层 Flash 损坏的真实问题。开发阶段可以开量产建议开一个标志位记录“已发生格式化”方便远程定位。日志分区我不用文件系统而是自己做一个简单的环形写入。思路也很直白把logdata分成 N 个 4KB 块维护一个写指针当前块写满就擦除最旧的一块再继续写。#define LOG_BLOCK_SIZE 4096 static size_t log_write_pos; // 当前写入偏移 esp_err_t log_append(const uint8_t *data, size_t len) { const esp_partition_t *log_part get_partition_by_label(logdata); size_t remaining log_part-size - log_write_pos; if (len remaining) { // 回到开头并擦除最前面的若干块 esp_partition_erase_range(log_part, 0, LOG_BLOCK_SIZE); log_write_pos 0; } return esp_partition_write(log_part, log_write_pos, data, len); }这里有个实际项目里的细节擦除动作不要放在每次写入时做而是判断“剩余空间不足时”批量擦一段。日志写入通常是一次几十到几百字节如果每写一条日志都去整扇区擦除Flash 寿命会掉得很快。我在项目里是累计到接近块边界才擦。OTA 升级方面使用乐鑫官方的 OTA 流程就够了esp_ota_begin、esp_ota_write、esp_ota_end、esp_ota_set_boot_partition最后重启。前提就是分区表里要有 otadata 和两个 ota 分区否则 API 直接报错。升级完成后旧固件还在另一个分区里躺着可以用它来做回滚。4.4 验证分区表与数据隔离效果代码写完验证起来也不难。烧录编程后idf.py monitor启动第一屏日志里会打印分区表Partition table: XXXX: name: nvs, type: data, subtype: nvs, offset: 0x9000, size: 0x6000 ...如果某个分区没出现优先检查 CSV 的 size 是否足够大、类型是否写错。更精确的方法是烧完固件后把每个分区起始地址的原始内容都用esptool.py read_flash读出来对比写入的数据落在谁的格子内。这种方法在排查“串门”问题的时候能直观定位数据到底写去哪了。我在实测中做过一个验证反复对web分区执行写操作同时在另一个任务里用esp_partition_read监听logdata区域的数据结果两边互不影响。再把ota_1里写入一个新固件重启后也能正常回滚到ota_0。分区隔离的效果在这个阶段就彻底落地了。5. 常见问题与排查技巧实录下面这些坑是我自己和朋友圈子里在多个 ESP32 项目中真实踩过的随手记下来照着排查就能省不少时间。5.1 烧录后不断重启日志循环打卡先看启动日志里打印的分区表如果根本没有自定义分区说明编译系统用的还是默认表。到 menuconfig 里确认 Custom partition table CSV 是不是选了你的文件。还有一种情况是改了CONFIG_PARTITION_TABLE_OFFSET但 bootloader 不认识新位置。一般不建议动这个配置保持默认 0x8000。如果动了升级 bootloader 也要同步。最后烧录完代码必须重新烧分区表。尤其是只执行idf.py app-flash而不烧partition-table时老分区表可能和新固件不匹配表现就是明明代码没问题跑起来却不认当前分区。5.2 数据写进去过一会儿又变回旧值这种“幽灵还原”多数是擦写范围不对。比如你用esp_partition_erase_range时传入了错误的 offset把整个分区擦掉再写入部分数据剩下的区域全是 0xFF读起来就像被清了。还有一次是 SPIFFS 挂载失败触发了format_if_mount_failed格式化后老版本模块重新写入数据把新模块刚刚写入的文件覆盖了。这种多个小应用同时写一个 spiffs 分区的场景我强烈建议在业务层拆开网页数据放读写频率很低的分区日志放另一个分区不要让两个模块同一个分区里打架。5.3 OTA 升级失败、回滚失效OTA 失败最常见的原因不是网络问题而是分区不够大。新固件编译出来 1.8MB你的 ota_0 只有 1.75MBesp_ota_write 到后面直接写不进去。另一个经典问题是ota_0和ota_1大小不一致。有些同学为了省空间把第一个分区搞大、第二个分区搞小结果升级逻辑按较小值校验升级反而频繁失败。我的建议是双 OTA 分区必须等大小没有例外。otadata 分区也检查一下如果它的 size 小于 0x2000后面字节可能写不下 OTA 信息导致每次重启都以为没有有效固件选择回滚或卡死在 bootloader。5.4 分区 label 找不到代码拿 NULL先看启动日志里打印的分区列表里有没有这个 label。有但esp_partition_find_first拿不到十有八九是 Type 或者 SubType 写错了。web分区如果是 spiffssubtype 就是ESP_PARTITION_SUBTYPE_DATA_SPIFFS日志分区用undefined代码里要用ESP_PARTITION_SUBTYPE_ANY而不是ESP_PARTITION_SUBTYPE_DATA_UNDEFINED。另外注意 label 的大小写要完全一致。Web和web在分区查询中是两个完全不同名字查不到不会报编译错误只会返回 NULL。5.5 Flash 磨损与空间局促的预警NOR Flash 的擦写寿命一般是 10 万次级别看起来挺多但一个每秒写一次的日志系统一天就是 86400 次如果没有磨损均衡几周就能把一片 Flash 写崩。这也是我前面坚持日志和普通数据分开的原因。常驻项目里我会加一个简单的告警任务周期读取分区剩余空间比如文件系统统计数据或日志写指针位置低于阈值就在日志里打印警告。不要等到用户报告“设备反复重启”才知道是 Flash 写穿了。6. 我的分区规划习惯最后说几个我自己的固定动作也算是多年踩坑后的条件反射。6.1 三条铁律第一任何 ESP32 项目哪怕只有几个按键控制我也先写一份分区表再动手。多做这 10 分钟后面能少调 100 小时 bug。第二OTA 项目只留ota_0和ota_1不保留factory。有 factory 分区时OTA 无法覆盖出厂镜像一旦出厂镜像有问题回滚逻辑会变得很纠结。第三每次调整 CSV 时同步跑一遍idf.py partition-table确保工具没有因为 size 或 offset 不对而报错。6.2 分区表本身纳入版本管理分区表是一行行能决定产品命运的逻辑但它太容易被忽略。我习惯把 partitions.csv 和sdkconfig一起提交到 git每次 SDK 升级、固件大小有变化时都检查一遍。别看这个动作不起眼它能避免“开发阶段分区表能用量产时固件长大 200KB 就塞不下”的经典事故。数据隔离这件事做的时候感觉像是多写了好几行配置但具备的收益是长期且确定的。把 Flash 分区这件事当成项目的基础设施来对待就不会总被“串门”这种问题拖住开发进度。
返回列表