资讯详情 用zlib手写C++ zip解压器:从EOCD定位到inflate落地
📅 2026/10/15 1:49:31
简介基于Zlib二次封装的C解压库myZlib面向需要在VS环境下集成zip解压功能的C开发者也适用于对库移植、编译排错感兴趣的中高级用户。资源将原先在VS中频繁报错的zlib源码逐项修正后重新封装提供完整修改源码、32位Debug版预编译库文件位于myZlib文件夹以及集中说明用法与接口的myZlib.h头文件头文件内含关键接口注释与调用示例拿到后即可在工程中调用省去重复排查时间。压缩包共738个文件约3.58MB除121个h/hpp头文件、58个cpp与42个c源文件外还包含vcproj、vcxproj、sln等工程配置、makefile脚本及obj、lib、dll等构建产物并附有txt说明、readme及chm/pdf文档完整保留了zlib多平台编译体系便于对照不同IDE的使用方式。已有758人学习适合需要快速解决C解压问题或深入理解Zlib库构建流程的开发者尤其可作为VS工程配置与第三方库封装时的参考样例。1. 用 Zlib 手写 C zip 解压器从 EOCD 到文件落地的完整实现很多刚接触 zlib C 解压 zip 的开发者第一反应是找现成的解压库或者以为调用uncompress就能搞定。实际上 zlib 核心只处理 deflate/inflate 压缩流zip 作为一种容器格式它的目录结构、文件头、编码标志位都要自己动手解析。这篇笔记把手写 C zip 解压器从零到能用的完整链路拆开讲怎么定位 EOCD中央目录结束记录、怎么遍历中央目录、怎么用inflate把每个文件解出来以及我实际踩过的四个高频翻车点。如果你想在嵌入式或服务端场景里控制整个解压流程不想为了解个 zip 引入几百 KB 的第三方大库这篇内容值得照着敲一遍。2. 动手前的准备把 zip 结构和 zlib 接口之间的关系理清楚2.1 zip 的三层索引结构为什么 zlib 不直接解 zip先说一个反直觉的事zlib 只认识 deflate 压缩流不认识 zip。zip 文件本质是一个“容器”里面装着若干个被压缩或未压缩的文件再加上一份目录索引。想用 zlib 解 zip必须先自己把这份容器结构读出来再对每个文件单独调用 inflate。zip 文件末尾有一个叫 EOCDEnd of Central Directory的结构固定 22 字节签名是0x06054b50。EOCD 里记录了中央目录的偏移量和大小而中央目录Central Directory才是所有文件的索引表每一条记录对应一个文件包含文件名、压缩方式、压缩前后大小、CRC32 值、本地文件头偏移量等信息。本地文件头Local File Header则直接躺在文件数据的前面告诉你数据从哪个字节开始。换句话说解压流程是先跑到文件末尾找到 EOCD再从 EOCD 拿到中央目录偏移然后遍历中央目录拿到每个文件的本地头偏移最后跳到数据区开始 inflate。这三层索引关系是解压器最核心的逻辑先把图画在脑子里后面写代码才不会迷路。// EOCD 结构小端字节序 struct EocdRecord { uint16_t disk_number; // 分盘编号一般用不到 uint16_t central_dir_disk; // 中央目录所在磁盘 uint16_t entries_on_disk; // 当前盘上的目录条目数 uint16_t total_entries; // 总条目数 uint32_t central_dir_size; // 中央目录总大小字节 uint32_t central_dir_offset; // 中央目录起始偏移 uint16_t comment_length; // 注释长度后面跟着变长注释 };EOCD 结构的字节序是小端Little Endianx86 平台可以直接按整型读但如果你在 ARM 大端平台上跑就得手动转字节序。我在写的解压器里加了ReadU16LE/ReadU32LE这两个辅助函数后续所有字段解析统一走这两个函数避免在不同架构之间来回改。2.2 zlib 的选型问题inflateInit2 才是关键入口有人会问既然 zlib 不够用那为什么不直接用 libzip 或者 minizip我的选择是 zlib 自写解析层理由有三点。第一zlib 是事实标准任何 Linux 发行版和嵌入式交叉编译工具链基本都自带不需要额外下载第三方源码包。第二zip 的 deflate 压缩流是 raw deflate不携带 zlib 头zlib 的inflateInit2(strm, -15)正好支持这种裸流模式接口的干净程度远超 minizip。第三自写解析层意味着你可以精准控制内存分配和 IO 方式这在资源受限的嵌入式环境里是很大的优势minizip 的 IO 回调抽象反而会增加阅读成本。安装 zlib 环境本身不难Linux 下用包管理器装开发库Windows 下用 vcpkg 也能快速搞定。# Ubuntu / Debian 安装 zlib 开发库 sudo apt-get install zlib1g-dev # macOS 可以用 homebrew brew install zlib # Windowsvcpkg ./vcpkg install zlib:x64-windows装好之后编译时链接-lz头文件包含zlib.h和zconf.h。在 CMake 里配置起来也很顺手cmake_minimum_required(VERSION 3.16) project(ZipExtractor CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(ZLIB REQUIRED) add_executable(zip_extractor main.cpp) target_link_libraries(zip_extractor ${ZLIB_LIBRARIES}) target_include_directories(zip_extractor PRIVATE ${ZLIB_INCLUDE_DIRS})find_package(ZLIB)会帮你把头文件路径和库路径都配好避免手写绝对路径带来的跨机器编译问题。需要注意的是在交叉编译环境下find_package不一定能命中目标平台的 zlib届时需要手动传入ZLIB_INCLUDE_DIR和ZLIB_LIBRARY两个 CMake 变量。3. 核心实现定位中央目录逐个文件 inflate 落地3.1 写 EOCD 扫描器注释坑和偏移校验实现解压器首先要解决“怎么找到 EOCD”。很多初学者直接读文件最后 22 字节然后满怀期待地解析结果发现签名对不上或者解析出来的偏移乱糟糟。原因很简单zip 文件末尾可能携带一个变长注释字段长度最大 65535 字节EOCD 不一定会整整齐齐地躺在最后 22 字节处。更稳妥的做法是从文件末尾往前扫描把最后 65535 22 字节读进内存然后在里面搜索0x06054b50签名。为什么边界是 65535 22EOCD 里 comment_length 是 16 位无符号整数最大 65535EOCD 固定体是 22 字节。从文件末尾往回最多偏移 65535 字节时再往后退 22 字节就是 EOCD 的起始位置。把这一段全部读进来从后往前找签名一旦找到就立刻从头解析。这里我加了一个校验读出来的 central_dir_offset 加上 central_dir_size 必须小于等于当前扫描块的长度否则说明解析出来的字段有问题多半是搜索到了二进制数据中的偶然签名。// 从文件尾部扫描 EOCD返回 EOCD 记录 bool FindEocd(std::ifstream file, EocdRecord eocd, uint32_t eocd_offset) { const uint32_t kMaxCommentLen 65535; const uint32_t kMaxSearchLen kMaxCommentLen 22; file.seekg(0, std::ios::end); std::streamoff file_len file.tellg(); if (file_len 22) return false; // 文件太小不可能是合法 zip uint32_t search_len (file_len kMaxSearchLen) ? kMaxSearchLen : static_castuint32_t(file_len); file.seekg(file_len - search_len, std::ios::beg); std::vectoruint8_t tail(search_len); file.read(reinterpret_castchar*(tail.data()), search_len); // 从尾部倒着找签名 0x06054b50 for (int i static_castint(search_len) - 22; i 0; --i) { if (tail[i] 0x50 tail[i1] 0x4b tail[i2] 0x05 tail[i3] 0x06) { // 从 tail 中解析 EOCD 字段 eocd.total_entries ReadU16LE(reinterpret_castuint8_t*(tail[i10])); eocd.central_dir_size ReadU32LE(reinterpret_castuint8_t*(tail[i12])); eocd.central_dir_offset ReadU32LE(reinterpret_castuint8_t*(tail[i16])); eocd_offset static_castuint32_t(file_len - search_len i); return true; } } return false; }这段代码里最值得注意的点是搜索时的i 22可以是search_len因为我们从缓冲区末尾的 22 字节位置开始往前比对正好覆盖 EOCD 恰好位于文件末尾的场景。如果这类扫描失败日志里要打印当前的eocd_offset作为排查线索这比你默默返回 false 要好排错得多。提示有些 zip 制作工具会写多个 EOCD 记录比如在注释里嵌入自解压信息只认从后往前找到的第一个签名即可不用继续往前找。3.2 中央目录解析每条记录读什么字段偏移必须记牢拿到central_dir_offset之后就可以跳到中央目录表逐个读取文件条目。中央目录条目 46 字节固定头部 变长字段文件名、额外字段、注释。固定头部的字段排列顺序是固定的这里给出每一个关键字段的偏移量因为 C 代码中没有现成的结构体对齐规则能满足这种紧凑排布必须用指针逐一读取。偏移大小字段名称含义04签名固定为 0x02014b5042version_made创建版本62version_needed解压所需最低版本82general_purpose_flag通用标志位102compression_method压缩方式0Stored8Deflate122mod_time修改时间142mod_date修改日期164crc32文件 CRC32 校验值204compressed_size压缩后大小244uncompressed_size解压后大小282file_name_length文件名长度302extra_field_length额外字段长度322file_comment_length注释长度342disk_number_start起始盘号362internal_attr内部属性384external_attr外部属性424local_header_offset本地文件头偏移每条记录的长度是46 file_name_length extra_field_length file_comment_length循环读取时用这个公式跳到下一条。我习惯先把所有条目都读取到内存里的结构体数组中再统一解压因为这样可以在解压前先做一次“总文件数、总解压大小”的预判防止某个恶意 zip 声称解压后 4GB 把磁盘写满。std::vectorCentralDirEntry ParseCentralDirectory(std::ifstream file, const EocdRecord eocd) { std::vectorCentralDirEntry entries; file.seekg(eocd.central_dir_offset, std::ios::beg); for (uint16_t i 0; i eocd.total_entries; i) { uint8_t header[46]; file.read(reinterpret_castchar*(header), 46); if (header[0] ! 0x50 header[1] ! 0x4b header[2] ! 0x01 header[3] ! 0x02) { throw std::runtime_error(bad central directory signature); } CentralDirEntry entry; entry.compression_method ReadU16LE(header[10]); entry.general_purpose_flag ReadU16LE(header[8]); entry.crc32 ReadU32LE(header[16]); entry.compressed_size ReadU32LE(header[20]); entry.uncompressed_size ReadU32LE(header[24]); entry.file_name_length ReadU16LE(header[28]); entry.extra_field_length ReadU16LE(header[30]); entry.file_comment_length ReadU16LE(header[32]); entry.local_header_offset ReadU32LE(header[42]); std::string name(entry.file_name_length, \0); file.read(name.data(), entry.file_name_length); entry.file_name name; // 跳过长字段和注释字段 file.seekg(entry.extra_field_length entry.file_comment_length, std::ios::cur); entries.push_back(entry); } return entries; }文件名长度、额外字段长度这些值都是直接从文件读出来的如果一个 zip 被故意破坏这些长度可能指向越界。我在解析时加了边界检查file_name_length 65535就直接报错避免后面的string分配超大内存导致 DoS。这类对不可信输入的保护在实际生产环境中比解压算法本身还重要。3.3 本地文件头和数据区inflate 前的最后一道坎中央目录记录只告诉你“本地文件头偏移”数据真正从哪里开始需要到本地文件头里再确认。本地文件头结构类似中央目录条目但字段更少30 字节固定头叫 “Local File Header”签名是0x04034b50后面同样跟着文件名长度和额外字段长度。数据区偏移 local_header_offset 30 file_name_length extra_field_length。这一步容易踩坑的地方是本地文件头里的文件名不一定和中央目录里一致做路径拼接的时候必须以本地文件头的文件名为准。但实际上正常压缩工具生成的 zip两边是一致的只有手工拼包或者损坏文件才会出现差异。我采取的做法是优先信任中央目录的字段因为你最终要写的文件路径来自中央目录本地文件头只用来定位数据区偏移。uint64_t LocateDataOffset(std::ifstream file, const CentralDirEntry entry) { file.seekg(entry.local_header_offset, std::ios::beg); uint8_t lfh[30]; file.read(reinterpret_castchar*(lfh), 30); // 检查签名 0x04034b50 if (lfh[0] ! 0x50 lfh[1] ! 0x4b lfh[2] ! 0x03 lfh[3] ! 0x04) { throw std::runtime_error(bad local file header signature); } uint16_t name_len ReadU16LE(lfh[26]); uint16_t extra_len ReadU16LE(lfh[28]); return entry.local_header_offset 30 name_len extra_len; }数据偏移找到后就是 zlib 的主场了。压缩方法为 8Deflate时数据区直接从 inflate 裸流开始压缩方法为 0Stored时数据区就是原始文件内容不需要 inflate。下面这段代码展示了如何对单个文件执行解压并用inflateInit2(strm, -15)打开 raw inflate 流。bool InflateToFile(std::ifstream in, uint64_t data_offset, uint32_t compressed_size, uint32_t uncompressed_size, const std::string output_path) { std::ofstream out(output_path, std::ios::binary); if (!out.is_open()) return false; in.seekg(data_offset, std::ios::beg); std::vectoruint8_t in_buf(65536); std::vectoruint8_t out_buf(65536); z_stream strm; strm.zalloc Z_NULL; strm.zfree Z_NULL; strm.opaque Z_NULL; strm.avail_in 0; strm.next_in Z_NULL; if (inflateInit2(strm, -15) ! Z_OK) return false; // -15 代表 raw deflate uint32_t remaining_in compressed_size; int err Z_OK; while (remaining_in 0 || strm.avail_in 0) { if (strm.avail_in 0) { uint32_t chunk std::min(remaining_in, static_castuint32_t(in_buf.size())); in.read(reinterpret_castchar*(in_buf.data()), chunk); strm.next_in in_buf.data(); strm.avail_in chunk; remaining_in - chunk; } strm.next_out out_buf.data(); strm.avail_out out_buf.size(); err inflate(strm, Z_NO_FLUSH); if (err Z_STREAM_END) break; if (err ! Z_OK) break; // Z_BUF_ERROR 除外见避坑章节 uint32_t produced out_buf.size() - strm.avail_out; out.write(reinterpret_castchar*(out_buf.data()), produced); } inflateEnd(strm); if (err ! Z_STREAM_END) return false; // 没到流末尾就是异常 // 处理最后一批输出 uint32_t produced out_buf.size() - strm.avail_out; out.write(reinterpret_castchar*(out_buf.data()), produced); return true; }这里有个隐藏细节循环条件里的strm.avail_in 0不是多余的。因为当remaining_in恰好读完但 inflate 内部可能还有残留数据等着输出比如某次调用只解出几十字节输出缓冲区就满了此时avail_in可能为 0但inflate函数仍需要在avail_out非空时再被调用一次才能把缓冲区的数据掏干净。我最初写的时候漏了这个条件导致小文件最后一个数据块丢失后来在对比 CRC 时才抓出来。4. 避坑解压 zip 时我遇到过的四个“血泪”问题4.1 现象inflate 返回 Z_DATA_ERROR解出来的内容全乱码原因zip 里的 deflate 流是 raw deflate不带 zlib 头那 2 字节0x78 0x9C前缀。如果直接把 windowBits 设成MAX_WBITS即 15inflate 会先试图解析 zlib 头发现第一个字节不是预期的头就报数据错误。解决必须用inflateInit2(strm, -15)负号表示省略 zlib 头纯粹解析 raw deflate。这也是整个解压器里最容易翻车的点没有之一。4.2 现象能够找到 EOCD 签名的位置但读出来的 central_dir_size 和 total_entries 对不上原因zlib 包里的 zip 文件尾注注释区里可能含有二进制垃圾恰好包含0x06054b50这个签名。或者有时候文件被刻意拼接前面一个 zip 的尾部和后面一个 zip 的头部在同一缓冲区里倒序扫描时先碰到假签名。解决扫描到签名后先按当前解析出来的central_dir_offset central_dir_size 当前找到的 EOCD 偏移来校验一致性不满足就继续往前找直到找到真正能匹配的为止。校验条件不成立时不要贸然使用数据这是我处理所有不可信文件时的一条铁律。4.3 现象在 Windows 上压缩的文件解压出来文件名乱码原因Windows 自带压缩工具写出来的 zip中文字段用的是 CP936GBK而 Linux 和 macOS 上的解压器默认按 UTF-8 解析。zip 规范里有个通用标志位general_purpose_flag的 bit 11如果置 1说明文件名编码是 UTF-8否则按本地区域码处理。解决读取条目时检查 bit 11有则直接按 UTF-8 处理没有并且代码运行在 UTF-8 环境下可以在转换函数里做 GBK 到 UTF-8 的映射。项目里我顺手写了个极简的转换查表没有引入 iconv 之类的外部依赖这部分代码量不大但有必要。4.4 现象某个大 zip 解压中途抛错但同样的文件用命令行解压工具能解原因中央目录里记录的文件名长度或解压后大小被篡改导致前面计算的数据偏移位置错了inflate 从错误位置开始读几 KB 后命中压缩流末尾返回Z_STREAM_END但 CRC 对不上。解决不要盲信compressed_size把inflate的返回值当成最终裁决依据——如果遇到Z_STREAM_END但解压出的字节数不等于uncompressed_size就不要写文件直接标记为异常。另外解压完写文件后应该用 zlib 的crc32函数重新计算一遍文件 CRC和中央目录里的记录比对不一致就明确报错。提示同步做 CRC 校验虽然会增加一次全文件读取的耗时但在数据完整性敏感的烧录包、增量升级包里这个成本完全划得来。5. 进阶用法从内存缓冲解压、流式解压和加密 zip 识别5.1 内存缓冲解压嵌入式场景里的关键路径很多嵌入式场景里zip 内容来自固件包或网络下载模块整个 zip 已经躺在内存 buffer 里了没必要先写磁盘再从磁盘读。只需要把前面“从文件读字节”的逻辑改成“从内存指针读字节”其它解析流程完全不变。std::ifstream的read操作对应到内存读取就是memcpy加上偏移递增。class MemoryStream { public: MemoryStream(const uint8_t* data, size_t size) : data_(data), size_(size), pos_(0) {} void Read(void* dst, size_t n) { if (pos_ n size_) throw std::runtime_error(memory read out of range); memcpy(dst, data_ pos_, n); pos_ n; } void Seek(uint64_t pos) { pos_ pos; } uint64_t Tell() const { return pos_; } private: const uint8_t* data_; size_t size_; uint64_t pos_; };用MemoryStream替换前面的std::ifstream时EOCD 搜索里的“从尾部读一段”就变成从内存尾部直接拷贝扫描逻辑没变数据的存取更可控了。嵌入式平台上如果 zip 体量大也可以考虑映射到只读 Flash 地址省掉一次完整拷贝。5.2 流式解压不知道压缩包总大小也能解流式场景麻烦一点因为 zip 的中央目录在文件末尾你不把整个流读完就不知道文件列表。常见的做法是把收到的数据先缓存到一个环形缓冲里一边接收一边往前推送同时从缓冲尾部持续搜索 EOCD 签名一旦签名出现且配套字段校验通过就立刻开始解析中央目录然后从构建好的文件索引里逐条解压。只要网络传输没有中途断开这种“边下载边解压”的体验流畅度远高于落盘后统一处理。它的核心逻辑仍然是前面的扫描器区别是搜索缓冲区不再是一块固定大小的 tail而是随着新数据不断向后滑动。void FeedStream(const uint8_t* chunk, size_t len) { // 将 chunk 追加到尾部缓冲 tail_buffer_.insert(tail_buffer_.end(), chunk, chunk len); // 但如果尾部缓冲超过 EOCD 最大搜索范围就截断前部 if (tail_buffer_.size() 65535 22) { size_t over tail_buffer_.size() - (65535 22); tail_buffer_.erase(tail_buffer_.begin(), tail_buffer_.begin() over); } // 尝试在尾部缓冲里搜索 EOCD EocdRecord eocd; uint32_t eocd_offset_in_file file_offset_ - tail_buffer_.size(); if (SearchEocd(tail_buffer_, eocd, eocd_offset_in_file)) { // 如果校验通过进入中央目录解析流程 } }这种方式的优势是只保留尾部的 64K 数据内存占用固定不会随着下载进度增长。劣势也很明显如果 EOCD 签名没有找到说明整个流还没完整到达这时候必须继续喂数据不能提前判失败。实际我在做某图像处理 Demo 的批量解压时把这种策略用在了 500MB 级存档的流式读取上内存占用始终稳定在几十 KB 级别。5.3 加密 zip 判定不能解但至少要能识别加密 zip 是目前实现里最容易被忽视的功能缺口。中央目录条目的general_purpose_flag的 bit 0值为 1表示该文件用传统 PKWARE 加密不能直接用 zlib 的 inflate 解因为密文流不是 deflate 格式。bit 0 配合 bit 3 时表明采用的是 ZipCrypto 加密本质上是流密码异或但实现复杂度和密钥派生逻辑远远超出本项目的目标范围。所以我在解压器里写了一段识别逻辑遇到general_purpose_flag 0x0001时直接抛出一个带文件名的异常说明该文件是加密条目当前版本不处理。这个策略听着消极但至少能让调用方知道结果不是“解出来乱码”而是“这个压缩包我处理不了”对集成方的排错体验要好得多。后续如果要支持 ZipCrypto可以在中央目录解析时读取额外字段中的0x9901签名段拿到 salt 和 password 校验值然后在 inflate 前先执行一段解密循环。6. 让解压结果可信CRC 校验与路径穿越防护的落地姿势解压器写完只是第一步真正让代码能在生产环境里跑起来至少要做到三件事路径安全、CRC 校验、性能验证。路径安全是最容易被忽略的zip 条目里的文件名可以是../evil.sh如果不加处理解压时就会跳出目标目录覆盖掉系统里的其它文件。这是一种经典的 zip 路径穿越攻击零基础脚本就能做出来。我的做法是在拿到每个条目的文件名后先做规范化处理将反斜杠替换为正斜杠再逐段检查是否出现..段名同时写文件前检查最终绝对路径是否严格位于目标目录前缀之下。bool IsSafeRelativePath(const std::string filename, const std::string output_root) { std::string normalized filename; std::replace(normalized.begin(), normalized.end(), \\, /); // 拒绝绝对路径和含 .. 的路径 if (normalized[0] / || normalized.size() 2 normalized[1] :) return false; std::stringstream ss(normalized); std::string segment; while (std::getline(ss, segment, /)) { if (segment ..) return false; } std::string full_path output_root / normalized; return full_path.rfind(output_root, 0) 0; }CRC 校验的做法也很直接中央目录里每个条目都带着一个 4 字节 CRC32 值解压完输出文件后用 zlib 的crc32函数对输出文件重新计算和记录值比对。这一关过了才允许文件真正落盘。有时候为了性能我会加一个参数--skip-crc-check在开发阶段临时跳过但生产流程里永远不会放开这个开关。至于性能验证我通常会在压测阶段构造一个包含 1000 个小文件和几个 100MB 大文件的 zip 包记录解压总耗时和内存峰值。zlib 的inflate本身性能已经足够好瓶颈通常出现在磁盘读写上。如果单文件解压速度低于 20MB/s优先检查是不是每次调用 inflate 都重新分配了输入输出缓冲区正确做法是复用同一个大小为 64KB 的缓冲区而不是每个文件 new 一次。具体的 zlib 吞吐参数可以用inflateGetDictionary查询当前字典窗口大小确保它在 32KB 以内否则读取速度会异常下降。我从第一次写解压器到现在中间至少重写过三轮每一次都倒在不同的问题上第一次是 windowBits 忘了写负号第二次是 EOCD 扫描没有校验注释长度边界第三次是路径穿越防护没做彻底。从那以后我每次写完解压器都强制走一遍“安全路径检查 CRC 校验 压测吞吐”这三板斧中途即使再遇到千奇百怪的 zip 文件也能在几分钟内定位到是解析层还是 inflate 层的问题。希望这篇笔记里的思路和踩坑记录能帮到你如果你也在用 zlib C 解压 zip不妨先从最小可用的 EOCD 扫描器开始一步步搭出属于自己的解压管线和异常处理策略。本文还有配套的精品资源点击获取