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 */
        };
    };
};

汇总成表:

分区偏移大小用途
mcuboot0x0000000064KBbootloader 本体(只读)
image-00x00010000220KBslot0,应用运行槽
image-10x00047000220KBslot1,升级暂存槽
scratch0x0007E0004KBMCUboot swap 中转区(2 page)
storage0x0007F0004KBNVS/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.magicboot_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 当前镜像的版本:候选版本低于当前版本直接拒绝启动。这能挡住两类场景:

  1. 云端误下发旧固件包;
  2. 攻击者拿一个已修复漏洞前的签名固件刷进去。

注意版本号必须在签名时正确填写,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_partitionslot1_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_MCUBOOTy启用 MCUboot 支持
CONFIG_FLASHyFlash 驱动
CONFIG_FLASH_MAPyFlash 分区表
CONFIG_FLASH_PAGE_LAYOUTyFlash 页布局查询
CONFIG_STREAM_FLASHy流式写入(IMG_UPLOAD 需要)
CONFIG_MCUMGRyMCUmgr 核心
CONFIG_IMG_MANAGERy镜像管理
CONFIG_MCUBOOT_IMG_MANAGERyMCUboot 镜像管理后端
CONFIG_IMG_ERASE_PROGRESSIVELYy边写边擦
CONFIG_MCUMGR_GRP_IMGyIMG_UPLOAD / IMAGE_STATE 命令组
CONFIG_MCUMGR_GRP_OSyRESET / ECHO 命令组
CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKSy管理通知钩子
CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKSy镜像状态钩子(DFU_STARTED 等)
CONFIG_MCUMGR_GRP_IMG_ALLOW_ERASE_PENDINGy允许擦除 pending slot,避免 NO_FREE_SLOT
CONFIG_MCUMGR_TRANSPORT_SHELLyShell 通道传输
CONFIG_MCUMGR_TRANSPORT_SHELL_MTU512Shell 通道 MTU
CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE640接收缓冲区,≥ MTU + 2
CONFIG_MCUMGR_GRP_IMG_LOG_LEVEL_INFy镜像命令 INFO 日志
CONFIG_MCUMGR_GRP_OS_LOG_LEVEL_INFyOS 命令 INFO 日志
CONFIG_NVSyNVS 存储(镜像确认状态 + 配置)
CONFIG_SETTINGSysettings 子系统

sysbuild.confmcuboot.conf 汇总:

配置项作用
SB_CONFIG_BOOTLOADER_MCUBOOTy启用 MCUboot bootloader
SB_CONFIG_MCUBOOT_MODE_SWAP_SCRATCHyswap + scratch 模式(支持回滚)
SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256yECDSA-P256-SHA256 签名
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE路径签名私钥(不入 Git)
CONFIG_MCUBOOT_LOG_LEVEL_WRNybootloader 日志 WRN 级,省 flash
CONFIG_MCUBOOT_DOWNGRADE_PREVENTIONy防降级,拒绝低版本镜像

十一、串起来看整个 OTA 流程

把上面这些机制串起来,一次完整的 OTA 是这样的:

  1. 云端通过 DR154 DTU 下发固件分片,MCUmgr IMG_UPLOAD 把数据流式写入 slot1;
  2. CHUNK_WRITE_COMPLETE 钩子触发进度上报,云端做断点续传;
  3. 上传完毕触发 DFU_PENDING,云端发 IMAGE_STATE 设置 test 状态;
  4. 云端发 RESET,MCUboot 启动时检测 slot1 有 pending 镜像,做 swap;
  5. swap 完成后 slot0 里是新固件,应用启动;
  6. main() 第一时间调用 boot_write_img_confirmed() 写确认标志;
  7. 业务正常初始化,下一次遥测帧的 running_slot 反映新槽位;
  8. 云端收到 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 变更。这些都是踩过坑才会注意到的点,配错一个就掉链子。