先说结论:多应用共用 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 的软件体系里,NVS(Non-Volatile Storage)不是一块神秘的内存,它本质上就是一个特殊格式的数据分区。Arduino 环境下用 Preferences 库,底层最终调用的是 NVS API;ESP-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 查分区时用这个名字。
- type:app 或 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;日志可能需要几百 KB;Web 静态资源、证书、算法模型可能需要按 MB 算。
常见错误是“随便拍一个偏移量”。最稳妥的做法是让每个分区都从地址 0x10000 开始手动规划,并预留对齐空间。
一个典型的多小应用分区方案,比如 4MB Flash:
| 分区名 | 类型/子类型 | 偏移 | 大小 | 用途 |
|---|---|---|---|---|
| nvs | data / nvs | 0x9000 | 0x4000 | 系统配置、WiFi 配置、各小应用自己的参数 |
| otadata | data / ota | 0xd000 | 0x2000 | OTA 启动标记 |
| phy_init | data / phy | 0xf000 | 0x1000 | 射频校准数据 |
| factory | app / factory | 0x10000 | 0x1C0000 | 主固件(第一版业务) |
| web | data / spiffs | 0x1D0000 | 0x100000 | Web 页面、静态资源 |
| log | data / fatfs | 0x2D0000 | 0x100000 | 日志、历史数据 |
| ota_0 | app / ota_0 | 0x3D0000 | 0x1C0000 | OTA 升级备分区 |
这种方案把“小应用”拆成一个可执行固件(factory/ota_0)+ 多个数据资源分区。每个数据分区都是独立地址空间,各自通过 API 操作,谁也不会越界。
3. 实操:一个真实的“多小应用共用 Flash”分区方案
3.1 场景背景
假设我在做一个智能网关:主逻辑负责 Modbus 采集,蓝牙模块负责本地配网,内嵌 Web 服务器显示状态和历史曲线,还要跑 OTA 升级。这个项目如果只用一个分区,蓝牙配网时把 WiFi 配置写进 NVS,Web 模块又把日志写进一个“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 分别有独立 bin:
idf.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 namespace:
nvs_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 分区设成 3MB,Web 分区 500KB,日志 500KB。等要加 OTA 时发现没有第二个 slot 的位置。所以分区规划要预留扩展口,别把整个 Flash 份额全分完。至少留出 4KB 对齐的空白区域,方便后续调整。
6. 个人实操心得:分区是设计问题,不只是配置问题
说了这么多,最后分享几个我自己的习惯,算不上标准流程,但确实让我少踩很多坑。
第一,每次改完分区表,我不急着写功能代码,先写一个分区自检例程。例程启动后,把每个分区的名称、类型、偏移、大小打印出来,然后往各写一个魔法数字再读回来。这一步能在一分钟内发现 90% 的地址冲突问题。
第二,我习惯把自定义分区表纳入 git 管理,并且每次改动版本都打 tag。分区表出错往往不是立刻爆发的,可能是三个月后的某次 OTA 才出现。没有版本记录,排查起来犹如大海捞针。
第三,对于日志和高频数据,我尽量使用支持掉电保护的文件系统,而不是裸分区读写。裸分区虽然性能高,但多应用共用 Flash 时,一次越界擦除的后果几乎不可恢复。
第四,不要把“多个小应用”理解成“多个固件”。真正的模块化,应该是分区隔离、接口解耦、单一固件。这个思路在资源受限的嵌入式设备上既简单又可靠。
如果你正在做类似项目,建议直接从一个最小分区表开始,跑通 NVS/SPIFFS/OTA 三个模块后,再扩展数据分区。这样无论 Flash 最终怎么分,每个模块的访问边界都清清楚楚。