一块 ESP32 的 Flash 就那么大偏偏要塞下设备配置、OTA 固件、日志、Web 页面、语音提示甚至几个独立的“小应用”。很多朋友都问我多个小应用共用一块 Flash怎么保证数据不串门经验是不是靠“小心写代码”而是靠一开始就把 Flash 的“地皮”划清楚再用分区表、命名空间、加密这些机制从底层堵死串门路径。这篇文章会把 ESP32 的 Flash 从“一块谁都能读写的裸存储”变成“每个模块都有自己产权证的独立空间”的完整过程拆开讲包括分区表设计、NVS 隔离、双 APP 槽、Flash 加密以及我实际排查过 N 次的串门故障。无论你是用 ESP-IDF 还是 Arduino只要理解这套逻辑写多应用共用 Flash 的程序就不会再提心吊胆。1. 为什么一块 Flash 上数据会“串门”先搞清楚病根1.1 三种最常见的“串门”现场我在实际项目中遇到的“串门”基本可以归成三类。第一类是配置项互相覆盖比如应用 A 存了一个 WiFi 密码应用 B 也在同一个地址范围写了东西结果两边数据交错设备重启后行为完全不可预知。第二类是固件和文件系统打架OTA 固件被日志或用户数据撑爆或者反过来日志写入把固件区冲掉设备直接变砖。第三类是逻辑看不出问题但偶发异常比如两个模块都调用了nvs_set_str但对同一个 key 名写入了不同含义的数据读出来的是另一个模块写的值——这种最坑因为代码 review 时很难发现。这三类问题的本质都是“地址空间没有独立产权”造成的。ESP32 的 Flash 是挂在 SPI 总线上的物理上所有代码、配置、日志都在同一颗芯片里软件层面如果不做地址隔离谁的指针写偏一点就会踩到别人的家里。1.2 从 Flash 的物理规律说起做嵌入式的人都知道Nor Flash 有几个硬约束按扇区擦除最小擦除单位通常是 4KBESP32 的 flash 最小扇区是 4KB某些型号甚至支持更小的安全扇区写之前必须擦擦除会把整个扇区打成全 0xFF写入次数有限一般十万次级别频繁写同一个扇区会有寿命问题。这些物理规律决定了隔离方案必须“用空间换安全”。如果你用文件系统思维去理解 Flash——以为像 SD 卡那样可以任意字节读写、任意位置建文件——那你很容易写出“随机地址写数据”的代码这在串口调试时可能看不出来一旦多个应用同时跑立刻乱套。正确的做法是把 Flash 按分区图切成固定大小的块每一块只允许特定类型的数据使用。ESP32 的 bootloader、应用程序、NVS、OTA 数据、文件系统各自有专属分区地址范围由分区表一锤定音代码层面不得越界访问。1.3 ESP32 的两个关键概念分区表和存储介质抽象先说分区表。ESP32 出厂后Flash 地址空间并不是“从头到尾给 App 用”。最前边是 bootloader之后是分区表再往后才是各种应用分区。分区表本身位于0x8000之后里面定义了一个数组每一行描述一个分区的偏移地址、大小、类型、子类型、标签。烧录时烧录工具直接把这个表烧进去bootloader 启动时按表查找可执行分区去加载固件。再说 NVS。NVS 是 ESP-IDF 提供的一个轻量级键值存储服务它不是直接裸写在某个地址上而是自己在指定分区内部维护一套哈希索引、链表和磨损均衡逻辑。你调用nvs_get_str、nvs_set_str时其实用的是“命名空间 key”两层访问路径。命名空间就相当于一个一个独立的抽屉把不同应用的数据分开即使 key 名字相同只要命名空间不同就不会互相踩到。这一点是本话题最关键的基础。2. 第一道防线用分区表把“地盘”先画清楚2.1 partitions.csv 长什么样ESP-IDF 项目里的partitions.csv就是那张“地契总表”。默认的单个 App 分区表只有两三个分区但对于多个小应用共存的场景必须手工规划。先看一个最朴素但合理的示例# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, phy_init, data, phy, 0x10000, 0x1000, app0, app, ota_0, 0x20000, 0x200000, app1, app, ota_1, 0x220000, 0x200000, smallapp, data, spiffs, 0x420000, 0x100000, logs, data, fat, 0x520000, 0x80000, webdata, data, spiffs, 0x5a0000, 0x60000,这里有几个细节。offset 是从 Flash 起始地址算起的绝对偏移单位是字节。分区大小和偏移都要对齐到 4KB因为 Flash 的最小擦除扇区就是 4KB。nvs 分区必须在最前面放一些系统初始化要用的数据比如校准信息和时钟校准偏置。otadata 专门给 OTA 记录当前运行哪个 app 槽。app0 和 app1 是两份固件镜像用于 OTA 切换。后面的 smallapp、logs、webdata 是我为了实现“多个小应用数据隔离”自定义的分区类型可以是 data子类型可以从 spiffs、fat、nvs 中选择也可以直接用自定义子类型。2.2 自定义分区实操步骤第一步当然是改partitions.csv。第二步要让构建系统知道用这张表。在 ESP-IDF 里打开工程根目录的sdkconfig或直接执行idf.py menuconfig进入 “Partition Table” 菜单选择 “Custom partition table CSV”并把 CSV 文件名填进去。如果是 Arduino 的esp32平台可以在Tools→Partition Scheme里选 “Custom”然后在工程里放partitions.csv。第三步用分区类 API 获取分区信息避免硬编码地址。推荐的代码是#include esp_partition.h const esp_partition_t *part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, smallapp); if (part NULL) { ESP_LOGE(MAIN, partition not found); return; } esp_partition_erase_range(part, 0, part-size);不要像我早期那样自己写#define DATA_ADDR 0x420000之类的魔法数字。一旦分区表调整所有硬编码全部作废轻则数据访问错位重则越界擦除把固件干掉。用 API 拿到的part-address和part-size永远是分区表里配置的最终结果这才是不会串门的基石。2.3 分区槽设计的几个关键原则我个人规划多应用共享 Flash 时有四条铁律。第一给每个“应用语义”一个独立分区而不是共享一个大分区。比如一个设备同时做传感器采集和本地 Web 服务端那么传感器数据和 Web 静态资源就应该分别建分区即使它们都是 spiffs 或 fatfs。这样做的好处是刷 Web 页面固件时不需要担心动到传感器历史数据重置其中一个分区也不会殃及池鱼。第二分区大小要预留 OTA 升级的空间模型。如果设备支持 OTAApp 至少要分两个槽app0 和 app1各占相同大小确保新固件写入 app1 后旧固件还能在 app0 继续跑。如果只留一个 App 分区升级中途断电就直接变砖因为新固件会覆盖旧固件又没有任何回退版本。第三数据分区和可执行分区严格隔离。可执行分区的子类型必须是app数据分区子类型必须是data。千万别把二进制数据塞进 app 分区后缀。原因很简单bootloader 扫描可执行分区时如果发现某个分区“内容看起来像应用”但其实是日志堆启动阶段就可能加载失败甚至触发无限重启。我亲眼见过有人把 OTA 镜像下载到数据分区结果 bootloader 把它当成 App 去跑整个设备反复崩溃。第四给日志和 OTA 缓存留有独立垃圾桶。日志分区满了可以直接擦除不影响其他数据OTA 下载临时文件放到专用分区防止下载中断污染正常数据。这些分区虽然占用空间但能极大降低“串门”引发的事故半径。3. 第二道防线用 NVS 命名空间做 Key 级隔离3.1 命名空间是怎么工作的分区表解决了“哪些地址归谁用”的大问题但同一个分区里如果有很多小应用都要用键值存储仍然可能互相干扰。ESP-IDF 的 NVS 在启动时会读取整个 NVS 分区建立页表结构。每次nvs_open都会指定一个命名空间底层会在命名空间内分配独立的迭代上下文。从官方文档和源码来看NVS 的 entry 结构里存储的是namespace和key的组合不同namespace下即使 key 完全相同也能共存。这里有一个很多人容易踩的坑命名空间名字最长 15 个字符key 名最长也是 15 个字符具体限制在nvs.h里有说明。一旦超长nvs_open会直接返回ESP_ERR_NVS_INVALID_NAME你还不一定能马上看出原因。我之前用过类似device_wifi_credentials_north这种名字结果一编译一跑NVS 报错排查半天才发现的。所以命名要短、明确比如wifi、mqtt、app_b别挑战长度限制。3.2 应用层封装与推荐用法实际项目我建议封装一层“命名空间管理模块”不要让每个业务直接调nvs_open。做法很简单定义枚举或宏规定每个模块的命名空间和 key 前缀typedef enum { NS_WIFI 0, NS_MQTT, NS_SENSOR, NS_OTA, NS_APP_B } ns_id_t; static const char *ns_names[] { wifi, mqtt, sensor, ota, app_b }; esp_err_t store_u32(ns_id_t ns, const char *key, uint32_t value); esp_err_t load_u32(ns_id_t ns, const char *key, uint32_t *value);在实现里store_u32每次都做nvs_open(ns_names[ns], NVS_READWRITE, handle)写入后立刻 commit。这个封装至少带来三个好处一是所有命名空间集中管理review 代码时一眼就能看出哪些模块用哪些抽屉二是可以统一加日志任何一次读写都有记录出事时能追溯三是在单元测试时可以轻松换成内存 mock不污染真 Flash。3.3 键名冲突和脏数据问题就算命名空间隔离开了可能还会遇到另一类串门键名相同、含义不同的问题因为“同命名空间”而出现。比如传感器模块存了status表示在线标志OTA 模块也存了status表示升级状态如果它们被错误地分到了同一个命名空间那就是灾难。我在项目里统一用“模块前缀 键名”的方式规避例如传感器用sens_statusOTA 用ota_status。没有强制机制只能靠规范。脏数据问题同样值得重视。第一次写入 NVS 时命名空间不存在nvs_get会返回ESP_ERR_NVS_NOT_FOUND。很多新手直接忽略返回值用未初始化的栈变量继续跑逻辑结果数据乱飞。正确做法是初次读不到时写入一个默认值并 commit 一次后续读就有结果。踩过几次坑之后我在封装里强制要求凡是读取函数如果返回ESP_ERR_NVS_NOT_FOUND必须由调用方提供默认值不允许留空。4. 分区实战从规划到代码一步步搭出隔离结构4.1 估算每个应用的空间需求多应用共用一个 Flash 之前最应该做的不是写代码而是拿一张纸算容量。以一颗常见的 8MB Flash 为例我先列一些典型分区的大小需求。固件镜像如果启用大量组件基础编译产物可能到 1.6MB 左右OTA 双槽至少 2×2MB。NVS 我建议至少 20KB太小容易频繁擦写触发磨损。一个简单 Web 页面如果只放几张静态资源和几个 JS1MB 足够但如果要放图片、图表库可能 2MB 起步。日志分区大小取决于保留周期和写入频率一般 0.5MB 到 1MB 比较舒服。我把一次实际估算过程写出来。项目包含两块业务一个是小型 Web 配置面板一个是传感器数据采集。固件加上 WiFi、HTTP Server 后编译出来 1.2MB。为了 OTA 稳定我分配 app0 和 app1 各 2MB。Web 静态资源一共 800KBspiffs 有块管理和目录项开销我给 1.5MB。传感器历史数据每天产生约 20KB考虑保留 7 天加文件系统元数据开销我给了 0.5MB。NVS 给了 64KB因为多应用配置项多同时留了 3 倍余量避免频繁擦写导致寿命下降。加起来大约 8MB 出头最后只能精简 Web 资源因为 Flash 只有 8MB。4.2 规划一张“不会串门”的分区表结合上面的场景我建议这么分配用途类型子类型大小说明bootloader 区由工具自动预留-0x10000 起通常不手写partition table由工具自动预留-0x1000固定nvsdatanvs0x10000系统与各应用配置otadatadataota0x2000OTA 槽状态出厂校准dataphy0x1000射频校准app0appota_00x200000主应用槽 0app1appota_10x200000主应用槽 1webdataspiffs0x180000Web 静态资源sensordatadataspiffs0x80000传感器历史数据logsdatafat0x80000运行日志分区表里我把 web 和 sensordata 分开即便 Web 分区被写满或崩溃传感器采集记录还能独立读写。logs 分区用 fatfs 管理成文件方便串口工具导出但日志分区溢出时我自己写了清理策略因为 fatfs 在 flash 上的掉电保护不如 spiffs 友好。4.3 烧录自定义分区表时需要避开的坑用了自定义分区表之后有几个烧录相关的坑特别容易踩。第一个坑是没有把 partition table 偏移写对。idf.py flash默认会烧 bootloader、partition table 和 app一般不会错。但如果你用第三方烧录工具手动刷必须知道 bootloader 在0x1000分区表在0x8000app 在第一个 app 分区起始地址如0x20000。有些工具默认从0x10000开始烧 app那就会直接烧到分区表区域设备起不来。第二个坑是分区表改过了但没有重新擦除整颗 Flash。如果旧分区表里 NVS 分区偏移是0x9000新分区表改成0x10000而 Flash 里还残留旧分区表和新分区表同时存在的数据bootloader 只认0x8000那张表一般没问题但 NVS 里原有数据如果地址重叠可能读到旧数据。所以我每次改分区表规划后第一件事是全片擦除再烧。第三个坑是 Arduino 环境里选错分区方案。Arduino 的Tools - Partition Scheme中有些选项叫 “Huge App”、“Minimal SPIFFS”这些模式会自动生成精简分区表可能没有你想要的 NVS 大小或 OTA 槽。如果你发现自己辛辛苦苦写的partitions.csv没生效检查编译日志里的--partition-table参数确认构建系统到底用了哪个文件。5. 进阶隔离Flash 加密、双 APP 槽与运行态保护5.1 打开 Flash 加密防止“读写串门”和篡改如果你做的是设备配网、支付相关或涉及用户配置的产品只做分区隔离还不够。因为在裸 Flash 上任何人都可以用外部编程器或串口命令直接读走整颗 Flash 内容。分区隔离只能解决程序内部的“误操作”解决不了物理层面的“被偷看”。这时候需要开 Flash 加密。Flash 加密不是把 Flash 上的所有物理内容都加密成人眼不可读而是由 ESP32 硬件在总线访问层面做透明解密Flash 里存的是密文CPU 访问时读出密文后用硬件解密成明文。启用方式是在 menuconfig 里开Enable flash encryption on boot然后在第一次烧录后执行idf.py flash批量烧录并把 eFuse 里的加密位烧死。烧死后这颗芯片就只认自己的加密密钥别人拿到 Flash 颗粒也读不出有效数据。要注意启用加密后再烧录需要通过加密烧录流程否则 bootloader 读到的是乱码。加密还能防止“写串门”带来的恶意篡改。比如攻击者想让应用 B 篡改应用 A 的配置如果懂得 Flash 地址映射理论上可以定位到 NVS 页面直接改写数据。开了 Flash 加密后总线写操作也要按硬件规则来非法的跨分区写入通常会被拒绝或导致校验失败。这给多应用共用 Flash 又加了一层保护罩。5.2 双 APP 槽多固件互为备份与“安全回滚”分区表里我给 app0 和 app1 都画了同样的 2MB。这不是浪费而是 OTA 的“串门保险”。启动时 bootloader 会查询 otadata 分区里的状态决定从 app0 还是 app1 启动。默认从 app0 启动新固件下载到 app1 后写入 otadata 标记“app1 可启动”然后重启。如果 app1 运行 30 秒后没有主动上报“运行成功”bootloader 会自动回滚到 app0。这个机制天然解决了“多个应用版本共存”时的数据隔离问题两个版本跑在两个独立槽位各自的配置如果也按命名空间分好就能做到互不干扰。有一点必须注意app0 和 app1 里的固件版本要支持相互回滚。如果你的 app 从分区表读取 NVS 配置时用了不同版本号的 key旧版本读不到新 key 要能恢复默认值否则回滚后系统出现“配置串门”假象。我在开发多应用设备时特意在每个应用里写了配置兼容函数遇到未知 key 直接跳过但不崩溃这样回滚才能顺利。5.3 运行态保护用 esp_partition API 而不是裸指针即使分区表画得再好如果代码里仍然有人直接操作裸 Flash 地址一切隔离都白搭。我在团队定了一条铁规禁止直接spi_flash_read/write某个自定义地址必须用esp_partition_*API。理由很简单esp_partition_*函数会带上分区描述符作为参数内部会检查你访问的地址是否在该分区的address到address size范围内。一旦越界直接返回错误而不是写穿到隔壁分区。这里有代码示例const esp_partition_t *part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, sensordata); if (part NULL) return; uint8_t buf[64]; esp_err_t err esp_partition_read(part, 0, buf, sizeof(buf)); if (err ! ESP_OK) { ESP_LOGE(MAIN, read partition failed: %s, esp_err_to_name(err)); }有人会说part-address我都拿到了直接memcpy不行吗不行。一旦你绕过 API分区表保护就失去了意义。而且spi_flash_*低层 API 在 flash encryption 开启时对密文操作会引入额外复杂度遇到加密还要处理明文密文转换问题直接用esp_partition_read/write让底层处理才是正经做法。我实测过用 API 时越界读取返回ESP_ERR_INVALID_ARG日志打印清晰排查省时多了。6. 串门故障排查实录看日志、读分区、查表定位6.1 先从启动日志和 NVS 错误码入手多应用共用 Flash 出了问题第一步不是拆机而是打开串口监视器看启动日志。ESP-IDF 默认日志会打印分区表摘要类似I (332) esp_image: segment 0: paddr0x00020020 vaddr0x3f400020 size0x0c4a0 I (350) esp_image: segment 1: paddr0x0002c4c0 vaddr0x400d0020 size0x1b930如果 log 打印出现E (123) NVS: nvs_flash_init failed because NVS partition is too small或者E (123) NVS: NVS partition is too small大概率是分区表里 NVS 分区被其他分区挤占或 NVS 分区被擦除后还没重新初始化。很多情况下不是代码逻辑错误而是分区表偏移冲突导致 NVS 读到了别的分区内容。这时候用partitions.csv里的 nvs 偏移和大小去对照 log 中的 org 信息能快速定位。还有一种常见情况是启动日志里出现E (123) boot: OTA app slot size check failed。这说明分区表中 app 槽大小比固件镜像实际大小小或者 app1 缺了。不少人是手动改了CONFIG_ESP_MAIN_TASK_STACK_SIZE等配置后发现编译多了几百 KB然后一次 OTA 后设备循环重启。日志会告诉你是 OTA 分区问题别急着怀疑应用代码。6.2 用 parttool 和 esptool 直接检查分区内容如果日志看不出问题就用工具直接扒分区的“肚子”。esp-idf的components/partition_table/parttool.py可以列出当前烧录到芯片里的真实分区信息。命令大概是python parttool.py --port /dev/ttyUSB0 get_partition_info --partition-type data --partition-subtype nvs它能读出芯片上实际分区表的偏移、大小和名称和你的partitions.csv对比一下就能发现是否烧录了旧表。如果想直接读某个分区的内容可以用 esptool 把 Flash dump 下来再按分区偏移切文件分析esptool.py --port /dev/ttyUSB0 read_flash 0x9000 0x10000 nvs_dump.bin然后把nvs_dump.bin用 hexdump 或 NVS 解析脚本打开能直接看到命名空间和 key 列表一眼就能判断是不是两个模块把数据写到了同一个命名空间下面。这个方法也适合排查“两个 app 是否写了同一个 key 的脏数据”。6.3 最典型的三类串门故障速查表现象可能原因快速排查手段配好的 WiFi 密码重启后又丢NVS 分区被其他分区覆盖或 NVS 大小不够检查分区表 NVS 偏移和大小dump NVS 看 key 是否存在设备 OTA 后启动反复重启app 槽划分不对或 app1 被数据分区挤压看启动日志的 OTA 校验parttool 查 app 槽大小模块 A 存的值模块 B 读出来了双方共用同一个命名空间或 key 名检查nvs_open的命名空间dump NVS 比对 key 归属Web 页面更新后传感器历史数据为空Web 和传感器数据共用了一个 spiffs 分区拆分独立分区检查分区表子类型擦除一个分区后整机配置丢失错误使用了全片擦除命令擦除时只擦目标分区不要擦 NVS 系统分区我处理过一个很隐蔽的故障NVS 分区大小是 0x5000某个小应用频繁写入一个 1KB 的 blob每次写入都会触发 NVS 垃圾回收把旧 entry 标记删除然后整理页表。由于分区太小回收操作频繁某次断电导致页表损坏随后整个 NVS 初始化失败所有模块配置消失。后来我把 NVS 分区从 0x5000 扩到 0x10000并限制了该应用的写入频率问题才彻底消失。多应用共用 NVS 时不要把 NVS 区域划得抠抠搜搜该给的空间一定要给够否则隔离做得再好垃圾桶满了照样倒出来串味。6.4 烧录相关问题的常见提示语热词里那些flash download failed cortex-m3、error flash download failed target dll has been cancelled、cannot load flash device description看着吓人但基本都是工具链与芯片连接的问题不是 Flash 隔离策略问题。常见原因有三个端口选错或驱动没装、Flash 被读保护、芯片上电时序不对导致无法进入下载模式。遇到这类报错先检查 USB 转串口芯片驱动、换根线、按住 BOOT 键重新上电再烧。烧录是一定会用到 bootloader 的分区表烧错了才会出现启动失败这和下载失败不是一回事。7. 多应用共存的整体规划思路与我的经验沉淀7.1 先定“地契”再写代码顺序不能反我见过太多项目一开始只写业务功能完全不规划 Flash等设备都快量产了才开始想“多个应用怎么隔离”。这个顺序必须反过来。第一步列出这个产品会有多少个独立运行的逻辑应用比如主控程序、配置程序、采集程序每个程序需要哪些静态资源和数据第二步估算每个实体的最大占用空间并乘以 1.5 到 2 的冗余系数第三步把分区表画出来再让所有开发人员对着这张表认领自己能操作的分区第四步写一个启动自检函数开机时枚举所有分区并打印每个分区的起始地址、大小和剩余容量。这样团队协作时谁动了别人分区立刻反应出来。7.2 用分区标签建立“权限矩阵”如果团队里有其他同事要加新功能他打开分区表看见一堆名字可能不知道哪些能碰。我在项目文档里维护了一个“分区权限矩阵”每次改分区表和命名空间都会同步更新。这相当于给所有小应用立了个“谁家的地谁种”的规矩。矩阵里写清楚main应用只能访问app0代码区和cfg_main命名空间webapp只能访问webdata分区并且只读web命名空间sensor_app只能访问sensordata分区和sensor命名空间。实际开发中我还会在构建脚本里做静态检查谁代码里出现了esp_partition_find_first(... webdata ...)但组件目录不是webapp就报 warning。虽然可能有误报但能挡掉大部分“手滑”。7.3 最后分享一个我自己的检查流程踩过几次坑之后我现在每做一个 ESP32 多应用项目都会在交付前跑一遍自检流程先看编译输出里Partition Table的打印信息确认各分区偏移、大小符合预期再用parttool.py读取芯片上的实际分区表确认烧录的与代码里编译一致接着写一个临时测试固件顺序读写每个数据分区的首尾扇区越界部分故意去访问下一个分区地址确认 API 能拦截非法访问并返回错误最后做一次断电老化测试在数据写入过程中随机断电观察重启后 NVS 和各文件系统是否能恢复。这一套动作做完我基本敢说“共享 Flash 但不串门”已经达标了剩下的就交给时间验证。多应用共享一块 Flash 的本质就是把“大家共用一间房子”变成“每人一套独立公寓”。分区表是房产证NVS 命名空间是房间里的保险柜Flash 加密是门锁API 接口是管家规范流程是物业制度。四层防线一起上ESP32 这一小块 Flash 才能被多个小应用从容地共享数据也才能真正做到不串门。