3、升级包制作与签名:差分升级算法、全量包与差分包、签名与校验机制

好,咱们进入第三个核心环节——升级包的制作与签名。

说实话,很多团队在OTA上栽跟头,不是死在传输协议上,而是死在升级包本身。要么包太大下载超时,要么包被篡改导致设备变砖。我见过最惨的一次,是某款车机因为签名校验没做严格,被黑客注入了恶意固件,整个批次都得返厂重刷。

所以这一章,咱们把升级包的“里子”和“面子”都讲透。

3.1 全量包 vs 差分包:什么时候该用谁?

先搞清楚两个基本概念。

全量包,就是把整个系统镜像打包。比如你的系统固件是128MB,全量包就是128MB。好处是简单粗暴,刷完就是全新系统,不存在依赖关系。坏处也明显——大。在2G/3G网络下,128MB下载可能要十几分钟,用户早没耐心了。

差分包,只包含新旧版本之间的差异部分。比如从v1.0升级到v1.1,可能只改了5MB的代码,差分包就只打包这5MB。下载快、流量省、用户体验好。

我个人习惯这样划分场景:

  • 大版本升级(比如从Android 9升到Android 11):用全量包。因为改动太大,差分算法生成的补丁可能比全量包还大,得不偿失。
  • 小版本迭代(比如从v2.0.1升到v2.0.2):用差分包。改动小,差分效率高,用户几乎无感。
  • 跨版本升级(比如从v1.0直接升到v3.0):建议用全量包。差分链太长,中间版本依赖复杂,容易出问题。
我的经验:在多屏系统中,主控屏(比如仪表屏)的固件通常较大,我建议主控屏用差分包,副屏(比如空调控制屏)因为功能简单、固件小,直接用全量包反而更省事。别为了炫技而用差分,简单也是一种可靠。

3.2 差分升级算法:bsdiff、hdiff 与 xdelta

差分算法的核心思想,就是找出新旧两个二进制文件之间的“差异块”,然后只传输这些差异。说白了,就是“找不同”。

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

算法 特点 适用场景 我踩过的坑
bsdiff 基于后缀排序,压缩率高,但内存消耗大 固件体积较大(>50MB)的场景 在低端MCU上跑bsdiff解压,内存直接爆了
hdiff 基于哈希匹配,速度快,内存占用低 嵌入式实时系统,资源受限的设备 压缩率比bsdiff低10%左右,但胜在稳定
xdelta 基于VCDIFF标准,平衡性好 通用场景,尤其是文件系统级别的差分 对非连续修改的二进制文件效果一般

为什么会这样?bsdiff虽然压缩率最高,但它需要在内存中构建整个后缀数组。你想想看,一个128MB的固件,bsdiff可能需要占用超过1GB的内存来做差分计算。这在服务器端没问题,但在设备端做解压时,同样需要较大的临时缓冲区。我曾经在一个只有256KB RAM的MCU上尝试用bsdiff解压,结果直接OOM(内存溢出),设备重启了。

所以我的建议是:

  • 服务器端生成差分包:用bsdiff,追求极致压缩率。
  • 设备端解压:用hdiff或xdelta,确保内存安全。

下面是一个典型的差分包生成命令示例:

# 生成差分包(服务器端)
bsdiff old_firmware.bin new_firmware.bin update.patch

# 应用差分包(设备端)
bspatch old_firmware.bin new_firmware.bin update.patch
注意:差分算法对“旧版本”的完整性要求极高。如果设备上的旧固件已经被损坏或篡改,应用差分包会得到一坨垃圾。所以,在应用差分前,一定要校验旧固件的哈希值。我曾经遇到过一批设备因为存储介质坏块,导致旧固件读出来少了几字节,结果差分包应用后系统直接起不来。

3.3 签名与校验机制:别让你的升级包被“调包”

升级包做好了,怎么保证它在传输过程中不被篡改?签名。

签名说白了就是给升级包盖个章。设备端只认这个章,没章或者章不对,直接拒绝升级。

我常用的签名流程是这样的:

  1. 生成密钥对:私钥放在服务器端,绝对保密;公钥烧录在设备中,不可更改。
  2. 计算哈希:对升级包(全量包或差分包)计算SHA-256哈希值。
  3. 签名:用私钥对哈希值进行RSA或ECDSA签名,生成签名文件。
  4. 打包:将升级包 + 签名文件 + 公钥证书打包成一个分发文件。

设备端收到后,做反向操作:

  1. 用内置的公钥解密签名,得到原始哈希值。
  2. 对收到的升级包重新计算SHA-256。
  3. 比对两个哈希值,一致则通过,否则拒绝。

代码示例(设备端校验逻辑):

// 伪代码:设备端签名校验
bool verify_update_package(const uint8_t* package, size_t size, 
                           const uint8_t* signature, size_t sig_len) {
    // 1. 计算升级包的哈希
    uint8_t hash[32];
    sha256(package, size, hash);
    
    // 2. 用公钥解密签名,得到原始哈希
    uint8_t decrypted_hash[32];
    rsa_decrypt(signature, sig_len, public_key, decrypted_hash);
    
    // 3. 比对
    if (memcmp(hash, decrypted_hash, 32) == 0) {
        return true;  // 校验通过
    } else {
        return false; // 校验失败,拒绝升级
    }
}
关键点:公钥一定要在设备出厂时烧录到安全存储区(如eFuse或TEE),并且设置为只读。如果公钥可以被篡改,那签名就形同虚设。我见过有厂商把公钥放在普通Flash里,结果被黑客读出来替换成自己的公钥,然后签了个恶意固件……嗯,那场面,惨不忍睹。

3.4 多屏系统的特殊考量

在多屏系统中,升级包制作还有一个头疼的问题——依赖关系

比如,仪表屏的固件v2.0依赖中控屏的固件v3.0的某个API。如果你只升级仪表屏,不升级中控屏,那仪表屏可能就罢工了。

我的做法是:

  • 在升级包的元数据中,明确声明依赖版本号
  • 设备端在升级前,先检查所有相关屏的版本,不满足依赖条件则拒绝升级。
  • 如果依赖不满足,提示用户“请先升级中控屏至v3.0”。

元数据示例(JSON格式):

{
    "package_name": "仪表屏_v2.0_to_v2.1_差分包",
    "target_device": "instrument_cluster",
    "old_version": "2.0",
    "new_version": "2.1",
    "dependencies": [
        {"device": "center_console", "min_version": "3.0"}
    ],
    "checksum": "sha256:abcdef...",
    "signature": "rsa:xxxx..."
}
避坑指南:我曾经在某个项目中,因为依赖检查没做好,导致仪表屏升级后中控屏的触控事件无法传递。用户一按方向盘上的按钮,仪表屏就死机。后来我加了一条规则:任何跨屏的依赖变更,都必须做联合回归测试。这个习惯一直保留到现在。

3.5 小结

升级包制作,说白了就是三件事:

  • 选对包类型:大版本用全量,小版本用差分。
  • 选对差分算法:服务器端用bsdiff压到最小,设备端用hdiff保平安。
  • 签好名、验好章:私钥锁死,公钥焊死,哈希比对不能省。

下一章,咱们聊聊升级包的存储与分发策略。嗯,那又是另一个坑了。