ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ESP32 Flash分区与隔离实战:一芯多应用如何避免数据串门

ESP32 Flash分区与隔离实战:一芯多应用如何避免数据串门

多个小应用共用一块 ESP32 Flash,这问题我在不少项目里都踩过。最开始做一个"内嵌Web配置页 + 传感器日志 + OTA升级 + 蓝牙配网"的全能小设备时,天真地以为程序里各管各的变量就能把数据分开,结果固件升级一次,配置文件没了;日志写多了,Web页面又打不开;更诡异的是,NVS里的配网信息偶尔会被一遍遍覆盖,整个设备必须重新配网。排查到最后,根子都在同一个地方:大家共用一块Flash,却没有给数据划清地盘。

这篇就聊聊我在这类"一芯多应用"场景下的Flash分区和隔离经验。核心思路一句话:想让数据不串门,靠代码里躲是躲不过去的,必须从Flash底层的分区设计做起,再配合应用层的命名空间和文件系统隔离。这整套东西适合正在做ESP32多功能设备、想搞懂分区表机制、以及被OTA和文件系统存储问题折磨过的开发者看。

1. 为什么"共用一块Flash"必然会出问题

1.1 多个小应用的真实存储场景

先盘点一下,一个ESP32设备上到底有哪几类东西在抢Flash。

最常见的是这五类:

  • 固件本身(App):编译出来的bin文件,可能一个也可能两个(OTA双备份)。
  • Web前端资源:用ESP32内置Web服务器时,HTML、CSS、JS、图片,一般打包进SPIFFS或LittleFS文件系统分区。
  • 配置参数:WiFi账号密码、设备编号、校准系数这类键值对,通常放NVS(Non-Volatile Storage,非易失性存储)。
  • 运行日志和数据采集结果:传感器历史记录、错误日志,也是放文件系统分区或者专门的日志分区。
  • 蓝牙配网、终端调试等临时数据:有时候也会想存下来,比如蓝牙配网时缓存中间状态。

一台设备同时干这么多事太正常了。网上随便搜"esp32内嵌web网页""esp32 蓝牙教程""esp32项目""esp32 温湿度传感器使用"这些词,出来的方案基本都是这几个功能叠加的组合体。

1.2 数据"串门"到底是怎么发生的

不做隔离的时候,所有应用都往同一个Flash地址空间里写,就会出下面这些乱子:

  • 覆盖式串门:A应用以为自己在存配置,实际写入的地址段正好是B应用的固件区域,一次写入直接让固件崩溃。这个最致命,而且很难复现,因为地址都是算出来的,差一个字节都不行。
  • 擦除式串门:Flash写入前必须擦除,而擦除的最小单位是扇区(常见4KB)。如果一个应用把某个扇区擦掉了,恰好另一个应用的数据也在这个扇区里,两边一起遭殃。
  • 增长式串门:日志类数据是往后追加写的,越写越长,今天没问题,跑三天后溢出了,把邻接区域冲垮。很多"用着用着突然变砖"的设备,都是这个原因。
  • OTA式串门:OTA升级时新固件要写到另一个分区,如果分区规划不合理,新固件长度超过分区大小,直接覆盖到隔壁文件系统分区,升级完数据全没了。

这些现象表面看是"程序bug",本质上是存储布局没有设计。就像一栋楼里住好几户,每户房间多大、走哪个门,必须有清晰的户型图。没有户型图就装修,谁先开工谁占地方,后面的人只能挤。

2. 分区表:给Flash划好"房间"

2.1 分区表长什么样

ESP32的分区表本质是放在Flash固定位置(默认0x9000偏移)的一张清单,里面对每一块Flash区域定义好名字、类型、子类型、偏移、大小。烧录时,bootloader会读这张表,系统运行时也是靠它来定位"我要找的数据在哪"。

我最早一直以为分区表是固定的,后来才发现它完全可以让用户自定义。官方工具链里用的是CSV文本,编译时会生成bin,再烧到Flash里。

一份最简单的CSV长这样:

# ESP-IDF Partition Table # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x200000,

看到没有,这就是户型图。有了它,bootloader就知道第一户(nvs分区)从0x9000开始,占24KB;第二户(phy_init)接着往后;固件从0x10000开始,占2MB。

