做 ESP32 开发久了你会发现一个铁律芯片上那块 Flash 就像合租屋的公共冰箱谁都能开门但谁都不爱收拾。多个小应用共用一个 ESP32 的 Flash 时“串门”是常态——今天 A 应用存好的配置明天被 B 应用随手覆盖OTA 升级一次老应用的状态全部蒸发日志越写越欢最后把别人固件占用的地址空间都挤没了。我最近梳理过三四个出现“离奇故障”的项目定位到最后无一例外都是 Flash 数据越界而不是代码逻辑本身。这篇文章我准备把 ESP32 的分区表、NVS 命名空间、分区 API、磨损均衡这几个工具一次讲透并给出一套可直接复制的多应用分区方案适合正在做多模块固件、边缘网关、小车或者任何“一个芯片干好几件事”的开发者参考。1. 先把分区表读懂Flash 里的“门牌号”决定一切1.1 ESP32 的 Flash 不是“一整块”而是靠分区表划分地盘很多人第一次接触 ESP32 时会把它当成一个“RAM 很大、ROM 很大”的普通单片机。实际上 ESP32 内部没有真正的 EEPROM所有固件、配置、文件、证书、日志都存放在外接 SPI Flash 里。这块 Flash 通过 SPI 总线挂在芯片旁边常见容量是 4MB、8MB、16MB部分模块甚至能外扩到 64MB。芯片上电后Bootloader 会先去 Flash 的固定位置读取一张“分区表”Partition Table然后严格按照分区表里写的偏移地址和大小去加载对应的固件、挂载对应的数据区域。分区表本身很小只占 4KB存储在 Flash 地址 0x8000 附近。你可以在 ESP-IDF 工程里用idf.py partition-table生成也可以通过 Arduino IDE 的“Tools - Partition Scheme”菜单选择预设方案。一个常见的 4MB Flash 出厂布局大概长这样# 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, 0x100000,你不需要把每个地址背下来但要理解一个关键概念分区表是 Flash 资源分配的“总章程”。Bootloader、固件、NVS 配置、文件系统全都被这一张表限制在各自的物理地址范围内。只要分区表不乱数据基本不会串门反过来一旦有人绕过分区表直接往 Flash 地址写数据或者分区表本身就设计得重叠那就等着各种离奇 bug 上门吧。1.2 一个分区表条目到底管了哪些事分区表的每一行有 8 个字段Name、Type、SubType、Offset、Size、Flags。在 ESP-IDF 里分区名字只是一个带标签的字符串方便代码里查找真正决定物理边界的是 Offset 和 Size 这两个字段。Type 决定这个分区是放应用程序app还是放数据data。app 类型下还有 factory、ota_0、ota_1 等子类型data 类型下则有 nvs、ota、phy、spiffs、littlefs 等子类型。系统启动时Bootloader 会先看有没有 otadata 分区有就按照 OTA 信息选择启动 ota_0 还是 ota_1没有或者 OTA 信息无效就老老实实启动 factory 分区。还有一个容易忽略的规则分区表条目必须 4KB 对齐。因为 SPI Flash 的最小擦除单位是一个扇区ESPRESSIF 官方要求偏移和大小都按 0x1000 的整数倍来设置。如果你手写分区表 CSV 时把某个分区写成了 0x12345后续编译和烧录阶段报错只是小事真正麻烦的是运行时的擦除操作很容易越界把相邻分区一起擦掉。我自己见过一次很隐蔽的事故某个文件系统分区没有按 4KB 对齐每次擦除都会把隔壁 NVS 分区的尾部扇区带走导致设备固定几天就丢一次配置。所以写分区表前先把门牌号规划清楚运行代码前再对着地址算一遍相邻关系。这是多应用共享 Flash 的第一道也是最硬的一道隔离屏障。2. 多数项目踩过的“串门”现场NVS 互踩、OTA 顶替、日志争地盘2.1 场景一同一个 NVS 分区里A 应用和 B 应用键名撞车NVSNon-Volatile Storage是 ESP-IDF 提供的一套轻量级键值存储底层就是一块普通 Flash 分区。很多开发者为了省事从头到尾只用默认的“nvs”分区开启句柄时也全都写nvs_open(nvs, ...)。这在只有一个应用模块时没问题一旦出现两个模块立刻就开始打架。我调试过一个带配网、灯光动画和电量上报三个功能块的设备。三个模块都往同一个默认 NVS 分区写数据大家都用了mode这个键名配网模块写入mode AP灯光模块写入mode rainbow电量上报模块写入mode report。三者本来互不相关却因为键名相同互相覆盖。最后表现是设备重启后灯光模块经常读到mode AP以为自己需要进入配网模式于是把 Wi-Fi 热点打开整台设备开始异常耗电。这种问题非常难查因为它不报错。NVS 的nvs_set_*和nvs_get_*函数在执行时只认命名空间和键名不认“谁写的”。只要你用了同一个命名空间、同一个键后写的人就会覆盖先写的人而系统完全认为这是正常操作。排查时最容易踩的坑是拿调试器看内存变量值读出来全是对的但重启后却恢复了旧值。解决思路有两个层面轻量级方案是所有模块各自使用独立命名空间比如nvs_open(wifi_cfg, ...)、nvs_open(light_cfg, ...)严格一点给每个应用分配独立的 NVS 分区从物理上隔开。这个后面会展开讲。2.2 场景二OTA 升级把别人的固件和配置一起顶掉OTA 升级是 Flash 串门的第二大高发区。常规玩法是出厂时烧录 factory 应用第一次 OTA 时把新固件写到 ota_0第二次写到 ota_1两个 OTA 分区轮流用Bootloader 负责切换。这套机制本身很成熟但问题往往出在“有人把数据也塞进了 app 分区”。有些项目图省事把静态网页、字体文件、算法模型直接编译进固件里或者运行时用esp_partition_write往 factory 分区末尾追加数据。这会导致两个严重后果一是每次 OTA 把 app 分区整体覆盖你辛辛苦苦存的“额外数据”全部消失二是如果追加数据时偏移计算少算了几 KB就可能越过 app 分区边界把相邻的 nvs 或文件系统分区写花。另一种典型情况是项目里其实有几个独立的“小应用”却把它们编译进同一个 app 镜像再靠一个调度器轮询运行。举一个例子一个模块负责网关规则一个模块负责本地语音两个模块都要保存各自的运行状态。如果整个项目只有一个“数据区”的概念两个模块写文件时都往同一个根目录下塞升级后规则文件还在但语音模型配置文件莫名其妙地损坏了——因为 OTA 包解压时覆盖了根目录下同一个文件名的资源。正确做法是任何和 OTA 相关的改造都要先回答一个问题我的数据分区是否独立于 app 分区数据分区是否被 OTA 写入逻辑覆盖如果答案是否定的那就要在分区表层面给数据单独分地并且 OTA 流程只允许碰 app 分区绝不碰数据区。2.3 场景三日志和素材文件互相争地盘第三个非常常见的现场是日志落盘和数据素材共用同一个文件系统分区。很多同学喜欢在 ESP32 上挂一个 SPIFFS 或 LittleFS 分区把所有文件都扔进去/web/index.html、/web/img/logo.png、/log/system.log、/log/error.log、/data/config.json。一开始 Flash 余量大大家相安无事运行几个月后日志文件越来越大文件系统空间逼近上限紧接着就出现各种“灵异事件”网页打开缺图片、配置文件读不到、格式化后设备才能恢复正常。我遇到过最夸张的一次一个小伙伴在 ESP32 里内嵌了一个 Web 管理页面页面文件只有几百 KB但系统日志每 10 秒写一条一个月就把 4MB 的 SPIFFS 分区写满了。然后他发现网页经常打不开报 404。一开始他以为是 HTTP 服务代码有问题排查很久才发现是日志文件把 SPIFFS 的根目录索引和页数据挤到了同一个块上文件系统为了保证一致性直接在挂载阶段拒绝加载整个分区于是所有文件全部“失踪”。这就是典型的文件系统层面串门同一分区里的任何文件都不设防任何一个应用都可以把空间占满进而拖垮其他应用。即便代码里约定“我只写/log目录不碰/web目录”文件系统本身并不知道这个约定它只负责在满盘时给你返回 ENOSPC 或者触发 GC根本不会替你保护某个目录。3. 根治做法用自定义分区表 命名空间 API 把数据隔离做扎实3.1 先说清楚“多个小应用”到底指什么动手之前建议先确认你的“多个小应用”是两种形态里的哪一种。第一种最常见一个编译好的固件镜像里包含多个功能模块模块之间通过任务、队列、文件分工协作第二种是真正把多个独立固件镜像放在同一块 Flash 里由 Bootloader 选择启动。第二种形态在 ESP32 里能做但需要给每个固件单独划分 app 分区并自行实现启动选择逻辑复杂度高很多一般产品很少直接采用。本文主要讨论第一种形态。在这种形态下“数据串门”的根源不是 CPU 计算而是 Flash 这块共享存储。所以隔离思路很明确第一给不同的功能模块分配不同的物理分区让它们各有各的地盘第二在同一个 NVS 分区里用命名空间做逻辑隔离第三在代码层永远通过分区标签而不是裸地址去读写。3.2 一张可直接改编的 8MB 多应用分区表我个人比较推荐在开发阶段就直接把分区表定义成下面这样。假设你的 ESP32 模块是 8MB Flash地址范围 0x000000~0x800000有三个大的功能域主固件区、配置区、文件区。# 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, 0x200000, ota_0, app, ota_0, 0x210000, 0x200000, ota_1, app, ota_1, 0x410000, 0x200000, nvs_app_a, data, nvs, 0x610000, 0x20000, nvs_app_b, data, nvs, 0x630000, 0x20000, fs_web, data, littlefs, 0x650000, 0xA0000, fs_log, data, littlefs, 0x6F0000, 0x50000, fs_user, data, littlefs, 0x740000, 0xC0000,解释一下这张表的思路。前三个分区是官方默认的基础分区Bootloader、分区表和基础配置必须要用。三个 2MB 的 app 分区负责固件本体和 OTA 轮换然后是nvs_app_a和nvs_app_b两个独立 NVS 分区分别给 A 模块和 B 模块存配置最后是三个 LittleFS 分区一个放 Web 静态页面一个放日志一个放用户生成的数据互不干扰。给 4MB Flash 的项目做类似设计时空间会紧张很多。我的建议是砍掉一个 OTA 轮换分区比如只用 factory ota_0或者干脆不做 OTA 只保留一个 app 分区文件分区压缩到每个 256KB 左右每个 NVS 分区控制在 64KB 以内。空间永远不够用但哪怕再挤也比所有应用扎堆在一个分区里强。用这张表烧录时记得用 ESP-IDF 的idf.py partition-table生成分区表 bin再用esptool.py merge_bin或乐鑫官方的 Flash Download Tool 把 bootloader、partition table、phy_init、app、各数据区按偏移地址拼成完整镜像。不要只烧 app 而不烧分区表否则 Bootloader 读取的还是旧分区信息偏移和大小全都对不上必然串门。3.3 用 esp_partition API 精确锁定自己的分区在代码里最忌讳的做法是“我记得我的数据在 0x123456直接读写”。地址一旦因为固件大小调整而漂移数据必然毁掉。ESP-IDF 提供了标准的esp_partition_find_*系列接口你只要用名字查找分区系统会返回一个esp_partition_t结构体里面的address和size才是真正的物理地址。#include esp_partition.h const esp_partition_t* part esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_LITTLEFS, fs_user); if (part NULL) { // 分区表里没有 fs_user属于配置错误建议直接停机 ESP_LOGE(main, fs_user partition not found); abort(); } uint8_t buf[256]; esp_err_t err esp_partition_read(part, 0, buf, sizeof(buf)); if (err ! ESP_OK) { ESP_LOGE(main, read fs_user failed: %s, esp_err_to_name(err)); }这段代码有三个值得注意的点。第一查找分区时的 SubType 要和分区表里的子类型一致NVS 分区就用 nvsLittleFS 分区就用 littlefs。第二拿到esp_partition_t后读写函数的第二个参数是“分区内的偏移”不是 Flash 全局地址这个偏移必须小于part-size。第三裸分区读写没有磨损均衡如果你要用它做日志文件建议还是挂上文件系统不要自己管理扇区。3.4 用 NVS 命名空间做逻辑隔离如果你觉得给每个模块单独分一个 NVS 分区太奢侈也可以共享一个 NVS 分区但必须严格使用不同的命名空间。ESP-IDF 从 4.x 开始提供了nvs_flash_init_partition和nvs_open_from_partition它们可以让你在指定名称的分区上开启句柄。#include nvs.h #include nvs_flash.h // 初始化指定的 NVS 分区 esp_err_t err nvs_flash_init_partition(nvs_app_a); if (err ! ESP_OK) { ESP_LOGE(main, nvs_app_a init failed: %s, esp_err_to_name(err)); return; } // 在指定分区上打开自己的命名空间 nvs_handle_t handle; err nvs_open_from_partition(nvs_app_a, app_a_cfg, NVS_READWRITE, handle); if (err ESP_OK) { nvs_set_str(handle, mode, offline); nvs_commit(handle); nvs_close(handle); }使用nvs_open_from_partition的好处是A 模块哪怕把命名空间里的所有键都清光也只会影响nvs_app_a分区碰不到 B 模块的nvs_app_b。如果只用一个共享分区虽然命名空间不同也能避免键名冲突但任何一个模块执行nvs_erase_all时会把整个 NVS 分区都清空这种“一损俱损”的行为在多人协作项目中很容易踩雷。这里还要提一个经常被忽略的限制NVS 的命名空间名最长 15 个字符键名最长也是 15 个字符。我见过有人把命名空间写成my_application_config_2024结果编译时直接报错排查了很久才发现是长度问题。起名字时要精打细算用app_wifi_cfg、app_light_cfg这类简洁短命名留出余量。3.5 用 LittleFS 给每个应用一个独立的挂载点文件系统层面我推荐用 LittleFS 而不是 SPIFFS。LittleFS 支持目录、文件重命名、崩溃恢复也比 SPIFFS 更成熟稳定。更重要的是它可以同时挂载多个分区每个分区分配一个唯一的partition_label路由到不同的挂载路径。#include esp_littlefs.h esp_vfs_littlefs_conf_t conf_web { .base_path /web, .partition_label fs_web, .format_if_mount_failed true, .dont_mount false, }; esp_vfs_littlefs_register(conf_web); esp_vfs_littlefs_conf_t conf_log { .base_path /log, .partition_label fs_log, .format_if_mount_failed true, .dont_mount false, }; esp_vfs_littlefs_register(conf_log);挂载好之后A 模块只在/web读写网页文件B 模块只在/log写日志。即使日志写几百 MB只要fs_log分区没满它就无法影响/web下的页面文件。这个方案比“一个分区多个子目录”更可靠因为子目录只是逻辑上的约定代码里任何一个人写remove(/web/index.html)都不会有人拦着独立分区才是物理层面的硬隔离。如果 Flash 实在小到分不出多个文件系统分区再退一步用同一个分区也要强制约定目录名/web、/log、/data并在代码评审时明确“每个模块只能操作自己的目录前缀”否则不如不做。4. Flash 擦写寿命与磨损均衡数据安全的另一条底线4.1 为什么 Flash 擦写次数是硬指标嵌入式项目里数据串门还有一个隐形变种不是因为“写错了地方”而是因为“同一个地方被写太多次”最终 flash 物理损坏读出来的数据变成随机乱码。这种损坏看起来和串门几乎一样——A 应用明明写的是 0x01读出来却是 0xFFB 应用以为自己的数据被覆盖了其实是一个坏扇区在捣乱。普通 NOR Flash 的每个扇区擦写寿命大约在 10 万次左右这个数字看起来很大但在高频率日志场景下根本撑不住。我给你算一笔账如果系统每 1 秒写一次状态记录每次写入都会让某个扇区进入脏数据状态等垃圾回收时就必须整扇区擦除。一个 4KB 的扇区可能容纳 128 条小记录也就是大约每 128 秒擦一次一天大约擦 675 次。10 万次寿命除以 675大概只能撑 148 天。这意味着半年后你的 Flash 就会出现可靠数据损坏的隐患。这种问题一旦出现基本没有软件层面的补救只能返厂换芯片。所以在多应用共享 Flash 的项目里谁负责高频写入谁就要对数据量级特别敏感。低频配置用 NVS 没问题心跳包、传感器原始数据、调试日志这种高频数据就不要妄想直接落 Flash 了。4.2 磨损均衡帮你做什么、不做什么ESP-IDF 里的 NVS 自带磨损均衡它会把写入分布在同一个 NVS 分区内的多个扇区上避免长期盯着一个扇区打。LittleFS 文件系统也有自己的擦写均衡策略会在分区内部的块之间轮换。如果你用 FAT 文件系统搭配esp_vfs_fat_spiflash_mount官方还提供了 wear levelling 层让底层尽量均匀擦写。但这些机制只能在“分区内部”做均衡没办法跨分区。这带来两个设计要点第一分区大小不要给得太抠磨损均衡需要足够的空余扇区来周转分区太小反而会导致垃圾回收频繁加速损坏第二如果有某个分区注定要被高频写入比如日志分区那么就单独给它一个分区别拖累其他低频分区的稳定性。另一个容易踩的坑是很多人以为“磨损均衡”能提升 Flash 的总寿命其实不是。磨损均衡只是让擦写压力分布得更均匀总写入量并不会减少。如果你一天要擦 675 次用了均衡算法后无非是把压力分散到 4~5 个扇区上让 Flash 能多撑几个月但最终写入总量不变寿命上限仍然是总擦除次数除以总数据量。所以降低写入频率才是根本。4.3 日志类数据落盘的正确姿势日志是 Flash 寿命的头号杀手。我在日志系统上的建议按优先级排列第一选择是日志直接走串口或网络发送到上位机根本不到 Flash第二选择是先用 RAM Ring Buffer 缓存日志量达到一个阈值比如 4KB后再一次性落盘第三选择才是每条日志都写文件系统。如果你必须周期落盘建议把日志写到独立的小分区并且定期轮转清理。ESP-IDF 里没有自带强大的日志轮转库但你可以用 LittleFS 自己维护两个文件log_0.log和log_1.log当前文件写满后切换到另一个并删除旧文件。这样既能控制分区占用又不会让单个文件无限膨胀。如果只是保存“最近 N 条状态”还可以用 NVS 的环形写法或者干脆用esp_ringbuf做内存缓冲只在关键时刻把快照写入 Flash。总之任何频繁写入的操作都要先问自己一句这数据真的需要持久化吗掉电丢了会有什么后果很多“必须写 Flash”的设计仔细一推敲其实写成 RAM 就够了。5. 现场排查顺序与一套可复制的参考方案5.1 按症状倒查“串门”的五步排查法如果项目已经出现了疑似数据串门的现象不要急着改代码。我建议按下面这套顺序检查每一步都能快速定位问题范围第一步检查启动后配置是否错乱。用串口工具查看两个模块读出的配置值重点对比 NVS 键名。如果 A 模块的键名和 B 模块重合先给其中一个改成不同命名空间跑 24 小时看是否复现。第二步检查 OTA 升级后数据是否消失。如果只有升级后才出问题很大概率是 app 分区覆盖了你之前塞进去的数据或者 OTA 包本身的分区表和你本地的不一致。这时候要把本地build/partition_table.bin的哈希和升级包里的分区表做对比。第三步检查文件系统空间是否被打满。用esp_littlefs_info查询各分区剩余空间如果日志分区满了而主程序又在无脑创建新文件文件系统会进入只读或异常状态。观察日志里是否频繁出现ENOSPC、CORRUPTED之类的关键词。第四步如果以上都没发现直接用esptool.py把 Flash 完整读出来按分区表逐段解剖# 读出完整的 8MB Flash 镜像 esptool.py read_flash 0 0x800000 flash_dump.bin # 单独读分区表区域 esptool.py read_flash 0x8000 0x1000 partition_table.bin # 单独读某个文件分区检查内容 esptool.py read_flash 0x650000 0xA0000 fs_web_dump.bin读出 bin 文件后用xxd、strings或者 WinHex 打开看看里面是否有不属于该分区的数据。比如在fs_web分区里发现了 NVS 的魔法数字那就是有人裸写地址写穿了边界。第五步检查启动流程里的版本校验。我会在 NVS 里固化一个schema_version并在 app 启动时读取如果当前固件期望的版本和 NVS 里存的版本不一致就直接拒绝启动并提示“存储区版本不匹配”。这能提前暴露“上一版固件写的配置这一版固件完全读不懂”的兼容性问题。5.2 一套 8MB Flash 多应用参考方案为了方便你直接抄作业我把前面讲的设计汇总成一张表。假设两个功能模块分别叫 App A 和 App B外加一个 Web 管理页面和一个日志模块分区名类型子类型偏移大小主要用途nvsdatanvs0x90000x4000系统级基础配置otadatadataota0xD0000x2000OTA 启动信息phy_initdataphy0xF0000x1000WiFi 物理校准数据factoryappfactory0x100000x200000出厂固件 / 恢复镜像ota_0appota_00x2100000x200000OTA 固件槽位 0ota_1appota_10x4100000x200000OTA 固件槽位 1nvs_app_adatanvs0x6100000x20000App A 私有配置nvs_app_bdatanvs0x6300000x20000App B 私有配置fs_webdatalittlefs0x6500000xA0000Web 静态页面fs_logdatalittlefs0x6F00000x50000日志文件fs_userdatalittlefs0x7400000xC0000用户生成的数据这张表的好处是三个数据区完全独立App A 的配置坏了不影响 App Bfs_log写满了不会挤掉 Web 页面OTA 无论怎么升级都只在三个 app 分区之间轮转永远不会碰数据区。如果你的项目不需要 OTA可以把三个 app 分区合并成一个 3MB 的 factory 分区额外空间补给fs_user。5.3 几个越早做越省心的实践建议最后分享几条我个人的经验也是在项目起步阶段就应该写进 checklist 的几条原则。第一从第一个 commit 开始就使用自定义分区表不要等到后期 Flash 不够了才换。临时用默认分区表意味着你的代码里可能到处是“裸的偏移地址”后期一改分区表这些地址全部失效排查成本极高。第二所有模块读写 Flash 时只通过esp_partition_find_*或nvs_open_from_partition指定分区名禁止在业务代码里出现“0x123456”这种裸地址。哪怕你是靠固件内置常量表管理地址也建议在初始化时和分区表实际返回的esp_partition_t做一次交叉校验不一致就报错。第三给每次写入的数据加版本号。无论是 NVS 里的键值还是文件系统里的 JSON 文件都放一个version字段。这样即使两个模块用错了分区至少能从版本号上快速识别出“这数据不是我的”而不是默默用错数据继续跑。我自己排查过的最头疼的一个 bug就是小车固件每隔几天突然重启查了一周才发现是网络状态模块和 PID 参数模块把键名写重了互相覆盖以后触发了看门狗。修好之后我在项目文档第一页贴上了最终分区表并规定任何新增模块必须先认领自己的分区和命名空间才能提交代码。从那以后同类的 Flash 串门问题再也没有出现过。希望这篇内容能让你在设计阶段就避开这些坑省下后面的一整周排查时间。