第4章 镜像管理:镜像格式定义、镜像头结构、镜像版本管理策略

好,咱们进入第4章。镜像管理,说白了就是给固件打个包,再贴个标签。你想想看,OTA升级的核心是什么?是让设备知道“该升级哪个文件”、“这个文件对不对”、“升级完是不是最新版”。这些信息,都得靠镜像管理来承载。

我在做第一个OTA项目时,就吃过亏。当时图省事,直接把二进制固件扔上去,结果设备升级后版本号乱套,有的升了有的没升,现场运维差点崩溃。从那以后,我对镜像格式和版本管理就特别较真。

4.1 镜像格式定义

Zephyr的镜像格式,其实不复杂。它遵循一个核心原则:自描述。什么意思?就是镜像文件自己带着“身份证”,里面写了它是谁、从哪来、要到哪去。

常见的镜像格式有两种:

  • 原始二进制格式(Raw Binary):就是纯固件数据,没有头信息。简单,但没法做校验和版本管理。
  • MCUboot格式:Zephyr官方推荐的格式。在固件前面加了一个镜像头,里面包含版本、大小、校验值等关键信息。

我个人习惯用MCUboot格式。为什么?因为它和Zephyr的OTA框架是深度集成的。你想想看,如果自己搞一套格式,还得写解析器、校验器,多麻烦。直接用现成的,省心。

核心要点:镜像格式的选择,决定了后续升级流程的复杂度。MCUboot格式是Zephyr生态下的最佳实践。

4.2 镜像头结构

镜像头,就是固件的“身份证”。咱们来看看它长什么样。

MCUboot的镜像头结构,我直接贴代码:

struct image_header {
    uint32_t ih_magic;         // 魔数,用于识别镜像格式
    uint32_t ih_load_addr;     // 加载地址
    uint16_t ih_hdr_size;      // 镜像头大小
    uint16_t ih_protect_tlv_size; // TLV区域大小
    uint32_t ih_img_size;      // 镜像数据大小
    uint32_t ih_flags;         // 标志位
    struct image_version ih_ver; // 版本号
    uint32_t _pad1;            // 填充
};

这里有几个关键字段,我重点说一下:

  • ih_magic:魔数。我习惯用0x96F3B83D,这是MCUboot的标准值。如果魔数不对,引导程序直接拒绝加载。
  • ih_hdr_size:头大小。这个很重要,因为镜像头后面可能跟着TLV(类型-长度-值)区域,用来存放签名、哈希等额外信息。
  • ih_img_size:镜像数据大小。注意,这个大小不包括镜像头本身。
  • ih_ver:版本号。结构体如下:
struct image_version {
    uint8_t iv_major;   // 主版本号
    uint8_t iv_minor;   // 次版本号
    uint16_t iv_revision; // 修订号
    uint32_t iv_build_num; // 构建号
};

嗯,这里要注意。版本号是4个字段,但实际使用时,很多项目只用前三个。我建议把构建号也利用起来,特别是做CI/CD时,每次构建自动递增构建号,方便追踪。

小技巧:在编译时,可以通过CMake的CONFIG_MCUBOOT_IMAGE_VERSION来指定版本号。比如:set(CONFIG_MCUBOOT_IMAGE_VERSION "1.2.3.456")。这样每次构建,版本号就自动嵌进镜像头里了。

4.3 镜像版本管理策略

版本管理,是OTA升级中最容易出问题的环节。我曾经见过一个项目,版本号用“v1.0”、“v1.1”这种字符串,结果升级时比较版本号,字符串比较“v1.10”比“v1.9”小,导致升级逻辑混乱。

所以,版本管理一定要有策略。我总结了几条经验:

  1. 语义化版本:主版本号.次版本号.修订号。主版本号不兼容时递增,次版本号新增功能时递增,修订号修复bug时递增。
  2. 构建号独立:每次构建,构建号必须递增。这样即使版本号没变,也能区分不同构建。
  3. 版本比较逻辑:不要用字符串比较。一定要按字段逐个比较:先比主版本,再比次版本,再比修订号,最后比构建号。

在Zephyr中,版本比较的代码大概是这样:

int image_version_cmp(const struct image_version *a,
                      const struct image_version *b)
{
    if (a->iv_major != b->iv_major)
        return a->iv_major - b->iv_major;
    if (a->iv_minor != b->iv_minor)
        return a->iv_minor - b->iv_minor;
    if (a->iv_revision != b->iv_revision)
        return a->iv_revision - b->iv_revision;
    return a->iv_build_num - b->iv_build_num;
}

你看,逻辑很清晰。先比大的,再比小的。这样就不会出现“v1.10”比“v1.9”小这种乌龙了。

避坑指南:我曾经遇到过一个问题——设备升级后,版本号没变,但固件确实更新了。后来发现,是构建号没递增。所以,我建议在CI脚本里强制检查:如果代码有变更,构建号必须递增。否则,直接拒绝构建。

4.4 镜像签名与校验

镜像管理还有一个重要环节——签名与校验。说白了,就是防止有人篡改固件。

MCUboot支持两种签名方式:

  • RSA签名:非对称加密,公钥放在设备里,私钥在服务器端。设备用公钥验证签名。
  • ECDS签名:椭圆曲线签名,比RSA更高效,适合资源受限的IoT设备。

我个人推荐用ECDSA。为什么?因为IoT设备的CPU通常比较弱,RSA验签太慢。ECDSA在同样安全强度下,计算量小很多。

签名后的镜像,结构会变成这样:

+------------------+
|   镜像头          |
+------------------+
|   固件数据        |
+------------------+
|   TLV区域         |
|   - 签名TLV       |
|   - 哈希TLV       |
+------------------+

TLV区域是MCUboot的扩展机制。签名和哈希都放在这里。设备在启动时,先校验哈希,再校验签名。两步都通过,才允许加载。

核心要点:镜像签名不是可选项,而是必选项。特别是做OTA升级时,固件通过网络传输,很容易被中间人攻击。签名就是最后一道防线。

4.5 实践建议

好了,理论讲完了。我结合自己的项目经验,给你几条实践建议:

  • 版本号写在代码里:我习惯在prj.conf里定义版本号,然后通过CMake传给镜像头。这样版本号和代码是绑定的,不会出现版本号对不上代码的情况。
  • 镜像头预留扩展字段:虽然MCUboot的镜像头是固定的,但TLV区域可以自定义。我建议预留几个TLV类型,比如设备类型、硬件版本等。这样在升级时,可以检查镜像是否兼容当前硬件。
  • 版本回退策略:OTA升级不是只能升不能降。有时候新版本有bug,需要回退。我建议在版本管理中加入“回退标记”,允许设备在特定条件下降级。

嗯,镜像管理这块,内容其实不少。但核心就三点:格式要标准、头信息要完整、版本要可追溯。把这三点做好了,OTA升级就成功了一半。

下一章,咱们聊聊升级流程设计。到时候我会结合一个实际案例,把整个升级过程串起来讲。到时候你就知道,镜像管理是怎么在升级流程中发挥作用的了。