ARTICLE DETAIL

资讯详情

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

ESP32 多应用共用 Flash 防数据串门:分区表规划全指南

ESP32 多应用共用 Flash 防数据串门:分区表规划全指南 先说个我最近调试的真实场景一台 ESP32 设备上同时跑了主控逻辑、Web 配网页面、运行日志记录还带了 OTA 升级。一开始觉得 Flash 空间又不紧张随手分了几个区结果一次升级之后 NVS 里校好的配置全丢日志里还冒出来一堆根本不该出现的网页文件碎片。查了两天才定位到根因——多个小应用共用同一颗 Flash分区规划不严谨数据串门了。如果你也正在做类似的多功能 ESP32 项目或者打算给自己的固件加日志、Web、OTA 这些模块那这篇内容基本能帮你在动工之前把“数据隔离”这个地基打牢。下面我用实际踩坑记录的方式把分区表原理、代码层约束、OTA 和日志这些重灾区一条一条拆开讲。1. 先搞清楚 ESP32 的 Flash 到底藏了什么秘密1.1 Flash 硬件不是你眼里的“一个大数组”很多人第一次用 Flash 时都把它想成一个超大的 byte 数组往哪个地址写数据都行。实际上 ESP32 用的 SPI NOR Flash 有几个非常反直觉的物理特性。第一写入之前必须先擦除。Flash 只能把 bit 从 1 变成 0想从 0 变回 1必须按扇区擦除。ESP32 的 Flash 扇区一般是 4KB也就是说你哪怕只改一个字节也得先把整个 4KB 擦一遍。第二擦写寿命有限。常见的 Winbond、Macronix、GigaDevice 这些 NOR Flash扇区擦除寿命通常在 10 万次左右。第三读取通过 cache 映射和 SPI 通路不是简单 memcpy。你能用 memcpy 去读 mapped flash 地址但绝不能对着 mapped 地址直接写必须走 SPI 控制器和驱动接口。所以“多个小应用共用一块 Flash”的问题本质上是一块物理介质容量有限、擦写单位固定、寿命有限却被好几个逻辑模块在共享。如果每个模块都随意指定地址去写数据轻则互相覆盖重则把文件系统元数据写坏表现成升级后配置丢失、日志乱码、Web 页面打不开。1.2 为什么分区表是数据串门的唯一裁判解决上面问题靠的不是某个高深的机制而是 ESP32 固件里那张分区表。分区表本质是一份记录表给每个功能模块划定了“谁可以住在哪个地址段”。它默认烧录在 Flash 偏移0x8000的位置bootloader 启动时会读取它然后按表项把每个区域暴露给上层 API。分区表里每条记录有名字、类型、子类型、偏移、大小。比如nvs负责存配置factory或ota_0负责放主固件web专门存网页文件logs专门存日志。上层代码通过esp_partition_find_first这种 API 找到对应分区拿到esp_partition_t结构体再对这个结构体做读写。也就是说真实项目中应该由分区表分配地址而不是业务代码自己拍脑袋定地址。不过我要强调一点ESP32 本身没有硬件级的内存隔离来阻止一个应用读另一个分区的内容。同一块 Flash、同一个地址空间任务 A 的代码是完全有能力直接用指针去读任务 B 所在地址的。所以分区表只是“协议”不是“保险箱”。数据不会串门最终靠的是约定 API 约束 大量自查代码。这也正是本文想讲透的核心。1.3 一张表看懂常见分区怎么分工下面我用一个 4MB Flash 的多应用设备做示例把每个分区规划出来。4MB 在生活中非常常见像 ESP32 DevKitC 模组一般就是 4MB。分区名类型子类型作用需要多大nvsdatanvs保存 Wi-Fi 配置、用户参数、校准值24KB 左右phy_initdataphy射频校准数据出厂固件通常要留4KBotadatadataota记录当前从哪个 OTA 分区启动8KBota_0appota_0主固件 A 副本1.5MBota_1appota_1主固件 B 副本作为升级目标1.5MBwebdataspiffs小应用的 Web 页面资源448KBlogsdatafat运行日志、设备状态记录448KB这里我把主固件拆成双 OTA 副本而不是用factory单分区主要原因是设备需要在线升级。双 OTA 副本的好处是升级失败还能回滚代价是占用空间大。如果你只有 4MB Flash 且固件体积很大那就需要仔细权衡甚至用压缩资源方案。这个问题后面专门讲。2. 动手规划分区表4MB Flash 是这样给多个应用分家的2.1 手写 CSV 分区表的正确姿势乐鑫官方在 ESP-IDF 里用 CSV 文件描述分区表。Arduino-ESP32 底层也是这套规则只是平时通过 Tools 菜单里的 Partition Scheme 选预设方案。自己写时我习惯在partitions.csv里把所有分区按起始地址从小到大排列防止看起来混乱。下面这份是配合上面那张表写的完整示例# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, otadata, data, ota, 0x10000, 0x2000, ota_0, app, ota_0, 0x20000, 0x180000, ota_1, app, ota_1, 0x1a0000, 0x180000, web, data, spiffs, 0x320000, 0x70000, logs, data, fat, 0x390000, 0x70000,注意每一行的 Offset 和 Size 都是十六进制。做完之后我用idf.py partition-table让它生成二进制分区表再用idf.py flash partition-table烧进去。如果项目用 Arduino也可以直接在partitions.csv里改然后在编译选项里指向这个文件。这里有几个细节必须划重点nvs偏移0x9000和phy_init偏移0xf000是 ESP-IDF 默认习惯不要乱动因为出厂依赖这些默认位置。app 类型的分区必须按 0x10000 对齐也就是 64KB 的倍数。数据分区一般按 4KB 对齐就行但为了减少心智负担我也尽量让它对齐到 0x10000。最后一个分区logs的 Size 是0x70000偏移加大小刚好等于0x400000也就是 4MB Flash 的总容量。任何一行如果Offset Size超过0x400000编译时大概率会报错但保险起见还是要自己算一遍。2.2 多应用场景下分区大小和偏移怎么算我在项目里给每个小应用留空间时不是靠感觉而是先做两项计算这个模块最大会用到多少数据以及这个模块的写入频率是多少比如 Web 配网页面我的页面压缩后约 300KB加上在线升级临时文件之类预留 448KB 较稳。日志模块按每 5 分钟写一条 128 字节算一天约 36KB环形覆盖的话保留一周大概 252KB所以 448KB 也合适。计算这些的目的是避免“空间不够时去偷邻居的地址”这是数据串门最常见的动机。计算偏移时我通常从最后一时就按上表写完然后单独拿出来验证一遍0x9000 0x6000 0xF000 - phy_init 起始 OK 0xF000 0x1000 0x10000 - otadata 起始 OK 0x10000 0x2000 0x12000 - 留出空隙到 ota_0 (0x20000) OK 0x20000 0x180000 0x1A0000 - ota_1 起始 OK 0x1A0000 0x180000 0x320000 - web 起始 OK 0x320000 0x70000 0x390000 - logs 起始 OK 0x390000 0x70000 0x400000 - 到达 Flash 末尾 OK这种“起始地址首尾相接”的检查方式虽然老土但我用这么多年几乎没漏过事。2.3 分区表改动的“高压线”一旦产品发出去、设备用起来分区表最好就当成“不可变契约”。尤其要注意下面几条不要随随便便改nvs分区的偏移和大小。NVS 分区里保存着 Wi-Fi 密码、设备校准值、用户配置这些数据都在固定偏移上。你挪了nvs的位置旧数据不会跟着搬走结果就是升级后一片空白看起来像“被串门抹掉”。不要在 OTA 固件里偷偷换分区表。OTA 升级只负责写app分区不会自动更新otadata的引导记录。如果你在升级流程里额外烧了新分区表而新表和老表不一致那重启后 bootloader 可能去读一个不存在或位置变了的分区整个设备变成砖。分区表改动之前先用esptool.py read_flash把整片 Flash 备份。哪怕只是挪了某个data分区的偏移也尽量写一个迁移代码在旧偏移读取数据再写入新分区偏移。我一般的做法是固件里保留一个硬编码“旧分区布局”兼容表仅用于一次性迁移迁移完成后标记完成位后续不再读旧偏移。3. 代码层隔离让每个小应用都只认自己的那一亩三分地3.1 必须走 esp_partition API而不是绝对地址最高频的“串门”原因是有人在业务代码里直接写了0x290000这类魔法地址。我问过几个同事他们当时的想法是“Flash 那么大随便找个位置存数据应该没问题吧”。问题大了去了——你这次写没问题下次写时可能就覆盖了别人正在用的文件元数据。正确的做法是永远通过分区表 API 找分区再操作分区。下面我给你一段可以“抄作业”的示例代码用于找到名为logs的 data 分区并写入日志#include esp_partition.h const esp_partition_t *part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, logs); if (part NULL) { ESP_LOGE(TAG, logs partition not found); return; } // 不要直接写 0x390000 这种地址 // 以 logs 分区的相对偏移 0 作为起点 esp_err_t err esp_partition_write(part, 0, log_buf, len); if (err ! ESP_OK) { ESP_LOGE(TAG, write logs failed: %s, esp_err_to_name(err)); }这里有几个容易踩的细节所有读写都使用“分区内相对偏移”不要自己叠加绝对地址。API 内部会帮你把分区起始地址和偏移换算成真正的 Flash 地址。写入前如果目标区域此前有旧数据通常要先esp_partition_erase_range否则结果可能不对。esp_partition 的 write 接口不像普通文件系统一样自动处理擦除。每个esp_partition操作本身不是线程安全的。如果你有多个 RTOS task 同时往同一个分区写日志一定要用互斥锁包起来。不然你 erasing 一段时另一个 task 正在同一个分区的相邻扇区写数据虽然不一定越界但并发访问扇区会让驱动内部状态混乱。3.2 每个应用一个 NVS 命名空间轻量配置不打架对于用户配置、设备参数这类小键值NVS 是首选。NVS 本身已经做了磨损均衡并且把不同键值存到不同位置省心很多。同一个 NVS 分区里可以通过不同的命名空间namespace做逻辑隔离。比如主控制逻辑用app_main这个命名空间Web 配网模块用app_webnvs_handle_t h; esp_err_t err; err nvs_open(app_main, NVS_READWRITE, h); if (err ESP_OK) { nvs_set_i32(h, baudrate, 115200); nvs_commit(h); nvs_close(h); } err nvs_open(app_web, NVS_READWRITE, h); if (err ESP_OK) { nvs_get_str(h, ssid, buf, len); nvs_close(h); }不过我把话说清楚NVS 的命名空间只是软件约定不是安全墙。任何代码只要知道app_main这个名字同样能调nvs_open(app_main)去读里面的值。对多应用共用 Flash 来说这种隔离已经足够防止“手滑串门”但如果你的应用之间需要真正的互不可见那得引入更复杂的安全机制而不能指望 NVS。NVS 还有几个注意点单键长度有限默认最大可以存 4096 字节实际上 NVS 对单个 value 有大小限制超过一定值建议另建分区。NVS 写操作尽量放在后台不要在关键中断里做否则 Flash 擦除时间可能拖垮实时任务。3.3 做文件系统缓存或日志时如何安全使用 SPIFFS / LittleFS / FATWeb 页面、证书、日志这类偏大块的数据我一般用文件系统分区。ESP-IDF 里常见的有 SPIFFS、LittleFS 和 FAT。现在新项目我优先选 LittleFS因为掉电稳定性比 SPIFFS 好也有磨损均衡和坏块管理。挂载时千万要绑定partition_label而不是用默认分区。下面是一段典型挂载代码esp_vfs_spiffs_conf_t conf { .base_path /web, .partition_label web, .max_files 8, .format_if_mount_failed false, }; esp_err_t err esp_vfs_spiffs_register(conf); if (err ! ESP_OK) { ESP_LOGE(TAG, Failed to mount /web); }如果多个小应用都使用文件系统必须给它们各自独立的partition_label和base_path。我见过最经典的翻车现场Web 模块和日志模块都挂到/spiffs结果日志写入时把网页 index.html 覆盖了用户界面直接崩。实际上两个模块根本没写错地址只是挂在同一个挂载点文件系统层已经把彼此当成同一份存储目录来用了。这里我要单独骂一句format_if_mount_failed true这个配置开发期为了方便挂载失败自动格式化可以让你快速恢复测试板但它是真能掩盖问题的。有一次我把web分区的偏移算错了挂载总是失败这个配置直接格式化表面上“正常了”实际上数据已经彻底没了。所以我建议调试期也要关掉自动格式化错误就让它错误才好追根因。3.4 给关键分区加 readonly / encrypted 标志分区表 CSV 最后一列是可以填 Flags 的。我常用两个readonly表示这个分区通过 esp_partition API 只能读不能写。如果想把 Web 静态资源锁死防止某个 task 误写可以这样写web, data, spiffs, 0x320000, 0x70000, readonly加了 readonly 之后esp_partition_write会返回ESP_ERR_NOT_ALLOWED。这不能阻止你绕过 API 直接操作物理地址但至少能在代码评审和运行期快速暴露违规调用。encrypted配合 Flash Encryption 功能给nvs这种敏感分区加密。加了 encrypted 后分区数据在物理 Flash 上是密文。注意它保护的其实是“别人拆机读 Flash 也看不懂”而不是防止应用 A 通过 API 去读应用 B。应用之间的逻辑隔离还是得靠分区名和命名空间。我自己的项目里Web 页面这类只读资源加了 readonly用户配置文件和校准数据所在的分区在启用加密时加了 encrypted双保险。4. OTA 升级和日志记录两个重灾区要单独处理4.1 OTA 升级分区怎么安排才不会把别人覆盖OTA 是所有“数据串门”事故里最折磨人的一块。很多人以为升级就是把新固件整个覆盖到 Flash实际上 ESP32 的 OTA 流程非常讲究。如果你的设备需要 OTA分区表里必须有otadata和至少两个 app 分区。以第 2 节示例为例ota_0和ota_1各 1.5MB分别用来放当前固件和待升级固件。升级时代码调用esp_ota_get_next_update_partition(NULL)拿到当前没有运行的那个 app 分区然后往里面写新固件。写完之后把 boot 分区改成新固件再重启。整个过程中它根本不会碰web或logs分区。典型升级代码const esp_partition_t *target esp_ota_get_next_update_partition(NULL); esp_ota_handle_t ota_handle; esp_ota_begin(target, OTA_SIZE_UNKNOWN, ota_handle); // 从网络、SD 或串口分批获取固件数据 esp_ota_write(ota_handle, image_buf, bytes_read); esp_ota_end(ota_handle); esp_ota_set_boot_partition(target); esp_restart();这里面有个隐藏陷阱OTA 写入的 app 分区一旦小于整个分区剩余区域仍可能是旧数据。esp_ota_begin会做好必要清理但我不建议在 app 分区里额外放业务数据。之前遇到一个项目为了省空间把配置塞在ota_0分区的尾巴上一次升级把这个“尾巴”擦没了配置自然丢了。正确的做法是业务数据一律放 data 分区app 分区就老老实实只放固件镜像。4.2 日志频繁擦写如何选型与防止磨损越界日志模块是所有分区里写入最频繁的。如果直接把日志写进 SPIFFS频繁擦写会很快磨损 Flash。我现在更倾向于给日志规划一个独立的裸分区自己做环形写入或者用 FAT wear levelling 组件。裸分区环形日志的核心思路是维护一个写指针写满一个 4KB 扇区后擦除下一个扇区继续写。这样不会把数据写到分区外面去。关键代码如下#define LOG_SECTOR_SIZE 4096 uint32_t write_pos get_log_current_pos(); // 写入前检查是否会越过分区末尾 if (write_pos data_len logs_partition-size) { write_pos 0; // 环形回卷 } esp_err_t err esp_partition_write(logs_partition, write_pos, buf, data_len); if (err ESP_OK) { write_pos data_len; if (write_pos LOG_SECTOR_SIZE logs_partition-size) { esp_partition_erase_range(logs_partition, 0, LOG_SECTOR_SIZE); } save_log_current_pos(write_pos); }实际生产里还要处理一个问题如果data_len很长一次写入横跨扇区边界而边界后面是未擦除区域写进去会变成脏数据。所以我在每次写入前都保证“从当前写指针到下一个 4KB 边界”足够装下整个日志条目不够就先擦除下个扇区再写。日志模块开发时多花这点心思能省掉很多夜里被电话叫醒的机会。用 FAT wear levelling 的好处是你可以直接fopen(/logs/data.log, a)利用标准文件系统 API。坏处是 FAT 本身也有元数据写入掉电时可能会丢日志文件索引。看你的项目对日志可靠性的要求来选。4.3 OTA 后配置丢失的常见原因经验升级后配置丢不一定是串门但十有八九和分区规划有关。最常见的情况是出厂时用的是默认分区表后来为了加 ota 或日志改了nvs分区的偏移然后把新分区表一起烧了上去。旧nvs数据还在原来的偏移上躺着新固件启动时从新偏移读读到的全 0xFF于是表现为“恢复出厂设置”。我的经验是设备一旦量产nvs和phy_init的偏移就焊死了。要加新数据分区优先从 Flash 剩余空间里去扣不要挪已经存在的分区。如果确实需要扩大nvs那必须先备份旧nvs.bin在升级流程里做一个数据迁移 step用特殊启动标志让升级后的程序进入迁移模式。从旧偏移读旧 NVS 数据。擦除新 NVS 分区。把数据写入新 NVS 分区。清除启动标志正常启动。这么干非常花时间而且一旦迁移程序有 bug用户配置还是保不住。所以我的建议简单粗暴分区表从第一版就规划好之后除非产品形态大改否则别动。5. 问题排查实录当数据真的“串门”了怎么办5.1 升级后 NVS 配置全部丢失一个很典型的翻车现场我有个项目主控逻辑和 Web 配网模块做了 OTA 之后所有 Wi-Fi 配置都没了。第一反应是“升级把 NVS 擦掉了”后来用parttool.py读 Flash 才发现 NVS 分区数据根本没丢只是新分区表的nvs偏移从0x9000挪到了0x11000旧数据不在读取路径上了。排查步骤大概是先把当前分区表读出来idf.py partition-table或者直接用parttool.py读取分区表二进制再解析。对比旧版分区表和当前烧录的分区表重点看nvs的 Offset 变了没有。用parttool.py read_partition --partition-namenvs --output nvs_backup.bin读出现有 NVS 备份。如果读出来内容全是0xFF说明这个偏移上从没有被写过数据否则再用nvs_*工具解析内容。这种问题靠“改代码”解决不了必须从分区表管理上下手。所以从那以后我强制自己把“分区表定版”当成一个正式里程碑和代码 release 同等重视。5.2 日志文件写到了 Web 文件系统里排查全过程有一次设备日志模块初始化失败但系统还继续跑过一阵子用户发现 Web 配置页面无法打开。我抓 log 时看到日志模块返回ESP_ERR_NOT_FOUND查了下partition label 填错了。当时日志模块和 Web 模块都在初始化里用了同一个 labelweb导致两个实例都挂载到了同一个 SPIFFS 分区。日志模块以为自己在写logs其实打开的是web文件系统于是把网页资源目录塞满了.log文件。修复很简单把 label 改成各自分区名就行。但我想提醒大家这种“软串门”特别隐蔽因为地址完全没错错的是逻辑层绑定。排查时不要只盯着 Flash 地址也要看挂载配置、路径前缀。我用了一个土办法初始化成功后打印每个文件系统根路径下的容量和剩余空间再写一个测试文件再删掉确认能落盘的地方确实符合预期。5.3 用 esptool.py 和 parttool.py 给 Flash 做“CT 检查”当你怀疑 Flash 数据串门时最好先把整颗 Flash 影像备份下来再分析。直接erase_flash是最差的选择因为现场证据全没了。常用命令# 读取整片 4MB Flash 做备份 python3 esptool.py -p /dev/ttyUSB0 -b 460800 read_flash 0x000000 0x400000 flash_full.bin # 单独读某个分区 python3 esp-idf/components/partition_table/parttool.py -p /dev/ttyUSB0 \ read_partition --partition-namelogs --output logs_dump.bin # 查看分区表二进制 python3 esp-idf/components/partition_table/gen_esp32part.py flash_full.bin拿到影像后我会用十六进制编辑器打开相邻分区交界处比如web从0x320000开始logs从0x390000开始。如果在web分区末尾发现了日志文本或者在logs分区开头发现了 HTML 文件头基本就能确定某个模块越界写了。再用二进制特征搜索结合各分区的 magic 字段能快速定位是谁干的。5.4 从源头杜绝用脚本自动扫描分区表重叠人总会犯错所以我给团队留了个自动化检查脚本。编译之前跑一下直接解析partitions.csv检查每个分区是否越界、是否和后面的分区重叠保证在设计阶段就把问题拦下来。下面是一个简化版本的 Python 脚本思路很清晰import csv FLASH_SIZE 0x400000 # 4MB parts [] with open(partitions.csv) as f: reader csv.DictReader(f, skipinitialspaceTrue) for row in reader: if row[Name].startswith(#): continue offset int(row[Offset], 16) size int(row[Size], 16) parts.append((offset, size, row[Name])) parts.sort() prev_end 0 prev_name BEGIN for offset, size, name in parts: if offset prev_end: print(fOVERLAP: {prev_name} and {name}) if offset size FLASH_SIZE: print(fOUT_OF_RANGE: {name} exceeds flash) prev_end offset size prev_name name这个脚本不复杂但很有用。我在 CI 流程里加了这一道检查之后带着错误分区表去烧录的概率几乎降为零。6. 一些真正管用的现场经验6.1 分区表定版后加新功能优先找剩余空间这是我被现实毒打后刻在心里的规则。多应用设备后期经常会加新功能设备证书、告警记录、数据缓存、云同步队列……每加一个第一反应永远是在剩余 Flash 空间里硬挤而不是去重新排列旧分区。重排意味着迁移、意味着概率性丢数据风险极高。宁可把某个分区缩小一点也不要动其他分区的偏移。6.2 数据分区“宁大勿小”给磨损和扩展留余地开发期我经常看到有人把日志分区设置成刚好 64KB理由是“一天才写 20KB够用了”。结果跑两个月后日志写入频率因为需求调整翻了几倍分区很快写满环形回卷频繁擦写Flash 磨损明显加快。经验是数据分区按预估需求的 1.5 到 2 倍预留算完发现空间紧张那就优先压缩 app 分区大小减少不必要的库而不是压数据区。6.3 出问题第一时间别 erase_flash先备份现场调试数据串门问题时最宝贵的就是现场数据。我见过有人一发现日志乱码立刻重新烧录并 erase_flash结果后续分析没有任何依据。正确顺序是断电把 Flash 完整读出来留档再考虑重新烧录。很多时候flash_full.bin里藏着足够线索能让你精确找到越界代码。6.4 代码评审时遇见“裸地址”常量直接打回我在团队里立了一条规矩业务代码里不允许出现任何0x000000样的 Flash 物理地址。所有持久化操作要么通过esp_partition_find_first拿分区句柄要么通过 NVS/文件系统 API。谁写裸地址代码评审就不通过。这一条坚持了几年数据串门的线上事故率直线下降。多个小应用共用 ESP32 的一块 Flash说到底拼的不是某个黑科技而是能不能把分区表当作一张必须遵守的城市规划图。你规划清楚、代码里只认分区名、遇到异常先备份再下手数据自然就不会串门。希望这套经验能让你少踩几个类似的坑。
返回列表