☰
ESP32多应用Flash隔离:分区表、NVS与LittleFS防数据串门实战
2026/10/31 10:36:47 网站建设 项目流程

你肯定遇到过这种场景:ESP32 项目越做越厚,Web 配置页、传感器采集、日志记录、点控逻辑,全部挤在同一颗芯片里,程序编译下载都正常,跑几个星期却发现某个模块的参数变成了另一台设备的编号,或者日志文件里混进了莫名其妙的临时数据,更夸张的是 OTA 升级完,之前辛苦保存的配方表全被清空了。代码层面查了一遍又一遍,没有任何内存越界痕迹,最后才反应过来——真正的战场是 ESP32 的 Flash。多个小应用共用一个 Flash,数据“串门”往往就是这么悄无声息地发生的。

这篇内容就是围绕这个标题来写的,适合正在往 ESP32 里堆功能、已经开始被存储问题折磨的开发伙伴,也适合刚接触 ESP32 分区表、想一次性把 Flash 规划干净的新人。我会把分区模型、NVS 命名空间、LittleFS 多挂载、OTA 分区隔离这些实战点逐个拆开,最后附上我踩过的一些坑。读完你至少能明白一件事:不要让“一块 Flash”变成“一团乱麻”,给每个小应用划清边界,数据自然就不会串门。

1. 先说“串门”是怎么发生的

1.1 一个看起来很正常的现象

我做一个小型智能控制面板的时候,里面同时跑了两个业务模块:一个是灯光控制,一个是定时任务。灯光模块要把“色温”和“亮度”存下来,定时模块要把“下次运行时间”存下来。从业务上看,这两个东西完全不相干,代码也是两个人写的,谁都没去翻对方逻辑。

但偏偏两个模块都习惯了用cfg这个键名去存配置。灯光模块存完亮度,过几个小时定时模块也往cfg里写一个时间戳,灯光模块下次开机读到的时间戳当成亮度去设置,面板直接跳到一股奇怪的白光。你查代码可能只看到每个模块读写自身的变量,但 NVS 底层就是一个共享的键值数据库,两个模块都往同一个命名空间、同一个 key 里写,后写的必然覆盖先写的。

类似现象在很多固件里都能看到:日志模块写log.txt,配置模块也写log.txt;或者 A 功能的人用Preferences.begin("app", false),B 功能的人也写"app"。这种共享关系一开始够用,因为你只在一个 app 里开发,没有“别人的分区”这个概念,等模块多起来,冲突立刻就会被触发。

1.2 Flash 上的“地盘”是如何划分的

ESP32 的 Flash 从物理上看是一颗 SPI Nor Flash,内部按块(Block)和扇区(Sector)管理,最小的擦除单位通常是 4KB。芯片上电后,ROM bootloader 会去固定偏移读取引导信息,然后跳转到分区表指定的 app 分区运行。你可以在menuconfig里选不同的分区表类型,也可以自定义partitions.csv。

默认的partitions.csv大致长这样:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy_init, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x100000,

这里0x9000开始有 24KB 的 NVS 分区,0xf000开始有 4KB 的 PHY 初始化参数区,0x10000开始有 1MB 的 application 分区。平时你的整个程序,包括所有“小应用”的代码,全都塞进factory这一个 app 分区里;所有键值参数,全都塞进nvs这一个分区里。哪怕物理上有 4MB 或者 8MB,默认表也只给你画了几个格子,其余空间就这么白白空着。

分区表本质是一张“地图”:它告诉你 Flash 上每个字节属于谁。如果没有这张地图,或者说没有为不同小应用分配独立区域,那么所有数据都在同一个可写空间里抢占地盘。你以为各自独立,其实底层全在一起。

1.3 为什么要用分区而不是让 App 自己写

有人会说:那我直接用一个变量记住自己的 Flash 地址,写的时候保证不跟别人重叠不就行了吗?确实,在裸机开发里大家经常这么干,收到一个地址就去擦扇区、写数据。但在 ESP32 上这种玩法风险很大,主要有三个原因。

