3. MCUboot基础:工作原理、镜像槽位管理与签名验证
好,咱们进入第三章。这一章我打算聊聊MCUboot——Zephyr OTA方案里最核心的组件。说实话,我第一次接触MCUboot的时候,觉得这东西挺绕的。但用熟了以后,你会发现它的设计其实非常巧妙。
3.1 MCUboot的工作原理
MCUboot是什么?说白了,它是一个专门为MCU设计的bootloader。它的任务就一个:决定启动哪个固件镜像。
你可能会问:「单片机启动不都是直接跳转到0x08000000吗?要bootloader干嘛?」
嗯,这里有个关键点。OTA升级的时候,你总不能一边跑着旧程序一边擦写Flash吧?万一写到一半断电了,设备就变砖了。MCUboot就是为了解决这个问题而生的。
它的工作流程大致是这样的:
- 上电启动:MCU复位后,首先执行MCUboot代码
- 检查镜像:MCUboot检查主槽位和备用槽位里的镜像
- 验证签名:用公钥验证镜像的数字签名
- 决定启动:根据验证结果和状态标志,决定启动哪个镜像
- 跳转执行:跳转到选中的镜像入口地址
核心思想: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:现代算法,性能优秀,但工具链支持稍弱
签名验证的流程是这样的:
- 生成密钥对:开发阶段用
imgtool生成私钥和公钥 - 签名固件:用私钥对固件镜像进行签名
- 烧录公钥:将公钥编译进MCUboot中
- 验证签名:启动时,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在整个流程中扮演的角色有多关键。