做 ESP32 项目做得久了你会发现最容易出事的往往不是功能逻辑而是 Flash 数据隔离。一块几 MB 的 Flash 上可能同时住着好几个“小应用”主控逻辑要存配置Web 界面要写参数OTA 升级要放固件日志模块还要不断追加记录。大家都用同一个 SPI Flash谁都不觉得自己会越界结果一越界就是配置被冲、日志乱写、启动校验失败。这篇就把这个问题讲透多个小应用共用 ESP32 的一块 Flash 时怎么用分区表把数据隔开以及我踩过哪些坑、现在怎么组织这套方案。1. 串门的本质从来不是 Flash 坏了而是分区表没立好规矩1.1 Flash 本身是“全通”的你放出去的地址就是权限先说一个容易被忽略的事实SPI Flash 本质上就是一块线性存储介质硬件层面并没有“这段归应用 A、那段归应用 B”的概念。CPU 和外设通过地址线访问 Flash只要地址对得上谁都能读、能写、能擦。单片机系统不像 PC 那样有 MMU 和进程隔离ESP32 上也没有一套操作系统帮你管内存保护。所谓的“物理隔离”从一开始就是软件层面的约定。很多入门者习惯直接对 Flash 地址操作觉得“我只要记住数据写在 0x310000就不怕丢”。这种思路短期没问题一旦分区表调整、固件升级、Flash 容量变化所有写死的地址全部作废。更可怕的是ESP32 的 Flash 是没有越界异常的你往某个地址写数据硬件不会告诉你“这个地址不属于你”只会默默覆盖。等覆盖到了别的应用的数据程序表现就是莫名其妙的配置丢失、校验失败、启动崩溃。我用一个合租房的类比来理解这件事一块 Flash 就是一套大开间的房子几个人合租互相能看到对方的东西。你不想串门就得靠自己在房间里砌隔断。分区表就是那张“隔断施工图”而代码里老老实实按分区表访问才是真正把门关上的动作。1.2 串门的具体形态和数据损坏不是一回事数据串门常见的形态就三种我分别说一下固件区被数据覆盖某个小应用擦写时把绝对地址算错了写进了 app 分区设备重启后 bootloader 校验镜像失败直接开不了机。数据区互相覆盖两个应用用了同一个偏移地址后写入的一方覆盖先写入的一方参数、校准值、用户配置错乱。日志蹭了配置区日志分区分配过小写满后继续写溢出的部分跑进了相邻的数据分区把别人的文件索引或者关键参数冲掉。这些现象看起来都像是数据损坏但根子往往不是 Flash 芯片质量问题而是边界定义不清。Flash 本身写坏的概率很低多数“串门”都是人为划界不清造成的。所以解决思路不是换芯片而是把分区表当成一等公民来对待。1.3 默认分区表已经把房间画了一部分ESP32 出厂固件默认带了一套分区表烧录时烧在 Flash 的 0x8000 位置。默认配置大致是这样分区名类型偏移大小作用nvsdata0x90000x4000 (16KB)非易失存储保存 WiFi 配置、校准参数等phy_initdata0xd0000x1000 (4KB)PHY 初始化数据factoryapp0x10000由 Flash 容量决定应用程序固件spiffsdata剩余空间剩余空间文件系统存网页、配置、日志等注意几个固定位置0x1000 是 bootloader0x8000 是分区表0x9000 起是 nvs。这些地址虽然也可以调整但一般没人动因为牵扯到一级 Bootloader 的固化逻辑。问题在于默认表只有一个 factory 区和一个数据区如果同一块 Flash 上要跑多个独立的小应用比如一个负责配置管理、一个负责日志、一个负责 OTA默认表根本不够用。于是很多人开始“借用” spiffs 区的剩余空间或者直接拿未分配的地址来写数据。这就像合租房里没人画隔断图每个人凭感觉占地方最后必然打架。正确的做法是自己做一张 partitions.csv把每一个小应用的存储空间在分区表层面明确划分好。这是数据隔离的第一道、也是最关键的一道锁。2. 手动定义 partitions.csv给每个小应用发一把独立钥匙2.1 六个字段到底代表什么ESP-IDF 用 CSV 文件描述分区表每行一个分区字段分别是 Name、Type、SubType、Offset、Size、Flags。我逐个拆一下Name分区名字纯粹是逻辑标签。代码里通过esp_partition_find_first查找分区时主要靠这个字符串定位。命名要语义清晰比如app_a_cfg、log_data不要起data1、data2这种看完就忘的名字。Type大类只有app和data两种。app分区放固件镜像data分区放数据。SubType细分类型。app下面常见factory、ota_0、ota_1、testdata下面常见nvs、ota、phy、spiffs、fat。也可以使用自定义值比如0x40表示自定义数据分区类型不过日常用 spiffs/fat 就够了。Offset分区在 Flash 里的物理起始地址十六进制表示。这个字段是隔离的关键必须以 Flash 擦除单位对齐。Size分区大小同样需要对齐。Flags可选字段支持encrypted和readonly。Flash 加密场景下可以把敏感数据分区标记为 encrypted。默认不写。关于对齐有一个容易踩的细节ESP32 的 SPI Flash 最小擦除单位是 4KB所以分区表理论上只要求 offset 和 size 都是 4KB 的倍数。但实践里我强烈建议让 app 区和数据区都按 0x1000064KB对齐。原因有两个一是 OTA 固件镜像计算长度时习惯按 64KB 对齐来预留升级空间二是分区表的可读性好每个分区占几个 0x10000 一眼就能算出来。唯一例外是 nvs、phy_init 这类固定小分区沿用官方默认的紧凑位置可保证兼容性。2.2 两个小应用共用一个 8MB Flash 的落地配置直接上一个我常用的配置这是一块 8MB Flash板子上有两个独立的小应用模块加一个日志模块# Name, Type, SubType, Offset, Size, nvs, data, nvs, 0x9000, 0x4000, phy_init, data, phy, 0xd000, 0x1000, factory, app, factory, 0x10000, 0x200000, app_a_data, data, spiffs, 0x210000, 0x100000, app_b_data, data, spiffs, 0x310000, 0x100000, log_data, data, fat, 0x410000, 0x100000, # 剩余空间0x510000 - 0x800000 留作扩展我解释一下这个布局的思路factory 分区 2MB足够放一个中等复杂度的固件。如果你用 Arduino 框架默认 app 分区经常只有 1.3MB自己定义 2MB 会更从容。app_a_data 和 app_b_data 各 1MB一个应用一个独立分区谁也不碰谁。log_data 是专门给日志模块的 FAT 分区1MB 空间写完一轮自己轮转。最后从 0x510000 到 0x800000 还有接近 3MB 的剩余留着以后加小应用或者给现有分区扩容。这个配置里最核心的一点每一个小应用都有自己名下的数据分区找分区靠名字不靠偏移。A 应用要写数据调用esp_partition_find_first找app_a_dataB 应用找app_b_data。哪怕以后调整了偏移代码里的逻辑不用改。如果你用的是 4MB Flash可以把这个方案压缩一下factory 改成 1.5MB两个数据分区各 512KB日志分区 384KB基本也能跑。但说实话4MB Flash 要同时满足多应用、OTA、日志空间非常紧张有条件还是优先选 8MB 以上的模块。2.3 加了 OTA 之后分区表怎么重排需要 OTA 时布局逻辑会变。OTA 的原理是固件有两个交替使用的 app 分区ota_0 和 ota_1启动时由 otadata 分区记录当前应该从哪个分区启动。分区表大概长这样# Name, Type, SubType, Offset, Size, nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, ota_0, app, ota_0, 0x10000, 0x200000, ota_1, app, ota_1, 0x210000, 0x200000, app_a_data, data, spiffs, 0x410000, 0x100000, app_b_data, data, spiffs, 0x510000, 0x100000, log_data, data, fat, 0x610000, 0x100000, # 剩余空间0x710000 - 0x800000注意几个地方otadata 放在 0xd000大小 0x20008KB。它记录 OTA 切换信息属于 data 类型、ota子类型。这个东西不能省没有它 OTA 功能无法工作。ota_0 和 ota_1 必须大小一致因为固件下载到备用分区后要保证能装得下当前固件版本。数据分区全部放在两个 app 分区之后。这样 OTA 升级时只擦写 app 分区不会碰数据分区。日志分区照旧独立防止日志写满影响别的区域。用这套表做出来的设备我实测过新固件通过 OTA 下载到 ota_1校验通过后启动到 ota_1原来的 ota_0 变成下次升级目标。整个过程中app_a_data、app_b_data、log_data 三个数据分区完全不受干扰数据一次都不会丢。3. 分区表只是规划图纸代码还要守住四个容易突破的口子分区表画好了不等于数据隔离就成功了。实际开发中我看到太多人栽在代码层。下面四个口子是最常见的漏网之鱼。3.1 绝对地址读写是最常见的“拆门”操作有些老代码或者示例程序会写成这样// 错误示范绕过分区表直接拿绝对地址读写 esp_flash_read(esp_flash_default_chip, buf, 0x310000, sizeof(buf));这种写法看起来很爽因为不用查分区不用管类型。但后果很严重一旦分区表调整过0x310000 可能不再是 app_b_data而是变成了 app_a_data 甚至 ota_1。代码读出来的数据是错的写进去的数据会覆盖别人。正确做法是永远用分区 API#include esp_partition.h const esp_partition_t* part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, app_b_data); if (part NULL) { // 没找到分区说明分区表和代码不一致要处理不要硬往下走 return ESP_ERR_NOT_FOUND; } // 所有读写都基于 part-address part-size 的范围内偏移 esp_partition_read(part, 0, buf, sizeof(buf)); esp_partition_write(part, 0, buf, sizeof(buf)); esp_partition_erase_range(part, 0, part-size);注意esp_partition_t结构体里有address和size。用esp_partition_read/write时传入的是相对于该分区起始地址的偏移不用关心绝对地址是多少。这样即使分区表偏移变了代码逻辑完全不用动。我接手过一些项目里面混用了绝对地址读写和分区 API排查起来极其痛苦。后来我立了一条规矩整个工程禁止直接写 flash 绝对地址。所有 Flash 访问都必须通过esp_partition_xxx或 NVS 接口。这条规矩帮我少踩了很多坑。3.2 NVS 命名空间不是物理隔离条件允许就分两个 NVS 分区NVS 是 ESP-IDF 提供的键值存储很多人以为只要 namespace 不同数据就天然隔离了。这个理解有偏差。NVS 的 namespace 只是逻辑分组数据始终存放在同一个 NVS 分区里共用同一个存储空间。举个实际例子应用 A 在 namespacecfg_a下写了几百个键应用 B 在 namespacecfg_b下写大量日志缓冲。当分区空间不足时A 的写入会失败B 也无法继续写两者互相拖累。更隐蔽的情况是如果一个应用通过nvs_erase_all清数据它会清掉整个 NVS 分区的内容另一个应用的数据也跟着没了。NVS 本身的设计目标是轻量键值存储不是多租户隔离系统。如果多个小应用互不信任、数据重要程度不同我建议给每个应用划分独立的 NVS 分区。分区表里这样写# Name, Type, SubType, Offset, Size, nvs, data, nvs, 0x9000, 0x4000, nvs_app_a, data, nvs, 0xd000, 0x4000, nvs_app_b, data, nvs, 0x11000, 0x4000, phy_init, data, phy, 0x15000, 0x1000,注意这里 nvs_app_a 和 nvs_app_b 的 SubType 依然是nvs只是 Name 不同。初始化时用对应的初始化函数// 初始化指定名字的 NVS 分区 esp_err_t err nvs_flash_init_partition(nvs_app_a); if (err ESP_ERR_NVS_NO_FREE_PAGES) { // 分区满了先擦掉再初始化 const esp_partition_t* partition esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_NVS, nvs_app_a); ESP_ERROR_CHECK(esp_partition_erase_range(partition, 0, partition-size)); err nvs_flash_init_partition(nvs_app_a); } nvs_handle_t handle; nvs_open_from_partition(nvs_app_a, cfg, NVS_READWRITE, handle);这样每个应用有自己的 NVS 存储池互不影响。代价是分区表里多几条记录。在做数据隔离方案时这个代价是值得的。3.3 多个小应用共用一个文件系统是隔离里最暧昧的区域如果实在舍不得给每个应用单独的 spiffs/littlefs 分区共享一个文件系统分区时至少要做到目录级隔离。比如 app_a 的数据放在/app_a/目录下app_b 的数据放在/app_b/目录下。这样的好处是文件名冲突的概率降到很低。但目录隔离只解决命名冲突解决不了空间耗尽问题。两个应用共享一个 1MB 分区app_a 写满 800KBapp_b 还剩 200KB 可用app_b 仍然会写入失败甚至文件系统索引错乱。如果 app_a 突然要写一个 600KB 的文件分区直接满app_b 正在用的文件也可能被影响到。所以我的建议是真要做隔离就做彻底。每个小应用独立数据分区是性价比最高的方案。如果因为硬件限制实在分不开至少约定每个应用只能在分区内固定的子目录活动并且应用内做容量自查。不要把“共用”当成省事的借口共用文件系统是隔离能力最弱的一环。3.4 中断回调里写 Flash等于在看不见的地方开门还有一个口子藏在任务调度层。ESP32 的 Flash 写操作不是瞬时的尤其是擦除一个扇区擦除可能耗时几十毫秒甚至上百毫秒。如果在中断服务函数ISR里调用 Flash 写接口轻则任务卡死重则数据写到一半被另一个任务打断Flash 内部状态错乱。更隐蔽的是ESP-IDF 的 Flash 访问默认有锁保护但如果你用了绝对地址访问或外部库绕过esp_partition直接调底层驱动锁保护就被绕过了。两个任务同时擦写 Flash 时正常代码会排队绕过锁的代码会插队最终数据交错、索引损坏。我的习惯是所有 Flash 写操作都放在任务上下文通过消息队列把写请求发给专门的存储任务。中断里只设置标志位绝不直接操作 Flash。这是多应用共用一块 Flash 时必须遵守的底线。4. 一次把日志写到固件区的事故完整排错链路讲完了理论分享一个我实际遇到的事故。这个过程能帮你看清“串门”到底是怎么发生的。4.1 事故现场加 OTA 后设备集体无法启动项目原本用默认分区表固件逻辑里有一个日志模块当时图省事直接把日志追加写在 factory 区后面的空闲区域大概在 0x410000 附近。当时的 Flash 是 8MB默认表只用了前面一部分后面大块空间都空着。日志模块用绝对地址写跑了一两个月都没事。后来产品要支持 OTA我把分区表改成了 2.3 节那种带 ota_0、ota_1 的布局。新表里原来日志模块写入的 0x410000 附近变成了 app_b_data 甚至 log_data 的领地。我当时只重新烧了 bootloader、分区表、最新 app 固件没有整片擦除 Flash。设备重启后bootloader 报错无法进入正常启动流程。4.2 逐步排查找到根因第一步接串口看启动日志。idf.py monitor打印到一半停了提示invalid app image。这说明 bootloader 在某个 app 分区没有找到合法镜像。第二步怀疑是分区表没烧进去于是导出当前 Flash 里的分区表esptool.py -p /dev/ttyUSB0 read_flash 0x8000 0x1000 partition_table.bin这里注意bootloader 真正使用的是 Flash 里的分区表而不是编译机上的 CSV。如果你只编译了 app 并烧进去没烧分区表Flash 里还是旧表。第三步用 parttool 工具核对当前分区信息python3 $IDF_PATH/components/partition_table/parttool.py -p /dev/ttyUSB0 get_partition_info结果确认当前 Flash 里的分区表确实是带 OTA 的新表ota_1 起始地址在 0x210000。到此为止系统还在正常范围。第四步读取疑似被日志污染的区域。我用 esptool 把 ota_1 起始区域读出来esptool.py -p /dev/ttyUSB0 read_flash 0x210000 0x20 app_region.bin用十六进制工具打开发现内容不是合法的 ESP32 镜像头正常应该是E9而是一堆 ASCII 日志字符串和时间戳。真相大白旧日志模块用绝对地址写的数据还留在原处新分区表把这块地方分配给了 ota_1bootloader 去校验 ota_1 时读到的是一堆日志自然报invalid app image。4.3 修复方案与事后反思修复其实不复杂先备份必要的 NVS 数据。整片擦除 Flashesptool.py -p /dev/ttyUSB0 erase_flash。这一步把旧日志在 0x210000 附近留下的脏数据全部清掉。重新烧录 bootloader、分区表、所有 app 镜像。把启动模式切到 factory 或 ota_0确认能够正常启动。改造日志模块废弃绝对地址写法改用分区 API 写入 log_data 分区。这次事故给我留下的教训非常深。我把它总结成几条大家可以直接记下来分区表是全局资源改动前必须考虑现存 Flash 数据是否还有效。绝不能在旧数据仍然占据地址的情况下直接变更布局。只要以前用过绝对地址写 Flash改分区表之前务必先擦除整片 Flash否则脏数据会污染新分区。日志这类持续写入的模块通常应该划给独立分区并做循环覆盖而不是依赖“后面还有空闲空间”。5. 我现在用的“一板多 App”方案可直接参考最后给出一套我现在实际在用的方案分两种场景可以直接抄。5.1 无 OTA、纯多应用数据隔离适用于不需要远程升级、但同一块 Flash 上要跑多个数据模块的场景。8MB Flash 配置如下分区名Type / SubType偏移大小说明nvsdata / nvs0x900016KB公共 NVS放 reboot 计数等phy_initdata / phy0xd0004KBPHY 初始化数据factoryapp / factory0x100002MB主固件app_a_datadata / spiffs0x2100001MB应用 A 独立存储app_b_datadata / spiffs0x3100001MB应用 B 独立存储log_datadata / fat0x4100001MB日志分区循环覆盖剩余空间—0x510000待分配后续扩展用4MB Flash 的话把 factory 压到 1.5MBapp_a_data 和 app_b_data 各 512KBlog_data 384KB也能工作但余量很小。如果产品计划内有多应用需求我强烈建议直接选 8MB 或 16MB 的 ESP32 模组Flash 成本差不了多少后续省心很多。5.2 需要 OTA、又要多应用数据隔离这个方案适合商业化产品既要远程升级又有多个小应用要独立存储。8MB Flash分区名Type / SubType偏移大小说明nvsdata / nvs0x900016KB公共 NVSotadatadata / ota0xd0008KBOTA 切换记录phy_initdata / phy0xf0004KBPHY 数据ota_0app / ota_00x100002MB固件 Aota_1app / ota_10x2100002MB固件 Bapp_a_datadata / spiffs0x4100001MB应用 A 数据app_b_datadata / spiffs0x5100001MB应用 B 数据log_datadata / fat0x610000512KB日志分区ota_0 和 ota_1 各 2MB两个固件加数据区总共大约 6.5MB在 8MB Flash 内能放得下。如果日志需求大可以把 log_data 提到 1MB但 app 分区就要相应缩小。这是一个此消彼长的取舍设计时最好先把每个应用的数据量上限列出来再定。5.3 配置和验证的完整流程这个方案从零搭起来我建议按下面的顺序走在 ESP-IDF 工程里新建一个 partitions.csv内容按上面表格填写。在idf.py menuconfig的 Partitioning 选项里把分区表方案改成 Custom partition table CSV并指定文件路径。重新 build烧录时记得idf.py -p /dev/ttyUSB0 flash monitor。这个命令会同时烧 bootloader、分区表、app顺序和 Flash 地址自动处理比自己用 esptool 一个个烧省心。启动后看串口日志ESP-IDF 会打印当前使用的分区表信息确认每一个分区的 offset 和 size 都符合预期。写一个自检程序向 app_a_data 写一串特征数据向 app_b_data 写另一串特征数据再从两个分区分别读回校验。重启后再次校验确保互不覆盖。如果有 OTA模拟一次升级全过程升级完成后检查 app_a_data 和 app_b_data 里的数据是否仍然完整。这一步自检非常关键。很多人只验证了启动正常没有验证数据完整结果上线后才发现两个应用的数据互相踩踏。我在流程里专门保留了这个步骤建议不要省。5.4 几条让你少走弯路的经验最后分享几条我反复踩坑后沉淀下来的习惯第一分区表要当成接口设计来对待。动手写业务代码之前先把分区表定下来代码里的所有分区名和用途写进注释。不要在开发中途频繁改分区表每次改动都意味着 Flash 上的旧数据可能失效。第二每个应用的数据文件头部放一个版本号。比如 app_a_data 的第一个字节存格式版本读数据时先校验版本。这样即使未来分区结构变了代码也能识别出旧数据并做迁移而不是直接崩掉。第三日志模块尽量用独立分区并且做成环形覆盖。别把日志写进 NVSNVS 是为高频小数据设计的不是给日志这种持续追加场景用的。我之前见过有人把每一条日志都写进 NVS几十万次擦写后 NVS 直接罢工。第四如果你要在同一个分区里放两个应用的文件至少约定目录分离但最好还是分区分离。目录分离防不了空间互相挤占只有分区分离才能真正做到互不干扰。第五绝对地址写 Flash 这个操作在整个团队里要明文禁止。代码评审的时候看到esp_flash_read带绝对地址或esp_flash_write带硬编码地址一律打回重写。用分区 API 的成本很低省下的排查时间却很可观。这套“分区表 分区 API 独立数据分区”的组合我用了很多年基本没再遇到过数据串门的问题。做 ESP32 多应用共存的方案时你只要先把分区这事想清楚后面所有存储相关的需求都会顺畅很多。我自己现在的流程就是拿到一块新板子先画分区表再写存储封装最后才是业务逻辑。建议你也试试这个顺序。