上周有个读者给我发来一段很典型的踩坑经历一块 ESP32 开发板上同时跑了三个小应用一个采集温湿度并上报一个驱动 OLED 屏幕还有一个专门负责 OTA 升级。Flash 就那一颗 4MB 的 NOR Flash按理说三个应用各占一摊互不干扰结果温度数据每隔几天就变成一串 0xFF重启后又恢复正常。排查到最后发现是 OTA 应用升级时按“整块尾部 Flash”擦了一遍恰好把温度校准数据所在的区域一并擦掉了。这不是个例。只要你的项目里存在“多个小应用共用 ESP32 一块 Flash”的情况数据串门就是迟早的事。ESP32 的 Flash 虽然已经被抽象成了分区表、NVS、文件系统但抽象层并不会自动帮你阻止越界。真正能挡住问题的只有基于分区表的合理规划外加一套严格的读写纪律。这篇就当我把这些年做多应用共享 Flash 项目的经验盘一遍从分区表到文件系统从 NVS 到 OTA尽量把容易踩的坑都给翻出来。1. 共享Flash的“合租”局面三个最典型的串门姿势1.1 串门姿势一擦除粒度太粗误伤邻居NOR Flash 的物理特性决定了它不能像内存一样按字节覆写必须先按扇区擦除擦完才能重新写入。ESP32 里常见的最小擦除单位是 4KB也就是说你只想改某个 1KB 的配置结构体也得先把那 4KB 所在扇区全部擦干净。这个特性本身就埋了雷如果两个应用的数据区域在 Flash 地址上贴得太近A 应用做了一次“按扇区擦除”只要起始地址差一点点B 应用的数据就可能跟着没了。我在一个项目里就见过把配置结构体放在一个地址紧挨着另一个应用的日志存储区两个区域中间甚至没有留一整个扇区的间隔。结果每次配置写入都触发一次扇区擦除日志区尾部频繁出现乱码。1.2 串门姿势二NVS 键名撞车很多人的第一反应是“数据串门我用 NVS 不就行了吗”实际上 NVS 就是事件重灾区。ESP32 的 NVS 是一个全局的键值存储虽然你可以用不同的命名空间做逻辑隔离但两个 30 人的团队做同一个产品时谁也没法保证所有键名全局唯一。最典型的情况是传感器模块存了wifi_ssid屏幕控制模块也存了自己的wifi_ssid。同一个键大家各自nvs_set_str写入最后读取出来的值就是最后一次写入的人赢。这种错误很难当场发现因为两个应用往往都认为“我在写我自己的字段”直到其中一方读到了对方的配置才明白问题出在哪。1.3 串门姿势三文件系统分区被共用比 NVS 撞车更可怕的是多个应用共用同一个文件系统分区。SPIFFS、LittleFS 这类文件系统都有格式化操作一旦某个模块为了“清理缓存”调用了格式化整个分区的文件都没了。即便没有主动格式化只要两个应用同时往同一个分区写文件又共用一套目录结构文件名稍微一样也会互相覆盖。更隐蔽的是有些文件系统在挂载失败后会执行修复或格式化。如果你的代码里format_if_mount_failed true而其中一个应用异常掉电下次上电时它就可能把整个分区格式化成干净状态所有邻居应用的数据一起陪葬。1.4 为什么“小心别越界”不靠谱很多人的防御思路是“我在代码里注意一点绝对不写超过自己区域的地址”。这个想法在工程上基本不可行。ESP32 的项目不是一个人维护也不是一套代码永远不变。今天你写的裸地址偏移是0x1A0000明天分区表调整后成了0x180000你这条代码就变成了一颗延时炸弹。而且 Flash 的擦写还牵扯磨损均衡、OTA 分区偏移、bootloader 加载地址任何一处硬编码地址出现偏差都不会立刻报错只会静静地把别人的数据盖掉。所以真正的解决方案只有一个让每个应用都从分区表那里拿到自己的“房产证”地址再通过框架提供的 API 来读写而不是自己拿指针去戳 Flash。2. 分区表是真正的“房产证”把Flash切成独立的房间2.1 看懂分区表 CSV 的关键字段ESP32 的分区表在编译时通过 CSV 文件定义。你只需要理解五个字段字段含义作用Name分区名字程序里用字符串查找分区相当于房间号Type分区类型app 还是 data决定用途SubType子类型比如 nvs、ota、spiffs、littlefsOffset起始地址分区在 Flash 上的位置Size分区大小房间面积很多人的误区是只看 Size不看 Offset。Offset 决定了这个房间从哪堵墙开始。如果两个分区的 Offset 和 Size 没有对齐好哪怕两个分区名字不同地址空间也可能重叠。CSV 里也有默认的phy_init之类的保留分区通常不需要动。真正要规划的是 app 分区、NVS 分区、OTA 分区以及每个应用各自的数据分区。2.2 多应用共存的推荐分区规划假设你有一颗 4MB Flash规划了两个逻辑模块一个传感器模块要存校准参数和上报补传文件一个显示模块要存字体和图片资源。分区表可以这样写# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app, app, factory, 0x10000, 0x1C0000, sensor_storage, data, littlefs,0x1D0000, 0x100000, display_storage, data, littlefs,0x2D0000, 0x80000, common_storage, data, littlefs,0x350000, 0x80000,这样划分后sensor_storage、display_storage、common_storage是三个完全独立的文件系统分区。每个应用挂载自己的分区格式化自己的分区互不相干。即使传感器模块把整个sensor_storage分区格式化显示模块的display_storage也毫发无损。需要注意 Offset 要从 4KB 边界对齐。上表其实已经留了一些间隙比如0x9000到0xE000之间留了 0x2000给可能的配置分区或扩展留余地。2.3 用分区 API 获取边界而不是硬编码地址代码里不要写0x1D0000这种魔法地址一律通过分区 API 查找#include esp_partition.h const esp_partition_t* sensor_part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_LITTLEFS, sensor_storage ); if (sensor_part NULL) { ESP_LOGE(MAIN, sensor_storage partition not found); return; } ESP_LOGI(MAIN, sensor_storage: offset0x%x, size0x%x, sensor_part-address, sensor_part-size); // 读取第 0 块数据 uint8_t buf[4096]; esp_err_t err esp_partition_read(sensor_part, 0, buf, sizeof(buf)); if (err ! ESP_OK) { ESP_LOGE(MAIN, read failed: %s, esp_err_to_name(err)); }esp_partition_read、esp_partition_write、esp_partition_erase_range这些 API 会严格检查传入的偏移和长度是否超过当前分区边界。一旦越界直接返回ESP_ERR_INVALID_SIZE或者类似错误码不会去默默破坏隔壁分区。这是第一层保险你的代码永远只通过分区句柄操作而分区句柄是系统根据分区表生成的天然有边界约束。3. 文件系统分房方案LittleFS多分区挂载而不是共用3.1 SPIFFS 与 LittleFS 的取舍如果你要按模块隔离数据文件系统的选型也很关键。SPIFFS 是 ESP32 早期常用的文件系统实现简单在极小容量下表现不错但它不支持目录坏块管理也比较弱而且掉电时的一致性表现不如 LittleFS。LittleFS 在 ESP-IDF 里已经是推荐的方案它支持目录、有掉电保护、磨损均衡对多分区多实例的支持也更清爽。所以现在新项目我基本直接选 LittleFS。除非你的分区小到只有几十 KB否则 LittleFS 的优势在长期运行的多应用场景下非常明显。3.2 一个应用一个分区的挂载配置ESP-IDF 的 VFS 层支持同时挂载多个 LittleFS 分区只要你给每个分区指定不同的base_path它们就像独立的小硬盘挂到了不同目录下。挂载配置如下#include esp_littlefs.h static void mount_littlefs(const char* base_path, const char* label) { esp_vfs_littlefs_conf_t conf { .base_path base_path, .partition_label label, .format_if_mount_failed false, .dont_mount false, }; esp_err_t ret esp_vfs_littlefs_register(conf); if (ret ! ESP_OK) { if (ret ESP_FAIL) { ESP_LOGE(FS, Failed to mount or format filesystem); } else if (ret ESP_ERR_NOT_FOUND) { ESP_LOGE(FS, Partition %s not found, label); } else { ESP_LOGE(FS, Failed to initialize LittleFS (%s), esp_err_to_name(ret)); } return; } ESP_LOGI(FS, Mounted %s on %s, label, base_path); }初始化调用mount_littlefs(/sensor, sensor_storage); mount_littlefs(/display, display_storage); mount_littlefs(/common, common_storage);挂载之后传感器模块只能操作/sensor/下的文件显示模块只能操作/display/下的文件公共配置放在/common/。这样即使某个模块写路径时少写了一层目录或者文件名撞了也因为它只能在对应的 base_path 下活动不会影响到别的应用。3.3 格式化策略里暗藏的“连锁爆炸”format_if_mount_failed这个字段一定要谨慎。很多人的第一版代码图省事直接设成true想着挂载失败就自动格式化上电就自愈。这个做法在单分区单应用项目里问题不大但在多应用共用 Flash 的场景里就是自爆装置。设想一下A 模块访问/sensor分区时因为断电发生了几块坏页挂载失败于是 LittleFS 自动把这个分区格式化紧接着 B 模块上电访问/display分区正常。看起来没问题。但如果 A、B 共用了同一个分区或者分区表偏移搞错导致两个 base_path 指向同一个分区自动格式化就会把全部数据清空。更隐蔽的情况是某个分区真的坏了format_if_mount_failed true会反复格式化一个无法挂载的坏分区反而加速 Flash 磨损。我的习惯是把它设为false由开机逻辑决定是提示用户还是从备份恢复。格式化这种操作永远不能让程序在异常路径上自动执行。4. NVS的隔离不是靠新分区而是靠“命名空间纪律”文件系统分区能隔离大块数据但像设备 ID、用户设置、MQTT 参数这类小键值大家还是习惯用 NVS。NVS 的隔离方式就完全不一样它通常是整块 Flash 上的一个独立分区所有模块共用一个 NVS真正区分彼此的是命名空间和键名。4.1nvs_open的命名空间到底是什么很多人以为 NVS 键名形如sensor_calib、display_brightness就足够唯一了其实 NVS 第一层隔离靠的是命名空间参数。nvs_open(sensor, NVS_READWRITE, handle)里的sensor就是命名空间。两个应用分别用sensor和display打开句柄然后各自写calib键互不干扰因为它们在底层存储的完整键名是sensor:calib和display:calib这样的组合。因此最基础的纪律是每个逻辑模块必须使用自己独有的命名空间而且命名空间的名字要提前在文档里登记谁也不能随便占用别人的名字。4.2 键名长度限制和前缀规范ESP32 NVS 的键名有长度限制实际使用时键名字符串不能超过 15 个字符。这意味着你不能写sensor_temperature_calibration_offset这种长名字取键名时必须短而明确。命名空间本身也有长度限制通常是 15 个字符左右但那是单独的限制不影响键长。建议的键名规范是命名空间用模块名例如sensor、display、ota、mqtt键名用功能缩写例如tcal、bright、ver、broker同一个模块内键名不允许重复跨模块靠命名空间区分。如果你们是多人协作还可以在命名空间前面加公司或产品缩写比如prj_sensor进一步降低撞车概率。4.3 更硬核的隔离给特定模块独立 NVS 分区如果你觉得一个 NVS 分区里所有命名空间还在同一张哈希表里不太放心那 ESP-IDF 也支持把 NVS 拆成多个独立分区。这需要你在分区表里增加额外的 data 类型 nvs 分区比如# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, nvs2, data, nvs, 0x40000, 0x5000,初始化时默认分区的nvs_flash_init()只初始化命名为nvs的分区。想要操作nvs2需要显式调用#include nvs.h #include nvs_flash.h esp_err_t err nvs_flash_init_partition(nvs2); if (err ESP_ERR_NVS_NO_FREE_PAGES || err ESP_ERR_NVS_NEW_VERSION_FOUND) { // 如果出现 NVS 冲突或版本变化需要先擦除重建 nvs_flash_erase_partition(nvs2); nvs_flash_init_partition(nvs2); } nvs_handle_t handle; err nvs_open_from_partition(nvs2, sensor, NVS_READWRITE, handle);nvs_open_from_partition会强制从指定分区打开命名空间这样传感器模块就完全接触不到另一个分区里的 NVS 数据。不过这种做法要权衡NVS 分区越多Flash 空间占用和磨损均衡复杂度越高。我通常在非常敏感的数据比如密钥、校准值上才用独立 NVS 分区一般场景够用的话一个 NVS 分区加命名空间纪律就足够了。5. OTA升级时最容易踩的雷跨区擦除怎么防5.1 app 分区和数据分区的依赖关系多应用共用 Flash 时OTA 是最容易引发“跨区擦除”的环节。ESP32 的 OTA 原理是把新固件写入一个专门标记为ota_0或ota_1的 app 类型分区写入完成后切换启动标记重启后 bootloader 选择新分区启动。这个机制本身是安全的前提是你对分区边界的理解足够清晰。但有些人为了省空间会把 app 分区和后面的数据分区“紧紧贴在一起”比如 app 分区结束地址是0x390000数据分区偏移就写0x390000。从分区表的数学上看这没问题。问题出在 OTA 包的写入过程中如果新版固件体积超过了原分区规划或者写入代码里没有做边界判断esp_ota_write就会用掉超出分区的字节把隔壁数据分区的头部吞掉。5.2 让 OTA 每次写入都检查剩余空间所有 OTA 写操作都有两个铁律绝对不要让长度参数超过当前分区剩余空间每次都从esp_ota_get_write_partition返回的分区句柄获取真实地址。一个简洁的写法esp_ota_handle_t ota_handle; const esp_partition_t* update_part esp_ota_get_next_update_partition(NULL); if (update_part NULL) { ESP_LOGE(OTA, No OTA partition found); return; } esp_err_t err esp_ota_begin(update_part, OTA_WITH_SEQUENTIAL_WRITES, ota_handle); if (err ! ESP_OK) { ESP_LOGE(OTA, esp_ota_begin failed: %s, esp_err_to_name(err)); return; } // 假设 buf 是每次从网络收到的一块数据len 是本次实际长度 size_t written_total 0; while (remaining 0) { size_t len MIN(chunk_size, update_part-size - written_total); if (len 0) { ESP_LOGE(OTA, No space left in OTA partition); esp_ota_abort(ota_handle); return; } err esp_ota_write(ota_handle, buf, len); if (err ! ESP_OK) { esp_ota_abort(ota_handle); return; } written_total len; remaining - len; }重点看update_part-size - written_total这个判断。只要每次写入前都检查剩余空间就不会出现写入越界的问题。OTA 分区里留一点空闲空间并不浪费它反而是你的缓冲垫。5.3 构建脚本里的“分区表防呆检查”人总会犯错分区表 CSV 也是一样。我后来习惯在构建脚本里加一个简单的 Python 检查专门用来检验所有分区是否重叠、是否有越界#!/usr/bin/env python3 import sys partitions [] with open(sys.argv[1], r) as f: for line in f: line line.strip() if not line or line.startswith(#): continue fields [x.strip() for x in line.split(,)] if len(fields) 5: continue name, type_, subtype, offset, size fields[0], fields[1], fields[2], int(fields[3], 0), int(fields[4], 0) partitions.append((name, offset, offset size)) partitions.sort(keylambda x: x[1]) flash_size int(sys.argv[2], 0) for i, (name, start, end) in enumerate(partitions): if start % 0x1000 ! 0: print(fWARN: {name} offset 0x{start:x} not 4KB aligned) if end flash_size: print(fERROR: {name} ends at 0x{end:x}, exceeds flash size) sys.exit(1) if i 0: prev_name, prev_start, prev_end partitions[i - 1] if start prev_end: print(fERROR: {name} overlaps {prev_name}) sys.exit(1) print(Partition table check passed)把这个脚本挂到 CI 或 Makefile 里以后任何人动了分区表构建阶段就能发现重叠问题。这比上线后数据串门再排查要省太多事。6. 我做过多应用共享Flash之后总结的实践守则6.1 一个可以直接抄的 4MB 分配模板这里给一个我目前比较常用的 4MB Flash 多应用数据隔离模板。它适用于单固件内两个逻辑模块加 OTA 的场景# Name, Type, SubType, Offset, Size, nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, phy_init, data, phy, 0x10000, 0x1000, app, app, factory, 0x20000, 0x1C0000, sensor_storage, data, littlefs,0x1E0000, 0xC0000, display_storage, data, littlefs,0x2A0000, 0xC0000, common_storage, data, littlefs,0x360000, 0xA0000,注意我在app分区和第一个数据分区之间留了0x20000的间隙。这个间隙不是为了好看而是为了应对未来固件膨胀导致的 OTA 越界以及给 bootloader 一些冗余空间。多花 128KB 不会伤筋动骨但能避免大量数据丢失事故。6.2 多应用数据隔离自检清单每次新项目合入提测前我会按下面这个清单过一遍你完全可以照着抄分区表里每个应用的数据分区是否有独立名称和独立类型分区表里所有 offset 是否 4KB 对齐是否存在重叠代码里有没有裸地址0x开头的 Flash 偏移常量所有读写操作是否都通过esp_partition_*、esp_vfs_littlefs_*这类带分区边界的 APINVS 命名空间是否在文档里登记过键名是否遵守 15 字符限制所有format_if_mount_failed是否都为falseOTA 写入前是否检查剩余空间是否有一个分区表检查脚本跟着代码库走。6.3 一条用血泪换来的建议如果你只让我说一条那就是永远不要相信“这个地址是我自己的别人不会碰”这种话。ESP32 的 Flash 是一整块物理存储分区表才是唯一的分界规则。每个应用都把数据写入自己从系统那里“领”回来的分区而不是自己编一个地址这才是长期可维护的保证。最后再分享一个土办法我会在分区表规划完成后故意把每个数据分区的头部写一串魔数比如0xDEADBEEF然后让每个模块上电时检查自己分区的魔数。如果某个分区被串门者写入过魔数绝对会被破坏开机就能立刻报警。这个办法不花多少代码却能把“偷偷串门”变成“当场抓包”很多隐藏问题都是靠它暴露出来的。