第一,Flash 只能按扇区擦除,不能随意在任意字节上做清零之外的修改。你如果要更新一个 4 字节参数,通常得先把整个扇区读出来,修改目标字节,再擦掉整个扇区写回去。这中间任何一个模块做了类似操作,就可能覆盖你的新值。

第二,ESP32 的 Flash 在运行时存在地址映射、Cache 和写保护机制,应用不能像操作普通寄存器一样直接*(volatile uint32_t *)(flash_addr) = value去写。厂商提供的是esp_partition_write、nvs_set这类 API,它们内部要根据分区表做边界检查。

第三,也是最关键的:一旦你直接操作地址,就无法享受 OTA 升级、NVS 抹平老化、文件系统磨损均衡这些现成能力。OTA 升级时新的固件会写入另一个 app 分区,你若因为地址重叠把数据区塞进了 app 分区,升级一次数据就全没了。所以正确做法是:先通过分区表把 Flash 划分成独立领地,再在各自领地里通过官方 API 读写。

2. Flash 分区表:把“一块地”隔成“几间房”

2.1 认识 partitions.csv

在 ESP-IDF 工程里,分区表通常是一个partitions.csv文件。每一行代表一个分区,从左到右五个字段:Name、Type、SubType、Offset、Size,最后还有可选的 Flags。我用一张表把这些字段说清楚:

字段作用示例
Name分区名称,代码里通过 label 查找storage
Type分区类型,主要分app和dataapp
SubType子类型,用于进一步区分用途factory、ota_0、spiffs
Offset分区在 Flash 上的起始地址0x10000
Size分区大小,可用 K/M 或十六进制0x100000

重点看 Type 和 SubType。app类型用于存放可执行固件,子类型有factory、ota_0、ota_1等;data类型用于存放数据,子类型有nvs、phy_init、littlefs、spiffs、fat、coredump等。当你想给某个小应用一块自己的存储区时,最直接的做法就是新建一个data类型分区,再给它一个子类型,比如littlefs。

Offset 和 Size 也很有门道。分区表本身位于 0x8000 偏移,长度 0x1000 字节,所以第一个普通数据分区一般从 0x9000 开始。app 分区由于引导要求,通常从 0x10000 开始,并且 Offset 必须按 4KB 对齐。Size 不一定要对齐到扇区,但建议也按 4KB 对齐,否则后续擦写、文件系统格式化容易出幺蛾子。

2.2 Type、SubType、Offset、Size 到底怎么填

很多新手拿到 CSV 模板,会照抄默认表,然后想在里面加一个数据分区,却不知道 Offset 该怎么算。我一般按这样的顺序来排:先确定 Flash 物理总容量,然后从低地址往高地址依次排列 bootloader、分区表、NVS、PHY init、app 分区、数据分区。

我来用一个常用 4MB Flash 的示例说明,总容量是 0x400000:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy_init,0xf000, 0x1000, factory, app, factory, 0x10000, 0x1F0000, storage, data, littlefs,0x200000, 0x100000, logstore, data, littlefs,0x300000, 0xE0000,

这里factory从 0x10000 开始,大小 0x1F0000,也就是约 1.94MB,给程序镜像用。然后storage从 0x200000 开始,大小 1MB,给 Web 页面、配置文件等长期数据用;logstore从 0x300000 开始,大小接近 896KB,专门给日志文件用。

每个分区之间没有重叠,因为我是用前一个分区的 Offset 加上 Size 得到下一个 Offset。你可以看到最后logstore用到了 0x3E0000,4MB Flash 到 0x400000 还剩下 128KB 空间,这部分可以留给未来扩展。如果你不需要日志这么大的空间,就可以把剩余空间塞给storage,或者在最后加一个coredump分区。

2.3 一个支持多小应用的分区表示例

