拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

ESP32 Flash分区表与数据隔离:防多应用数据串门攻略

ESP32 Flash分区表与数据隔离:防多应用数据串门攻略 先问一个很多人到中期才会撞上的问题你的 ESP32 项目是不是也在往一块 Flash 里塞了好几个“小应用”比如一个模块负责配网、一个模块处理传感器数据、一个模块跑 Web 控制台再加上一个 OTA 升级。功能都跑起来了但某天你发现 A 模块写的数据把 B 模块的配置冲掉了或者 OTA 升级一次之后整个设备反复重启。这种“数据串门”问题根子基本都在 ESP32 的 Flash 布局上。ESP32 的 Flash 不是一块随便读写的大硬盘它是一块有固定地址空间、按扇区擦写、分区管理严格的 SPI NOR Flash。同一块 Flash 上要跑多个应用、存多份数据最核心的隔离机制是分区表Partition Table。把每一份代码、每一份数据都圈进自己的“地盘”再用 ESP-IDF 提供的分区 API 和 NVS 命名空间去访问才能从根上避免数据互相覆盖。这篇文章我把多层隔离的完整思路、分区表设计、代码实现和踩坑记录都整理出来写给正在用 ESP32 做多业务模块、或者准备把一套代码拆成多个独立应用的开发者。1. 先从“为什么需要隔离”说起1.1 多个小应用共用一个 Flash到底会发生什么我见过不少 ESP32 项目前期功能单一代码全塞在 main 里后面需求一多就开始往里面加模块。Wi-Fi 配网、MQTT 连接、传感器采集、OLED 显示、Web Server、OTA……每个模块都有自己的配置数据、缓存数据、日志数据于是所有人都盯上了同一块 Flash。ESp32 的 Flash 默认外挂在 SPI 总线上常见容量 4MB 或 8MB系统启动时由 Bootloader 读取分区表然后按分区加载应用。分区表是一张“地图”它告诉 Bootloader 和应用程序哪个地址范围是应用代码、哪个地址范围是 NVS 参数、哪个范围是文件系统。如果没有这张地图的约束多个模块都按自己的“直觉”去读写 Flash就会出现三类典型事故。1.2 “数据串门”的三类典型事故第一类NVS 键名冲突。两个模块都把 Wi-Fi 密码存到键名为ssid、password的 NVS 键里A 模块写入的配置被 B 模块覆盖设备重启后连不上网。第二类绝对地址覆盖。有同学图省事直接按绝对地址spi_flash_write(0x300000, ...)写数据结果这个地址正好落在另一个应用的代码区或文件系统区轻则数据错乱重则启动崩溃。第三类OTA 升级把整个设备搞坏。OTA 要写入新的 app 镜像如果分区表本身就没规划好或者升级时把 otadata 和 app 分区写串了Bootloader 不知道从哪里启动设备直接变砖。这三类事故我都踩过而且是“非常规”手段踩的——不是文档里那种“恶意越界”纯粹是没理解分区机制想当然地读写 Flash。1.3 分区表Flash 里的“房产证”分区表本质上是存放在 Flash 偏移0x8000处的一段数据结构每个分区条目固定 32 字节里面记录了这个分区的偏移地址、大小、类型、子类型和标签。最直观的理解方式是把 Flash 当作楼盘分区表就是房产证。每个分区写了“这块地属于谁、面积多大、用途是什么、门牌号叫什么”。ESP-IDF 在启动时会把这张表读出来后续所有esp_partition_*API 都是在表上“找房间”而不是在整块 Flash 上“乱跑”。所以多应用共存的第一个设计动作不是写代码而是打开项目的partitions.csv把地先分好。2. 分区表设计给每个应用划定“各自的地盘”2.1 系统默认分区表你看懂了吗新建 ESP-IDF 项目时默认会带一份 4MB Flash 的分区表。用idf.py menuconfig可以看到它大概是这样的# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, phy_init, data, phy, 0xe000, 0x1000, factory, app, factory, 0x10000, 0x200000,这说明 4MB Flash 的空间分配是最前面 0x1000 是 Bootloader0x8000 是分区表然后 NVS、PHY 初始化数据、主应用。很多人没意识到这个默认表里其实就藏着“隔离”的影子。NVS 是一个专门存键值对的分区PHY 校准数据是另一个独立分区它们不和 app 代码混在一起。只是默认表没有考虑“多个应用”的场景所以功能模块一多大家还是挤进了 NVS 和同一个 app 分区。2.2 多应用共用的典型分区表方案假设你有一个项目主固件里跑两个业务模块一个负责传感器采集一个负责 Web 控制面板。两者都需要存配置、存历史数据而且希望互不干扰。我会把分区表设计成这样# 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, 0x200000, app_sensor, data, 0x40, 0x212000, 0x40000, app_web, data, 0x41, 0x252000, 0x40000,注意几点app_sensor和app_web的类型是data子类型用了0x40和0x41。在 ESP-IDF 里数据分区的子类型 0x00 到 0x3F 是系统保留0x40 到 0xFE 留给你自定义。用这种方式就能创建“属于某个应用的专属数据分区”。offset 我留了继续增长的余地每个分区大小都是0x40000256KB留白空间足够后续扩展。otadata分区保留为了以后搞 OTA 不返工。为什么每个应用都要有独立数据分区而不是大家共用一个大分区因为分区的粒度和 Flash 擦除粒度有关——Flash 是按扇区擦除的一个扇区最小 4KB。共用分区意味着所有模块共享一套坏块管理、共享同一片物理空间只要有一个模块的写入逻辑出错整个分区的数据都可能受影响。独立分区等于把这个风险隔离在应用自身范围内。2.3 自定义分区类型需要时怎么办如果你的模块还想存图片、HTML 页面、日志这类文件可以再挂一个 SPIFFS 或 LittleFS 分区方法也一样# Name, Type, SubType, Offset, Size, Flags web_assets, data, spiffs, 0x292000, 0x100000,SPIFFS 和 LittleFS 都是文件系统适合存成块的文件。它们自带磨损均衡和坏块管理但“串门”风险依然存在——如果两个应用共用一个文件系统分区文件名冲突和目录污染一样会出现。我给的建议是要么每个应用独立文件系统分区要么在文件系统里建目录层但目录隔离远没有分区隔离干净因为文件系统的底层坏块管理仍是全局的。自定义分区的 offset 必须对齐 Flash 的擦除块大小通常 4KB0x1000对齐app 分区建议 64KB0x10000对齐。ESP-IDF 编译时如果发现问题会直接报错不会给你留模糊空间。3. 数据不串门的三个层级硬件、系统、应用3.1 硬件层Flash 擦写的基本规律要真正理解“为什么不能随便写 Flash”得先知道 SPI NOR Flash 的物理特性。NOR Flash 写入最小单位是“页”通常 256 字节擦除最小单位是“扇区”通常 4KB。更关键的是写入前必须先擦除擦除会把整个扇区清成 0xFF不能只擦一个字节。所以如果你想改一个字节实际流程是把整个扇区读出来→在内存里改掉目标字节→擦除整个扇区→把修改后的数据整页整页写回去。这个特性意味着什么意味着 Flash 上的“写”操作天然就是破坏性的。如果一个分区只占 0x1000但你的程序用绝对地址写到了 0x1010 之外的区域你擦除的可能是别人的地盘。硬件层没有“越界保护”它只会傻乎乎地响应当前的 SPI 命令。所以隔离一定是在软件层做死的。3.2 系统层分区表 API 帮你做“软隔离”ESP-IDF 提供了一套esp_partition_*API它们的好处是每次操作都会检查地址是否在分区范围内越界直接返回错误而不是闷头写坏 Flash。我常用的几个接口const esp_partition_t *esp_partition_find_first( esp_partition_type_t type, esp_partition_subtype_t subtype, const char *label); esp_err_t esp_partition_read( const esp_partition_t *partition, size_t src_offset, void *dst, size_t size); esp_err_t esp_partition_write( const esp_partition_t *partition, size_t dst_offset, const void *src, size_t size); esp_err_t esp_partition_erase_range( const esp_partition_t *partition, size_t offset, size_t size);这里最核心的原则是拿到esp_partition_t指针之后永远只用相对偏移。比如要往app_sensor分区的第 0x100 字节写数据调用是esp_partition_write(sensor_part, 0x100, buf, len)。系统会在内部判断0x100 len有没有超出该分区大小超了直接报错。我见过有人拿到分区基址之后又自己加上0x212000去做绝对地址操作这完全是多此一举而且绕过了所有安全检查。分区 API 给你的就是“相对房间号”不要自己去换算“整栋楼的门牌号”。3.3 应用层NVS 命名空间和键名规范除了自定义数据分区ESP32 还提供了一个专门存键值对的 NVS 系统。NVS 可以理解为 Flash 里的“小型数据库”它自带哈希表和磨损均衡。NVS 隔离的核心是“命名空间”。同一个 NVS 分区里不同命名空间的键是互相隔离的nvs_handle_t handle; nvs_open(app_sensor, NVS_READWRITE, handle); nvs_set_str(handle, cfg, sensor_cfg_str); nvs_commit(handle); nvs_close(handle);另一个应用打开app_web命名空间同样用cfg这个键名写出来的数据完全独立。从实现上看NVS 会为每个命名空间加前缀底层键名实际上是namespace key的组合天然避免了键名冲突。需要注意 NVS 键名本身有严格限制最长 15 个字符只能包含字母、数字和下划线。命名空间名称没有太严格的长度限制但也别起太长因为每个命名空间都会占用 NVS 页面的头部空间。4. 实操环节用代码实现数据隔离4.1 在分区表中新增两个自定义分区打开项目根目录下的partitions.csv按前面演示的分区表修改。如果你的项目之前用默认表此时factory分区的大小和偏移可能和默认值不一样改完后需要全片擦除重新烧录。修改完partitions.csv之后在menuconfig里确认idf.py menuconfig进入Partition Table→Partition Table→ 选择Custom partition table CSV→ 填上你自己的 CSV 文件名保存退出。然后重新编译烧录idf.py erase-flash idf.py flash monitor这里erase-flash不是可选项是必选项。因为分区表变了之后Flash 里可能残留旧分区表对应的数据尤其是旧 NVS 数据会干扰新分区的初始化。第一次改分区表我强烈建议全片擦除。4.2 读取和写入自定义分区下面这段代码演示如何找到一个自定义数据分区并往里写数据。我以app_sensor分区为例#include esp_partition.h #include nvs_flash.h #include string.h #include stdio.h void sensor_partition_write_demo(void) { const esp_partition_t *part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, (esp_partition_subtype_t)0x40, // 对应 CSV 里的 SubType 0x40 app_sensor); if (part NULL) { printf(sensor partition not found\n); return; } printf(partition: offset0x%lx, size0x%lx\n, (unsigned long)part-address, (unsigned long)part-size); // 写入前先擦除一段区域。擦除大小必须是 4KB 的倍数。 esp_err_t err esp_partition_erase_range(part, 0, 0x4000); if (err ! ESP_OK) { printf(erase failed: %s\n, esp_err_to_name(err)); return; } uint8_t data[128]; memset(data, 0x5A, sizeof(data)); err esp_partition_write(part, 0, data, sizeof(data)); if (err ! ESP_OK) { printf(write failed: %s\n, esp_err_to_name(err)); return; } uint8_t buf[128]; err esp_partition_read(part, 0, buf, sizeof(buf)); if (err ESP_OK) { printf(read back, first byte: 0x%02x\n, buf[0]); } }几个要点esp_partition_erase_range的第一个参数是分区指针第二个和第三个参数是相对偏移。擦除的大小必须对齐扇区4KB官方 API 会检查如果你传了 0x2000 字节它也会内部对齐到 0x2000但你不对齐会得到ESP_ERR_INVALID_ARG。先擦除再写入这是一个习惯。如果只写不擦你很可能把新数据“或”到旧数据上得到一堆 0x5A 和旧值混合的垃圾。写入长度没有强制要求按页对齐但跨页写入时系统会自己拼页性能会低一些。大批量写数据时最好自己把数据按 256 字节对齐分页减少系统内部拼接开销。4.3 NVS 隔离实现NVS 更适合存小型配置比如传感器的阈值、Web 控制面板的语言设置。我用两个命名空间演示隔离#include nvs_flash.h #include nvs.h void nvs_isolation_demo(void) { // 初始化 NVS 分区整个项目只调一次 esp_err_t err nvs_flash_init(); if (err ESP_ERR_NVS_NO_FREE_PAGES || err ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); nvs_flash_init(); } nvs_handle_t h_a, h_b; // 应用 Asensor 命名空间 if (nvs_open(app_sensor, NVS_READWRITE, h_a) ESP_OK) { int32_t threshold 75; nvs_set_i32(h_a, threshold, threshold); nvs_commit(h_a); nvs_close(h_a); } // 应用 Bweb 命名空间也用 threshold 这个键 if (nvs_open(app_web, NVS_READWRITE, h_b) ESP_OK) { int32_t threshold 99; nvs_set_i32(h_b, threshold, threshold); nvs_commit(h_b); nvs_close(h_b); } // 读回验证 nvs_handle_t h_r; int32_t val_a 0, val_b 0; if (nvs_open(app_sensor, NVS_READONLY, h_r) ESP_OK) { nvs_get_i32(h_r, threshold, val_a); nvs_close(h_r); } if (nvs_open(app_web, NVS_READONLY, h_r) ESP_OK) { nvs_get_i32(h_r, threshold, val_b); nvs_close(h_r); } printf(sensor threshold: %d\n, val_a); printf(web threshold: %d\n, val_b); }运行结果sensor threshold: 75、web threshold: 99两者互不影响。如果当初都写在同一个命名空间里第二个nvs_set_i32就会把第一个覆盖掉。4.4 加一个“数据完整性”保险魔数 版本号分区隔离解决的是“空间不串门”但解决不了“数据内容是否正确”。如果设备在写入中途断电或者 Flash 长期使用后出现位翻转你读到分区里的数据可能是一堆废数据。我习惯在每个自定义分区的起始位置放一个结构体包含魔数Magic Number和版本号#define APP_SENSOR_MAGIC 0xA55A5AA5 typedef struct { uint32_t magic; uint32_t version; uint32_t crc32; uint32_t data_len; uint8_t data[0]; } sensor_partition_header_t;写入流程擦除分区。填充header字段对data部分计算 CRC32。把header data写入分区。读取流程从分区偏移 0 处读取header。检查magic APP_SENSOR_MAGIC如果不对说明分区未初始化或数据损坏按默认配置重建。检查version如果旧版本号做数据迁移。读data并重新计算 CRC32检查数据完整性。这个做法花不了多少代码量但能在排查“数据串门”类问题时快速区分是“写错位置”还是“数据本身损坏”。我见过很多项目排查了很久才发现是写入中断导致 CRC 错误而不是地址串了。5. 常见问题与避坑技巧实录5.1 改分区表后烧录失败或设备起不来这是最常踩的坑。症状是之前烧录好的设备改完partitions.csv后idf.py flash能成功但上电后日志停在app not found或反复重启。原因很简单分区表变了但 Flash 上的旧分区数据还留着。Bootloader 按新分区表去找 app结果偏移和旧固件的位置对不上。解决办法idf.py erase-flash idf.py flash monitor如果用的是 ESP32 开发板也可以按住 BOOT 键进入下载模式后用esptool.py erase_flash擦除。注意erase_flash会把 NVS、PHY 校准数据一起擦掉重新上电后会重新校准但如果你设置了nvs_flash_erase()和nvs_flash_init()的逻辑这不会导致设备不可用。5.2 NVS 被写坏了系统一直重启NVS 分区有自己的页管理和损坏检测。如果你的程序里多个任务并发写 NVS或者写入中途频繁复位有可能把 NVS 的元数据页写坏导致系统启动时nvs_flash_init()失败然后循环 reboot。排查思路idf.py monitor看日志里有没有E (xxx) nvs: ...或assert failed。如果确定是 NVS 损坏nvs_flash_erase(); nvs_flash_init();直接擦掉重建。但这里有个隐藏问题如果你有两个应用共用同一个 NVS 分区擦掉之后两个应用的数据都没了这就是“共用一块 Flash”的代价。所以强烈建议每个应用自己有独立的数据分区NVS 只放系统级参数应用配置放自定义数据分区。5.3 多个应用共用一个 SPIFFS/LittleFS 分区文件系统分区的“隔离”比 NVS 更脆弱。虽然你可以为每个应用建不同目录但文件系统本身的坏块管理、磨损均衡和各种内部操作是跨越整个分区的。应用 A 在目录A/下疯狂写入可能会导致整块 Flash 的擦写次数分布发生变化影响应用 B 目录下的文件的物理存储位置。如果你的应用 A 需要存历史日志文件应用 B 需要存网页资源我更倾向用两个独立文件系统分区而不是一个分区里分目录。唯一的问题是分区表条目有限默认最多 95 个分区一般项目完全够用。隔离方案优点缺点适用场景独立自定义数据分区隔离最彻底API 有越界检查需要自己管文件/记录麻烦一点两个应用的核心业务数据同一 NVS 分区不同命名空间使用简单官方推荐数据量小错误时会牵连配置项、阈值、开关状态同一文件系统分区不同目录文件管理方便无需分表底层坏块管理全局共享临时文件、非关键数据独立文件系统分区文件管理和空间隔离都做好分区表占用增加浪费空间大文件、日志、网页资源5.4 OTA 和多应用分区的潜在冲突OTA 升级要写app类型的分区如果你之前把自定义数据分区的偏移放得离 app 分区太近并且 app 分区不够大升级时会报空间不足。更隐蔽的问题是otadata分区——它是 Bootloader 判断该从哪个 app 槽启动的关键。我建议所有涉及 OTA 的项目分区表按照这个结构组织# 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, 0x200000, ota_0, app, ota_0, 0x212000, 0x200000, ota_1, app, ota_1, 0x412000, 0x200000, app_sensor, data, 0x40, 0x612000, 0x40000, app_web, data, 0x41, 0x652000, 0x40000,注意 app 分区之间的偏移都是连续的 2MB每个 app 分区大小一致这是 OTA 的基本要求。数据分区排在 app 区后面互不干涉。如果没有 OTA 需求前面的otadata、ota_0、ota_1都可以不建省下的空间全给数据分区。5.5 几个容易忽略的细节分区表条目大小是 32 字节默认最多 95 个分区。虽然看起来很宽裕但如果你给每个应用都分配独立分区加文件系统、OTA、NVS没多久就会逼近上限。不要用绝对地址访问 Flash。我在好几个项目里都见过spi_flash_write(g_partition-address offset, ...)这种写法这等于抛弃了分区 API 的越界检查。正确做法是esp_partition_write(part, offset, ...)系统内部会自己加上基地址。Flash 操作期间会禁用 cache所以不要在 ISR、CPU 中断上下文里做 Flash 读写。多核环境下一个核在擦写 Flash另一个核取指会短暂 stall这是正常现象。分区 label 是字符串API 靠它匹配分区必须唯一。如果你的两个子应用都叫data_appesp_partition_find_first只会找到第一个。改 CSV 后最好先erase-flash再flash。别嫌慢一次全片擦除能省下很多排查时间。哪怕是开发阶段我也很少用idf.py flash直接增量烧录来调试分区问题。结尾我在实际项目里一开始也是图省事用绝对地址往 Flash 塞数据后来做 OTA 时直接把应用 A 的固件区覆盖了设备当场变砖。从那之后我养成了一个习惯不管多小的 ESP32 项目开工第一天就要把分区表画清楚哪怕是只有一个应用也把 NVS、phY、OTA、自定义数据分区规划出来。分区表改起来不麻烦但它在整个系统里是最底层的地基地基画错了后面所有“隔离”都是空中楼阁。最后再分享一个实用小技巧给每个自定义数据分区的魔数留一个“版本迁移”位。以后应用升级数据结构变了读取时一看到旧版本号就可以执行一次迁移逻辑把旧数据格式转换成新格式再写回分区。这样就算多个应用共用一块 Flash经历多轮升级也不会因为数据布局变化而互相影响。有了这套机制再往上加功能我心里就踏实多了。
返回列表