2026/10/19 1:24:36

7-Zip源码深度解析:从LZMA压缩算法到二次开发实践

7-Zip源码深度解析:从LZMA压缩算法到二次开发实践 简介7ZIP源码是一份面向软件开发者与算法学习者的开源压缩软件完整实现包涵盖压缩解压缩核心功能、多语言版本、DLL生成与命令行工具设计。这份资源定位在逆向研究、跨平台开发及算法优化场景尤其适合希望深入理解LZMA无损压缩原理的程序员。包体共643个文件压缩后1.72MB以C头文件与实现h/cpp、C源码、C#与Java扩展、makefile构建脚本、rc界面资源、asm汇编优化等类型为主结构清晰便于按模块查阅。已有660人学习适合在读完压缩算法理论后对照源码印证细节。价值点在于可研究C、C#、Java不同语言对同一功能的实现差异学习如何将压缩能力封装为DLL命令行工具部分能展示参数解析与复杂文件操作逻辑汇编代码与LZMA编码器源码则有助于理解高性能压缩优化策略无论用于个人进阶还是项目集成都有较强的参考意义。1. 7-Zip源码能吃透什么一本用C写出来的压缩算法活教材大多数人第一次接触7-Zip只是把它当成一个压缩率更高的绿色软件。真正让它在技术圈里地位特殊的是这份用C写出的完整源码从7z容器格式、LZMA/LZMA2压缩算法到固实打包、分卷、自解压、加密整套现代压缩工具箱的实现都在里面。如果你正在做文件格式解析、嵌入式存储、服务端日志压缩或者只是想把压缩这个黑匣子打开看一眼这份源码比任何压缩算法书都直接。它能回答三个具体问题LZMA的字典匹配是怎么实现的7z格式的头部到底写了什么怎么把一个压缩库嵌进自己的工程本文我会按工程结构、核心代码、编译运行、踩坑记录和改造思路的顺序把它拆开讲。2. 工程结构拆解模块边界与COM风格接口2.1 源码根目录的分层逻辑打开7-Zip源码包第一眼会看到一个很经典的C工程布局。根目录下CPP/7zip是全部核心代码所在里面按职责分成了几个互不越界的目录7zip/Common底层公共代码ByteBuffer、Defs.h、MyString、CRC计算这类基础件基本不依赖平台是所有模块的地基。7zip/Compress压缩算法层LZMA、LZMA2、BZip2、PPMd、Deflate等编解码器都在这里每个算法一个子目录。7zip/Crypto加密层7z格式里的AES-256加密、ZipCrypto、哈希计算CRC32、SHA、BLAKE2等在这里实现。7zip/Archive容器格式层NArchive::N7z负责读写7z格式NZip、NTar、NRar只读等负责解析其它格式。这一层把一堆文件组装成一个压缩包或者反过来解开。7zip/UI用户界面层命令行工具、文件管理器右键扩展、SFX自解压模块的引导逻辑都在这里也是离软件最近的一层。这个分层的顺序不是随便排的。Common不依赖CompressCompress不依赖ArchiveArchive不依赖UIUI只在最外层调用下层接口。换句话说如果你只想要一个能解压7z的SDK直接把UI目录扔掉从Archive和Compress里挑文件就行。这种倒金字塔依赖关系是这份源码能裁剪的最小前提。目录职责典型子模块Common基础工具与平台抽象Defs.h、MyVector、CRCCompress压缩算法编解码器Lzma、Lzma2、Ppmd、BZip2Crypto加密与哈希Aes、MyCrypt、ShaArchive容器读写与格式转换NArchive::N7z、NZip、NTarUI命令行与图形交互7z.exe、7zFM、SFX从依赖顺序看这是一个典型的算法可替换、容器可扩展架构。加一种新压缩算法只需要在Compress目录里实现一组统一的编解码器接口再到注册表登记加一种新容器格式也只需要在Archive目录里实现IInArchive/IOutArchive两个大接口。7-Zip之所以能处理这么多格式靠的不是每种格式写一套独立逻辑而是这套统一的接口约定。2.2 整个SDK的基石IInArchive / IOutArchive在7-Zip的代码里你看不到一堆继承层次很深的抽象基类取而代之的是一组COM风格的接口。所谓COM风格是指这些接口本质上是一个虚函数表每个接口继承自IUnknown通过QueryInterface做类型转换。这样写的好处是编译器不会把接口类型和具体实现强绑定不同模块之间只需要依赖头文件里声明的接口签名而不依赖具体类的头文件。其中最重要的两个接口是IInArchive读压缩包和IOutArchive写压缩包。拿IInArchive来说它定义了一组非常直白的方法Open打开一个压缩包流读取格式信息。GetNumberOfItems获得压缩包里的文件条目数量。GetProperty按属性ID取文件元数据比如文件名、大小、CRC32、修改时间。Extract提取指定索引范围的文件把解压后的字节交给回调流。Close释放资源。源码里的关键实现大概是这个形态class CArchiveHandler : public IInArchive { public: MY_UNKNOWN_IMP1(IInArchive) // 定义 QueryInterface/AddRef/Release STDMETHOD(Open)(ISequentialInStream *stream, const UInt64 *maxCheckStartPosition, IArchiveOpenCallback *openCallback); STDMETHOD(GetNumberOfItems)(UInt32 *numItems); STDMETHOD(Extract)(const UInt32 *indices, UInt32 numItems, Int32 testMode, IArchiveExtractCallback *extractCallback); };这里的Extract有一个关键参数testMode传入非0时只读流不写数据用来做解压自检不会落盘。很多二次开发者在解压前会先跑一遍testMode确认压缩包没坏再真正写文件这就是它存在的意义。Open方法接收一个ISequentialInStream作为输入流。这个流接口是所有读取操作的统一入口它只有两个方法Read和GetSize。无论你传入的是文件流、内存流还是网络流只要实现了这个接口7-Zip就能解压。反过来写压缩包时用的是ISequentialOutStream同样抽象。后面二次开发章节里我会演示怎么用这个接口做内存到内存的压缩不加一个临时文件。2.3 Codec注册机制加一个新算法要改哪几个文件7-Zip的压缩算法在代码里被称为CodecCompress目录下每个算法文件夹都有一个Register.cpp里面通过宏把一个编解码器工厂对象挂到全局注册表上。注册表本质上是一个静态数组存着一组CreateArc、CreateCodec这类函数指针。常见的注册宏REGISTER_CODEC_CREATE_2(CreateLzmaEncoder, CLzmaEncoder) REGISTER_CODEC_CREATE_2(CreateLzmaDecoder, CLzmaDecoder) REGISTER_CODEC_7Z(CLzmaEncoder, CLzmaDecoder, 0x030101 /* LZMA 的 Codec ID */, LZMA, LZMA)宏展开后做的事情是在一个静态CodecInfo结构里登记编码器/解码器的创建函数、7z格式内的算法ID、算法名字和对齐方式。这样Archive层打开压缩包时读到该算法的ID就能从注册表里找到对应的创建函数动态造出解码器。换句话说你加的算法只要实现了ICompressCoder接口然后在注册表里填上一行7z容器就能认它。LZMA的Codec ID是0x030101这个数字直接写进7z文件头解压端靠读这个字节序列来路由算法。所以二次开发时如果你发明了一个私有压缩算法需要给自己申请一个不会冲突的ID段——最省事的做法是放在0x03F70000之后的私有区间。3. 核心链路代码阅读一次压缩请求从界面到算法怎么走3.1 起点命令行参数如何进入Archive模块当一个用户敲下下面的命令7z a archive.7z /data/logsUI层7z.exe的入口先解析参数a表示添加文件到压缩包archive.7z是目标压缩包/data/logs是待压缩的目录。参数解析完UI会创建一个CArchiveHandler然后调用它的UpdateItems方法。这是压缩操作的真正起点它接收一组文件源、一个IOutArchive指针以及一个回调接口。回调接口IArchiveUpdateCallback用于向界面回传进度百分比和文件名。如果待压缩路径里有的文件在压缩包之外UpdateItems会先启动一个收集文件阶段遍历目录、计算每个文件的大小、读文件的属性。这一步完成后才会进入实际的编码流程。很多人第一次读源码时在这里会被绕晕因为UpdateItems之后不是直接进LZMA而是先走一个把文件分成固实块的过程——它决定哪些文件放进同一个LZMA流。这一步为什么重要zip是每个文件独立压缩最后拼成一个目录表7z则是把一批文件的首尾字节连成一个固实流整体交给LZMA/LZMA2压缩。固实压缩的好处是压缩率更高重复内容跨文件也能匹配到代价是修改任意一个文件时后面的固实流可能全部要重压。源码里这个决策在NArchive::N7z::CArchiveHandler的UpdateItems中完成它会按压缩级别、文件数量和内存限制决定固实块大小。3.2 容器层7z格式的固实块与文件目录结构7z文件的物理布局是一个三段式Start Header起始头、固实数据流、End Header结束头。End Header里记录着一个文件目录包含每个文件的名称、大小、CRC32、属性和它所属的固实块编号。解压时先读尾部目录是7z格式的一个重要特性这让随机访问单个文件成为可能先定位目录找到文件在固实流中的偏移再只解压这一段。源码里对应的是NArchive::N7z::CInArchive::ReadDatabase方法和CDatabaseEx结构。CFileItem结构里最重要的几个字段是struct CFileItem { UInt64 Size; // 原文件大小 UInt32 Crc; // 原文件 CRC32 UInt32 Attrib; // 文件属性位掩码 bool IsDir; bool CrcDefined; UInt16 MTime; // 修改时间低精度 };注意这里没有文件名文件的路径和编码信息保存在CDatabase的另一个数组里。这样设计是为了在解析目录时最小化内存占用——一个包含十万个文件的压缩包光文件大小和CRC就要占不少空间路径单独存放可以让目录解析更快。CrcDefined是解压后校验的开关只有当这个字段为true时解压器才会把算出的CRC与记录值比对否则跳过校验。某些流式生成的7z包比如一边录一边压会因为写入时不知道最终CRC而把CrcDefined置为false。3.3 算法层LZMA的核心状态机与Range Coder真正压缩率高的核心是LZMA算法它本质上是字典匹配 区间编码Range Coder的组合。先说核心逻辑LZMA维护一个滑动字典窗口编码时从字典里寻找当前待编码字节的最长匹配串匹配成功就输出一个(距离, 长度)对匹配不上就输出一个原始字节之后再把这些输出符号交给Range Coder做熵编码。源码里具体的入口是CLzmaEnc核心循环在LzmaEnc.c——这个文件很值得从头读一遍代码组织得相当清晰。它的核心循环逻辑简化后是这个样子// LZMA 编码主循环简化示意实际还要处理状态机迁移 while (nowPos nowPos64) // 当前输入流还没到末尾 { // 1. 在滑动字典里查找最长匹配得到 matchLen 和 matchDistance if (matchFinder-GetMatches(matches) 0) { // 2a. 找到了匹配编码匹配标记再区间编码距离和长度 LitEnc_EncodeMatched(p-rc, p-litProbs, p-state, rep0, len); LenEnc_Encode(p-rc, p-lenEnc, len, posState, p-state, 0); } else { // 2b. 没找到匹配编码一个普通字节 LitEnc_Encode(p-rc, p-litProbs, p-state, p-previousByte, byte); } // 3. 移动匹配查找器的窗口指针把当前字节滑入字典 MatchFinder_Skip(matchFinder, len); nowPos len; }这里的matchFinder是匹配查找器它负责在字典窗口内找最长匹配。在LZMA源码里有多种实现HashChain哈希链和BinaryTree二叉树通过btMode参数切换。二叉树模式匹配精度更高压缩率略好但更慢哈希链模式速度快内存占用低。这个循环每跑一次就把一个LZMA符号——或者是一个匹配距离长度对或者是一个独立字节——送进Range Coder。Range Coder是LZMA的熵编码器实现位于RangeEnc.c。它用一段64位区间不断收窄来表示输出符号序列每处理一个符号就调一次ShiftLow把高位移到输出字节流。它比传统的算术编码实现更简单所以作者选它作为默认熵编码器。理解这对组合你再看命令行里的压缩级别参数就顺了级别越高匹配查找器的搜索深度越大字典越大Range Coder的概率表更新也越充分代价是时间和内存。需要特别注意的是CLzmaEnc是经过大量优化和内联的版本函数里频繁出现宏、指针合并和位运算初学者直接啃细节容易陷入微观拜读。我个人的读法是完全跳过matchFinder的内部实现先只在窗口层理解它的行为它对外只回答一个问题当前位置往后看字典里能匹配的最长字符串有多长、在哪内部实现细节放到第二遍再啃。3.4 多线程的边界哪些部分真的会并行7z源码里其实只有一个相对简单的线程池CThreads和CSyncEvents并行度主要体现在两个地方一是固实块之间可以并行压缩每个固实块一个LZMA编码器实例二是一个大文件的LZMA2编码可以按块并行。注意LZMA1是严格串行的LZMA2支持分块块之间有独立的编码状态块边界不共享字典所以才能并行。源码中控制并行的核心参数是MT相关开关默认在GUI里会按处理器核心数自动选择。命令行里可以通过-mmtnum指定线程数比如-mmt1强制单线程。多卷分卷、合并固实流的逻辑都发生在这一层之上算法的并行几乎完全由LZMA2的分块特性支撑。4. 编译与运行在Windows和Linux上把源码跑起来4.1 源码包里找构建入口拿到源码包后先别急着开IDE。先看清构建入口在哪。7-Zip的源码目录结构是跨平台友好的代码本体在CPP/7zip平台相关的构建文件分散在各层。Windows构建在CPP/7zip/Bundles目录下有现成的工程文件新版源码用的是Visual Studio解决方案.sln另外还有老的.dsw项目文件。打开后直接生成解决方案输出会落在bin目录里面有7z.exe命令行版和7z.dll核心逻辑动态库。Linux/macOS构建在CPP/7zip根目录下有makefile其中makefile.gcc是通用的GCC构建入口。不需要autotools直接make -f makefile.gcc就行。注意Windows上编译有两条路径一种是把整个解决方案全编生成7z.exe 7z.dll另一种是编译全部静态链接的7zr.exe它把全部模块揉进一个可执行文件适合做单文件工具。给出实际命令# Linux 构建 CLI 工具 7z 格式支持 cd CPP/7zip make -f makefile.gcc # 构建后二进制位置 ls -l bin/7z如果只想要一个最小化的纯7z读包工具可以只编译Bundles/Alone入口下的7zr。Alone是一个不带加密、不带其它格式的迷你构建对应的工程或make目标很小。这个最小化目标对我来说很有用它把代码量收敛到一个可以单步跟读的范围内。编译参数注意不要在make命令里混用-j和7-Zip自带的并行逻辑如果你的gcc版本过新偶尔会碰到LZMA代码里某些老的C风格写法触发编译警告但不会报错。真正会让构建失败的是32/64位宏不一致比如平台头文件里没有检查_WIN64导致中间变量统一走32位路径解压大文件时溢出。4.2 验证构建结果用命令行压一个测试集编译成功只是第一步关键是验证这个二进制是不是真的能工作。我每次编译完都会做三件事# 1. 基础压缩目录压成 7z ./7z a test.7z ./mydir # 2. 列出压缩包内容验证头部目录解析正常 ./7z l test.7z # 3. 解压到新目录验证 CRC 校验通过 ./7z x test.7z -o./out -y命令行参数里最有价值的是-m参数它控制压缩方法和参数。例如-mmt1强制单线程。-mx9最高压缩级别效果是更大的字典和更慢的匹配搜索。-mse固实模式开关e表示按扩展名分组固实。-m0LZMA2:d64m:lc3:lp0:pb2用LZMA2字典64MB字面量上下文位数3位置位数0位置上下文位数2。这里面lc/lp/pb三个参数是LZMA状态机里的关键维度。lc是字面量编码时取前几个字节作为上下文lp是位置上下文pb是区间编码时按什么粒度划分posState。改这些参数会直接影响压缩率和内存占用——这也是读源码时最有意思的实践因为你能在-m0参数后面直接验证源码里学到的概念。4.3 静态链接与裁剪的取舍常见的二次开发场景是只想要一个静态链接的命令行工具不依赖目标机器上的任何动态库直接拷过去运行。做法是make -f makefile.gcc staticmakefile里static目标会带上-static链接参数。但要注意静态链接的7zr会显著变大因为它把压缩算法、容器解析全编译进去了。正常发行版依赖动态库体积小很多。如果是嵌入到嵌入式设备我一般只保留LZMA编解码器这一块通过DOC/lzma.txt里说明的C接口直接调编解码完全不引入7z容器层。裁剪之后整个算法库约200~300KB级别具体体积跟编译选项相关。5. 常见问题与避坑源码阅读与二次开发的五个典型坑5.1 Debug断点打不进去进函数全是汇编现象在CLzmaEnc里设断点按F11后发现直接进入了一片内联汇编或被优化掉的空跳转根本看不到匹配循环的流程。原因默认构建配置是Release编译器把大量小函数内联掉了。LZMA编码器为了性能还专门用宏控制内联_7ZIP_ST宏在单线程构建里可能干掉了部分状态检查参数变量在寄存器间跳转。解决把构建改成Debug配置并定义_7ZIP_DEBUG或直接去掉优化选项。我在Windows上一般用Debug配置编译完整解决方案在Linux上用-O0 -g编译LzmaEnc.c单独生成一个调试目标。从那以后我读LZMA的匹配循环时就再没陷入汇编黑洞。5.2 自实现的输出流在解压中途遭遇CRC错误现象用自定义ISequentialOutStream解压文件写到一半时返回失败报CRC错误。原因没理解Write方法的返回值约定。7-Zip的输出流Write的返回不是告诉我们你写了多少而是告诉调用者你要不要停。如果返回值与实际写入的字节数不一致解压器会认为流被截断置为失败。解决严格实现Write的语义若传入的size参数大于0则要么全写并返回size要么以错误码中止。任何部分写入都会被当作错误处理。我后来在封装网络流时采用要么全收要么报错的策略彻底避开这个坑。5.3 打开自解压SFX压缩包总是找不到正确偏移现象把7z包和SFX引导程序合成为exe后用IInArchive直接打开这个exe文件头校验失败。原因7z数据被追加在引导程序尾部头部偏移记录在文件尾部的StartHeader中用Open方法直接打开时7-Zip要求传入的流起始于7z数据开头。解决先读取尾部32字节找到StartHeader计算7z数据的实际偏移量然后用支持Seek的流比如IInStream把位置跳到偏移处再传入Open。常见的实现方式是继承CInArchive并传入一个跳过前N字节的流包装器。这个坑在源码里其实有注释说明但如果你先读API而不是先读文档很容易踩中。5.4 Linux编译报错找不到Windows.h或_msize未定义现象make -f makefile.gcc编译时大量编译单元报告Windows.h缺失甚至在Common里报_msize未定义。原因makefile.gcc对应的是跨平台目标但源文件里很多平台适配是靠宏开关切分的比如_WIN32、_7ZIP_PLATFORM。如果编译器没有定义正确的宏或者混用了不同的makefile条件编译没有走到Unix分支就把Windows分支的代码引出来了。解决检查makefile.gcc的预处理器定义不要手动修改源码里的头文件去强匹配而是统一编译参数。最干净的做法是只保留makefile.gcc一个入口别用makefile那是给Windows编译器准备的。5.5 内存占用是预期的三倍现象压缩一个大文件时进程占用内存远大于预期字典大小。原因LZMA匹配查找器在内存里的不只是一个哈希表还要维护窗口缓冲区、literal概率表、区间编码器状态等。特别是最高压缩级别默认开启bt4二叉树匹配每个窗口位置存储的左右子节点和附加信息比哈希链大一倍还多。解决控制-mx等级并显式设定字典大小。一般场景我用-mmt1和-md48m既要快又不至于内存爆炸。内存敏感的场景我会在源码里把MatchFinder从bt4换成hc4代价是压缩率略降但内存占用减半。6. 二次开发技巧基于SDK把它改造成自己的压缩工具6.1 裁剪出只解压7z的迷你SDK如果你要做一个私有服务每天接收一批7z压缩包并解压入库不需要压缩功能不需要多格式支持。我从这份源码里裁剪出来的最小集合是7zip/Common里的基础文件 Archive/7z的解压器 Compress/Lzma的解码器 Crypto如果加密可选。UI层全部不要Archive目录里其它格式全部不要。这样编译产物会缩小到原来的五分之一左右。最小解压调用链长这样伪代码引用源码里的函数// 1. 打开压缩包流 CMyComPtrIInArchive archive; CreateObject(archive, CLSID_CFormat7z); // 注册表里找 7z 格式处理器 archive-Open(stream, 0, openCallback); // 2. 遍历文件并解压 UInt32 numItems; archive-GetNumberOfItems(numItems); for (UInt32 i 0; i numItems; i) { CMyComPtrISequentialOutStream outStream new CFileOutStream(savePath); archive-Extract(i, 1, 0, extractCallback); // 只解压第 i 个文件 }这里CreateObject的参数是格式CLSID对应源码里Register.cpp登记的GUID。如果你把其它格式都裁掉只保留7z这个CLSID可以直接硬编码省掉整个格式探测逻辑。6.2 把LZMA2当流压缩器用内存到内存的压缩7-Zip源码里LZMA编码器CLzmaEnc_*本身就是流式的不需要依赖7z容器。可以直接创建编码器、配置属性、把输入内存流压缩到输出内存流。这里用到的API在DOC/lzma.txt有文档是作者专门为集成者准备的一套C接口。CLzmaEncHandle enc LzmaEnc_Create(alloc); CLzmaEncProps props; LzmaEncProps_Init(props); // 初始化为默认参数 props.level 9; props.dictSize 64 20; // 64MB 字典 props.lc 3; // 字面量上下文位 props.lp 0; // 位置位 props.pb 2; // posState 位 CLzmaEncProps_Normalize(props); LzmaEnc_SetProps(enc, props); // 输出前先把 5 字节属性头和 8 字节解压大小写出去 Byte header[13]; LzmaEnc_WriteProperties(enc, header); // 循环调用 LzmaEnc_Encode 处理缓冲每次给一块输入 for (;;) { // src 是当前块指针dest 是输出缓冲 SizeT srcLen ...; // 本次输入块大小 SizeT destLen ...; // 输出缓冲剩余空间 ELzmaStatus status; SRes res LzmaEnc_Encode(enc, dest, destLen, src, srcLen, NULL, status, callback); // status LZMA_STATUS_FINISHED 表示输入全部处理完 }这些API的参数注释很完整。需要注意一点LzmaEnc_Encode在非FINISHED状态下返回时destLen表示这次实际输出了多少字节srcLen表示这次实际消费了多少字节两者都不保证一次性完成。所以外层循环必须正确处理这两个返回值否则会白白丢失数据。我自己做嵌入式日志压缩时就是把它封装成一个拉模式的压缩器上游不断喂数据下游取压缩输出。核心收益是完全没有7z容器头的开销适合私有归档协议。6.3 怎么验证你改出来的东西没改坏改完源码最容易翻车的不是编译错误而是输出文件格式变了但你没发现。我给自己定了一套验证流程每次改动后用官方命令行跑一组固定测试集对比压缩前后文件大小、解压后CRC以及压缩时间三者。测试集里会放三类文件全零的稀疏文件测LZMA对重复串的匹配、纯文本日志测熵编码效率、一个5MB的随机二进制文件测最差情况。另外用一份标准的7z存档做解压测试确认改动后的解压器能打开旧版源码压出来的文件——这个兼容性测试是把压缩格式做坏的最后一道防线。在命令行里可以这样跑回归# 压缩测试集生成同一命名规则的存档 ./7z a -t7z -mx5 ./regress/test1.7z ./regress/data1 ./7z a -t7z -mx9 ./regress/test2.7z ./regress/data2 # 解压后对每个文件做 CRC 校验 ./7z t ./regress/test1.7z ./7z t ./regress/test2.7z如果对输出体积敏感的改动我还会在改动前后各跑一遍同一个测试集用diff对比清单文件里显示的压缩前总大小/压缩后总大小用数字确认回归是否存在。从那以后我每次改完压缩相关代码都会强制走一遍这四件事编译、压测试集、解压校验、对比体积。这个习惯救过我很多次尤其是改过Range Coder位运算以后一个小小的符号错误可能让解压器静默输出错误数据而不报错——如果不在压缩时同步核对CRC你根本发现不了。希望帮到你。本文还有配套的精品资源点击获取