☰
ESP32分区表详解:多应用共用Flash的数据隔离方案
2026/10/1 9:02:27 网站建设 项目流程

1. 为什么要认真对待 ESP32 的分区表

先把结论放在前面:ESP32 的 Flash 是多个小应用共存的唯一命脉,而分区表就是这套共同居住方案的“房契”和“地契”。我用 ESP32 做过几个实际项目,最让人头疼的往往不是逻辑代码跑飞,而是多个功能模块在同一个 Flash 上互相踩踏——今天这个模块存的配置把另一个模块的数据覆盖了,明天升级固件时又把用户的历史记录刷没了。这种问题一旦上线,排查起来非常痛苦,因为代码逻辑上看起来完全没问题,但数据就是莫名其妙地“串门”了。

先说清楚一个问题:ESP32 片上集成的是 SPI Flash,用来存放固件、配置文件、日志、证书、Web 页面等一切掉电不能丢失的东西。常见的 ESP32 模块(比如 WROOM-32)默认带 4MB Flash,也有 8MB、16MB 的版本。Flash 本身是一块连续的存储空间,但同一时刻可能有多个“小应用”——比如一个 App 负责联网升级、一个 App 负责传感器数据记录、一个 App 负责蓝牙配网,它们都往 Flash 里写东西。如果不把这块 Flash 提前划分清楚,那后面发生的事情就是灾难。

我最早吃过一次亏:当时在做一个智能家居网关,固件里集成了一套 Web 页面(用于本地管理设备),同时还有温度历史记录存储。我刚开始图省事,直接把 Web 页面打包进固件分区,把传感器数据也往同一个分区里写。结果运行了两个月后,用户反馈设备升级后会变成“白屏”,而且历史记录经常丢失。查了一圈才发现,固件分区被 OTA 升级覆盖时,连带把历史数据区也一并清掉了——因为这两段数据在 flash 地址上挨在一起,而分区表又没有明确划分边界。从那以后,我彻底弄明白了:在 ESP32 上,分区表绝不是可有可无的配置项,而是整个 Flash 管理的地基。

这篇文章会从分区表的原理讲起,再结合常见的“多个小应用共用 Flash”场景,给你一套可以直接抄作业的配置方法和代码示例。适合刚接触 ESP32 开发、以及已经在用 ESP32 做项目但是苦于数据互相干扰的开发者。

2. ESP32 Flash 的整体结构解析

2.1 Flash、分区表、分区之间的关系

拿买房来打比方:Flash 相当于一整栋毛坯楼,分区表相当于房产局的登记册,而每个分区就是一套已经划分好边界的房子。ESP32 启动时,ROM 引导程序会先去读取 Flash 开头的分区表,确认哪个区域存 Bootloader、哪个区域存应用固件、哪个区域存文件系统,然后才敢动手干活。

ESP32 的 Flash 地址从 0x000000 开始,下面是典型的出厂布局:

起始地址长度分区类型分区子类型名称用途
0x0000000x10000x000x00bootloader一级引导程序
0x0100000x100000x010x00nvs键值存储(NVS)
0x0200000x100000x010x01otadataOTA 信息记录
0x0300000x2000000x000x10app0主应用固件(OTA 槽位 0)
0x2300000x2000000x000x11app1OTA 备份槽位
0x4300000x1000000x010x82spiffs文件系统存储

分区表本身是一张 CSV 格式的表格,编译时通过“partition_table_CSV”配置文件生成二进制分区表,烧录到 Flash 的 0x8000 地址附近。所有分区的总大小不能超过 Flash 实际容量,而且每个分区都有自己的起始地址、长度、类型和子类型。

这里有一个关键点:分区表决定了 Flash 的“法律边界”,但代码能不能守住这条边界,就要靠你自己了。因为 ESP32 的读写 API(比如 esp_partition_read/write)确实会在分区内部做边界检查,可如果你用裸的 SPI Flash 读写函数(esp_flash_read/esp_flash_write),那就是完全绕过分区表直接访问硬件地址了,随意一个越界地址就可能把别的分区打得稀巴烂。

2.2 分区类型和子类型到底怎么区分

在 ESP-IDF 中,分区类型(type)和子类型(subtype)是一个让人一开始容易懵的组合。常见的 type 有以下几种:

  • 0x00(app):存放固件,子类型 0x10 是 factory 出厂固件,0x00 是 OTA 槽位 0,0x10 是 OTA 槽位 1,以此类推。
  • 0x01(data):存放数据,子类型非常多,常见的有 0x00(NVS)、0x01(OTA 信息)、0x02(PHY 初始化数据)、0x81(SPIFFS)、0x82(LittleFS)等。
  • 0x02 ~ 0xFE:自定义分区类型,可以由开发者自由使用。
  • 0xFF:表示未使用。

