跳到正文
ZLIB × LEAN / 算法精读与验证

gzip 文件与缓冲 I/O

通过文件接口使用 gzip,理解格式识别、成员和逻辑位置。

这一环节的任务

应用需要交出输入、接收输出,并处理返回结果。缓冲区、回调和文件接口提供不同的使用方式,底层仍然复用压缩与解压引擎。

在全局图中定位 ↗

你手里有一个文件,希望应用程序读到的是普通字节。它可能经过 gzip 压缩,可能包含多个连续 gzip 成员,也可能是未压缩文件。这时可选择 gzFile 读取接口,让 zlib 管理文件读取、格式检测、缓冲和解码。输入是路径或描述符,gzread 的输出则是调用者存储中的未压缩字节。写文件要另开写模式句柄,接收未压缩输入并形成 gzip 文件。

读取一个双成员文件

设文件内容为 gzip("ABC") 后面紧接 gzip("DEFG"),中间没有分隔符。每个 gzip(...) 都表示带有头尾的完整 gzip 成员。逻辑输出是七字节 ABCDEFG。下面的完整教学程序对指定的阻塞文件每次请求三个字节,并把返回字节写到标准输出。它在关闭前检查活句柄的错误,最后也检查关闭结果。

#include <stdio.h>
#include <zlib.h>

int main(int argc, char **argv) {
    if (argc != 2) return 1;
    gzFile file = gzopen(argv[1], "rb");
    if (file == NULL) return 1;
    int ok = gzbuffer(file, 16) == 0;
    unsigned char bytes[3];
    while (ok) {
        int n = gzread(file, bytes, sizeof bytes);
        if (n > 0) {
            if (fwrite(bytes, 1, (size_t)n, stdout) != (size_t)n)
                ok = 0;
            continue;
        }
        int code;
        const char *message = gzerror(file, &code);
        if (n < 0 || (code != Z_OK && code != Z_STREAM_END)) {
            fprintf(stderr, "%s\n", message);
            ok = 0;
        }
        break;
    }
    int closed = gzclose(file); /* file is no longer usable */
    return !(ok && closed == Z_OK);
}

普通成功执行依次返回 ABC、DEF、G、零。第二次请求跨越成员边界,应用程序无需重置。如果输入改成内容为 ABCDEFG 的普通文件,透明路径也会交付相同应用字节。这是两种不同的输入情形,不能据此认为 gzip 成员后的任意字节都会变成透明输出。

因此,gzFile 管理的不只是编解码器,还有描述符所有权、未读压缩输入、已解码输出、逻辑位置和错误状态。下面从打开到关闭,追踪这些责任。相应证明需要同时描述文件世界与内存。

打开与延迟分配

gzopen 打开路径并创建句柄状态。gzdopen 则包装已有描述符,不复制它。成功的 gzdopen 把关闭描述符的责任转交给句柄;如果另一位使用者还需要原描述符,应传入它的副本。失败时,描述符仍归调用者负责。句柄拥有描述符期间,应用程序不能另外移动或关闭它。

打开不会执行载荷 输入输出。缓冲区与编解码器的分配推迟到第一次使用或格式检查。默认请求的缓冲大小是 8192 字节。缓冲区尚不存在时,gzbuffer 可以改变这个请求,但成功不保证后续分配一定成功。若 gzdirect 等操作已经触发分配,再设置就太迟了。“查询”并不总意味着“不会执行 输入输出”。

选定实现中的读取句柄使用输入缓冲区和两倍大小的输出缓冲区;压缩写入句柄使用两倍大小的输入缓冲区和一个输出缓冲区。透明写入不需要编解码器的输出分配。完整 gzip 接口证明未完成;这些分配与读写路径还需要逐一建立失败清理和所有权转移的结论,详见 gzip 实现论证。

检测与读取路径

普通读取句柄可以接收 gzip,也可以透明地返回未压缩输入。检测要求看起来合理的四字节 gzip 头,不只检查前两个魔数字节。空输入属于透明输入;强制 gzip 模式有不同要求。输入太短或暂时无法取得时,还要先处理相应状态,才能确定走哪一条路径。

