3. 升级包生成与签名:差分升级算法、全量升级包制作、数字签名与验签、升级包版本管理

升级包怎么来的?怎么保证它没被篡改?版本号怎么管?

这三个问题,是OTA项目里最容易被轻视、但出事最要命的环节。我见过不止一个团队,差分算法跑通了就欢呼,结果签名没做好,设备被刷了恶意固件——嗯,那场面相当惨烈。

3.1 全量升级包制作

全量包,说白了就是把整个固件镜像打包。简单,但最可靠。

制作流程大致分四步:

  1. 编译输出:从构建系统拿到最终的二进制镜像(比如 firmware.bin)。
  2. 添加元数据:在头部或尾部写入设备型号、硬件版本、固件版本、校验和等信息。
  3. 压缩:用 LZMA、gzip 或 zstd 压缩,减少传输体积。
  4. 打包:生成最终的分发文件,通常带 .swu 或 .ota 后缀。

关键点:元数据必须包含 hardware_id 和 compat_level。否则刷错硬件变砖,你连哭的地方都没有。

我曾经接手过一个项目,全量包只写了版本号,没写硬件版本。结果同一套固件被刷到了两个不同硬件批次上,WiFi 模块直接烧了。从那以后,我要求元数据里必须有三项:product、hw_rev、sw_ver。

3.2 差分升级算法

全量包省心,但费流量。差分升级就是为了解决这个问题——只传输新旧固件之间的差异部分。

目前主流的差分算法有三种:

算法 原理 适用场景 补丁大小
bsdiff 基于后缀排序的 LCS 匹配 二进制固件、大文件 极小(通常 5%-15%)
hdiffpatch 基于哈希的块匹配 嵌入式固件、内存受限设备 较小(10%-25%)
xdelta3 VCDIFF 标准实现 通用文件、配置更新 中等(15%-30%)

我个人习惯用 bsdiff。为什么?因为它生成的补丁最小。在 NB-IoT 这种按字节收费的网络上,每省 1KB 都是钱。但 bsdiff 有个缺点——内存占用高。生成补丁时,新旧固件都要加载到内存,32MB 以下的 MCU 基本跑不动。

避坑指南:如果你的设备 RAM 小于 64KB,别用 bsdiff。改用 hdiffpatch,它支持流式处理,内存占用能控制在 8KB 以内。

差分升级的完整流程是这样的:

# 服务端:生成差分补丁
bsdiff old_firmware.bin new_firmware.bin patch.bin

# 设备端:应用差分补丁
bspatch old_firmware.bin new_firmware.bin patch.bin

设备端拿到 patch.bin 后,读取当前固件,逐块应用补丁,生成新固件。整个过程不需要额外存储完整的新固件——这也是差分升级的核心优势。

注意:差分升级依赖旧固件的精确版本。如果设备当前版本和生成补丁时用的版本不一致,补丁会应用失败。所以,版本管理必须严格。

3.3 数字签名与验签

升级包在传输过程中可能被篡改。怎么防?数字签名。

我常用的方案是 ECDSA(椭圆曲线数字签名算法)。相比 RSA,ECDSA 的签名更短、验签更快,特别适合资源受限的嵌入式设备。

签名流程:

  1. 服务端用私钥对升级包的哈希值签名,生成签名文件。
  2. 将签名文件和升级包一起下发。
  3. 设备端用公钥验签:计算升级包的哈希,用公钥解密签名,比对两者是否一致。

核心原则:私钥永远留在服务端,公钥烧录在设备 Bootloader 中。设备端绝对不能存储私钥。

代码示例(使用 OpenSSL 和 mbedTLS):

// 服务端签名(OpenSSL)
openssl dgst -sha256 -sign private.pem -out signature.bin firmware.bin

// 设备端验签(mbedTLS)
int verify_signature(const unsigned char *firmware, size_t fw_len,
                     const unsigned char *signature, size_t sig_len) {
    mbedtls_pk_context pk;
    mbedtls_pk_init(&pk);
    mbedtls_pk_parse_public_key(&pk, public_key, pub_key_len);

    unsigned char hash[32];
    mbedtls_sha256(firmware, fw_len, hash, 0);

    int ret = mbedtls_pk_verify(&pk, MBEDTLS_MD_SHA256,
                                hash, 0, signature, sig_len);
    mbedtls_pk_free(&pk);
    return ret;
}

我曾经遇到过一个坑:公钥烧录时字节序搞反了,验签一直失败。排查了两天才发现是 大端/小端 的问题。嗯,嵌入式开发里这种低级错误最折磨人。

3.4 升级包版本管理

版本管理做不好,升级就是灾难。我见过一个团队,版本号用 v1.0、v1.1 这种字符串,结果设备升级时无法判断哪个版本更新——因为字符串比较 "v1.9" > "v1.10" 是错的。

我建议用 语义化版本号,格式为 MAJOR.MINOR.PATCH:

  • MAJOR:不兼容的 API 或硬件变更
  • MINOR:向下兼容的功能新增
  • PATCH:向下兼容的 bug 修复

版本比较逻辑:

typedef struct {
    uint16_t major;
    uint16_t minor;
    uint16_t patch;
} version_t;

int version_compare(version_t a, version_t b) {
    if (a.major != b.major) return a.major - b.major;
    if (a.minor != b.minor) return a.minor - b.minor;
    return a.patch - b.patch;
}

除了版本号,还要维护一个 兼容性矩阵:

当前版本 可升级到的版本 升级方式
v1.0.x v1.1.x, v1.2.x 差分
v1.1.x v1.2.x 差分
v1.0.x v2.0.x 全量
v1.2.x v2.0.x 全量

个人经验:版本号最好用 uint32_t 编码,高 8 位 MAJOR、中 8 位 MINOR、低 16 位 PATCH。这样比较时直接做整数减法,省掉结构体比较的开销。

3.5 整体流程与架构

把上面几块串起来,就是完整的升级包生成与签名流程。我画了一张图,帮你理清关系:

升级包生成与签名流程 旧固件 (old.bin) 新固件 (new.bin) bsdiff 差分算法 差分补丁 (patch.bin) ECDSA 数字签名 打包 + 版本元数据 升级包 (update.swu) 私钥仅存服务端 公钥烧录在 Bootloader

从图上你能看到:旧固件和新固件经过差分算法生成补丁,补丁经过签名和版本元数据打包,最终输出可下发的升级包。设备端拿到包后,先验签、再解包、最后应用补丁。

最后提醒一句:差分升级虽然省流量,但容错性差。如果设备在应用补丁时断电,旧固件可能被破坏。所以,一定要保留一个 恢复分区 或 双备份分区,确保升级失败后还能回滚。

嗯,升级包生成这块,核心就是三件事:差分算法选对、签名验签做牢、版本号管好。这三件事做好了,OTA 升级就成功了一大半。