ESP32多应用Flash分区设计:数据不串门实战指南

发布时间:2026/10/31 17:21:22
ESP32多应用Flash分区设计:数据不串门实战指南

去年年底我做智能家居网关的时候,被 Flash 分区折腾得够呛。一块 4MB 的 ESP32-WROOM-32E 上同时跑了三个"小应用":一个温湿度采集上报模块,一个蓝牙 Mesh 网关,还有一个 Web 配置服务器。一开始它们各干各的,相安无事了大半年。直到一次 OTA 升级之后,用户反馈设备名称变成乱码、WiFi 配网信息全部丢失。我在串口日志里翻了半天,最后发现根子根本不在业务代码,而是 Flash 里这三个模块的数据早就开始"串门"了。

这个标题应该能戳中不少人。ESP32 的 Flash 就那么大一块,多任务、多功能模块共存是常态,谁都不想自己的配置数据被邻居覆盖。但很多人对 Flash 的理解还停留在"一个可以读写的 U 盘"层面,直接拿地址就开始存数据,结果分区表一改、OTA 一升级、擦除边界一错,数据就静悄悄地互相踩踏。这篇文章我就用实际踩坑的经验,把"多个小应用共用一块 Flash 怎样才能不串门"这件事彻底讲清楚,从分区表设计、NVS 命名空间隔离,到 OTA 升级时的数据保护,一次性给全。

1. 先弄明白为什么会"串门":Flash 上其实有一张分区地图

很多人第一次接触 ESP32 的 Flash 存储,会本能地把它当成一块连续的内存来用:偏移 0x100000 放我的数据,偏移 0x200000 放你的数据,大家井水不犯河水。这个思路本身没错,错在"偏移地址靠人肉记忆"这件事上。地址会因为固件大小调整、OTA 分区切换、日志分区轮转而漂移,一旦两次分配之间出现重叠,数据串门就是必然的。

1.1 "共用 Flash"的真实含义:冷冰冰的地址空间

ESP32 启动时,CPU 会把 Flash 映射到内存地址空间里,其中 0x3F400000 到 0x3F7FFFFF 是可以在代码里直接读写的一段区域。但这块区域不是被某一个应用独占的,而是整个芯片所有代码的"共同地基"。

可以这么理解:整片 Flash 就像一栋写字楼,bootloader 占了一层,不同的小应用各占一个房间,用户配置数据又在另一个区域。楼里的每个人手里都有一把"房间号"清单,清单上写明了哪一块房间属于谁。如果你的代码把手伸到别家房间门口——哪怕是读一下,也等于擅自闯入;如果更过分,直接去擦写,那就是把人家家具砸了。

听起来恐怖,但现实中"砸家具"的事情确实不少见。比如我在社区里见过有人直接把 WiFi 配置存在 0x3F0000 这个"稳妥内地址"上,理由是这地址够高、不容易和固件冲突。结果后来工程师在新版本里给 OTA 分区划到了这个位置,设备一升级,老用户的 WiFi 配置一夜之间全变成 0xFF。这就是典型的"人肉地址"惹的祸:你以为的"安全角落",在分区表眼里只是别人的地盘。

1.2 分区表是产权证:启动流程里的第一道防线

从 ESP-IDF 的启动流程看,芯片上电后做的事大概是这样的:

  1. ROM Bootloader 先执行,它做的事很有限,主要就是加载 Flash 0x0 地址处的一段程序。
  2. 这段程序通常是二级 Bootloader(IDF 编译生成的 bootloader.bin),它去固定的偏移地址(默认是 0x8000)读取分区表。
  3. 分区表找到 factory 分区或者当前的 OTA 分区,把 app 固件加载进内存执行。

关键就在第 2 步。分区表这个 4KB 大小的区域(默认最多 95 个条目)里,每个条目 32 字节,记录了一个分区的名字、类型、子类型、偏移地址、大小和标志位。它相当于整个 Flash 的"不动产权登记簿"。

只要分区表本身没有被破坏,芯片就知道每个应用和数据该往哪里放。所以保护分区表,比保护任何单个应用的数据都重要。我有一次测试的时候写了一个擦除函数,参数传了一个错误的分区偏移,一执行就把整个 Flash 从 0 开始全擦了一遍。那一刻我终于体会到什么叫"一夜回到解放前"——bootloader、分区表、app、数据,全没了,最后只能重新接串口线烧 bootloader 救砖。

1.3 ESP32 默认分区布局到底够不够用

