码字长短不一,应用接收的却是整字节。这个组件选择块的表示,把编码接成字节,暂存尚未交付的部分,再随调用者提供空间逐步交付。
在全局图中定位 ↗把一个 3 位块头、两个 8 位字面量和几个更短的编码交给普通的“写一个字节”操作,会在字段之间留下空隙。解码器却要求这些位紧挨着出现。位写入器的任务,就是把一连串 (值, 位数) 接成连续位序列,再拆成完整字节,同时保留暂时凑不满一个字节的尾部。
沿用 ABABABAB 的最终固定编码块:输入依次为块头、A、B、长度 6、距离 2 和结束码,共 38 位;输出是 73 74 82 40 00。最后两位是对齐填充。下面先确定每个字段送进来的值,完整追踪这五个字节,再读实现如何跨越寄存器边界。字节先进入内部待交付存储,交给调用者是更后面的一步。
码字与到达顺序
DEFLATE 的普通数值字段按最低有效位先行。Huffman 码字则按码字顺序,从最高有效位开始消费。zlib 用反转后的规范码字连接这两种规则:编码表预先保存反转结果,位写入器便可以统一把整数的最低位当成下一位。
例如,字面量 A 的固定规范码字是 01110001。反转这八个位置后,交给 send_bits 的整数是 10001110,即十六进制 8e。从这个整数的 第 0 位 开始读取,得到 0,1,1,1,0,0,0,1,恰好是原来的规范码字。这里没有在写完以后把整个数据流倒过来。
最终固定编码块的三位数值块头是 3,按低位先行发送为 1,1,0:最后一块标志为 1,随后是固定编码块类型 01 的数值字段顺序。写入请求中的 value 是字段数值,length 是要发送的位数。这个长度表示编码字段的位宽,不是匹配要复制的原文字节数。对已经选定的例子,完整调用如下:
| 字段 | 写入器的 value(十六进制) |
length(十进制) |
|---|---|---|
| 最终固定编码块头 | 03 |
3 |
| A | 8e |
8 |
| B | 4e |
8 |
| 长度 6 | 10 |
7 |
| 距离 2 | 10 |
5 |
| 块结束符号 | 00 |
7 |
这里的 10 是十六进制 0x10,也就是十进制 16。长度和距离恰好传入相同数值,并不意味着它们是同一个字段。它们的位宽不同,在格式中的位置与含义也不同。
跨越寄存器边界的例子
实现先把最多 16 位暂存在一个寄存器中:bi_buf 是它的内容,bi_valid 是其中有效位的个数。待交付数组保存已经写出的完整字节。先同时观察这两个位置,再看后面的实现公式。下面的十六进制寄存器把高位写在左边;发射时依然先取低字节。
| 已完成的操作 | bi_buf(十六进制) |
bi_valid(十进制) |
新增待交付字节(十六进制) |
|---|---|---|---|
| 块头 | 0003 |
3 | — |
| A | 0473 |
11 | — |
| B | 0002 |
3 | 73 74 |
| 长度 6 | 0082 |
10 | — |
| 距离 2 | 4082 |
15 | — |
| 块结束符号 | 0000 |
6 | 82 40 |
| 最终对齐 | 0000 |
0 | 00 |
写入 B 时,已有 11 位,新字段有八位。当前字只能再容纳其中五位,剩下三位成为新的寄存器内容。块结束码同样让有效位从 15 跨到 22:它发射一个字,留下六个仍属于块结束码的零。最后对齐再补两个零,写出末字节。
得到的无封装字节为 73 74 82 40 00。解码这条构造出的流,会恢复 ABABABAB。这个有限例子检查的是字段记账,不是对所有输入的写入器证明,也不是在断言实际压缩器会选中这组符号。
从空的 16 位寄存器开始,依次加入块头、A、B、长度、距离和块结束码。加入 B 时写出 73 74,加入块结束码时写出 82 40,最终对齐写出 00。
十六位暂存寄存器
状态中,bi_buf 保存暂存寄存器,bi_valid 保存其中有效低位的个数。在逻辑输出中,待交付字节排在这些暂存位之前。下面的式子把已有有效位数简写为 r = bi_valid,把新字段数值简写为 v = value,把其位数简写为 l = length。因此,追加 B 时是 r = 11、v = 0x4e、l = 8。写入器要做的,就是把 v 的低 l 位接在已有的 r 位之后。这里 v << r 把新字段左移到第一个空位,按位或 OR 再把它和旧寄存器合并;调用前提保证字段值能放进声明的位宽,寄存器的其他位不会干扰这个拼接。
当 r ≤ 16-l 时,当前字段放得下:
bi_buf := bi_buf OR (v << r)
bi_valid := r + l
当 r > 16-l 时,新字段的一部分会填满一个 16 位字。实现把这些低位与寄存器合并,先把这个字的低字节、再把高字节写入待交付,随后保留余下的位:
写出 (bi_buf OR (v << r)) 的低 16 位
bi_buf := v >> (16-r)
bi_valid := r + l - 16
对应的真实 C 是发布版 send_bits 宏的跨界分支,trees.c:276–281。行末反斜线让宏续到下一行;这里摘出 if 分支的开头,完整的 else 见源链接。Buf_size 命名寄存器的位容量,其值为 16。宏中的 len 是新字段的位数,val 是其数值的局部整数副本,s 指向压缩器状态,put_short 以低字节在先写出一个两字节的字。
if (s->bi_valid > (int)Buf_size - len) {\
int val = (int)value;\
s->bi_buf |= (ush)val << s->bi_valid;\
put_short(s, s->bi_buf);\
s->bi_buf = (ush)val >> (Buf_size - s->bi_valid);\
s->bi_valid += len - Buf_size;\
逐行代入发送 B 的那一步:第一行比较 11 > 16−8,进入分支;第二行取字段值 0x4e;第三行将它左移 11 位,与旧的 0x0473 合并,保留到 16 位寄存器后得到 0x7473;第四行以低字节在先写出 73 74;第五行右移 16−11 = 5 位,得到余值 0x0002;第六行把有效位数更新为 11+8−16 = 3。这恰好是上表 B 那一行。
这些式子描述位运算;实际 C 还包含对应的强制转换和机器宽度。溢出分支使用严格的大于号。因此,恰好达到 16 个有效位时,容纳得下的分支仍把一个完整的字留在寄存器中。把三种情况并排看:
| 旧有效位数 r | 新字段位数 l | 这次调用之后 |
|---|---|---|
| 11 | 4 | 共 15 位,继续暂存 |
| 11 | 5 | 恰好 16 位,仍继续暂存 |
| 11 | 8 | 共 19 位,写出 16 位并留下 3 位 |
寄存器“已经装满”和“已经写出”不是同一件事。中间一行要等下一次跨界写入或刷新,才会变成待交付字节。
这项暂存优化避免每发射一个短码或额外位字段,就做一次字节写入。代价是状态更难一眼看懂:一些位已经有了确定的编码含义,却仍然没有成为待交付字节。
刷新、对齐与交付
bi_flush 只写完整字节,保留不足一个字节的部分。有效位为 16 时,它写出两个字节并清空寄存器;为 8 到 15 时,它写出一个字节并保留剩余位;不足八位时不做写入。后续字段仍然可以接着填充同一个字节。
bi_windup 则会关闭这个不完整字节。它按需要写出一或两个字节,清空寄存器,并用 bi_used 记录最后一个字节的有效位数。格式要求对齐时才需要它,例如最终块结束。若在任意两个字段之间使用,就会把填充位插进原本连续的编码数据。
交付另有一组以字节为单位的状态:pending 是内部尚未交付的字节数,pending_out 指向其中第一个;next_out 指向调用者接下来接收字节的位置,avail_out 是剩余可写字节数,total_out 是累计已交付字节数。这两种寄存器操作都不表示应用已经收到字节。flush_pending 先调用 _tr_flush_bits,把暂存的完整字节显露出来,再把至多 min(pending, avail_out) 个字节复制到 next_out。它推进两边的输出游标,更新 total_out,减少 avail_out 和待交付字节计数;这些字节全部交付后,再把 pending_out 恢复到缓冲区起点。调用者提供的小缓冲区影响交付进度,但不会改变已经编码好的字段。
下一篇会把待交付字节和未读符号记录放回它们共用的那一块内存。这样才能看清:正确的位序列和足够的最终容量之外,还需要满足哪一条内存条件。
覆盖完整公开定义域的写入器组合证明尚未完成。它需要由实际调用路径建立寄存器条件和容量条件,并把返回状态交给后续发射或交付。位输出组件的详细论证给出寄存器与容量义务;deflatePending则让调用者查询待交付字节数和暂存位数。