差分更新与增量更新:全量 vs 差分,bsdiff/bspatch 算法原理

说到设备更新,很多人第一反应就是「把新固件整个下载下来,刷进去」。嗯,这确实是最简单粗暴的方式——全量更新。但你在实际产品中试试看?尤其是那些网络带宽有限、存储空间吃紧的物联网设备,全量更新分分钟把用户体验拖垮。

我个人习惯把更新策略分成三个层次:全量更新是保底方案,差分更新是主流选择,增量更新是进阶优化。今天咱们重点聊差分和增量,顺便把 bsdiff/bspatch 这套经典算法掰开揉碎讲清楚。

全量更新 vs 差分更新:到底差在哪?

先看一张对比表,你心里就有数了:

对比维度 全量更新 差分更新
更新包大小 整个固件(通常数 MB~数十 MB) 新旧差异(通常数 KB~数 MB)
生成复杂度 低,直接打包即可 高,需要差异算法
设备端计算量 低,直接写入 中等,需要合并运算
网络传输量 大 小(通常可减少 70%~95%)
适用场景 首次烧录、重大版本升级 日常 OTA 小版本迭代

说白了,全量更新就是「我不管你现在是什么版本,直接覆盖成新的」。差分更新则是「我只发变化的部分,你在本地自己拼回去」。你想想看,一个 2MB 的固件,每次改几行代码就发 2MB 的包,用户流量扛得住吗?

核心结论:差分更新不是「可选项」,而是规模化 OTA 的「必选项」。我见过一个项目,全量更新时 10 万台设备同时下载,CDN 带宽直接被打满,运维同学差点崩溃。换成差分后,流量降了 90%,问题迎刃而解。

bsdiff/bspatch 算法原理:它到底怎么工作的?

bsdiff 是目前嵌入式领域最主流的差分算法,由 Colin Percival 在 2003 年提出。它的核心思想是:找到新旧两个二进制文件的最长公共子序列,然后只记录差异部分。

算法流程大致分三步:

  1. 后缀排序:对旧文件的所有后缀进行排序,构建一个后缀数组。这一步是为了快速查找匹配子串。
  2. 差异扫描:用新文件去匹配旧文件的后缀数组,找到所有匹配的片段,记录匹配位置和长度。
  3. 差异编码:把匹配信息(位置、长度)和不匹配的新增数据压缩成差分补丁。

我刚开始看 bsdiff 源码时,觉得后缀排序那一步特别绕。后来在项目中调试一个差分失败的问题,才发现是旧固件里有一段未初始化的内存区域,导致后缀数组构建出错。嗯,从那以后我养成了一个习惯:生成差分前,先对旧固件做一次完整性校验。

bspatch 是合并端,它做的事情正好相反:

  • 读取差分补丁
  • 根据补丁中的匹配信息,从旧文件中复制对应片段
  • 把不匹配的新增数据插入到指定位置
  • 最终合并成完整的新固件

小提示:bsdiff 对内存的消耗比较大,因为它需要把新旧两个文件都加载到内存中做后缀排序。在资源受限的 MCU 上,你可能需要改用 xdelta3 或自己裁剪的轻量差分算法。我在 STM32F4 上做过测试,bsdiff 在 256KB RAM 的设备上跑不动,换成 xdelta3 才勉强跑通。

增量包生成与合并策略:实战中的坑与解法

差分更新听起来很美好,但落地时你会发现一堆细节问题。我总结了几条实战经验:

增量包生成策略

  • 版本链管理:不要只维护最新版和旧版的差分,要维护一个版本链。比如 v1→v2、v2→v3、v3→v4,这样设备可以从任意版本逐步升级。
  • 基线选择:如果设备版本跨度太大(比如 v1 直接升到 v10),差分补丁可能比全量还大。这时候我建议做一次全量兜底,或者生成多个中间差分。
  • 签名与校验:差分补丁必须签名,合并后的固件必须校验哈希。我曾经遇到过补丁被篡改导致设备变砖的案例,从那以后我把「先验签、后合并」写进了代码规范。

