ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++ 进程内解压实战:用 7z SDK 替代外部 7z.exe

C++ 进程内解压实战:用 7z SDK 替代外部 7z.exe

简介:这份资源面向需要在 C++ 项目中集成 7-Zip 解压能力的开发者,尤其适合使用 Visual Studio 环境、希望直接调用 7z SDK 而非依赖外部命令行工具的中高级程序员。资源包共 488 个文件,以 186 个 .h 头文件与 136 个 .cpp 源文件为核心,涵盖 Lzma 编解码、7z 归档读写等模块,另含 40 个 .c 文件、71 个 .obj 编译产物及 dsp、dsw、sln、vcxproj 等工程配置,整体约 2.9MB,可直接对照搭建或复用。内容围绕 IInArchive 接口展开,涉及库初始化、打开压缩包、遍历文件列表、按索引解压与错误码处理等关键环节,并提示了非内存解压的 CPU 占用与速度权衡。已有 1822 人学习下载,适合作为理解 7z SDK 调用流程、排查链接与接口使用问题的参考素材。

1. 为什么我不再让 C++ 项目去 fork 一个 7z.exe

上周帮一个做桌面端的朋友排查一个解压模块的崩溃问题,现象很典型:程序在客户机器上偶发卡死,日志里没有任何异常,最后定位到是CreateProcess调起外部7z.exe时,路径里带空格加中文,命令行拼接没转义,进程创建失败但返回值没检查,主线程还在死等管道输出。这类问题在「C++ 调用 7z SDK 解压」这个场景里几乎每周都能碰到一次。用外部进程解压,本质上是把进程管理、路径编码、错误传播三件事全甩给了调用方,而这三件事恰恰是 C++ 里最容易翻车的地方。

7z SDK(也就是 LZMA SDK)提供的是进程内解压能力,你不需要额外部署7z.exe,不需要处理命令行转义,压缩包格式支持 7z、zip、tar、gzip、bzip2、xz 等一大票。它适合谁?适合那些要把解压能力嵌进安装器、资源加载器、日志归档工具、游戏补丁模块的 C++ 工程。代价是你要自己写几十行胶水代码,把 SDK 的 C 风格接口包成能用的东西。这篇就按我实际拆包复现的顺序,把这条路走一遍。

2. 7z SDK 的接口分层:从 C 接口到 C++ 封装到底怎么选

2.1 先搞清楚 SDK 里到底有哪几层

LZMA SDK 的源码目录结构不复杂,但第一次打开容易懵。核心分三块:C/目录下是纯 C 实现的压缩解压算法,CPP/7zip/下是 7-Zip 自己的 C++ 封装,CPP/Common/是一些基础工具类。很多人一上来就想用CPP/7zip/里的IInArchive接口,觉得那是「官方 C++ 接口」,结果被一堆 COM 风格的IUnknown、QueryInterface绕晕。

我的建议是分场景选:

场景推荐接口理由
只解压 7z/zip,要格式自动识别CPP/7zip/Archive/IArchive.h的IInArchive支持多格式,SDK 帮你判断
只处理单文件 .xz/.lzmaC/LzmaDec.h的LzmaDecode无依赖,代码量最小
要嵌入安装器,体积敏感C/目录裸接口可以只编译需要的 .c 文件
要遍历压缩包内文件列表IInArchive+IArchiveOpenCallback能拿到每个 entry 的元信息

大部分业务场景其实是第一种和第四种,也就是走IInArchive。这条路的关键在于:SDK 不提供现成的「解压到目录」函数,它只给你「打开归档 → 遍历条目 → 读取流 → 写文件」这套原语,剩下的目录创建、路径拼接、覆盖策略全要自己写。

2.2 把 SDK 编进工程:源码引入还是静态库

我不推荐直接拿预编译的.lib,因为 LZMA SDK 的版本和编译器 ABI 绑得很死,MSVC 2019 编出来的库拿到 MSVC 2022 工程里链接,轻则警告重则崩。常见做法是把C/和CPP/下需要的源文件直接拖进工程编译。

最小文件集大概是这样:

