跳到主要内容
AZ Tools

Python .pyc 字节码检查器

.pyc 是 CPython 写在源码旁边 __pycache__ 目录里的编译结果,结构只有两部分。前十六个字节是文件头:四字节魔数精确标明写出该文件的 CPython 版本(不是你现在装的那个,而是编译它的那个),随后是一个标志字。标志字的第 0 位决定缓存如何失效。为 0 时,接下来的八个字节是源文件的修改时间和大小,只要其中之一与磁盘上的 .py 不再一致,导入系统就会丢弃缓存。为 1 时按 PEP 552 的做法,这八个字节改成源码哈希,第 1 位则表示运行时是否每次导入都重新校验,还是无条件信任——可复现构建要的正是后者。文件头之后是一个由 marshal 序列化的代码对象,模块里的每个函数、lambda、推导式和类体都作为另一个代码对象嵌在它的常量里,所以这里的树会有好几层。每个代码对象都会列出它能触及的名称、局部变量、单元变量与自由变量、参数个数、栈大小、解码后的标志位、按 repr() 写法呈现的每个常量,以及把 EXTENDED_ARG 折叠进后续指令、并把跳转解析为绝对偏移的反汇编。操作码编号与版本绑定且几乎每个版本都在变,因此这里按魔数选择对应的表并在界面上写明用的是哪一版:覆盖 3.7 到 3.13,其他版本只读文件头,宁可放着不解码,也不用错误的表去猜。它不会把源码还给你。这不是反编译器:名称、文档字符串和常量都完整保留,但你看到的是字节码而不是你写的循环,注释和排版在这个文件产生之前就已被编译器丢弃。行号表以及 3.11 起的异常表只跳过,不做解析。

使用方法

  1. 把 __pycache__ 目录里的 .pyc 拖进来,文件名通常形如 module.cpython-311.pyc。
  2. 先看“写入版本”:那是编译该文件的 CPython 版本,魔数不同的解释器会忽略这份缓存并重新从源码编译。
  3. 确认失效方式。时间戳方式记录源文件的修改时间和大小;哈希方式记录源码哈希,其中“不校验”那一种此后再也不会与源码比对。
  4. 在树里挑一个代码对象。最上面是模块,函数、类体、lambda 以及(3.12 之前的)推导式各自挂在定义它们的对象下面。
  5. 阅读所选对象的反汇编:解析结果一列会把索引换成名称、局部变量和常量,>> 表示有跳转落在该指令上。

常见问题

我改了源码,Python 却还在跑旧代码,为什么?
时间戳方式的 .pyc 以秒为精度记录源文件的修改时间,并以字节记录大小,只要两者仍然吻合,CPython 就会重用缓存。有两种情况会击穿它。如果一次修改恰好落在记录时间的同一秒内,而且文件长度没变(比如在生成的文件里改动一个字符),看上去毫无差别,于是旧缓存被继续使用。另外,用保留时间戳的工具还原文件,或切换到会把时间往回拨的分支,都可能留下一个比源码还“新”、却已经对不上的 .pyc。删除 __pycache__ 目录可以解决这两种情况;用 -B 选项或 PYTHONDONTWRITEBYTECODE 则可以让这些文件根本不被写出。
哈希方式里“校验”和“不校验”有什么区别?
PEP 552 用源码的八字节哈希取代时间戳,使构建不再依赖文件修改时间:同样的源码在任何机器上都会产生逐字节相同的 .pyc,这正是可复现构建所需要的。标志字的第 1 位决定行为:校验意味着解释器每次导入都对源码求哈希,不同就重新编译;不校验意味着它根本不看源码。后者适用于由 Python 之外的机制保证新鲜度的场合,例如一次性构建出来的容器镜像;在别处它就是个陷阱,因为在手工重新生成之前,改源码不会产生任何效果。
能从 .pyc 还原出原始源码吗?
用这个工具不行,用任何工具也无法完全还原。编译器保留了运行所需的一切:每个名称、每个常量、文档字符串、参数名,以及每个函数的起始行号,所以 .pyc 泄露的信息远比人们以为的多,绝不能当作混淆手段。它不保留的是文本本身:注释、空行、排版以及表达式的确切形态都没了。uncompyle6、decompyle3 之类的反编译器能从指令流里重建出貌似合理的源码,但它们落后语言好几年,大致停在 3.9 附近,因为新编译器重排控制流的方式已经无法一一对应地还原。
同样的源码,为什么在两个 Python 版本上字节码完全不同?
因为指令集是 CPython 可以随意改写的实现细节。3.11 引入了内联缓存——指令流里真实存在、归属于前一条指令的字节,并把栈帧处理挪到 RESUME、MAKE_CELL 等新操作码上。3.12 把列表和集合推导式内联,于是它们不再是独立的代码对象。3.13 又几乎重排了全部编号。魔数存在的意义正在于此:一个 .pyc 只对写出它的那个版本有效,其他解释器会当它不存在。
偏移为什么一次跳超过 2?EXTENDED_ARG 又是什么?
每条指令固定两个字节,一个操作码一个参数字节,所以大于 255 的参数要靠前面一条或多条 EXTENDED_ARG 指令拼出来,每条再补八位。这里和 dis 一样把它们折叠进后一条指令,因此你会在同一行看到数千的参数。从 3.11 起,指令流里还有归属于前一条指令、运行时保存特化状态的内联缓存槽;这里跳过而不列出,所以偏移一次会前进超过 2。在磁盘上的 .pyc 里这些槽永远是零:特化只发生在内存中,加速后的操作码不会被写进文件。
单元变量和自由变量是什么?为什么同一个名字出现在两个列表里?
闭包需要变量活得比创建它的栈帧更久,于是编译器把它放进一个单元里:定义它的函数把这个名字列在单元变量中,每个读取它的嵌套函数则把同一个名字列在自由变量中。从 3.11 起两者合在同一个数组里,每项带一个种类字节,所以被嵌套函数捕获的参数会同时出现在局部名称和单元变量里——它确实两者都是,而该函数会以一条把参数搬进单元的 MAKE_CELL 指令开头。

相关工具