差分升级(bsdiff/bdiff)实战:把 OTA 包从 812KB 压到 27KB,以及落地时踩过的 6 个坑

2026年9月22日 0 By admin

上一篇文章聊 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 更久
变砖风险 低 中(基线错配是最主要风险) 中
回滚兼容 单包即可回滚 需要”反向差分”或备用全量包 同左

两条结论:

  1. 差分只对”相邻版本”有奇效。跨 5 个版本时收益缩水到 14%,跨 10 个版本以上往往还不如直接发全量包。所以服务端要有”基线不是当前版本就退化成全量”的兜底逻辑。
  2. 省下的是流量,付出的是内存和风险。一个能把全量包写到 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 上又遇到一次,正好把案例整理出来。