1. 先弄清楚:一颗 Flash 里到底住了谁
ESP32 项目一旦过了“点灯”阶段,你就会发现手头这颗 Flash 根本不够折腾。主固件要占一份,OTA 要留一份备份,WiFi 配置和蓝牙配对信息要存,网页资源要放,传感器历史记录还想写下来。几个小应用、十几个任务共用同一颗 4MB 或 16MB Flash,平时相安无事,哪天某个模块正常升级一次,另一个模块的数据莫名其妙清零,这时候基本就是“串门”了。
先别急着怀疑芯片质量。绝大多数 ESP32 的 Flash 串门事故,都是地址规划、分区配置和代码习惯的问题。我在项目里见过太多次类似的事故:温度采集模块重启后调了一次nvs_flash_erase(),结果整个 WiFi 配置消失;一个 App 写日志时把文件系统的 block 越界,固件直接 CRC 校验失败;还有更隐蔽的,两个模块偷偷用了同一个 NVS 命名空间和同一个 key,互相覆盖配置,查了两天才定位到。
所以这篇文章我打算按“物理结构 -> 分区表 -> 模块规划 -> 实操验证 -> 排障”的顺序,把多个小应用共用一颗 ESP32 Flash 时怎么保证数据不串门这件事讲透。适合正在使用 ESP-IDF 或 Arduino 做多模块项目的朋友,也适合那些刚把多个功能塞进一块板子、还没遇到问题但早晚会被问题找上门的开发者。
1.1 Flash 不是储物柜,是好几层抽屉
ESP32 外挂的 NOR Flash,物理上的基本访问单位是 256 字节的 Page,擦除单位通常是 4KB 的 Sector,再往上是 32KB 或 64KB 的 Block。写入时只能把 1 写成 0,擦除时才把整个 Sector 恢复成全 1。这个特性决定了 Flash 不像硬盘那样能随便改一个字节,你必须先擦掉一整块,再重写。
你可以把整颗 Flash 想象成一张草稿纸。草稿纸本身是连续的,但住在上面的功能模块并不认识彼此。甲写字从第 2 行开始,乙以为第 2 行是自己的地盘,两人都把字写到同一行——这时候纸面上是乱的,两个人都读不出自己的内容。
对应到 ESP32,这颗 Flash 上其实住着好几类需要持续保留的数据。Bootloader 在最低地址,紧接着是存放分区表的区域,之后是 NVS(非易失存储)、OTA 数据、应用程序本体、用户文件系统、日志和崩溃转储等。所有这些都映射到同一个 32 位地址空间里,CPU 可以通过 MMU 把它们映射进数据段或指令段,但物理上仍然是同一颗芯片、同一条 SPI 总线。
很多“串门”事故本质上是边界问题:一个模块越过自己的 partition 边界,写到了邻居的地址范围。而边界到底在哪,不是靠猜,是靠分区表定的。
1.2 “串门”有哪三种形态
我总结下来,ESP32 上多个小应用共存时的数据串门,逃不出三种形态。
第一种是地址越界串门。模块 A 拿到自己的分区 label,算偏移时多加了或者少加了几百 byte,写入时越过了size字段,数据跑到了模块 B 的分区里。表现是 A 一切正常,B 重启后配置丢失、文件系统挂载失败,甚至启动时 OTA 校验失败。这种问题最隐蔽,因为 A 写入时不会报错,寄存器层面也没有越界保护,完全是“沉默的错误”。
第二种是语义串门。多个模块都打开同一个 NVS 命名空间,或者在不同 namespace 里用了同一个 key,后者其实没问题,但很多人图省事直接nvs_open("nvs", ...),于是两个功能模块共享同一个 key 池。A 存了key = "data",B 也存了key = "data",后写的一方覆盖先写的一方。等到设备重启,A 读到的已经不是自己写的数据,而是一个完全同类型但不同含义的值。
第三种是生命周期串门。某个模块初始化时过于粗暴,直接调用nvs_flash_erase()或者对自定义分区做整片格式化,它在清理自己垃圾的同时,把邻居的分区表项对应的物理区域一并抹掉。这种问题往往出现在公共函数没有做分区隔离、一个工具函数被多个模块复用的场合。
理解了这三种形态,再往后看分区表就会明白,ESP-IDF 提供的那张表格,本质上就是给每个功能模块画一个绝对不允许跨过的地界。
2. 分区表:唯一的官方地图
ESP32 在复位后,Bootloader 会固定从0x8000读取分区表,分区表里每一条记录包含名称、类型、子类型、偏移和大小。CPU 不认识模块名,只认地址,所以分区表就是整颗 Flash 唯一的地契。谁住在哪里,由这张表说了算;谁能不能访问谁的地盘,本质上没有硬件隔离,全靠软件自觉。
2.1 一张 CSV 定天下
在 ESP-IDF 中,自定义分区表通常是一个 CSV 文件,例如partitions.csv。每一行代表一个分区,前四列里 Name 是自己起的名,Type 区分 app 和 data,SubType 进一步细分用途,Offset 是分区起始位置,Size 是分区长度。
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app_factory, app, factory, 0x10000, 0x180000, app_ota_0, app, ota_0, 0x190000, 0x180000, app_ota_1, app, ota_1, 0x310000, 0x180000, coredump, data, coredump, 0x490000, 0x10000, storage, data, 0x40, 0x4A0000, 0x360000,Type 为app的分区存放应用程序镜像,SubType 可以是factory、ota_0、ota_1、test等保留类型。Type 为data的分区存放数据,像nvs、otadata、phy_init、coredump都是保留子类型。如果你需要的自定义数据分区没有对应保留类型,可以像上面那样用0x40这类自定义 SubType,ESP-IDF 允许自定义数据分区使用0x00~0x7f范围内的 SubType。
最关键的一点是 Name 必须全局唯一,而且最好起得一眼能看出用途。我之前见过data1、data2这种分区名,排查时根本分不清谁是谁。建议用conf_sys、conf_wifi、log_data、web_assets这种带语义的名字,后续做esp_partition_find_first查找时也不容易传错 label。
2.2 偏移量必须 4KB 对齐,不是洁癖
很多人刚接触分区表时,觉得 Offset 随手填一个就行,反正地址是整数。这种想法迟早出事。Flash 的擦除单位是扇区,一个 Sector 通常是 4KB。如果分区 A 的结束地址正好落在某个 Sector 中间,而分区 B 的起始地址从下一个 Sector 开始,那么 B 做一次 erase 就会把共享 Sector 中属于 A 的部分一起擦除。
举个例子,A 分区从0x10000开始,大小是0x18000,结束地址0x28000,看起来没问题。但如果 B 分区从0x28000 - 1开始,也就是说起始地址没有按 4KB 对齐,B 擦除时把 A 最后一个 Sector 擦了一半。A 下次读数据时,末尾一段已经全变成了 0xFF,行为完全随机。
所以所有 Offset 和 Size 都必须是0x1000的整数倍。我习惯在写完 CSV 后用脚本做一次校验,遍历每个分区的offset + size,确保不超过 Flash 总容量,同时检查每个分区的起止地址是否 4KB 对齐。ESP-IDF 本身编译时也会做一些检查,但别依赖它,因为烧录时犯错的代价是整块板子启动失败。
2.3 默认分区表应付不了“多个小应用”
ESP-IDF 默认提供的分区表非常简单:一个 NVS 分区、一个 PHY 初始化数据分区、一个 factory app 分区,有时带一个 OTA 分区。这套默认表只适合跑一个单一固件,不适合多个功能模块长期使用。
我用一个实际例子说明:你有一个固件放 WiFi 温湿度采集,一个模块做人机交互网页,一个模块做数据记录。默认分区表里没有独立文件系统分区,也没有 coredump 分区。如果你强行把网页资源塞进 app 分区,每次改 HTML 都要重新编译整份固件;如果你把日志塞进 NVS,NVS 24KB 的空间会很快被写满,导致 WiFi 配置丢失;更别提 OTA 升级时,默认表根本没有足够空间同时放两份 app 镜像。
所以项目一旦有多个功能模块,第一步就是切换到自定义分区表。在menuconfig的 Partition Table 选项里选择Custom partition CSV file,指定自己的 CSV 路径。这是后续所有隔离工作的基础。
3. 各模块落位:谁住哪层,怎么隔离
分区表只是画好了地界,真正让数据不串门的关键,是把每个模块的功能放到合适的分区,并且遵守访问规则。下面按我们在项目中常见的五类数据分别拆解。
3.1 程序本体:factory 与 OTA 不是装饰品
多个小应用共用一个固件,这个“固件”分区千万不能只有一份。我强烈建议至少保留两个 app 分区:一个factory作为出厂兜底,一个ota_0用于运行升级;如果 Flash 容量允许,再加一个ota_1形成三槽布局,升级时可以做到 A/B 双备份。
你可能会想:我只有一个主固件,用 OTA 干什么?事实上,当你调试多个模块时,几乎必然发生“改了一行代码,居然刷不进 Flash”的情况。如果只有 factory 分区,当前固件出问题后没有可回退版本,要么重新接串口烧录,要么变成砖。而 OTA 分区可以配合esp_ota_ops接口,实现运行中的安全升级,升级失败还能回滚。
具体到代码,升级固件时用esp_ota_begin、esp_ota_write、esp_ota_end和esp_ota_set_boot_partition这几个函数。每个 app 分区大小不能小于编译出来的.bin文件大小。建议先执行一次idf.py size,看看当前固件占多少空间,再留出至少 20% 余量。如果你的固件从idf.py size看到占用 1.2MB,那么单个 app 分区至少应该给 1.5MB,否则 OTA enter 阶段就会报错。
另外一个很容易被忽略的规范是,不要把任何运行期数据写到 app 分区里面。有人为了省空间,把配置参数直接写到 app 分区末尾空闲区域,一旦下次 OTA 用新固件覆盖了这个分区,配置自然全部消失。这属于典型的生命周期串门,而且很难排查。
3.2 配置参数:NVS 命名空间就是门牌号
ESP32 的 NVS(Non-Volatile Storage)是专门为小键值数据设计的,比如 WiFi 的 SSID 和密码、蓝牙配对信息、用户配置。它的读写 API 基于 key-value,逻辑上非常友好,但正因为太友好,多模块共用时才容易踩坑。
NVS 的正确用法是分区隔离加命名空间隔离。每个模块打开自己的 namespace,而不要共用一个默认命名空间。
nvs_handle_t wifi_handle; ESP_ERROR_CHECK(nvs_open("wifi", NVS_READWRITE, &wifi_handle)); nvs_set_str(wifi_handle, "ssid", saved_ssid); nvs_commit(wifi_handle); nvs_close(wifi_handle);另一个模块应该这样:
nvs_handle_t ble_handle; ESP_ERROR_CHECK(nvs_open("ble", NVS_READWRITE, &ble_handle)); nvs_set_blob(ble_handle, "peer_addr", &addr, sizeof(addr)); nvs_commit(ble_handle); nvs_close(ble_handle);同一个 namespace 里,每个 key 在写入时会做哈希,NVS 分区内部还做磨损均衡,所以逻辑上这些 key 是独立的。但如果你所有模块都调用nvs_open("nvs", ...),多个模块就可能用同一个 key 读写不同含义的数据。我建议在代码规范里直接禁止裸调nvs_open("nvs", ...),所有模块必须显式使用自己的 namespace。
还有一点,NVS 写入一定要调用nvs_commit,否则掉电时最近几次修改可能没落盘。多个模块并发操作同一个 namespace 时,NVS 本身是线程安全的,但它不保证跨 module 的事务一致性。如果两个模块需要更新一组相关 key,建议用一个 namespace、统一设一个版本号,读取时检查版本,不一致就恢复默认。
3.3 网页资源与用户文件:文件系统挂载点
当你想给设备加一个内嵌 Web 页面,或者让用户导出日志,光靠 NVS 就不够了,因为 NVS 是 key-value,不适合存几百 KB 的 HTML、图片和文本。这时需要独立的数据分区,挂载 SPIFFS 或 LittleFS 文件系统。
我在新项目里基本只用 LittleFS,原因有两个:一是掉电恢复能力更强,二是目录操作和文件操作更接近 POSIX 习惯。挂载时要注意,每个分区只能挂载一次,并且 base_path 不能互相冲突。
esp_vfs_littlefs_conf_t conf = { .base_path = "/data", .partition_label = "storage", .format_if_mount_failed = false, }; esp_vfs_littlefs_register(&conf);这里format_if_mount_failed必须小心。如果某些模块在文件系统未就绪时误触发格式化,会把整个分区清空。我建议把它设为false,宁可挂着失败,也不要让非驱动代码直接格式化。
多个小应用共用一个文件系统分区时,如果它们只是读写不同的文件,问题不大;但如果 A 写config.json,B 也写config.json,那么同样需要引入文件锁或互斥量。一个常见做法是让所有文件访问都经过一个fs_manager模块,内部用一个 FreeRTOS mutex 串行化整个文件系统的操作,这样既能避免多个任务同时写造成 journal 损坏,也能统一管理路径前缀。
3.4 传感器记录和日志:自定义数据分区
NVS 适合小键值,文件系统适合中小文件,但当你需要记录带时间戳的原始传感器数据、调试日志,或者用环形缓冲存最近一天的采样数据,直接操作分区是本分方案。
ESP-IDF 提供了一套esp_partition_*接口,可以绕过文件系统直接读写分区。这个方式效率高、体积小,但风险也高,因为你要自己管理偏移和数据结构。我的做法是定义一个自定义数据分区,比如sensor_log,起始地址0x4A0000,大小留 1MB 左右,然后用一个简单的环形日志结构:分区的第一个 sector 存放头部,里面记录写指针、读指针、块大小和 CRC;后面的每个 block 是一个记录单元,按 4KB 对齐。
写入前必须先调用esp_partition_erase_range擦除对应 block,再调用esp_partition_write写入。直接覆盖旧数据是不行的,因为 Flash 只能把 1 写成 0,不能保证位翻转后的结果符合新数据。这里最容易出错的是“写入半块”导致校验失败,所以每个记录单元前面要写 magic number 和 length,读取时先校验,校验失败就跳过这个记录。这种做法天然会把不同模块的数据隔离到各自的 block 里,只要各模块都遵守“只操作自己的分区”原则。
3.5 崩溃日志:coredump 分区不要省
我在不少同事的项目里发现,他们把 coredump 分区看成可有可无的东西,结果系统一崩溃,只能对着串口看 log。ESP32 支持把崩溃现场存入独立的 coredump 分区,之后用工具回溯调用栈,这比人肉看日志高效得多。
coredump 分区不需要很大,64KB 到 256KB 足够。它和数据分区一样,必须在分区表里单独列出,并且不要和其他分区共用空间。配置项在menuconfig的 Core dump 里,可以设置保存到 flash 或打印到串口。如果你想在 OTA 升级后还能保留崩溃信息,那么 coredump 分区不能放在 app 分区里,也不能和 NVS 混用。
不过要注意,coredump 分区本身也会被反复擦写,虽然不是高频操作,但也不能无限扩容。按实际经验,128KB 的 coredump 分区足够存放大多数崩现场景,再大只是浪费 Flash。多个小应用如果都要上报崩溃日志,建议统一在公共crash_report模块里收集,而不是每个应用自己写一段裸读取逻辑。
4. 实操:从改分区表到代码验证一条龙
规划得再好,烧录时如果偏移和大小对不齐,照样启动失败。下面用一块 4MB Flash 的 ESP32 开发板作为例子,走一遍完整的实操流程。
4.1 先量好 Flash 尺寸,再做分区表
第一步永远是确认你的 Flash 有多大,很多开发板虽然芯片是 ESP32,外挂 Flash 却有 4MB、8MB、16MB 几种。如果你按 16MB 做分区表,烧到 4MB 的板子上,启动后通常会在partition table parse时崩溃,或者烧录阶段就提示空间不足。
用 esptool 查看 Flash ID 和大小:
esptool.py -p /dev/ttyUSB0 flash_id输出里Detected flash size: 4MB就是当前板子的实际容量。与此同时,在工程里执行:
idf.py menuconfig进入Serial flasher config,把 Flash size 改成4MB或8MB,并确保和硬件一致。然后进入Partition Table,选择自定义 CSV。完成后,ESP-IDF 会根据你的 CSV 生成最终的 flash 地址布局。
4.2 一个 4MB 分区表实例
下面是我在 4MB Flash 上使用的典型分区表,兼顾了三个 app 槽位、NVS、otadata、coredump 以及一个 864KB 的 storage 数据分区:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app_factory, app, factory, 0x10000, 0x180000, app_ota_0, app, ota_0, 0x190000, 0x180000, app_ota_1, app, ota_1, 0x310000, 0x180000, coredump, data, coredump, 0x490000, 0x10000, storage, data, 0x40, 0x4A0000, 0x360000,简单核算一下:nvs从0x9000开始,占0x5000,结束于0xe000;otadata从0xe000占0x2000,结束于0x10000;三个 app 槽位各占0x180000(1.5MB),从0x10000一路排到0x490000;coredump占0x10000,到0x4A0000;最后storage从0x4A0000占0x360000,结束于0x800000,正好用满 4MB。
这个布局里每个 app 槽位都有 1.5MB 空间,一般固件很难超过 1.5MB,所以 OTA 时基本不会因为空间不足而失败。storage 分区虽然是自定义 SubType0x40,但它足够装下网页资源和用户文件。唯一要注意的是,烧录后如果发现 app_factory 地址必须和 bootloader 约定一致,不要手动挪动前面的nvs和otadata偏移,因为这是 ESP-IDF 的默认约定。
4.3 代码里怎么挂载和访问
分区表定义好以后,代码里要用分区名而不是地址去查找分区,因为不同批次硬件可能 Flash 容量不同,同一个 label 对应的 offset 可能不同。
const esp_partition_t *storage = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, 0x40, "storage"); if (storage == NULL) { ESP_LOGE("main", "storage partition not found"); return; } uint8_t buf[256]; esp_partition_read(storage, 0, buf, sizeof(buf));这一段就是所有模块访问自定义分区的统一入口。各个小应用拿到storage之后,绝对不能自己拿一个假设的 offset 去写,必须基于storage->address和storage->size动态计算。这样即使以后分区表变了,只要 label 不变,代码就能继续工作。
如果你要把 storage 挂载为 LittleFS,可以在esp_vfs_littlefs_register时指定同一个 label,然后在代码里用fopen("/data/web/index.html", "r")这种标准文件接口访问。
NVS 部分仍然用 namespace 隔离,这里不重复细节。重点是每一个模块在初始化时,先打印自己找到了哪个分区、分区偏移是多少:
ESP_LOGI("module", "use partition %s at offset 0x%lx size 0x%lx", storage->label, storage->address, storage->size);这看起来是小事,但对排查串门却极其有效。真出问题时,看启动日志就能知道每个模块找对地址没有。
4.4 压力测试:真的不会串门吗
分区表改完,代码写完,不能直接上线。建议写一个压力测试来验证隔离性。最简单的压力测试是让两个模块同时做高频写操作:模块 A 往自己的 NVS namespace 不停写递增计数器,模块 B 往 storage 文件系统不停写大文件,各跑 24 小时。
结束后做三件事:第一,检查模块 A 读到的计数器是否连续;第二,检查模块 B 读出的文件和写入时一致;第三,对比esp_partition_read读出的各分区内容,确认没有越界修改。如果所有分区在访问前后边缘数据都不变,说明隔离有效。
另一个针对性测试是模拟擦除越界:手动构造一个错误的esp_partition_erase_range参数,看看它会不会跳到自己分区范围之外。ESP-IDF 的esp_partition_erase_range本身会校验 offset 是否落在分区内,但如果你直接调用底层 SPI flash API 绕过它,就没有保护了。因此我建议项目里禁止业务代码直接调用spi_flash_write,而是统一封装safe_partition_write,内部先做边界检查,再调用esp_partition_write。
5. 常见事故与排查实录
规划做得再好,现实里总会遇到一些奇怪现象。下面列几个我实际排查过的问题,基本覆盖了“串门”的高发场景。
5.1 分区找不到或烧录失败
现象是启动日志出现E (184) esp_image: image at 0x... failed,或者调用esp_partition_find_first返回 NULL。第一反应不是代码问题,而是看分区表 CSV 有没有被正确编译烧录进去。你可以在idf.py flash monitor启动信息里找到当前分区表内容,重点看每条分区的 Offset 和 Size。
常见原因有三个:一是 CSV 里的 SubType 写错了,比如data, 0x04这种写过期值,导致和代码里的查找类型不匹配;二是 CSV 里有空行或注释格式不标准,ESP-IDF 解析失败;三是menuconfig中依然使用默认分区表,自定义 CSV 根本没生效。另外,如果自定义数据分区用了保留的spiffs等 SubType,但你在代码里用0x41查找,也会找不到。我一般会把自定义数据分区的 SubType 统一写成0x40,并固定在一个flash_config.h头文件里定义常数,避免各处传不同的数字。
烧录失败则比较直接。很多报错是flash download failed,但不一定是分区表问题,可能是 Flash 电压、波特率导致写入超时,或者芯片进入了 download mode 然后又被他方占用串口。建议先用esptool.py write_flash --verify检查能否独立烧录分区表,排除工具链干扰后再看应用逻辑。
5.2 NVS 反复初始化导致数据被清
这是生命周期串门的重灾区。某个模块在启动时为了保证 NVS 可用,调用nvs_flash_init(),如果返回值不是ESP_OK,就直接调用nvs_flash_erase()后重新初始化。当多个小应用各自都包含这段逻辑时,只要某一个应用启动时检测到 NVS 空间不足或者有 ECC 错误,就会触发全片擦除,把其他应用的数据一起清掉。
正确的做法是,整个系统只在一个入口点初始化 NVS,而且只有当nvs_flash_init明确返回ESP_ERR_NVS_NO_FREE_PAGES或ESP_ERR_NVS_NEW_VERSION_FOUND这类可恢复错误时,才考虑擦除重来。更重要的是,把 NVS 初始化代码放进一个公共模块,而不是让每个功能模块各自写一遍。
排查时如果发现某次重启后所有网络配置都没了,先用代码打印 NVS 的打开状态和剩余空间,同时检查是不是有哪个模块执行了nvs_flash_erase。直接在工程里搜索这个函数,基本能一抓一个准。
5.3 OTA 失败 / 回滚后配置丢失
OTA 相关的串门通常分两类:一类是分区空间不足,固件已经开始写入但写到一半越界;另一类是分区表修改后,新固件运行在ota_1,而旧数据分区按照factory时的偏移访问,结果读错地址。
esp_ota_begin返回错误码ESP_ERR_OTA_SIZE_INVALID或ESP_ERR_OTA_PARTITION_CONFLICT时,先确认三个 app 槽位的 size 是否都大于新固件体积。如果其中一个小于,就会出现部分烧录成功但启动失败。
OTA 后配置丢失更隐蔽。我遇到过一个小项目,分区表从没有存储区改成有独立storage,用户升级到新版本后,配置全部回到默认。原因是旧版本根本没有 storage 分区,新版本的启动代码在format_if_mount_failed = true下把分区格式化了一遍,旧数据文件自然一个不剩。解决方法是升级程序里先检查是否存在旧的标志文件,如果存在则执行显式迁移,而不是直接格式化。
5.4 文件系统自爆,App 配置跟着复位
文件系统比 NVS 更容易出现问题,因为文件系统的元数据会散布在分区各处,任何一个坏块或者越界写都可能让整个分区无法挂载。
很多人把 WiFi 配置和运行日志放在同一个文件系统分区,日志模块频繁擦写,一旦分区空间耗尽,文件系统会进入只读或错误状态。更麻烦的是,如果另一个模块此时尝试用fopen创建新文件,会触发日志引擎的重建流程,可能会删除其他文件。要避免这种问题,日志和数据文件应该分到两个不同的分区,即便你只有一个文件系统分区,也至少要把日志文件大小限制在固定范围,并且及时滚动删除。
我还遇到过一次奇怪的现象:LittleFS 挂载后,有模块用remove("config.json")删除旧文件,但另一个模块还在写这个路径,结果写到了已经被 release 的块,文件数据被撕裂。后来改成所有跨模块文件访问都必须经过统一带 lock 的 API,问题才消失。
5.5 排查套路小结
我把排障顺序总结成三层:先看分区表,再用日志确认各分区偏移,最后用压力测试复现。很多串门问题在启动日志阶段就有迹可循,不需要上线后才头疼。建议每次修改分区表后都重新烧录一份干净的 Flash,然后立刻打印分区表内容存个档,后续对比就有了基线。
6. 一些很多人忽略的细节
说完大块设计,再聊几个容易被忽略、但关键时刻要命的细节。
6.1 让每个模块都自带“门锁”:锁和上下文
多个小应用运行在同一个 FreeRTOS 环境里,它们的任务完全可能并发访问同一个 Flash 分区。ESP-IDF 的esp_partition_*接口并不保证不同任务之间的流程串行,文件系统也只在单个操作层面有内部锁。因此,你自己必须在上层加互斥。
我习惯做一个flash_mgr组件,内部维护一把递归互斥锁,所有分区读写、NVS 访问、文件系统操作都经过它。每个模块想访问 Flash 时,传入自己的模块 ID,flash_mgr 内部检查这个 ID 是否声明过对当前 partition 的使用权,避免 A 模块误操作 B 模块分区。这套机制虽然简单,但在多人协作的项目里能拦住不少低级错误。
另外一个容易被忽略的是上下文问题。比如某个 app 往 NVS 写入前先调用了nvs_close,另一个模块又在同一个 namespace 上继续写,后者的 handle 可能已经失效,返回值却显示成功。这种问题难以从数据本身看出来,只能靠每次读写都检查返回值、并且避免跨模块共享 handle 来避免。
6.2 分区布局不要频繁变,变了要迁移
Flash 分区布局一旦确定,最好不要轻易改 offset 和 size。因为线上设备的 OTA 升级只会覆盖 app 分区,分区表本身如果在 OTA 中被更新,而数据分区没有做迁移,老设备上的数据文件还在旧地址,新程序按新分区表去读,自然读不到。
如果你必须改布局,至少要保证 NVS 和用户数据分区的 label 不变。更进一步,写一个一次性的迁移函数:启动时比较当前固件期望的分区表和数据区的 magic number,发现版本不一致,就把旧配置读出并转换后写入新位置,再更新 magic。这个过程要放在 OTA 完成后第一次启动时执行,千万不要在 OTA 写入阶段做迁移,否则一旦掉电,新旧数据都不完整。
我自己就踩过坑:为了给 OTA 腾空间,把 storage 分区从0x410000挪到了0x4A0000,发布新版本后,老设备全部读不到用户网页资源。最后重新整机烧录才恢复。从那以后,每当我打算调整某个分区 offset,都会先在本地模拟一次“旧分区表旧数据 -> 新分区表新程序”的升级流程,通过后再发布。
6.3 最后想补一句真实体会
我做了这么多年 Flash 相关开发,最大的感受是:Flash 不会主动串门,串门基本都是从代码里漏进来的。分区表画得再清楚,如果业务模块各自为政、不经过统一访问层,迟早有人越界。
每当你准备加一个新模块,先问自己三个问题:这个模块的数据放在哪个分区?它会不会被其他模块擦除?它会不会擦除其他模块的数据?答案写进代码注释,比任何高深的优化都值钱。尤其是一颗 Flash 同时承载固件、配置、文件、日志和崩溃信息时,把“隔离”当成默认前提,而不是出了事再补,才能让多个小应用真正安分守己地住在一起。