3. 差分升级原理:差分算法、差分包生成与还原
聊完了全量升级,咱们进入差分升级的核心地带。说实话,差分升级的精髓就一句话:只传变化的部分。你想想看,一个固件 100MB,每次只改了 2MB 的代码,却要下载整个包,这多浪费带宽?差分升级就是为了解决这个痛点。
我个人习惯把差分升级比作「打补丁」——不是把整件衣服重做,而是哪里破了补哪里。但这里的「补丁」可不是简单的文件替换,背后有一套精密的算法在支撑。
3.1 差分算法简介:bsdiff 与 hdiffpatch
目前主流的差分算法有两个流派:bsdiff 和 hdiffpatch。我在项目中两个都用过,各有千秋。
| 对比维度 | bsdiff | hdiffpatch |
|---|---|---|
| 算法原理 | 基于后缀排序(LCS) | 基于哈希匹配(Hdiff) |
| 差分包大小 | 通常更小(压缩率高) | 略大,但可控 |
| 内存消耗 | 高(需加载新旧固件到内存) | 低(流式处理) |
| 适用场景 | 资源丰富的网关/服务器 | 资源受限的 MCU/嵌入式设备 |
| 还原速度 | 较慢 | 较快 |
bsdiff 是 Colin Percival 在 2003 年提出的经典算法。它的核心思路是:找到新旧两个二进制文件的最长公共子序列,然后只记录差异部分。说白了,就是「找出哪些字节没变,剩下的就是差异」。这个算法的压缩率确实漂亮,我见过有些场景差分包能压到原包的 5% 以下。
但 bsdiff 有个硬伤——内存占用太大。它需要把新旧两个固件完整加载到内存里做后缀排序。对于动辄几十 MB 的固件,MCU 那点 RAM 根本扛不住。我曾经在一个 STM32 项目上试过 bsdiff,结果设备直接 OOM 重启了。
hdiffpatch 就不一样了。它采用哈希匹配的方式,把旧固件分成固定大小的块,然后在新固件里找匹配。这种方式可以流式处理,不需要一次性加载整个固件。虽然差分包会大一点(大概大 10%-20%),但对于 RAM 只有几百 KB 的嵌入式设备来说,这是唯一可行的方案。
我的选择建议:
- 服务器端生成差分包:用 bsdiff,压缩率高,服务器不缺资源
- 设备端还原差分包:用 hdiffpatch,内存占用低,还原速度快
- 如果设备 RAM > 固件大小 × 2:可以考虑 bsdiff 还原
- 如果设备 RAM 紧张:老老实实用 hdiffpatch
3.2 差分包的生成过程
差分包的生成,说白了就是「找不同」。但这里的「不同」不是逐字节对比,而是基于算法的智能匹配。我画了一张流程图,帮你理清整个过程:
生成差分包的具体步骤,我总结为以下 5 步:
- 加载新旧固件:把 v1.0 和 v2.0 的二进制文件读入内存。对于 bsdiff,这一步就需要完整加载;对于 hdiffpatch,可以分块加载。
- 分块与匹配:算法将旧固件分成若干块(通常 64KB 或 128KB),然后在新固件中查找每个块的匹配位置。匹配到的块用「复制指令」记录,未匹配的块用「插入指令」记录。
- 生成差异数据:对于未匹配的部分,计算新旧字节的差异(通常是 XOR 或差值),这部分就是差分包的核心载荷。
- 打包控制指令:将复制指令、插入指令、跳过指令等打包成控制头。控制头告诉还原程序:从哪里复制、复制多少、哪里插入新数据。
- 输出差分文件:将控制头 + 差异数据 + 校验值合并成一个 .patch 文件。这个文件就是最终要下发给设备的差分包。
实战小技巧:
我在项目中遇到过一个问题:差分包生成后,设备端还原总是校验失败。排查了半天,发现是生成时新旧固件的字节对齐方式不一致。旧固件是 4 字节对齐,新固件改成了 8 字节对齐,导致大量「伪差异」。后来我在生成脚本里加了对齐检测,问题就解决了。
3.3 差分升级的还原过程
还原过程,说白了就是「逆向操作」——把差分包和旧固件合并,生成新固件。这个过程在设备端执行,所以对资源消耗非常敏感。
我画了还原过程的流程图:
还原过程的核心逻辑,我用伪代码表示一下:
// 差分还原伪代码
function applyPatch(oldFirmware, patchFile):
newFirmware = []
patchReader = openPatch(patchFile)
// 解析控制头
controlHeader = patchReader.readHeader()
checksum = controlHeader.checksum
// 逐指令执行
while patchReader.hasMoreCommands():
cmd = patchReader.nextCommand()
switch cmd.type:
case COPY:
// 从旧固件复制一段数据
data = oldFirmware.read(cmd.offset, cmd.length)
newFirmware.append(data)
case INSERT:
// 插入差分包中的新数据
data = patchReader.readData(cmd.length)
newFirmware.append(data)
case SKIP:
// 跳过旧固件中的一段(未修改区域)
oldFirmware.seek(cmd.length)
// 校验完整性
if calculateChecksum(newFirmware) != checksum:
throw "校验失败,固件损坏"
return newFirmware
⚠️ 还原过程中的常见坑:
- 内存碎片问题:差分还原需要临时缓冲区,如果设备内存碎片严重,可能导致分配失败。我建议在还原前做一次内存整理。
- 断电保护:还原过程中断电,旧固件可能被破坏。一定要做双备份或使用 A/B 分区方案。
- 版本匹配:差分包必须基于当前设备的固件版本生成。如果设备偷偷升级了旧固件,差分包就废了。我遇到过客户反馈「升级失败」,结果发现是设备固件版本和差分包不匹配。
嗯,这里要注意一个细节:还原过程中的校验。差分包生成时,会对新固件计算一个校验值(通常是 MD5 或 SHA256)。还原完成后,设备必须重新计算新固件的校验值,和差分包里的校验值比对。不一致就说明还原出错了,需要回滚。
我曾经在一个项目中踩过这个坑:差分包生成时用的校验算法是 MD5,但设备端还原后用的却是 CRC32。结果每次还原都校验失败,排查了两天才发现是算法不一致。后来我统一用 SHA256,再也没出过问题。
差分升级的核心优势总结:
- 差分包大小通常只有全量包的 5%-30%
- 下载时间缩短,流量消耗降低
- 适合带宽受限的物联网场景
- 但需要设备端有足够的计算能力和内存来执行还原