“多个小应用”很多情况下并不是指多个独立固件镜像,而是同一个固件里面的几个功能模块。假如这个固件本身要跑两个应用,每个应用都有独立的配置存储和日志存储,那么分区表可以这样设计:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy_init,0xf000, 0x1000, factory, app, factory, 0x10000, 0x200000, app1_data, data, littlefs,0x210000, 0x180000, app2_data, data, littlefs,0x390000, 0x180000,

如果 Flash 是 8MB,还能更从容。8MB 对应 0x800000,前面预留 0x10000,中间给 app、两个数据分区各留 1.5MB 以上都行。核心原则就是:每一个数据分区分配一个独立的data子类型分区,代码里用partition_label去寻找属于自己的分区。这样即使 app1 区的文件系统整个损坏格式化,也不会影响 app2 区。

提示:分区表 CSV 的 Type 和 SubType 不一定只能用官方预定义值,Type大于等于 0x40 时也可以视为自定义类型。但作为常规方案,我推荐先用官方的data + littlefs组合,因为 ESP-IDF 的内置组件和工具链对标准子类型的支持最完整。

3. 键值数据如何隔离:NVS 命名空间

3.1 NVS 的定位和限制

NVS(Non-Volatile Storage)是 ESP-IDF 提供的一个嵌入式键值数据库。它的特点是小巧、可靠、有磨损均衡,适合保存设备配置、网络参数、传感器校准值这类几十到几百字节的数据。它不会像 FAT 或 LittleFS 那样暴露文件路径,而是暴露一个类似“key-value”的接口,写起来非常方便。

但也正因为接口方便,很多人把它当成万能存储,什么数据都往一个全局 namespace 里塞。NVS 对 key 长度有限制,不同版本限制不同,早期的是 15 个字符,现在可能是 15 或更大;namespace 同样有限制。一个 key 对应一个 value,value 可以是整数、字符串、Blob。如果两个模块共用同一个 namespace,并且用了同一个 key,后写者就会覆盖前者,没有任何提醒。

NVS 的底层在 Flash 里是按页管理的,每页大小和擦写策略由驱动内部封装。正因为它做了一层抽象,使用 NVS 时我们几乎不直接碰地址,但代价是同一分区内的键值空间是全局共享的,必须在应用层做隔离。

3.2 用命名空间隔离多个应用

最简单的隔离方式就是给每一个小应用分配一个独立的 NVS 命名空间。ESP-IDF 的nvs_open函数第一个参数就是 namespace 名称。

看一段实际代码,这是“灯光控制”模块把亮度存到名为light_ctl的 namespace:

#include "nvs.h" #include "nvs_flash.h" esp_err_t save_brightness(uint8_t brightness) { nvs_handle_t handle; esp_err_t err = nvs_open("light_ctl", NVS_READWRITE, &handle); if (err != ESP_OK) { return err; } err = nvs_set_u8(handle, "brightness", brightness); if (err == ESP_OK) { err = nvs_commit(handle); } nvs_close(handle); return err; } esp_err_t load_brightness(uint8_t *brightness) { nvs_handle_t handle; esp_err_t err = nvs_open("light_ctl", NVS_READONLY, &handle); if (err != ESP_OK) { return err; } *brightness = 0; err = nvs_get_u8(handle, "brightness", brightness); nvs_close(handle); return err; }

另一个定时模块则使用timer_cfg作为 namespace:

nvs_open("timer_cfg", NVS_READWRITE, &handle); nvs_set_i64(handle, "next_run", next_run_ms); nvs_commit(handle); nvs_close(handle);

两个 namespace 在逻辑上相互独立,即使里面都出现brightness、next_run也不会串。所以,规则第一条就是:每个业务模块都要有自己的 namespace,不要所有模块共用"app"或"config"这种宽泛名字。

3.3 封装一套不串门的参数接口

光靠记住“不要用同一个 namespace”还不够,因为人总会偷懒。我比较推荐在工程里再抽一层统一的存储接口,把所有 NVS 调用封装起来,让业务模块看不到裸的 key-value 操作。

