简介:这是一份面向C++开发者的zip解压实现资源,基于Zlib库完成,适合需要在Visual Studio环境中快速集成解压功能的开发者参考。作者在编译官方zlib源码时遇到大量VS工程报错,经逐一排查后重新封装为myZlib库,形成一套可直接使用的32位Debug库文件;库文件集中在myZlib文件夹下,并随附myZlib.h使用说明。资源共包含738个文件,压缩包约3.58MB,涵盖h/cpp/c源文件、vcxproj/vcproj工程文件、sln解决方案以及lib/dll/pdb等编译产物;同时保留makefile、bat构建脚本和官方readme等原始文件,便于对比学习与二次开发。myZlib.h中写有封装说明,使用者无需再从零梳理zlib源码结构,可直接调用解压接口,也可从作者的修正过程中获得工程配置和编译排错思路。目前已有758人学习下载,适合需要实际动手集成解压功能、或想深入理解Zlib在Windows下编译细节的C++开发者。
1. C++ 解 ZIP 卡在第一步:Zlib 不负责 ZIP 容器
用 C++ 实现 ZIP 文档解压、基于 Zlib 起步,是很多开发者踩过的第一道坎:装好库、包含头文件,然后对着一个 .zip 文件调用 compress / uncompress,结果不是 Z_DATA_ERROR 就是根本不知道从哪读起。原因很反直觉——Zlib 只认识 Deflate 压缩流,它不负责 ZIP 文件的外层结构。ZIP 文件是一套容器:文件尾有 EOCD 记录,中间有中央目录,每个文件紧跟着本地文件头,里面记录文件名、压缩方法、CRC32、偏移量。这篇文章把这三层结构拆开讲清楚,给出一套最小可运行的 C++ 解压实现,再挑几个最容易翻车的点逐一说透。适合做轻量集成的后端、做工具链的桌面端,以及想自己控制内存的嵌入式开发者。
2. 先读透 ZIP 二进制布局:EOCD、中央目录与本地头
ZIP 文件不是“一串压缩数据”,而是“一个容器 + 多条压缩数据流”。容器负责索引、校验和描述,压缩数据流才交给 Zlib。绝大多数解压失败,都是因为把容器结构和数据流混为一谈。这一章先把 ZIP 的三个关键结构讲清楚,代码放到第 3 章。
2.1 本地文件头:每个文件自带的身份证
ZIP 里每个文件条目都由“本地文件头 + 压缩数据”组成,本地文件头固定 30 字节,后面跟着变长的文件名和扩展字段:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | 签名 0x04034b50 | 本地文件头固定标志 |
| 4 | 2 | 解压所需最低版本 | 版本过高要提示用户 |
| 6 | 2 | 通用标志位 | bit0 加密、bit3 数据描述符、bit11 UTF-8 文件名 |
| 8 | 2 | 压缩方法 | 0=Store 不压缩,8=Deflate |
| 10 | 2 | 文件修改时间 | DOS 格式,多数场景用不上 |
| 12 | 2 | 文件修改日期 | 同上 |
| 14 | 4 | CRC32 | 未压缩数据的校验值 |
| 18 | 4 | 压缩后大小 | 注意可能是 0,见 2.2 |
| 22 | 4 | 未压缩大小 | 同上 |
| 26 | 2 | 文件名长度 | 单位是字节 |
| 28 | 2 | 扩展字段长度 | 单位是字节 |
| 30 | 变长 | 文件名 | 编码见第 4 章避坑 |
本地文件头是“局部信息”,它的作用是告诉你:这个文件从哪里开始、压缩方法是什么、数据区在哪里。单看本地文件头不够,因为流式写入的 ZIP 会在数据写完后才补元数据,那时本地头里的 CRC 和大小字段可能还是 0。
2.2 中央目录与 EOCD:真正的解压入口在文件尾
中央目录是 ZIP 文件的“总索引”,集中在文件尾部,里面复制了每个条目的元数据,另外多记录了本地文件头的偏移量。解压器应该从中央目录出发,而不是从文件头出发逐个猜。
中央目录每条约 46 字节固定区,签名 0x02014b50,关键字段与本地头类似,多了 42 字节处的 local header offset,指向对应本地文件头在文件中的位置。中央目录后面是 EOCD(End Of Central Directory),它是整个 ZIP 的收尾记录:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | 签名 0x06054b50 | EOCD 固定标志 |
| 4 | 2 | 当前磁盘号 | 单文件 ZIP 恒为 0 |
| 6 | 2 | 中央目录起始磁盘 | 分卷压缩才非 0 |
| 8 | 2 | 本磁盘上的目录条数 | 正常等于总条数 |
| 10 | 2 | 中央目录总条数 | 也就是文件条目数 |
| 12 | 4 | 中央目录字节数 | 读取中央目录的大小依据 |
| 16 | 4 | 中央目录起始偏移 | 相对 ZIP 文件头 |
| 20 | 2 | 注释长度 | 尾部注释最多 65535 字节 |
| 22 | 变长 | 注释 | 解压时必须跳过 |
EOCD 的注释长度可变,所以 EOCD 签名不一定在文件末尾的固定位置。标准做法是从文件尾部往回扫,最多回扫 22 + 65535 字节,找到签名后再校验中间几个字段,防止注释内容里碰巧出现同样的四字节。这也是解压 ZIP 的第一个关键动作:先找文件尾,再定位文件头。
2.3 窗口参数 windowBits=-15:ZIP 用的是裸 Deflate 流
Zlib 的 inflate 有两种模式:带 zlib wrapper 的流和裸 Deflate 流。ZIP 里 method=8 的文件,数据区就是纯粹的 Deflate 压缩流,没有 zlib 头(0x78 0x9C),也没有尾部 Adler-32 校验。调用 inflateInit 时用默认参数15,inflate 会在开头找 zlib wrapper,找不到就直接返回 Z_DATA_ERROR。
正确写法是inflateInit2(&strm, -15),负号表示 raw Deflate。这个参数是解 ZIP 最常见的失分点,也是“明明数据没问题却解不开”的第一嫌疑。
| windowBits 取值 | 模式 | 适用场景 |
|---|---|---|
| 15 | zlib 格式 | compress2() 产出的 .z 流 |
| -15 | raw Deflate | ZIP 文件里的 method=8 数据 |
| 31 | gzip 格式 | .gz 文件 |
| 15 + 32 | 自动检测 zlib/gzip | zlib 1.2.x 起支持 |
提示:不要用 compress2() 造出数据来反向验证解压器——它产生的是带 zlib 头的流,不是 ZIP 数据。测试样本用系统 zip 命令或任何文件管理器生成即可。
3. 手写最小 C++ 解压器:从 EOCD 回扫到 inflate 落盘
这一章给出一个能跑通普通 ZIP 的最小实现:支持 Store(不压缩)和 Deflate 两种方法,包含 CRC32 校验,暂不处理 ZIP64 与加密。代码用 C++17 加 Zlib,文件读写用标准库。
3.1 回扫 EOCD:入口在文件尾,而不是文件头
先写两个小端读取函数,ZIP 规范里所有多字节整数都是小端序,这点不能记反。
#include <cstdint> #include <fstream> #include <vector> #include <string> #include <zlib.h> using u16 = uint16_t; using u32 = uint32_t; using u64 = uint64_t; static u16 rd16(const std::vector<unsigned char>& b, size_t o) { return (u16)b[o] | ((u16)b[o + 1] << 8); } static u32 rd32(const std::vector<unsigned char>& b, size_t o) { return (u32)b[o] | ((u32)b[o + 1] << 8) | ((u32)b[o + 2] << 16) | ((u32)b[o + 3] << 24); }rd16/rd32从字节数组的指定偏移读小端整数。ZIP 文件里所有长度、偏移、CRC 字段都用这两个函数取,后面解析结构全部依赖它。
bool find_eocd(std::ifstream& in, u64 file_size, u32& cd_offset, u32& cd_size, u16& entry_count) { const u64 max_tail = 65557; // 22 字节固定区 + 最大 65535 字节注释 u64 tail_len = (file_size < max_tail) ? file_size : max_tail; std::vector<unsigned char> tail((size_t)tail_len); in.clear(); in.seekg((std::streamoff)(file_size - tail_len)); in.read((char*)tail.data(), (std::streamsize)tail_len); if (in.gcount() != (std::streamsize)tail_len) return false; for (long long off = (long long)tail_len - 22; off >= 0; --off) { if (rd32(tail, (size_t)off) != 0x06054b50u) continue; u16 disk_no = rd16(tail, (size_t)off + 4); u16 cd_disk = rd16(tail, (size_t)off + 6); u16 disk_entries = rd16(tail, (size_t)off + 8); u16 total_entries = rd16(tail, (size_t)off + 10); if (disk_no != 0 || cd_disk != 0) continue; // 多磁盘 ZIP 不支持 if (disk_entries != total_entries) continue; // 分卷场景直接拒绝 cd_size = rd32(tail, (size_t)off + 12); cd_offset = rd32(tail, (size_t)off + 16); entry_count = total_entries; return true; } return false; }逻辑说明:先算尾部最大可能长度,把文件尾读进内存,从尾部往前逐字节找 EOCD 签名。找到后校验磁盘号和条目数,目的是排除“注释内容里恰好出现签名”的假阳性。参数max_tail = 65557是 ZIP 规范允许的最大 EOCD 总长度,少读会漏,多读没必要。循环用long long而非size_t,避免 off 减到 0 时下溢出成巨大正数。
3.2 解析中央目录:拿到每个文件的元数据
中央目录是解压的信息主源,解析时要跳过文件名、扩展字段、注释三段变长数据,才能移动到下一条。
struct Entry { std::string name; u16 method = 0; u16 flag = 0; u32 crc32v = 0; u64 comp_size = 0; u64 uncomp_size = 0; u32 local_offset = 0; }; bool parse_central_dir(std::ifstream& in, u32 cd_offset, u32 cd_size, std::vector<Entry>& out) { std::vector<unsigned char> cd(cd_size); in.clear(); in.seekg((std::streamoff)cd_offset); in.read((char*)cd.data(), (std::streamsize)cd_size); if (in.gcount() != (std::streamsize)cd_size) return false; size_t pos = 0; while (pos + 46 <= cd.size()) { if (rd32(cd, pos) != 0x02014b50u) break; Entry e; e.flag = rd16(cd, pos + 8); e.method = rd16(cd, pos + 10); e.crc32v = rd32(cd, pos + 16); u32 csize = rd32(cd, pos + 20); u32 usize = rd32(cd, pos + 24); u16 name_len = rd16(cd, pos + 28); u16 extra_len = rd16(cd, pos + 30); u16 comment_len = rd16(cd, pos + 32); e.local_offset = rd32(cd, pos + 42); if (csize == 0xFFFFFFFFu || usize == 0xFFFFFFFFu || e.local_offset == 0xFFFFFFFFu) { pos += 46 + name_len + extra_len + comment_len; continue; // ZIP64 入口,本章先跳过 } e.comp_size = csize; e.uncomp_size = usize; e.name.assign((const char*)&cd[pos + 46], name_len); out.push_back(std::move(e)); pos += 46 + name_len + extra_len + comment_len; } return true; }逻辑说明:解析完全按 2.2 的字段布局走。中央目录条目固定区是 46 字节,后续三个变长段逐条跳过。参数pos + 46处的name_len、extra_len、comment_len必须累加进 pos,漏掉任何一个都会导致后续解析错位,这是手写 ZIP 解析最容易出隐性问题的地方。遇到 0xFFFFFFFF 的字段值,说明文件是 ZIP64,最小实现先跳过,第 6 章再讲识别链。
3.3 逐文件处理:定位本地头并调用 inflate
从中央目录拿到的local_offset指向本地文件头,先读 30 字节确认签名,算出数据区偏移,再按压缩方法分流。
bool extract_one(std::ifstream& in, const Entry& e, const std::string& out_path) { in.clear(); in.seekg((std::streamoff)e.local_offset); std::vector<unsigned char> lh(30); in.read((char*)lh.data(), 30); if (in.gcount() != 30 || rd32(lh, 0) != 0x04034b50u) return false; u16 name_len = rd16(lh, 26); u16 extra_len = rd16(lh, 28); u64 data_off = (u64)e.local_offset + 30 + name_len + extra_len; if (e.method == 0) { std::vector<unsigned char> buf(e.comp_size); in.clear(); in.seekg((std::streamoff)data_off); in.read((char*)buf.data(), (std::streamsize)buf.size()); if (in.gcount() != (std::streamsize)buf.size()) return false; std::ofstream ofs(out_path, std::ios::binary); ofs.write((const char*)buf.data(), (std::streamsize)buf.size()); return ofs.good(); } if (e.method != 8) return false; // 只支持 Store(0) 与 Deflate(8) std::vector<unsigned char> in_buf(e.comp_size); in.clear(); in.seekg((std::streamoff)data_off); in.read((char*)in_buf.data(), (std::streamsize)in_buf.size()); if (in.gcount() != (std::streamsize)in_buf.size()) return false; std::vector<unsigned char> out_buf(e.uncomp_size); z_stream strm = {}; strm.next_in = in_buf.data(); strm.avail_in = (uInt)in_buf.size(); strm.next_out = out_buf.data(); strm.avail_out = (uInt)out_buf.size(); if (inflateInit2(&strm, -15) != Z_OK) return false; // -15: raw deflate int ret = inflate(&strm, Z_FINISH); u64 got = strm.total_out; inflateEnd(&strm); if (ret != Z_STREAM_END) return false; if (got != e.uncomp_size) return false; u32 crc_calc = (u32)crc32(0L, out_buf.data(), (uInt)out_buf.size()); if (crc_calc != e.crc32v) return false; std::ofstream ofs(out_path, std::ios::binary); ofs.write((const char*)out_buf.data(), (std::streamsize)out_buf.size()); return ofs.good(); }逻辑说明:Store 方法没有压缩,直接把压缩区字节复制到目标文件;Deflate 方法则把压缩数据整个读入,调用inflateInit2(&strm, -15)进入 raw 模式,用inflate(..., Z_FINISH)一次解出全部数据。参数-15是核心,去掉负号会得到 Z_DATA_ERROR。Z_FINISH要求一次性喂完整输入且输出缓冲足够大,这个写法简单,但只适合中小文件,大文件直接内存失控,第 5 章会改成流式。
3.4 写盘前的双重校验:total_out 与 CRC32
上面的extract_one里做了两道校验:解出的字节数必须等于中央目录声明的uncomp_size,CRC32 必须等于中央目录里的crc32v。两道校验是互补的,字节数一致只能说明 inflate 没提前结束,CRC 一致才能证明数据没有被篡改或损坏。
我一般会把 CRC 校验放在写盘之前,不要在写盘之后补校验。一旦发现 CRC 不匹配,文件已经污染了目标目录,重试逻辑会变得很脏。另外注意crc32()的长度参数是uInt,在 64 位系统上如果uncomp_size超过 4GB 会截断,这也是为什么第 5 章必须用分块累计的方式算 CRC。
提示:先把第 3 章代码接到一个 main 里,用系统 zip 命令生成一个只有几个小文件的样本跑通,再处理复杂场景。不要一上来就测大文件、加密包、中文名。
4. 解压避坑:中文名、CRC 失败、bit3 与路径穿越的排查记录
这一章是真正花时间调过的几条血泪经验,按“现象→原因→解决”写,每一条都值得在实现前先读一遍。
4.1 文件名乱码的真相:bit11 与 GBK 回退
现象:解出来的文件名在 Windows 下全乱码,尤其是国内用 GUI 工具压出来的包;用命令行 zip 压的包又正常。
原因:ZIP 规范对文件名编码有明确说法——通用标志位 bit11(0x0800)置位时,文件名按 UTF-8 解释;未置位时,规范默认用 CP437(OEM 编码)。但国内主流压缩工具实际按系统 ANSI 代码页(GBK)写文件名,且不置 bit11。于是这批文件在 Linux / macOS 下按 UTF-8 硬解,字节错位,满屏乱码。
解决:解析中央目录时保留 flag,文件名解码逻辑这样走:bit11 置位直接按 UTF-8 处理;未置位先尝试 GBK 转 UTF-8,转换失败再退 CP437。Windows 下用系统 API 做转换:
#ifdef _WIN32 std::string gbk_to_utf8(const std::string& s) { if (s.empty()) return s; int wlen = MultiByteToWideChar(CP_ACP, 0, s.c_str(), -1, nullptr, 0); std::wstring wbuf(wlen ? wlen - 1 : 0, L'\0'); MultiByteToWideChar(CP_ACP, 0, s.c_str(), -1, wbuf.data(), wlen); int ulen = WideCharToMultiByte(CP_UTF8, 0, wbuf.c_str(), -1, nullptr, 0, nullptr, nullptr); std::string u(ulen ? ulen - 1 : 0, '\0'); WideCharToMultiByte(CP_UTF8, 0, wbuf.c_str(), -1, u.data(), ulen, nullptr, nullptr); return u; } #endif注意CP_ACP不能改成CP_UTF8,否则未置 bit11 的 GBK 名还是会被 UTF-8 硬解,等于没修。Linux 下用 iconv 做同样的 GB18030 到 UTF-8 转换,实践中 glibc 自带的编码表覆盖 GBK 没问题。
4.2 CRC32 校验失败:先查 inflate 的结束状态
现象:inflate 返回 Z_OK,文件也写出来了,但 CRC 比对始终不一致,有时解出来的文件大小还差几 KB。
原因:inflate(..., Z_FINISH)返回 Z_STREAM_END 才代表“输入完整消化、输出完全生成”。只解一次且输出缓冲正好被填满时,inflate 会返回 Z_OK,这不是错误,是“还有数据待解”。如果这时候直接拿输出缓冲算 CRC,算的是半截数据。另一个常见原因是中央目录里的 uncomp_size 与实际解压结果不一致,inflate 可能提前遇到 Z_BUF_ERROR。
解决:不管用哪种循环,必须等ret == Z_STREAM_END才算完成,退出后同时校验total_out == uncomp_size和 CRC。排查顺序固定为三查:先看 inflate 返回值,再看 total_out,最后算 CRC。前两项过了 CRC 还不对,才是真的数据损坏。
4.3 本地头里 size 全 0:bit3 数据描述符在数据尾
现象:某些 ZIP 的本地文件头里 CRC、压缩大小、未压缩大小全为 0,按本地头字段去读数据区,读出来的字节数不对,inflate 中途报错。
原因:制作者用了流式写入,写入时不知道最终大小,于是把通用标志位 bit3(0x0008)置位,本地头先写 0,真实元数据放在压缩数据末尾的 Data Descriptor 里。本地头在这种包里的信息是不完整的。
解决:永远以中央目录为准。中央目录是在所有数据写完后生成的,里面的 size、CRC 一定是最终值。本地头只用来取文件名长度和扩展字段长度以计算数据区偏移,不要读它的 size 字段。第 3 章的extract_one已经按这个思路写:data_off用local_offset + 30 + name_len + extra_len计算,size 全取Entry里的值。
4.4 大文件内存失控:一次性 inflate 的解压比
现象:解压一个几百 MB 的文本型压缩包,进程内存直接飙到 GB 级,嵌入式环境直接被杀。
原因:第 3 章的extract_one先把整个压缩数据读入 vector,再分配整个未压缩大小的 vector。文本类数据压缩比动辄 5:1、10:1,一个 100MB 的包解出 800MB 很正常,一次性分配就是自找爆内存。这个方法不是错,但不适合大文件。
解决:换流式解压,输入按 64KB 读、输出按 64KB 写,内存占用与文件大小无关。完整循环在第 5.1 节,这里只给结论:解压器上线前,必须拿一个超过 1GB 未压缩大小的样本压测,内存峰值应该稳定在几十 MB 量级。
4.5 zip slip:路径穿越不检查,解压即漏洞
现象:压缩包里有条目叫../evil.exe或..\\..\\windows\\x.dll,直接拼路径解压,文件被写到目标目录之外。
原因:ZIP 文件名完全由制作者控制,任何压缩工具都能造出带../的条目名。老牌压缩工具曾出现过这个洞,本质是解压器把文件名当普通字符串拼接,没有做规范化。
解决:拼接输出路径前先做归一化,把反斜杠转正斜杠、清除.和..片段、拒绝绝对路径和盘符路径:
bool normalize_safe(const std::string& name, std::string& out) { std::string norm = name; std::replace(norm.begin(), norm.end(), '\\', '/'); if (norm.empty() || norm[0] == '/') return false; if (norm.find(':') != std::string::npos) return false; std::vector<std::string> parts; std::stringstream ss(norm); std::string part; while (std::getline(ss, part, '/')) { if (part.empty() || part == ".") continue; if (part == "..") { if (parts.empty()) return false; parts.pop_back(); } else { parts.push_back(part); } } if (parts.empty()) return false; out.clear(); for (size_t i = 0; i < parts.size(); ++i) { if (i) out += '/'; out += parts[i]; } return true; }逻辑说明:..回退时如果栈为空,说明路径试图越过根目录,直接拒绝。注意这只防了“相对路径穿越”,如果目标目录本身在挂载点边缘,建议再加一层std::filesystem::weakly_canonical校验。路径穿越不是概率问题,只要不检查,恶意包一解一个准。
5. 工程化:64KB 流式缓冲、进度回调与按文件并行
第 3 章的最小实现能跑,但离“可以放进产品里”还差三件事:内存可控的流式解压、可观测的进度接口、以及多线程解压的边界处理。这一章把这三件事补齐。
5.1 流式解压循环:输入输出双缓冲的标准写法
核心思路:输入缓冲用完就补,输出缓冲写满就落盘,inflate 在 Z_OK 和 Z_STREAM_END 之间循环。
using ProgressCb = std::function<bool(u64 done, u64 total)>; // 返回 false 可中断 bool extract_streaming(std::ifstream& in, const Entry& e, const std::string& out_path, ProgressCb cb) { in.clear(); in.seekg((std::streamoff)e.local_offset); std::vector<unsigned char> lh(30); in.read((char*)lh.data(), 30); if (in.gcount() != 30 || rd32(lh, 0) != 0x04034b50u) return false; u16 name_len = rd16(lh, 26), extra_len = rd16(lh, 28); u64 data_off = (u64)e.local_offset + 30 + name_len + extra_len; std::ofstream ofs(out_path, std::ios::binary); if (!ofs) return false; if (e.method == 0) { std::vector<unsigned char> buf(64 * 1024); u64 remaining = e.comp_size; in.clear(); in.seekg((std::streamoff)data_off); while (remaining > 0) { size_t want = (size_t)std::min<u64>(buf.size(), remaining); in.read((char*)buf.data(), (std::streamsize)want); if (in.gcount() != (std::streamsize)want) return false; ofs.write((const char*)buf.data(), want); remaining -= want; if (cb && !cb(e.uncomp_size - remaining, e.uncomp_size)) return false; } return ofs.good(); } if (e.method != 8) return false; const size_t IN_CHUNK = 64 * 1024, OUT_CHUNK = 64 * 1024; std::vector<unsigned char> inbuf(IN_CHUNK), outbuf(OUT_CHUNK); z_stream strm = {}; if (inflateInit2(&strm, -15) != Z_OK) return false; u64 bytes_left = e.comp_size; u32 crc = crc32(0L, Z_NULL, 0); u64 produced = 0; int ret = Z_OK; in.clear(); in.seekg((std::streamoff)data_off); while (ret != Z_STREAM_END) { if (strm.avail_in == 0 && bytes_left > 0) { size_t want = (size_t)std::min<u64>(IN_CHUNK, bytes_left); in.read((char*)inbuf.data(), (std::streamsize)want); size_t got = (size_t)in.gcount(); if (got == 0) { ret = Z_DATA_ERROR; break; } bytes_left -= got; strm.next_in = inbuf.data(); strm.avail_in = (uInt)got; } strm.next_out = outbuf.data(); strm.avail_out = (uInt)OUT_CHUNK; ret = inflate(&strm, Z_NO_FLUSH); if (ret != Z_OK && ret != Z_STREAM_END) break; size_t n = OUT_CHUNK - strm.avail_out; if (n > 0) { ofs.write((const char*)outbuf.data(), (std::streamsize)n); crc = crc32(crc, outbuf.data(), (uInt)n); produced += n; if (cb && !cb(produced, e.uncomp_size)) { inflateEnd(&strm); return false; } } } inflateEnd(&strm); if (ret != Z_STREAM_END) return false; if (produced != e.uncomp_size || crc != e.crc32v) return false; return ofs.good(); }逻辑说明:这个循环是全文核心。bytes_left来自中央目录的comp_size,每次输入缓冲耗尽就从文件补一段;inflate用Z_NO_FLUSH,输出缓冲满时返回 Z_OK,循环继续,直到返回 Z_STREAM_END。CRC 在每轮输出后分块累计,绕开了crc32()单次uInt长度的限制。参数层面,输入输出都是 64KB,这个值不用贪大——解压瓶颈通常在磁盘 IO 和 Zlib 的 Huffman 解算,缓冲加倍不会带来吞吐提升,反而拖累缓存命中率。进度回调返回 false 表示调用方主动取消,解压器要能干净退出。
5.2 对外 API 与进度:把解压器变成可观测组件
流式循环写好了,接下来把它包成一个类,让调用方不关心内部结构,只看得到文件列表和解压结果:
class ZipArchive { public: bool open(const std::string& path) { // find_eocd + parse_central_dir 的内部结果存放在 entries_ 中 } const std::vector<Entry>& entries() const { return entries_; } bool extract(size_t idx, const std::string& out_dir, ProgressCb cb) { const Entry& e = entries_[idx]; std::string safe; if (!normalize_safe(e.name, safe)) return false; std::string full = out_dir + "/" + safe; if (e.name.back() == '/') { return std::filesystem::create_directories(full); } std::filesystem::create_directories(std::filesystem::path(full).parent_path()); return extract_streaming(file_, e, full, cb); } private: std::ifstream file_; std::vector<Entry> entries_; };这个接口的设计点是:extract只暴露索引,调用方先遍历 entries 决定解哪些、跳过哪些,而不是传一个裸文件名进来。目录条目以/结尾、未压缩大小为 0,直接创建目录而不是当文件写。Windows 下还有一个隐藏坑:std::ofstream在 MSVC 上碰到 UTF-8 路径会乱,构造输出路径前要把 UTF-8 string 转成 wstring 再开文件,规则与 4.1 的转换方向相反。
5.3 多线程边界:stream 隔离、独立句柄与磁盘竞争
ZIP 解压天然适合按文件并行——每个文件的数据区互相独立,本地文件头里自带偏移。但并行时有三条边界必须守住。
第一,Zlib 的z_stream不是线程安全的,多个线程绝不能共享同一个 stream。稳妥模型是“每线程一个文件、每个文件一个 stream”,中央目录解析保持单线程。第二,std::ifstream的内部读写位置是共享状态,多个线程共用同一个ifstream交替 seekg 和 read 会互相踩位置。常见做法是每个工作线程各自 open 同一个 ZIP 文件,拿到独立句柄后按local_offset定位,互不干扰。第三,写磁盘的竞争比想象中大:多个线程同时写同一物理机械硬盘,随机寻道时间叠加,吞吐可能反降。SSD 场景可以放松些,但线程数控制在物理核数以内更稳。
// 伪代码示意:按文件粒度分发 void extract_all_parallel(ZipArchive& zip, const std::string& out_dir, int threads) { std::atomic<int> failed_count{0}; std::vector<std::future<bool>> futures; for (size_t i = 0; i < zip.entries().size(); ++i) { futures.push_back(std::async(std::launch::async, [&, i] { ZipArchive local; local.open(zip_path); // 每个线程独立打开同一文件 return local.extract(i, out_dir, nullptr); })); if (futures.size() >= (size_t)threads) { // 收集已完成任务,失败计数累加 } } }注意不要直接用omp parallel for套住ZipArchive::extract,如果类内部持有单个ifstream,并行时数据竞争是隐性的。线程数我给的建议是 4 到物理核数取小,小文件密集的包并行收益有限,瓶颈在目录创建和文件系统元数据,而非 CPU。
6. 进阶:ZIP64、加密包与现实中的取舍
6.1 ZIP64 的识别链:哪些字段值在告诉你“别硬解”
ZIP64 不是一种新格式,而是一套“超出 32 位时使用的扩展字段”。识别链有三层:EOCD 里entry_count == 0xFFFF或cd_offset == 0xFFFFFFFF时,要在 EOCD 前 20 字节找 ZIP64 Locator(签名 0x07064b50),从中读出 8 字节的 ZIP64 EOCD 偏移;再往 Entry 看,comp_size、uncomp_size、local_offset任一为 0xFFFFFFFF,就要去扩展字段里找 Headers ID 0x0001,按顺序读取 64 位值。
| 字段值 | 含义 | 处理动作 |
|---|---|---|
| EOCD 的 cd_offset = 0xFFFFFFFF | 中央目录偏移超过 4GB | 找 ZIP64 Locator |
| EOCD 的 entry_count = 0xFFFF | 条目数超过 65535 | 同上 |
| 条目 size = 0xFFFFFFFF | 单个文件超过 4GB | 读 extra field ID 0x0001 |
| 本地头 size = 0xFFFFFFFF | 配合 bit3 常见 | 仍以中央目录为准 |
最小实现遇到这些字段直接跳过是安全的,但产品里不能这样。写全 ZIP64 支持至少要多处理两层结构,边际成本明显上升——这时候直接换成熟封装更现实。
6.2 加密 ZIP:Zlib 不碰密钥,别自己补
加密 ZIP 是另一套战场。通用标志位 bit0 置位时,本地头后紧跟 12 字节加密头,压缩数据被传统 PKWARE 加密算法保护。这种算法基于三个 32 位内部状态配合 CRC 表逐字节迭代,Zlib 完全不参与,必须在 inflate 之前先解密出 Deflate 数据流。WinZip AES 加密更复杂,涉及 AES-256、认证码和额外的盐值字段。结论很明确:有加密需求的场景,不要在自己 Zlib 壳上加解密逻辑,直接接带 AES 支持的开源封装,否则你会在密钥流和校验字段里消耗大量时间。
6.3 换 minizip 的信号:稳定压倒一切的时刻
有个决策标准我用了很多年:如果压缩包可能包含 ZIP64、AES 加密、分卷、Unix 扩展属性这些“合法但不常见”的特性,就不要维护自己的解析器。minizip 这类基于 Zlib 的封装已经把这些边角处理完,API 也简单。自研方案只适合三种情况:嵌入式环境不允许引入额外依赖、需要完全控制内存和流式写入、或者纯粹把 ZIP 格式当学习材料。决策表供参考:
| 场景 | 选择 |
|---|---|
| 只解本地工具生成的普通 Deflate 小包 | 自研,控制力最强 |
| 包可能来自第三方、格式不固定 | 直接上成熟封装 |
| 有加密需求 | 直接上带 AES 支持的封装 |
| 嵌入式无动态库 | 自研,但要接受边界残缺 |
我做这套解压器时最深的教训,不是 inflate 参数,而是顺序:一开始想当然从文件头开始读,结果被 bit3 的 0 字段和 zigzag 偏移折腾了很久,后来把“入口在文件尾、信息在中央目录、本地头只是线索”写进了接口注释,后面所有问题都顺了。先跑通最小实现,再补流式、并行和边界特性,这条路我走了多次,每一次都比直接上大而全的库更扎实。希望帮到你。
本文还有配套的精品资源,点击获取