ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Qt工程移植bsdiff生成patch包:压缩算法替换为QuickLZ的完整实践

Qt工程移植bsdiff生成patch包:压缩算法替换为QuickLZ的完整实践 简介qt环境下移植bsdiff并采用qlz压缩算法生成增量补丁包的项目源码包面向需要为Qt桌面或嵌入式应用设计差分升级机制的开发者帮助解决全量更新体积大、传输耗时的问题适合具备基础C与Qt知识的进阶学习者。压缩包共705个文件以675个idx索引文件为主体包含差分计算所需的索引数据另有cpp源码、h头文件、ui界面、pro工程文件、bin可执行文件及调试遗存等整体约6.22MB目录结构清晰便于对照工程分析编译产物与源码对应关系。已有496人学习下载。包内提供了可运行的demo二进制与Qt工程模板读者可以借此掌握bsdiff差异比较逻辑在Qt中的接口封装方式理解qlz压缩流如何替代默认bzip2并参考其性能优化与多线程数据流处理思路同时了解压缩失败或异常时的错误处理与状态恢复设计为自研轻量级软件更新方案提供可直接复用的代码骨架。1. 为什么Qt工程里最终选了bsdiff加qlz来做升级做Qt客户端和嵌入式设备升级的开发者迟早会碰到同一个问题旧版本怎么平滑升级到新版本。整包更新最简单但安装包稍微大点几MB的补丁下载成本就会让用户抱怨在网速受限的嵌入式环境里更是直接劝退。bsdiff是二进制差分算法里的老牌方案它能拿旧文件和新文件对比生成一个只包含差异部分的patch包客户端拿到patch后和本地旧文件合并就能还原出完整的新文件。原生bsdiff默认用bzip2做压缩但bzip2在生成patch时压缩速度偏慢库的依赖也重搬进Qt工程并不顺手。把压缩算法换成QuickLZqlz之后patch生成速度快了一个量级内存占用更可控整个补丁链路也变成了一套纯C加Qt就能独立维护的小模块。这篇文章就围绕“qt移植bsdiff生成patch包压缩算法采用qlz”这条主线把原理、移植步骤、文件格式设计和踩坑点一次说透适合正在做OTA和固件差分升级的工程师参考。2. 先看bsdiff的算法结构和压缩瓶颈2.1 bsdiff的差分过程后缀数组、diff流、extra流bsdiff的聪明之处在于它不逐字节做简单比较而是先对旧文件做一次后缀排序。旧文件的每个后缀都生成一个索引然后按字典序排成一个数组。接下来遍历新文件的每个位置在后缀数组里做二分查找找出旧文件中能和当前位置内容形成最长匹配的位置。匹配上的部分用diff流记录因为旧文件和新文件在对应位置上大概率只差几个字节把每个字节的差值存下来数据会非常小且重复度高后续压缩收益极大。匹配不上的部分无法和旧文件对应就原样塞进extra流。生成patch的过程最终被抽象成一组控制三元组也就是bsdiff里最核心的ctrl块。每个三元组包含三个数字x表示本轮要从diff流里拷多少字节y表示要从extra流里拷多少字节z表示下一个匹配在旧文件里的偏移修正量。bspatch应用patch时就是读一个三元组先从diff流拷贝x个字节并和旧文件对应位置相加再从extra流拷贝y个字节原样写入最后把旧文件指针按z调整继续下一个循环。这个设计让bspatch不需要扫描全量新文件只需要跳着读旧文件配合压缩后的三个数据块就能把新文件完整重建出来。一个典型的例子是固件镜像升级。镜像里可能只是改了版本号、签名或者某个配置文件新旧文件大部分区域完全一致。bsdiff跑一轮后diff流里大量字节都是0或者很小的差值extra流几乎为空整个patch包可能只有几百字节。这也是为什么差分升级能极大降低分发成本。不过要注意bsdiff的后缀排序阶段计算量相当大对旧文件越大的场景排序时间越长内存占用也越高这一点在选型时就要有心理预期。2.2 原生bzip2链路为什么搬进Qt工程不顺手原版bsdiff默认用bzip2做压缩这个选择在几十年前是合理的毕竟当年bzip2压缩率高patch包能压到很小。但放到现在的Qt工程里问题很明显。第一是依赖问题bzip2是一套独立的库在桌面平台还好到了交叉编译环境要么手动交叉编译一份libbz2要么在qmake里链接一个静态库多引入一个第三方依赖就让工程复杂不少。第二是压缩速度bzip2的压缩主要靠BWT变换配合Huffman编码算法本身很重一个几百MB的固件跑完patch生成等待时间长到会让调试节奏变得非常难受。第三是流式IO和内存模型的问题。bsdiff原版用bzip2的流式接口把三个数据块写进patch文件同时bspatch也以多路bz流的方式边读边解压。这个设计本身没问题但移植到Qt场景时维护多个BZ2_stream状态还要处理读一半断流的情况代码复杂度一下就上来了。而且bzip2解压时对缓冲区的大小和清零要求也比较苛刻在内存受限的设备上解压一个大的extra块往往要临时申请大块内存一旦分配失败整个升级流程就卡住了。2.3 换成QuickLZ之后性能指标发生了哪些变化QuickLZ可以简单理解为一种面向速度和轻量集成的LZ系列压缩库。它最大的特点是压缩和解压都极其快代价是压缩率不如bzip2但patch包体积在差分升级场景里本来就不是唯一指标。我自己在实际项目中感受到的差异是这样的同样的两个文件做差分用bzip2压缩过的patch很小但生成时间多出好几倍换成qlz后patch生成时间大幅缩短patch包会大一些但对OTA分发影响有限因为diff流和extra流里大量相似数据依然能压掉很大一部分。下面是两种压缩算法在我常用的开发机上的表现对比数据和环境有关但量级差异是有参考价值的项目bzip2原生bsdiffQuickLZqlz压缩速度偏慢大文件要等快接近LZ4级别解压速度一般很快适合嵌入式压缩率较高中等第三方依赖需要libbz2只有quicklz.c和quicklz.h线程安全流状态需要隔离状态结构需要隔离但要简单很多内存模型流式可边读边写块式一次压缩一整段数据QuickLZ的块式压缩模型对移植是有利的。它在调用前把一块完整数据放进缓冲区压缩后得到一块完整数据不需要像bzip2那样维护在线流状态。这样一来补丁生成和应用两侧的逻辑都变得直白先缓冲数据再压缩再把长度信息和压缩数据一起写文件。虽然文件头需要多记录几个字段但换来的是代码可读性和调试友好度大幅提升。3. 在Qt工程里移植bsdiff并把压缩算法换成qlz3.1 工程组织qmake项目文件与源码放置移植的第一步不是改算法而是先把代码结构理清。我习惯把bsdiff核心算法、bspatch核心算法、QuickLZ分别放在独立目录里再用qmake把它们组合成一个静态库或者直接编进主工程。下面是一个典型的qmake组织方式QT core CONFIG console c11 TEMPLATE app DEFINES _FILE_OFFSET_BITS64 INCLUDEPATH $$PWD/3rdparty/quicklz SOURCES \ main.cpp \ patch_generator.cpp \ patch_applier.cpp \ bsdiff_core.c \ bspatch_core.c \ $$PWD/3rdparty/quicklz/quicklz.c HEADERS \ patch_generator.h \ patch_applier.h \ bsdiff_core.h \ bspatch_core.h \ $$PWD/3rdparty/quicklz/quicklz.h这段配置里有几个关键点。_FILE_OFFSET_BITS64必须加上尤其当你需要在32位平台上处理超过2GB的文件时否则bsdiff里的off_t会退化成32位整数读写位置直接溢出。quicklz.c放在SOURCES里qmake会按C文件编译不会受到C名字修饰的影响。bsdiff_core.c和bspatch_core.c里保留原版的差分和合并算法但在对外接口处用C风格函数暴露方便后面用C封装。我一般会砍掉bsdiff_core.c里原有的main函数和命令行解析只保留核心的diff计算函数比如bsdiff算法的入口。这样Qt层可以自己决定从哪里读文件、往哪里写patch而不是被标准输入输出限制住。同理bspatch_core.c里也只保留“从内存中读取三个块并合并出新文件”的核心逻辑文件读取交给Qt侧管理。3.2 为qlz订制补丁文件头弥补流式压缩和块压缩的格式差异原版bsdiff的patch文件头只有几个字段magic、三个块的压缩后大小、新文件大小。因为bzip2是流式的bspatch知道自己按流读到多少字节算完不需要提前知道解压后大小。但QuickLZ的块式压缩不同解压时需要明确知道目标缓冲区的大小否则qlz_decompress不知道预期输出多少字节很容易越界。所以移植时一定要改patch文件格式给每个数据块同时记录解压前大小和压缩后大小。我采用的自定义头结构如下#define QLZ_PATCH_MAGIC 0x514C505A0401ULL typedef struct { quint64 magic; quint64 new_size; quint64 ctrl_raw_size; quint64 ctrl_comp_size; quint64 diff_raw_size; quint64 diff_comp_size; quint64 extra_raw_size; quint64 extra_comp_size; } QlzPatchHeader;ctrl_raw_size表示控制块解压后的字节数ctrl_comp_size表示压缩后的字节数diff和extra同理。new_size是最终新文件的总长度bspatch用它来预先创建输出文件。这个头结构总共8个字段每个8字节固定64字节没有对齐问题也很容易做小端序列化。在生成patch时我会先把整个头用占位数据写进文件然后依次写入三个压缩块最后再回过头把真实的长度数据填进头部。这样写入逻辑不用关心头的总长度也方便后面增加字段。考虑到不同平台字节序可能不同所有长度字段在写入时统一转成小端读取时再转回主机字节序这样patch包才能跨设备通用。3.3 生成侧改动把diff和extra块批量交给qlz_compress生成侧的核心改动在于把原来直接调用bzip2流式写入的地方替换成一个独立的写块函数。这个函数接收一段完整的原始数据用QuickLZ压缩后连同raw_size和comp_size一起写入patch文件。代码实现如下static bool write_qlz_block(QFile out, const QByteArray raw, quint64 *raw_size, quint64 *comp_size) { quint64 raw_len (quint64)raw.size(); quint64 comp_len 0; qlz_state_compress state_compress; QByteArray comp; comp.resize(raw.size() 400); // QuickLZ压缩时允许的最大膨胀量 comp_len qlz_compress((const void *)raw.data(), (void *)comp.data(), raw.size(), state_compress); *raw_size raw_len; *comp_size comp_len; out.write((const char *)raw_len, sizeof(quint64)); out.write((const char *)comp_len, sizeof(quint64)); out.write(comp.data(), (qint64)comp_len); return true; }写入时先写raw_len再写comp_len最后写压缩数据。这样读取侧能先知道要分配多大的解压缓冲再决定读多少压缩数据。qlz_compress的第四个参数是QuickLZ要求的状态结构这里在函数内部定义每次调用都是全新的状态避免线程间互相污染。输出缓冲大小给raw.size()加400这是QuickLZ文档建议的安全余量因为极端情况下压缩输出可能略微膨胀。生成patch的整体流程会把这个函数调用三次分别压缩ctrl块、diff块和extra块。这三个块的原始数据从哪里来bsdiff算法在跑完后会得到一整段控制数据和一整段diff、extra拼接数据在原有C代码里它们是直接按内存块处理的。我把bsdiff_core.c中间结果通过结构体返回给Qt层Qt层再把它装进QByteArray调三次write_qlz_block就完成patch文件的主体写入。这种方式有一个值得注意的副作用原来的bzip2流式写入可以边计算边写不会额外占用太多内存。改成qlz块式压缩后diff块和extra块必须全部缓存在内存里才能一次性压完。对于几十MB的固件这个代价可以接受对于数GB的文件就要评估设备内存是否够用。后面避坑章节会专门讲这一点。3.4 应用侧改动用qlz_decompress还原三个数据块并合并应用侧也就是bspatch的逻辑改动同样集中在块读取上。原来bzip2是流式读现在需要把每个压缩块完整读进内存然后一次性解压。读取侧的代码和写入侧对称static bool read_qlz_block(QFile in, QByteArray raw) { quint64 raw_len, comp_len; if (in.read((char *)raw_len, sizeof(quint64)) ! sizeof(quint64)) return false; if (in.read((char *)comp_len, sizeof(quint64)) ! sizeof(quint64)) return false; if (comp_len 1ULL * 1024 * 1024 * 1024) return false; QByteArray comp; comp.resize((qint64)comp_len); if (in.read(comp.data(), (qint64)comp_len) ! (qint64)comp_len) return false; qlz_state_decompress state_decompress; raw.resize((qint64)raw_len); size_t got qlz_decompress((const void *)comp.data(), (void *)raw.data(), raw.size(), state_decompress); return got (size_t)raw_len; }comp_len设置了上限1GB防止损坏的patch文件导致程序尝试申请超大内存。qlz_decompress的目标长度直接传raw_len函数返回实际解压得到的字节数如果不等于预期就说明patch文件损坏或者版本不匹配。解压完成后bpatch的合并循环并不需要改动多少。它从ctrl块里顺序读出三元组再从diff块和extra块里取出对应数据按规则写入新文件。因为三个完整块都常驻内存循环里只是做内存拷贝和字节加法运算不再有任何文件IO所以bspatch的跑分通常不会比生成侧慢。3.5 在Qt层封装成生成器和应用器纯粹暴露C函数给Qt调用虽然可行但用起来不顺。我习惯再包一层带QString参数的类让调用方可以直接传文件路径内部统一处理异常和错误信息。核心类的接口大概是这样class PatchGenerator { public: bool generate(const QString oldPath, const QString newPath, const QString patchPath, QString *errorMessage); }; class PatchApplier { public: bool apply(const QString oldPath, const QString patchPath, const QString newPath, QString *errorMessage); };在Qt层面我会用QFile读取文件内容并映射到QByteArray然后调用bsdiff_core.c里的核心函数。整个过程不需要把整个文件再复制一份旧文件和新文件各留一份原始数据再加上diff和extra的缓存内存占用量已经是理论下限的一部分。错误处理尽量控制在C层core层只返回整数错误码避免把C的errno和Qt的错误机制纠缠在一起。为了让生成和应用两侧的日志可读我还会在类里记录每个阶段的耗时和内存占用比如“后缀排序耗时”“diff块压缩字节数”“patch最终大小”这类信息。这些数据在优化参数时很有用后面验证章节会展开说明。4. 移植避坑清单5个最容易翻车的地方4.1 补丁头只存压缩大小导致qlz_decompress内存越界现象生成的patch在生产环境能正常应用换一个文件就崩溃或者解压出的内容末尾变成乱码。排查下来发现是bspatch侧申请的解压缓冲区大小不够qlz_decompress把数据写到了缓冲区边界之外。原因原版bsdiff文件头只保存三个块的压缩后大小因为bzip2流式解压不需要预先知道解压后字节数。QuickLZ不一样qlz_decompress需要明确指定目标缓冲区大小而这个值只有生成侧知道。解决补丁文件头必须为每个块同时保存raw_size和comp_size读取侧严格按raw_size分配缓冲区并在解压后比较实际返回值。我在read_qlz_block里加了一个断言got不等于raw_len直接返回失败避免数据损坏时无感继续。4.2 32位平台下off_t溢出超过2GB的文件直接崩现象在32位的嵌入式设备上跑bspatch文件读到一半位置变成负数或者生成patch时后缀排序数组下标变成负数程序退出。原因bsdiff原生代码里的off_t在32位编译环境下是32位有符号整数能表达的最大长度只有2GB左右。固件镜像一旦超过这个阈值所有偏移计算都不可信了。这不是QuickLZ引入的问题是bsdiff本身就需要处理的大文件隐患。解决qmake工程里统一加DEFINES _FILE_OFFSET_BITS64确保glibc的off_t按64位扩展。同时在自己写的Qt封装层里所有文件偏移和长度类型都强制用qint64不跟平台的long类型混用。交叉编译时还要确认工具链默认的off_t实现支持64位。4.3 qlz状态结构体在线程池里被共用压缩结果全乱现象单线程生成patch一切正常把生成任务丢进QThreadPool并发跑多个文件有的patch能出来有的压缩数据完全是烂的偶尔还直接crash。原因QuickLZ要求调用者提供压缩状态结构这个结构内部保存了上一轮匹配的窗口信息。多个线程共用同一个state对象时写操作互相覆盖压缩结果自然不可预测。bsdiff原版的bzip2流也类似但块式压缩让这个错误更容易被忽略。解决把qlz_state_compress和qlz_state_decompress定义成write_qlz_block和read_qlz_block的局部变量每个线程每次调用都有自己的状态。不要尝试在线程池里缓存状态来省那几十KB内存收益太低风险太高。4.4 端序不同导致patch在另一个架构上不兼容现象在x86开发机上生成的patch包部署到ARM设备上应用失败控件不匹配或者解压后数据错位。原因bsdiff原版把控制三元组直接按off_t的内存布局写入文件在不同字节序平台上内存布局正好相反。QuickLZ自带的压缩头虽然宣称跨端序安全但我们的自定义文件头和控制块数据都是二进制格式不能假定双方主机字节序一致。解决文件头和ctrl块三元组在写入时统一用小端序读取时用小端转主机序的函数解析。不要让结构体直接memcpy到文件里把字段逐个用put_le64和get_le64处理一遍代码多十几行但patch包的兼容性问题是彻底解决了。4.5 排序阶段内存占用叠加分配失败却没有任何报错现象处理大文件时程序在某一步突然卡死或者被系统OOM杀掉。用日志看内存占用爬升到接近设备物理内存上限然后进程消失。原因bsdiff的后缀排序阶段本身就需要给旧文件每个后缀建立一个索引内存占用大约是旧文件大小的4到8倍。换掉bzip2之后diff块和extra块又必须在内存里完整缓存再叠加QuickLZ的输出缓冲峰值内存比原来的方案还要高。很多移植代码忽略了对内存峰值的估算分配失败时又只检查了一个中间指针没有形成闭环。解决生成patch前先做一次内存预测。我用的经验公式是峰值内存约等于旧文件大小的6倍加上新文件大小的4倍再加上输出缓冲的余量。在资源受限设备上先验算一遍超了就改用分块策略或者在小文件上跑生成侧把应用侧放到设备端。另外所有malloc和QByteArray::resize都要检查返回值resize失败时立即给出明确错误信息而不是让后续逻辑继续踩空指针。5. 移植完怎么验证回归测试与性能基线5.1 建立最小回归用例覆盖三类边界数据移植完成后我建议先跑一个最朴素的回归脚本不要上来就拿真实固件压测。准备三组测试数据一组是只有几十字节差异的小文本文件一组是结构完全不同的两个二进制文件一组是内容整体偏移了几个字节的模拟固件。生成patch、应用patch然后直接比较新文件和原文件是否完全一致用字节级比较确认差异为零。# 回归测试三步走 ./patchtool gen old.bin new.bin patch.qlz ./patchtool apply old.bin patch.qlz out.bin cmp new.bin out.bin echo patch round-trip ok这三组数据分别覆盖了diff流占主导、extra流占主导、以及匹配偏移修正占主导的三种典型场景。bpatch的合并逻辑在这三类输入下最容易暴露出边界错误比如偏移修正量为负、diff块用完而extra块还有很多剩余数据之类的情况跑过这三组再上真机才算心里有底。5.2 参数调整QLZ压缩级别与匹配窗口的取舍QuickLZ的压缩级别通过宏控制常见做法是在编译时指定压缩等级。等级越高压缩率越好但内存和CPU开销也随之增大。在差分升级场景里我倾向于先测试两份参数一个追求最小patch体积一个追求最快生成速度然后把patch大小和生成时间放到一张表里对比。以模拟项目X的固件验证为例两组参数下的数据差异是低级配置生成时间缩短了近一半patch体积只增大了百分之十到二十。如果分发渠道的带宽紧张几MB的差异确实值得换压缩级别如果每次更新都有几十上千台设备并发下载生成速度反而更重要。没有绝对正确的参数只有适合你业务约束的选择。我自己在实际工程里养成了一个习惯在每个版本的性能基准测试里固定记录三项指标——patch生成耗时、patch包体积、设备端应用耗时。这些数据作为历史基线留在工程里下次改算法或者换压缩库时就能用同一组测试数据横向比较而不是靠感觉判断方案是否退步。建议你也在移植完成后跑完这几项写进自己的工程笔记里后续维护时能省不少心。这类移植工作最怕的不是算法复杂而是出了问题不知道是格式错误、内存问题还是字节序问题。数据记录下来日志打清楚验证脚本跑起来整个链路就不那么像玄学了。希望这几个步骤和避坑经验能帮你顺利把bsdiff加qlz的方案落地到自己的Qt工程里。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表