做法是建一个storage.h:

esp_err_t storage_light_set_brightness(uint8_t value); esp_err_t storage_light_get_brightness(uint8_t *value); esp_err_t storage_timer_set_next_run(int64_t timestamp); esp_err_t storage_timer_get_next_run(int64_t *timestamp);

每个函数内部去nvs_open对应的 namespace,并统一处理错误码。这样做有几个好处:业务代码不需要关心 key 命名;模块负责人可以只负责自己的函数;以后如果要迁移到数据库或改用文件系统,只改storage.c就够了。实际写代码时我还会在每个nvs_open之后检查返回值,因为 Flash 分区未初始化或损坏时,NVS 可能返回ESP_ERR_NVS_NO_FREE_PAGES之类错误。

注意:NVS 的 namespace 长度和 key 长度有限,命名不要太文艺。我建议用「模块名_用途」的格式,比如light_bri、timer_run,既短又能表达含义。不要用带空格或中文的 key,部分 ESP-IDF 版本的 NVS 对某些特殊字符支持并不好。

4. 文件数据如何隔离:多分区挂载 LittleFS

4.1 为什么文件系统也要分区

NVS 适合存小的键值对,但不适合存网页、日志文件、截图、OTA 缓存包这类大块数据。这时候就得用文件系统。ESP32 上常见的文件系统有 LittleFS 和 SPIFFS,LittleFS 更适合掉电安全、目录操作较多的场景。

文件系统一旦被引入,就出现一个新的串门通道:多个模块共用同一个分区、同一个挂载点,在根目录下创建同名文件。比如 Web 模块把生成的临时网页写到web/index.html,日志模块也把日志写到web/log.txt,短期内看不出问题,一旦 A 模块执行remove("/web/index.html"),B 模块的日志也受牵连。

给每个小应用分配独立的分区是最好的方案。在 2.3 节的分区表里,app1_data和app2_data是两个独立的littlefs分区,代码里分别用不同的挂载路径和分区 label 注册,就能从根上避免文件名冲突。

4.2 在 ESP-IDF 中挂载多个 LittleFS 分区

ESP-IDF 官方没有默认集成 LittleFS,但在较新版本里可以通过idf.py add-dependency esp_littlefs添加,或者去 GitHub 拉取joltwallet/esp_littlefs组件。挂载多个分区时,需要为每个分区调用一次esp_vfs_littlefs_register,关键是base_path和partition_label不能重。

下面是我在项目里实际用过的挂载代码,核心逻辑就是给两个分区分别注册:

#include "esp_littlefs.h" #define APP1_MOUNT "/app1" #define APP2_MOUNT "/app2" esp_err_t mount_my_fs(void) { esp_vfs_littlefs_conf_t app1_conf = { .base_path = APP1_MOUNT, .partition_label = "app1_data", .format_if_mount_failed = false, .dont_mount = false, }; esp_err_t err = esp_vfs_littlefs_register(&app1_conf); if (err != ESP_OK) { return err; } esp_vfs_littlefs_conf_t app2_conf = { .base_path = APP2_MOUNT, .partition_label = "app2_data", .format_if_mount_failed = false, .dont_mount = false, }; err = esp_vfs_littlefs_register(&app2_conf); if (err != ESP_OK) { return err; } return ESP_OK; }

partition_label必须和 CSV 里的 Name 一致,否则esp_vfs_littlefs_register会返回ESP_ERR_NOT_FOUND。base_path是每个文件系统在 VFS 里的挂载点,两个分区挂到不同路径后,/app1/config.json和/app2/config.json互不影响。

4.3 文件读写时的“跨区”风险与规避

有了独立分区和挂载点之后,还有最后一层风险:代码里如果直接绕过抽象层,用绝对路径去访问不属于自己的分区,依然可能串门。比如日志模块正确路径是/app2/log.txt,但如果某个人图方便写成了/app1/log.txt,它就会去写 app1 的分区,两边数据就混了。