我见过不少朋友有个误解,以为只要把多个文件系统分区都设为 SPIFFS 类型就能互相隔离。实际上,同类型的多个分区虽然物理上分开,但如果你在代码里没有把它们各自挂载到不同的目录/标签,那么操作时的路径冲突一样会造成串门。举个例子:两个 SPIFFS 分区分别命名为“web”和“data”,如果都用esp_vfs_spiffs_register挂载时报了相同的“/spiffs”前缀,第二个挂载往往会失败,就算成功,读写路径也会彼此覆盖。所以分区规划完了,代码里的挂载路径和标签也必须有严格的隔离意识。

2.3 为什么 OTAdata 这个分区如此重要

提到分区表,绝对不能跳过 OTA 信息分区。它位于分区表中,名为“otadata”,专门记录当前应该从哪个 OTA 槽位启动。ESP32 的 OTA 机制是典型的 A/B 分区方案:app0 是当前运行的槽位,app1 是待升级槽位。升级时把新固件写入 app1,写入完成后将 otadata 的状态标记为“app1 已验证可用”,然后重启,引导程序根据 otadata 的指示从 app1 启动。

如果分区表里没有 otadata,会怎么样?OTA 功能直接不可用。我实际遇到过把出厂默认分区表删掉、自己重写一个简易分区表时忘了加 otadata,结果升级时不断重启到 factory 分区,连日志都是老的。排查了很久才想起这件事。所以凡是打算用 OTA 升级的项目,分区表必须预留 otadata 分区,哪怕它只有 0x10000(64KB)大小——实际上 otadata 只需要一个扇区大小就够,但为了对齐和未来扩展,一般给 0x10000 比较稳。

3. “多个小应用共用 Flash”的实际场景推演

3.1 典型场景一:一套固件内集成多个功能模块

这是最常见的情况。一个 ESP32 项目里同时有:

  • 配网模块(用 NVS 保存 Wi-Fi SSID/密码)
  • 业务模块(用 NVS 保存设备运行参数)
  • Web 服务器模块(用文件系统保存前端页面)
  • 日志模块(用文件系统保存运行日志)

这四类数据都存放在同一块 Flash 上。如果不做任何规划,全往 NVS 里塞,那么 NVS 的键值空间很快会被不同的功能模块搅成一锅粥——Wi-Fi 模块可能把业务模块的键给覆盖了,或者日志文件把 Web 页面文件顶掉了。最直接的办法,就是将不同类型的数据分配到不同分区,并且为每个模块建立独立的 NVS namespace。

NVS 在 ESP-IDF 中的设计其实已经考虑到了这个问题:它支持命名空间(namespace)。你在代码里调用nvs_open("wifi_config", NVS_READWRITE, &handle),只会操作名为“wifi_config”的命名空间;nvs_open("device_config", NVS_READWRITE, &handle)操作的是“device_config”空间。两个空间的键值不会互相覆盖。我一个项目里最多开了五六个 namespace,每个模块只管自己的,互相之间完全无感,而且不同 namespace 还可以设置为只读,防止其他模块误写。

3.2 典型场景二:同一 Flash 上运行多个独立固件

还有一种场景是“轮换式”:同一块 Flash 上安装了多个独立编译的固件,比如一个固件是传感器数据采集器,另一个是网关模式,通过某种方式在启动时根据外部开关或网络指令选择运行哪个固件。这种方案本质上就是使用多个 app 分区,配合不同的分区表组合。

但这里有一个硬约束:同一时刻只有一个 app 分区能运行。因为 ESP32 的 CPU 只能执行 Flash 中映射到内存区域的内容,不可能同时跑两份固件。如果你真的需要“两个应用同时跑”,那就不是分区表的范畴,得靠 FreeRTOS 多任务——在同一个固件里开两个任务。而如果是“多个固件轮替运行”,那就可以用多个 factory/OTA 槽位实现切换,每次只烧录到非当前运行的分区,重启切换槽位。