先看 ESP-IDF 默认生成的分区表长什么样。4MB Flash 的典型布局如下:

名称类型子类型偏移大小
nvsdatanvs0x90000x4000
otadatadataota0xd0000x2000
phy_initdataphy0xf0000x1000
factoryappfactory0x100000x200000

这个布局只给"单固件单应用"留了空间。如果你的项目只有一个主程序,而且不做 OTA,那确实够用了。但多数人实际是多个功能模块往里塞——Web 服务器要存登录凭据,传感器要存校准参数,蓝牙模块要存配对信息,升级模块还要缓存新固件。所有模块如果都往 NVS 里挤,NVS 那 16KB 很快就不够。

更麻烦的是,这种默认布局里 app 只留了 2MB,剩下 2MB 白白浪费。所以我拿到一块 ESP32 的第一件事,就是先想清楚整体业务,再设计分区表,而不是沿用默认配置。分区表这东西,等代码写完了再改,代价往往是最高的。

2. 用自定义分区表给每个应用"圈地":从 CSV 到二进制

ESP-IDF 提供了很完善的分区表定制机制,用 CSV 文件定义,编译的时候生成二进制分区表烧录到 0x8000。前几年用起来还比较麻烦,现在新版 ESP-IDF 直接在partitions.csv里写好,idf.py partition-table一键搞定。

2.1 一张 CSV 定义全部地盘

下面这份是我在一个 4MB Flash 设备上常用的分区表,适合"主固件 + OTA 升级 + Web/传感器/蓝牙多模块共用"的场景:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, ota_0, app, ota_0, 0x10000, 0x180000, ota_1, app, ota_1, 0x190000, 0x180000, storage, data, spiffs, 0x310000, 0xC0000, sys_params, data, nvs, 0x3D0000, 0x30000,

几个字段的含义逐个说:

  • Name:分区标签,代码里esp_partition_find_first用的就是这个字符串,必须全局唯一。
  • Type:app 类型是 0,data 类型是 1。app 分区用来放固件,data 分区用来放各种数据。
  • SubType:app 类型里区分 factory、ota_0、ota_1 等;data 类型里区分 nvs、spiffs、fat、phy、otadata 等。子类型会影响一些 API 的默认行为,比如 OTA 查找更新分区只会找ota_0/ota_1这类子类型。
  • Offset:分区在 Flash 上的起始地址。官方要求 app 分区和大多数 data 分区按 64KB(0x10000)对齐,NVS 这种小分区按 4KB 对齐即可。
  • Size:分区大小。多个分区加起来不能超过 Flash 总容量,idf.py编译时会严格检查。

我在上面专门给"多模块共享数据"留了一个sys_params分区,子类型还是 nvs。这样虽然和前面的nvs分区同名类型,但在物理上是两块独立的区域。一个放系统级的 WiFi 配置、NVS 键值对,另一个放应用层的业务数据,互不干扰。为什么要分开?因为 NVS 分区有 GC(垃圾回收)机制,频繁擦写会让它自动做页合并,如果业务数据和系统配置混在一起,一次业务写入触发 GC,可能导致系统配置的读写性能跟着抖动。

2.2 多固件共存的分区设计:不是每个应用都要 OTA 分区

标题里说的"多个小应用",另一个常见场景是芯片上真的放了多份固件:比如一个主控固件,一个诊断/恢复固件,甚至一个开机引导菜单。这种场景下,分区表要按"多 app 共存"来设计,而不是标准的"单 app + OTA"。

我见过一个很巧妙的小设计:在 4MB Flash 上放了三个 app 分区,分别是主固件、恢复固件和出厂测试固件。引导方式不是靠 bootloader 自动选择,而是用一个专门的小分区保存"当前要启动哪个 app"的索引,由主固件里的一段代码在重启前写入。

这种玩法对应的分区表大概是:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, main_app, app, ota_0, 0x10000, 0x100000, recovery_app, app, ota_1, 0x110000, 0x100000, factory_test, app, ota_2, 0x210000, 0x100000, app_switch, data, nvs, 0x310000, 0x10000, user_data, data, spiffs, 0x320000, 0xE0000,

注意这里我用了ota_0、ota_1、ota_2三个子类型,而不是factory。因为 ESP-IDF 的 bootloader 在找不到factory分区时,会读取otadata里的信息决定启动哪一个ota_x分区。通过操作otadata,应用程序可以在重启前自动切换到不同的固件。recovery_app就是一个典型的"救援分区":主固件自己检测到频繁崩溃,就把otadata改成指向恢复固件,重启进入救援模式,做完修复再切回来。

