我试图了解编译后的 CPython 字节码的结构。
假设我有一个包含foo.py以下内容的文件:
def hello(name):
print("Hello, %s" % name)
编译__pycache__\foo.cpython-35.pyc看起来像这样:

接下来我明白了什么:
16 0d 0d 0a是神奇的数字06 e2 7f 57- 最后修改日期31 00 00 00- 文件大小(应该是 - 虽然文件中有 216 字节,所以我不知道实际大小是多少)- 接下来的 22 个字节 (
e3 00 00 00 ... 00 00 00 73) - 不知道为什么 10 00 00 00- 显然是模块代码的大小(但后来发现前一个字符必须是一个类型,而这73是一个字符串类型的代码)64 00 00,64 01 00-LOAD_CONST(0); LOAD_CONST(1),其中常量 0 -code object函数,常量 1 - 它的名称 ("hello")84 00 00-MAKE_FUNCTION(0),但我仍然不明白为什么需要这个操作码的参数5a 00 00-STORE_NAME(0),其中名称 0 - 函数名称 ("hello")64 02 00 53-LOAD_CONST(2); RETURN_VALUE,其中常量为 2 -None(尽管还不完全清楚为什么模块应该返回一些东西)29 03- 模块常量元组:(<code object hello>, "hello", None)- 接下来的 18 个字节 (
74 00 00 64 ... 64 00 00 53) - 功能代码hello(那个hello.__code__.co_code) 29 02- 函数常量元组:(None, "Hello, %s")4e- 常量类型NONE7a 09- 字符串常量类型 (SHORT_ASCII) 和字符串长度"Hello, %s"(9 个字符)48 65 6c 6c 6f 2c 20 25 73- 线"Hello, %s"29 01- 函数名称元组:("print",)da 05- 这里似乎应该有一种字符串类型,但取而代之的是一种不存在的类型da;05- 字符串长度70 72 69 6e 74- 函数名称print29 01- 局部变量名称的元组:("name",)da 04- 再次未知类型da和字符串长度046e 61 6d 65- 变量的名称namea9 00 72 03 00 00 00- 没听懂fa 06- 另一个晦涩的类型fa和字符串长度0666 6f 6f 2e 70 79- 模块文件名 (foo.py)da 05- 难以理解da的字符串类型和长度0568 65 6c 6c 6f- 函数的名称hello(尽管没有声明模块名称的元组或类似的东西)- 接下来的 17 个字节 (
01 00 00 00 ... 00 00 00 4e) - 没看懂 29 01- 一些长度为一个元素的元组72 ...- 类型常量ref(这是什么类型?)da 08- 同样是神秘类型da和字符串长度 083c 6d 6f 64 75 6c 65 3e- 线"<module>"(为什么?)01 ...- 我不知道接下来会发生什么
帮助填补空白。如果我只需要找出某个常数的值,那么我就已经拥有足够的知识了。但是我正在尝试编写一个 Python 字节码解释器,所以我需要完全理解.pyc-file 结构。
故事很长-你可以放一只海鸥。我也没有能力将文档中的所有内容复制到答案中,所以我必须自己阅读文档。此外,不会对dis进行实验- 假设问题的作者已经玩够了它并决定深入挖掘。
这是一个很长的问题,但由于它很受欢迎,我将针对一个选定的实现和一个选定的版本略微讨论这个主题
CPython 3.6.1——这个版本是因为它是最新的,答案稍后会过时,而且因为这个问题明确说明了版本3.5.一般来说,在最新版本中很容易弄清楚什么是什么 - 没有任何困难可以让你大吃一惊。起点将是py_compile模块。为什么来自它,而不是来自 C 来源?- 因为文档是这样说的:
Этот модуль предоставляет функцию для генерации байт-кода из исходников. 正是医生所吩咐的。我们也会提前写一个测试用例,在一个单独的
test.py.现在在主文件中我们可以像这样生成字节码:
扩展
*.pyc只是一个扩展,一个命名约定,可以是任何东西,重要的是内容。你可以像往常一样运行 -
python test.pyc。现在是时候关注生成字节码的函数了。你可以自己看资料,我摘录一段:
文本源被转换为字节。该模块还负责 this -
importlib或更确切地说是类importlib.machinery.SourceFileLoader。突然间,那里运行的所有代码都也就是说,只是阅读源代码。在我们的代码中它看起来像:
源被转换为 AST - 抽象语法树。不可能在这里写出源代码经过一系列各种函数和检查的路径,但简而言之:编译函数被调用:
在上面的代码中,我复制了他们的源代码
py_compile(loader.source_to_code) 并使用一个函数自己编写了它compile——简单情况下的结果是相同的。结果是模块中类的一个实例code。最后,最重要的是AST到字节码的转换。在 py_compile 中,这是这样完成的:
sourcestats看起来像这样{'mtime': 1493229661.7715623, 'size': 180}:mtime- 这是源代码更改的时间,以一些特殊的时间戳表示。它会进一步派上用场。内容
code_to_bytecode:我很惊讶,但是转换为字节码本身通常需要 4 行。
MAGIC_NUMBER- 一个神奇的词,每个新版本都是新的。对于 3.6.1(Python 3.6rc1)是(3379).to_bytes(2, 'little') + b'\r\n'. 可以在 中找到所有魔术词的列表importlib/_bootstrap_external.py。请注意,除了数字之外,还会插入一个回车符。功能_w_long是(int(x) & 0xFFFFFFFF).to_bytes(4, 'little')。- 您可以使用 HEX 编辑器检查 .pyc 文件的实际内容 - 一切都匹配。也就是说,代码本身在2 + 2 + 4 + 4字节之后开始。这些都是小东西,真正的目标是marshal模型和dumps函数。marshal-这个модуль, содержащий функции, которые могут читать и писать значение переменных в бинарном формате. Формате специфичен для Python, но независим от архитектуры конечной машины。此外,不幸的是,它在 python 中不起作用,你将不得不去源代码:你可以跟随对象的命运,但最终对象被这个函数
marshal.dumps打成字节。它很长但相对简单。marshal 可以将很多对象放入一个文件中,但我们对该类型的对象感兴趣(它被传递给他)——为此有一个特殊的检查来查看传递的对象是否是一个代码。从这个函数可以看出,肯定写了很多信息——操作类型(op_code)、参数、参数值是这个列表中最简单的。这些变量的确切含义可以在 code.h文件中 PyCodeObject 结构的定义中看到(那里有很多注释)code不过,总的来说,并非全部。这仅仅是开始。我们可以看到具体的内容,按字节,写入了 pyc 文件,而不必爬入 C。事实是 Python 对象具有所有必要的属性
code。我按照在 pyc 中编写的顺序排列参数:在屏幕上打印参数的值并计算偏移量(1 个调用
w_long写入 4 个字节,W_TYPE写入 1 个字节)后,您可以使用 HEX 编辑器轻松查看写入的内容和位置。您已经可以使用 dis 分析 co_code - 在新版本中它有一个王牌get_instructions方法:它还具有一个字节偏移量、一个操作数代码以及您可能需要的一切。
为了巩固您阅读的内容,您可以完全亲手解开
pyc文件。我不会编写整个解析器 - 它非常昂贵,但根据已经编写的内容,您可以编写完整的解析器:代码有些不完整,只处理了几个类型,但我希望思路清晰 - 这段代码是函数的镜像
dumps- 只有使用w_object(写入对象)和它自己的简单类型记录器,你还需要r_object(读取对象)和简单的读者。r_object 已经写入,只需要仔细阅读即可。