MCUboot 集成:A/B 槽位与镜像确认
STM32F103ZE 上要实现可靠的 OTA,光把固件写进 Flash 远远不够。真正麻烦的事情在于:升级失败怎么回滚、新固件跑崩了怎么自动退回旧版本、升级过程中断电了怎么办、怎么防止刷一个旧固件下去把漏洞打回来。MCUboot + Zephyr sysbuild 提供了一套现成的 A/B 双槽方案,本文记录 Mill 工程把它落到 STM32F103ZE 上的全过程。
一、MCUboot 是什么
MCUboot 是一个跨 RTOS 的 secure bootloader 框架,Zephyr 把它作为默认 bootloader 集成。它解决三件事:
- 签名校验:启动时验证镜像的 ECDSA-P256 / RSA 签名,拒绝未签名或被篡改的固件;
- A/B 槽位交换:把 slot1 里的新固件和 slot0 里的旧固件交换,失败可回滚;
- 镜像确认:新固件首次启动处于 testing 状态,应用必须主动调用
boot_write_img_confirmed()确认,否则下次重启回滚。
它不依赖文件系统,直接读 Flash 分区表,适合 STM32F1 这种小内存场景。
二、sysbuild 双镜像构建
Zephyr 的 sysbuild 把 bootloader 和应用当成两个独立镜像一起构建。sysbuild.conf 里几行关键配置:
# 启用 MCUboot
SB_CONFIG_BOOTLOADER_MCUBOOT=y
# Swap using scratch 模式(A/B 双槽 + scratch 中转区,支持回滚)
SB_CONFIG_MCUBOOT_MODE_SWAP_SCRATCH=y
# ECDSA-P256-SHA256 签名
SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y
# 签名密钥路径
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="/home/mydei/.config/mill/keys/root-ecdsa-p256.pem"
SB_CONFIG_MCUBOOT_MODE_SWAP_SCRATCH 选用的是 swap + scratch 模式:升级时 MCUboot 把 slot1 新固件和 slot0 旧固件通过 scratch 区做物理交换,交换完后 slot0 里是新固件、slot1 里是旧固件。万一新固件没被确认,下次启动再把它们换回去,完成回滚。
构建命令执行后,sysbuild 会产生两个产物:
build/mcuboot/zephyr/zephyr.bin—— bootloader 本体build/<app>/zephyr/zephyr.signed.bin—— 被 imgtool 签名后的应用镜像
烧录时先刷 mcuboot.bin 到 0x08000000,再刷 zephyr.signed.bin 到 0x08010000。注意应用必须用 .signed.bin,未签名的 .bin 会被 MCUboot 直接拒绝启动。
三、imgtool 自动签名
sysbuild 在构建链尾部自动调用 imgtool,用 SB_CONFIG_BOOT_SIGNATURE_KEY_FILE 指定的私钥对应用镜像签名。签名信息(TLV)追加在镜像末尾,包含 version、依赖关系、签名值等字段。MCUboot 启动时会解析这些 TLV 完成校验。
私钥放在 WSL 用户私有目录,不随 Git 仓库同步,避免密钥泄漏导致固件被伪造。原始工程用的是 ECDSA-P256,这里保持一致,签名速度和验证速度都适合 STM32F1。
四、Flash 分区布局
STM32F103ZE 内置 512KB Flash,page size 2KB。分区表这样切:
&flash0 {
partitions {
compatible = "fixed-partitions";
#address-cells = <1>;
#size-cells = <1>;
boot_partition: partition@0 {
label = "mcuboot";
reg = <0x00000000 0x00010000>; /* 64KB */
read-only;
};
slot0_partition: partition@10000 {
label = "image-0";
reg = <0x00010000 0x00037000>; /* 220KB */
};
slot1_partition: partition@47000 {
label = "image-1";
reg = <0x00047000 0x00037000>; /* 220KB */
};
scratch_partition: partition@7e000 {
label = "scratch";
reg = <0x0007e000 0x00001000>; /* 4KB */
};
storage_partition: partition@7f000 {
label = "storage";
reg = <0x0007f000 0x00001000>; /* 4KB */
};
};
};
汇总成表:
| 分区 | 偏移 | 大小 | 用途 |
|---|---|---|---|
| mcuboot | 0x00000000 | 64KB | bootloader 本体(只读) |
| image-0 | 0x00010000 | 220KB | slot0,应用运行槽 |
| image-1 | 0x00047000 | 220KB | slot1,升级暂存槽 |
| scratch | 0x0007E000 | 4KB | MCUboot swap 中转区(2 page) |
| storage | 0x0007F000 | 4KB | NVS/LittleFS 配置存储 |
zephyr,code-partition = &slot0_partition; 这一行告诉 Zephyr:应用代码应该链接到 slot0,而不是从 0x08000000 开始。
slot 大小为什么是 0x37000(220KB)
这个数字是和原始 STM32 工程对齐的。原工程的 App 区就是 220KB,业务代码加上 mbedtls、cJSON、Zephyr 子系统刚好能塞下。slot0 和 slot1 大小必须相等,否则 swap 没法做——MCUboot 是按字节交换的,大小不一致就找不到对应位置。
scratch 给了 4KB(2 个 page),主要为了规避 MCUboot swap 时 page 边界问题。slot1 写满到尾巴时,scratch 要留出余量做中转,太小会撞边界报错。
64KB 的 mcuboot 区对 STM32F1 来说够用,因为 bootloader 不带 shell 和 mcumgr,只跑签名验证和 swap 逻辑。
五、boot_write_img_confirmed 镜像确认
这是整个 OTA 流程里最关键的一步。MCUboot swap 完成后,新固件首次启动处于 testing 状态——它已经能跑了,但还没被”认可”。应用必须在初始化阶段主动调用确认函数,否则下次重启 MCUboot 会把 slot0 和 slot1 再换一次,退回到旧固件。
main.c 里把它放在所有初始化之前:
#include <zephyr/dfu/mcuboot.h>
int main(void)
{
int rc;
printf("Mill STM32F103ZE booting\r\n");
/* MCUboot swap 后新固件处于 testing 状态,必须主动确认,
* 否则下次重启 MCUboot 会回滚到旧固件,OTA 看似成功实际失败。
* 若已是 confirmed 状态,本调用为空操作。
*/
rc = boot_write_img_confirmed();
if (rc != 0) {
LOG_WRN("boot_write_img_confirmed failed: %d (可能是首次启动或已确认)", rc);
} else {
LOG_INF("Current image confirmed");
}
/* 后续业务初始化 */
app_led_init();
BusService_Init();
/* ... */
}
为什么不放到业务线程里?因为确认越早越好。如果新固件一启动就崩,根本没机会调用 boot_write_img_confirmed(),下次重启 MCUboot 自动回滚——这是设计使然,保证只有真正能跑起来的固件才会被保留。
boot_write_img_confirmed() 内部会向 image trailer 写入 boot_record.magic 和 boot_record.confirm,MCUboot 下次启动读 trailer 时看到 confirm 标志就不再 swap。已经是 confirmed 状态再调用一次是无害的空操作。
六、防降级 CONFIG_MCUBOOT_DOWNGRADE_PREVENTION
sysbuild/mcuboot.conf 里两行:
# 日志级别设为 WRN,减少 flash 占用
CONFIG_MCUBOOT_LOG_LEVEL_WRN=y
# 防降级:拒绝版本号低于当前运行槽的镜像
CONFIG_MCUBOOT_DOWNGRADE_PREVENTION=y
防降级依赖镜像 header 里的 version 字段。imgtool 签名时把版本号写进 TLV,MCUboot 启动时比较 slot1 候选镜像和 slot0 当前镜像的版本:候选版本低于当前版本直接拒绝启动。这能挡住两类场景:
- 云端误下发旧固件包;
- 攻击者拿一个已修复漏洞前的签名固件刷进去。
注意版本号必须在签名时正确填写,imgtool 命令行 --version 参数或 sysbuild 的 SB_CONFIG_BOOT_IMGTOOL_SIGN_VERSION 都行。
CONFIG_MCUBOOT_LOG_LEVEL_WRN 把 bootloader 日志压到 WRN 级别。bootloader 区只有 64KB,INFO 日志会把 flash 撑爆,WRN 足够诊断启动失败问题。
七、boot_fetch_active_slot() 判断运行槽位
应用层需要知道当前跑在 slot0 还是 slot1,用于上报遥测。dr154_telemetry.c 的做法:
/* 获取 MCUboot 当前运行槽位索引(0=slot0/image-0, 1=slot1/image-1)。
* Zephyr 4.4 已移除 boot_current_slot 全局变量定义(img_mgmt.h 仅留 extern 声明),
* 改用 boot_fetch_active_slot() 返回活动 slot 的 flash area id,
* 再与 slot0/slot1 分区 id 比较判断运行槽位。swap 模式下应用始终从 slot0 运行。
*/
static int dr154_boot_slot_index(void)
{
uint8_t active_id = boot_fetch_active_slot();
if (active_id == DT_FIXED_PARTITION_ID(DT_NODELABEL(slot0_partition))) {
return 0;
}
return 1;
}
这里踩过一个坑。原来用的是 boot_current_slot 全局变量,Zephyr 4.4 把它的定义从 img_mgmt 模块里移除了,只剩 extern 声明在头文件里,链接时找不到符号。官方推荐 boot_fetch_active_slot(),它返回当前槽位的 flash_area id,应用拿它和分区表里 slot0_partition、slot1_partition 的 id 比较。
swap 模式下应用其实始终从 slot0 启动,但 slot0 里到底是新固件还是旧固件取决于是否被确认。这个函数实际意义是把 slot0/slot1 在 MCUboot 视角下的”A/B”概念映射到业务侧的”A/B”字符串——遥测帧里要给云端一个 running_slot 字段。
running_slot_name = DR154_SlotName((dr154_boot_slot_index() == 0) ? DR154_SLOT_A : DR154_SLOT_B);
八、MCUmgr 镜像管理配置
MCUmgr 是 Zephyr 的设备管理协议,承载镜像上传、状态查询、复位等命令。prj.conf 里的关键配置:
# Flash 访问
CONFIG_FLASH=y
CONFIG_FLASH_MAP=y
CONFIG_FLASH_PAGE_LAYOUT=y
CONFIG_STREAM_FLASH=y # 流式写入,IMG_UPLOAD 需要
# MCUmgr 核心
CONFIG_NET_BUF=y
CONFIG_ZCBOR=y
CONFIG_BASE64=y
CONFIG_CRC=y
CONFIG_MCUMGR=y
# 镜像管理命令组
CONFIG_IMG_MANAGER=y
CONFIG_MCUBOOT_IMG_MANAGER=y
CONFIG_IMG_ERASE_PROGRESSIVELY=y
CONFIG_MCUMGR_GRP_IMG=y # IMG_UPLOAD / IMAGE_STATE
CONFIG_MCUMGR_GRP_OS=y # RESET / ECHO
# OTA 状态钩子
CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y
CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y
# 允许擦除 pending slot,避免 NO_FREE_SLOT 错误
CONFIG_MCUMGR_GRP_IMG_ALLOW_ERASE_PENDING=y
CONFIG_IMG_ERASE_PROGRESSIVELY=y 让上传过程边写边擦,省一次预擦除流程,对 STM32F1 这种 flash 慢的设备很关键。
CONFIG_MCUMGR_GRP_IMG_ALLOW_ERASE_PENDING 避免 NO_FREE_SLOT
这个配置项解决一个具体场景:slot1 里残留一个”pending”镜像(MCUboot 已经标记下次启动切换),但用户想重新刷一个新固件。默认情况下 IMG_ERASE 命令会拒绝擦 pending slot,返回 NO_FREE_SLOT(错误码 9)。开启这个配置后允许擦除,避免用户必须重启一次才能继续上传。
CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE=640 适配 MTU=512
CONFIG_MCUMGR_TRANSPORT_SHELL_MTU=512
CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE=640
MCUmgr 接收缓冲区必须 ≥ MTU + 2,否则 smp_packet_alloc() 分配的 net_buf 装不下完整 SMP 帧(header + CBOR payload)。默认 384 字节撑不住 512 MTU,强行用会丢字节。设为 640 留出 CBOR header 和 base64 编码膨胀的余量。
STM32F1 的 RS485 透传链路 MTU 设 512 是一个权衡:太小则单包数据少、握手开销大;太大则一帧要跨越多个 RS485 字符时间,DR154 DTU 透明传输容易截断。
九、OTA 状态钩子
CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y 开启镜像管理命令组的状态回调,应用可以挂钩到几个关键事件:
DFU_STARTED:IMG_UPLOAD 开始,新固件第一次写入;CHUNK_WRITE_COMPLETE:每个 chunk 写入 flash 完成;DFU_PENDING:镜像写完,等待确认;DFU_STOPPED:上传中断或失败。
Mill 工程在 DR154_ReportOtaEvent() 里把这些事件转成 JSON 上报给云端,云端据此驱动会话状态机:
void DR154_ReportOtaEvent(const char *phase,
const char *result,
uint16_t error_code,
const char *message)
{
/* 上报字段:session_id / session_key / phase / next_offset /
* written_bytes / total_bytes / progress / slot 等 */
/* ... */
}
progress 字段由 next_offset / image_size 算出,云端拿到进度后可以决定断点续传的起始 offset,避免每次都从头开始。
钩子和 boot_write_img_confirmed() 是配合关系:钩子在 DFU_PENDING 阶段通知云端”固件已写完,等待重启切换”;应用重启后 boot_write_img_confirmed() 完成确认,下一次遥测帧里 running_slot 字段更新成新槽位,云端据此判断 OTA 真正落地。
十、配置项速查表
prj.conf 里 MCUboot / MCUmgr 相关配置项汇总:
| 配置项 | 值 | 作用 |
|---|---|---|
CONFIG_BOOTLOADER_MCUBOOT | y | 启用 MCUboot 支持 |
CONFIG_FLASH | y | Flash 驱动 |
CONFIG_FLASH_MAP | y | Flash 分区表 |
CONFIG_FLASH_PAGE_LAYOUT | y | Flash 页布局查询 |
CONFIG_STREAM_FLASH | y | 流式写入(IMG_UPLOAD 需要) |
CONFIG_MCUMGR | y | MCUmgr 核心 |
CONFIG_IMG_MANAGER | y | 镜像管理 |
CONFIG_MCUBOOT_IMG_MANAGER | y | MCUboot 镜像管理后端 |
CONFIG_IMG_ERASE_PROGRESSIVELY | y | 边写边擦 |
CONFIG_MCUMGR_GRP_IMG | y | IMG_UPLOAD / IMAGE_STATE 命令组 |
CONFIG_MCUMGR_GRP_OS | y | RESET / ECHO 命令组 |
CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS | y | 管理通知钩子 |
CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS | y | 镜像状态钩子(DFU_STARTED 等) |
CONFIG_MCUMGR_GRP_IMG_ALLOW_ERASE_PENDING | y | 允许擦除 pending slot,避免 NO_FREE_SLOT |
CONFIG_MCUMGR_TRANSPORT_SHELL | y | Shell 通道传输 |
CONFIG_MCUMGR_TRANSPORT_SHELL_MTU | 512 | Shell 通道 MTU |
CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE | 640 | 接收缓冲区,≥ MTU + 2 |
CONFIG_MCUMGR_GRP_IMG_LOG_LEVEL_INF | y | 镜像命令 INFO 日志 |
CONFIG_MCUMGR_GRP_OS_LOG_LEVEL_INF | y | OS 命令 INFO 日志 |
CONFIG_NVS | y | NVS 存储(镜像确认状态 + 配置) |
CONFIG_SETTINGS | y | settings 子系统 |
sysbuild.conf 与 mcuboot.conf 汇总:
| 配置项 | 值 | 作用 |
|---|---|---|
SB_CONFIG_BOOTLOADER_MCUBOOT | y | 启用 MCUboot bootloader |
SB_CONFIG_MCUBOOT_MODE_SWAP_SCRATCH | y | swap + scratch 模式(支持回滚) |
SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256 | y | ECDSA-P256-SHA256 签名 |
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE | 路径 | 签名私钥(不入 Git) |
CONFIG_MCUBOOT_LOG_LEVEL_WRN | y | bootloader 日志 WRN 级,省 flash |
CONFIG_MCUBOOT_DOWNGRADE_PREVENTION | y | 防降级,拒绝低版本镜像 |
十一、串起来看整个 OTA 流程
把上面这些机制串起来,一次完整的 OTA 是这样的:
- 云端通过 DR154 DTU 下发固件分片,MCUmgr
IMG_UPLOAD把数据流式写入 slot1; CHUNK_WRITE_COMPLETE钩子触发进度上报,云端做断点续传;- 上传完毕触发
DFU_PENDING,云端发IMAGE_STATE设置test状态; - 云端发
RESET,MCUboot 启动时检测 slot1 有 pending 镜像,做 swap; - swap 完成后 slot0 里是新固件,应用启动;
main()第一时间调用boot_write_img_confirmed()写确认标志;- 业务正常初始化,下一次遥测帧的
running_slot反映新槽位; - 云端收到
running_slot变化,OTA 标记完成。
任何一步出错——比如新固件启动崩溃没机会确认——下次重启 MCUboot 自动回滚到旧固件,云端看到 running_slot 没变就知道这次升级失败了。这套机制不需要云端做复杂判断,全靠 bootloader 和应用配合完成。
小结
MCUboot 把 OTA 的安全性、可靠性、可回滚性封装到了 bootloader 层,应用代码只需要做两件事:确认新固件、上报当前槽位。剩下的签名验证、slot 交换、回滚判断都是 bootloader 自动完成。Zephyr sysbuild 又把构建链打通——一条命令出 bootloader + 应用签名镜像,imgtool 自动签名,密钥不进仓库。整个方案对资源敏感的 STM32F1 来说不算重,64KB bootloader + 220KB 应用 + 4KB scratch 的开销完全可接受。
真正需要小心的是几个细节:缓冲区大小匹配 MTU、pending slot 擦除权限、bootloader 日志级别、Zephyr 4.4 API 变更。这些都是踩过坑才会注意到的点,配错一个就掉链子。