这种玩法比自己写自定义 bootloader 省事太多,相当于白捡了一套分区切换机制。代价是三个 app 分区可能都缩水,每个只能分到 1MB,如果你的固件超过这个体积,就得考虑换 8MB/16MB 的 Flash 模组。

2.3 让代码只认分区标签,不认魔法地址

有了分区表,代码里获取正确地址的正确姿势是这样的:

#include "esp_partition.h" const esp_partition_t *part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "user_data"); if (part == NULL) { // 分区表里没有这个分区,不能继续 ESP_LOGE("app", "partition user_data not found"); return ESP_ERR_NOT_FOUND; } // 读、写、擦除都用 part->offset 和 part->size esp_err_t err = esp_partition_read(part, 0, buf, sizeof(buf));

我见过不少代码直接把 offset 写死,然后判断 Flash 大小"够不够"就开始读写。短期看能跑,可一旦你想调大某个分区、给固件腾空间,所有写死的地址都得翻出来改,漏一个就等着数据串门吧。

反过来说,只认标签的做法天然免疫地址漂移——分区表改了,esp_partition_find_first拿到的 offset 也跟着变,业务代码一行都不用动。这也是 ESP-IDF 设计这套 API 的初衷。

3. 同分区内的数据隔离:NVS 命名空间和 API 自律

分区表解决的是"小区墙"的问题,把地盘划开了。但很多时候,多个功能模块共享的其实是同一个分区——比如大家都用默认的 NVS 分区。这时候墙内还得再分房间,靠的就是 NVS 命名空间。

3.1 命名空间:一个分区内的"合租房分房间"

NVS 的读写接口长这样:

nvs_handle_t handle; esp_err_t err = nvs_open("sensor_cal", NVS_READWRITE, &handle); if (err == ESP_ERR_NVS_NEW_VERSION_FOUND) { // 分区里的数据是用新版格式写的,需要先擦掉重来 } if (err == ESP_ERR_NVS_NO_FREE_PAGES) { // NVS 分区满了,需要扩大分区或清理数据 } nvs_set_i32(handle, "offset", calibration_offset); nvs_commit(handle); nvs_close(handle);

这里的"sensor_cal"就是命名空间。不同命名空间下即使键名相同,也不会互相覆盖。也就是说,只要每个模块在打开 NVS 时各用各的名字,就算大家都存一个叫"state"的键,数据也是各自独立的一份。

这个字段太容易被人忽略了。踩过最典型的坑是:业务同学图省事,所有模块都nvs_open("config", ...)往同一个命名空间里写,然后蓝牙模块存了一个name,Web 服务器也存了一个name,两边互相覆盖,设备重启之后名字到底是"客厅灯"还是"ESP32-1234",取决于谁最后写了一次。

命名空间的设计原则很简单:进程级/模块级一个命名空间,跨模块共享的数据单独拉一个命名空间,命名空间内部才考虑键名。我习惯在代码里放一个头文件专门定义命名空间名字符串,避免手写拼写错误。

3.2 spi_flash 直接读写的边界检查:这里最容易被自己坑

NVS 是 ESP-IDF 提供的高级封装,但有些人嫌它慢,有些人要存大块二进制数据,还有些人要用内存映射直接访问,这时候就会直接碰到底层接口,比如spi_flash_read/spi_flash_write,或者esp_partition_read/esp_partition_write。

我强烈建议,只要能拿到分区句柄,就不要直接用不带分区信息的spi_flash_*接口。因为前者会自动检查偏移有没有超出part->size,后者全靠自觉。

举个例子,用分区句柄读:

const esp_partition_t *part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, "sys_params"); // offset 越界时 esp_partition_read 会返回 ESP_ERR_INVALID_ARG,不会静默读到隔壁 esp_err_t err = esp_partition_read(part, part->size + 0x100, buf, 16);

如果你直接用spi_flash_read(part->offset + part->size + 0x100, buf, 16),只要这个地址还没超出整个 Flash 容量,函数就照读不误。这一读,就把人家房间里的数据看光了。写更是如此,写进别人的分区,等于把别人房间的家具搬走又塞了一堆杂物。

动手写之前,代码里做这样几个检查:

  • 写入前确认write_offset + write_size <= part->size
  • 擦除前确认erase_offset和erase_size都是 4KB 对齐
  • 多任务环境对同一个分区并发写,要加互斥锁,NVS 内部有自己的锁,自定义分区没有

3.3 擦除粒度、写前擦除和对齐:物理特性决定规则

