bsdiff 二进制diff库

 

简介

提供两个文件的 binary diff 的patch 补丁

Patch 的数据结构

  1. Control Data: 补丁过层指导
  2. Diff Data: 新旧两个版本文件的差异
  3. Extra data:在新文件里完全没有的data

基于stream的设计

通过stream来抽象 io 操作,方便在多种环境(实测linux server, android,ios设备都有一致性, server打出来的patch 可以直接在端上merge)

二进制diff算法

Suffix Array Generation 后缀数组

针对于 老文件,进行 利用 qsufsort 进行排序,实际上就是产出一份方便匹配的数据

  1. 为每个字节值创建桶(0-255)
  2. 计算每个字节的出现次数并计算桶位置
  3. 将后缀放入各自的桶中
  4. 使用分割函数对桶内的后缀进行排序
  5. 基于逐渐增长的前缀长度来细化排序

demo 字符串

old = "banana"

长度 oldsize = 6,再加一个哨兵 \0,总共参与排序的有 7 个后缀(包括空串):

索引 后缀
0 banana
1 anana
2 nana
3 ana
4 na
5 a
6 ”“(空串)

步骤1: 桶统计并排序(基于首字符)

我们统计每个字节的频次(ASCII):

  • 'a' → 3次
  • 'b' → 1次
  • 'n' → 2次

构造桶位置(以 ASCII 排序):

buckets['\0'] = 0
buckets['a']  = 1
buckets['b']  = 2
buckets['n']  = 3

步骤2:初始桶排序后缀位置

将后缀下标按其首字符放入 I 中:

字符 后缀起始位置 排序后 I 位置
a 1, 3, 5 I[1], I[2], I[3]
b 0 I[4]
n 2, 4 I[5], I[6]

再加上空串:

I[0] = 6  (空后缀的起始位置)

所以:

I = [6, 1, 3, 5, 0, 2, 4]

步骤3:初始化 V 数组(当前排序排名)

V[i] = 在 I 数组中 i 出现的位置

I = [6, 1, 3, 5, 0, 2, 4]

所以:

V[0] = 4   // "banana"
V[1] = 1   // "anana"
V[2] = 5   // "nana"
V[3] = 2   // "ana"
V[4] = 6   // "na"
V[5] = 3   // "a"
V[6] = 0   // ""(空串)

步骤4:进入迭代排序 refine(以 h=1,2,4…)

每一轮排序按 (rank[i], rank[i+h]) 的元组排序。

比如:

  • 对于 h=1,我们比较每个后缀的前两个字符;
  • split() 函数就是在同一“段”内按这个二元组继续排序。

最终输出(后缀数组)

当排序稳定后,V[i] 就是后缀 i 的排名,而 I[rank] = 后缀位置

即:

后缀:        排名       => I[rank] = 后缀起始位置
""            0         => I[0] = 6
a             1         => I[1] = 5
ana           2         => I[2] = 3
anana         3         => I[3] = 1
banana        4         => I[4] = 0
na            5         => I[5] = 4
nana          6         => I[6] = 2

所以最终输出的 suffix array:

I = [6, 5, 3, 1, 0, 4, 2]

非常好的问题!你已经掌握了后缀数组 I 的生成过程,现在我们来回答核心问题:


字符串搜索与匹配

为什么 Suffix Array 能加速字符串匹配?

因为它将所有后缀按字典序排序了,我们就可以像查词典一样 用二分查找来匹配子串,从而大大减少搜索复杂度。

对于字符串 S = "banana",其后缀数组为:

I = [6, 5, 3, 1, 0, 4, 2]

表示的后缀排序为:

idx  后缀
---------------
6    ""
5    "a"
3    "ana"
1    "anana"
0    "banana"
4    "na"
2    "nana"

