第四章:差分升级原理

差分升级,说白了就是只传输变化的部分。你想想看,一个固件包动不动几兆甚至几十兆,如果每次升级都全量传输,那对带宽和时间的消耗太大了。尤其是在物联网设备上,很多还是2G、3G网络,甚至走的是蓝牙、LoRa这种低速通道,全量升级简直是噩梦。

我最早接触差分升级是在做智能电表项目的时候。那时候设备只有几百KB的Flash,OTA升级包必须控制在100KB以内。全量包根本塞不下,逼得我们不得不研究差分算法。嗯,今天我就把这块的经验好好跟你聊聊。

4.1 差分算法的核心思想

差分算法的本质,就是找出新旧两个二进制文件之间的差异。然后只保存这些差异信息,生成一个很小的补丁包。设备端拿到补丁包后,结合本地旧固件,就能还原出新固件。

这里有个关键点:差分算法不是简单的逐字节比较。因为固件编译后,哪怕只改了一行代码,整个二进制文件的布局都可能发生巨大变化。函数地址变了、变量偏移变了,甚至指令顺序都变了。如果逐字节比较,你会发现到处都是差异,补丁包反而比全量包还大。

所以,真正的差分算法要做两件事:

  • 匹配相似块:找到新旧文件中相同或相似的数据段
  • 编码差异信息:只记录那些真正变化的部分

我个人习惯把差分算法分成两类:一类是基于字节流的差分,比如bsdiff;另一类是基于内存布局的差分,比如hdiffpatch。下面我分别讲讲。

4.2 bsdiff:经典的二进制差分算法

bsdiff是Colin Percival在2003年提出的算法。它至今仍是嵌入式领域最常用的差分算法之一。为什么?因为它对二进制文件的压缩率非常高,尤其是对可执行文件。

bsdiff的工作原理大致分三步:

  1. 后缀排序:对旧文件进行后缀数组排序,构建索引结构
  2. 匹配查找:用新文件在旧文件的索引中查找最长匹配块
  3. 差异编码:将匹配部分和不匹配部分分别编码

我在项目中遇到过一个问题: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端。流程大概是这样的:

  1. 获取旧固件:从设备上报的版本信息中,确定当前运行的固件版本
  2. 准备新固件:编译好的新版本固件
  3. 执行差分算法:用bsdiff或hdiffpatch生成补丁包
  4. 校验补丁包:用旧固件+补丁包合并,验证结果是否等于新固件
  5. 打包分发:将补丁包加上元数据(版本号、校验值等)下发给设备

这里有个坑,我曾经踩过:旧固件必须和设备端完全一致。哪怕差一个字节,合并出来的新固件都是坏的。所以云端必须精确记录每个设备当前运行的固件版本,甚至要记录固件的MD5值。

避坑指南:我曾经因为编译环境不一致,导致新旧固件虽然功能相同,但二进制内容不同。结果差分补丁包合并后设备直接变砖。后来我强制要求:所有固件必须用相同的编译器、相同的优化选项、相同的链接脚本。这样才能保证差分算法的可靠性。

4.5 差分包的合并流程

设备端合并差分包的流程相对简单,但要求非常严格:

  1. 接收补丁包:将补丁包存入临时存储区(通常是外部Flash或RAM)
  2. 读取旧固件:从当前运行分区读取旧固件
  3. 执行合并算法:根据补丁包中的指令,逐块合并生成新固件
  4. 写入新分区:将新固件写入备用分区
  5. 校验完整性:计算新固件的哈希值,与补丁包中的元数据比对
  6. 切换启动:校验通过后,标记新分区为启动分区,重启设备

设备端合并代码示例(伪代码):

// 读取补丁包头
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空间足够大、网络带宽也够,全量升级其实更简单、更可靠。差分升级是为了解决资源受限的问题,而不是为了炫技。我见过不少团队为了用差分而用差分,结果搞出一堆兼容性问题。所以,选型之前先想清楚:你的设备真的需要差分吗?

下一章我会讲差分升级在实际项目中的落地细节,包括如何做版本管理、如何保证差分包的兼容性。到时候再聊。