合并策略

  • 原地合并 vs 临时空间:原地合并省空间,但风险高——如果合并过程中断电,旧固件和新固件都坏了。我建议在 RAM 充足时用临时缓冲区,或者保留一个备份分区。
  • 断点续传:差分补丁通常比较小,但网络差的时候也可能下载一半。我习惯把补丁分成多个 chunk,每个 chunk 校验 CRC,下载完再合并。
  • 回滚机制:合并失败后,设备应该能自动回滚到旧版本。这个机制必须在合并前就准备好,而不是等出问题了再想。

警告:千万不要在差分更新过程中断电!如果你没有做双备份或回滚机制,设备大概率变砖。我曾经在一个智能家居项目中吃过这个亏,50 台设备在升级时断电,最后只能返厂重烧。从那以后,我所有 OTA 方案都强制要求「先备份、后更新、再切换」。

差分更新的整体流程

下面这张图展示了从生成差分到设备合并的完整链路,你可以对照着理解:

差分更新流程图 差分更新完整流程 旧固件 (v1.0) 新固件 (v2.0) bsdiff 生成差分 差分补丁 (.patch) 网络传输到设备 设备旧固件 (v1.0) bspatch 合并 新固件 (v2.0) 哈希校验 → 切换启动 注意:合并前先备份 旧固件到备用分区

代码示例:bsdiff 与 bspatch 的典型用法

下面是一个实际项目中的简化示例,展示了如何在服务端生成差分补丁,以及在设备端合并:

// 服务端:生成差分补丁
// 输入:old_firmware.bin, new_firmware.bin
// 输出:update.patch

#include <bsdiff.h>

int generate_diff(const char *old_path, const char *new_path, const char *patch_path) {
    // 1. 读取旧固件和新固件
    uint8_t *old_data = NULL;
    size_t old_size = read_file(old_path, &old_data);
    uint8_t *new_data = NULL;
    size_t new_size = read_file(new_path, &new_data);
    
    // 2. 调用 bsdiff 生成补丁
    int ret = bsdiff(old_data, old_size, new_data, new_size, patch_path);
    
    // 3. 对补丁文件签名
    sign_patch(patch_path, private_key);
    
    free(old_data);
    free(new_data);
    return ret;
}

// 设备端:合并差分补丁
// 输入:old_firmware (当前运行), update.patch (下载的补丁)
// 输出:new_firmware (写入新分区)

#include <bspatch.h>

int apply_patch(const uint8_t *old_data, size_t old_size,
                const uint8_t *patch_data, size_t patch_size,
                uint8_t **new_data, size_t *new_size) {
    // 1. 先验签
    if (!verify_signature(patch_data, patch_size, public_key)) {
        return -1; // 签名验证失败,拒绝合并
    }
    
    // 2. 调用 bspatch 合并
    int ret = bspatch(old_data, old_size, patch_data, patch_size, 
                      new_data, new_size);
    
    // 3. 校验合并后的固件哈希
    if (sha256_verify(*new_data, *new_size, expected_hash) != 0) {
        return -2; // 哈希不匹配,合并失败
    }
    
    return 0;
}

实战建议:bsdiff 生成的补丁默认是 bzip2 压缩的,你可以考虑换成 zstd 压缩,解压速度更快,适合实时性要求高的设备。我在一个音频设备项目里试过,zstd 的解压速度比 bzip2 快了 3 倍,补丁大小只多了 5%。

什么时候该用全量,什么时候该用差分?

这个问题没有标准答案,但我给你一个参考判断逻辑:

  • 首次烧录或出厂恢复:必须用全量。设备上没有任何旧固件,差分无从谈起。
  • 小版本迭代(patch 版本号变化):优先用差分。改动通常只有几 KB 到几十 KB,差分效率极高。
  • 大版本升级(主版本号变化):看情况。如果新旧固件差异超过 50%,差分补丁可能比全量还大,这时候直接发全量更省事。
  • 安全补丁:必须用差分。安全补丁通常只改几个函数,差分包小、传输快、风险低。

我记得有一次给一个智能门锁做 OTA,固件从 v2.3 升到 v2.4,只改了一个蓝牙配对的 bug。全量包 1.2MB,差分包只有 8KB。你想想看,门锁用的是 NB-IoT 网络,发 1.2MB 要十几分钟,发 8KB 几秒钟就搞定了。用户体验完全不是一个量级。

好了,差分更新的核心内容就这些。记住一句话:能差分就别全量,能增量就别差分。下一层咱们会聊更细的增量更新策略,包括如何做版本链管理、如何做断点续传——不过那是后面的事了。