所以我会在工程里增加一个简单的访问约束:每个模块只允许通过自己封装的文件操作函数读写文件;所有路径以模块自己的挂载点为前缀,不允许任意拼路径。

同时要注意format_if_mount_failed的选项。如果设成true,当分区表信息不匹配或文件系统损坏时,设备会自动格式化该分区。这在调试期非常方便,但在生产环境却很危险——如果误把partition_label填错,格式化就可能发生在另一个分区的头上。我的经验是:生产固件把format_if_mount_failed设为false,让挂载失败直接暴露出来,而不是悄悄把一个分区清空。

5. 多固件共享 Flash 的进阶姿势:OTA 分区

5.1 factory / app0 / app1 的用途

前面说的“多个小应用”指一个固件里的多个模块。但如果你的产品里真的存在多个独立固件,比如一个 bootloader 负责引导,两个应用固件分别对应不同模式,那 Flash 的划分就得更谨慎。ESP32 的 OTA 机制使用ota_0、ota_1这类 app 子类型分区,bootloader 会根据 OTA 数据切换到某个分区运行。

一个典型的 OTA 分区表长这样:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy_init,0xf000, 0x1000, ota_0, app, ota_0, 0x10000, 0x200000, ota_1, app, ota_1, 0x210000, 0x200000, storage, data, littlefs,0x410000, 0x200000,

ota_0和ota_1大小相同,固件运行时可以在它们之间做 A/B 切换。当前运行的是ota_0,升级新版本时会写入ota_1,写完后把ota_1标记为可启动。这样即使新固件启动失败,bootloader 还能回滚到旧版本。

5.2 怎么防止 OTA 升级把数据区冲掉

OTA 升级本身并不会主动去动 NVS 或 LittleFS 分区,但有两个常见坑让数据看起来像被“冲掉”了。

第一个坑是 app 分区大小不足。如果固件编译出来的factory.bin超过ota_0或ota_1的空间,烧录时可能失败,或者升级时数据被写到相邻分区尾部。解决办法是先用size查看固件体积,再根据实际体积给每个 OTA app 分区分配足够空间。比如固件是 1.2MB,你就要留 1.5MB 以上,而不是卡着 1MB。

第二个坑是 NVS 里的ota_data分区或nvs分区被重新格式化。默认 NVS 分区里除了业务数据,还存放着 bootloader 和 OTA 的状态信息(例如当前启动的 app 分区序号)。如果你为了给某个小应用腾空间,把 NVS 分区整个擦掉,OTA 状态也会一起丢失,下次启动 bootloader 找不到有效的 app 分区,表现为“升级后变砖”或“数据全没了”。

解决思路是:在分区表里给nvs分区留出足够空间,建议至少 24KB;不要把 OTA 状态和数据存储混到同一个会被业务频繁写改的 NVS 分区里。如果业务模块需要使用独立 NVS 空间,可以在分区表中再增加一个nvs类型分区,但一定要保留一个专门的ota_data分区或默认nvs分区给 OTA 状态使用。

5.3 多应用固件的升级策略

如果你的“多个小应用”确实是多个独立固件,例如一个负责联网,一个负责交互,那更推荐把它们放进同一个 app 分区,做成一个带多任务的单固件,而不是真的让 ESP32 同时运行两个 app 镜像。ESP32 的 app 分区机制侧重 A/B 升级,不是像 Linux 那样跑多个可执行文件。

如果由于业务隔离需要确实要维护两套独立固件,可以做成“一个引导固件 + 一个业务固件”的组合:引导固件通过 OTA 接口下载业务固件,写入另一个 OTA app 分区,然后通过esp_ota_set_boot_partition设置启动目标。这里面的关键还是分区表:引导固件放在factory,业务固件放在ota_0,两个分区互不覆盖。这样升级业务固件时引导固件始终安全,升级失败也还有办法救回来。

6. 实战排错:分区串门的高频坑

6.1 常见报错与排查步骤