我做过一个录音笔类的设备,里面存了“录音采集固件”和“播放器固件”两份 App,按键长按 3 秒切换到播放器模式。实现方式就是在分区表里分配两个足够大小的 app 分区,当前固件运行时会检查一个“模式切换”的 NVS 键,然后调用esp_ota_set_boot_partition切换到另一个 app 分区。整个过程核心就是分区表要把两个 app 分区的边界画清晰,并且给“模式标记”留一个专门的 NVS 分区。

3.3 典型场景三:文件系统空间互相隔离

文件系统(LittleFS / SPIFFS)在 ESP32 项目里简直是刚需:网页资源、图片、音频、证书文件都需要它。多个小应用如果都往同一个文件系统分区里写文件,很容易把空间耗尽,甚至出现一个应用删掉另一个应用文件的“惨案”。

解决办法就是给每个应用分配独立的文件系统分区。比如定义两个分区:web_fs(用于 Web 页面,挂载到/web)、log_fs(用于日志,挂载到/log)。这两个分区物理上是两块,代码里分别挂载,互不干扰。即使其中一个被写满、损坏,另一个也不受影响。

同样,要在分区表里给日志分区,也留一个独立空间。很多垃圾问题排查往往需要看日志,如果日志和 Web 页面在同一个文件系统里,一旦 Web 页面被 OTA 升级覆盖,日志同时也没了,下次想定位问题都没法查。把日志分区单独拉出来,升级 App 时不动日志分区,就能保留历史日志。

4. 分区表设计:一步步搭建防串门的隔离方案

4.1 先画清楚内存地图

分区表设计的第一步,不是打开配置工具,而是先算清楚你的需求。我建议你在纸上或表格里列一下:

需求大概需要大小类型/子类型期望的持久性
系统固件(含 OTA 备份槽)根据编译后固件大小 ×2app升级后保留
配网信息(SSID、密码、设备ID)16KB~64KBNVS升级后保留
业务参数(阈值、开关量)16KB~64KBNVS升级后保留
Web 页面资源1MB~3MBLittleFS升级后保留
传感器历史日志256KB~1MBLittleFS升级后保留
OTA 切换标记16KB~64KBotadata升级后保留

估算好了之后,再把这些需求映射到 Flash 的物理空间里。这里有个重要原则:地址对齐到 Flash 扇区。ESP32 Flash 的扇区大小通常是 0x1000(4KB),所以每个分区的起始地址和长度最好都是 0x1000 的整数倍,否则分区工具可能会报警,甚至烧录时出现奇怪的越界问题。

另外一个小经验:如果 Flash 总容量不大,宁可将 app 的 OTA 槽压缩到刚好够放固件的大小,也要把 NVS 和文件系统留足。因为固件升级后代码是可以重新烧录的,但用户的数据一旦没了就是真没了。我在做 4MB Flash 的模块时,通常会限制固件在 1.2MB 以内,这样 app0、app1 各占 1.4MB,留下约 1.2MB 给 NVS、文件系统和日志。当然,具体分配要根据实际项目来,没有一种“万能比例”。

4.2 用 CSV 自定义分区表:实操演示

ESP-IDF 的分区表使用 CSV 文件编写,默认位置是partitions.csv。下面是它最基本的结构:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, app0, app, ota_0, 0x10000, 0x200000, app1, app, ota_1, 0x210000,0x200000, web_fs, data, spiffs, 0x410000,0x100000, log_fs, data, littlefs,0x510000,0x80000,

这个 CSV 里的字段依次是:名称、类型、子类型、偏移地址、大小(十六进制)、标志位。

实际使用中,你不需要手动计算偏移。ESP-IDF 支持不带 Offset 的写法,比如:

nvs, data, nvs, 0x9000, 0x4000 otadata, data, ota, , 0x2000 app0, app, ota_0, , 0x200000 app1, app, ota_1, , 0x200000 web_fs, data, spiffs, , 0x100000 log_fs, data, littlefs, , 0x80000

此时分区表工具会自动按顺序把没有指定 Offset 的分区排到上一个分区之后。不过我还是建议至少把 nvs 的 Offset 显式写出来,因为默认厂商分区表会把 nvs 放在 0x9000,如果你把nvs放到最后,可能会与默认的 bootloader 区域重叠。

在 CMake 项目里,你需要在CMakeLists.txt中指定:

set(PARTITION_TABLE_CSV "partitions_custom.csv")

或者在menuconfig的Partition Table选项中选择Custom partition table CSV并填入文件名。

4.3 烧录时分区表如何生效

分区表本身也是一个独立的烧录对象,地址固定在 0x8000。烧录命令常见的有两种:

# 整片烧录(包含 bootloader、分区表、应用固件) idf.py -p /dev/ttyUSB0 flash # 只烧分区表 idf.py -p /dev/ttyUSB0 partition-table

如果你使用 esptool.py 手动烧录,需要注意分区表偏移:

esptool.py --chip esp32 -p /dev/ttyUSB0 -b 460800 write_flash 0x8000 partition_table.bin

这里有个隐藏坑:如果你在 menuconfig 里完成了分区表配置,但烧录时没有把新的分区表写入 Flash,那么应用固件启动后可能按照旧分区表来解析,导致明明代码里加了新分区,Flash 里却没有对应空间,一读写就报错。这个问题在我刚开始用自定义分区表时遇到过好几次,后来我习惯在每次改了 CSV 之后,先单独烧一遍 partition-table,再烧 app,确保 Flash 里的“房契”和代码里的“房契”是同一版本。

5. 实践:在代码层面真正守住数据边界

5.1 使用 esp_partition API 进入专属区域

分区表写清楚只是第一步,代码里也要用对 API。正路是使用esp_partition_*系列函数,它们会在操作前自动校验地址是否越界。示例代码如下:

#include "esp_partition.h" const esp_partition_t *my_part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_LITTLEFS, "log_fs"); if (my_part == NULL) { ESP_LOGE("TEST", "log_fs partition not found"); return; } // 写入数据,length 不能超过分区大小 esp_err_t err = esp_partition_write(my_part, 0, data, data_len); if (err != ESP_OK) { ESP_LOGE("TEST", "write failed: %s", esp_err_to_name(err)); }

这里反复强调的是分区名必须和 CSV 里定义的名字完全一致。一个字母大小写错都不行,比如 CSV 写的log_fs,代码里用了LOG_FS,esp_partition_find_first就会返回 NULL。我踩过这个坑,排查了半天才发现是大小写问题。

如果你要读写的分区是 app 类型,必须确保该分区当前不在运行状态。不能直接往正在运行中的 app 分区写入数据,因为 Flash 的代码映射区域会被破坏,导致 CPU 取指错误。这几乎是所有 ESP32 新手都会问的:为什么我对 app 分区调用esp_partition_write没有反应或者直接崩溃?答案就是这个。

5.2 NVS 的多命名空间隔离

NVS 是大家最容易“串门”的地方。它虽然提供了命名空间隔离,但很多人不注意,把所有的配置全都扔进同一个默认的 namespace 里,然后键名又起得非常通用,比如“mode”“count”“data”,后果就是 A 模块改了配置,B 模块读到的是 A 的值。

正确的做法是:每个小应用都创建自己独立的 namespace。以一个包含配网模块和业务模块的项目为例:

#include "nvs.h" #include "nvs_flash.h" // Wi-Fi 配网模块 nvs_handle_t wifi_handle; nvs_open("wifi_config", NVS_READWRITE, &wifi_handle); nvs_set_str(wifi_handle, "ssid", "my_wifi"); nvs_set_str(wifi_handle, "password", "secret123"); nvs_commit(wifi_handle); // 业务模块 nvs_handle_t device_handle; nvs_open("device_config", NVS_READWRITE, &device_handle); nvs_set_i32(device_handle, "fan_speed", 3); nvs_commit(device_handle);

当业务模块想读 Wi-Fi 密码时,即使它真的调用了nvs_get_str(..., "password", ...),只要它还是用device_config这个 namespace,就不会读到 Wi-Fi 模块存的值——因为两个 key 存在于不同命名空间,互不可见。这就是“门牌号和房间号双重隔离”的意义。

注意,NVS 的一个键名最多 15 个字符,命名空间本身最长也是 15 个字符。太长会被截断或返回错误。如果你的键名天然就很长,请做缩写映射,否则后面排错会是一场灾难。

还有一个细节:NVS 的读写在写入后需要调用nvs_commit才会真正落盘到 Flash。很多新手忘了这一步,数据写到一半断电就丢了。不是大问题,但如果你指望掉电保护数据,必须在关键写操作后调用 commit。

5.3 多个文件系统分区的挂载与使用

文件系统隔离在代码层面也相当直接。每个分区单独挂载到不同的 VFS 路径,避免冲突。使用 LittleFS 的典型代码:

#include "esp_littlefs.h" esp_vfs_littlefs_conf_t web_conf = { .base_path = "/web", .partition_label = "web_fs", .format_if_mount_failed = true, .dont_mount = false, }; esp_vfs_littlefs_register(&web_conf); esp_vfs_littlefs_conf_t log_conf = { .base_path = "/log", .partition_label = "log_fs", .format_if_mount_failed = true, .dont_mount = false, }; esp_vfs_littlefs_register(&log_conf);

注册完成后,你就可以用标准的 POSIX API 进行文件读写:

// Web 模块写自己的页面文件 FILE *fp = fopen("/web/index.html", "w"); fwrite(html, 1, strlen(html), fp); fclose(fp); // 日志模块写自己的日志 FILE *lf = fopen("/log/2025_04_01.log", "a"); fprintf(lf, "temp=25.6\n"); fclose(lf);

两个模块通过路径天然隔离,一个模块再怎么乱写,也只能在/web或/log这两个挂载点内部折腾。即使同一个分区下的目录可以随便创建,跨分区的路径根本无法访问,因为 VFS 层就把路封死了。

注意一个事项:当你使用partition_label挂载文件系统时,该 label 必须和分区 CSV 里的名称一致。如果标签找不到,esp_vfs_littlefs_register会返回错误。此外,如果两个分区都定义成 SPIFFS 但挂载路径不同,那也是没问题的;不过 LittleFS 在掉电保护、目录操作上比 SPIFFS 更成熟,新项目我建议直接使用 LittleFS。

5.4 防止跨分区绕行:别用底层 Flash API

在代码中,如果你想保持数据隔离的严格性,最需要避免的是绕过esp_partition_*直接调esp_flash_read/write裸写 Flash。理由很简单:

  • esp_flash_read/write的地址是全局 Flash 地址,分区表不会帮你做边界检查。
  • 一旦你算错偏移,写入可能直接落在其他分区的地址范围内,轻则破坏数据,重则损坏 OTA 元数据导致设备无法启动。

我见过一个真实案例:某开发者想给日志模块提升写入速度,直接用esp_flash_write往log_fs分区的绝对地址写数据,结果因为偏移计算错误,把一个扇区写到了app1分区中。当时运行正常,但等到 OTA 升级时,系统从 app1 启动就直接崩溃。查了很久才定位到是日志模块的越界写入惹的祸。

所以,只要你能用esp_partition_*就绝不要用裸 Flash API。只有在你真正处理自定义分区类型、且完全清楚边界条件时,才考虑底层操作——而且即便如此,我也建议你封装一层分区信息校验,把“起始地址 + 偏移”做个边界判断。

6. 升级与启动链路中的数据保护细节

6.1 OTA 升级时哪些分区会被动,哪些不会动

这个问题在多个小应用共存的场景中极其关键:OTA 升级默认只写 app 分区(app0/app1)和 otadata 分区,不会动 nvs 和文件系统分区。也就是说,正常情况下,你的配置、历史记录、日志在升级后都会保留。这正是分区表分离的“数据护城河”。

但实际项目中,很多人会手滑将分区表“整体擦除”再烧录。比如用esptool.py erase_flash或者烧录工具勾选了“擦除整个 Flash”,那抱歉,你辛辛苦苦存的用户数据全部归零。所以如果你在生产环境发布固件,千万别用整片擦除的方式。

更隐蔽的一种情况是:升级 app 时选错了分区表。比如你现在跑在 app0,新版本固件把自己的 app 大小从原来的 1MB 扩大到 2MB,而当前分区表里 app0 只有 1MB。这时候 OTA 写入 app1 同样大小也很可能不够,导致写入超界直接被拒绝。所以固件体积变化时,必须检查分区表是否同步调整。

6.2 启动时检查分区表是否与固件匹配

ESP32 启动时不会检查 app 固件是不是“按当前分区表编译”的,它只会按照分区表内容去加载对应地址上的代码。如果你把不同分区表编译的两个固件交叉烧录,系统会启动到一个意想不到的位置。轻则启动异常,重则 panic。

建议在你的应用启动早期添加一个“分区表自检”逻辑:

const esp_partition_t *running = esp_ota_get_running_partition(); ESP_LOGI("MAIN", "Running on partition: %s, subtype: %d", running->label, running->subtype);

然后在日志里对比一下预期分区名。如果跑出来的分区名和你预想的不一致,说明分区表或烧录流程已经出了问题。这条日志在项目交付时能帮你省下大量售后排查时间。

对需要严格保护数据的设备,可以在 NVS 里存一个“上次正常运行次数”的计数。每次启动先读计数,启动成功后加 1;如果发现计数比上次的数值少了 1 以上,说明可能经历了一次启动失败后的自动回滚,这时你就应该考虑是否需要把数据回退到某个安全版本。