很多人第一次在 ESP32 上做 Flash 存储,都会遇到一个怪现象:写入偶发失败,或者读到 0xFF。这不是代码 bug,是没搞懂 Flash 的物理特性。

NOR Flash 的擦除以扇区为单位,ESP32 用的芯片扇区大小基本都是 4KB(0x1000)。写入操作只能在擦除后的空间上进行,而且只能把 1 变成 0;要把 0 变回 1,唯一的办法就是整扇区擦除。所以在改写一个字节之前,你可能要先把整块 4KB 擦掉。这也是为什么分区 offset 必须按扇区对齐——如果某个分区只占用了半个扇区,剩下的半个扇区擦除时会把邻居的数据一起清掉。

我见过一个项目,数据分区大小写成了0x3F000(258048 字节),不是 4KB 的整数倍。平时读写没问题,一旦执行esp_partition_erase_range时传了一个不合适的 size,接口直接报错ESP_ERR_INVALID_ARG: Partition size does not match。因为这个参数不仅要对齐,还必须是分区大小的整数倍。

设计分区时,建议所有分区大小都按 64KB(0x10000)对齐来规划。这样既能满足大多数限制,也给未来 OTA 固件体积增长留了余量。NVS、otadata 这种特殊小分区本身要求低,但也不建议塞到 4KB 以下。

4. OTA 升级:最容易把数据串门"放大"的高危场景

日常运行时间,数据串门是小概率事件,一个"写错地址"的 bug 可能几个月都不触发。但每次 OTA 升级都是一次全 Flash 级别的"大扫除",任何边界处理不到位,都会在升级瞬间把问题暴露出来。

4.1 OTA 双分区切换:app 互相覆盖时的保护

标准 OTA 机制是双分区轮换。ota_0用来跑当前固件,ota_1留作新固件写入;写完ota_1之后,把它标记为下一次启动的引导分区,重启后新固件就跑起来了。下次再升级,又反过来写ota_0,如此往复。

这个机制本身很成熟,但很多人不知道的是:esp_ota_write写入的是新固件的 app 分区,而不是某个"临时文件"。如果新固件正好比你规划的 app 分区大,写入会一直写满分区,然后报错ESP_ERR_OTA_PARTITION_CONFLICT。这时候旧固件往往已经因为段错误不能跑了,设备直接变砖。

我在设计里做了一层保险:OTA 之前先用固件的实际大小对比目标分区大小,不够就直接放弃升级。这看起来多此一举,但能挡住 90% 的"烧错固件"事故。

const esp_partition_t *update_part = esp_ota_get_next_update_partition(NULL); if (update_part->size < firmware_size) { ESP_LOGE("ota", "firmware %u bytes > partition %u bytes", firmware_size, update_part->size); return ESP_ERR_INVALID_SIZE; }

4.2 升级阶段的数据分区保护策略

OTA 升级本身只写ota_0/ota_1分区,不碰数据分区。如果代码里有人在不合适的地方统一做了"升级前擦除数据分区",那才是真正的灾难。我就见过一个项目,升级流程里一句话:esp_partition_erase_range(data_part, 0, data_part->size),美其名曰"清掉旧缓存"。结果每次升级,所有 WiFi 配置、设备名称、用户偏好全部恢复出厂。用户第一次遇到还能忍,第二次直接退单。

正确做法是:升级操作只碰 app 分区,数据分区保持原样。如果新版本的数据结构发生了不兼容变更,也应该是新固件在首次启动时做"数据迁移",而不是升级流程里做"数据删除"。迁移逻辑里要判断新旧版本号,老数据备份一份,迁移完成后再标记删除。做不到备份,至少要在代码里留一个预设的"分区重置接口",让用户在 Web 页面或者按键长按触发,而不是自动执行。

4.3 日志和故障转储:哪些可以随便覆盖,哪些必须保住

很多项目会在 Flash 上单独划一个日志分区,用循环写入的方式记录运行日志。日志和配置数据混在一起的时候,往往是配置被覆盖的重灾区。

日志分区的设计原则是:日志可以丢,配置必须留。那就在分区表上直接拆分,别让它们共用一个分区。我把日志放在一个独立的 SPIFFS 分区,按规定大小轮转;NVS 放"烧了不该丢"的系统配置;业务数据放另一个 data 分区。三者互相隔离,即使日志写满、反复擦除,也不影响配置和业务数据的稳定。

