本节目标:看懂分区表 CSV 的每一列,会给工程添加自定义分区,并用 esp_partition API 完成擦除、写入、读取
一、本节要解决的问题
ESP32 的程序不是"整个 bin 一股脑烧到某个固定地址",而是把整块 flash 划分成多个分区(partition),每个分区承担一种职责:放 bootloader、放分区表自己、放应用程序、存 NVS 配置、存 PHY 校准数据、放 OTA 固件、当文件系统、存用户数据……
这样划分的好处:
- 好管理:大工程按功能分区,升级程序只烧 app 分区,不会动到配置数据;
- 功能清晰:bootloader 靠分区表就知道哪块 flash 是"要启动的程序"、哪块是"数据";
- 避免冲突:擦除 NVS 不会误擦程序,OTA 升级也不会破坏用户数据。
如果不用分区表,所有地址都得写死在代码里,应用一多就极易互相踩踏。本节先把 ESP-IDF 默认分区表逐列拆开分析,再自定义一个分区,并用官方 API 对它擦写、读取。
二、相关知识:ESP32 flash 的固定布局
ESP32 的 flash 从 0x0 开始编址,有几处地址是约定俗成、不能乱动的:
| 区域 | 起始地址 | 大小 | 说明 |
|---|---|---|---|
| Bootloader | 0x1000 | - | 上电后最先运行的程序 |
| 分区表 | 0x8000 | 0x1000 | 描述"其余 flash 怎么分"的那张表 |
| 第一个可用分区 | 0x9000 | - | 除开上面两块,剩下的从 0x9000 开始分配 |
所以分区表里所有分区的 Offset 都必须大于等于 0x9000;默认分区表的第一个分区(nvs)正是从 0x9000 开始的。
三、知识点
3.1 分区表 CSV 的六列
分区表是一个逗号分隔的文本文件(.csv),#开头的是注释。列头固定为六列,教程重点要求看懂前五列:
| 列名 | 含义 | 取值说明 |
|---|---|---|
| Name | 分区名称(标签) | 如 nvs、factory、user;同一张表内不能重名 |
| Type | 分区类型 | app(0x00) 放程序;data(0x01) 放数据;用户自定义可写 0x40~0xFE |
| SubType | 子类型,含义随 Type 变化 | Type=app:factory(0x00)、ota_0(0x10)~ota_15(0x1F);Type=data:ota(0x00)、phy(0x01)、nvs(0x02)、nvs_keys(0x04) 及组件特定子类型 |
| Offset | 分区在 flash 中的起始地址 | 可以留空,由构建工具自动计算;不能与其他分区重叠;app 分区必须与 0x10000 对齐 |
| Size | 分区总大小 | 支持 1K、1M 这种写法(1M = 0x100000) |
| Flags | 标志位 | 本节两个例子都留空,入门阶段不用管 |
逐列再补充几句:
- Name:教程说它"并不重要",其实它有两个用处——保证表内唯一,便于管理;以及可以被 API 当作 label 按名字查找分区(配套例程后面正是这样用)。
- Type:只分 app 和 data 两大类,其他值(0x40~0xFE)全部留给用户自定义;0x00~0x3F 是系统保留范围。
- SubType:它必须结合 Type 理解——同是 0x00,在 app 下是 factory,在 data 下就是 ota;自定义类型的子类型怎么写、怎么解释,完全由用户自己约定。
- Offset:最灵活也最容易出错的一列,留空时由工具计算,手填时必须自己保证不重叠。
3.2 Offset 留空时的自动计算规则
Offset 不写时,构建工具从 0x9000 开始,把上一个分区的 Offset + Size作为下一个分区的起点,并按类型做对齐(app 补齐到 0x10000,data 只要求 4 字节对齐)。以最简单的默认分区表为例:
# Name, Type, SubType, Offset, Size, Flags # Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 1M,| 分区 | 计算过程 | 实际 Offset |
|---|---|---|
| nvs | 紧接着分区表,从 0x9000 开始 | 0x9000,大小 0x6000(24KB) |
| phy_init | 0x9000 + 0x6000 | 0xF000,大小 0x1000 |
| factory | 0xF000 + 0x1000,app 对齐到 0x10000 | 0x10000,大小 1M |
3.3 各个分区的作用
| 分区 | Type / SubType | 作用 |
|---|---|---|
| nvs | data / nvs | 非易失存储区,掉电数据不丢;存放 PHY 校准数据、Wi-Fi 配置、应用自己的键值数据(下一节详讲) |
| phy_init | data / phy | 存放每台设备单独的 PHY 初始化数据;默认配置下并不启用,PHY 数据直接编进应用程序,所以这个分区一般用不到、可以删掉 |
| factory | app / factory | 默认的应用程序分区,上电后 bootloader 从这里启动程序 |
| otadata | data / ota | 仅 OTA 分区表有,记录当前该从哪个 OTA 分区启动,大小固定 0x2000 |
| ota_0 / ota_1 | app / ota_0、ota_1 | 仅 OTA 分区表有,两个可升级的应用程序分区,bootloader 依据 otadata 的内容决定加载哪一个 |
| 自定义分区 | 0x40~0xFE / 自定义 | 用户自己存放数据,怎么解读完全由应用程序决定 |
3.4 为什么 app 分区不能重叠
编译期,ESP-IDF 会用脚本把 CSV 转成二进制分区表,脚本会检查每个分区的地址范围:只要某个分区的起点落进了前一个分区的区间,就直接报Partitions overlap并中止编译。这是第一道保险。
运行期更要命:bootloader 按分区表把 app 分区里的程序加载到内存执行,OTA 升级时还会往 ota_x 分区写新固件。两个 app 分区一旦重叠,新固件就会覆盖另一个分区里的程序;下次 bootloader 切回旧分区,读到的就是被破坏的内容,设备可能无法启动。数据分区重叠也是同理——擦其中一块,另一块的数据跟着一起没了。所以手工填 Offset 时一定要自己把地址算清楚。
顺带解释另一个问题:app 分区为什么必须 0x10000 对齐?因为 bootloader 按 64KB 为单位做 flash 到内存的地址映射,不满足对齐要求的分区无法被正常映射执行,工具链会直接拒绝。
3.5 ESP-IDF 内置的默认分区表
ESP-IDF 自带几张常用分区表,放在 IDF 安装目录的components/partition_table/下,在idf.py menuconfig的 Partition Table 菜单里选择:
| 选项 | 对应文件 | 内容 |
|---|---|---|
| Single factory app | partitions_singleapp.csv | nvs 0x6000 + phy_init 0x1000 + factory 1M,最简单的三行 |
| Single factory app, large | partitions_singleapp_large.csv | 与上面相同,只是 factory 放大到 1.5M |
| Factory app, two OTA | partitions_two_ota.csv | 在单 app 基础上增加 otadata、ota_0、ota_1,支持 OTA 升级 |
| Custom partition table CSV | 工程自己的 CSV | 勾选后由 CONFIG_PARTITION_TABLE_CUSTOM_FILENAME 指定工程根目录下的 CSV 文件名 |
两者区别一句话:内置表是"选现成的",自定义表是"自己写一张 CSV 交给编译系统"。不需要额外分区时用内置的最省事;一旦要加 spiffs、自定义数据区(比如本节例程)就必须切到 Custom。
OTA 表里为什么至少要有两个 OTA app 分区?因为 OTA 的基本玩法是:把新固件写进当前没在运行的那个分区,写完整、校验通过后再让 otadata 指向它重启。只有一个分区的话没有"备胎",升级失败就没有退路了。
3.6 本仓库的分区表使用现状
仓库 27 个工程里,有 23 个工程的
sdkconfig都含有CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"这一行,但工程目录里并不存在partitions.csv;而且这些工程真正的开关是CONFIG_PARTITION_TABLE_SINGLE_APP=y(CONFIG_PARTITION_TABLE_CUSTOM is not set)。也就是说那行文件名只是一个没被启用的默认值,编译时实际用的仍是 ESP-IDF 内置的partitions_singleapp.csv。
真正启用了自定义分区表的只有 4 个工程:partition(partitions_user.csv)、spiffs(partitions_example.csv)、web_monitor(partitions_webserver.csv)、onenet(partitions_two_ota_nofactory.csv)。想让自己的工程用上自定义分区表,光改文件名不够,必须在 menuconfig 里把 Partition Table 选项切到 Custom partition table CSV。
四、代码分析
4.1 工程结构
partition/ ├── CMakeLists.txt ├── partitions_user.csv # 自定义分区表(本节重点) ├── sdkconfig # 已启用自定义分区表 └── main/ ├── CMakeLists.txt └── main.c # 找分区 → 擦除 → 写入 → 读取sdkconfig里与本节有关的关键几行(说明自定义分区表已经启用,并指向 partitions_user.csv):
CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_user.csv" CONFIG_PARTITION_TABLE_FILENAME="partitions_user.csv" CONFIG_PARTITION_TABLE_OFFSET=0x80004.2 自定义分区表 partitions_user.csv
在默认三行之后追加了一行user分区:Type=0x40(自定义类型)、SubType=0x01(自己约定的标记)、Offset 留空自动计算、Size=0x1000(4KB)。
# Name, Type, SubType, Offset, Size, Flags # Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 1M, user, 0x40, 0x01, , 0x1000,各行的落点:nvs 在 0x9000、phy_init 在 0xF000、factory 在 0x10000 且到 0x110000 结束,所以 user 自动排到0x110000,大小 0x1000。因为 Type 是自定义的 0x40,SubType 写 0x01 只是自己作标记,编译系统不会按它做特殊处理;但代码里必须用相同的 0x40/0x01 才能找到这个分区。
4.3 main.c:找分区、擦除、写入、读取
第一步是按类型、子类型和名字找到分区,拿到一个指向分区描述符的指针,后续所有操作都基于它:
#defineUSER_PARTITION_TYPE0x40//自定义的分区类型#defineUSER_PARTITION_SUBTYPE0x01//自定义的分区子类型//分区指针staticconstesp_partition_t*partition_res=NULL;//读取缓存staticcharg_esp_buf[1024];voidapp_main(void){//找到自定义分区,返回分区指针,后续用到这个指针进行各种操作partition_res=esp_partition_find_first(USER_PARTITION_TYPE,USER_PARTITION_SUBTYPE,"user");if(partition_res==NULL){ESP_LOGI(TAG,"Can't find partition,return");return;}esp_partition_find_first的三个参数与 CSV 一一对应:Type=0x40、SubType=0x01、Name=“user”;找不到返回 NULL,所以必须判空。
接着就是 flash 的经典三步——先擦、再写、后读:
//擦除ESP_ERROR_CHECK(esp_partition_erase_range(partition_res,0x0,0x1000));//测试字符串constchar*test_str="this is for test string";//从分区偏移位置0x0写入字符串ESP_ERROR_CHECK(esp_partition_write(partition_res,0x00,test_str,strlen(test_str)));//从分区偏移位置0x0读取字符串ESP_ERROR_CHECK(esp_partition_read(partition_res,0x00,g_esp_buf,strlen(test_str)));ESP_LOGI(TAG,"Read partition str:%s",g_esp_buf);esp_partition_erase_range(分区, 偏移, 大小):这里从 0x0 开始擦满整个 4KB 分区,偏移和大小都必须 0x1000 对齐;esp_partition_write(分区, 偏移, 数据, 长度):写之前必须先擦除,否则写不进去(flash 只能把 1 变 0,擦除才能把 0 恢复成 1);esp_partition_read(分区, 偏移, 缓存, 长度):按字节读回。
最后while(1)里延时 1 秒,让程序一直挂着不退出。
与教程不一致的地方:PDF 里
esp_partition_find_first的第三个参数写的是NULL(只按类型/子类型查找),仓库代码改成了"user",即同时用 CSV 中的 Name 作为标签。这样更严格——如果 CSV 里分区名被改动,会直接找不到分区而报错,问题能更早暴露。
另外 PDF 文字说"esp_partition_write 第四个参数是写入的数据内容",这是笔误:第四个参数是长度,数据内容在第三个参数。
五、关键 API 速查
| API | 作用 |
|---|---|
esp_partition_find_first(type, subtype, label) | 按类型/子类型/标签查找第一个分区,返回esp_partition_t*,找不到返回 NULL |
esp_partition_erase_range(part, offset, size) | 擦除分区内一段区域,offset 与 size 必须 0x1000 对齐 |
esp_partition_write(part, offset, src, size) | 向分区写数据,写前必须先擦除 |
esp_partition_read(part, offset, dst, size) | 从分区读数据 |
ESP_ERROR_CHECK() | 检查返回值,出错时打印文件名和行号并重启 |
以上 esp_partition API 都声明在esp_partition.h中。
六、编译与现象验证
cdesp32-board/partition idf.py build idf.py-pCOM<端口>flash monitor工程 sdkconfig 的目标芯片是 esp32;如果重新指定过目标或清空过配置,先执行idf.py set-target esp32。
正常运行时串口打印一行关键日志:
I (xxx) main: Read partition str:this is for test string说明写进去的字符串被原样读回来了。
验证"数据真的在 flash 里"的小实验:把esp_partition_erase_range和esp_partition_write两行注释掉,重新编译烧录,重启后仍然能读出这句话——数据不是存在 RAM 里,而是上次烧录时写进 flash 的。