说句实在话,“多个小应用共用一块 Flash”这个需求,在 ESP32 项目里几乎是必然遇到的。一个物联网设备往往要同时跑 OTA 升级、日志记录、配置存储、用户数据,天然就是好几个“应用”在抢同一颗芯片。很多人一开始跟我一样,觉得只要地址分得够开就没事,后来我踩过一个坑才明白:串门这件事,远不是地址加减那么简单。这颗 Flash 不是你电脑上的硬盘,它有擦除粒度、磨损寿命、掉电时序这些物理约束,任何一个环节没想清楚,数据就会在你不知道的时候悄悄跑到邻居家里去。
这篇文章就按我实际排查问题的思路来写,从分区规划讲到物理层对齐,再讲怎么在代码层面做隔离,最后给出一套能复现的验证方法。适合正在用 ESP32 做多模块存储方案、或者被“数据莫名被改写”这类问题折磨过的开发者参考。
1. 先说清楚:什么是“数据串门”?两类事故模型
一提到串门,大多数同行第一反应是“地址算错了,写到别人地盘了”。这确实是最常见的一类,但不是唯一一类。我把实际工作中遇到的串门事故归成两类,先理清模型,后面所有的对策才有针对性。
1.1 第一类:逻辑越界
这类事故的根因是代码里拿到的地址或者 Length 参数不对,导致写入动作超出了自己的合法范围。比如应用 A 要往偏移 0x1000 写数据,结果因为宏定义写错,算出来的物理地址是 0x101000,而 0x101000 正好落在应用 B 的分区头上。一次写入操作直接把 B 的配置头覆盖成垃圾值,设备重启后 B 就起不来了。
这类事故在早期开发阶段最频繁,因为大家还在频繁调整分区布局。调完 offset 忘了同步代码里的宏,是最经典的低级错误。我见过最离谱的一次,是同事在代码里硬编码了一个地址常量,分区表改了三轮他都没发现自己的常量早就不指向自己家分区了,跑了一个星期才在回归测试里暴露出问题。
另外还有一种隐蔽的逻辑越界:擦除粒度问题导致的“逻辑上没越界、物理上越了”。这个我先按下不表,后面专门讲 Flash 的物理特性时再说。
1.2 第二类:物理共享与设备擦除干扰
这类串门不涉及地址写错,但结果同样致命。多块小应用共用同一块 Flash,意味着它们共享同一个物理介质、同一套 PSRAM 总线、同一个 Flash 控制器。任何一方执行了擦除操作,影响的范围可能是它自己地址段之外的好几个扇区——尤其是当两个应用的分区在物理上紧挨着时,情况更明显。
举一个真实案例。某个项目在系统参数区旁边紧挨着放了一个日志滚动区,日志模块每 10 秒就擦写一次。压力测试第三天,系统参数区开头 4 个字节变成了 0xFF,而那块位置的合法写入方只有一个——就是日志模块。排查了很久才发现,日志模块实现者在做“滚动擦除”时,起始地址写成了 0x100010 而不是 0x100000,结果擦除操作在硬件上会向下对齐到扇区边界,也就是从 0x100000 开始擦。0x100010 非法起始地址反而让擦除动作往低地址方向多抹了一个扇区,把邻居家门前的地板给掀了。
所以你看,物理共享的 Flash 有一个铁律:地址是逻辑概念,擦除才是物理动作。所有串门事故,最终都可以归结为“某一次擦除或者写入动作,改变了本不该被改变的那几个字节”。
1.3 为什么 ESP32 项目特别容易犯这个错
ESP32 的 Flash 方案有两面性。好处是它自带成熟的分区管理机制,官方 SDK 里甚至提供了带边界检查的读写 API;坏处是很多人并不走这套 API,而是直接拿地址去调底层驱动——比如在 Arduino 环境下用ESP.flashRead、ESP.flashWrite之类的接口,甚至直接操作spi_flash_mmap映射后的指针。一旦绕过了带“门牌号”的官方接口,错误边界就只能靠自觉了。
另一个原因是 ESP32 的 Flash 容量并不大,常用型号就 4MB、8MB、16MB。容量紧张的时候大家就会想尽办法“省空间”,把两个应用的分区拼在一起不留余量,甚至把一个扇区划给两个逻辑块用。这种极限压榨,在 NOR Flash 这种“以扇区为最小擦除单位”的介质上,简直是为串门事故量身定制的温床。
2. 第一道防线:用分区表从地址上把地盘划死
解决串门问题最有效的手段,永远是先做一层硬隔离:让每个应用在 Flash 物理地址空间上都有自己的专属连续区间,任何读写都只发生在这个区间内部。ESP32 官方把这件事做成了配置文件,这一层做扎实,后面很多问题都能挡住。
2.1 partitions.csv 长什么样
在 ESP-IDF 工程里,分区表通常是一个名为partitions.csv的文本文件,烧录时会解析成二进制 partition table 写入 Flash 的固定位置。每行代表一个分区,核心字段有四个:name、type、subtype、offset和size。其中offset就是分区的起始物理地址,size是分区字节数。
一个典型的双应用分区规划我直接写出来:
# name, type, subtype, offset, size nvs, data, nvs, 0x9000, 0x5000 phy_init, data, phy, 0xf000, 0x1000 factory, app, factory, 0x10000, 0x200000 app_a_data, data, custom, 0x210000, 0x80000 app_b_data, data, custom, 0x290000, 0x80000这里的app_a_data和app_b_data就是两个应用各自的数据地盘。注意 offset 和 size 都要求对齐到 0x10000(64KB),这是 ESP32 分区表的硬性要求。custom是 type/data 下可以自由定义的 subtype 名称,用它就不怕和系统内置的nvs、phy等类型冲突。
2.2 工程示例:两个应用+OTA 的分区规划
再往细里说,一个带 OTA 能力的双应用方案,分区表会长这样:
# name, type, subtype, offset, size nvs, data, nvs, 0x9000, 0x5000 otadata, data, ota, 0xe000, 0x2000 phy_init, data, phy, 0x10000, 0x1000 factory, app, factory, 0x11000, 0x1f0000 ota_0, app, ota_0, 0x200000, 0x1f0000 ota_1, app, ota_1, 0x3f0000, 0x1f0000 app_a_data, data, custom, 0x5e0000, 0x10000 app_b_data, data, custom, 0x5f0000, 0x10000这里每个 app 分区都预留了足够的 OTA 空间,app_a_data和app_b_data排在所有 app 分区之后,互不重叠,且与 OTA 升级地带的边界物理隔离。OTA 升级时只会往ota_0或ota_1里写固件,不会碰后面的数据分区,这本身就是一种隐含的防串门保护。
每个应用自己的配置和日志,我建议再往下拆细分区。因为 ESP32 官方esp_partition_*系列 API 只要你传入正确的esp_partition_t*指针,它就会自动完成地址换算和边界检查,比你自己维护一份“分区起点+长度”可靠得多。自己拿地址算的代码,一旦逻辑分支一多就很容易绕晕。
2.3 一个烧毁 bootloader 的真实事故
分区表划得再清楚,也架不住烧录时参数错乱。我之前遇到过一次设备批量变砖:烧录工装脚本里写死了 Flash 下载起始地址,某天调整分区表把factory分区偏移从 0x10000 改成 0x11000 之后,脚本没同步更新,烧录工具直接从这个错误地址开始灌固件,整整多写了 64KB,把后面的app_a_data分区全部覆盖,更糟的是接近 Flash 末尾的 bootloader 区域也被写花了一片。那批板子只要掉电重启就再也起不来,只能焊下来用编程器重刷。
这个事故给我的教训是:分区表不是只给 MCU 用的,它还同时是烧录工具、出厂测试程序、应用层读写模块三方的“共同契约”。每次调整分区布局,必须同步更新所有文件和脚本,最好在 CI 里跑一个脚本比对预期地址范围和实际烧录参数,差一个字节都不放过。
3. 粒度对齐:比地址计算更容易翻车的物理层细节
分区表把逻辑地址划好了,那只解决了“往哪写”的问题。真正到了 Flash 内部,还有一套物理约束。NOR Flash 的写入和擦除根本不是我们平常想象的那个“按字节改”的逻辑,它的三个基础单位——扇区、页、块——决定了你能怎样访问它,也埋下了很多串门事故的引信。
3.1 扇区、页、块的关系
以最常见的 SPI NOR Flash 为例:
- 最小擦除单位是
扇区,通常是 4KB(0x1000)。也就是说你不能只擦掉 1 个字节,想改任何数据,最少也得把这个字节所在的整个 4KB 区域擦成 0xFF,然后再重新写入。 - 写入单位是
页,一般 256 字节。页写操作要求目标地址不能跨页边界,否则要么写入失败,要么数据会被“截断”到页尾部——不同厂家表现还不一样。 - 擦除还有更粗的粒度,比如
块是 64KB,有的大块是 128KB 甚至更大。某些擦除指令(比如批量擦除)直接忽略地址区间,对整个芯片就地进行全片擦除。
这里的关键点是:所有地址参数在 Flash 内部都会向下对齐到某个边界。
你发一个“擦除地址 0x100010”的指令,硬件不会老老实实只擦 0x100010 那一个字节——它会向下对齐到扇区边界,从 0x100000 开始擦 4KB。如果你本意是要改 0x100010 开始的几个字节,但你“没意识到”自己的操作会波及整个扇区,那你实际上擦掉的范围是 0x100000 到 0x100FFF。只要邻居家的数据有任何一部分落在 0x100000 到 0x100FFF 这个区间,就被你顺手带走了。
3.2 对齐错误的经典翻车现场
我自己的项目里就出过这么一次。当时做的是一个双传感节点,应用 A 存校准参数,应用 B 存采样日志。校准参数分区起始地址 0x200000,日志分区起始地址 0x201000——我当初觉得只差 0x1000,正好一个扇区,很紧凑很省空间。
应用 B 的日志模块实现了一个滚动覆盖逻辑:日志写满时,擦掉“最老的那一扇区”,再从头写。我让同事看那个擦除调用,他传入的地址是0x201000 + log_index * 0x1000。看起来没问题?问题出在 Flash 驱动的擦写接口里做了一层“地址向下对齐到扇区边界”的处理。当log_index = 0时,擦除地址是 0x201000,对齐到扇区边界后就是 0x201000,平安无事。但当log_index = 1,地址变成 0x202000,也平安无事。真正出事的是应用 A 在 0x201000 前一扇区末尾连续写了一串参数之后,日志模块为了“省一个扇区的浪费”,把日志的起始地址定在了0x200FC0——一个没有对齐到扇区边界的地址。
这下就热闹了。擦除地址 0x200FC0 对齐到 0x200000,直接擦掉了应用 A 所在的那整个扇区。应用 A 辛辛苦苦存了半天的参数,一个都没保住。
那次排查花了我一整天才定位到,最后发现根因就是“省空间”省出了事。后来我给自己定下一个规矩:规划地址时,绝不让跨应用的分区共享或拼凑同一个扇区。宁可每边空出几个字节,也不要让另一个应用的数据落在别人擦除粒度会波及的范围内。
3.3 地址对齐检查的实用写法
代码层面其实可以做一层很简单的防御。给每个应用分区定义一个宏,然后在初始化时做一次断言检查:
#define APP_A_BASE_ADDR 0x200000 #define APP_A_SIZE 0x10000 #define APP_B_BASE_ADDR 0x210000 #define APP_B_SIZE 0x10000 _Static_assert((APP_A_BASE_ADDR % 0x1000) == 0, "APP_A base must 4KB aligned"); _Static_assert((APP_B_BASE_ADDR % 0x1000) == 0, "APP_B base must 4KB aligned");然后把所有擦除动作入口都包一层对齐检测函数:
static bool is_sector_aligned(uint32_t addr, uint32_t len) { return (addr & 0xFFF) == 0 && (len & 0xFFF) == 0; }任何擦除请求进来,先过一遍这个检查,不过就报错误码而不是默默执行。这看起来是笨办法,但真能挡住一大批低级错误。
4. 轻量隔离:自己封装带“门牌号”的存储 API
分区表解决了“宏观地盘”的问题,粒度对齐解决了“擦除波及范围”的问题。但实际开发中,还有一个很现实的问题:并不是所有驱动都愿意走官方esp_partition接口。很多人用的是第三方库、老工程遗留代码,或者出于性能考虑直接操作地址。这种情况下,就得靠自己在 API 层加“门牌号”,让每一个读写操作都带上明确的分区归属和边界检查。
4.1 为什么直接拿地址裸操作不可靠
裸操作最大的问题不是性能,而是毫无上下文约束。你拿到一个地址和一个长度,调底层驱动直接写,编译器不会提醒你“这个地址属于哪个分区”,也不会检查你看不到的另一块区域是否会被擦掉。所有保护全靠调用者自己心里有数。问题在于,一个项目写久了,调用者自己也会懵。
最典型的就是:某个底层函数被复用到多个模块。一开始它只服务应用 A,地址写死了 0x200000,代码里到处都这么调。后来应用 B 想复用这个函数,有人图省事直接传 0x210000 进去,以为“反正地址参数化了,加个偏移就行”。结果函数内部有一个erase + write的组合操作,erase 的地址和 write 的地址用了不同的基址宏——一个改了,另一个忘改了,直接串门。
所以我的主张是:哪怕不引入官方分区表,也应该在应用和 Flash 驱动之间加一层轻量封装。这层封装不一定要复杂,核心就三件事:记录每个分区的起点和大小、检查每一次读写是否越界、对外只暴露“分区句柄”而不是裸地址。
4.2 一个最小可用的存储句柄实现
我实际在项目里用过的一个简化版本是这样的:
typedef struct { const char *name; uint32_t base_addr; /* 分区起始物理地址 */ uint32_t size; /* 分区字节数 */ uint16_t sector_start; /* 起始扇区号 */ uint16_t sector_cnt; /* 扇区总数 */ } store_handle_t; static bool in_range(const store_handle_t *h, uint32_t off, uint32_t len) { if (h == NULL) return false; if (off + len > h->size) return false; if (off + len < off) return false; /* 溢出保护 */ return true; } int store_write(const store_handle_t *h, uint32_t off, const uint8_t *buf, uint32_t len) { if (!in_range(h, off, len)) return -1; /* 这里再转换成具体 Flash 驱动需要的物理地址 */ uint32_t phy_addr = h->base_addr + off; return flash_drv_write(phy_addr, buf, len); }初始化时通过store_handle_init(&handle_a, "app_a_data")从分区表直接查信息填进去,不让任何模块自己硬编码地址。之后应用 A 的所有读写都长这样:
store_handle_t handle_a; store_handle_init(&handle_a, "app_a_data"); uint8_t tmp[64]; int rc = store_read(&handle_a, 0x100, tmp, sizeof(tmp));任何跨界访问都会在in_range这里被拦下,返回 -1。调用方最多收到一个“超出分区范围”的错误码,而不会真的去改别人家数据。
这个封装的另一层好处是:如果之后换了不同厂家的 Flash、调整了分区地址,只需要重新初始化句柄,业务代码完全不用改。我们有一次把工程从 4MB Flash 迁移到 8MB Flash,所有业务代码一行没动,只改了分区表和初始化参数。
4.3 并发访问时的锁问题
多应用共用一块 Flash 还有一重特殊风险:并发。ESP32 是双核芯片,如果应用 A 的日志线程正在擦写扇区,应用 B 的配置存储线程也同时发起了一次写入,两个操作可能交错执行,轻则数据错乱,重则直接破坏另一方的数据。
ESP32 的底层 Flash 驱动带有一个内部互斥锁,会保证单次擦写操作原子性。但保险起见,我建议在封装层再加一把更粗粒度的锁,粒度放原到“整个分区”级别:
static SemaphoreHandle_t flash_mutex; void store_operation_begin(void) { xSemaphoreTakeRecursive(flash_mutex, portMAX_DELAY); } void store_operation_end(void) { xSemaphoreGiveRecursive(flash_mutex); }注意这里用的是递归锁。因为擦写过程中经常会出现“擦一个扇区、再写回数据”这种复合动作,递归锁允许同一个任务在持锁期间再次进入,避免自己把自己锁死。跨任务的并发访问,必须在封装层外统一规划好时序,比如规定同一时刻只允许一个任务访问 Flash。
5. 磨损均衡与写保护:让共享 Flash 活得更久
串门不只是“写错地方”,还有一种更隐蔽的“串门”——把邻居家的扇区耗尽寿命,导致数据开始丢位、读错。NOR Flash 的每个扇区都有擦写次数上限,常见型号在十万次量级。多个应用共享同一块 Flash 时,如果某个应用只盯着自己分区里同一个扇区反复擦写,那个扇区的寿命会快速耗尽,而这个扇区物理上紧邻其他应用的数据区,一旦 Flash 介质出现弱单元,干扰就可能传导到旁边扇区的数据上。
5.1 磨损均衡的基本原理
磨损均衡(Wear Leveling)的本质是:不要把数据总写在固定位置,而是让写入压力在多个物理扇区之间轮转。最简单的做法是“双备份轮换”:每个逻辑槽位准备两个物理扇区,写新数据时先写当前不活跃的那个扇区,再把活跃标记切过去。这样最坏情况下,每个物理扇区的擦写次数减半。
ESP-IDF 自带了一个 wear-leveling 库,通常和 FAT 文件系统搭配使用。如果你只是存配置、存日志,不一定需要上完整的 FAT,自己实现一个小的轮换策略也够了。我项目里用过一种极简方案:
- 给某个逻辑槽分配 4 个物理扇区(16KB);
- 每次写入新值时,追加写到当前活跃扇区的下一个空页;
- 写满后,把最新值集中拷贝到下一个扇区,然后擦掉旧扇区;
- 用扇区头部的一个 32 位序列号来标识哪个扇区最新。
这样做下来,同样 16KB 的空间,寿命扩大了 4 倍,同时还天然实现了掉电恢复——因为只要序列号没乱,就能找到最新数据。
5.2 在分区层面做只读保护
除了均衡磨损,另一个实用手段是“把不该写的分区设为只读”。ESP32 的分区表本身不支持完整的硬件只读保护,但有几种变通方案:
- 在应用层严格控制写入口:配置类分区只在“写入配置”的代码路径里允许调用写函数,其他模块一律只读。这属于代码纪律层面的软保护。
- 在编译期/运行期做只读检查:封装层直接拒绝非授权模块的写请求。比如让
store_write请求参数里带上一个“写令牌”,只有那几个已知模块持有令牌,其他模块即使调用了接口也只会得到-EACCES。
有朋友问我,是不是可以用 Flash 的 Status Register 里的 BP 位(Block Protect)做硬件写保护?这确实可以,NOR Flash 的 BP 位能把一部分地址区域设为只读,需要先解锁才能擦写。但实际操作很麻烦:一是不同厂家的 BP 位含义和粒度不同,二是 ESP32 上电过程中 bootloader 往往需要在 Flash 末尾区域执行写入,锁得太死会破坏系统启动,三是如果在擦写中途掉电,保护位可能处于半锁状态,恢复逻辑复杂。我的经验是:除非你能完全控制整个生命周期里对 Flash 的每一次访问,否则硬件 BP 位在量产设备上慎用。留着它在设计阶段“防手滑”倒是可以的。
5.3 掉电崩溃下的脏扇区处理
这个问题表面看和串门无关,但掉电恰恰是串门事故的高发时机。想象这样一个场景:应用 A 刚擦完一个扇区,还没来及写入新数据,设备掉电了。下次开机时,应用 A 的代码发现“数据缺失”,重新初始化一套默认值写进去。如果写入过程又赶上内存紧张之类的异常,新数据被写到了错误偏移,或者擦除动作多擦了一个扇区,B 的数据就被顺带带走了。
应对掉电问题有两个要点。一是每个分区都在头部放一个结构体,包含 magic number、版本号、CRC 校验值。读取时 CRC 不过就当脏数据,而不是把所有数据当作有效值继续用。二是擦写顺序上“先写新、再擦旧”,保证任何掉电时刻至少有一份有效数据在 Flash 上。这个设计原则虽然简单,但很多开发者就是没做到,才在低概率事故里栽了跟头。
我在实际产品里就遇到过:设备连续运行三个月,某一次市电闪断,重启后用户配置全部丢失,而另一个应用的日志分区也被清掉了一片。后来查下来就是之前那个“擦旧再写新”的实现,掉电刚好卡在擦和写之间。改成“拷贝到新扇区->更新序列号->擦掉旧扇区”之后,这个问题再没出现过。
6. 验证方案:用指纹数据证明“真没串门”
划了分区、加了封装、做了对齐检查,你是不是觉得足够稳了?不够。所有防串门方案最终都要经过验证,而且要反复验证。这一节我给出我自己在产线验证时用的那套方法,不需要复杂的测试设备,只要有一个上位机脚本和一点点耐心。
6.1 指纹填充分区
验证思路很直白:在每个分区里填充一个唯一的、可识别的指纹数据,然后让它跑一段时间,看指纹有没有被篡改。
我先给每个分区定义一个指纹模式:
app_a_data填充 0xAAapp_b_data填充 0x55app_a_log填充 0xA5app_b_log填充 0x5A
通过一段 Python 脚本读回整片 Flash,比对每个分区的指纹。如果某个分区里出现了大量不属于它指纹模式的字节,说明它被其他模块写过了。注意这一步要把“合法写入”排除掉——比如日志分区的数据本来就是不断变化的,这时就得配合另一套校验。
更精确的做法是:在分区的固定偏移位置存放一个“哨兵结构”,哨兵由 magic、应用标识、版本、CRC 组成。每次系统自检时做一次 CRC 校验,任何一次非法写入导致哨兵损坏,都能被快速发现。
6.2 边界压力与掉电测试
指纹静态验证只是第一步,真正有价值的是动态压力测试。我推荐的组合是:
- 让应用 A 开启一个高频率写入线程,比如每秒写 10 次。
- 让应用 B 同时做读操作,并且在每次读之前检查自己分区的哨兵 CRC。
- 运行期间随机触发设备重启(或用自动断电装置每隔几十秒断一次电再重新上电)。
- 重复 100 轮,检查 B 的哨兵是否完好、A 的写入数据是否能被自己正确恢复。
边界压力测试也值得做。写地址尽量贴着分区末尾,比如往base_addr + size - 1处写 1 字节,往base_addr + size - 8处写 8 字节,再故意把 length 加长导致越界——这时候封装层应该返回错误,而不是真把数据写到邻居那去。越界封堵是这个测试的核心验收点。
6.3 测试结果怎么读
我把跑完的测试数据整理成了一个简单的表,方便大家直接参考:
| 测试项 | 操作 | 预期结果 | 实测结果 |
|---|---|---|---|
| 指纹一致性 | 静态填充后读回全片 | 各分区指纹无混入 | 通过 |
| 边界写入 | 写分区末尾 1 字节 / 8 字节 | 数据读写正确 | 通过 |
| 越界请求 | 写 length 超限 | 返回 -1,不触发 Flash 写操作 | 通过 |
| 擦除对齐 | 擦除非对齐地址 | 初始化断言拦截 | 通过 |
| 高并发读写 | A 写 + B 读 1 万次 | B 哨兵 CRC 持续有效 | 通过 |
| 掉电恢复 | 随机断电 200 次 | 系统可恢复,A/B 哨兵完好 | 通过 |
这张表贴到项目验收报告里,比口头说一百句“没问题”都更有说服力。每次调整分区表、改动 Flash 相关驱动,都应该至少把“指纹一致性”和“越界请求”这两项测试回归一遍,成本很低但价值很大。
最后再分享一个经验:真正容易出问题的,往往不是那些一眼就能看出风险的代码路径,而是为了“优化性能”“节省空间”而临时绕开规范的取巧做法。分区表、封装层、对齐检查这些约束,看似增加了点工作量,但它们保护的不仅是数据,更是你后面几个月排错的时间和交付物质量的稳定性。我们现在每款产品在 Flash 方案定稿时就把这套流程跑完,量产以来再没被“数据串门”这件事折腾过。