
做了几年 ESP32 开发遇到最多的一个“隐形杀手”就是 Flash 使用混乱。看着代码没问题固件也能烧进去但设备跑几天后突然蓝牙连不上、WiFi 配置丢了、数据莫名其妙被重置这些问题大概率都出在 Flash 分区和存储隔离上。尤其当你像标题里说的那样——多个小应用共用一个 ESP32 Flash——如果分区表和存储地址没有提前规划好很快就会出现“数据串门”A 应用写的参数把 B 应用的配置覆盖了日志存储和固件升级区域互相踩踏甚至 OTA 升级一次就把用户数据清空。这篇文章就围绕 ESP32 的 Flash 分区管理展开讲清楚数据为什么串门、怎么用分区表和 NVS 命名空间做好隔离、OTA 场景下怎么保护数据以及实战中容易踩的坑。这篇文章适合刚接触 ESP32 分区管理和存储设计的开发者也适合已经做了几个项目但一直被存储数据不稳定困扰的朋友。不需要你有多深的底层经验只要会基本的环境烧录和 C 语言基础就够了。我会从 Flash 的基础布局讲起然后给出可直接用的分区配置、代码示例和问题排查方法。看完你会发现“多个应用共用一块 Flash 而且不串门”这件事其实有一套非常成熟可靠的解法。1. 先搞清楚底层ESP32 的 Flash 到底是怎么分的ESP32 内部有一颗 SPI Flash通常容量是 4MB、8MB 或 16MB。别把它想成电脑硬盘那样可以随便分几个盘符它是一个线性地址空间从0x000000到0xFFFFFF每个地址都对应 Flash 内部的一个字节位置。你写数据、存固件、读参数本质上都是在操作这些地址区间。之所以会发生“串门”核心原因就是多个应用各自随意往这个线性空间里写东西没有统一规划。比如你写一个温湿度传感器程序把校准值存到0x100000另一个蓝牙遥控程序又把配对信息写到0x101000两个区域如果重叠或者边界没算好就会互相覆盖。更可怕的是固件升级往往也会往 Flash 里写新固件如果固件区域和数据区域挨在一起一次升级就可能把数据区擦掉。1.1 Flash 地址空间与 Bootloader、分区表的位置每颗 ESP32 Flash 在出厂后默认会有几个固定用途的区域。在 ESP-IDF 的默认布局里0x000000到0x00FFFF左右一级 Bootloader就是芯片上电后最先运行的那段代码它负责做最基本的初始化和加载分区表。0x010000到0x07FFFF左右默认的分区表区域。分区表本身也存放在 Flash 里它告诉你“这块区域是应用区、那块区域是 NVS 分区”。之后才是各个应用分区和数据分区的区域。当然实际地址会因为你用默认配置还是自定义分区方案而不同。你可以通过idf.py partition-table命令查看当前固件使用的分区表设定或者用parttool.py读取 Flash 上的实际分区表内容。这个“分区表”很关键它相当于城市规划图规定好哪里建住宅、哪里建公园、哪里是工厂区。没有这张图各个应用就像无头苍蝇哪里能写就写哪里。1.2 分区表Partition Table是隔离的第一道防线ESP-IDF 工程里有一个partitions.csv文件它就是分区表定义。每一行说明一个分区的名称、类型、子类型、偏移地址和大小名称类型子类型偏移地址大小nvsdatanvs0x90000x6000otadatadataota0xf0000x2000app0appota_00x100000x300000app1appota_10x3100000x300000storagedatafat0x6100000x200000logdatafat0x8100000x100000你可以看到每个分区都有明确的边界。固件只能写 app0/app1 分区NVS 只能写 nvs 分区日志和用户数据写在各自的 fat 分区里。只要各个应用都通过 ESP-IDF 提供的 API比如esp_ota_ops、nvs_get/set、esp_vfs_fat_spiflash_mount来读写数据系统会在底层帮你做越界检查和偏移换算数据想串门都难。但如果有人不走这些 API直接读写 Flash 的裸地址那分区表也拦不住。1.3 启动流程决定了你在哪块地上盖房ESP32 上电后Bootloader 会读分区表中的 otadata 分区来确定当前应该启动 app0 还是 app1。这个机制就是为 OTA 升级准备的你可以在 app0 上写新版本写完后把 otadata 里的启动标志改成指向 app0重启后芯片就运行新固件。如果新固件启动失败Bootloader 还能根据回滚机制重新切回 app1。这个流程说明一个事你要想安全地做 OTA 升级和多应用共存分区表里必须放 bootloader、partition table、nvs、otadata 这些基础分区再预留至少两个应用分区。否则你只能在一个 app 分区上原地覆盖升级一旦升级失败设备就变砖了。当然这也是我见过“串门”率最高的一个地方很多人为了省空间把 otadata 分区去掉结果 OTA 升级时状态无法保存导致固件和数据互相踩踏。2. 不让数据串门的核心设计思路理解 Flash 布局只是第一步真正要在代码层面做到“多个小应用互不打扰”需要一套完整的存储设计思路。我把它拆成三个层次物理分区隔离、逻辑命名空间隔离、升级过程中的数据保护。2.1 把“应用固件”和“用户数据”分开最基础也最容易做到的一点就是“应用固件”和“用户数据”绝对不能放同一个分区。很多人图省事把配置参数和日志直接存在 app 分区里的只读常量区附近或者干脆用一个全局数组 自定义 Flash 写函数。一开始确实验证方便也不容易发现什么问题直到你执行一次 OTA 升级新固件会把 app 分区整体擦掉再写入你的“配置数据”自然就灰飞烟灭了。所以正确做法是给用户数据分配一个独立分区而且这个分区的大小要留足余量。常见方案有两个data/nvs分区适合存放键值对设置项比如 WiFi 账号密码、设备 ID、校准值等接口简单适合小数据量场景。data/fat分区适合存放日志文件、网页资源、大数据文件相当于一个小 U 盘可以挂载成文件系统使用。划分时还要注意别把两个应用的配置数据放在同一个 NVS 分区里除非你对命名空间有极强的控制力。更稳妥的是给每个应用分配独立的 NVS 子分区或者在同一 NVS 分区中严格使用不同命名空间。稍后我会详细讲。2.2 NVS 命名空间隔离与 key 命名规范NVSNon-Volatile Storage是 ESP-IDF 专门提供的一种轻量级键值存储底层就是一个 key-value 数据库。它的关键特性是支持“命名空间”nvs_handle_t handle; esp_err_t err nvs_open(app1_config, NVS_READWRITE, handle);这里面nvs_open的第一个参数就是命名空间名字。不同命名空间之间的 key 是互不可见的A 应用存了一个叫ssid的 keyB 应用在另一个命名空间里也可以存自己的ssid两者不会互相覆盖。那么问题来了如果不使用命名空间所有应用都把 key 塞进默认命名空间例如用nvs_open(nvs, ...)那么完全可能出现“两个应用用了同一个 key 名一个把另一个覆盖”的情况。我曾经在一个项目里见过LED 控制应用把亮度存为power另一个配网应用把开关状态也存为power结果每次配网都会把灯亮度重置排查了很久才反应过来是 key 冲突。建议的做法是每个独立功能模块使用独立命名空间命名要有业务含义比如wifi_cfg、ble_bond、sensor_cal、device_info。同一个命名空间内的 key 也要规范统一统一用小写下划线避免跨硬件版本时大小写不一致。NVS 分区里避免存大块数据比如超过 4KB 的 blob不然会严重消耗 NVS 的稀疏表空间还可能触发垃圾回收机制导致写入延迟。2.3 OTA 升级下的数据保护策略OTA 升级是最容易引发“串门”的场景因为升级过程会大量擦写 Flash。如果你只分配了一个 app 分区继续用“原地升级”模式那升级时 Flash 擦除范围可能包含相邻区域。如果相邻区域正好是你的数据存储区升级完成后数据全废。推荐的做法使用双 app 分区ota_0、ota_1配合 otadata 分区让 ESP-IDF 管理切换这样升级过程中老固件始终保留新固件写在另一个分区即使升级失败也能回滚。用户数据分区独立于 app 分区且升级过程中绝不让固件擦除数据分区。ESP-IDF 的 OTA API 本身就只操作指定 app 分区不会碰数据分区所以只要你在分区表里把 app 和数据分区分开“升级后数据丢失”的性能问题基本就不会出现。NVS 分区中保存一个“固件版本号”和“分区表表版本号”。升级后启动新固件时先读取这两个版本如果发现固件升级到了新版本且数据结构不兼容就执行数据迁移逻辑避免新代码解析旧数据导致崩溃。3. 实操一个多应用共存的完整分区方案讲了这么多原理下面我以一个实际项目为例在一块 4MB Flash 的 ESP32 上同时部署“温湿度采集”“蓝牙遥控”“OTA 升级”三个功能模块并且三者都有自己的参数、日志和状态数据。这个案例很典型覆盖了标题里“多个小应用共用一块 Flash”的核心诉求。3.1 实际案例温湿度传感器 蓝牙控制 OTA 多合一我先描述一下需求背景。设备需要定时采集温湿度并通过 WiFi 上报支持手机蓝牙进行参数配置和手动控制同时支持 OTA 远程升级。用户数据包括温湿度采集模块传感器校准参数offset、scale、采集周期、上报地址。蓝牙控制模块配对密钥、绑定设备列表、手动亮度/开关状态。系统公共数据设备名称、日志文件、当前固件版本、升级状态记录。如果不做分区隔离最简单粗暴的做法是开一个全局结构体把所有配置字段放在一起再用esp_partition_write写到一个固定地址。这样写起来快但后面每加一个字段都要重新设计结构体而且任何模块写配置时都有可能破坏其他模块的数据布局。分区化方案才是可持续的。3.2 分区表 CSV 怎么写在menuconfig中选择自定义分区表 CSV然后把下面的内容存到partitions.csv# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, app0, app, ota_0, 0x10000, 0x1C0000, app1, app, ota_1, 0x1D0000, 0x1C0000, wifi_cfg, data, nvs, 0x390000, 0x4000, ble_cfg, data, nvs, 0x394000, 0x4000, device, data, nvs, 0x398000, 0x4000, storage, data, fat, 0x39C000, 0x80000, log, data, fat, 0x41C000, 0x40000,这里有几个关键计算4MB Flash 总大小是0x400000所以偏移地址加分区大小不能超过0x400000。0x10000是默认的应用起始地址Bootloader 和分区表占用了它之前的空间。每个 app 分区给了0x1C0000约 1.75MB两个 app 共占约 3.5MB剩下的空间分给 NVS 和各种数据分区。wifi_cfg、ble_cfg、device三个分区用的都是data/nvs类型这是因为每个 NVS 分区可以有独立的命名空间集合把不同应用的 NVS 数据放到不同物理分区虽然看起来空间利用率低一些但隔离效果最强排查问题也方便。storage和log使用data/fat类型挂载成 FatFS 文件系统日志和数据文件互不干扰。有人可能会问既然 NVS 区分命名空间就够了为什么还要分多个data/nvs分区答案是隔离层级越深风险越小。万一某应用的 NVS 底层因为写入异常损坏了整个分区其他应用的数据还能存活。代价是每个分区的尾部都有一些 NVS 内部开销总共也就几十 KB在 4MB Flash 面前完全可以接受。3.3 NVS 隔离访问代码示例下面这段代码展示了三个模块如何使用各自独立的 NVS 分区#include nvs.h #include nvs_flash.h // 初始化 NVS 分区时要把所有 data/nvs 分区都初始化 void init_all_nvs_partitions(void) { nvs_flash_register(); // 注册分区表ESP-IDF 5.x 推荐 API // 分别访问三个 NVS 分区 nvs_handle_t wifi_handle; nvs_open_from_partition(wifi_cfg, wifi_cfg, NVS_READWRITE, wifi_handle); nvs_set_str(wifi_handle, ssid, MyHomeWiFi); nvs_commit(wifi_handle); nvs_handle_t ble_handle; nvs_open_from_partition(ble_cfg, ble_cfg, NVS_READWRITE, ble_handle); nvs_set_u32(ble_handle, bind_key, 0x12345678); nvs_commit(ble_handle); }重点注意这个 APInvs_open_from_partition。它允许你指定操作哪个 NVS 硬件分区同时指定命名空间。如果用的是常规nvs_open它只能操作默认 nvs 分区无法跨分区访问。项目中如果各模块共用同一个 NVS 分区那至少也要把命名空间区分开nvs_handle_t handle; nvs_open(wifi_cfg, NVS_READWRITE, handle); // 命名空间 wifi_cfg nvs_set_str(handle, ssid, MyHomeWiFi); nvs_commit(handle); nvs_open(ble_cfg, NVS_READWRITE, handle); // 命名空间 ble_cfg nvs_set_u32(handle, bind_key, 0x12345678); nvs_commit(handle);第二种写法是同一分区不同命名空间简单项目够用第一种是不同物理分区隔离更强。你在实际项目中可以按复杂度和风险偏好选择。3.4 数据分区FatFS读写示例与日志管理data/fat分区通过 FatFS 挂载后可以直接用标准 C 的文件操作接口。下面是我在log分区写日志的常用代码#include esp_vfs_fat.h #include esp_partition.h static wl_handle_t s_wl_handle WL_INVALID_HANDLE; void mount_log_partition(void) { const esp_partition_t *log_partition esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_FAT, log); esp_vfs_fat_spiflash_mount_rw_wl(/log, log, s_wl_handle, CONFIG_LOG_PARTITION_MAX_SIZE); } void write_log(const char *msg) { FILE *fp fopen(/log/app.log, a); if (fp) { fprintf(fp, %s\n, msg); fclose(fp); } }这里esp_vfs_fat_spiflash_mount_rw_wl里最后一个参数是分区大小必须和你分区表里 log 分区的大小一致否则挂载会报错。在 4MB Flash 场景下log分区 256KB 已经能存很多条文本日志。注意日志分区不能一直无限写文件系统满了之后fopen会失败或写入异常你可以定期清理或者用“写满删旧”的策略。比如启动时检查日志文件总大小超过阈值就把.1后缀的老日志删除。4. 实战中遇到的那些坑理论方案再完美真正上手后还是会碰到各种怪问题。我把自己踩过、也帮别人排查过的典型坑列出来这些案例都是“数据串门”的常见表现形式。4.1 NVS 莫名其妙丢数据其实是 Key 冲突现象设备运行正常某天升级了 Wi-Fi 配网模块的固件后传感器校准参数丢失了恢复出厂设置但之前并没有主动清空数据。排查思路首先查两个模块的 NVS key 名。结果发现配网模块用nvs_set_str(mode, AP)传感器模块也用nvs_set_u32(mode, 100)存储采样模式。两个 key 都叫mode但类型不同。ESP-IDF 的 NVS 在写入新类型数据时会尝试读取旧数据并做类型检查如果类型不匹配写入就会失败或者覆盖旧数据结果就是校准参数被重置。解决办法统一 key 命名前缀比如sensor_mode、net_mode并且强烈建议不同类型的 NVS key 不要重名。更好的方案是如前文所说给不同模块分命名空间。4.2 OTA 后数据全没了otadata 分区缺失现象首次使用 OTA 升级成功后设备能正常运行但所有 NVS 数据全部丢失连 WiFi 配置都要重新配。排查结果工程的分区表里没有otadata分区。ESP-IDF 在 OTA 时如果找不到otadata分区会退回一个默认状态并使用默认的 NVS 分区甚至可能会擦除某些区域来保存 boot 状态。某些版本下这会导致 NVS 区域被初始化。说白了otadata 不是一个可有可无的分区它是重启后决定“启动哪个固件”的唯一依据。没有它OTA 状态就无法保存。解决办法分区表里一定要有otadata, data, ota, 0xf000, 0x2000。哪怕你暂时不做 OTA只要预留了这个分区未来升级就不用改分区表也避免新版固件和老分区表不匹配的问题。4.3 分区表改了之后烧录无法启动现象修改了partitions.csv增加了一个data/fat分区重新编译烧录后设备一直打印TRACE错误或者不断重启无法进入主程序。原因Flash 中原来的分区表还是旧的Bootloader 按旧分区表读到的新固件的app分区可能不在有效位置或者新分区表指定了无法满足的启动条件。另外如果你用esptool.py write_flash只烧了 app 和分区表没有擦除整个 Flash旧的分区数据签名和新的分区表会在后续启动时产生冲突。解决办法烧录时使用idf.py erase-flash先全擦一遍再烧整个新镜像。或者在menuconfig里打开“Partition Table - Factory app will be reset on first boot”之类的选项取决于你的 SDK 版本确保新分区表和 NVS 内容兼容。更推荐的做法是分区表变更后先用idf.py erase-flash idf.py flash全量刷一遍再进入后续调试避免各种脏状态。4.4 Flash 越界写入直接把日志区写坏了现象蓝牙控制模块的程序里用了第三方库这个库内部用esp_partition_write直接写一个固定地址比如0x3A0000。这个地址原本在旧分区表里是“蓝牙配对数据区”但你把分区表调整之后这个地址正好落在storage分区中间。结果每次蓝牙配对都导致存储区文件系统崩溃设备上的温度曲线记录全丢了。这是非常典型的“绕过分区 API”引发的问题。ESP-IDF 的nvs、FatFS、OTA接口都自带分区上下文和偏移检查你可以放心使用。但任何第三方库尤其是那些直接操作 Flash 的库都可能带有硬编码地址。排查方式就是全工程搜索0x开头的 Flash 地址常量看看它们是否合法。如果第三方库会直接读写 Flash 地址一定要用esp_partition_get_actual_offset之类的 API 换算才行。4.5 用 parttool.py 与 idf.py 辅助排查当你已经遇到“串门”问题最快的定位方法是先读出 Flash 的实际分区表和各分区内容。读取分区表python esp-idf/components/partition_table/parttool.py \ --port /dev/ttyUSB0 \ read_partition --partition-type0 --partition-subtype0 \ --output partition_table.bin读取某个分区的原始内容python esp-idf/components/partition_table/parttool.py \ read_partition --partition-namestorage \ --output storage_dump.bin拿到storage_dump.bin后可以用strings命令看看里面有没有别应用写入的关键词比如 Wi-Fi SSID、传感器配置项之类马上就能判断是否被别人写入了。这个技巧在排查“日志区里有别人数据”这种诡异问题时特别有用。5. 我的经验总结与后续可扩展方向这段话不是收尾套话而是真正想分享的几条经验。在实际开发中遇到“多个小应用共用 Flash 要不要做分区隔离”这个问题时我的建议永远是一句话从一开始就做分区而且宁多勿少。画分区表只需要十分钟但数据串门的问题一旦发生在产线上排查的成本可能是几个通宵。尤其当设备已经量产、OTA 已经推出去之后再想改 NVS key 命名规范或者分区布局代价会非常大。所以我会在项目早期就把以下几个问题全部确定好固件有几个 OTA 槽位哪些模块的数据必须持久化日志需要保留多久将来会不会增加新模块想清楚这些再动手写partitions.csv。一个小技巧如果你不确定某个模块将来会不会用到大数据存储就在分区表里给它预留一个小fat分区哪怕现在只放了几个配置文件。日后你要加音频文件、图片资源或更长的日志时就不用为改分区表而被迫全量刷机。另外跨版本升级时一定要把分区表版本号写入 NVS每次启动检查版本旧版本数据自动迁移。我用这个方案维护过多个批量设备确实把“升级后数据丢失”的概率降到很低。如果你有兴趣继续深入可以往这几个方向再挖一挖Flash 加密与esp_partition的安全属性配置在CONFIG_ESPTOOLPY_FLASHSIZE_*与分区表大小之间的一致性校验FatFS 分区掉电损坏后的重启自恢复以及 ESP32-S3 / ESP32-C3 上同样的分区策略有哪些细微差别。每一个细节展开来都是不错的实战话题也欢迎你自己踩坑后分享出来。