3. MCUboot基础:工作原理、镜像槽位管理与签名验证

好,咱们进入第三章。这一章我打算聊聊MCUboot——Zephyr OTA方案里最核心的组件。说实话,我第一次接触MCUboot的时候,觉得这东西挺绕的。但用熟了以后,你会发现它的设计其实非常巧妙。

3.1 MCUboot的工作原理

MCUboot是什么?说白了,它是一个专门为MCU设计的bootloader。它的任务就一个:决定启动哪个固件镜像。

你可能会问:「单片机启动不都是直接跳转到0x08000000吗?要bootloader干嘛?」

嗯,这里有个关键点。OTA升级的时候,你总不能一边跑着旧程序一边擦写Flash吧?万一写到一半断电了,设备就变砖了。MCUboot就是为了解决这个问题而生的。

它的工作流程大致是这样的:

  1. 上电启动:MCU复位后,首先执行MCUboot代码
  2. 检查镜像:MCUboot检查主槽位和备用槽位里的镜像
  3. 验证签名:用公钥验证镜像的数字签名
  4. 决定启动:根据验证结果和状态标志,决定启动哪个镜像
  5. 跳转执行:跳转到选中的镜像入口地址

核心思想:MCUboot本身不参与OTA下载过程,它只负责启动时的镜像选择和验证。OTA下载通常由应用程序自己完成。

我在一个智能家居项目里就吃过亏。当时以为MCUboot能帮忙管理下载过程,结果发现它只管启动。后来老老实实在应用层写了下载逻辑,配合MCUboot做验证,这才跑通。

3.2 镜像槽位管理

MCUboot使用「槽位」来管理固件镜像。最常见的配置是两个槽位:

槽位名称 作用 典型地址(STM32F4为例)
主槽位(Slot 0) 存放当前运行的固件 0x08020000
备用槽位(Slot 1) 存放下载的新固件 0x08040000
暂存区(Scratch) 用于交换操作时的临时存储 0x08060000

你想想看,为什么需要两个槽位?

因为「原子性」。OTA下载新固件到备用槽位时,主槽位的固件还在正常运行。即使下载过程中断电,重启后MCUboot发现备用槽位镜像不完整,还是会启动主槽位的旧固件。设备不会变砖。

MCUboot支持几种槽位管理策略:

  • 覆盖模式(Overwrite):新固件直接覆盖旧固件。简单粗暴,但升级失败就回不去了。
  • 交换模式(Swap):主槽位和备用槽位的内容互换。升级失败可以回滚,但需要暂存区。
  • 直接运行模式(Run-in-place):新固件直接在备用槽位运行。省去交换时间,但需要链接脚本支持。

我的建议:产品初期用交换模式,稳妥。等OTA流程稳定了,再考虑切换到覆盖模式节省Flash空间。我曾经在一个量产项目里直接用覆盖模式,结果有一次网络传输丢包导致固件损坏,几百台设备需要返厂...从那以后,我至少会在开发阶段保留回滚能力。

3.3 签名与验证机制

签名验证是MCUboot的安全基石。没有签名验证,攻击者可以伪造固件,你的设备就成别人的了。

MCUboot支持多种签名算法:

  • RSA-2048/3072:经典算法,兼容性好,但签名验证较慢
  • ECDSA-P256:椭圆曲线算法,签名短,验证快,我比较推荐
  • Ed25519:现代算法,性能优秀,但工具链支持稍弱

签名验证的流程是这样的:

  1. 生成密钥对:开发阶段用imgtool生成私钥和公钥
  2. 签名固件:用私钥对固件镜像进行签名
  3. 烧录公钥:将公钥编译进MCUboot中
  4. 验证签名:启动时,MCUboot用公钥验证镜像签名

这里有个细节——签名不是对整个固件文件签名,而是对固件的哈希值签名。这样既保证了完整性,又提高了效率。

来看一个实际的签名命令示例:

# 使用imgtool对固件进行签名
imgtool sign \
  --key my_private_key.pem \
  --align 8 \
  --version 1.2.3 \
  --slot-size 0x200000 \
  --header-size 0x200 \
  build/zephyr/zephyr.bin \
  signed_firmware.bin

注意:私钥一定要妥善保管!我见过有团队把私钥直接提交到Git仓库里,结果被外部人员拿到,伪造了固件签名。建议使用硬件安全模块(HSM)或密钥管理服务来保护私钥。

3.4 镜像头格式

MCUboot的镜像有一个特殊的头部,里面包含了元数据。这个头部结构是这样的:

struct image_header {
    uint32_t magic;          // 魔数,用于识别MCUboot镜像
    uint32_t load_addr;      // 加载地址
    uint16_t header_size;    // 头部大小
    uint16_t image_size;     // 镜像大小
    uint8_t  version[8];     // 版本号
    uint32_t flags;          // 标志位
    uint8_t  pad[8];         // 填充
    uint32_t tlv_size;       // TLV区域大小
};

头部后面跟着TLV(Type-Length-Value)区域,里面存放签名、哈希值、密钥ID等信息。这种设计很灵活——你可以根据需要添加新的TLV类型,而不需要修改头部结构。

我记得第一次调试MCUboot时,镜像总是验证失败。后来发现是头部大小没对齐——MCUboot要求头部大小必须是16字节的倍数。这种小坑,文档里写得清楚,但你不踩一次真的记不住。

3.5 实际项目中的配置要点

在Zephyr中配置MCUboot,主要关注这几个地方:

  • Flash分区表:在.dts文件中定义槽位地址和大小
  • Kconfig选项:使能MCUboot并配置签名算法
  • 密钥文件:将公钥以C数组形式编译进MCUboot

一个典型的Kconfig配置片段:

# 使能MCUboot
CONFIG_BOOTLOADER_MCUBOOT=y

# 选择签名算法
CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y

# 使能镜像验证
CONFIG_BOOT_VALIDATE_SLOT0=y
CONFIG_BOOT_VALIDATE_SLOT1=y

# 使能交换模式
CONFIG_BOOT_SWAP_USING_MOVE=y

避坑指南:我曾经把CONFIG_BOOT_VALIDATE_SLOT0关掉了,想着「主槽位的固件肯定没问题」。结果有一次调试时不小心烧录了未签名的固件到主槽位,MCUboot直接跳过了验证...嗯,从那以后我再也不敢偷懒了。

3.6 小结

MCUboot的核心就三件事:选槽位、验签名、跳转执行。听起来简单,但每个环节都有不少细节。

我个人觉得,理解MCUboot最好的方式就是动手试。先在一个开发板上配好两个槽位,手动下载固件到备用槽位,观察MCUboot的启动日志。看到「Image 0 is valid, booting to it」这样的信息,你就知道整个链路通了。

下一章我们会聊OTA的完整流程——从固件生成、签名、上传到设备下载、验证、切换。到时候你会看到MCUboot在整个流程中扮演的角色有多关键。