2.2 字段逐个拆解

每个字段都是有讲究的,我踩过坑之后才真正搞明白:

Name:就是给分区起名字,代码里要用这个名字来查找分区。比如partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "storage"),这里"storage"就是CSV里定义的名字。名字可以随便起,但最好见名知义。

Type和SubType:这是整个分区表最关键的设计。Type分两大类:app和data。app是固件,data是数据。SubType进一步细分,app有factory、ota_0、ota_1等,data有nvs、phy、spiffs、fat等。类型的作用是让ESP-IDF的API能区分和处理不同分区,也决定了部分分区的默认行为。

Offset:分区的起始地址。注意几点:

  • 第一个分区通常从0x9000开始(0x1000到0x8000是bootloader区域,0x8000到0x9000是分区表本身)。
  • 后续分区的offset不写的话(留空),工具会自动接着上一个分区末尾继续排。但要小心自动排和手动排混用容易出问题。
  • Offset有对齐要求,app分区必须按0x10000(64KB)对齐,大部分时候为了省心,所有分区都按0x10000对齐。

Size:这个不用多说,唯一要提醒的是,别只算当下。日志分区要给余量,OTA分区要够容纳固件的最大膨胀版本,NVS分区太小会导致键写不进去。

2.3 常用分区类型的功能定位

我整理了一张常用分区类型的对照表,做方案时直接对着选:

分区TypeSubType典型用途备注
appfactory出厂固件没有OTA时用这个
appota_0 / ota_1OTA双备份固件必须有otadata分区配合
apptest工厂测试固件一般不常用
datanvs键值对存储(WiFi配置等)有专门API,注意大小
dataotadataOTA状态记录OTA方案必配,很小
dataphy_initWiFi/BT校准数据默认保留即可
dataspiffs / littlefs文件系统Web页面、日志数据
datacoredump崩溃日志调试神器

注意nvs和otadata都有"只能有一个"的约束(使用特殊key的情况除外),ota_0和ota_1必须成对出现。这些细节都是在规划阶段必须确认的,否则编译报错或者运行时行为异常。

3. 实操:给"一芯多应用"设计一份可靠的分区方案

3.1 先盘点需求再动手

做分区规划,我推荐先拿一张纸,把设备所有功能列出来,再对应到存储需求,不要上来就抄模板。我自己做的一个典型设备是这样盘点的:

  • 固件区:主固件基于ESP-IDF开发,编译后约800KB,考虑到以后加功能,至少留1MB。要做OTA,就得两份:ota_0和ota_1各1MB。
  • 文件系统:Web管理页面(HTML/CSS/JS)约300KB,运行日志和传感器数据预留空间,合计1MB比较稳妥。
  • NVS:用来存WiFi账号、设备参数、校准数据,默认20KB够用,但考虑到蓝牙配网的中间状态,我留了24KB或32KB更安心。
  • 调试辅助:coredump日志不常开,但线上问题排查非常有用,我给它32KB。
  • phy_init:默认即可,4KB。

盘完之后发现,4MB的Flash如果不仔细规划,很可能捉襟见肘。尤其是一块4MB的Flash,bootloader加分区表去掉约36KB,实际可用大约4036KB。

3.2 分区表CSV实例与参数计算

我最后用的CSV长这样:

# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x100000, otadata, data, ota, 0x110000, 0x2000, ota_0, app, ota_0, 0x120000, 0x100000, ota_1, app, ota_1, 0x220000, 0x100000, coredump, data, coredump,0x320000, 0x8000, storage, data, spiffs, 0x328000, 0xC8000,

这里的参数我是这么计算的:

  • nvs大小0x6000(24KB):ESP32的NVS页大小是4KB,24KB能容纳很多键,且留有余量。我之前用过默认16KB,结果键一多就返回ESP_ERR_NVS_NOT_ENOUGH_SPACE。
  • factory分区0x100000(1MB):当前固件800KB,留200KB冗余。注意app分区起始偏移0x10000,这是align到64KB的。
  • otadata 0x2000(8KB):足够存放OTA状态,官方默认也差不多。
  • ota_0和ota_1各1MB:这两个分区必须大小一致且对齐,才能保证OTA后新旧固件可以互相覆盖而不越界。
  • coredump 32KB:崩溃时的RAM镜像,32KB够大部分场景。
  • storage 0xC8000(800KB):Web页面300KB + 日志预留500KB。这里我特意算过,日志轮转保留最近7天,每天约60KB,500KB刚好。