flowchart TD
    Read["gzread 请求"] --> Buffered{"已有缓冲的解码字节"}
    Buffered -->|有| Copy["复制可用的解码前缀"]
    Buffered -->|没有| Detect["初始输入:检查格式"]
    Detect -->|gzip| Decode["解压一个成员"]
    Detect -->|允许透明输入| Plain["原样复制文件字节"]
    Decode -->|成员结束| Next["等待成员输入;重置 gzip 解码器"]
    Next --> Decode
    Decode --> Result["返回解码字节或保留它们"]
    Plain --> Result
    Copy --> Result
    Result -->|请求仍需要数据| Buffered

这里的 strm 是句柄内部的解码流,next_in 指向第一个未读压缩字节,avail_in 是从那里起可读的字节数;数量大于三,才可以读取四个索引。句柄的 how 字段选择读取路线:LOOK 表示检查下一个成员,COPY 表示透明复制,GZIP 表示解码;direct 标志记录透明模式,junk 用于跟踪尾随垃圾的处理路径。

第一个成员通过下面这段真实的 gzread.c:158–166 分类代码进入 gzip 路径:

    if (strm->avail_in > 3 &&
            strm->next_in[0] == 31 && strm->next_in[1] == 139 &&
            strm->next_in[2] == 8 && strm->next_in[3] < 32) {
        inflateReset(strm);
        state->how = GZIP;
        state->junk = 0;
        state->direct = 0;
        return 0;
    }

检查的依次是魔数 1f 8b、DEFLATE 方法 08,以及没有保留高位的标志字节。这只负责选择 gzip 路径,不负责验证完整头尾。普通 ABCDEFG 不符合分类条件,会进入 COPY。本例第二个成员则走更早的 124–139 行:必要时等待输入,然后重置 inflate,直接选择 GZIP。阅读流程图时,要保留这两条路径的区别。

实际读取句柄有三条值得区分的数据路径:消费已有解码缓冲;把透明输入直接复制到调用者缓冲区;或者直接解压到足够大的调用者缓冲区。直接路径省去经过内部输出存储的一次复制,却也改变了哪些指针可以指向调用者内存,使生命周期推理更复杂。

gzip 成员会在逻辑输出中拼接。若第一个成员产生 ABC,第二个产生 DEFG,应用程序读到的是 ABCDEFG。成员结束后会返回 LOOK,但后续成员分支与初始格式检测不同:必要时先等待输入,再重置 gzip 解码器,尝试下一个成员。它不会重新运行最初的四字节分类,把输入改判为透明数据。gzip 解码之后的非成员尾随字节另有规则,并不保证把任意垃圾原样继续返回。精确条件见 gzread 契约和格式规则。

gzeof 记录的是一次超出可用数据的读取尝试,不能直接解释成“最后一个字节已经返回”。零返回值需要结合 gzerror 理解:完整结束和压缩数据截断不是同一种结果。已有错误被报告之前,缓冲字节仍可能继续交付。按项读取时,gzfread 返回完整项数,而最后一个不完整项仍可能已经被消费并写进目标区。

逻辑位置与定位

gztell 报告未压缩的逻辑位置,gzoffset 报告扣除压缩输入缓冲影响后的文件偏移。这两个数通常不同。读取三个解码字节,可能已经让句柄获取了很多压缩字节,甚至检查后续结构;缓冲大小和压缩率都不能提供固定的位置换算。

回到程序中两个成员产生的 ABCDEFG。消费 ABC 后,逻辑位置是 3。请求 gzseek(file, 5, SEEK_SET),要到达 F 前面的位置。gzip 读取句柄可以先记录向前跳过的数量,等后续 输入输出 再进行相应解压。因此,定位请求当下成功,不保证源里确实有足够且有效的数据来完成这项跳过。

在这段代码中,pos 是请求到达的未压缩绝对位置,offset 则是在使用已有输出缓冲之后,还需要跳过的未压缩字节数。把它存入 state->skip,是在登记后续读取要完成的工作,不是在记录一个压缩文件偏移。

