第四章:差分升级原理
差分升级,说白了就是只传输变化的部分。你想想看,一个固件包动不动几兆甚至几十兆,如果每次升级都全量传输,那对带宽和时间的消耗太大了。尤其是在物联网设备上,很多还是2G、3G网络,甚至走的是蓝牙、LoRa这种低速通道,全量升级简直是噩梦。
我最早接触差分升级是在做智能电表项目的时候。那时候设备只有几百KB的Flash,OTA升级包必须控制在100KB以内。全量包根本塞不下,逼得我们不得不研究差分算法。嗯,今天我就把这块的经验好好跟你聊聊。
4.1 差分算法的核心思想
差分算法的本质,就是找出新旧两个二进制文件之间的差异。然后只保存这些差异信息,生成一个很小的补丁包。设备端拿到补丁包后,结合本地旧固件,就能还原出新固件。
这里有个关键点:差分算法不是简单的逐字节比较。因为固件编译后,哪怕只改了一行代码,整个二进制文件的布局都可能发生巨大变化。函数地址变了、变量偏移变了,甚至指令顺序都变了。如果逐字节比较,你会发现到处都是差异,补丁包反而比全量包还大。
所以,真正的差分算法要做两件事:
- 匹配相似块:找到新旧文件中相同或相似的数据段
- 编码差异信息:只记录那些真正变化的部分
我个人习惯把差分算法分成两类:一类是基于字节流的差分,比如bsdiff;另一类是基于内存布局的差分,比如hdiffpatch。下面我分别讲讲。
4.2 bsdiff:经典的二进制差分算法
bsdiff是Colin Percival在2003年提出的算法。它至今仍是嵌入式领域最常用的差分算法之一。为什么?因为它对二进制文件的压缩率非常高,尤其是对可执行文件。
bsdiff的工作原理大致分三步:
- 后缀排序:对旧文件进行后缀数组排序,构建索引结构
- 匹配查找:用新文件在旧文件的索引中查找最长匹配块
- 差异编码:将匹配部分和不匹配部分分别编码
我在项目中遇到过一个问题:bsdiff对内存的消耗非常大。处理一个1MB的固件,可能需要占用几十MB的RAM。这在PC上没问题,但在嵌入式设备上根本跑不动。所以bsdiff通常只在服务器端或PC端生成补丁包,设备端只负责合并。
bsdiff的典型用法:
# 生成差分补丁包
bsdiff old_firmware.bin new_firmware.bin patch.bin
# 合并补丁包(设备端执行)
bspatch old_firmware.bin new_firmware.bin patch.bin
我的经验:bsdiff生成的补丁包通常只有全量包的5%-15%。但要注意,如果新旧固件差异太大(比如换了编译器版本),补丁包可能会膨胀到30%以上。这时候我建议考虑全量升级。
4.3 hdiffpatch:针对嵌入式优化的差分算法
hdiffpatch是后来出现的算法,专门针对嵌入式场景做了优化。它的核心优势是内存占用低,而且支持流式处理。什么意思?就是不需要把整个固件加载到内存里,可以一边读一边处理。
我记得有一次做智能门锁的OTA升级,设备只有64KB的RAM。bsdiff根本跑不起来,最后换成了hdiffpatch才搞定。它的算法原理和bsdiff类似,但在实现上做了很多工程优化:
- 分块处理:把固件分成多个小块,逐块进行差分
- 增量编码:对差异部分使用更紧凑的编码方式
- 内存复用:重复利用缓冲区,减少内存峰值
hdiffpatch的典型用法:
# 生成差分补丁包
hdiffpatch -c old_firmware.bin new_firmware.bin patch.bin
# 合并补丁包(设备端执行)
hdiffpatch -d old_firmware.bin new_firmware.bin patch.bin
注意:hdiffpatch虽然内存占用低,但压缩率通常比bsdiff差一些。我做过对比测试,在同样的固件上,bsdiff的补丁包可能只有hdiffpatch的70%-80%。所以这是一个取舍问题:内存紧张选hdiffpatch,带宽紧张选bsdiff。
4.4 差分包的生成流程
在实际项目中,差分包的生成通常放在云端或PC端。流程大概是这样的:
- 获取旧固件:从设备上报的版本信息中,确定当前运行的固件版本
- 准备新固件:编译好的新版本固件
- 执行差分算法:用bsdiff或hdiffpatch生成补丁包
- 校验补丁包:用旧固件+补丁包合并,验证结果是否等于新固件
- 打包分发:将补丁包加上元数据(版本号、校验值等)下发给设备
这里有个坑,我曾经踩过:旧固件必须和设备端完全一致。哪怕差一个字节,合并出来的新固件都是坏的。所以云端必须精确记录每个设备当前运行的固件版本,甚至要记录固件的MD5值。
避坑指南:我曾经因为编译环境不一致,导致新旧固件虽然功能相同,但二进制内容不同。结果差分补丁包合并后设备直接变砖。后来我强制要求:所有固件必须用相同的编译器、相同的优化选项、相同的链接脚本。这样才能保证差分算法的可靠性。
4.5 差分包的合并流程
设备端合并差分包的流程相对简单,但要求非常严格:
- 接收补丁包:将补丁包存入临时存储区(通常是外部Flash或RAM)
- 读取旧固件:从当前运行分区读取旧固件
- 执行合并算法:根据补丁包中的指令,逐块合并生成新固件
- 写入新分区:将新固件写入备用分区
- 校验完整性:计算新固件的哈希值,与补丁包中的元数据比对
- 切换启动:校验通过后,标记新分区为启动分区,重启设备
设备端合并代码示例(伪代码):
// 读取补丁包头
patch_header = read_patch_header(patch_addr);
// 逐块合并
for (i = 0; i < patch_header.block_count; i++) {
block_info = read_block_info(patch_addr, i);
// 如果是复制块,直接从旧固件拷贝
if (block_info.type == COPY) {
memcpy(new_addr + block_info.new_offset,
old_addr + block_info.old_offset,
block_info.length);
}
// 如果是差异块,需要应用差异数据
else if (block_info.type == DIFF) {
apply_diff(new_addr + block_info.new_offset,
old_addr + block_info.old_offset,
patch_addr + block_info.data_offset,
block_info.length);
}
}
// 校验新固件
if (calculate_md5(new_addr, new_size) == patch_header.expected_md5) {
mark_boot_partition(new_partition);
system_reset();
} else {
// 校验失败,回滚
report_error(ERROR_CHECKSUM_MISMATCH);
}
重要提醒:合并过程中如果断电或出错,设备可能变砖。所以一定要有回滚机制。我通常的做法是:先写入备用分区,校验通过后再切换启动分区。这样即使合并失败,旧固件还在,设备还能正常启动。
4.6 差分算法的选型建议
说了这么多,到底该选哪种差分算法?我根据自己的经验,整理了一个简单的对照表:
| 场景 | 推荐算法 | 理由 |
|---|---|---|
| 服务器端生成补丁包 | bsdiff | 压缩率高,服务器资源充足 |
| 设备端内存小于128KB | hdiffpatch | 内存占用低,支持流式处理 |
| 固件差异极小(<5%) | bsdiff | 补丁包可以做到非常小 |
| 固件差异较大(>30%) | 全量升级 | 差分优势不明显,不如直接全量 |
| 需要实时合并 | hdiffpatch | 支持边下载边合并 |
嗯,最后我想说一句:差分升级不是万能的。如果你的设备Flash空间足够大、网络带宽也够,全量升级其实更简单、更可靠。差分升级是为了解决资源受限的问题,而不是为了炫技。我见过不少团队为了用差分而用差分,结果搞出一堆兼容性问题。所以,选型之前先想清楚:你的设备真的需要差分吗?
下一章我会讲差分升级在实际项目中的落地细节,包括如何做版本管理、如何保证差分包的兼容性。到时候再聊。