上个月我把一个跑了好久的ESP32环境监测项目拆成了三个独立的小模块:WiFi配网、传感器数据记录、LED效果控制。三个模块都理所当然地要往Flash里写东西,结果一上电,WiFi密码没了,温度CSV文件打不开,LED配色文件更是变成了一堆乱码。
排查了半天,原因其实特别简单:三个模块都把这片Flash当成了自己的“私有仓库”,写数据时全凭感觉指定地址,或者用了同一个NVS key。在板子上只有一块Flash的前提下,这种“串门”几乎是必然的。
这篇文章就把我当时的排查过程和最终解决方案完整讲清楚:怎么通过分区表(partition table)划分“产权”,怎么用NVS命名空间和文件系统目录做逻辑隔离,以及一个可以直接抄走的“仪表盘+日志+用户设置”三合一分区方案。适合正在做ESP32多模块固件整合、或者准备给项目加OTA升级的开发者参考。
1. 多个应用共用 Flash 时,数据是怎么“串门”的?——两类真实场景
1.1 场景一:地址越界,A模块把B模块的数据直接覆盖
ESP32的Flash是一块通过SPI接口挂在芯片外面的NOR Flash,所有程序代码、配置文件、日志、OTA备份都住在同一片芯片里。在默认情况下,ESP-IDF的启动流程会把Flash分成几个逻辑区域,但很多刚开始写多模块代码的朋友并不关心这些区域,写数据时直接用spi_flash_write或者干脆用esp_flash_write指定一个“看起来没人用”的地址。
比如模块A为了存传感器校准值,写到了0x120000这个地址;模块B为了存系统日志,也正好选了0x120000,于是两边互相覆盖。更隐蔽的情况是,模块A先写了一批数据,模块B也写了一批,读的时候各有各的偏移,表面看着没冲突,实际上其中一方的数据已经被悄悄冲掉了。
这里要明白一件事:物理地址本身没有“属于谁”的概念,它只是一段连续的存储空间。如果没有任何分配规则,那每一块区域谁都能写,谁都能读。尤其在多个模块都存在“爆改Flash”习惯的项目里,地址冲突几乎是午夜幽灵,不知道什么时候就跳出来咬你一口。
1.2 场景二:逻辑名称冲突,配置文件“同名不同姓”
比地址越界更隐蔽的是逻辑名字撞车。多数模块不会直接碰Flash地址,而是用现成库:比如用NVS存储WiFi凭据、设备状态、用户偏好,或者用文件系统存设定好的JSON、CSV。
NVS允许你以namespace+key的形式读写数据。如果模块A开了一个叫config的命名空间,模块B也开了一个叫config的命名空间,那两边读写同一个key时就会互相干扰。文件系统也是一样,如果两个模块都把配置文件命名为settings.json放在根目录,最后落盘的结果,就是谁后写谁生效,另一个模块读到的永远是别人家的配置。
这类串门不会像地址越界那样导致程序崩溃,但比崩溃更麻烦:数据看起来还在,内容却是错的。WiFi密码可能变成了一段乱码,摄像头配置可能变成了一串奇怪的数字。
1.3 本质原因:缺一张“房产证”
Flash的物理特性是:它可以按扇区(通常4KB)擦除和写入,但寿命有限(典型擦写次数在10万次左右)。在共享Flash上,所有模块看到的都是同一块地址空间,如果没有一套从全局角度出发的分配方案,那么“你写你的、我写我的”就必然失败。
解决方式无非两种:要么在应用层手工约定地址范围和使用规则;要么使用ESP-IDF提供的分区表,让系统在启动时为你建好一套带名字、带大小、带类型的“空间产权证”。
手工约定是能跑,但脆得不行。换一个人维护,或者加一个新功能,就可能打破约定。分区表的好处是硬性的:每个分区都有起始地址和大小,库函数和API在操作时会限定在分区内,越界直接返回错误,而不是默默改到别人的地盘。
2. 一张分区表搞懂 Flash 的“空间产权”:offset、size、type 与子类型
2.1 分区表在系统里的位置
ESP32的默认启动过程是:芯片上电后,ROM里的引导代码加载位于Flash地址0x1000附近的二级引导程序(bootloader),bootloader会从Flash地址0x8000附近读取分区表,然后根据分区表找到要运行的应用程序镜像。
这个分区表不是随便放在Flash里的内容,它有一个固定的头部(ESP_PARTITION_TABLE_MAGIC)和一组条目。你可以用ESP-IDF菜单配置里的Partition Table页面去选择“单分区App”“双OTA App”,或者“自定义分区表CSV”。更直接的方式是在项目的partitions.csv文件里自己定义分区布局,然后在idf.py menuconfig里指向这个文件。
2.2 partitions.csv 的字段到底是什么意思
一个标准的CSV行是这样的:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x200000,每个字段的含义:
- Name:分区名,随便取,但最好可读。后面用
esp_partition_find时也会用到它。 - Type:
app或data。app存放固件镜像,data存放数据。也有预定义的其他类型,但多数时候只用这两个。 - SubType:进一步细分。
app类的常见子类型是factory、ota_0、ota_1;data类的常见子类型是nvs、phy、otadata、spiffs、littlefs、fatfs。 - Offset:分区在Flash上的偏移地址。它必须按4KB对齐,也就是十六进制地址最后三位是
000。 - Size:分区大小,也必须是4KB的整数倍。
- Flags:加密、不可读写等属性。日常可以留空。
2.3 默认分区表长什么样,它埋了什么雷
大部分开发板出厂时,默认分区表是这样的:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x1F0000,看上去很干净,但它给“多个小应用共用Flash”的场景埋了雷:
- 只有一个
factory应用分区,没有办法支持OTA,因为OTA需要两个备用app区域。 nvs分区只有24KB。如果多个模块都往NVS里塞数据,很快就会被填满。- 没有独立的文件系统分区。想用LittleFS存日志或用户文件,还得自己加。
默认分区表适合“单一个固件占绝对主导”的Hello World项目。一旦项目里出现日志、用户配置、网页资源、OTA等多路数据流,必须抛弃默认方案,主动设计分区的“房产产权”。
3. NVS 命名空间隔离:让配置数据住进各自的“房间”
3.1 NVS 是什么,为什么适合存小数据
NVS是ESP-IDF里的非易失存储组件,它以键值对Key-Value的格式在Flash上保存内容。它的典型使用场景是存放小尺寸配置项:WiFi的SSID和密码、设备唯一ID、用户偏好、开关状态。
NVS最大的好处是内置了磨损均衡逻辑,不需要你关心每个key到底落在哪个扇区,而且它提供了类似“数据库事务”的commit机制,可以在写入完整后统一生效,降低掉电损坏风险。
3.2 用 namespace 给每个模块一间“独立小房间”
NVS里的namespace相当于一个全局前缀。用它做多模块隔离,是最简单、最暴力的有效方式。
比如WiFi配网模块:
#include "nvs.h" #include "nvs_flash.h" void save_wifi_config(void) { nvs_handle_t handle; nvs_open("wifi_cfg", NVS_READWRITE, &handle); nvs_set_str(handle, "ssid", "MyHomeWiFi"); nvs_set_str(handle, "password", "12345678"); nvs_commit(handle); nvs_close(handle); }另一个模块,比如传感器校准模块,就绝对不要再用wifi_cfg作为命名空间:
void save_sensor_calib(void) { nvs_handle_t handle; nvs_open("sensor_calib", NVS_READWRITE, &handle); nvs_set_u32(handle, "temperature_offset", 5); nvs_set_u32(handle, "humidity_offset", 2); nvs_commit(handle); nvs_close(handle); }这样,即使两边都用了名叫ssid或temperature_offset的key,它们各自的命名空间前缀已经不同,底层写入的键名实际上是wifi_cfg:ssid和sensor_calib:temperature_offset,互不干扰。
3.3 使用 NVS namespace 的限制与注意点
Namespace不是无限多的。在默认NVS实现中,命名空间最终也会作为key的条目占用Flash空间。当你频繁nvs_erase、nvs_set时,会产生“幽灵碎片”,所以分区不宜太小,否则容易出现ESP_ERR_NVS_NO_FREE_PAGES。
我个人的习惯是:NVS分区至少给0x6000(24KB)。如果项目里模块超过5个,每个模块又存几十个key,我建议直接给到0x10000(64KB)。同时,命名空间名字不要超过15个字符,这是ESP-IDF内部对key名称长度的硬约束,命名空间如果太长,会在nvs_open时直接返回错误。
另外留意一点:namespace只是逻辑上的划分,并不是把物理空间严格切开。两个namespace的key可能会落在同一个Flash扇区。但这没有任何问题,因为NVS内部已经用哈希表和索引区保证互不覆盖,你要做的只是每个模块用自己唯一的namespace,不要图省事全塞进"main"一个篮子里。
4. 文件系统隔离:LittleFS / SPIFFS 里的目录与独立分区
4.1 为什么大数据不能塞进NVS
NVS适合几百字节级别的Key-Value存储,但如果要存几十KB的CSV日志、网页资源、用户上传的图片,那就得上文件系统。ESP-IDF里常见的Flash文件系统有两种:SPIFFS和LittleFS。
SPIFFS是ESP32初期常用的文件系统,但它在掉电恢复、目录操作、并发访问上不如LittleFS稳定。LittleFS是后起之秀,针对嵌入式小Flash做了大量优化,支持目录、重命名、掉电保护,现在也已经被ESP-IDF官方作为VFS组件重点支持。如果新项目没有历史包袱,优先选LittleFS。
4.2 独立分区 vs 同一个分区内的目录隔离
当你有多个功能模块时,每个模块各挂一个文件系统分区是最“物理”的隔离方式:模块A挂载到/web,模块B挂载到/log,模块C挂载到/cfg,大家互不往来。
但如果Flash空间有限,或者怕维护多个分区太费事,也可以让所有模块共享一个文件系统,然后用目录约定来隔离。比如:
/wifi/config.json/sensor/record.csv/led/effects.dat
这个方案在代码上非常简单,因为LittleFS天然支持多级目录。但要注意两点:
- 你需要保证每个模块在创建文件时都带上自己的目录前缀,不能只写文件名。
- ESP32的LittleFS分区不像PC文件系统那样有权限控制,任何模块都能读别人的目录。如果要求严格的“只能自己读自己”,还是要用独立分区,甚至可以用不同挂载点来彻底阻断路径访问。
4.3 怎么挂载多个LittleFS分区
在partitions.csv里定义多个LittleFS分区后,你需要在应用初始化时逐个注册VFS挂载点:
#include "esp_vfs_littlefs.h" void mount_littlefs_partitions(void) { esp_vfs_littlefs_conf_t conf = { .partition_label = "web_fs", .base_path = "/web", .format_if_mount_failed = true, }; esp_vfs_littlefs_register(&conf); conf.partition_label = "log_fs"; conf.base_path = "/log"; esp_vfs_littlefs_register(&conf); conf.partition_label = "cfg_fs"; conf.base_path = "/cfg"; esp_vfs_littlefs_register(&conf); }之后操作文件时,用"/web/index.html"、"/log/sensor.csv"、"/cfg/user.json"这样的完整路径。每个模块只读取自己领域内的路径,数据自然不会串门。
一个可以留意的小坑:format_if_mount_failed = true意味着如果文件系统挂载失败,它会自动格式化。这虽然在开发期方便,但会让数据丢失。正式产品里最好把它设成false,并在格式化前做分区日志和备份策略。
5. 实战:一个“仪表盘 + 日志 + 用户设置”共存的完整分区方案
5.1 需求梳理
假设我现在做一个智能家居网关,上面有三个功能模块:
- 仪表盘 Web UI:需要存HTML/CSS/JS资源,大概希望有1MB空间。
- 系统日志:每小时写一个CSV文件,包含温度、湿度、开关状态,大概需要512KB。
- 用户设置:用户自定义的设备名、房间名、主题配色,大概需要256KB。
此外,项目还要支持OTA升级,需要预留ota_0和ota_1两个应用分区,外加一个otadata分区。
Flash总容量我按8MB设计(板子上实际是8MB SPI Flash)。
5.2 实际分区表 CSV
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x10000, phy_init, data, phy, 0x19000, 0x1000, otadata, data, ota, 0x1a000, 0x2000, factory, app, factory, 0x20000, 0x200000, ota_0, app, ota_0, 0x220000, 0x200000, ota_1, app, ota_1, 0x420000, 0x200000, web_fs, data, littlefs, 0x620000, 0x100000, log_fs, data, littlefs, 0x720000, 0x100000, cfg_fs, data, littlefs, 0x820000, 0x080000,逐个解释一下:
nvs:我特意给了64KB,因为多个模块都要往NVS里写配置。phy_init:存放射频校准数据,一般放在固定位置,大小4KB。otadata:OTA状态区,8KB。factory:出厂固件,2MB,用于首次烧录,如果以后有OTA固件回滚需求,可以保留。ota_0、ota_1:两个OTA应用区,各2MB,最新固件会交替写入。web_fs、log_fs、cfg_fs:三个LittleFS分区,大小分别为1MB、1MB、512KB。
这里要注意offset的连续性:所有offset都按4KB对齐,而且每个分区的结束地址正好是下一个分区的起始地址。写partitions.csv的时候,用十六进制能减少计算错误。
5.3 代码:分区初始化与读写不越界
在app_main里,我们需要依次做几件事:
#include "nvs_flash.h" #include "esp_partition.h" #include "esp_vfs_littlefs.h" void app_main(void) { // 1. 初始化 NVS esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); err = nvs_flash_init(); } ESP_ERROR_CHECK(err); // 2. 挂载三个 LittleFS 分区 esp_vfs_littlefs_conf_t lfs_conf[3] = { {.partition_label = "web_fs", .base_path = "/web", .format_if_mount_failed = true}, {.partition_label = "log_fs", .base_path = "/log", .format_if_mount_failed = true}, {.partition_label = "cfg_fs", .base_path = "/cfg", .format_if_mount_failed = true}, }; for (int i = 0; i < 3; i++) { ESP_ERROR_CHECK(esp_vfs_littlefs_register(&lfs_conf[i])); } // 3. 演示写数据:模块A写Web资源 FILE *web = fopen("/web/index.html", "w"); if (web) { fputs("<html>...</html>", web); fclose(web); } // 4. 演示写数据:模块B写日志 FILE *log = fopen("/log/20250115.csv", "w"); if (log) { fprintf(log, "time,temp,hum\n10:00,25.3,60\n"); fclose(log); } // 5. 演示写数据:模块C读用户设置 FILE *cfg = fopen("/cfg/user.json", "r"); if (cfg) { /* 解析JSON */ fclose(cfg); } }这里没有什么魔法魔法,就是一个朴实原则:每个模块只用与它相头的挂载点路径。用esp_vfs_littlefs_register时传入的partition_label会自动关联到分区表里的分区,底层地址由文件系统管理,你不需要自己算Flash偏移。
5.4 验证:怎么确认数据真的没有“串门”
开发阶段,我最推荐用一个“破坏性测试”来验证分区隔离是否可靠:
- 往三个分区分别写入独特的标记字符串,比如
/web/a.html写"WEB_HELLO",/log/a.csv写"LOG_HELLO",/cfg/a.json写"CFG_HELLO"。 - 执行一次
idf.py erase-flash(注意这会清掉整片Flash)。重新烧录后,再次读取三个文件,确认内容仍分别存在。 - 把其中一个模块的目录改名叫“奇怪的路径”,再重启,确认另外两个模块不受影响。
更硬核的做法是用parttool.py把每个分区dump下来,然后用xxd或者十六进制编辑器查看。如果web_fs分区里出现了log前缀的内容,说明你的文件系统挂载点或路径写错了。
6. 升级与磨损均衡:不能忽略的连带风险
6.1 OTA 升级时,新固件会把数据区“吃掉”吗
OTA升级的过程,其实就是把新的应用镜像通过网络或本地写入到ota_0或ota_1分区。升级完成后,bootloader会根据otadata分区的状态决定启动哪个应用。
如果事先没有分区表,或者分区表里的app分区与数据分区重叠了,那么在OTA写入时,新固件极可能覆盖掉NVS或文件系统里的内容,造成配置和日志全部丢失。所以当你为项目加了OTA功能后,必须检查分区表里的offset和size,确保ota_0、ota_1与所有data分区在空间上不重叠。
我见过一个案例:有人把ota_0的size定义得太大,吓到了web_fs分区的起始地址,OTA升级后Web UI的资源文件全部损坏,网关开机变成白板。后来把OTA分区缩小,才解决。
6.2 Flash 磨损均衡:不能让一个分区“高频加班”
Flash分区大小直接决定擦写寿命。例如,一个日志分区如果只有8KB,每天写几MB数据,用不了多久就会让某些扇区提前报废。日志分区的容量要按“预计设备寿命 × 平均写入速率”来估算。
一个经验值:8MB的NOR Flash,每个扇区典型擦写寿命是10万次,如果按天记录100KB日志,一年的写入量约36MB,均匀分布到一个512KB的分区(128个4KB扇区),每个扇区擦写约288次,看起来完全没问题。但如果你每天写1MB日志,扇区磨损就会再加一个数量级,这时就要考虑增大日志分区,或者采用动态压缩、丢弃旧日志的策略。
NVS和LittleFS都有磨损均衡机制,但前提是分区必须有足够大的空间来“腾挪”。分区越小,均衡效果越差。这也是为什么我总建议NVS不要低于24KB的原因。
6.3 掉电与“半写”状态:如何避免变成“两边都认不出来”
Flash写入是以扇区为单位的,如果写入到一半掉电,数据可能处于半扇区状态。NVS靠commit标记和回滚机制来保证:只有commit成功后,更新才对上层可见。LittleFS也有掉电一致性设计,它采用日志提交(copy-on-write)结构,写入失败时,旧数据依然可以回退。
但即便如此,不要在掉电后对文件系统做“随意删除”这类盲目操作。更稳妥的做法是:
- 对于重要配置,定期备份到另一个分区或SD卡。
- 在写入关键数据时,用一个“版本号”或“校验和”字段,读出来时先校验,再决定是否采用。
- 不要直接调用
spi_flash_write去裸写地址,永远用esp_partition_read/write或VFS文件API。
7. 几个我用下来的隔离经验
7.1 三条黄金法则
先画分区图,再写代码。把Flash当成一块地皮,画清楚哪个区域属于哪个模块,多少空间,然后才能在partitions.csv里落笔。没有分区图,后边写再多代码也容易出事故。
每个存储用户(模块/组件)都要有独一无二的namespace或目录根路径。比如WiFi用wifi_cfg,热使用led_cfg,日志用/log,网页用/web。名字越具体越好,千万不要用cfg、data这种会撞车的通用名。
永远不要用裸地址去操作Flash。ESP-IDF提供的esp_partition_read/write天然支持分区绑定,你再怎么传参也不会跨越分区边界。裸地址操作虽然灵活,但一失手就是整个系统的不稳定。
7.2 常见误区清单
- 误区1:默认分区表“够用”了。实际上默认分区表没有OTA区,也没有独立文件系统区,多模块项目八成会翻车。
- 误区2:NVS命名空间可以随便起。它最长15字符,但如果你把两个模块的namespace都取成“main”,串门概率直接飙升。
- 误区3:一个文件系统分区可以无限扩。LittleFS虽然支持目录,但它没有权限隔离,一个模块误写路径,照样能污染另一个模块的目录。
- 误区4:多个“小应用”可以同时独立运行。很多网友误解这个。准确说,ESP32同一时间只能运行一个固件镜像,我们可以把一个大的固件内拆成多个功能模块,也可以把不同固件放在不同分区通过OTA切换,但“同时跑两个独立固件”不是ESP32的默认能力。因此,多模块数据隔离更依赖分区表与命名空间的协同调配。
7.3 分区设计检查清单
- [ ]
partitions.csv里,所有app分区和所有data分区的地址范围互不重叠。 - [ ]
nvs分区大小至少24KB,最好给到64KB。 - [ ] 每个功能模块都有一个明确指定的NVS命名空间或文件系统目录。
- [ ] 如果支持OTA,已经定义
ota_0、ota_1、otadata,且正确配置了app子类型。 - [ ] 文件系统分区使用LittleFS,并设置
format_if_mount_failed = false(生产环境)。 - [ ] 代码里只通过
esp_partition_*或VFS路径访问存储,不要出现硬编码的Flash地址。
这套方案我从那次丢数据之后一直用到现在,两个多月没再出过配置丢失或者日志损坏的问题。最后分享一个烧录小技巧:开发阶段反复改动分区表后,记得执行一次idf.py erase-flash把整颗Flash清干净,再烧新分区表和固件,避免旧分区残留数据干扰。玩ESP32别心急,先把资源共享的“房产契约”定好,后面写代码能省掉你大把查Bug的时间。