不管你规划得再好,真机跑起来还是会出问题。我整理了这几个最常见的情况,基本能覆盖 80% 的 Flash 串门问题:

现象可能原因排查方向
NVS 读到别的模块的值两个模块用了同一个 namespace 或同一个 keydump NVS 内容,或给模块加独立 namespace
LittleFS 挂载返回ESP_ERR_NOT_FOUNDpartition_label和 CSV Name 不一致用esp_partition_find_first打印实际分区 label
升级后配置清空没有独立 OTA 状态空间,或者 NVS 被业务模块整个格式化检查 NVS 分区大小,确认 OTA 状态未被破坏
OTA 写镜像失败app 分区太小,固件溢出到数据分区看编译生成的.bin大小,重新分配分区
flash download fail分区表偏移与 Flash 总容量不匹配,或 Flash 实际芯片小于表配置确认芯片容量,用parttool.py烧录正确分区表

排查的第一步永远是确认当前芯片里实际烧录的分区表,别只看 CSV。用 esptool 读出来看:

esptool.py --port /dev/ttyUSB0 read_flash 0x8000 0x1000 partition_table.bin

把这 4KB 文件解析后,就能看到每个分区的真实地址。很多“串门”其实就是这里错了:代码里找app1_data,但烧进芯片的分区表里写的却是app1_dat,完全对不上。

6.2 用工具检查实际分区布局

编译时可以打印分区表。ESP-IDF 工程里执行:

idf.py partition-table

它会根据partitions.csv生成partition-table.bin,并输出解析后的表格信息。也可以用parttool.py查询单个分区:

python3 parttool.py --partition-table-offset 0x8000 --port /dev/ttyUSB0 get_partition_info --partition-name storage --partition-type data

在 Arduino 环境里,分区表一般封装在开发板 JSON 配置中,但也可以用esptool.py read_flash把整个 Flash 读回来,再用esp_partition.py之类脚本分析。我的习惯是:每次修改 CSV 后,一定要先编译,再烧录,再跑一下所有涉及 Flash 读写的最小用例,确认每个模块都能读写自己的分区。

6.3 我惯用的一条分区设计基线

并不是每个项目都需要复杂分区。针对 4MB Flash、单固件多小应用的普通产品,我基本会这样布局:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy_init,0xf000, 0x1000, factory, app, factory, 0x10000, 0x1F0000, storage, data, littlefs,0x200000, 0x100000, logstore, data, littlefs,0x300000, 0xE0000,

这套布局的要点是:NVS 只保存键值配置,不存大文件;storage给所有需要持久化的小应用共用,但每个小应用在挂载后自己建子目录,比如/storage/light、/storage/timer;logstore单独一块,避免日志把正常业务文件挤爆。如果后续要上 OTA,再把factory的位置替换成ota_0和ota_1,并相应压缩数据区大小。

提示:不要看到默认分区表里有 1MB 的 factory,就直接把整个 4MB Flash 的剩余空间当成“空地”来用。未分区区域没有被任何 API 管理,直接访问会触发非法写保护,或者说即使能写,也不会被 wear leveling 管理。想利用空余 Flash,一定要通过分区表合法声明。

最后再分享一个小技巧

我在多个 ESP32 项目里摸爬滚打后,总结出一条规律:不要指望靠开发者的“自觉”来防止串门,而是要用分区表、命名空间、挂载点这些硬隔离把边界划死。每个业务模块只允许访问自己的 namespace 和挂载路径,其余地方一律不碰。哪怕某个模块崩溃、Flash 出现坏块或者自动格式化,也只影响它自己,不会殃及池鱼。

还有一个细节值得记住:分区表的 Name 不要随手起,因为它不仅是代码里查找分区的依据,也是文件系统挂载时和partition_label做匹配的关键。我曾经把storage写成了storage2,代码里却一直用storage去查,挂载失败折腾了一整天,最后打印出实际分区列表才发现拼写问题。先用idf.py partition-table看一眼,再写代码,能省掉很多调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询