我一直觉得,ESP32 上最难控制的东西不是代码,而是那颗看起来不起眼的 SPI Flash。我手里这颗 4MB 的 Flash 上同时跑着三个小应用:一个采集温湿度上传云端,一个控制 LED 灯带,还有一个提供本地的 Web 配置页面。每个应用都想在 Flash 里存东西,传感器要存校准值和掉电待传数据,LED 要存上次的颜色和亮度,Web 页面要存 html、js、字体图片。问题很快就来了:所有应用都住在同一块 Flash 里,怎么保证数据不会串门?
这篇文章就把这个场景拆开讲透。核心就三件事:分区表怎么规划,NVS 的 namespace 和独立分区各自解决什么问题,以及万一已经串门了怎么排查。全程用我实际调试时的视角来说,不整虚的,适合你正在用一块 ESP32 同时跑多个功能模块、又想数据互不干扰的场景。
1. 先把“串门”问题拆开:Flash 不是一整块饼
1.1 Flash 是什么:一块按扇区管理的大容量数组
ESP32 用的 Flash 一般是 SPI NOR Flash,常见容量 4MB、8MB、16MB,地址从 0 开始编址。4MB 就是 0x000000 到 0x3FFFFF,CPU 可以通过地址映射直接“读”里面的内容,甚至能直接在 Flash 里执行代码。
但“写”和“读”完全不是一个量级的事。NOR Flash 的写规则很别扭:要写之前,必须先把目标区域擦除成全 0xFF,擦除的最小单位通常是 4KB 一个 sector,而且擦写寿命有限,常见的 W25Q32 这类芯片标称也就十万次擦除,实际保守估计按几万次用更稳。也就是说,如果两个应用还在同一个 sector 里抢着写,互相覆盖是必然的,而且频繁擦写还会加速整片 Flash 老化。
所以,想让多个应用互不串门,第一步不是写代码,而是把 Flash 这一整块“饼”按用途切成不同的“房间”。这个切法,就是分区表。
1.2 分区表:给 Flash 做“房产证”
ESP-IDF 和 Arduino-esp32 默认都靠一张分区表(Partition Table)来管理 Flash 划分。分区表本身会烧录在 Flash 的 0x8000 偏移处,占固定 4KB 空间。你要把 Flash 分成几个区、每个区多大、放在哪个偏移地址、用来放固件还是放数据,都在这张表里写死。
如果不改分区表,默认的 4MB 配置里只有三个分区:nvs、phy_init、factory。factory 是整个 app 固件。这种布局下,你所有业务数据都只能塞进 nvs 那个分区里,多个模块挤在一个 NVS 中,key-value 一多,不串门才叫见鬼。
我自己写分区表,有一句口头禅:分区表就是 Flash 的房产证,没有房产证之前不要谈数据隔离。所有后续的隔离方案,本质上都是在这张表上划房间。
1.3 分区表里那些字段到底什么意思
以 ESP-IDF 的 CSV 格式为例:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, phy_init, data, phy, 0xd000, 0x1000, factory, app, factory, 0x10000, 0xC0000,- Name 是分区标签,代码里用字符串来定位分区,比如
esp_partition_find_first的第三个参数填的就是它。 - Type 分两大类:app 表示存放可执行固件,data 表示存放数据。app 只能被 bootloader 和 OTA 机制认领,data 可以自己任意读写。
- SubType 是子类型,进一步限定用途。app 下面有 factory、ota_0、ota_1 等;data 下面有 nvs、phy、spiffs、littlefs 等。
- Offset 是该分区在 Flash 上的起始地址。Offset 和 Size 决定了房间的位置和面积。
有一点要特别注意:Offset 默认要按 0x10000(64KB)对齐,除了紧跟分区表的那几个系统分区(比如 0x9000 的 nvs 是历史惯例)。乱写偏移地址,烧录不一定报错,但运行时可能因为地址映射对齐问题出各种莫名其妙的 crash。这个我在第 5 章会专门讲。
2. 数据隔离的两条路:逻辑命名隔离与物理分区隔离
2.1 NVS namespace:不同模块各开一个抽屉
NVS(Non-Volatile Storage)是 ESP-IDF 内置的键值存储,把数据以 key-value 形式存进 Flash。它天生支持 namespace 概念,可以理解为在同一个 NVS 分区里再开几个“抽屉”。
写法很简单:
#include "nvs.h" nvs_handle_t sensor_handle; nvs_open("sensor", NVS_READWRITE, &sensor_handle); nvs_set_i32(sensor_handle, "calib", 1234); nvs_commit(sensor_handle); nvs_handle_t led_handle; nvs_open("led", NVS_READWRITE, &led_handle); nvs_set_i32(led_handle, "brightness", 80); nvs_commit(led_handle);sensor和led是两个 namespace,里面用同一个 key 都不会互相干扰。这个方案胜在改动极小,不用动分区表,代码里把原来单一的 nvs 改成“namespace 名 + key”的组合就完成了一半隔离。
但 namespace 隔离有个明显盲区:所有 namespace 还是躺在同一个 NVS 分区里。只要这个分区被整体擦除,比如有人调用了nvs_flash_erase(),或者分区表重新规划导致 NVS 分区位置变化,所有 namespace 一起清零。还有一点,namespace 和 key 都有命名长度限制,官方默认 namespace 名最长 15 个字符,key 最长 15 个字符,起名太随意最后苦的是自己。
2.2 独立 data 分区:每个模块一间房
如果 namespace 是同一间房里的几个抽屉,那独立 data 分区就是给每个模块单独砌一间房。每个模块有自己专属的分区,专属的起始地址、大小和属性,谁也不会碰到谁。
自定义分区的读写要走esp_partitionAPI,比如我手里有个 LED 模块要保存灯带参数,我给它单独划一块led_store分区,代码可以这样写:
#include "esp_partition.h" const esp_partition_t* part = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, "led_store" ); if (part == NULL) { ESP_LOGE("APP", "led_store partition not found"); return; } uint8_t buf[64]; esp_partition_read(part, 0, buf, sizeof(buf)); // 修改 buf 后写回 esp_partition_erase_range(part, 0, part->size); esp_partition_write(part, 0, buf, sizeof(buf));这种隔离是物理级的。我想擦led_store就擦led_store,跟传感器模块的数据一点关系都没有。对于需要频繁擦写、数据量大、或者以后可能单独导出单独备份的模块,独立分区是最稳妥的路线。
2.3 怎么选:先看你的隔离需求到哪一层
两种隔离适合不同场景,我一般这么判断:如果只是“不想让两个应用的 key 撞车”,namespace 够用;如果存在“一个应用崩了不能把所有应用数据连坐”“日志需要周期性擦除”“某个模块的数据格式跟 NVS 不兼容”这类要求,独立分区才是正解。
| 方案 | 隔离粒度 | 实现成本 | 数据容量效率 | 典型场景 |
|---|---|---|---|---|
| NVS 多 namespace | 逻辑命名 | 低,改几行代码 | 高,分区复用 | 多个小功能模块共用一个小 NVS 分区 |
| 独立 data 分区 | 物理分区 | 中,要改分区表+读写 API | 低,可能有碎片 | 日志、文件系统、频繁擦写数据 |
3. 实操:做一张多房间分区表并让代码认账
3.1 容量估计与对齐:动手前先把账算清
规划分区表第一个问题就是每个房间分多少面积。我的做法是先编译一把,看每个小应用的 bin 有多大。多个应用放在多个 app 分区时,每个 app 分区至少留出当前 bin 大小 30%~50% 的余量,因为后续加功能、开优化、加 OTA 缓存都会膨胀。
数据分区的容量不用贪大。NVS 分区一般给 0x4000(16KB)到 0x10000(64KB)就足够存很多条 key-value 了。文件系统分区按目标文件总大小的两倍预估,留出文件系统元数据和磨损均衡的开销。
分区大小最好按 4KB 的整数倍来,offset 尽量按 0x10000 对齐。一个经验法则是:先把所有分区按从低到高的顺序排好,计算每个分区的结束地址,下一个分区从结束地址向上对齐到 0x10000 的整数倍开始。
3.2 一份可以直接抄的 4MB 分区表
下面是我在一个四合一项目里实际用过的分区表,4MB Flash,三个应用逻辑:主固件占 factory,传感器配置单独用一个 NVS 分区,LED 数据单独用一个 NVS 分区,Web 文件系统单独一个 SPIFFS 分区,日志单独一个 SPIFFS 分区。
# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x4000, phy_init, data, phy, 0xd000, 0x1000, factory, app, factory, 0x10000, 0xC0000, sensor_cfg, data, nvs, 0x100000, 0x10000, led_store, data, nvs, 0x110000, 0x10000, web_fs, data, spiffs, 0x120000, 0x100000, log_fs, data, spiffs, 0x220000, 0x100000,逐个解释一下。nvs是系统默认 NVS,放 WiFi、蓝牙等系统配置,这个小分区跟业务无关。phy_init放射频校准数据。factory是主固件区,768KB。接着是sensor_cfg和led_store,都是 data/nvs 类型,但名字不同,相当于两个独立的 NVS 分区。web_fs和log_fs是两个文件系统分区,一个给 Web 静态文件,一个给日志。
注意一个关键点:我用了两个 data/nvs 分区。ESP-IDF 的nvs_flash_init()默认只会初始化名字恰好叫nvs的那个分区,其他 NVS 分区必须用nvs_flash_init_partition("sensor_cfg")或者nvs_open_from_partition("sensor_cfg", ...)来访问,这正好给了业务数据分层的机会。
3.3 编译、烧录与 Arduino 环境适配
在 ESP-IDF 里,把上面的 CSV 保存为partitions.csv,然后:
idf.py set-target esp32 idf.py partition-table idf.py flashidf.py partition-table会把 CSV 编译成二进制并烧录到 0x8000。如果只想单独烧分区表:
esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 921600 write_flash 0x8000 build/partition_table/partition-table.binArduino-esp32 环境稍微复杂一点。新版核心在“Tools -> Partition Scheme”里有“Custom Partition Table”选项,选中后它会在工程目录里找名为partitions.csv的自定义分区表。老版本则要手动改 boards.txt,这里不展开,记住一个大方向:Arduino 环境也能用自定义分区表,只是入口在工具菜单里,不在代码里。
烧录有个特别常见的坑:只重新烧了app.bin,忘了烧partition-table.bin。如果旧分区表和新分区表不一致,app 启动后读到的数据偏移全是错的,表现就是配置丢失、文件系统挂载失败,误以为数据串门。我现在的习惯是:只要改过分区表,就整套重新烧,最彻底的是先erase_flash再全部写一次。
3.4 代码里如何访问“自己那间房”
数据分区按我的经验分三种访问姿势。
第一种,访问默认 NVS 分区里的子 namespace,最简单:
nvs_handle_t h; nvs_open("sensor", NVS_READWRITE, &h); nvs_set_i32(h, "calib", 100); nvs_commit(h); nvs_close(h);第二种,访问非默认名字的 NVS 分区。比如我要把传感器校准数据存进sensor_cfg,那就不能再用nvs_open,要用指定分区的变体:
nvs_handle_t h; nvs_open_from_partition("sensor_cfg", "sensor", NVS_READWRITE, &h); nvs_set_i32(h, "calib", 100); nvs_commit(h); nvs_close(h);使用前还要先初始化分区:
esp_err_t err = nvs_flash_init_partition("sensor_cfg"); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase_partition("sensor_cfg"); err = nvs_flash_init_partition("sensor_cfg"); }这里有个很隐蔽的细节:如果你在一个分区里反复改 key、删 key,NVS 的存储页面会因为碎片越用越少,最后报ESP_ERR_NVS_NO_FREE_PAGES。所以初始化时的擦除兜底逻辑不是摆设,是保命用的。
第三种,文件系统绑定指定分区。我把 Web 静态文件放web_fs,在 Arduino-esp32 里可以这样挂载:
if (!SPIFFS.begin(true, "/fs", 10, "web_fs")) { Serial.println("SPIFFS mount failed"); }SPIFFS.begin的第四个参数就是分区标签。这样你在/fs下写的所有文件都会落在web_fs这个独立分区里,跟其他模块的文件完全不搭界。
4. 三种多应用架构横向对比:各有各的主场
4.1 单固件多模块 + NVS 多 namespace
这是最省事的做法。所有小应用编译成一个固件,运行在同一片内存里,数据通过 NVS 的 namespace 做逻辑隔离。适合几个流程简单、数据量小、彼此调用关系紧密的小功能,比如一个采集程序里同时处理温度、湿度、电量三个数据块。
优点是代码改动少,不用管分区表,一个大 NVS 分区就够了。缺点是隔离靠“约定”,如果后来有人又写了个裸nvs_open("default", ...),或者某个 bug 把 NVS 分区整片格式化,大家一起遭殃。我一般只在快速原型验证阶段用它,正式产品里数据都往外挪。
4.2 单固件多模块 + 每模块一个独立分区
这是我在量产项目里最推荐的架构。固件还是一个固件,但每个业务模块有自己的 data 分区,数据访问走esp_partitionAPI 或者独立文件系统分区。
好处是模块之间谁也不能动谁的数据。日志模块天天擦写,不会磨损到配置分区;Web 升级想刷新页面文件,也不会把传感器校准值冲掉。代价是分区表要花心思规划,代码里要多写一层“分区查找”的逻辑。不过这套逻辑复用性很强,写一次后面所有模块都按这个套路走。
4.3 多固件共存 + 启动/OTA 切换
如果架构升级到“每个小应用是独立固件”,那就要在分区表里放多个 app 分区。ESP-IDF 的 app 分区除了 factory,还有 ota_0、ota_1 等,bootloader 会根据分区表启动指定的 app。用这种办法可以在同一块 Flash 里装两个甚至多个独立应用,运行时通过代码切换启动项。
切换的代码大致是这样:
const esp_partition_t* next = esp_partition_find_first( ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL ); if (next != NULL) { esp_ota_set_boot_partition(next); esp_restart(); }这个方案本质上是把“多应用”做成了“多系统”,每个固件有自己独立的代码、堆栈、业务。但注意,分区表还是全局的,公共的nvs系统分区还是大家共用。所以每个独立固件内部仍然要使用不同的 NVS namespace,或者干脆给每个固件配置一个专属的 NVS data 分区,否则 WiFi 配置这种全局数据还是会串。
4.4 横向对比一张表说清楚
| 维度 | NVS 多 namespace | 独立 data 分区 | 多固件 app 分区 |
|---|---|---|---|
| 改动规模 | 小,只改代码 | 中,改代码+分区表 | 大,要维护多个固件工程 |
| 隔离强度 | 逻辑隔离 | 物理隔离 | 物理隔离 + 进程隔离 |
| 数据容量 | 受限于单个 NVS 分区 | 可按模块灵活划分 | 受限于整体 Flash 容量 |
| 更新策略 | 整包升级 | 整包升级+分区数据独立维护 | 可分别 OTA 各应用 |
| 适合阶段 | 原型、内部工具 | 量产中小型产品 | 大型多子系统产品 |
5. 数据串门事故复盘与排查实录
5.1 事故一:NVS key 撞车,两个模块互相读错数据
曾经有个项目,传感器模块和 UI 模块都用了同一个 keystate存标志位。传感器模块每次上报后写state=1,UI 模块读到自己想要的state=2永远读不到,画面卡在某个状态不刷新。查了两天,最后把 NVS 内容 dump 出来才发现,两个模块写在同一个 namespacedefault里,同一个 key 互相覆盖。
从那以后我立了三条规矩:
- 每个模块必须有独立 namespace,名字用功能前缀,比如
sensor_cfg、ui_state、led_status。 - key 也要带模块缩写,比如
sensor_calib,宁可长点不要裸奔。 - 访问非默认分区时,统一用
nvs_open_from_partition指定分区名,杜绝“顺手打开默认 nvs”的习惯。
5.2 事故二:分区表没跟着升级,配置数据直接找错房间
这个坑尤其隐蔽。产品已经跑了几百台设备,某天我把固件升级到新版,新版分区表把led_store从 0x110000 挪到了 0x100000,但现场只烧了 app bin,没烧分区表。结果旧分区的数据还在 0x110000,新代码去 0x100000 找,读取到的全是 0xFF 或者别人的数据。
排查时用esptool.py read_flash把 0x100000 和 0x110000 两段内容拉出来对比,一下就明白了。数据没丢,但代码找不到之前的房间。修复方案有两个:一是升级固件时强制同时更新分区表并做一次数据迁移;二是分区表一旦发布就不再改变位置,只加新分区,不动老分区的 offset 和 size。
5.3 事故三:频繁擦写日志分区,整片 Flash 老化快
日志模块为了省事直接写一个 SPIFFS 分区,每条日志都 fsync,结果三个月后那个分区读取速度明显变慢,部分扇区开始报损坏。数据不是串门,是自残。
后来我把日志分区单独划出去,并且采用先写内存缓冲、攒够一页再落盘的策略,磨损速度降了一个数量级。如果你也有高频写日志的需求,分区表里一定给日志独立的房间,别跟配置类数据挤在一起。
5.4 排查看家本领:把 Flash 内容读出来做“法医鉴定”
怀疑数据串门的时候,别瞎猜,直接把 Flash 拉出来看。用 esptool 读取指定偏移的数据:
esptool.py --chip esp32 --port /dev/ttyUSB0 read_flash 0x110000 0x10000 led_store.bin读出来的 bin 用十六进制编辑器打开,看文件头是不是你写的特征字段。我一般会在每个分区头部写一个 magic number,比如0xA5A5表示 LED 模块,0x5A5A表示传感器模块。读回来一比对,立即能确定数据是不是落在预期位置。
如果发现数据跑到了别的分区,排查下一步就是确认分区表。把分区表二进制也读回来:
esptool.py --chip esp32 --port /dev/ttyUSB0 read_flash 0x8000 0x1000 partition_table.bin再跟工程里编译出的partition-table.bin做二进制对比,两份一样就基本排除了分区表烧录问题。不一样,说明有人烧过旧分区表,擦掉重来。
我在实际项目的最终方案是单固件 + 多独立 data 分区。每个模块数据都有自己的房间,擦写隔离、备份隔离、升级隔离都做到位了。这条路线虽然要多写几个esp_partition_find_first,但换来的是半年多没再处理过数据串门的售后单。
最后再分享一个小习惯:把partitions.csv当作代码一样提交进版本库,每次改动分区表都在 commit 里写清楚“谁动了哪个房间”。别等到出问题才去翻聊天记录,分区表这东西,配一次可能只要十分钟,但要是记不清改过哪里,排查一次可能就得一整天。