简介PyInstaller-3.2.1 是一份定位于 Python 程序分发与打包实践的旧版工具资源适合需要把脚本交付给无 Python 环境用户、或希望研究 PyInstaller 内部实现与构建流程的开发者也可用于教学演示和旧版本兼容性排查尤其适合 Windows 平台上的 .exe 交付场景。它通过分析脚本依赖将标准库、第三方库、C 扩展、DLL 与数据文件一并收集支持单文件、多文件发布及跨平台构建可复现 3.x 时期项目的打包环境。压缩包为 zip共 790 个文件、约 3.01MB以 549 个 .py 源码为主体同时包含 .c/.h 扩展源码、.spec 构建配置、.toc 清单、rst/txt 文档及少量 exe、dll、pyd 等二进制与示例文件便于按模块查阅。已有 602 人学习下载。读者可从中了解依赖分析、隐式导入处理、资源收集和调试日志等关键环节并借助自带示例与配置理解 onefile/onedir 产物结构为排错和旧项目维护提供参考。1. 一个只认旧版本的维护项目把我推回了 PyInstaller 3.2.1接手一个维护期的桌面工具时运行环境被锁死在 Python 3.6机器上连编译链都是老的新版本装上去直接报错。能对得上的打包工具只剩 PyInstaller 3.2.1。它干的事很朴素把.py连同解释器、依赖库、数据文件一起封成一个不依赖目标机 Python 环境的可执行文件。难的不是打出来是打出来之后在别人机器上还能跑。这篇把这套老版本打包工具的链路、命令行参数、spec 改法和踩过的坑摊开讲适合手上有 Python 3.6 老项目、又必须交付 exe 的人。2. 拆开 bootloader 与 _MEI 目录PyInstaller 3.2.1 的打包链路很多人对打包工具有个误解以为 PyInstaller 是把 Python 代码编译成机器码。不是。它做的是搬运把解释器、字节码、扩展模块、数据文件按依赖关系收集起来装进一个容器再配一个用 C 写的小启动器。理解这一点后面所有的报错就都能对上号——打包产物跑不起来九成是该搬的东西没搬进去或者搬进去了但运行时找不到。3.2.1 这个版本的处理流程分三段依次是 Analysis、PYZ、EXE三段之间靠 TOC 表传递结果。下面按顺序拆。2.1 Analysis依赖是静态扫出来的不是跑出来的Analysis 阶段拿到你的入口脚本后调用内部依赖图工具从import语句出发做静态分析把入口能触及的模块一条条列出来。注意关键词是静态——它不执行你的代码只读语法树。这意味着任何在运行时才决定的导入它都看不见__import__(某个变量)、importlib.import_module(字符串)、eval拼出来的 import、靠扫描目录发现的插件、pkg_resources的入口点加载统统逃过扫描。这是后面所有ModuleNotFoundError的总根源记住这条补依赖时就不用瞎猜。扫描结果落成四张 TOC 表每行是(name, path, typecode)三元组例如(yaml, /venv/lib/python3.6/site-packages/yaml/__init__.py, PYMODULE)。四张表分别是a.scripts入口脚本、a.pure纯 Python 模块、a.binaries.pyd/.so这类二进制扩展和动态库、a.datas配置文件、模板、字体等非代码文件。同时它会去 PyInstaller 自带的 hooks 目录和hookspath里找与模块同名的 hook 文件hook 里可以往这几张表里补东西这是官方给常见第三方库打的补丁。分析跑完build/name/下会生成Analysis-00.toc和一个极有价值的文件warn-name.txt。后者列出了被 import 了但没找到的模块清单判断到底缺什么第一眼看的就是它比在打包产物上盲试快得多。2.2 PYZ 与 EXEbootloader 干的是解压和接管导入a.pure里的纯 Python 模块会被编译成.pyc压进一个叫 PYZ 的归档。这一步是性能优化把成百上千个小文件收成一个减少运行时的文件系统调用。接着进入 EXE 阶段把预编译好的 bootloader一个独立的 C 可执行体与你的业务代码无关和装着所有料包的 CArchive 拼接得到最终产物。运行时的动作序列是这样的bootloader 启动判定自己是单文件还是目录模式确定一个工作目录把料包解开单文件模式解到%TEMP%下的_MEIxxxxxx随机目录把这个目录塞进sys.path注册一个从归档里读模块的导入器到sys.meta_path最后执行入口脚本。所以程序跑起来之后sys.frozen为Truesys._MEIPASS指向那个解压目录__file__的含义也跟着变了——凡是在代码里靠__file__拼路径的打包后基本都会断。注意_MEIxxxxxx目录名每次启动都不同。写日志、写缓存时把它当固定路径用是新手最容易踩的一脚。2.3 单文件与目录模式代价不在打包在每一次启动-F和-D的差别不是一个文件还是多个文件这么表面它决定了启动时的 I/O 形态。单文件模式下每次双击都要把整个料包解压一遍包体 200MB 就意味着每次启动先读写 200MB目录模式直接从磁盘加载没有这个开销。曾经有个项目图省事用了-F带着一堆科学计算库产物 300MB 出头客户机上冷启动要七八秒用户以为程序卡死了反复双击开出好几个实例——这就是打包选项选错带来的血泪经验。维度-F单文件-D目录模式产物形态一个 exe一个目录 若干文件启动开销每次全量解压与包体成正比无解压接近原生崩溃残留异常退出可能留下_MEI临时目录无杀软敏感度偏高自解压行为容易被判可疑相对温和分发方式直接发 exe压 zip或做安装包换文件调试必须重新打包可直接替换目录内文件验证我的选型习惯是交付给外行用户、体积小于 30MB 的小工具用-F图省事带 GUI、带大量依赖、或者需要现场替换资源排查问题的一律-D最后用压缩包或安装包交付。别为了看起来干净牺牲启动速度用户对慢的容忍度远低于对多个文件的容忍度。2.4 spec 文件真正被执行的构建脚本敲pyinstaller main.py的时候它做的第一件事是生成一份main.spec然后执行这个 spec。命令行参数只是往 spec 模板里填值。这意味着两件事一是 spec 是纯 Python 脚本可以写判断、读环境变量、按平台分支二是每次直接对.py跑 pyinstaller都会重新生成 spec把你手改的内容覆盖掉。所以正确的姿势是分两步走第一次用命令行生成 spec 并跑通然后改 spec之后所有构建都改成pyinstaller main.spec。这个习惯我从踩坑之后就一直保持着因为手改的hiddenimports和datas一旦被覆盖重新找回来要花半小时。3. 从命令行到 spec把一个脚本打成可交付 exe 的完整流程链路清楚了接下来是动手。这一章按造环境 → 用命令行跑通 → 改 spec 固化 → 管理产物四步走每一步都给出可以直接抄的命令和需要盯的参数。3.1 先造一个干净的打包环境PyInstaller 的收集逻辑是能扫到就带走所以打包环境里装了什么直接影响产物。在全局环境里打包site-packages 里几百个包会被挨个分析产物虚胖不说还可能因为同名模块冲突打出诡异的运行时错误。我一般专门建一个 venv只装这个程序运行期真正需要的依赖打完就删。版本匹配是另一个硬约束。3.2.1 这一代对应的是 Python 3.6 及更早的解释器拿它去跑 3.7 以上的解释器会在导入钩子和字节码层面出问题报错信息还很不直观。所以环境必须锁死。# 建一个只用于打包的干净环境 python3.6 -m venv pyi-env source pyi-env/bin/activate # Windows 下用 pyi-env\Scripts\activate python -V # 必须确认是 3.6.x别跳过这步 # 锁定版本安装别用不带版本号的 install pip install pyinstaller3.2.1 pip install -r requirements.txt # 只装运行期真正需要的包 pyinstaller --version # 输出 3.2.1 才算装对代码逻辑很简单关键是三处python3.6 -m venv保证解释器版本3.2.1保证工具版本requirements.txt保证依赖清单受控。如果内部源上取不到这个版本就从能连通的环境里pip download出 wheel 再离线安装不要试图用新版本凑合一下兼容问题会在打包后期集中爆发。参数上唯一值得改的是 venv 路径建议放在一个短路径下避开中文和空格。3.2 六组命令行参数覆盖八成场景参数不少但真正天天用的就那么几组。下面这张表按使用频率排右边一列是我自己的使用判断。参数作用什么时候用-F/-D单文件 / 目录模式小工具-F带 GUI 或大依赖-D-c/-w保留控制台 / 去掉控制台调试期一律-c交付前再换-w-n NAME指定产物名必填别让产物叫main.exe-i app.ico指定图标Windows 只认.ico多尺寸最稳--add-data src;dst附带数据文件有配置文件、模板、字体时必须--hidden-import MOD补一个扫不到的模块报ModuleNotFoundError时逐个补--exclude-module MOD排除模块削体积排除测试包和用不到的重型库--clean清掉缓存和旧 TOC改了依赖或删了模块之后必须加--log-levelINFO提高日志详细度打包过程报错时定位用--noupx禁用 UPX 压缩被安全软件拦截时首选两条常用命令直接抄# 调试期保留控制台能看见 traceback pyinstaller -D -c -n demo_app --clean --log-levelINFO main.py # 交付期单文件、无控制台、指定图标 pyinstaller -F -w -n demo_app -i icons/app.ico --noupx main.py第一条的-c是关键所有双击闪退的问题都靠它暴露--clean会清掉build/下的旧 TOC防止构建结果里混进已经删掉的模块。第二条里的--noupx是防御性选择UPX 能把体积压下去三分之一但压缩后的高熵特征在安全软件眼里和加壳程序很像误报率明显上升交付给外部用户的包我一律关掉它。3.3 手改 specdatas、hiddenimports、excludes 三处最常动spec 生成好之后真正需要动手改的通常只有三个位置。不要让命令行参数越堆越长固化进 spec 才是可维护的做法。# demo_app.spec —— 只展示需要改动的位置其余保持自动生成的样子 a Analysis( [main.py], pathex[.], binaries[], # 元组含义是 (源路径, 打包后放的目录)不是 (源, 目标文件名) datas[ (config/config.yaml, config), (templates, templates), (assets/fonts, assets/fonts), ], # 静态分析扫不到的模块在这里补 hiddenimports[ pkg_resources, yaml, demo_app.plugins, ], hookspath[hooks], # 自定义 hook 目录 runtime_hooks[], # 明确排掉的东西比指望分析器猜要靠谱 excludes[ tkinter, unittest, pydoc_data, matplotlib.tests, numpy.testing, ], win_no_prefer_redirectsFalse, win_private_assembliesFalse, )三处改动的逻辑说明datas里每个元组是从哪拿和放到产物的哪个相对目录下第二项必须是目录写成文件名是常见错误会导致资源落到根目录而代码找不到hiddenimports是把静态分析漏掉的模块硬塞进去补之前先看warn-name.txt别凭感觉加excludes是主动瘦身把测试包、文档数据、GUI 库里没用到的部分排除掉一个tkinter就能省下十几兆。改完后用pyinstaller demo_app.spec构建命令里不再带任何参数。参数一多就记不住也没法进版本管理这是 spec 存在的意义。3.4 build 与 dist两个目录各有各的用处build/name/是中间产物dist/name/是最终交付物。前者经常被当成垃圾直接删其实里面有两个文件很有用warn-name.txt记录了所有找不到的模块缺依赖时看它xref-name.html是模块引用关系图想知道某个库是被谁拖进来的——比如排查为什么tkinter明明没写却被打进去了——打开这个文件搜一下就有答案。dist/目录则是验证的地方。改了 spec 重新构建后我一般会先删掉build/再跑虽然加了--clean也行但 3.2.1 的增量构建在删模块之后容易残留旧条目产物里躺着一个已经删掉的包的字节码排查起来非常费劲。宁可多等两分钟。4. 资源、插件与多进程三类扫不到的依赖怎么补打包报错基本可以归到三类文件没带进去、模块没被扫到、子进程起不来。这三类的成因和处理方式完全不同混在一起试只会浪费时间。这一章分开讲。4.1 --add-data 的分隔符与 sys._MEIPASS 取路径数据文件的问题通常出在两个地方一是没带进去二是进去了但运行时定位不到。前者的坑在分隔符——--add-data的源和目标之间Windows 用分号;Linux 和 macOS 用冒号:。在 Windows 上写成冒号路径会被从中间截断因为C:\本身就带冒号打包不报错运行时文件凭空消失非常隐蔽。# Windows分隔符是分号 pyinstaller -F --add-data config/config.yaml;config main.py # Linux / macOS分隔符是冒号 pyinstaller -F --add-data config/config.yaml:config main.py运行时的取路径要统一走一个函数不要在业务代码里散落open(config/config.yaml)import os import sys def resource_path(rel_path): 打包后从 _MEIPASS 取源码运行时从脚本目录取。 base getattr(sys, _MEIPASS, None) if base is None: base os.path.dirname(os.path.abspath(__file__)) return os.path.join(base, rel_path) # 用法无论源码跑还是打包后跑路径都正确 cfg resource_path(config/config.yaml)sys._MEIPASS是 bootloader 在启动时注入的属性单文件模式下指向%TEMP%\_MEIxxxxxx目录模式下指向 exe 所在目录。用getattr加默认值是为了让同一份代码在源码环境和打包环境都能跑省得维护两套路径逻辑。参数上唯一要注意的是rel_path必须和 spec 里datas的目标目录保持一致两边对不上就等于没打。4.2 动态导入与 hook让 hiddenimports 有地方落插件式架构的程序最容易在这里翻车。主程序扫描plugins/目录用importlib.import_module逐个加载静态分析只看到有个字符串在拼模块名自然一个插件都不会打进去。表现是开发机上完美打包后插件全部失联日志里一行ModuleNotFoundError都没有因为异常被吞了。# 动态加载插件这种写法静态分析一定扫不到 import importlib import os def load_plugins(pkg_dir): for name in os.listdir(pkg_dir): if not name.endswith(.py) or name.startswith(_): continue mod_name demo_app.plugins. name[:-3] # 运行时才知道要导入谁 importlib.import_module(mod_name)解决办法有两个层次。简单场景就在hiddenimports里把插件模块名一个个列全插件数量固定时够用。插件会动态增加的项目写成 hook 更省事在hooks/下放一个hook-demo_app.py里面直接给hiddenimports赋值构建时会被自动合并进去。# hooks/hook-demo_app.py # hook 文件名必须以 hook- 开头后面跟包名 hiddenimports [ demo_app.plugins.csv_loader, demo_app.plugins.json_loader, demo_app.plugins.xml_loader, ]这个文件由 PyInstaller 在分析阶段自动执行作用等价于在 spec 里写hiddenimports好处是跟包绑定、可以随源码一起管理不必每换一个入口脚本就复制一遍。注意文件名规则写错了不会报错只是静默不生效这个哑巴亏我吃过一次。4.3 multiprocessing 与子进程freeze_support 不是可选项Windows 下用多进程的程序打包后会出现一个很吓人的现象进程数指数级增长任务管理器里全是你的 exe机器直接卡死。成因是 Windows 的spawn启动方式会在子进程里重新执行入口模块而冻结之后没有解释器的引导逻辑子进程实际上是又启动了一遍 exe然后再孵化子进程如此递归。import multiprocessing def worker(q): q.put(done) if __name__ __main__: # 打包成 exe 后这一行是必须的缺了会无限孵化子进程 multiprocessing.freeze_support() q multiprocessing.Queue() p multiprocessing.Process(targetworker, args(q,)) p.start() p.join() print(q.get())freeze_support()的作用是检测当前进程是不是 PyInstaller 拉起来的子进程如果是就立刻返回、不执行后面的业务代码。它的位置必须在if __name__ __main__:之后的第一行放到函数里或者条件分支里都可能失效。这个函数在源码环境里是空操作加上不会有任何副作用所以别犹豫直接加。同样的思路适用于所有会重启解释器的场景subprocess调用自身、concurrent.futures的进程池、某些 GUI 框架的守护进程。判断标准很简单——只要这段代码在运行时会再启动一个 Python 解释器就要考虑冻结后的行为差异。4.4 二进制依赖binaries 与 datas 的区别--add-binary和--add-data长得像作用对象不同。前者放的是.pyd、.dll、.so这类可加载的二进制PyInstaller 会对它们再做一次依赖扫描把二级依赖的 DLL 一并带进来后者只是原样复制不做任何分析。用ctypes加载的自研库、或者通过dlopen动态载入的第三方库必须走 binaries否则运行时找不到入口点报的错还和文件不存在完全不像。在 spec 里对应的是binaries[(path/to/native.pyd, .)]元组含义与datas一致。判断该放哪边的标准能被import或LoadLibrary加载的走 binaries需要open()读的走 datas。放错位置不会报错只会在运行时以各种离奇的方式失败。5. 排查清单五类翻车现场的现象、成因与处置前面讲的都是应该怎么做这一章讲做砸了怎么救。下面五条按出现频率排每条都按现象、成因、处置三段写遇到问题时可以直接对号入座。5.1 双击闪退命令行里才有 traceback现象双击 exe 窗口一闪而过什么都没留下事件查看器里也没有有效信息。成因用了-w去掉控制台程序启动初期的异常缺模块、缺资源、配置解析失败没有输出目标异常信息随进程一起消失。更麻烦的是单文件模式下临时目录会被清理连日志都没地方找。处置调试期一律用-c构建从命令行启动traceback 直接打屏。已经交付的包来不及重打用cmd /k your_app.exe启动窗口不会自动关闭。实在拿不到输出就在入口最外层包一层 try/except把异常写进%APPDATA%下的日志文件——这是黑匣子出问题时能救命。5.2 目标机报缺 DLL 或 Failed to load Python DLL现象开发机运行正常拷到客户机上弹出缺少api-ms-win-crt-runtime-l1-1-0.dll、vcruntime140.dll或者直接提示无法加载 Python DLL。成因新版 Python 依赖 Universal CRT 和 VC 运行库PyInstaller 会把python36.dll带进包但系统级的运行库不在它的收集范围里。另外位数不匹配32 位程序跑在纯 64 位环境、或反之也会报类似错误。处置确认打包用的解释器位数与目标系统一致32 位 Python 打出来的就是 32 位程序目标机安装对应版本的 VC 可再发行组件这是最省事的方案。要做免安装交付就在 spec 的binaries里把vcruntime140.dll和相关 CRT 库显式带上让它跟着包一起走。别指望用户自己会装。5.3 杀软拦截与 UPX 压缩的副作用现象产物在自己机器上好好的发出去十分钟就被反馈被杀毒软件删了或者运行时被拦截、进程被终止。成因单文件 exe 的自解压行为本身就敏感叠加 UPX 压缩之后熵值升高静态特征和加壳恶意程序高度相似启发式引擎很容易误判。没有数字签名的产物更是雪上加霜。处置先关 UPX--noupx或在 spec 里设upxFalse多数误报会消失。仍有问题就改走目录模式把整个目录压成 zip 分发安装形态比自解压 exe 温和得多。长期方案是给产物做代码签名并整理一份说明提交给安全厂商做误报申诉。改文件名、改图标这类做法完全无效别浪费时间。5.4 中文路径、中文程序名与图标不生效现象-i指定的图标打进去还是默认图标或者构建目录带中文时打包中途报编码错误又或者程序名用中文在某些机器上双击无反应。成因图标格式不对是首要原因Windows 只认.ico把 png 改名成 ico 是最常见的自欺欺人其次是老版本 bootloader 对非 ASCII 路径的处理不够稳路径里带中文或全角空格时容易在解压环节出错。处置图标用工具转成包含 256/128/64/48/32/16 多尺寸的 ico 文件构建目录放在纯 ASCII 的短路径下比如D:\build产物名也用英文加下划线。中文只出现在界面上不要出现在路径和文件名里。这条听起来像老生常谈但每次项目交接都能遇到。5.5 开发机能跑、换台机器就崩现象同一份 exe在自己机器上反复测试都正常到同事或客户机器上启动即退出没有任何提示。成因最常见的是程序试图往 exe 所在目录写文件装到Program Files下就没有写权限其次是打包环境不干净某个模块是靠开发机特有的路径被扫进去的换台机器构建就没了还有一种是无控制台模式下权限错误直接吞掉异常。处置所有运行时产生的数据日志、缓存、配置一律写到%APPDATA%或用户目录下绝不用 exe 所在目录构建统一在干净的 venv 里做构建脚本进版本管理保证任何人任何机器构建结果一致验证时专门用一个非管理员账户跑一遍权限问题会立刻暴露。6. 交付前的三道验证与一个可复用的构建脚本打包这件事我这能跑和交付没问题之间隔着一段距离。我的习惯是交付前固定跑三道验证缺一道都不发。验证项做法能暴露的问题干净虚拟机全新系统镜像不装 Python 和任何运行库缺 DLL、缺 CRT、路径假设非管理员账户用普通用户登录运行写入权限、注册表访问断网运行断开网络启动并走一遍主流程隐式的在线依赖、超时卡死三道验证做完基本能覆盖九成的现场事故。虚拟机这一步尤其别省开发机上装了 VC 运行库和一堆 Python 版本很多缺失问题在自己机器上永远复现不出来。构建脚本把参数固化下来避免每次靠回忆敲命令#!/usr/bin/env bash # build.sh —— 放到项目根目录构建前先激活打包专用 venv set -e APP_NAMEdemo_app # 清掉旧产物避免增量构建残留已删除的模块 rm -rf build dist # 统一走 spec 构建不再往命令行堆参数 pyinstaller --clean --noconfirm --log-levelINFO ${APP_NAME}.spec # 打包完成后立刻检查 warn 文件有缺失模块就人工确认 echo ----- 缺失模块清单 ----- cat build/${APP_NAME}/warn-${APP_NAME}.txt | head -n 40 echo 产物在 dist/ 下接下来跑三道验证干净虚拟机、非管理员、断网。脚本里的set -e让任何一步失败都立刻中止避免拿着半成品去测试rm -rf build dist配合--clean双保险最后把 warn 文件的前几十行打出来是强制自己每次扫一眼——大部分缺模块的问题在这一步就能发现不用等到用户反馈。Windows 下把同样的逻辑写成.batrm -rf换成rmdir /s /q即可其余不变。还有一个细节值得单独说产物名里带上日期或提交号demo_app_20240612这种。出了问题能立刻确认对方手上是哪一版省掉你发我的是哪个包这种来回扯皮。从那以后我每次交付前都会在一个什么都没装的虚拟机上用普通账户断网跑一遍主流程确认没问题才发出去。这个习惯救过我至少三次——一次是缺 CRT一次是往安装目录写缓存一次是启动时偷偷请求了一个接口导致超时卡死。希望帮到你。本文还有配套的精品资源点击获取