news 2026/11/6 4:45:45

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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偏移处,大小一般0x1000(4KB)。里面每条记录是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分区偏移必须对齐到0x10000(64KB),data分区则建议至少对齐0x1000(4KB),这是芯片手册规定的。

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分区只有0x100000(1MB),但你的固件因为启用了大量组件,编译出来是1.4MB,烧录时直接报错,或者烧完启动就崩。解决方法是先看编译日志里固件段的大小,留足富余量,一般建议分区大小比固件实际大小多30%以上,给OTA升级留空间。

2.4 NVS的namespace是逻辑隔离,不是保险柜

很多人以为NVS(Non-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应用存了一个键叫"mode",B应用也存"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_app)
ota_0appota_00x2000000x1F0000第二应用区,放第二个固件(ui_app)
sensor_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目录重新编译,结果忘记重新选CSV,idf.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传ns="sensor_app",ui_app传ns="ui_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未commit

SPIFFS数据写入后会自动落盘,但NVS和它不同。nvs_set_i32、nvs_set_str之后必须调用nvs_commit,数据才会真正写入Flash。如果不调用,断电后数据还在内存里,没写回物理存储,表现为“当时读写都成功,重启后全丢”。不光是断电,任务是重启或系统panic也可能丢失。

你以为你写了commit,实际上可能因为提前return或异常分支跳过了commit。排查办法:把commit调用放在函数出口处统一处理,用宏封装“set+commit”。另外,频繁调用nvs_commit会加速Flash磨损,NVS分区也有寿命,适合低频参数存储,不适合写高频日志。高频日志应该走SPIFFS或分区预留的日志区。

4.5 最后再分享两个小技巧

第一个技巧是:把分区表当作普通源文件纳入版本管理,并在提交信息里写明改动理由。我见过太多项目,代码仓库里找不到partitions.csv,只有旧烧录器的bin。后来加新功能,要新增一个数据分区,只能凭记忆改sdkconfig,很容易改出重叠地址。分区表应该跟main.c地位一样,每次改动都要diff、评审。

第二个技巧是:给每个spiffs分区预留10%到20%的空闲空间。SPIFFS在写满时会出现碎片化,性能下降甚至读写出错。你预留的空闲空间就是磨损均衡和碎片整理的“缓冲区”。我实际项目中给sensor_data分区分了0x4000(16KB),实际数据每天只有2KB,看起来“浪费”,但这小小的冗余让文件系统稳定跑了一年多,一次挂载失败都没遇到。

多个小应用共用一块Flash,数据不串门的关键从来不是“靠运气”,而是启动时就规划好每块区域的边界,然后用分区表这个硬件级别的规则把它固定下来。分区表设计得越清晰,后面写代码越省心。常见坑我都列在表里了,对比着排查效率最高:

症状最可能原因快速解法
反复重启分区表未烧录或不匹配erase_flash后完整烧录全套
SPIFFS挂载失败label拼写错或分区类型错误核对CSV与代码中的label
NVS重启丢数据未调用nvs_commit封装set+commit
新旧数据串扰分区地址重叠dump Flash核对地址分布
两个spiffs分区互相干扰重复挂载同一个label每个分区只挂载一次

如果你准备搞OTA、多固件共存、数据加密,这套分区表的思路一样适用。把基础打牢,后面做什么都顺手。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 4:43:58

FOC基础知识全解析:从磁场定向到无感控制与调试实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 4:43:58

提升文章分享率的核心方法:社交货币、分享驱动力与传播设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 4:43:27

电赛医疗送药小车硬件协同设计实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 4:41:36

企业级BI平台高并发与多租户架构实战解析

1. 企业级BI平台的三大核心挑战在数字化转型浪潮中,商业智能(BI)系统已成为企业决策的中枢神经。衡石科技作为国内领先的BI解决方案提供商,其产品设计直面企业级应用的三大核心痛点:高并发查询响应、多租户资源隔离、全链路数据安全防护。这三…

作者头像 李华
网站建设 2026/11/6 4:39:14

汽车零部件PLM系统实施:从数据迁移到工程变更优化

1. 项目背景与行业痛点汽车零部件行业正面临前所未有的转型压力。去年参加行业峰会时,一位深耕二十年的老工程师感叹:"现在客户订单变更频率比十年前快了五倍,但交付周期要求缩短了60%"。这背后反映的是整个汽车产业链对敏捷响应和…

作者头像 李华
网站建设 2026/11/6 4:39:10

思科交换机MAC地址表原理与端口定位实战指南

1. 这不是“找端口”,而是网络排障的底层逻辑入口在某高校网络中心值班的第三年,我第一次被叫去处理一个“奇怪”的故障:财务处的两台电脑突然无法互访,但各自都能上外网。现场用笔记本连上去一测,ping通,t…

作者头像 李华