升级包制作与签名:全量包与差分包生成、数字签名与验签流程、升级包版本管理规范

升级包怎么做?说白了就两种路子:全量包和差分包。我刚开始带项目时,总觉得差分包高大上,恨不得每次都打差分。结果有一次差分算法选错了,升级包倒是小,设备升级后直接变砖……嗯,从那以后我学乖了——场景决定方案。

全量包:简单粗暴,但最稳

全量包就是把整个固件镜像打包成一个升级文件。设备拿到后,直接擦除旧分区,写入新数据。好处是啥?逻辑简单,容错率高。哪怕升级过程中断电,重启后还能进恢复模式重试。

我个人的习惯是:首次量产、重大版本升级、或者设备存储空间充裕时,优先用全量包。别为了省那几百KB的流量,把稳定性搭进去。

全量包适用场景:
  • 设备首次出厂或恢复出厂后的首次升级
  • 跨大版本升级(如 v1.x → v2.x)
  • 文件系统结构发生重大变化
  • 设备存储空间充足,网络带宽不是瓶颈

差分包:省流量,但坑多

差分包只包含新旧版本之间的差异数据。算法上主流的有 bsdiff、hdiffpatch、xdelta 等。我踩过最大的坑是 bsdiff 在低端 MCU 上跑不动——内存不够,直接 OOM。

后来我改用 hdiffpatch,它对嵌入式更友好。但要注意:差分算法对二进制文件的布局敏感。编译器版本变了、链接顺序变了,哪怕源码没改,生成的 bin 也可能差异巨大,差分包反而比全量包还大。

我的经验:差分包生成后,一定要对比一下大小。如果差分包超过全量包的 70%,干脆用全量包。别为了“差分”而差分。

数字签名与验签:防篡改的最后一道防线

升级包如果不签名,就等于把设备的安全拱手让人。我见过一个案例:某厂商的升级包没签名,黑客伪造了一个恶意固件,通过中间人攻击推送下去,整批设备全部被控。惨痛教训。

签名流程其实不复杂:

  1. 生成密钥对:私钥留在服务器,公钥烧录到设备 Bootloader 中
  2. 计算哈希:对升级包做 SHA-256 或 SHA-512
  3. 签名:用私钥对哈希值做 RSA 或 ECDSA 签名
  4. 打包:将签名值附加到升级包头部

验签流程在设备端:

  1. 设备收到升级包,提取签名值
  2. 用公钥解密签名,得到原始哈希
  3. 对升级包重新计算哈希
  4. 比对两个哈希值,一致则通过
注意:公钥一定要放在 Bootloader 的只读区域,不能放在可擦写的应用分区。否则攻击者替换了公钥,签名就形同虚设。

升级包版本管理规范

版本号乱写,运维起来就是灾难。我接手过一个项目,版本号是“v1.0_final_真的最终版_不改了.bin”……你想想看,这种命名怎么自动化?

我建议采用 语义化版本 + 内部构建号 的双层结构:

字段 示例 说明
主版本号 2 不兼容的 API 或重大功能变更
次版本号 3 向下兼容的功能新增
修订号 1 Bug 修复或小优化
构建号 20250315 CI 自动生成,精确到日期

完整的版本号示例:v2.3.1_build20250315。这个格式既能让人看懂,也方便脚本做大小版本比较。

避坑指南:版本号比较不要用字符串字典序。比如 "v1.10.0" 会被认为小于 "v1.9.0",因为 '1' < '9'。一定要按段解析成整数再比较。

升级包结构设计

一个标准的升级包,我习惯这样组织:

ota_package_v2.3.1_build20250315.bin
├── 包头 (Header)
│   ├── 魔数 (Magic Number)      // 0xOTA1
│   ├── 版本号                   // v2.3.1
│   ├── 包类型                   // 0x01 全量 / 0x02 差分
│   ├── 目标设备型号              // HW-03
│   ├── 包体长度                  // 4 字节
│   ├── 包体哈希 (SHA-256)       // 32 字节
│   └── 签名值 (RSA-2048)        // 256 字节
├── 包体 (Payload)
│   ├── 固件镜像                  // 实际升级数据
│   └── 元数据                    // 分区表、校验和等
└── 尾部 (Footer)
    └── 包尾标记                  // 0xOTA_END

这个结构的好处是:设备可以先解析包头,验证签名和哈希,确认无误后再解析包体。如果签名验证失败,直接丢弃,不浪费后续的解析时间。

签名与验签的代码骨架

服务端签名(Python 示例):

import hashlib
from Crypto.Signature import pkcs1_15
from Crypto.PublicKey import RSA

def sign_package(package_bytes, private_key_path):
    # 1. 计算哈希
    h = hashlib.sha256(package_bytes).digest()
    
    # 2. 加载私钥
    with open(private_key_path, 'rb') as f:
        key = RSA.import_key(f.read())
    
    # 3. 签名
    signature = pkcs1_15.new(key).sign(h)
    
    return signature

设备端验签(C 伪代码):

int verify_signature(uint8_t *package, uint32_t len, 
                     uint8_t *signature, uint8_t *public_key) {
    uint8_t hash[32];
    SHA256(package, len, hash);
    
    // RSA 验签
    if (RSA_verify(public_key, hash, signature) != 0) {
        return VERIFY_PASS;
    }
    return VERIFY_FAIL;
}
我曾经踩过的坑:验签时忘了检查公钥的来源。结果攻击者通过漏洞篡改了公钥存储区,签名验签都通过了,但升级包是恶意的。所以公钥必须放在 Bootloader 的只读区域,并且 Bootloader 本身也要做防回滚保护。

升级包版本管理流程

我推荐用 Git Tag + CI 自动构建 来管理版本:

  1. 开发完成,合并到 master 分支
  2. 打 Git Tag:git tag v2.3.1_build20250315
  3. CI 检测到新 Tag,自动拉代码、编译、打包、签名
  4. 生成的升级包上传到 OTA 服务器,版本号写入数据库
  5. 设备端轮询服务器,比对版本号,决定是否升级

这样做的好处是:版本号与代码提交一一对应,可追溯、可回滚。出了问题,直接查 Tag 对应的 commit,定位问题非常快。

升级包制作与签名全流程 源码编译 生成固件镜像 计算 SHA-256 私钥签名 打包升级包 上传 OTA 服务器 设备下载升级包 公钥验签 验签通过? 执行升级 丢弃并告警 是 否
核心要点总结:
  • 全量包稳,差分包省,按场景选,别盲目
  • 签名验签是安全底线,公钥必须放在 Bootloader 只读区
  • 版本号用语义化版本 + 构建号,比较时按段解析整数
  • 升级包结构要支持先验签后解析,避免无效操作
  • CI 自动构建 + Git Tag,保证版本可追溯