引导程序也能安全 OTA:ESP-IDF 恢复机制详解
本文将介绍如何通过恢复引导程序 (Recovery Bootloader) 功能,安全地对引导程序 (bootloader) 执行 OTA 更新。
设想这样一个场景:设备已经部署到现场并正常运行,此时安全审查发现了一个引导程序漏洞。修复该漏洞需要对现场设备的引导程序进行更新。
虽然这看起来与常规 OTA 更新类似,但替换引导程序的风险要远高于更新应用程序。如果因为写入中断、镜像不完整或校验失败导致引导程序失效,设备可能无法再启动。
恢复引导程序功能正是为了应对这一风险而设计:在 flash 的另一个位置保留一份额外的、已知可用的引导程序镜像。当主引导程序无法加载时,系统会转而使用该恢复镜像。
本文将说明如何在尽量降低设备无法启动风险的前提下,完成引导程序的 OTA 更新。文中示例基于 ESP-IDF v6.1 与 ESP32-C5,但相同的思路同样适用于其他支持该功能的芯片。
应用程序 OTA 与引导程序 OTA
应用程序的 OTA 更新通常会使用多个应用分区。新镜像会被写入一个空闲的分区,而当前正在运行的应用程序则保持不变。如果新镜像校验失败或无法成功启动,系统可返回到之前的程序中。
默认情况下,引导程序并不提供同样的保护。典型的 flash 布局中只包含一份引导程序镜像。一旦该镜像被擦除、部分覆写或损坏,就无法继续启动流程,设备将处于无法正常工作的状态,直到通过物理连接方式重新进行烧录。
由于引导程序体积很小、改动频率也很低,历史上引导程序更新并不常见。但引导程序中也可能存在漏洞,因此仍需要对已经部署的设备进行引导程序更新。
恢复功能还需要芯片 ROM 引导程序的支持。因此,恢复引导程序功能仅在实现对应 ROM 级回退机制的芯片上可用。
引导程序更新方案
更新引导程序主要有两种思路:
- 借助恢复引导程序的更新方式:在替换主引导程序之前,应用程序会先将一份已知可用的引导程序镜像写入恢复引导程序所在位置。如果主引导程序失效,ROM 引导程序可以加载该恢复镜像。这是推荐做法,也是本文重点介绍的方案。
- 直接更新方式:应用程序直接将新镜像覆写到主引导程序位置,不预先准备恢复镜像。该方式使设备变砖的风险更高,通常只在不支持恢复引导程序的芯片上作为唯一可行方案。
关键术语
在深入介绍之前,有必要先厘清各个启动阶段及其相互关系。
一级引导程序 (1st stage bootloader),即 ROM bootloader,是芯片上电后运行的第一段代码,固化在芯片内部的只读存储器中,无法更改。其作用是在 flash 中找到有效的 2nd stage bootloader 镜像,对其进行校验、加载,并将控制权移交给它。
二级引导程序 (2nd stage bootloader),即通常所说的 bootloader,是 ESP-IDF 的引导程序二进制文件,负责读取分区表、选择应用分区、校验并加载该分区,然后启动应用程序。引导程序 OTA 更新替换的正是这个镜像。
主引导程序 (Primary bootloader) 是位于常规引导程序偏移地址(通常为 0x0000、0x1000 或 0x2000)的引导程序。
恢复引导程序 (Recovery bootloader) 是存放在不同 flash 偏移地址上的、已知可用的引导程序副本。当 ROM 引导程序在无法加载主引导程序时,才会使用它;正常情况下,恢复引导程序不会被用到。
正常启动流程
当主引导程序存在且有效时,启动流程如下:
上电
└─► ROM 引导程序 (一级引导程序)
└─► 主引导程序 (二级引导程序)
└─► 应用程序
恢复引导程序流程
当主引导程序缺失或已损坏时,ROM 引导程序会回退至恢复引导程序:
上电
└─► ROM 引导程序
├─► 主引导程序 —— 失败
└─► 恢复引导程序
└─► 应用程序
恢复引导程序的地址保存在芯片的 eFuse 中。eFuse 是芯片上的一次性可编程存储器,其比特位一旦从 0 烧录为 1,就无法再更改。
支持恢复引导程序的芯片
恢复引导程序功能仅在 ROM 引导程序支持该特性的芯片上可用。在 ESP-IDF 中,该功能通过 CONFIG_BOOTLOADER_RECOVERY_ENABLE Kconfig 选项开放,可在 menuconfig 中的 Bootloader config -> Recovery Bootloader and Rollback 路径下找到。截至本文撰写时,支持该选项的芯片列表如下:
| 芯片 | CONFIG_BOOTLOADER_RECOVERY_ENABLE 可用版本 |
|---|---|
| ESP32-C5 | ESP-IDF v5.5 及以上 |
| ESP32-C61 | ESP-IDF v5.5 及以上 |
| ESP32-P4(芯片版本 >= 3.0) | ESP-IDF v5.5 及以上 |
| ESP32-S31 | ESP-IDF v6.1 及以上 |
恢复引导程序的配置步骤
在确认目标芯片支持该功能并开启 CONFIG_BOOTLOADER_RECOVERY_ENABLE Kconfig 选项之后,下一步就是配置恢复引导程序。首先需要检查现有的分区布局,并为恢复引导程序预留一块合适的空闲 flash 区域。
1. 选择恢复引导程序的存放位置
在选择恢复引导程序的位置之前,需要先估算当前布局所允许的最大引导程序镜像大小。在 ESP-IDF 中,该上限由主引导程序偏移地址与分区表偏移地址之间的区域决定。CONFIG_PARTITION_TABLE_OFFSET 本身并不直接定义引导程序镜像大小,但它决定了该区域的上边界。
恢复引导程序镜像的大小与主引导程序镜像相同。最大引导程序大小的计算方式为:CONFIG_PARTITION_TABLE_OFFSET - CONFIG_BOOTLOADER_OFFSET_IN_FLASH。例如,若分区表起始地址为 0x8000、主引导程序位于 0x1000,则最大引导程序大小为 0x7000 字节。
选定恢复引导程序位置后,需要将该地址设置到 CONFIG_BOOTLOADER_RECOVERY_OFFSET Kconfig 选项中。同一地址后续也需要烧录进 eFuse(相关步骤将在本文后面介绍)。这是 ROM 引导程序查找恢复引导程序时唯一需要知道的地址。
此外,也可以在分区表中显式添加恢复引导程序条目 (recovery_bloader) 来明确定义该区域,这主要是为了保持一致性并记录完整的 flash 布局。
2. 分区表布局
ESP-IDF 官方指南中记录了分区表的类型 (Type) 与子类型 (SubType),详见 ESP-IDF Partition Tables。推荐的分区表布局如下:
# ESP-IDF 分区表
# 名称, 类型, 子类型, 偏移, 大小, 标志
bootloader, bootloader, primary, N/A, N/A,
partition_table, partition_table, primary, N/A, N/A,
ota_data, data, ota, , 0x2000,
nvs, data, nvs, , 0x6000,
phy_init, data, phy, , 0x1000,
ota_0, app, ota_0, , 1M,
ota_1, app, ota_1, , 1M,
recovery_bloader, bootloader, recovery, N/A, N/A,
与 ESP-IDF 默认分区表相比,此布局新增了显式的 bootloader 与 partition_table 条目,便于查看和梳理整体 flash 布局,并可完整记录整个 flash 的分区情况。可运行 idf.py partition-table 查看带有偏移地址与大小信息的分区表。
说明:
- 如果产品已经部署,且现有分区表中不包含
bootloader分区条目,仍然可以使用引导程序恢复功能——有专门的 API 可以在运行时向分区表中动态添加缺失的分区。 N/A是一个特殊取值,表示分区工具会根据配置自动为某些分区类型填充相应字段。- 主引导程序与主分区表之间不应放置任何其他分区,因为该区域专用于主引导程序,并决定了引导程序的最大允许大小;恢复引导程序应放置在单独的区域中。
3. 烧录恢复引导程序 eFuse
烧录恢复引导程序 eFuse 有两种方式:
- 在应用程序运行时烧录:调用以下 API:
esp_efuse_set_recovery_bootloader_offset(CONFIG_BOOTLOADER_RECOVERY_OFFSET);
- 在主机端烧录:使用
espefuse.py工具:
espefuse --port /dev/ttyUSB0 burn_efuse RECOVERY_BOOTLOADER_FLASH_SECTOR 0x3F0
恢复引导程序对应的 eFuse 字段以扇区号 (sector number) 的形式表示,而非原始字节地址。要将恢复引导程序的偏移地址转换为扇区号,需将该偏移地址除以 flash 扇区大小 (0x1000)。例如,若恢复引导程序偏移地址为 0x3F0000,则对应的 eFuse 取值为 0x3F0000 / 0x1000 = 0x3F0。
RECOVERY_BOOTLOADER_FLASH_SECTOR = CONFIG_BOOTLOADER_RECOVERY_OFFSET / 0x1000
引导程序 OTA 更新
引导程序 OTA 更新的流程与应用程序 OTA 更新流程类似,主要区别在于:在开始更新之前,需要预先准备好引导程序相关的分区与 eFuse。
当 Secure Boot v1 已启用时,无法执行引导程序 OTA 更新。
1. 获取主引导程序与恢复引导程序的分区句柄
如果分区表中已经包含显式的引导程序条目,可以使用以下代码分别获取主引导程序与恢复引导程序区域的分区句柄。这两个句柄用于在不同分区之间拷贝引导程序镜像。
const esp_partition_t *primary_bootloader = esp_partition_find_first(
ESP_PARTITION_TYPE_BOOTLOADER,
ESP_PARTITION_SUBTYPE_BOOTLOADER_PRIMARY,
NULL);
const esp_partition_t *recovery_bootloader = esp_partition_find_first(
ESP_PARTITION_TYPE_BOOTLOADER,
ESP_PARTITION_SUBTYPE_BOOTLOADER_RECOVERY,
NULL);
如果分区表中不包含显式的引导程序条目(适用于已经部署的产品),可以使用以下代码在运行时注册对应区域:
const esp_partition_t *primary_bootloader;
esp_partition_register_external(NULL,
ESP_PRIMARY_BOOTLOADER_OFFSET,
ESP_BOOTLOADER_SIZE,
"PrimaryBTLDR",
ESP_PARTITION_TYPE_BOOTLOADER,
ESP_PARTITION_SUBTYPE_BOOTLOADER_PRIMARY,
&primary_bootloader);
const esp_partition_t *recovery_bootloader;
esp_partition_register_external(NULL,
CONFIG_BOOTLOADER_RECOVERY_OFFSET,
ESP_BOOTLOADER_SIZE,
"RecoveryBTLDR",
ESP_PARTITION_TYPE_BOOTLOADER,
ESP_PARTITION_SUBTYPE_BOOTLOADER_RECOVERY,
&recovery_bootloader);
2. 在 eFuse 中烧录恢复引导程序偏移地址
将恢复引导程序的偏移地址烧录进 eFuse。如果该 eFuse 字段尚未设置,此 API 会写入该数值;如果该 eFuse 已设置且取值相同,此 API 不会做任何操作;但如果取值不同,此 API 将返回错误。
esp_efuse_set_recovery_bootloader_offset(CONFIG_BOOTLOADER_RECOVERY_OFFSET);
3. 创建已知可用的恢复引导程序备份
在开始 OTA 更新之前,需要先将主引导程序备份到恢复分区中。由于设备刚刚正是从该主引导程序启动的,可以确认该镜像是可用的。如果设备本次是通过恢复引导程序启动的,说明恢复引导程序中已经保存了有效镜像,此时可跳过备份步骤,直接进入下载环节。
if (esp_rom_get_bootloader_offset() == ESP_PRIMARY_BOOTLOADER_OFFSET) {
// 本次是通过主引导程序启动的。
ESP_LOGI(TAG, "Backing up primary bootloader to recovery partition.");
esp_partition_copy(recovery_bootloader, 0, primary_bootloader, 0, primary_bootloader->size);
} else {
// 恢复引导程序中已保存有效镜像。
ESP_LOGW(TAG, "Recovery bootloader was used to boot the device");
ESP_LOGI(TAG, "Skipping backup, recovery bootloader already holds a valid image.");
}
4. 将新引导程序镜像下载到空闲 OTA 分区
使用空闲的 OTA 分区(ota_0 或 ota_1)来下载新的引导程序镜像,该流程与应用程序 OTA 更新流程相同。新的引导程序镜像会先下载到这一暂存分区中,只有在校验通过之后,才会被拷贝到主引导程序分区。此处所用的高层 OTA 辅助接口详见 ESP HTTPS OTA 相关文档。
esp_https_ota_config_t *ota_config;
// free app ota partition will be used to store the new bootloader image
const esp_partition_t *free_ota_slot = esp_ota_get_next_update_partition(NULL);
ota_config->partition.staging = free_ota_slot;
// The target for the OTA update.
ota_config->partition.final = primary_bootloader;
esp_https_ota(ota_config);
esp_partition_copy(primary_bootloader, 0, free_ota_slot, 0, primary_bootloader->size);
新引导程序镜像通过校验后,将其拷贝到主引导程序分区。这是整个更新流程中最关键的一步,因为此时主引导程序会先被擦除、再重新写入。如果这一步骤中发生掉电或其他故障,主引导程序将处于损坏状态,此时下一次启动会转而使用恢复引导程序。
5. ROM 引导程序的行为
设备重启后,ROM 引导程序会先尝试加载并校验主引导程序:如果成功,则立即跳转执行;如果失败,ROM 会读取 RECOVERY_BOOTLOADER_FLASH_SECTOR 这一 eFuse 字段——若该字段为 0(表示尚未配置)或 0xFFF(表示已被永久禁用),ROM 会跳过恢复流程,反复重试主引导程序,进入无限循环;若该字段为其他任意数值,则视为一个有效的扇区偏移地址,ROM 会从该地址加载恢复引导程序,加载成功后即跳转执行。如果恢复引导程序的加载同样失败,ROM 会再次重试主引导程序,同样陷入无限循环。
这也是为什么必须在擦除主引导程序之前预先烧录好一份可用的恢复镜像:如果两者同时损坏,设备将无法从该循环中恢复。
当触发恢复路径时,ROM 日志大致如下:
ESP-ROM:esp32c5-eco2-20250121
Build:Jan 21 2025
rst:0x1 (POWERON),boot:0x18 (SPI_FAST_FLASH_BOOT)
invalid header: 0xffffffff
invalid header: 0xffffffff
invalid header: 0xffffffff
PRIMARY - FAIL
Loading RECOVERY Bootloader...
SPI mode:DIO, clock div:1
load:0x408556b0,len:0x17cc
load:0x4084bba0,len:0xdac
load:0x4084e5a0,len:0x3140
entry 0x4084bbaa
I (46) boot: ESP-IDF ... 2nd stage bootloader
ROM 引导程序对恢复引导程序执行的安全启动校验,与对主引导程序执行的校验是完全相同的。
6. 引导程序 OTA 更新后应用程序的行为
设备每次启动时,都应检查本次是通过哪个引导程序启动的:
- 主引导程序处于激活状态 —— 说明新的引导程序已成功加载。
- 恢复引导程序处于激活状态 —— 说明主引导程序启动失败,设备已优雅地完成了恢复。此时应用程序需要用已知可用的恢复镜像重新恢复主引导程序,以便下一次 OTA 周期能够从一个干净的状态重新开始。
if (esp_rom_get_bootloader_offset() == ESP_PRIMARY_BOOTLOADER_OFFSET) {
ESP_LOGI(TAG, "Primary bootloader is active — OTA succeeded.");
} else {
ESP_LOGW(TAG, "Recovery bootloader is active — primary OTA failed, restoring primary from recovery.");
esp_partition_copy(primary_bootloader, 0, recovery_bootloader, 0, primary_bootloader->size);
}
这种"先检查、再处理"的模式,是闭环完成引导程序 OTA 更新、并为下一次 OTA 周期做好准备的推荐做法。
防回滚 (Anti-rollback)
ESP-IDF 为引导程序提供了与应用程序相同的防回滚机制。每个镜像的头部都包含一个 secure_version 字段,芯片上有对应的 eFuse 字段,用于保存所接受的最低版本号。引导程序只会加载 secure_version 大于或等于该 eFuse 存储值的镜像。一旦新镜像被确认可正常工作,就可以将该 eFuse 值提升到该镜像的 secure_version——此后,任何版本号更低的镜像都将被无条件拒绝,即便该镜像本身签名有效、内容完整也是如此。这一机制可以防止攻击者将引导程序回滚到已知存在漏洞的旧版本。
应用程序与引导程序的防回滚各自使用独立的 secure_version eFuse 字段,并在不同阶段进行校验:一级引导程序负责校验二级引导程序的版本,二级引导程序负责校验应用程序的版本。
引导程序的 secure_version 字段属于 Bootloader Image Format(引导程序镜像格式)的一部分。引导程序防回滚功能仅在部分支持恢复引导程序的芯片上可用,请确认目标芯片是否支持 CONFIG_BOOTLOADER_ANTI_ROLLBACK_ENABLE 选项。
引导程序的防回滚功能是可选的,由 CONFIG_BOOTLOADER_ANTI_ROLLBACK_ENABLE Kconfig 选项控制。启用后,CONFIG_BOOTLOADER_SECURE_VERSION 用于设置构建时写入引导程序镜像头部的 secure_version 数值。
eFuse 数值的推进方式取决于 BOOTLOADER_ANTI_ROLLBACK_UPDATE_IN_ROM 选项:若启用该选项,ROM 引导程序会在校验成功后自动烧录更高的安全版本号;若未启用,则需要由应用程序显式推进该数值。下面展示的是由应用程序管理的方式。
esp_bootloader_desc_t primary_bootloader_desc;
size_t efuse_secure_version = 0;
if (esp_rom_get_bootloader_offset() == ESP_PRIMARY_BOOTLOADER_OFFSET) {
// Get the primary bootloader description, which includes the secure_version field.
esp_ota_get_bootloader_description(NULL, &primary_bootloader_desc);
// Read the eFuse secure_version
esp_efuse_read_field_cnt(ESP_EFUSE_BOOTLOADER_ANTI_ROLLBACK_SECURE_VERSION, &efuse_secure_version);
// Advance the secure_version in eFuse. It prevents loading older bootloaders.
if (primary_bootloader_desc.secure_version > efuse_secure_version) {
size_t diff = primary_bootloader_desc.secure_version - efuse_secure_version;
esp_efuse_write_field_cnt(ESP_EFUSE_BOOTLOADER_ANTI_ROLLBACK_SECURE_VERSION, diff);
}
}
BOOTLOADER_ANTI_ROLLBACK_SECURE_VERSION 字段采用单调递增的比特计数编码方式:每递增一次,就会多烧录一个比特位为 1。大多数芯片为该字段分配了 4 个比特位,最多支持 4 次递增(0x0 → 0x1 → 0x3 → 0x7 → 0xF)。在产品出货前,应确认目标芯片该字段的具体位宽,并据此规划版本管理方案,确保不超出该预算。
下表展示了引导程序版本管理机制在实践中的运作方式。其中,Ver 表示 esp_bootloader_desc_t.version,sec_ver 表示镜像的 secure_version,eFuse min 表示 BOOTLOADER_ANTI_ROLLBACK_SECURE_VERSION。
| Ver | sec_ver | eFuse min | 说明 |
|---|---|---|---|
| 1 | 0 | 0 | 初始生产镜像,尚未启用防回滚机制。 |
| 2 | 0 | 0 | 新的引导程序构建版本,但不涉及安全修复,仍接受旧镜像。 |
| 3 | 1 | 0 | 包含安全修复的构建版本,因 1 >= 0 而被接受。 |
| 3 | 1 | 1 | 同一镜像,eFuse 已推进;此后任何 secure_version = 0 的镜像都将被拒绝。 |
| 4 | 2 | 1 | 后续的安全修复构建版本,因 2 >= 1 而被接受。 |
| 4 | 2 | 2 | eFuse 再次推进;此后任何 secure_version < 2 的镜像都将被拒绝。 |
不使用恢复引导程序的引导程序 OTA
在没有恢复引导程序的情况下,覆写主引导程序不具备任何自动的安全保障。在烧录过程中一旦发生掉电或镜像无效,设备将永久无法启动——此时的恢复只能依靠 JTAG 或串口编程器进行物理接入。因此,只有在具备其他救援手段并能够接受相应风险的前提下,才建议采用此方式。
其更新流程与前文所述的「引导程序 OTA 更新」基本相同,只是省略了其中的第 2 步与第 3 步——因为不存在恢复分区,也无需在其中放置已知可用的备份镜像。第 4 步末尾的 esp_partition_copy() 调用是整个流程中最关键的一步:一旦该操作失败,设备将直接变砖。因此,务必在确保电源供应稳定的情况下再执行该操作。
完整示例可参考 partitions_ota example。
结语
本文完整介绍了引导程序 OTA 更新的两条路径:在支持 ROM 级恢复功能的芯片上使用恢复引导程序的安全方案,以及在不支持该功能的芯片上采用的风险较高的直接烧录方案。乐鑫建议优先采用恢复引导程序方案。完整的工作示例可参考 ESP-IDF 仓库中的 partitions_ota example。
相关资源
更多推荐



所有评论(0)