差分升级(bsdiff/bdiff)实战:把 OTA 包从 812KB 压到 27KB,以及落地时踩过的 6 个坑
上一篇文章聊 Bootloader 时我留了个尾巴——那次设备升级用的是全量包,App 区 812KB,走 4G 下载一次要一分半,流量按套餐算下来,一千台设备迭代一版的流量费比我一个月工资还高。更要命的是文件大了下载失败率就上去了,现场在弱网环境下重传三次以上是常态。
所以这次把差分升级补齐:服务端只发”新旧固件的差异部分”,设备端拿本地旧固件 + 差分包还原出新固件。我实测下来,相邻版本之间差分包只有 27KB,是全量包的 3.3%。这篇文章把算法选型、服务端生成、MCU 侧落地的完整链路和坑都写清楚。
一、先算账:差分到底省什么、贵什么
差分升级不是免费的午餐,它把复杂度从”传输”搬到了”存储与内存”。先看收益:
| 项目 | 全量包 | 差分包(相邻版本) | 差分包(跨 5 个版本) |
|---|---|---|---|
| 压缩后大小 | 812 KB(gzip 后约 520 KB) | 27 KB | 118 KB |
| 4G 下载耗时(弱网 30KB/s) | ~17 s | ~1 s | ~4 s |
| 下载失败重传概率 | 高 | 低 | 低 |
| 服务端制作 | 直接打包 | 需保留历史全量固件 | 同左 |
| 设备端内存需求 | 流式写 Flash,几乎无额外需求 | 需同时访问旧固件 + 解压缓冲 | 同左 |
| 设备端 CPU | 无(只校验 CRC) | 解压 + bspatch 重算,约 2–8 s | 更久 |
| 变砖风险 | 低 | 中(基线错配是最主要风险) | 中 |
| 回滚兼容 | 单包即可回滚 | 需要”反向差分”或备用全量包 | 同左 |
两条结论:
- 差分只对”相邻版本”有奇效。跨 5 个版本时收益缩水到 14%,跨 10 个版本以上往往还不如直接发全量包。所以服务端要有”基线不是当前版本就退化成全量”的兜底逻辑。
- 省下的是流量,付出的是内存和风险。一个能把全量包写到 Flash 的 Bootloader,往往没有足够 RAM 去跑 bspatch 解压——这是差分升级落地时最常卡住的地方。
二、bsdiff 和 bdiff:不是同一个东西,别混用
网上聊差分升级,经常把 bsdiff 和 bdiff 混着说,但它们在 MCU 场景下的取舍差别很大。
bsdiff 是 Colin Percival 的方案:对 old 和 new 分别做后缀排序(suffix array),找到最长匹配串,输出三段式补丁,最后用 bzip2 整体压缩。压缩率很高,代价是内存和 CPU——排序阶段的内存需求约 17 × (old_size + new_size)。
bdiff 走的是另一条路:把文件切成固定块(常见 16B / 32B),对每块算滚动校验和(Adler-32 变体),用哈希表在 old 里找匹配块,输出控制序列 + 新增数据,通常配 zlib/deflate。它可以流式处理,内存只要几个块大小,代价是压缩率不如 bsdiff。
对比表(这是我拿同一组固件实测 + 官方算法特性整理的,非绝对基准):
| 维度 | bsdiff | bdiff / 块匹配类 |
|---|---|---|
| 典型差分包(相邻版本,800KB 固件) | 20–35 KB | 35–60 KB |
| 内存需求(处理 800KB 文件) | 数十 MB(服务器无压力,MCU 不可行) | 几十 KB 起,可流式 |
| 算法复杂度 | O(n log n) 排序,CPU 重 | O(n) 哈希匹配,CPU 轻 |
| 是否需要随机访问 old 文件 | 是(按偏移拷贝) | 是(按块号取块) |
| 压缩后端 | bzip2(内存重) | deflate / LZ4 / 自选 |
| 适合场景 | PC/服务器端生成、Linux 整机镜像 OTA | MCU 端解析、资源受限设备 |
| MCU 端可行性 | 需自研低内存变体 | 原生可行 |
我的选择:服务端用 bsdiff 生成(压缩率高,服务器内存不是问题),但 MCU 端不直接用 bspatch 的原版实现——因为原版 bspatch 依赖 bzip2 解压 + 整块输出缓冲。我做的是把补丁格式拆出来,只保留”三段式指令 + 数据流”,把压缩后端换成 LZ4 或小块 bzip2,再手写流式解析器(下面第四节给代码)。
如果你不想改格式,另一个更保守的方案是:服务端用 bsdiff,但用 -b 之类的参数调小压缩块,MCU 端用 bzip2 的 BZ2_bzDecompress 小块模式(block size 1 = 100KB 窗口),只要 MCU 有 64KB+ 连续 RAM 就能跑。这条路的门槛是 RAM,不是算法。
三、服务端:差分包怎么生成、怎么管
3.1 生成
Linux 下最省事的是直接用 bsdiff/bspatch 工具链:
# 生成差分包
bsdiff old_fw_v1.2.0.bin new_fw_v1.2.1.bin patch_v1.2.0_to_v1.2.1.bin
# 本地自检:用旧固件 + 补丁还原,必须与新版固件逐字节一致
bspatch old_fw_v1.2.0.bin restored.bin patch_v1.2.0_to_v1.2.1.bin
cmp new_fw_v1.2.1.bin restored.bin && echo "PATCH OK"
Python 侧我用 bsdiff4,方便塞进 CI:
import bsdiff4, zlib, hashlib, os
def make_patch(old_path, new_path, out_path, level=9):
old = open(old_path, "rb").read()
new = open(new_path, "rb").read()
raw_patch = bsdiff4.diff(old, new) # 纯 bsdiff 指令流
packed = zlib.compress(raw_patch, level) # 再叠一层 deflate,缩小传输量
header = {
"magic": b"MIUADP01",
"old_crc32": zlib.crc32(old) & 0xFFFFFFFF,
"new_crc32": zlib.crc32(new) & 0xFFFFFFFF,
"new_size": len(new),
"patch_size": len(packed),
}
with open(out_path, "wb") as f:
f.write(build_header(header)) # 32B 定长头,见下
f.write(packed)
print(f"{os.path.basename(out_path)}: {len(packed)/1024:.1f} KB "
f"({len(packed)/len(new)*100:.1f}% of full)")
关键在 header 里的 old_crc32——它是整个差分方案的安全绳,后面第五节会细讲它的作用。
3.2 版本资产管理
差分升级真正的运维难点不是算法,是基线管理。你必须在服务器上完整保留每个线上版本的全量固件,并且知道每台设备当前跑的是哪个版本。
我的目录约定:
firmware/
full/v1.2.0/app.bin 全量包(永久保留,不要删)
full/v1.2.1/app.bin
diff/v1.2.0__v1.2.1/patch.bin 差分包(命名含双向版本号)
diff/v1.2.1__v1.2.0/patch.bin 反向包:用于"升级后发现问题要回退"
三条铁律:
- 全量包永不删除。差分包是从全量包推导出来的,删了基线就永久失去生成历史差分的能力,只能让老设备先升级到某个中间版本再差。
- 反向包一起生成。升级后新固件出 bug 要回退,如果只有正向差分,回退就得下全量包——而恰恰是出了问题的时候最不希望下大包。
bsdiff new old就是一行命令的事。 - 为 N-1、N-2 各生成一个差分包,再往前的版本走全量。设备版本越老,差分包收益越低,而制作和维护成本线性上升。
四、MCU 侧落地:内存是唯一的敌人
4.1 补丁格式:三段式指令
无论 bsdiff 还是变体,补丁的内核都是同一种结构:
ctrl[0] = x 从 old 中拷贝 x 字节,追加到输出
ctrl[1] = y 从补丁的数据段取 y 字节,追加到输出
ctrl[2] = z 跳过 old 中 z 字节(old 指针前移但不输出)
bspatch 的主循环就是不断读这三个数。我把它写成流式版本,边解压边写 Flash,不缓冲整个新固件:
/* 伪代码,压缩后端用 LZ4 流式 API */
int apply_patch(const fw_src_t *old, flash_writer_t *wr, lz4_stream_t *dec, uint32_t new_size)
{
uint8_t buf[1024]; /* 复用的输出缓冲,大小取 Flash 页的整数倍 */
uint8_t ctrl_buf[24];
uint32_t old_pos = 0, written = 0, new_crc = 0xFFFFFFFF;
if (lz4_read(dec, ctrl_buf, 24) != 24) return -1;
while (written < new_size) {
int64_t c0 = offtin(ctrl_buf), /* old 拷贝长度 */
c1 = offtin(ctrl_buf + 8), /* 新增数据长度 */
c2 = offtin(ctrl_buf + 16); /* old 跳过长度 */
if (c0 < 0 || c1 < 0 || c2 < 0) return -2;
/* 1) 从旧固件拷贝 c0 字节 */
while (c0 > 0) {
uint32_t n = LV_MIN(c0, sizeof(buf));
if (old_read(old, old_pos, buf, n) != (int)n) return -3;
new_crc = crc32_update(new_crc, buf, n);
if (flash_write(wr, buf, n) != 0) return -4;
old_pos += n; written += n; c0 -= n;
}
/* 2) 从补丁数据段取 c1 字节 */
while (c1 > 0) {
uint32_t n = LV_MIN(c1, sizeof(buf));
if (lz4_read(dec, buf, n) != (int)n) return -5;
new_crc = crc32_update(new_crc, buf, n);
if (flash_write(wr, buf, n) != 0) return -6;
written += n; c1 -= n;
}
/* 3) old 指针跳过 c2 字节(旧版被删掉的内容) */
old_pos += c2;
if (written < new_size && lz4_read(dec, ctrl_buf, 24) != 24) return -7;
}
return (new_crc == expected_new_crc) ? 0 : -8;
}
这个循环里最省内存的两点:
buf只有 1KB,且被三段复用,栈/全局占用恒定,与固件大小无关;- 输出直接写 Flash,不做整包缓冲。全量包方案里我甚至不需要 buf(收一块写一块),差分方案多了 1KB,这个代价可以接受。
4.2 旧固件从哪里读
old_read() 是差分升级在 MCU 上最容易翻车的地方,因为 bspatch 是随机访问语义——它可能先拷贝 old 偏移 0x20000 处的 300 字节,再跳回 0x10 处拷 20 字节。
三种做法:
| 方案 | 内存 | 速度 | 适用 |
|---|---|---|---|
| 旧固件整块拷到 RAM | 需 800KB(不可能) | 快 | 排除 |
| 旧固件映射到内存地址(XIP),直接指针读 | 0 额外 | 最快 | Flash 直接可寻址的 MCU(首选) |
| 走 Flash 驱动按扇区随机读 + 缓存 | 1 个扇区(2–4KB) | 受限于 Flash 驱动速度 | 旧固件存在外部 SPI Flash / SD 卡时 |
我的板子 App 就在内部 Flash,old_read 就是一句 memcpy(dst, (void *)(APP_A_BASE + off), n),零成本。如果你的旧固件在外部 SPI Flash(比如从 SD 卡升级的老设备),一定要评估随机读的性能——SPI Flash 每次读要发命令 + 地址,小粒度随机读会慢得离谱。这时应该给 old_read 加一个”按扇区缓存”层,把连续的旧固件访问命中缓存。
4.3 资源占用实测
我这次落地在一颗 STM32F411(128KB SRAM / 512KB Flash) 上,实测占用:
| 项目 | 占用 |
|---|---|
| Bootloader 代码(含差分引擎 + LZ4 解码 + CRC32) | 约 21 KB Flash |
| 差分解包 RAM(buf 1KB + LZ4 状态 64B + 变量) | 约 2 KB |
| 补丁接收缓冲(512B × 2 双缓冲) | 1 KB |
| 还原耗时(27KB 补丁 → 812KB 固件) | 3.2 s |
| 全量包对比(812KB 直写,无解压) | 约 26 s(受 4G 带宽限制,非 CPU) |
有意思的结论:差分方案 CPU 时间更长(多了解压和重算),但总耗时更短——因为瓶颈在传输不在 CPU。这也是为什么在窄带物联网场景(NB-IoT、LoRa)差分升级几乎是唯一可行方案。
五、踩坑清单
这部分是本文最有价值的地方,六条都是我真实踩过的。
坑 1:基线错配——差分方案唯一能让你变砖的坑
设备上报”当前版本 v1.2.0″,服务器下发 v1.2.0→v1.2.1 的差分包。但设备实际运行的固件被现场工程师手工改过(比如改了个 IP 参数的编译期宏后重新烧录,版本号没动),old 内容对不上,bspatch 依然会”成功”输出一个 812KB 的文件,只是内容错了——CRC 校验通过不了还算幸运,能回滚;怕的是补丁本身合法但结果错位,写出一个”看起来正常但跑不起来”的固件。
对策是双重校验,我在补丁头里放 old_crc32,设备在应用补丁前先算一遍自己 App 区的 CRC32:
if (crc32(APP_A_BASE, app_size) != header.old_crc32) {
/* 基线不匹配:不发差分包,向上位机要全量包 */
request_full_package();
return;
}
这一条救过我一次——某台样机的 App 区被人用 ST-Link 刷了别的分支,设备自报版本号还是 v1.2.0,就是靠 old_crc32 拦下来的。
坑 2:差分包比全量包还大
第一次做的时候我图省事,用 v1.0.0 直接差分到 v1.3.0(跳过 5 个版本,中间重构过一个大模块),结果差分包 418KB,比 gzip 压缩后的全量包(520KB)没省多少,解压还多花 5 秒。
对策是服务端加一个”差分不划算就退回全量”的判断:
if patch_size > full_size_gzip * 0.6:
log.info("diff not worthwhile (%d vs %d), fallback to full", patch_size, full_size_gzip)
return serve_full()
阈值我取 60%——当差分包已经达到全量包六成以上,省下的那点流量换不回解压时间和风险。
坑 3:bzip2 解压把 RAM 吃穿
原版 bspatch 用 bzip2,BZ2_bzDecompressInit 默认块大小 9(= 900KB 窗口),解压状态对象本身就要 3.7 倍块大小的内存——STM32F411 那 128KB SRAM 直接爆掉,而且爆的方式是 malloc 返回 NULL,如果你没检查,就是 HardFault。
对策:BZ2_bzDecompressInit(&strm, 0, 0) 的 blockSize100k 参数传 1,窗口降到 100KB,仍需约 370KB——还是不够。所以最后我换成了 LZ4:解码状态只有 64 字节,压缩率比 bzip2 差大约 15–20%,但对我们这种”固件差异本来就小”的场景,最终补丁 27KB(bzip2 版本约 23KB),差异可以接受。
教训:压缩库的”内存需求”要按解压状态算,不是按”窗口大小”算。选型时先查解的 API 文档,别只在 PC 上试压缩率。
坑 4:解压输出速度快过 Flash 写入,缓冲区溢出
我把 LZ4 解码的 ring buffer 设成了 4KB,Flash 写入是同步的按页写,本以为解压一定比写快、会自然阻塞——但 LZ4 解码是纯 CPU 的,一次 LZ4_decompress_safe 调用的输出量由输入决定,如果补丁里某个新增块特别大(我遇到过连续的调试字符串段),一次就可能要吐出 8KB,超出我 4KB 缓冲,越界写。
对策有两条,我都做了:解码 API 用 LZ4_decompress_safe_partial 限定单次输出上限;同时用固定 1KB 的 buf 循环消费(就是 4.1 那段代码的写法),从结构上杜绝一次大输出。“缓冲区够大”永远不是设计,结构上没有越界路径才是。
坑 5:Flash 擦除和写入的粒度对不上
差分输出是连续字节流,但 Flash 只能按扇区擦、按字(或半字)写,且同一扇区不能重复写。B 区擦干净后,bspatch 写出的字节数可能不是写入粒度的整数倍,最后一页会剩下几个字节——如果下次升级复用这块区域时忘了先擦,就是写失败或者数据损坏。
对策:flash_writer_t 里维护一个 页对齐的 RAM 中转页(我用 1KB 页):
typedef struct {
uint32_t base; /* 当前中转页地址 */
uint32_t fill; /* 已填充字节 */
uint8_t page[1024];
} flash_writer_t;
int flash_write(flash_writer_t *w, const uint8_t *data, uint32_t n)
{
while (n--) {
w->page[w->fill++] = *data++;
if (w->fill == sizeof(w->page)) {
feed_watchdog();
if (flash_program_page(w->base, w->page) != 0) return -1;
w->base += sizeof(w->page);
w->fill = 0;
}
}
return 0;
}
/* 全部写完后调用,处理尾部非整页数据 */
int flash_flush(flash_writer_t *w);
这样上层只需要关心”写字节流”,粒度对齐完全封装在驱动里,写完整包、写差分补丁、写参数区共用同一套代码。
坑 6:看门狗和中断在两个世界里
差分解包要 3.2 秒,全量写要 26 秒。Bootloader 里的 IWDG 我设的是 4 秒超时,第一版全量升级就是这么挂的。而且差分升级里,解压过程是纯 CPU 密集的,如果你把喂狗放在”每次 Flash 写之后”,遇到连续大块 old 拷贝(无 Flash 写、纯 memcpy + CRC)时喂狗间隔会被拉长。
对策:
- 在
flash_program_page前后、以及 old 拷贝循环的每 16KB 喂一次狗; - 升级期间关闭所有非必要中断,包括那个 1ms 的 SysTick 和 UART 空闲中断——Bootloader 阶段不需要实时性,中断只会带来”在错误时刻跳进 App 向量表”的风险;
- 整个升级流程做成”任意时刻断电都安全”:补丁写到临时区 + 校验通过后才置启动标志,中断了下次重新来。
六、差分还是全量?一张决策表
经验总结成表,实际项目里照着选:
| 条件 | 推荐 |
|---|---|
| 设备在窄带网络(NB-IoT / LoRa / 2G) | 差分必选 |
| 固件 > 256KB 且迭代频繁(月更以上) | 差分 |
| 设备按流量计费(4G 物联网卡) | 差分 |
| 设备只有很小 RAM(< 64KB)且旧固件在外部 SPI Flash | 谨慎:差分随机读性能可能不划算,先实测 |
| 固件 < 64KB | 全量,差分的复杂度不值得 |
| 版本跨度 > 3 个 | 全量(或先用全量升到中间版本再差) |
| 存量设备版本极度分散、无法可靠收集版本号 | 全量 |
| 首次上线、OTA 链路还没跑通 | 先做全量,稳定后再加差分 |
七、差分升级自查清单
- ☐ 服务端永久保留所有历史全量固件,且做过异地备份
- ☐ 为 N-1、N-2 生成正向差分包,同时生成反向差分包用于回退
- ☐ 补丁头包含
old_crc32/new_crc32/new_size/ 魔数,头部本身也校验 - ☐ 设备在应用补丁之前校验自身 App 区 CRC32,不匹配立即要全量包
- ☐ 服务端有”差分包 > 全量包 60% 时退回全量”的兜底
- ☐ 压缩后端已按解压状态内存而非压缩率选型(LZ4 / 小块 bzip2)
- ☐ bspatch 解析器是流式的,缓冲大小与固件大小无关
- ☐ old_read 对小粒度随机访问有性能评估(尤其是外部 SPI Flash)
- ☐ 写入路径封装了页对齐中转,尾部非整页数据有 flush
- ☐ 单次解压输出有明确上限,不存在”一次吐 8KB 进 4KB 缓冲”的路径
- ☐ 全流程无
malloc,或所有malloc返回值都检查 - ☐ 喂狗点覆盖纯 CPU 密集段(old 拷贝循环),不只是 Flash 写
- ☐ 升级全程关闭非必要中断,且跳转 App 前有完整的反初始化
- ☐ 断电安全性:任意时刻掉电,重启后都能进 Bootloader 重新升级
- ☐ CI 里对每个差分包做
bspatch+cmp自检,结果不一致直接构建失败 - ☐ 至少在一台真实弱网设备上验证一次完整升级,而不是只在实验室局域网测
最后一条尤其重要。我在实验室局域网测差分升级一切顺利,到了现场 30KB/s 的弱网环境,才暴露出”分块重传时 old 指针状态没保存”的问题——这就是需要把补丁切成多个传输块后做断点续传,每块独立应用、状态机可重入。弱网不是”更慢的局域网”,它有自己的一整套失败模式。
八、小结
差分升级的收益很直接:相邻版本把 OTA 包从 812KB 压到 27KB,弱网下载从 17 秒降到 1 秒,重传概率大幅下降。但它的成本在别处:
- 算法选型要按 MCU 的内存和解压状态来选,不是按压缩率;
- 基线管理是运维问题而非技术问题,全量包和反向包一个都不能少;
- 每一个环节都要能中断——传输、解压、写 Flash、跳转;
- 风险要能兜底:
old_crc32拦基线错配,差分包超阈值退全量,写入前留好回滚路径。
我的建议是分两步走:第一版先做全量 OTA,把 Bootloader、分区、回滚这套骨架跑稳(上一篇文章的内容);等链路稳定、有明确流量成本压力了,再把差分引擎作为”可插拔的一层”加进去。反过来做,一旦差分出问题,你会分不清是 OTA 框架的锅还是差分的锅。
—
实施耗时参考:服务端生成与版本资产整理 0.5 天 → 差分解包引擎(含流式改造)2 天 → 集成与弱网联调 1.5 天 → 六条坑的整改 1 天。工作量不大,但”差分包生成正确”和”设备端还原正确”之间隔着一堆内存与断电边界条件,这才是真正花时间的地方。
下一期准备写 CAN 总线错误帧排查:从一次总线”随机掉线”讲到 TEC/REC 计数、错误主动/被动状态、终端电阻与共模干扰的现场定位方法。前段时间在储能设备的 BMS-CAN 上又遇到一次,正好把案例整理出来。