# 以 7z 解压为例,需要加入工程的源文件(相对 SDK 根目录) C/7zCrc.c C/7zCrcOpt.c C/7zStream.c C/Alloc.c C/Bra.c C/Bra86.c C/BraIA64.c C/CpuArch.c C/Delta.c C/Lzma2Dec.c C/LzmaDec.c C/Ppmd7.c C/Ppmd7Dec.c C/7zBuf.c C/7zBuf2.c C/7zDec.c C/7zIn.c C/7zArcIn.c C/Bcj2.c C/Xz.c C/XzDec.c CPP/7zip/Archive/7z/7zHandler.cpp CPP/7zip/Archive/7z/7zIn.cpp CPP/7zip/Archive/7z/7zOut.cpp CPP/7zip/Archive/7z/7zUpdate.cpp CPP/7zip/Common/StreamUtils.cpp CPP/7zip/Common/InBuffer.cpp CPP/7zip/Common/OutBuffer.cpp CPP/7zip/Common/FileStreams.cpp CPP/7zip/Common/CreateCoder.cpp CPP/7zip/Common/CoderMixer2.cpp CPP/7zip/Common/MethodProps.cpp CPP/7zip/Common/PropId.cpp CPP/Common/MyString.cpp CPP/Common/MyWindows.cpp CPP/Common/StringConvert.cpp CPP/Common/UTFConvert.cpp CPP/Common/IntToString.cpp CPP/Common/NewHandler.cpp CPP/Common/StdInStream.cpp CPP/Common/StdOutStream.cpp

这份清单不是绝对的,取决于你实际用到的格式。如果只解 7z,Xz.c、XzDec.c可以去掉;如果还要解 zip,得再加CPP/7zip/Archive/Zip/下的文件。我一般会先按最小集加,编译报缺符号再补,比一次性全加进去然后被几百个编译错误淹没要高效。

提示:SDK 里大量文件用了#include "StdAfx.h",如果你的工程没开预编译头,要么在项目属性里关掉,要么给每个文件加一个空的StdAfx.h。这个坑我踩过,报错信息完全不指向真实原因。

2.3 用 IInArchive 打开归档的最小闭环

先看打开归档这段,这是所有后续操作的前提:

#include "CPP/7zip/Archive/IArchive.h" #include "CPP/7zip/IPassword.h" #include "CPP/Common/MyCom.h" #include "CPP/7zip/Common/FileStreams.h" // 创建 7z 格式的 InArchive 实例 static HRESULT CreateInArchive(const GUID& formatId, IInArchive** archive) { // CreateArchiver 是 SDK 提供的工厂函数,按 GUID 创建对应格式的处理器 return CreateArchiver(formatId, IID_IInArchive, (void**)archive); } // 打开一个 7z 文件 HRESULT OpenArchive(const std::wstring& path, IInArchive** outArchive) { CInFileStream* fileStream = new CInFileStream; CMyComPtr<IInStream> stream(fileStream); if (!fileStream->Open(path.c_str())) { return E_FAIL; // 文件打不开,常见于路径含中文但用了窄字符 } CMyComPtr<IInArchive> archive; // k_7z 是 7z 格式的 GUID,定义在 7zHandler.h 里 HRESULT hr = CreateInArchive(k_7z, &archive); if (hr != S_OK) return hr; // 第二个参数是最大检查内存,传 (UInt64)-1 表示不限制 hr = archive->Open(stream, nullptr, nullptr); if (hr != S_OK) return hr; *outArchive = archive.Detach(); return S_OK; }

这段代码里几个参数值得说清楚。CInFileStream是 SDK 自带的文件流封装,它内部用的是 Windows 的CreateFileW,所以路径必须传宽字符,传std::string会在中文路径上直接失败,而且失败得很安静。archive->Open的第二个参数是IArchiveOpenCallback*,传nullptr表示不报告进度也不提供密码;如果压缩包有密码,这里必须传一个实现了ICryptoGetTextPassword的回调,否则Open会返回E_ABORT。第三个参数是IInStream*,一般传nullptr,SDK 会用第一个参数那个流。

3. 遍历条目与流式解压:把每个 entry 落到磁盘

3.1 拿到条目数量和每个条目的属性

归档打开之后,第一件事是问它里面有多少个条目:

UInt32 numItems = 0; archive->GetNumberOfItems(&numItems); for (UInt32 i = 0; i < numItems; ++i) { // 每个属性用 PROPID 枚举查询,返回的是 PROPVARIANT CMyComBSTR name; PROPVARIANT prop; PropVariantInit(&prop); // kpidPath 是条目在压缩包内的相对路径 archive->GetProperty(i, kpidPath, &prop); if (prop.vt == VT_BSTR) { name.Attach(prop.bstrVal); prop.vt = VT_EMPTY; // 所有权转移,避免重复释放 } // kpidIsDir 判断是不是目录 archive->GetProperty(i, kpidIsDir, &prop); bool isDir = (prop.vt == VT_BOOL && prop.boolVal != VARIANT_FALSE); PropVariantClear(&prop); // kpidSize 是解压后大小,kpidPackSize 是压缩包内占用 // 这两个值可以用来做进度条和磁盘空间预检查 }

kpidPath返回的路径分隔符在 7z 里统一是/,在 Windows 上落盘前要换成\。这里有个安全细节:压缩包里的路径可能包含..\,如果直接拼接输出目录,就会写到目标目录外面去,这是典型的 Zip Slip 问题。我一般会做一次规范化,把..段直接拒绝掉。

3.2 用 Extract 回调把数据写出去

SDK 的Extract方法接受一个IArchiveExtractCallback,你需要自己实现这个接口。核心是三个方法:SetTotal、SetCompleted、GetStream。GetStream在 SDK 准备解压第 N 个条目时被调用,你要在这里返回一个可写的IOutStream。

class ExtractCallback : public IArchiveExtractCallback, public ICryptoGetTextPassword, public CMyUnknownImp { public: CMyComPtr<IInArchive> archive; std::wstring outDir; UInt64 total = 0; MY_UNKNOWN_IMP2(IArchiveExtractCallback, ICryptoGetTextPassword) // 返回第 index 个条目的输出流 STDMETHOD(GetStream)(UInt32 index, ISequentialOutStream** outStream, Int32 askExtractMode) { *outStream = nullptr; // 先查这个条目是不是目录,目录不需要流 PROPVARIANT prop; PropVariantInit(&prop); archive->GetProperty(index, kpidIsDir, &prop); bool isDir = (prop.vt == VT_BOOL && prop.boolVal != VARIANT_FALSE); PropVariantClear(&prop); if (isDir) return S_OK; // 拼输出路径 CMyComBSTR name; archive->GetProperty(index, kpidPath, &name); std::wstring fullPath = outDir + L"\\" + (const wchar_t*)name; // 创建目录(多级) CreateDirectories(fullPath); COutFileStream* fileStream = new COutFileStream; CMyComPtr<ISequentialOutStream> stream(fileStream); if (!fileStream->Create(fullPath.c_str(), false)) { return E_ABORT; // 文件创建失败,通常是权限或路径过长 } *outStream = stream.Detach(); return S_OK; } STDMETHOD(SetTotal)(UInt64 total) { this->total = total; return S_OK; } STDMETHOD(SetCompleted)(const UInt64* completeValue) { return S_OK; } // 密码回调,没有密码时返回 S_FALSE STDMETHOD(CryptoGetTextPassword)(BSTR* password) { *password = SysAllocString(L""); return S_FALSE; } };

调用的时候:

CMyComPtr<ExtractCallback> cb = new ExtractCallback; cb->archive = archive; cb->outDir = L"D:\\output"; // 第三个参数传 (UInt32)-1 表示解压全部条目 // 如果只想解压某几个,传索引数组和数组长度 HRESULT hr = archive->Extract(nullptr, (UInt32)-1, false, cb);

Extract的第二个参数是UInt32* indices,传nullptr配合第三个参数(UInt32)-1就是全解。第四个参数askMode传false表示不询问,直接解压。如果你要做「只解压某个文件」的功能,就把要解的条目索引放进数组传进去。

3.3 进度回调与取消:别让用户以为程序死了

SetCompleted会被高频调用,不要在里面做重活。我一般只更新一个std::atomic<UInt64>,然后 UI 线程定时读。取消功能靠GetStream返回E_ABORT实现,SDK 收到这个返回值会中止整个Extract并返回E_ABORT。注意E_ABORT和真正的错误要区分开,前者是用户主动取消,后者是磁盘满、权限不足这类问题。

4. 避坑与排查:五个让我加班到凌晨的现场

4.1 中文路径解压出来是乱码

现象:压缩包内文件名是中文,解压后文件名变成问号或乱码。原因:7z 归档里的文件名以 UTF-16 存储,但kpidPath返回的BSTR在某些 SDK 版本里需要经过UTFConvert转换,直接当char*用就丢编码。解决:统一用std::wstring接收,落盘走CreateFileW。如果必须转窄字符,用CPP/Common/UTFConvert.cpp里的ConvertUnicodeToUTF8,别用WideCharToMultiByte配CP_ACP。

4.2 大文件解压到一半内存暴涨

现象:解压一个 4GB 的 7z,进程内存涨到 2GB 以上。原因:COutFileStream默认带缓冲,但如果你自己实现ISequentialOutStream时把每次Write的数据都攒在std::vector里,就会全量驻留。解决:Write里直接落盘,不要缓存;或者用 SDK 自带的COutFileStream,它的缓冲是固定大小的。

4.3 编译报一堆LNK2005重复符号

现象:链接阶段报Alloc.c里的g_Alloc重复定义。原因:SDK 的C/Alloc.c和CPP/Common/下某些文件都定义了全局分配器,同时编进工程就冲突。解决:只保留一份,通常保留C/Alloc.c,把CPP/Common/里重复的实现排除。或者用#define Z7_ST关掉 SDK 的多线程分配器。

4.4 密码压缩包 Open 直接返回 E_ABORT

现象:带密码的 7z 调Open就失败,还没到Extract。原因:Open阶段 SDK 需要验证密码,IArchiveOpenCallback没传或者没实现ICryptoGetTextPassword。解决:把密码回调实现在Open用的 callback 上,而不是Extract的 callback 上。这两个是独立的接口,很多人只实现了后者。

4.5 解压完文件时间戳全是当前时间

现象:解压出来的文件修改时间不对。原因:SDK 不会自动设置文件时间,kpidMTime属性拿到了但没写回文件系统。解决:在GetStream里拿到kpidMTime的FILETIME,创建文件后用SetFileTime设置。注意PROPVARIANT里的时间是 UTC,写之前要转本地时间。

5. 进阶:把解压封装成可复用的 C++ 类与验证清单

走到这里,基本功能已经能跑了。但要在工程里长期用,我习惯把它封成一个类,对外只暴露两个方法:Open和ExtractAll。内部把IInArchive、回调、路径规范化全藏起来。这样调用方不需要知道PROPVARIANT是什么,也不需要 include 任何 SDK 头文件。

封装时有个细节值得单独说:CMyComPtr是 SDK 自带的智能指针,但它不是线程安全的,引用计数用的是普通long。如果你的解压类要在多线程里共享,得自己加锁,或者每个线程一个实例。我一般选后者,因为 7z 解压本身是 CPU 密集的,多线程共享一个 archive 实例反而会互相拖慢。

验证一个解压实现是否靠谱,我固定跑这几项:

验证项构造方式期望结果
中文路径压缩包内放「测试/文档.txt」解压后路径和文件名正确
空目录压缩包里建一个空文件夹目录被创建,不报错
大文件单个 2GB 以上文件内存占用稳定,不 OOM
密码包带密码的 7z传对密码成功,传错返回明确错误
路径穿越条目名为..\..\evil.txt被拒绝或规范化到输出目录内
磁盘满输出到小容量分区返回错误,不崩溃,不留半截文件

最后一项「不留半截文件」需要额外处理:GetStream创建文件后,如果后续Extract失败,已经写了一半的文件不会自动删除。我的做法是在回调里记录所有创建过的文件路径,Extract返回非S_OK时统一清理。这个逻辑不复杂,但漏掉的话,用户会看到一堆损坏的半成品文件,然后来问你为什么解压坏了。

从那以后我每次接解压相关的需求,都强制先把上面那张验证表跑一遍再联调业务代码。这套流程帮我省掉的返工时间,远比写封装类花的时间多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进