目标:快速判断子串是否在 S 中(如查找 "ana"

方法1(暴力):

  • 枚举 S 的每个后缀(共 n 个),对每个后缀做子串比较(长度为 m);
  • 时间复杂度 O(n × m)

方法2(用后缀数组):

  • 所有后缀已经 排好序
  • 我们可以用 二分查找"ana"
  • 比较时只需最多 log(n) 次 + 最多 m 个字符比较;

这样匹配复杂度就变成 O(m × log n),比 O(nm) 快太多!

二分查找示例

我们要查找 pattern = "ana",在下列后缀中二分查找:

5    "a"
3    "ana"
1    "anana"
0    "banana"
4    "na"
2    "nana"
  • 中间是 0 → "banana",”ana” < “banana”,往左;
  • 然后查 3 → "ana",完全匹配,找到!

本质理解:后缀排序 = 有序前缀空间

Suffix Array 把所有可能的子串查找问题,降维为 一个有序集合中的子串前缀匹配问题


再扩展一步:找所有匹配位置?

如果我们不是只要知道有没有 "ana",而是要知道它出现的位置呢?

你可以在后缀数组中找出所有以 "ana" 开头的后缀:

3    "ana"     ← ✅
1    "anana"   ← ✅

然后提取后缀起始下标 [3, 1] 就是所有匹配位置!

search 函数

主算法中

search 函数被用来为新文件中从 scan 位置开始的子串在旧文件中找到最佳匹配位置,返回匹配长度并通过 pos 参数返回匹配位置。

search函数内部:

采用二分搜索算法在已排序的后缀数组中查找匹配:

搜索逻辑分析

  1. 基础情况处理:当搜索范围缩小到2个元素以内时,直接比较两个候选位置的匹配长度,返回较长的匹配 bsdiff.c:149-160

  2. 二分搜索:选择中间位置进行字符串比较,根据比较结果决定搜索左半部分还是右半部分 bsdiff.c:162-167

  3. 匹配长度计算:使用 matchlen 函数计算实际的匹配长度 bsdiff.c:134-142

Diff 计算

bsdiff_internal 函数构建整个diff过程

alt text

1. 前向扫描 (Sf, lenf)

前向扫描从上次处理的位置向前扩展匹配,寻找最佳扩展: bsdiff.c:264-269

这个循环比较 req.old[lastpos+i] 和 req.new[lastscan+i] 之间的字节,每次匹配时递增分数 s。算法使用公式 s2-i>Sf2-lenf 来找到最优扩展长度 lenf,该长度能最大化匹配与长度的比率。

2. 后向扫描 (Sb, lenb)

后向扫描从当前位置向后扩展匹配: bsdiff.c:272-278

这从当前的 scan 位置和找到的 pos 向后扫描,比较 req.old[pos-i] 与 req.new[scan-i]。它使用相同的优化公式 s2-i>Sb2-lenb 来找到最佳的后向扩展长度 lenb。

3. 重叠调整 (Ss, lens)

当前向和后向扩展重叠时,算法会解决这个问题以最小化补丁大小: bsdiff.c:280-293

重叠调整过程:

  • 计算重叠区域:overlap=(lastscan+lenf)-(scan-lenb)
  • 通过比较两个潜在匹配来评估重叠中的每个位置
  • 找到最大化净收益的最优分割点 lens
  • 调整边界:lenf+=lens-overlap 和 lenb-=lens

patch 补丁的生成

这些优化的边界随后用于生成三种类型的补丁数据:

控制数据:使用计算出的长度 bsdiff.c:295-301 差异数据:覆盖前向匹配的 lenf 字节 bsdiff.c:304-307 额外数据:处理前向和后向区域之间的间隙

note:

优化使用评分公式中的 2:1 比率(s*2-i)来平衡匹配质量和长度,即使匹配率稍低也偏好更长的匹配。这个启发式算法由 Colin Percival 在 commit bd08be73 中设计,有助于产生更紧凑的补丁

时间复杂度

  • Suffix array construction 后缀数组构建: O(n log n), n是old file的大小
  • 匹配和diff计算: O(m log n),m是new file 的大小
  • 总复杂度: O((n+m) log n)

空间复杂度

  • Suffix array: O(n)
  • Additional buffers: O(n+m)
  • Overall space usage: O(n+m)

空间复杂度

二进制打patch算法

在diff之后算出来,patch,再用patch打回去

old_file + patch -> new_file

二进制patch文件结构

  • File Header: 文件头,包含 magic string “ENDSLEY/BSDIFF43” (16 bytes) 和 新文件的大小 (8 bytes)
  • Control Data: 控制数据,每个控制快ctrl包含3个64位整数
    • ctrl[0] - diff 数据的长度
    • ctrl[1] - extra 数据的长度
    • ctrl[2] - 旧文件位置的偏移调整量
  • Diff Data: 新旧diff数据
  • Extra Data: 额外的数据

流式数据接口

通过,bspatch_stream 接口 用流式读取 patch data

struct bspatch_stream
{
	void* opaque; //这是一个不透明指针,用于存储客户端自定义的上下文数据。该字段在 bspatch 函数内部不会被读取或修改,完全由调用者控制,可以用来保存文件句柄、压缩状态或其他自定义数据。
	int (*read)(const struct bspatch_stream* stream, void* buffer, int length); //该函数被 bspatch 调用来从流中读取二进制数据块。成功时返回 0,失败时返回非零值。

};

二进制patch算法

alt text

  1. 初始化指针:函数开始时将 oldpos 和 newpos 都设置为 0,这两个指针分别跟踪在旧文件和新文件中的当前位置。

  2. 循环处理补丁块 主循环持续执行直到 newpos 达到 newsize(新文件的总大小)。

    • 读取三个控制值,ctrl[0], ctrl[1], ctrl[2]
    • 控制值完整性检查
    • 读取diff 数据长度:ctrl[0], 将旧文件中对应位置的字节加到差异数据,调整两个文件的位置指针
    • 读取 extra 数据长度:ctrl[1], 再次调整位置指针,其中 oldpos 按 ctrl[2] 调整

alt text

alt text

Integer 特殊处理:从字节序列到64位整数

为确保补丁文件中的控制数据在不同平台间能够正确解析,bsdiff/bspatch 采用固定的字节序格式来避免字节序差异问题。该机制包含两个核心函数:offtin(解码)和 offtout(编码)。

64位整数需要转换到 8个 uint8

offtin 函数:字节序列解码为64位整数

static int64_t offtin(uint8_t *buf)
{
	int64_t y;

	y=buf[7]&0x7F;
	y=y*256;y+=buf[6];
	y=y*256;y+=buf[5];
	y=y*256;y+=buf[4];
	y=y*256;y+=buf[3];
	y=y*256;y+=buf[2];
	y=y*256;y+=buf[1];
	y=y*256;y+=buf[0];

	if(buf[7]&0x80) y=-y;

	return y;
}

核心机制:

  • 字节序转换:按小端序(little-endian)格式将8字节缓冲区解码为64位整数
  • 符号处理:使用最高位字节的符号位(buf[7] & 0x80)来确定正负性
  • 解码顺序:从高位字节(buf[7])开始,逐步累积到低位字节(buf[0]) 在patch应用中的作用:
  • offtin 用于读取补丁文件中的控制数据,每次迭代解析三个控制值来指导补丁应用过程。

    offtout 函数 64位整数编码为字节序列

static void offtout(int64_t x,uint8_t *buf)
{
	int64_t y;

	if(x<0) y=-x; else y=x;

	buf[0]=y%256;y-=buf[0];
	y=y/256;buf[1]=y%256;y-=buf[1];
	y=y/256;buf[2]=y%256;y-=buf[2];
	y=y/256;buf[3]=y%256;y-=buf[3];
	y=y/256;buf[4]=y%256;y-=buf[4];
	y=y/256;buf[5]=y%256;y-=buf[5];
	y=y/256;buf[6]=y%256;y-=buf[6];
	y=y/256;buf[7]=y%256;

	if(x<0) buf[7]|=0x80;
}

核心机制:

  • 符号预处理:先取绝对值进行编码,负数标记延后处理
  • 字节分解:通过取模和除法操作将64位整数分解为8个字节
  • 符号标记:若原值为负数,则在最高位字节设置符号位(buf[7] = 0x80)