差分更新与增量更新:全量 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 年提出。它的核心思想是:找到新旧两个二进制文件的最长公共子序列,然后只记录差异部分。
算法流程大致分三步:
- 后缀排序:对旧文件的所有后缀进行排序,构建一个后缀数组。这一步是为了快速查找匹配子串。
- 差异扫描:用新文件去匹配旧文件的后缀数组,找到所有匹配的片段,记录匹配位置和长度。
- 差异编码:把匹配信息(位置、长度)和不匹配的新增数据压缩成差分补丁。
我刚开始看 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 方案都强制要求「先备份、后更新、再切换」。
差分更新的整体流程
下面这张图展示了从生成差分到设备合并的完整链路,你可以对照着理解:
代码示例: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 几秒钟就搞定了。用户体验完全不是一个量级。
好了,差分更新的核心内容就这些。记住一句话:能差分就别全量,能增量就别差分。下一层咱们会聊更细的增量更新策略,包括如何做版本链管理、如何做断点续传——不过那是后面的事了。