资讯详情

ESP32 Flash分区表详解:多应用数据隔离与OTA布局实践

📅 2026/11/6 4:45:52 | 华诺云谱 👁 阅读
ESP32 Flash分区表详解:多应用数据隔离与OTA布局实践
1. 先说结论Flash规划乱数据必串门很多人一开始玩ESP32都是单应用编译完直接烧Flash里怎么排不用操心。等手里的小应用多起来——一个做温湿度采集、一个做蓝牙配网、一个是OLED菜单、还有一个OTA升级器——问题就来了这些应用共用一个Flash芯片每个都要存配置、存采集记录、存固件副本稍不注意数据就互相踩踏。更头痛的是明明A应用没动重启之后B应用的数据却被清掉了这种情况十有八九是Flash分区问题不是代码Bug。要彻底讲清楚这个事得先建立一个概念ESP32的Flash本来就不是“一块完整的大存储”而是一个可以按地址切割的块设备。数据会串门本质上是没人告诉你哪个区域归谁管。就像合租房客厅公共区域谁都能用但真正出问题的往往是因为没有“户型图”——没人画清楚哪面墙是卧室、哪块地是厨房。解决办法是官方的分区表Partition Table机制。把这四个字记住后面的所有操作都围绕它展开。分区表是存放在Flash里的一个特殊数据结构里面定义了每个分区的名字、类型、起始地址和大小。ESP32启动时ROM里的引导程序先读分区表再根据分区表和bootloader一起决定加载哪个应用、把哪个区域分配给数据存放。说白了分区表是Flash的“不动产权证”每个人都只能在自己的产权范围内活动。这篇文章我会从分区表的原理开始讲清楚CSV文件怎么编、多应用场景怎么规划布局、代码里怎么按分区读写数据、最后附上我踩过的坑和一些排查方法。内容适合两类人一类是刚接触ESP32、被多应用存储问题劝退的新手另一类是已经会用单分区、但想搞OTA多分区布局的进阶玩家。全程以ESP32和ESP-IDF环境为例Arduino环境思路相同我会在关键位置说明差异。2. 想分区先看分区表怎么工作2.1 物理Flash和逻辑分区不是一回事ESP32常见的Flash容量是4MB地址从0x000000开始到0x3FFFFF结束。你把它想象成一条大街门牌号就是地址。默认情况下地址最低段放的是bootloader引导程序往上一点是分区表再往上才是你的应用固件。但如果不用分区表应用就会占据一条街上的大片区域想把第二个应用塞进去要么改链接脚本要么直接覆盖没有任何隔离。分区表做的事就是在Flash的固定位置写入一张“街区划分图”。这张图本身也占空间它默认放在0x8000偏移处大小一般0x10004KB。里面每条记录是32字节包含分区名、类型、子类型、偏移、大小和一些标志位。ESP-IDF在编译时会把一个CSV文件转换成分区表二进制烧录时和固件一起写入Flash。启动流程是ROM - bootloader - 读分区表 - 按配置引导应用。这里有个关键点bootloader本身没有分区表也能跑但遇到OTA多镜像、数据分区等复杂布局就抓瞎。所以只要你需要多应用共用Flash用分区表不是可选项而是必选项。理解了物理地址和逻辑分区的关系你就明白为什么不能靠“记地址”来隔离数据——烧进去的固件每次编译都可能变化地址会漂唯有分区表是稳定的锚点。2.2 CSV分区表逐字段拆解在ESP-IDF项目里有一个默认的partitions.csv文件也可以用menuconfig指定自定义路径。每行非注释内容代表一个分区格式如下# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x300000,字段从左到右分别是分区名、类型、子类型、偏移、大小、标志。分区名是逻辑标识代码里按名字找分区可以随便起但建议用有意义的名字比如app_ota_a、storage_log、nvs_sensor。类型分两类app表示可执行固件data表示纯数据。子类型则进一步细分app类有factory、ota_0、ota_1、ota_2等data类有nvs、phy、spiffs、fat等。offset是分区起始地址size是分区大小单位字节一般用十六进制。标志位目前常见的是encrypted用于Flash加密场景如果没启用Flash加密这个字段留空。另外CSV支持注释行以“#”开头。我自己写CSV文件的习惯是在最顶部注释掉整个布局说明免得三个月后回来看不懂自己当初为什么留那么大一个分区。需要注意的是名字不能重复类型加子类型的组合也不能有两个一样。如果定义了多个app分区必须有一个带boot标志或明确指定启动分区否则分区表校验会失败。还有分区表对offset有对齐要求app分区偏移必须对齐到0x1000064KBdata分区则建议至少对齐0x10004KB这是芯片手册规定的。2.3 偏移与大小对齐规则和自动布局CSV里有两类写法显式写offset或者offset留空让编译工具自动计算。官方强烈建议app分区的offset不要手动指定让系统自动排布。为什么因为如果你手动把factory写在0x10000而bootloader实际占用的区域从0x1000到0x8000间隔没算好可能撞车。自动布局的规则是每个分区的offset等于上一个分区的offset加size并按照对齐要求向上取整。例如第一个app分区起始于0x10000大小0x200000那么下一个app分区如果offset留空就会被自动放到0x210000如果对齐要求是0x10000正好满足。手动指定offset时最怕出现重叠或空隙。重叠会导致引导崩溃空隙则浪费空间。我在实际项目里偏向混合方式bootloader、分区表、nvs、phy_init这些系统区用显式地址因为它们的位置相对固定app和数据区则让工具自动排。这样既保证系统区安全又减少手动计算的工作量。一个项目中常见的问题是编译生成的bin文件大小超过了分区大小。比如factory分区只有0x1000001MB但你的固件因为启用了大量组件编译出来是1.4MB烧录时直接报错或者烧完启动就崩。解决方法是先看编译日志里固件段的大小留足富余量一般建议分区大小比固件实际大小多30%以上给OTA升级留空间。2.4 NVS的namespace是逻辑隔离不是保险柜很多人以为NVSNon-Volatile Storage天生隔离不同应用只要名字不同就行其实这是个误区。NVS在分区表里是data类型的nvs子类型系统会为它初始化一个键值数据库。ESP-IDF的NVS API确实有namespace概念调用nvs_open时可以指定一个命名空间比如sensor_app和wifi_app。听起来像隔离但它本质只是键名前加了个前缀并没有访问控制。任何一个应用只要不指定namespace或者故意打开别人的namespace就能读到那些键值。所以如果你的多个小应用是编译进同一个固件、由不同任务分别调度的那NVS的namespace够用。但如果你的“多个应用”是同一个Flash里多个独立固件镜像那NVS的隔离就形同虚设——它们共用同一个分区、同一个数据库文件没有越界检查。这种情况下要么给每个固件分配独立的NVS分区会浪费空间且配置复杂要么约定在键名前加应用前缀并在代码里强制校验。我见过最典型的NVS串门事故A应用存了一个键叫modeB应用也存mode两者在不同namespace下本来相安无事。后来某个版本代码里有人偷懒直接把nvs_open的namespace参数写成了NULL于是A写的数据被B的初始化逻辑覆盖表现为用户设置丢失。所以说NVS只能防“无意冲突”防不了“有意越权”严谨的工程做法是除了namespace还要在键值内容里加入应用标识字段读取时校验。3. 双应用加共享数据区的完整落地流程3.1 先规划再编码我的分区布局表为了演示多应用共用Flash还不串门我设计一个非常典型的需求设备上一共跑两个其实互相独立的固件应用。一个叫sensor_app负责采集温湿度并存储历史数据一个叫ui_app负责OLED菜单、按键交互和配网。为了简化这里说的“两个应用”可以是同一个固件里两个模块也可以是OTA方式切换的两个镜像关键是它们都不能互相碰数据。Flash是4MB我的布局规划如下分区名类型子类型偏移大小说明bootloaderboot无0x10000xf000引导程序建议不动partition_table系统保留无0x80000x1000分区表建议不动nvsdatanvs0x90000x6000系统参数存储WiFi配置、校准数据phy_initdataphy0xf0000x1000射频校准数据建议保留factoryappfactory0x100000x1F0000主应用区放第一个固件sensor_appota_0appota_00x2000000x1F0000第二应用区放第二个固件ui_appsensor_datadataspiffs0x3F00000x4000sensor_app私有数据区shared_datadataspiffs0x3F40000xC000两个应用都可以读写的公共数据区这个布局有个细节要说明从“多应用共用Flash”角度app区域里我放了两个镜像区factory和ota_0。但ESP32同一时间只能运行一个固件镜像所以真正需要隔离的是各自的数据区。如果你把多个“小应用”理解为App A、App B同时常驻内存那它们其实是同一个固件里的模块不是两个镜像数据隔离靠的是分区而不是镜像切换。3.2 编写自定义partitions.csv并启用在ESP-IDF项目根目录创建partitions_custom.csv内容如下# ESP32 Flash layout for multi-app scenario # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x1F0000, ota_0, app, ota_0, 0x200000, 0x1F0000, sensor_data, data, spiffs, 0x3F0000, 0x4000, shared_data, data, spiffs, 0x3F4000, 0xC000,然后执行idf.py menuconfig找到“Partition Table”选项选择“Custom partition table CSV”并指定文件路径为partitions_custom.csv。这个配置会写进sdkconfig直接决定了每次编译生成的bin文件结构。如果你用的是Arduino环境操作方法不同到“Tools - Partition Scheme”里选合适的模板Arduino自定义分区表需要手动改board文件相对麻烦但原理一致。这里强烈建议在工程目录下保留一份分区表备份。因为sdkconfig如果被清理默认分区表会替换你的自定义配置导致烧录时不匹配的问题。我在multip_app_mgr项目里踩过一次改了分区表清了build目录重新编译结果忘记重新选CSVidf.py默认回到“Single factory app, no OTA”方案烧录后应用能启动但所有SPIFFS挂载失败排查了半小时才想起来是分区表被重置了。3.3 应用代码里按分区label读写数据天然隔离有了分区表代码侧要做的是“告诉系统我想用哪个分区”。文件系统类操作用分区label键值类操作用namespace。演示SPIFFS挂载#include esp_spiffs.h #include esp_log.h static const char *TAG storage; void mount_storage(const char *label) { esp_vfs_spiffs_conf_t conf { .base_path /storage, .partition_label label, .max_files 5, .format_if_mount_failed false }; esp_err_t ret esp_vfs_spiffs_register(conf); if (ret ESP_OK) { ESP_LOGI(TAG, %s mounted at /storage, label); } else if (ret ESP_ERR_NOT_FOUND) { ESP_LOGE(TAG, Partition %s not found, label); } else { ESP_LOGE(TAG, Mount failed: %s, esp_err_to_name(ret)); } }挂载之后sensor_app往/storage/sensor.log写数据ui_app往/storage/shared.txt写数据只要两条挂载路径不同就不会物理冲突。但要注意如果两个应用都挂载同一个分区比如都挂载shared_data那么它们都可能打开同一份文件应用层必须自己加锁或约定文件名前缀。分区表能防“物理越界”防不了“逻辑竞争”。NVS操作的隔离稍微不同。ESP-IDF的NVS是独立于SPIFFS的它在nvs分区的键值库里工作。代码里这样区分#include nvs.h #include nvs_flash.h void nvs_write_example(const char *ns, const char *key, int32_t value) { nvs_handle_t handle; if (nvs_open(ns, NVS_READWRITE, handle) ESP_OK) { nvs_set_i32(handle, key, value); nvs_commit(handle); nvs_close(handle); } }调用时sensor_app传nssensor_appui_app传nsui_app键名可以同叫status互不覆盖。这里的namespace就是逻辑隔离的关键。但就像前面强调的这不是安全机制遇到恶意或误操作的代码仍然挡不住。所以我在写库函数时还会在值里塞一个magic字段比如“0x53414d50”读取时先验magic再取数据防止旧版本遗留的脏数据干扰新逻辑。3.4 烧录两个镜像与OTA切换有了分区表烧录不是一句话的事。第一次烧录时除了要烧bootloader和分区表还要把sensor_app烧到factory区、ui_app烧到ota_0区。用esptool.py手动操作esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 921600 write_flash \ 0x1000 build/bootloader.bin \ 0x8000 build/partition_table/partition-table.bin \ 0x10000 build/sensor_app.bin \ 0x200000 build/ui_app.bin注意地址必须与分区表CSV里的offset一致。如果之前烧过旧固件建议先执行esptool.py erase_flash把Flash完全清干净避免残留的分区表或者旧数据干扰。这一点很多人忽略旧分区表和旧数据往往比新分区表先被bootloader读到导致出现匪夷所思的启动异常。切换到ui_app有两种方式一是用esp_ota_ops接口运行时切换把ota_0标记为启动分区重启后就进入ui_app二是直接用esptool把ui_app烧到factory区覆盖sensor_app。我推荐用OTA接口因为这样两个应用都能保持在Flash里可以随时切回。运行时切换的核心代码#include esp_ota_ops.h void switch_to_ui_app(void) { const esp_partition_t *ota_partition esp_partition_find_first( ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_OTA_0, NULL); if (ota_partition) { esp_ota_set_boot_partition(ota_partition); esp_restart(); } }切换前要确保两个应用的分区表一致否则目标分区解析会失败。如果你只烧了sensor_app而分区表里没有ota_0切换函数返回错误设备不会重启。这类问题排查起来很容易绕圈子所以先把分区表备份好。3.5 验证“数据没串门”的三种方法配置做完了不能光看表面要实测才放心。我最常用的验证手段是按顺序做三步第一步在sensor_app里往sensor_data分区写一个文件内容是一串带时间戳的标识然后重启回到ui_app查看shared_data分区是否存在这个文件。如果ui_app能列出sensor_data分区的文件说明分区隔离失效。正常结果是无法打开sensor_data分区因为代码里只挂载了shared_data压根不知道sensor_data的存在。第二步检查NVS键值。分别在sensor_app和ui_app的namespace下写一个同名键比如“counter”值分别是100和200。重启后在两边分别读取各自读到的必须是自己的值。如果值互相覆盖说明两个固件用了同一个namespace或者分区表里nvs分区被共用了。第三步用esptool.py dump整个Flash离线检查地址分布。命令类似esptool.py --chip esp32 --port /dev/ttyUSB0 read_flash 0x00000 0x400000 flash_dump.bin然后打开hex编辑器定位到sensor_data分区起始地址0x3F0000看前64字节应该是我们写入的标识定位到shared_data分区0x3F4000看是否只有ui_app写的文件。这种验证最直观能确凿证明数据落在预期区域没跑偏。我实际测试中第三步最有效但最容易被忽略。比如一次调试时共享数据区写入正常但sensor私有区数据被另一个任务篡改了。在代码里怎么查都查不出来dump Flash后才发现两个分区地址在CSV里写重了。为什么编译没报错因为CSV是文本工具不校验重叠。所以千万不要迷信“编译通过”要手动核对分区表。4. 常见问题与排查实录4.1 烧录后反复重启分区表没生效现象是设备烧录完成后不断重启串口日志能看到“invalid partition table”或“No factory partition found”。最常见原因是你改了自定义分区表但烧录时漏烧partition-table.bin还在用Flash里旧的默认分区表。旧表里没有ota_0但新固件按新地址放到了0x200000引导时发现这个地址没有合法的应用头于是崩溃。排查方法是先erase_flash再完整烧录bootloader、partition-table、所有应用镜像。另一个隐蔽原因是bootloader版本太旧不支持你定义的分区子类型。如果你的bootloader是几个月前烧的建议这次连同新bootloader一起更新旧bootloader读新分区表时可能把unknown subtype当作损坏表现也是反复重启。4.2 SPIFFS挂载失败label拼写和分区类型挂载SPIFFS时报ESP_ERR_NOT_FOUND第一反应应该查两处一处是esp_vfs_spiffs_conf_t里partition_label是否和CSV里的name完全一致注意大小写也得一致另一处是CSV里这个分区的Type是不是data、SubType是不是spiffs。如果你把分区类型写成了app挂载接口会拒绝访问因为它是给可执行代码用的不是给文件系统用的。还有一种情况你在分区表里写了sensor_data和sensor_data2但代码里两个应用都用label sensor_data。这等于两个应用抢同一个文件系统可能发生数据覆盖。派生的具体场景是读好好的文件重启后变成空目录。SPIFFS本身有坏块管理但不会隔离多挂载者。遇到这种问题把分区表打印出来核对label很快就看清了。4.3 NVS键冲突namespace还需要分层NVS的namespace解决的是“键名冲突”但同一个namespace下不同部门代码仍可能互相覆盖。举个例子sensor_app自己写了“filter”这个键它启动的WiFi配网服务也写了“filter”两者同namespace结果WiFi配网的初始化把滤波参数清成默认值。解决方案是给每个功能模块单独开namespace比如“app_sensor_calib”、“app_sensor_filter”、“app_wifi_cred”。namespace名字虽然不会占多少Flash但也不要滥用因为NVS会维护表头索引namespace过多会降低操作效率和空间利用率。折中方法是namespace用应用名键名用功能名加模块前缀读取时统一校验magic。我倾向这种“两层防护”既好调试又不浪费空间。4.4 断电重启后数据丢失NVS未commitSPIFFS数据写入后会自动落盘但NVS和它不同。nvs_set_i32、nvs_set_str之后必须调用nvs_commit数据才会真正写入Flash。如果不调用断电后数据还在内存里没写回物理存储表现为“当时读写都成功重启后全丢”。不光是断电任务是重启或系统panic也可能丢失。你以为你写了commit实际上可能因为提前return或异常分支跳过了commit。排查办法把commit调用放在函数出口处统一处理用宏封装“setcommit”。另外频繁调用nvs_commit会加速Flash磨损NVS分区也有寿命适合低频参数存储不适合写高频日志。高频日志应该走SPIFFS或分区预留的日志区。4.5 最后再分享两个小技巧第一个技巧是把分区表当作普通源文件纳入版本管理并在提交信息里写明改动理由。我见过太多项目代码仓库里找不到partitions.csv只有旧烧录器的bin。后来加新功能要新增一个数据分区只能凭记忆改sdkconfig很容易改出重叠地址。分区表应该跟main.c地位一样每次改动都要diff、评审。第二个技巧是给每个spiffs分区预留10%到20%的空闲空间。SPIFFS在写满时会出现碎片化性能下降甚至读写出错。你预留的空闲空间就是磨损均衡和碎片整理的“缓冲区”。我实际项目中给sensor_data分区分了0x400016KB实际数据每天只有2KB看起来“浪费”但这小小的冗余让文件系统稳定跑了一年多一次挂载失败都没遇到。多个小应用共用一块Flash数据不串门的关键从来不是“靠运气”而是启动时就规划好每块区域的边界然后用分区表这个硬件级别的规则把它固定下来。分区表设计得越清晰后面写代码越省心。常见坑我都列在表里了对比着排查效率最高症状最可能原因快速解法反复重启分区表未烧录或不匹配erase_flash后完整烧录全套SPIFFS挂载失败label拼写错或分区类型错误核对CSV与代码中的labelNVS重启丢数据未调用nvs_commit封装setcommit新旧数据串扰分区地址重叠dump Flash核对地址分布两个spiffs分区互相干扰重复挂载同一个label每个分区只挂载一次如果你准备搞OTA、多固件共存、数据加密这套分区表的思路一样适用。把基础打牢后面做什么都顺手。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