6.3 意外断电时的抗损能力

Flash 写入过程断电,会撕裂一个扇区。如果你的配置在 NVS、日志在 LittleFS,那么断电只会损坏单个扇区,不会祸及整个分区。但如果你把配置和日志放在同一个分区里,断电时两个模块的数据可能同时受损。

文件系统层面,LittleFS 对掉电有比较强的保护设计,目录项和文件数据均通过写入前日志实现原子操作;而 SPIFFS 虽然也能恢复,但有时会留下半截文件。如果你在开发阶段用的是 SPIFFS 上线,我强烈建议尽早切到 LittleFS。

NVS 本身具备“写前擦除 + 双页备份”的机制,在一定范围内(比如 NVS 每次最多约 4KB 的写缓冲)可以保证掉电不损坏旧数据。但注意 NVS 对单键的频繁写入会消耗 Flash 寿命。ESP32 Flash 的可擦写次数一般在 1 万到 10 万次之间。如果你的日志是高频写入(比如每秒一次),那千万不能用 NVS 来存,要放到 LittleFS 分区,并且自己做文件轮转和磨损均衡——或者干脆用外部存储。

6.4 自定义分区数据的安全擦除

当你要在出厂前将敏感数据擦除干净时,通常只会擦除 nvs 或 data 分区,而保留 app 分区。使用 esptool.py 擦除指定分区:

# 擦除整个 Flash esptool.py --chip esp32 -p /dev/ttyUSB0 erase_flash # 擦除指定地址范围(需要知道分区偏移和大小) esptool.py --chip esp32 -p /dev/ttyUSB0 erase_region 0x9000 0x4000

擦除 NVS 分区比较简单,但如果你只想擦除当前应用的 NVS namespace,而不动其他模块的配置,那么代码里可以调用nvs_erase_all(handle),这是按 handle 对应 namespace 删除,非常精准。

我在调试固件时经常用这个顺序:先擦数据分区,保留 app,然后一键重启,系统会自动重建 NVS 和文件系统。这样既保留了固件,又恢复了出厂状态,效率很高。

7. 实操手记:一个完整的四分区隔离项目配置

7.1 场景设定

为了让你更容易套用,我拿一个现实中做过的“环境监测节点”来举例。设备功能:

  • 固件支持 OTA 升级
  • 配网参数(Wi-Fi SSID、密码、服务器地址)存 NVS
  • 设备业务参数(采样间隔、报警阈值)存独立的 NVS namespace
  • Web 页面资源(1MB)存放在web_fsLittleFS
  • 采样历史数据(512KB)存放在data_fsLittleFS

7.2 分区表全量配置

以 4MB Flash 为例,我划分如下:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, app0, app, ota_0, 0x11000, 0x1D0000, app1, app, ota_1, 0x1E1000,0x1D0000, web_fs, data, littlefs, 0x3B1000,0x50000, data_fs, data, littlefs, 0x401000,0x80000,

这里说明几个设计考虑:

  • nvs 放在 0x9000、大小 0x6000(24KB):足够放配网和业务参数,未来扩展空间还行。
  • otadata 紧随其后,大小为 0x2000(8KB):实际一个扇区就够,给双份是为了防止写坏时备份。
  • app0/app1 各 0x1D0000(约 1.8MB):4MB Flash 留给应用 1.8MB ×2 = 3.6MB,剩余空间约 0.4MB 给文件系统。如果你的固件小于 1.8MB,这个分配没问题;如果超过,你需要扩大 app 区,而削减文件系统。
  • web_fs 0x50000(320KB)+ data_fs 0x80000(512KB):为了对齐,我把 web_fs 和 data_fs 都放在后续地址,中间没有空洞。

当然这个方案有个缺陷:web 资源 320KB 有点紧张。如果你页面里有比较多的图片或 JS 库,建议把 Flash 升级到 8MB 再分配。

7.3 代码层初始化顺序

一个稳妥的启动初始化顺序是:

  1. 调用nvs_flash_init()初始化 NVS 子系统(必须先于几乎所有需要持久化的模块)。
  2. 挂载 LittleFS 分区(web_fs、data_fs)。
  3. 读取 NVS 中的设备配置。
  4. 启动网络/业务任务。

初始化代码大致长这样(简化版):