另一个容易忽略的是 coredump。ESP-IDF 默认在phy_init之后留了一个 0x10000 的 coredump 分区,设备崩溃时内核会把现场转储进去。如果你不知道这个分区的存在,又把它当成普通数据区来写,那你随时可能被"神秘重启"困扰——因为你把崩溃现场证据覆盖了。分区表里要是不需要 coredump,可以在配置里直接关掉CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH,腾出空间给别的数据。需要就保留,但别动它的偏移。

5. 实测排查:数据串门症状对应哪些根因

如果你已经发现数据串门了,别急着改代码。先把症状和根因对应起来,往往能节省大半排查时间。以下是我实际遇到过、也帮朋友排查过的几类典型故障。

5.1 三个典型故障的定位全过程

第一个:设备重启后 WiFi 配置消失。

排查过程是先看代码里 WiFi 配置存哪。如果用的是nvs_get_str,下一步就要确认读的是哪个命名空间。很常见的情况是:bootloader 升级了 app,新的 app 里某个模块也调用了nvs_open("config", ...),而且写了一个同名键wifi_ssid。旧 app 的 WiFi 模块存的是wifi_ssid,新 app 的传感器模块也存了wifi_ssid,两者在同一个命名空间里打架。解决方式就是给每个模块独立命名空间,WiFi 的叫wifi_config,传感器的叫sensor_cal。

第二个:应用 A 写入后,应用 B 的配置全变成 0xFF。

这个很典型,这是"擦除越界"或者"地址区间重叠"的强信号。我帮人定位过,最终原因是他把业务数据存进了 0x310000 偏移处,而另一个 app 做了整块 Flash 的"垃圾清理",把 0x300000 到 0x340000 的区间都擦了。这两个区间的 offset 没对齐,四百多 KB 的范围里,上面是 B 的数据,下面是 A 的数据,一擦全没。解决办法是:任何全局擦除操作都必须缩小到本分区范围,并且用esp_partition_find_first动态获取 offset,而不是硬编码。

第三个:OTA 升级后,Web 页面里的设备名变回默认值。

这种多半不是串门,而是新固件跑起来了,但它的 NVS 读写代码和旧版不兼容。比如旧版键名是dev_name,新版改成了device_name,读取失败就走默认值。表面看是"数据被抹掉了",其实是"数据没变,读取方变了"。这种情况只要做一下键名兼容映射或者版本号迁移就能解决,不需要动分区表。

5.2 用 esptool 和比较工具还原现场

数据都串门了,怎么证明"它在串门"?最好的办法是直接把 Flash 的内容抓出来看。

# 把整块 4MB Flash 导出来 esptool.py -p /dev/ttyUSB0 -b 460800 read_flash 0x0 0x400000 flash_dump.bin # 抓指定区间的数据 esptool.py -p /dev/ttyUSB0 -b 460800 read_flash 0x310000 0xC0000 user_data_dump.bin

然后把 dump 文件用十六进制编辑器打开,对照分区表逐段看。正常的情况下,每个分区的数据尾部会有明显的"空洞"(0xFF);如果发现某个分区的数据出现在其他分区的偏移上,那就是实锤的越界写。

我还习惯用xxd或者binwalk配合做关键字搜索,比如在整片 dump 里搜 WiFi 名称、设备名。如果同一个字符串出现在两个不同分区里,说明两个分区都写过这份数据,接下来就看是谁写的了。

5.3 隐蔽陷阱:类型子类型不匹配、偏移未对齐、大小四舍五入

排查过程里还有三个"看似正常却坑死人不偿命"的细节。

第一,类型/子类型不匹配。esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, "ota_1")能不能找到,取决于分区表里 Type 和 SubType 字段是否严格匹配。老项目从 ESP-IDF v4 升级到 v5,分区表的 CSV 格式、子类型枚举都变过,经常出现"分区明明在,代码就是找不到"的情况。排查时先在启动日志里加一段打印:

esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); while (it) { const esp_partition_t *p = esp_partition_get(it); printf("part %s type=0x%x subtype=0x%x offset=0x%x size=0x%x\n", p->label, p->type, p->subtype, p->address, p->size); it = esp_partition_next(it); }

第二,偏移未按 4KB 对齐。分区表的 offset 如果写成奇数,烧录时不一定报错,但运行时esp_partition_erase_range会特别严格,一个不齐就报ESP_ERR_INVALID_ARG。我见过有人把 NVS 分区设在 0x9100,理由是"想省 4KB",结果整个 NVS 读写像神经病一样时好时坏。

