ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ESP32多应用Flash分区隔离实战:用分区表+NVS+LittleFS防止数据串门

ESP32多应用Flash分区隔离实战:用分区表+NVS+LittleFS防止数据串门 最近在弄一个ESP32项目把配网、传感器校准、运行日志、OTA升级全塞进同一个4MB Flash里。一开始没做任何规划直接用裸地址读写结果A应用写日志的时候把B应用辛辛苦苦校准好的数据给抹了WiFi配置也时不时变成出厂值。调试了整整两天最后发现根因只有一个多个小应用在共用同一块Flash却没有给它们划定各自的“房间”。这篇文章就围绕“怎么保证数据不串门”展开核心就是用ESP32官方分区表partition table把Flash切分成独立区域再配合NVS、LittleFS和版本管理把每个应用的数据关进自己的笼子里。适合正在做多模块共存、OTA升级、或者被Flash读写问题折磨过的朋友参考。1. 为什么会“串门”先看清 Flash 的使用边界1.1 一片 Flash 要同时扮演这么多角色ESP32用的NOR Flash是一块能随机读取、按块擦除、按字节编程的存储介质。它和单片机里的SRAM不一样Flash里的内容掉电不会丢失所以既得装程序又得存数据。同一个4MB地址空间里通常要塞下Bootloader、分区表、应用程序镜像、NVS参数区、文件系统、日志区甚至还有OTA双备份。问题就出在这大家都住在同一栋楼里如果没人管理房间号谁都能推开别人的门。多个小应用想要共用Flash首先要理解这片Flash里已经有什么东西。比如地址0x1000附近是二级Bootloader0x8000附近是分区表0x9000往后是NVS再往后可能是Factory固件或者OTA固件。如果你不了解这个布局随手向0x200000写日志可能正好覆盖了另一个应用的程序区轻则数据丢失重则设备启动失败。1.2 三种典型的“串门”事故我在实际调试中遇到过几类典型的串门场景分别是裸地址越界、NVS键冲突、分区重叠。第一种是裸地址越界。有人觉得反正Flash有4MB我直接调用spi_flash_write(0x300000, buf, len)写日志多简单。可问题是你不知道0x300000此刻放的是什么。如果分区表已经把0x300000划给了OTA应用你的日志就把固件给写了。这种事故最难排查因为日志数据可能都是合法数据但重启之后系统就崩了。第二种是NVS键值互相覆盖。NVS是ESP32自带的KV存储很多应用都喜欢用NVS存配置。官方默认只开放一个NVS分区如果两个小应用都用了同一个namespace比如叫“storage”还都往key“value”里写东西那后写的必然覆盖先写的。哪怕你聪明地用了不同namespace如果其中一个应用执行了nvs_erase_all()另一个应用的配置也会被清掉。第三种是分区重叠或文件系统越界。有时候你给应用A分了0x10000到0x20000给应用B分了0x18000到0x28000两个区间重叠了0x8000读写的时候两个应用都会认为自己在合法范围内但实际上是同一块物理区域。这种情况用esp_partition_read/write的时候不会报错因为API并不知道另一个分区的存在。1.3 “不串门”的第一原则想要彻底解决原则只有一个逻辑隔离。同一片Flash上画清晰的分区表每个应用只能操作自己的区间。ESP32的Bootloader和分区表本来就是一对最佳搭档只要你把partitions.csv规划好编译时分区表会生成一个二进制文件烧录到Flash的0x8000地址。系统启动时Bootloader根据这张表决定去哪里加载应用运行时esp_partition_*API会根据表里的偏移和大小做边界校验天然防止写穿。所以“不串门”的第一步不是写代码而是设计一张不会重叠、不会越界的分区表。2. 分区表给每个应用画好独立房间2.1 分区表是怎么工作的ESP32的分区表是一个CSV文本文件编译时通过gen_esp32part.py转换成二进制格式固化在Flash的固定位置。CSV每一行代表一个分区字段顺序是# Name, Type, SubType, Offset, Size, Flags其中Name是分区标签字符串Type代表大类app或者dataSubType代表子类型Offset是分区起始地址Size是分区大小Flags一般可以留空。Bootloader启动时会解析这张表找到类型为app的分区根据OTA信息决定从哪个分区加载固件。应用程序里也可以通过esp_partition_find和esp_partition_find_first来按标签查找任意分区。可以说分区表就是整栋Flash大楼的房产证。每套房子多少平、在几楼、谁住都写得明明白白。你不知道邻居住哪儿没关系物业知道只要你不跨过公共走廊就不会闯进别人家。2.2 一张直接能用的多应用分区表我目前在用的一个项目里跑着WiFi配网模块、传感器校准模块、运行日志模块、OTA升级模块共用一颗4MB Flash。分区表长这样# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, phy_init, data, phy, 0x10000, 0x1000, factory, app, factory, 0x11000, 0x300000, app1, app, ota_1, 0x311000, 0x300000, calib, data, 0x40, 0x611000, 0x8000, logs, data, 0x41, 0x619000, 0x100000, wifi_cfg, data, 0x42, 0x719000, 0x20000,这张表怎么读前四行是ESP32运行的基础分区作用是系统启动和OTA引导。factory是出厂固件app1是OTA升级固件。后三行就是我说的“小应用数据区”calib存传感器校准参数属于低频写入但极其关键的数据logs存运行日志属于高频循环写入的数据wifi_cfg存配网信息和用户设置属于中频读写数据。每个数据区都有自己的Type和SubType互相之间完全独立。calib的类型是data子类型是0x40logs的子类型是0x41wifi_cfg的子类型是0x42。0x40到0xFE是ESP-IDF留给用户自定义的子类型你可以随便用只要在同一个Type下不重复就行。2.3 命名和偏移的讲究看到这里有人会问Type直接写data不就行了为什么还要自定义子类型因为ESP32自带的ESP_PARTITION_TYPE_DATA只能区分data大类没法区分具体用途。如果所有数据分区的子类型都一样esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, logs)就只会匹配第一个SPIFFS分区后面的分区永远找不到。所以给不同用途的数据区用不同的自定义子类型本质上是给每个房间贴了门牌号查找的时候不会找错门。偏移和大小也很有讲究。Flash的扇区大小是4KB任何分区的起始偏移和大小必须是4KB的整数倍。否则擦除扇区时会跨到别的分区导致邻居住得好好的突然就被全擦了。实际开发里我见过有人给日志分区分配0x12345大小的后果就是日志写到一半NVS分区的内容莫名被清空。所以填Offset和Size之前先用十六进制算一遍保证能被0x1000整除。另外app分区的SubType不能随心所欲填。只能用factory、ota_0、ota_1这些官方枚举值否则OTA机制无法识别。app分区的大小也要尽量一致方便做OTA回滚。如果不一致万一OTA后想回滚到旧版本另一个分区可能放不下新固件。2.4 告别手算偏移用占位符灵活生成手动填Offset容易算错esp-idf其实支持在CSV里把Offset写成*让工具自动分配。例如nvs, data, nvs, *, 0x5000, otadata, data, ota, *, 0x2000, factory, app, factory, *, 0x300000, calib, data, 0x40, *, 0x8000,工具会根据前面的分区大小顺延填充。但我个人的习惯是至少在第一次手动定稿前自己把偏移算清楚。一旦固件里用的分区表和Flash上实际烧录的分区表不一致轻则数据错位重则启动不了。idf.py partition-table命令可以生成分区表binparttool.py可以读回当前Flash里的分区表建议发布前都做一次比对。3. 实操用分区 API 和文件系统把数据按规矩写进各自房间3.1 自定义分区上的裸读写有了分区表代码里就不能再用spi_flash_write直接碰地址了应该用esp_partition_*接口。以calib校准数据为例先按名字找到分区const esp_partition_t* part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, (esp_partition_subtype_t)0x40, calib); if (part NULL) { ESP_LOGE(MAIN, calib partition not found); return ESP_FAIL; }读取校准数据size_t read_len 0; esp_err_t err esp_partition_read(part, 0, s_calib_data, sizeof(s_calib_data)); if (err ! ESP_OK) { ESP_LOGE(MAIN, read calib failed: %s, esp_err_to_name(err)); }写入前先擦除err esp_partition_erase_range(part, 0, part-size); if (err ESP_OK) { err esp_partition_write(part, 0, s_calib_data, sizeof(s_calib_data)); }注意自定义data分区默认不开启磨损均衡。擦除最小单位是4KB如果每次只改一个字节就整扇区擦除寿命和效率都很差。所以我一般把校准数据打包成结构体集中读写避免频繁小范围写入。对于需要高频、小粒度写入的数据更推荐NVS或LittleFS。3.2 多应用 NVS一个分区一个实例NVS天然支持多namespace但多个小应用挤在同一个NVS分区里依然会相互影响。举个例子应用A跑一次nvs_erase_all()应用B的参数全部跟着陪葬。更稳妥的方法是给每个需要独立参数的应用各准备一个NVS分区。在分区表里放两个NVS分区nvs_app1, data, nvs, 0x9000, 0x4000, nvs_app2, data, nvs, 0xd000, 0x4000,代码里初始化时指定分区名esp_err_t err nvs_flash_init_partition(nvs_app1); if (err ESP_ERR_NVS_NO_FREE_PAGES || err ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase_partition(nvs_app1)); ESP_ERROR_CHECK(nvs_flash_init_partition(nvs_app1)); }打开句柄时也要指定分区nvs_handle_t handle; nvs_open_from_partition(nvs_app1, wifi_cfg, NVS_READWRITE, handle); nvs_set_blob(handle, ssid, ssid, strlen(ssid) 1); nvs_commit(handle); nvs_close(handle);这样一来每个应用只需要关心自己分区里的键值就算另一个分区被清空、被打爆也不影响这边的数据。代价是NVS分区会多占几块Flash空间但对4MB Flash来说每个分区40KB左右是完全可以接受的。3.3 LittleFS 数据分区挂载各挂各的路径日志这种结构化、会增长的数据裸分区和NVS都不合适。最好的选择是挂一个轻量文件系统。LittleFS比SPIFFS更稳掉电恢复能力强我目前主要用LittleFS。每个分区对应一个文件系统挂到不同的根路径下esp_vfs_littlefs_conf_t logs_conf { .base_path /logs, .partition_label logs, .format_if_mount_failed true, .dont_mount false, }; esp_err_t err esp_vfs_littlefs_register(logs_conf); esp_vfs_littlefs_conf_t wifi_conf { .base_path /wifi, .partition_label wifi_cfg, .format_if_mount_failed true, .dont_mount false, }; err esp_vfs_littlefs_register(wifi_conf);挂好之后应用A写/logs/app_a.log应用B写/logs/app_b.log数据都在同一个文件系统里但通过文件名隔离。你可能会觉得这不也算串门吗文件系统内部共用同一个磨损均衡池和可用空间如果应用A疯狂写日志占满空间应用B自然写不进去。但至少文件系统边界是明确的不会越过物理分区去破坏校准区。文件系统里再做一层子目录隔离已经能满足绝大多数的“互不干扰”需求。日志场景还有一个隐藏问题两个任务同时用fopen往同一个log文件里写会因为互斥不够而错乱。我在工程里给日志文件加了一把互斥锁SemaphoreHandle_t log_lock xSemaphoreCreateMutex(); void log_write(const char* tag, const char* msg) { xSemaphoreTake(log_lock, portMAX_DELAY); // fopen / append / fwrite / fclose xSemaphoreGive(log_lock); }3.4 不要让数据越过“院子”越界检测与写保护分区表是逻辑边界但不是物理墙。如果你非要手动用spi_flash_write去写Flash分区表根本管不住你。所以工程上要立一条规矩所有数据访问必须走esp_partition_*API不允许直接用全局地址。esp_partition_read/write内部其实也会校验offset和size是否超过part-size超了就返回ESP_ERR_INVALID_ARG等于帮你挡了一道越界写。针对低频、大块数据我喜欢在结构体头部加一个魔法数typedef struct { uint32_t magic; uint32_t version; uint32_t len; uint32_t crc32; } data_header_t;读数据时先看magic是不是自己定义的“CALI”再看CRC对不对。如果数据来自别的地方或者被人为写坏至少能识别出来而不是直接当成有效数据用。这个习惯在多个小应用共存的场景里非常管用能快速定位是“哪个应用把Flash写脏了”。4. OTA 升级最容易让数据“串门”的隐藏场景4.1 OTA 会把分区布局也改掉吗很多人以为OTA升级只是把新固件写到OTA分区数据分区不动就没什么问题。但这里藏着一个大坑OTA固件本身自带一份分区表。如果新固件里的分区表和当前Flash上的分区表不一致OTA完重启后Bootloader可能按新表加载分区导致所有数据偏移量全变了。比如你原本的calib分区在0x611000新固件改成了0x510000那新固件里的应用读到的calib内容其实是旧表里app1固件的残留代码。形同串门且是全小区的门牌号全乱了。我的原则是生产固件一旦发布后续升级只改app区代码绝不轻易改动分区表结构。如果非得改必须做整片擦除升级放弃旧数据或者写一段专门的迁移代码在新固件起来后根据分区表版本搬运数据。4.2 让分区表本身有版本既然分区表会变最好的做法是给分区表固化一个版本标志。怎么实现最简单的方法是在每一个数据分区里写一个分区布局版本号。比如用第一节的data_header_t中的version字段当应用启动时读取所有数据分区的版本号如果和自己编译时定义的版本不一致就执行迁移。实际操作时我会在应用代码里定义一个常量#define DATA_LAYOUT_VERSION 3启动后检查各分区头部版本如果版本小于当前版本调用各自的迁移函数比如从旧offset的文件系统里导出数据再写回新分区如果版本大于当前版本说明固件回滚到旧版了应当停止写数据只读稳妥数据并上报错误。这种做法能让“分区表更新”这个原本危险的举动变得可迁移、可回滚少了很多提心吊胆。4.3 OTA 分区选择和回滚机制分区表里同时存在factory和ota_1意味着我第一次烧录时用的是factory分区OTA升级会写入ota_1重启后Bootloader根据otadata分区里的标记优先引导ota_1。如果ota_1固件几次启动都失败我设置的CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE会让Bootloader自动回滚到factory。这里有个容易忽略的点otadata本身就是一个分区也会被写坏。如果otadata和某个自定义数据分区重叠OTA重启后引导状态会错乱可能每次都在两个固件间反复横跳。解决办法就是重新检查分区表偏移确保otadata只属于系统管理任何应用都不许越界写。4.4 恢复出厂设置时别误伤邻居设备常常需要一个“恢复出厂设置”功能。大多数代码里会直接调用nvs_flash_erase()。如果多个小应用共用一个NVS分区这么做没问题但如果你像我一样给关键校准参数开了独立NVS分区这时候就得想清楚恢复出厂到底要清哪些分区WiFi配置可以清日志可以清但传感器标定数据通常不该动。我建议恢复出厂的操作细分为三类restart_default只清nvs_app1WiFi配网数据factory_reset清所有NVS分区和/wifi文件系统但保留calib和logsfull_erase整个Flash全部擦除回到出厂固件。这样不同小应用的服务等级不一样数据保留策略也不一样避免一个“恢复出厂”把所有东西一锅端。5. 常见问题与排查技巧实录5.1 升级后配置“回到出厂”的根因现象做完OTA点击重启WiFi配置不见了校准参数也变成默认值。第一反应是NVS被清了但日志里根本没有任何擦除动作。排查思路是先用parttool.py读回当前分区表python parttool.py --port /dev/ttyUSB0 read_partition --partition-namenvs --output nvs.bin然后用gen_esp32part.py查看实际的偏移和大小python gen_esp32part.py nvs.bin如果读回来的分区表和编译时的partitions.csv对不上十有八九是新旧固件分区表不一致。另外也要检查otadata分区是否记录了正确的OTA状态。OTA升级后偶尔会触发一次整分区NVS重建如果应用代码里又在启动时执行了nvs_flash_erase_partition那配置丢失就是意料之中。5.2 文件系统读到脏数据现象/logs目录下有个文件名存在打开后内容却是乱七八糟的二进制甚至mount失败。排查时先确认文件系统分区没有被另一个应用用裸地址写过。我的一个案例是日志模块用LittleFS另一个应用为了存一个临时标志直接用了spi_flash_write写了一个固定地址恰好落在文件系统的文件分配区。文件系统管理了那些扇区裸写破坏了元数据后续挂载时校验失败。解决方法是禁止裸地址操作并给每个小应用分一个NVS键值来代替临时标志。文件系统如果已经损坏可以在代码里配置format_if_mount_failed true会自动格式化。但要注意这会丢掉全部日志所以重要日志要做好备份传输。5.3 应用读到的数据长度和分区大小不匹配现象应用A明明读取成功但是内容看起来像编译产物里的字符串或者乱码。这通常不是数据串门而是你使用了错误的esp_partition_find_first找到了app分区或别的数据分区。我之前把一个自定义子类型参数设置错了结果esp_partition_find_first(ESP_PARTITION_TYPE_DATA, (esp_partition_subtype_t)0x41, logs)匹配到的却是另一个同类型的calib分区。解决方案是打印找到的分区信息const esp_partition_t* part esp_partition_find_first(...); if (part ! NULL) { ESP_LOGI(MAIN, part%s type%d subtype%d size%d, part-label, part-type, part-subtype, part-size); }对照分区表确认label、subtype和size完全一致再做读写。5.4 一个实用的分区表可视化脚本排查串门问题最难的是“脑补整个Flash布局”。我习惯用一个Python脚本把分区表画成一张文本图输出每个分区占用的偏移区间快速判断是否有重叠import csv, sys with open(sys.argv[1]) as f: for row in csv.reader(f): if not row or row[0].startswith(#): continue name, typ, sub, off, size row[:5] if off * or off : continue start int(off, 16) end start int(size, 16) print(f{name:12s} 0x{start:08x} - 0x{end:08x})脚本跑一遍如果看到两个分区的区间重叠串门问题基本实锤。平时写分区表时我也会特意把同一类的数据分区排在一起留出余量方便以后扩展。5.5 常见问题速查表现象可能根因排查/解决配置被清空错误的NVS分区被擦除或键冲突使用独立NVS分区不用nvs_erase_allOTA后数据错乱新旧分区表不一致保持分区表固定或做数据迁移文件系统挂载失败分区重叠或被人为裸写破坏用format_if_mount_failed自动格式化读到乱码数据读取到错误分区/偏移错位打印分区信息校验magic和CRC日志无规律清空日志分区太小或误被擦除扩大日志分区改用LittleFS循环写启动后反复重启otadata被覆盖或损坏检查otadata分区偏移是否被占6. 这些坑踩过之后我的几条工程建议经历过几次数据串门事故之后我现在设计任何ESP32项目都会在一开始就做三件事第一把分区表当成一份独立文档来维护表格里写清楚每个分区留给哪个小应用第二所有数据访问统一封装成模块每个应用只能看到自己模块的接口拿不到全局Flash地址第三给每个关键数据分区加一个版本头部哪怕是文件系统分区也至少保证magic字段完好。另外一个实际体验很深的心得是别贪图方便把所有数据放进一个巨大的SPIFFS或LittleFS分区以为用文件名隔离就完事。文件系统虽然方便但一旦某个应用写满了空间另一个应用照样会受影响。数据通信频率不同、重要性不同的模块在物理分区上分开才是长远之计。还有一个小技巧我每次烧录固件前都会用idf.py partition-table生成分区表内容烧录后再用parttool.py读取一遍真实分区表确认自己的代码里没有用错分区标签。这个步骤只需一分钟却能在后面省下无数排查时间。毕竟Flash串门这种问题往往不是一次性暴露而是在某个应用写数据的那一刻才突然爆发。我不喜欢断电现场去翻地图宁可提前把房间号都设好。
返回列表