有个容易被忽略的点:所有尺寸单位在CSV里可以是K或M,但我习惯直接用十六进制字节,避免误读。另外工具链对分区大小有对齐要求,如果某个值不对,编译时会直接报错,不会给你含糊的空间。

3.3 烧录与验证分区方案

分区表CSV写好后,编译时ESP-IDF或Arduino环境会自动生成partition table bin。但如果你是用自定义CSV,一定要确认构建系统真的用了你的文件。

在ESP-IDF里需要配置:

idf.py menuconfig # 进入 Partition Table -> Custom partition table CSV # 填入自定义CSV路径

烧录时分区表必须和固件一起烧:

idf.py -p /dev/ttyUSB0 flash

如果想单独验证烧进去的分区表有没有生效,强烈推荐用读回的方式确认:

esptool.py --port /dev/ttyUSB0 read_flash 0x9000 0x3000 partition_table.bin

再用ESP-IDF自带的工具解析一下内容,看打印出来的表是不是自己设计的那份。这一步虽然多花两分钟,但能在项目一开始就拦住"分区表没烧进去"这类极其隐蔽的问题。我在Arduino环境下也反复确认过:用Arduino IDE时工具链默认用内置分区方案,自定义方案必须修改board配置里的Partition Scheme,或者在platformio.ini里指定board_build.partitions = partitions.csv。

4. 代码里的数据隔离:不只靠分区

分区表划完,只是"房间"分好了。住在不同房间里的应用,还得有自己独立的"门锁和钥匙"。这一层应用侧的隔离做不好,照样能串门。

4.1 NVS键值对的命名空间隔离

NVS本身就是一个键值数据库,很多人以为有键名就够了,其实NVS还有个"命名空间"(namespace)的概念。两个不同的namespace,即使键名相同,值也互不可见。这是多个小应用共享NVS时的第一道隔离墙。

我的做法是给每个小应用分配独立的namespace:

// 配置类应用使用 config_ns esp_err_t err = nvs_open("config_ns", NVS_READWRITE, &handle_cfg); nvs_set_str(handle_cfg, "wifi_ssid", ssid); nvs_set_str(handle_cfg, "wifi_pwd", password); // 日志类应用使用 log_ns esp_err_t err2 = nvs_open("log_ns", NVS_READWRITE, &handle_log); nvs_set_u32(handle_log, "last_boot", timestamp);

这样即使两个namespace里都有"last_boot"这个键,读出来也各是各的,不会混淆。

还有一个细节:NVS在断电瞬间写入容易出现损坏,损坏后NVS会尝试用备份页恢复。如果两个应用同时频繁写NVS,冲突概率会上升。所以我在实际项目里还会做写操作互斥,用nvs_set_*后立刻nvs_commit,并且建议多个小应用不要都直接高频写NVS,日志类数据尽量走文件系统,NVS只存配置和状态。

4.2 文件系统分区挂载:一份数据一个"盘"

如果两个小应用都用SPIFFS/LittleFS,但共用同一个文件系统分区,那"串门"依然存在:A应用删文件,B应用的文件也不见了;A应用写满,B应用就没空间了。真正的隔离是给它们分配不同的文件系统分区,然后分别挂载。

在程序里这样挂载:

// 挂载Web资源分区 esp_vfs_spiffs_conf_t web_conf = { .base_path = "/web", .partition_label = "webui", .max_files = 5, .format_if_mount_failed = false, }; esp_vfs_spiffs_register(&web_conf); // 挂载日志分区 esp_vfs_spiffs_conf_t log_conf = { .base_path = "/log", .partition_label = "logdata", .max_files = 5, .format_if_mount_failed = false, }; esp_vfs_spiffs_register(&log_conf);