真实 seek 实现先消费可用的已缓冲输出,再把剩余工作记录下来。下面两句来自 gzlib.c:426–438:

    state->skip = offset;
    return pos;

从逻辑位置 3 请求位置 5 时,已经缓冲的 DE 可以直接跳过;尚未缓冲的部分才记入 skip。两种路线都指向 F 之前。返回 pos 报告请求的逻辑位置,剩余工作却可能还没执行。

压缩输入的向后定位还有另一种成本。从这里返回位置 1,可能需要先回到记住的文件起点,再重新向前解码。库通常无法在不解码的情况下,算出第二个未压缩字节对应的压缩文件偏移。透明读取句柄在允许时可以直接定位描述符。写入句柄则通过后续输出、刷新请求或关闭操作补充零字节来完成向前定位,不允许向后定位。

支持的定位基准有两种:SEEK_SET 从逻辑位置零计算请求,SEEK_CUR 从当前逻辑位置计算;SEEK_END 要从解码后的末尾计算,这一模式不受支持。位置算术必须可以表示。起始处退回一个已读字节,甚至能产生负逻辑位置。gzseek64 契约保留相应源码疑点,没有悄悄排除这种边界。成功的定位请求可以留下推迟的工作,因此零长度读写也不保证执行这项工作。

写入接收与最终清理

小写入可以复制到句柄的输入缓冲区,较大的写入可以直接从调用者存储进行压缩。透明模式原样写入,不进行 gzip 编码。gzwrite 通常报告已经接收的未压缩字节,不代表相应内容已经安全交付给文件:句柄里仍可能保留等待压缩的输入,或者等待系统写入的压缩输出。

flowchart TD
    Write["gzwrite 接收未压缩输入"] --> Mode{"输出模式"}
    Mode -->|gzip| Codec["缓冲或压缩输入"]
    Mode -->|透明| Direct["原样写入字节"]
    Codec --> IO["向描述符写入字节"]
    Direct --> IO
    IO --> Live["带位置和错误状态的活句柄"]
    Live --> Close["gzclose:尝试完成待处理工作"]
    Close --> Release["释放缓冲和状态;尝试关闭描述符"]
    Release --> Gone["句柄销毁;最终 输入输出 失败也一样"]

硬失败可能在返回失败之前已经消费一段前缀,因此返回零不总意味着可以从头安全重发整个请求。非阻塞写入暂时无法继续时,调用则可能报告较短的接收量;应用需要遵循相应的重试协议。按项写入同样只报告完整项,即使一个部分项已经被接收。建立重试循环前,应查看具体的 gzwrite 或 gzfwrite 契约。

程序把 gzerror 放在 gzclose 前面,原因是必须在句柄仍存活时检查错误。gzerror 返回的文本可能在后续操作替换或清理错误消息时失效。写入句柄的关闭操作尝试补齐待处理零字节并执行 Z_FINISH,然后即使输出失败,也会释放存储并尝试关闭描述符。读取句柄的关闭操作不会解码剩余全部输入来验证它。关闭报错并不让有效句柄继续存活:不要再次关闭操作,也不要通过已销毁的句柄查询 gzerror。

写入句柄还要承担本例只读程序没有的最终完成义务。真实的写入句柄关闭主体,gzwrite.c:715–734记录补零或完成失败后,仍然继续释放缓冲、关闭描述符并释放句柄。这个顺序解释了:即使 gzwrite 看起来成功,也必须检查关闭操作,而且不能通过同一句柄重试关闭操作。

公开 gzip 索引按打开与配置、数据 输入输出、位置、状态和关闭组织入口。外部 输入输出 模型则另行说明当前形式化世界能表示哪些文件行为。先理解文件 API,才能判断一个关于该世界的定理究竟覆盖什么;编解码器结果和普通文件模型结果,都不能自动覆盖所有操作系统行为。

这一机制的证明思路应用接口的证明思路 ↗
ZLIB × LEAN

全局目录

当前位置gzip 文件与缓冲 I/O