void app_main(void) { // 初始化 NVS esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); ret = nvs_flash_init(); } ESP_ERROR_CHECK(ret); // 挂载 web_fs esp_vfs_littlefs_conf_t web_fs_conf = { .base_path = "/web", .partition_label = "web_fs", .format_if_mount_failed = true, .dont_mount = false, }; ESP_ERROR_CHECK(esp_vfs_littlefs_register(&web_fs_conf)); // 挂载 data_fs esp_vfs_littlefs_conf_t data_fs_conf = { .base_path = "/data", .partition_label = "data_fs", .format_if_mount_failed = true, .dont_mount = false, }; ESP_ERROR_CHECK(esp_vfs_littlefs_register(&data_fs_conf)); // 开启 Web 服务器、传感器采集任务等 start_web_server(); start_sensor_task(); }

注意:如果format_if_mount_failed设为 true,那么文件系统一旦异常,就会被重新格式化。作为开发调试很方便,但在生产环境中,如果你不希望在日志区被损坏后自动抹掉历史数据,这里就要谨慎了。我的做法是:业务配置分区的 autoformat 设为 false,日志分区的设为 true。业务数据丢不起,日志丢了可以重建。

7.4 写入/读取日志时的分区边界验证

在写入文件时,你无法直接通过文件 API 获知剩余空间,但可以用esp_littlefs_info获取分区容量和已用空间:

size_t total = 0, used = 0; esp_littlefs_info("data_fs", &total, &used); ESP_LOGI("LOGGER", "data_fs total=%d, used=%d", total, used);

当写入日志前检查剩余空间,若剩余空间不足,可以触发日志轮转:将当前日志文件重命名为.old,再新建写入文件。这个流程能保证 file system 不会因为写满而崩溃。日志轮转判断代码如下(简化):

FILE *log_fp = fopen("/data/current.log", "a"); if (log_fp == NULL) { // 尝试轮转 rename("/data/current.log", "/data/current.old"); log_fp = fopen("/data/current.log", "w"); }

日志路径在/data下,不会影响/web下的页面资源,这就是隔离分区带来的直接好处。

7.5 分区表变更后如何更新设备

这是很多团队忽略的问题。分区表一变,设备上实际 Flash 的边界就变了。如果你只是烧录新的 app 到旧分区表中,是会出大乱子的。因此,在线下量产/升级时,若分区表变化,必须做“全量刷机”,也就是同时烧写 bootloader、partition-table、app,甚至要擦除旧的 NVS / 文件系统。

在开发阶段,我通常用:

idf.py -p /dev/ttyUSB0 flash monitor

它会根据当前配置烧写 bootloader、分区表、app。如果改了分区表,执行这个命令就能保持开发板和固件一致性。

在量产阶段,如果你已经在设备上跑着老版本,而新版本需要更换分区表,唯一的稳妥流程是:先备份用户可提取的数据,再全盘擦除,然后烧写新版本全套镜像。不要尝试做“原地扩容分区”这种操作,几乎必然导致数据混乱。我有一回试图保留 NVS 数据、只扩大 app 分区,结果偏移一变,app 启动后读不到 NVS 的内容,整整导致返工。

8. 常见问题排查速查表:数据串门经典案例

8.1 现象一:A 应用写的配置,B 应用读不到,或读到别人的值

这大概率有两个原因。

  • 原因 1:你在两个应用里使用了相同的 NVS namespace,却用了相同的 key 名。即使两个模块代码不同,nvs_open("app_config", NVS_READWRITE, ...)打开的是同一个空间,key 自然冲突。
  • 原因 2:两个应用使用了不同的 NVS namespace,但你在 B 应用里调nvs_open时没有检查返回值,导致 handle 无效,读写全部静默失败或指向意外位置。

排查方法:先用 python 脚本从 Flash 镜像里 dump NVS 内容(可以用nvs_tool.py之类的工具),或者用nvs_dump命令在设备上把 namespace/key 打出来,一目了然。

我提供一个快速自测思路:在 A 和 B 的启动日志里分别打印自己打开的 namespace 名称和句柄是否成功打开。确认“门牌号”对了,再检查键名。基本上 70% 的问题出在 namespace 或 key 名字冲突上。

8.2 现象二:OTA 升级后文件系统数据丢失

如果你在升级后发现自己存的日志或 Web 页面没了,不要马上怀疑是升级脚本的问题。先检查分区表中 NVS / OTA 是否配置正确。如果你把 NVS 分区删了或缩得太小,系统初始化 NVS 失败就会自动擦除整个 NVS,进而其它数据也可能被影响。另外,如果你用同一个标签(partition_label)同时挂载两个不同文件系统分区,启动时也有可能触发格式化。

还有一种很隐蔽的情况:升级过程中,新固件代码里的文件系统挂载标签和旧固件不一致(比如从“web_fs”改成了“web”),那么设备会认为这是一个全新的文件系统,在format_if_mount_failed = true的情况下自动格式化旧分区,自然数据就没了。所以任何时候修改分区标签名,都必须小心处理用户数据迁移。

8.3 现象三:写入文件时报错“No space left on device”

有时候日志明明没满,但还是报空间不足。这时你查看一下是不是把两个不同用途的数据都塞进了同一个 LittleFS 分区,把空间挤爆了。比如:Web 页面缓存了一个大文件,日志还在继续写,最终撞到一起。

解决方案:要么在 Web 模块做缓存清理策略,要么把 Web 和日志分开成两个独立分区。这也是我在文章开头强调“先画需求地图”的原因。如果空间实在不够,用 8MB 版本模块直接加大 Flash,成本也就几块钱,但省下大量精力和 bug。

8.4 现象四:烧录新固件后设备进入无限重启

无限重启往往是 bootloader 或 partition table 损坏,或者 app 分区与分区表不匹配。

快速判断:用串口工具连接开发板,按复位键,看打印的ESP32 ROM boot信息。如果报invalid header,很可能 app0 地址处没有有效的可执行头。这时候不要慌,重新烧写正确的分区表和 app 镜像即可。

如果确定是 Flash 被改坏了,毫不犹豫全片擦除再来一遍。这不是偷懒,而是最靠谱的恢复路径。擦除命令:

esptool.py --chip esp32 -p /dev/ttyUSB0 erase_flash

然后完整烧录 bootloader、partition-table、app、NVS 等。

8.5 现象五:程序跑着跑着突然访问异常,甚至触发 Guru Meditation Error

如果只是纯逻辑错误,一般不会突然访问异常。如果出现了 Guru Meditation Error 或者 Cache disabled,极有可能是有人往 Flash 的代码映射区之外乱写,导致缓存状态异常。

排查思路:

  1. 打开idf.py monitor,看崩溃时 PC/EA 落在哪个地址。
  2. 用addr2line把地址换算到对应源文件行号。
  3. 确认该报错行有没有调用esp_flash_write或esp_partition_write时传了错误偏移。
  4. 如果能定位到是某个分区写入越界,把参数加上边界判断再测。

这个问题的根因往往就是我在第 5.4 节说的:绕过分区表用底层 API。所以排查时优先搜索项目代码里有没有直接调用esp_flash_*的地方。

9. 我的一些额外建议

尽量只在有需求和能力的情况下使用自定义分区表。如果你只是入门、做一个单一体积的小项目,那么使用 Espressif 默认的 factory 分区表就够了——它留足了一个 app 分区和一块 NVS,完全满足基本需求。但一旦你涉及多个功能模块存活、OTA 升级、Web 资源、日志保存,自定义分区表就是必需品,而不是可选项。

针对于运维和量产,我强烈建议把分区表 CSV 纳入版本管理,并且每次发布固件时同时归档一份经过编译的工具生成的partition_table.bin。这样万一售后反馈问题,你可以快速把设备和固件版本绑定的分区表比对,排查是否有偏移漂移。

另外提醒一句:并非所有 Flash 都是 4KB 扇区对齐。个别外部 SPI Flash 可能有不同的页大小。分区表里写的 Offset 必须基于实际 Flash 硬件参数进行计算。如果你在用外部 QSPI Flash 扩展容量,分区表的管理思路依然适用,但地址映射方式会有变化,请不要直接套用内部 Flash 的分区表。

最后分享一个我自己常用的技巧:在开发阶段,我会在应用里加一个chip_info命令,通过串口打印当前分区表配置:

esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL); for (; it != NULL; it = esp_partition_next(it)) { const esp_partition_t *part = esp_partition_get(it); ESP_LOGI("CHIP", "label=%s type=%d subtype=%d addr=0x%x size=0x%x", part->label, part->type, part->subtype, part->address, part->size); }

这个命令在排查数据串门问题时极其有用——眼前一黑时,看一眼打印就知道当前设备上的分区边界到底是怎么画的。

分区表就是数据安全的护城河,护城河画好了,多个小应用再怎么折腾也串不了门。希望你把这个基础打好,少踩几个我踩过的坑。

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

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

立即咨询