注意这里每个partition_label对应CSV里不同的分区名。挂在/web下的文件,和挂在/log下的文件,从文件系统层面就完全隔离了。A应用的代码去/web写东西,永远不会碰到/log里的日志。

还有一点容易踩坑:format_if_mount_failed这个参数。如果设为true,一旦文件系统挂载失败(比如被异常数据破坏了),系统会自动格式化,这倒是省心,但会把该分区所有数据全清掉。对存储重要日志的分区我会设成false,即使挂载失败,也要先保住数据再说。

4.3 分区标签在代码中的准确引用

前面提到代码里用partition_label,但在ESP-IDF的API里,找分区更常用的方式是通过类型和子类型。很多初学者在这一步会把"分区名"和"子类型"搞混。

比如:

const esp_partition_t* part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "storage"); if (part == NULL) { ESP_LOGE(TAG, "partition storage not found"); return; }

第三个参数是CSV里的Name,而前面两个是Type和SubType。如果我CSV里叫"storage",代码里写"webui",就找不到。这种错误编译期查不出来,运行时才报错,排查时先核对名字拼写。

4.4 OTA升级时的分区切换与数据保护

OTA场景下,"串门"还有一层特殊含义:新固件可能和旧固件读写不同的分区。如果旧固件用的是factory分区,新固件OTA到了ota_0分区,两者虽然代码逻辑一样,但跑起来后,对NVS和文件系统分区的访问应该是一致的。

OTA本身依赖otadata分区记录当前应该从哪个分区启动。升级流程大致是:

  1. 下载新固件,写入ota_1分区(假设当前从ota_0启动)。
  2. 写入完成后,设置otadata为"下次从ota_1启动"。
  3. 重启,bootloader读取otadata,从ota_1启动。
  4. 新固件自检通过后,把otadata标记为"确认可用";如果不通过,回滚到ota_0。

这里面最容易出问题的是分区大小和固件大小不匹配。我遇到过固件在开发期不断膨胀,某次OTA后写入失败,原因是目标分区大小不够。排查时看见esp_ota_ops.c报ESP_ERR_FLASH_SIZE_NOT_MATCH,这才意识到是分区规划时的余量给少了。从此之后,我在规划OTA分区时有一个习惯:把分区大小设为当前固件体积的1.5倍或至少多留300KB。

另外,如果多个小应用都在做OTA,一定要明确只有一个OTA主流程,不能让两个应用同时操作otadata和ota_0/ota_1分区。我见过一个设备,主控逻辑和蓝牙模块都尝试触发OTA,结果两个写并发,把otadata写坏,系统反复重启也起不来。后来的处理是在OTA入口加互斥锁,并且由统一的任务管理。

5. 常见问题与排查技巧实录

5.1 分区表没有生效

这个问题我遇到太多次了,典型现象是:代码里明明能找到的分区,运行时报错找不到;或者明明CSV里定义了很大空间,实际写文件写到一半就满了。

排查思路按顺序来:

  1. 确认烧录时确实烧了新的分区表。用esptool.py read_flash读回0x9000处的内容,和本地生成的partition table bin对比。很多"分区没生效"其实是烧录时分区表没更新,固件倒是新的,分区表还是旧的。
  2. 确认用的是自定义CSV。ESP-IDF默认会用内置的分区方案,Arduino环境更明显,需要手动选择分区方案。在PlatformIO里,我会在platformio.ini里写死board_build.partitions = partitions.csv,避免IDE设置被无意改动。
  3. 确认CSV语法和内容没有逻辑冲突。比如NVS分区和otadata分区出现多个、app分区没有按64KB对齐、分区之间重叠(特别是手动指定offset时),这些CSV工具会报错,但有些"看起来合法"的重叠(比如size写过了边界)工具未必能发现。

我自己还踩过一次特别隐蔽的坑:用文本编辑器在CSV里加了带BOM的UTF-8头,工具链解析第一个字段时带了隐藏字符,导致找不到分区。排查了很久,最后用xxd看文件头才发现。

5.2 数据串门的典型症状

遇到下面这些情况,优先怀疑Flash隔离方案有问题:

症状可能原因排查方向
升级固件后WiFi配置丢失NVS被擦除或NVS分区被覆盖检查是否有代码调nvs_flash_erase;检查OTA写越界
写日志后Web页面文件损坏日志分区和Web分区在同一文件系统拆分文件系统分区,或检查文件系统分区Size溢出
设备运行几天后崩溃重启日志区写满后覆盖邻接分区增加日志轮转,或单独给日志分配分区
OTA升级后启动异常ota分区大小不足或otadata损坏检查分区表里ota_0/ota_1大小,调整otadata
NVS写入返回ESP_ERR_NVS_NOT_ENOUGH_SPACENVS分区过小或namespace过多扩大NVS分区或减少存储键

其中一个我印象最深的问题是:设备里一个任务是采集温湿度并写日志,另一个任务是Web服务器。两者共用SPIFFS分区,一开始都正常,但采集任务每5分钟写一条日志,几天后Web页面所有图片全打不开了。表面看是图片损坏,实际是日志把分区写满,文件系统碎片化严重,导致原先可以读取的文件被挤到不可用区域。后来把日志独立到专门分区,Web页面设置format_if_mount_failed=false且禁止日志任务跨分区访问,问题彻底消失。

5.3 避坑清单

结合这些年的项目经验,整理一份我每次做ESP32存储方案都会过一遍的清单:

  1. 先画分区图再写代码。任何"存数据"的功能,动手前先明确写在哪个分区、用什么API。没有明确存储归属的功能,默认不实现。
  2. 减小NVS写入频率。NVS按扇区擦写,频繁写入会加速Flash磨损。日志、采集数据不要用NVS,用文件系统。
  3. OTA分区要比实际固件大出足够余量。建议至少1.3倍至1.5倍。每次固件体积大幅增加后,回头检查分区大小是否仍够。
  4. 文件系统挂载失败策略要分场景。可再生的缓存数据可以格式化,珍贵日志和用户配置绝对不能自动格式化。
  5. 用分区标签(partition_label)而不是硬编码地址。代码里用硬编码地址,一旦分区表调整就全盘崩盘,用API查表找分区是正道。
  6. 日志和业务数据尽量分应用独立目录,即使在同一文件系统分区,也通过子目录约束,方便误删时止损。
  7. 定期做掉电测试。数据隔离方案是否可靠,不能只在常温常态下验,要做随机断电写入测试。ESP32在写入中途断电,最容易暴露Flash隔离设计的漏洞。

5.4 应急恢复:数据已经被串门了怎么办

最后这条是给已经出问题的朋友看的。当你发现设备数据已经被写坏,先别急着格式化重来。

如果只是某个分区的数据损坏,可以先尝试只擦除那个分区:

esptool.py --port /dev/ttyUSB0 erase_region 0x328000 0xC8000

这样可以保住其他分区的数据。如果整个Flash乱成一团,且固件还能正常跑,可以先导出NVS里还有的配置:

nvs_handle_t handle; nvs_open("config_ns", NVS_READONLY, &handle); char ssid[32]; size_t len = sizeof(ssid); if (nvs_get_str(handle, "wifi_ssid", ssid, &len) == ESP_OK) { // 写回电脑或备份 }

数据备份这个习惯真的很重要。我后来在所有项目的NVS写入流程里都加了一个"配置版本号"键,每次写入配置时版本号加1。一旦出现异常,至少能知道配置是什么时候被改的,排查范围一下子缩小很多。

关于现网设备的远程数据恢复,还有一个技巧:利用OTA流程里的esp_ota_get_running_partition区分当前固件,再结合分区表做定向恢复。但这个方法需要提前在固件里预留恢复逻辑,属于"平时不用、关键时救命"的设计,值得在架构规划时就埋进去。

我个人在实际操作中的体会是:Flash隔离这件事,越早做成本越低。等代码写了几万行再回来调整分区表,等于给一栋已经住满人的楼重新改户型,牵一发动全身。新项目开始的那天,花半小时画一张分区图,后面能省下几天甚至几周的排查时间。另外多说一句,分区方案不是一劳永逸的,固件体积、日志策略、功能数量都会变,每次版本迭代都回头瞄一眼分区表,这个习惯帮我避开了不少线上事故。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进