第三,分区大小的"四舍五入"。有人算分区大小,习惯算成近似值,比如 app 固件实际 1.8MB,他给ota_0写了 0x1A0000。这个倒是够了,但剩下的碎片空间用不了,还容易让人误以为"还有空间"继续往里塞数据。到最后所有分区加起来超过 Flash 总容量,idf.py编译时才发现。所以每次改分区,我都用脚本算一遍总和:

python3 -c "print(hex(0x4000 + 0x2000 + 0x1000 + 0x180000 + 0x180000 + 0xC0000 + 0x30000))"

确保结果严格等于 0x400000(4MB),一个字节都不能多。

6. 沉淀下来的一套分配规范和代码防御习惯

写到这里,如果你还忍得住不打瞌睡,说明你确实想把这套东西拿回自己项目里用。最后这部分,我把我自己总结的分区分配规范和代码防御习惯完整写出来,可以直接抄作业。

6.1 4MB 小空间和 8MB 充裕空间的两套经典布局

先说 4MB 的紧凑方案,适合功能明确、固件不大、不需要太多日志的项目:

名称类型子类型偏移大小用途
bootloader--0x00x8000引导程序
partition--0x80000x1000分区表
nvsdatanvs0x90000x4000系统配置
otadatadataota0xd0000x2000OTA 切换标记
phy_initdataphy0xf0000x1000RF 校准
ota_0appota_00x100000x100000主固件
ota_1appota_10x1100000x100000升级固件
storagedataspiffs0x2100000x100000Web/业务共享数据
logdataspiffs0x3100000xF0000循环日志

这份布局的好处是ota_0和ota_1各 1MB,对固件大小刚好;storage分区分给 Web 页面和业务配置文件;log独立起来,日志怎么写都不影响前面的数据。4MB 空间正好占满。

8MB 方案就在 4MB 基础上加一个独立的sys_paramsNVS 分区和更大的storage,还能留出 3MB 左右的富余给未来扩展。我不建议把日志放在 4MB 方案里占那么大空间,因为日志终究是"可抛"数据,能省则省。

6.2 分区表和代码的自检清单

每次新增模块或者调整分区,我都会过一遍下面这张清单:

  • 所有分区 offset 和 size 是否 4KB/64KB 对齐,总和是否刚好等于 Flash 容量
  • 分区表名称是否全局唯一,有没有重复的 Name
  • app 分区数量是否满足 OTA 双区切换需求,otadata分区是否保留
  • 所有新模块是否都通过esp_partition_find_first或esp_partition_find获取地址,全局搜索有没有硬编码0x...地址
  • NVS 命名空间是否每个模块独立,跨模块共享键是否单独抽出并注释清楚
  • 底层spi_flash_*调用是否都带边界检查,擦除是否限制在本分区内
  • 日志分录、coredump 分录是否和数据分区隔离
  • OTA 升级流程里是否只有 app 分区写入,没有顺手擦数据

这份清单看起来琐碎,但每一条背后都是真实事故。有一次我忘了检查第 7 条,结果一个新品发布前的压测阶段,日志分区满了,循环覆盖的时候没算好边界,一路擦到了相邻的 storage 分区。测试同事反馈"调试信息偶尔变乱码",最后定位到就是这个原因。

6.3 编译期断言配合运行时日志,尽早暴露越界风险

代码层面,我还会做两层防御。第一层是编译期断言,把"分区表里 key 分区大小必须大于某值"这种强约束写死:

// 经过预处理验证分区标签存在且大小足够 _Static_assert(USER_DATA_PARTITION_SIZE >= 0x100000, "user_data partition too small");

USER_DATA_PARTITION_SIZE这种宏可以从构建系统传入,或者直接让外部 Python 脚本解析 CSV 生成头文件,保证编译时就知道分区表里的数字。这个做法看着重,实际值得。因为一旦分区表被改小,编译立刻报错,而不是运行半年后才发现数据写不下。

第二层是运行时拦截。在统一封装的数据写入函数里,除了依赖esp_partition_*自身的边界检查,再根据写入日志做一次"实际写入位置是否落在本分区"的判断。正常情况下没有人会越界,一旦发生越界,日志里会有一条带分区名和偏移量的 ERROR,可以立刻定位到是哪个模块在乱搞。

其实说到底,"不串门"的本质就三条:分区表分配清晰、代码不再硬编码地址、异常情况能被日志抓住。前两条靠设计,第三条靠习惯。这三件事做扎实了,哪怕以后 Flash 容量变化、OTA 次数增加、模块数量翻倍,数据也不会再串门。