ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ESP32多应用共享Flash防串门:分区、对齐与隔离实战

ESP32多应用共享Flash防串门:分区、对齐与隔离实战 说句实在话“多个小应用共用一块 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 都要求对齐到 0x1000064KB这是 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 为例最小擦除单位是扇区通常是 4KB0x1000。也就是说你不能只擦掉 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 方案定稿时就把这套流程跑完量产以来再没被“数据串门”这件事折腾过。
返回列表