字节码解释器 (3.11)¶
序言¶
CPython 3.11 字节码解释器(又称虚拟机)相较于 3.10 有许多改进。我们在此描述 3.11 解释器的内部工作原理,重点在于理解代码及其设计。虽然解释器在不断发展,3.12 设计无疑会再次不同,但了解 3.11 设计将帮助你理解解释器的未来改进。
其他来源
简介¶
字节码解释器的任务,在 Python/ceval.c
中,是执行 Python 代码。其主要输入是一个代码对象,尽管这不是解释器的直接参数。解释器被构造为一个(递归)函数,采用线程状态 (tstate
) 和堆栈帧 (frame
)。该函数还采用一个整数 throwflag
,它被 generator.throw
的实现使用。它返回对 Python 对象 (PyObject *
) 的新引用或错误指示器 NULL
。根据 PEP 523,此函数可以通过设置 interp->eval_frame
进行配置;我们仅描述默认函数 _PyEval_EvalFrameDefault()
。(此函数的签名已演变,不再与 PEP 523 规范的内容相符;线程状态参数已添加,堆栈帧参数不再是一个对象。)
解释器通过在堆栈帧 (frame->f_code
) 中查找来找到代码对象。解释器所需的各种其他项(例如全局变量和内置函数)也可以通过堆栈帧访问。线程状态存储异常信息和各种其他信息,例如递归深度。线程状态还用于访问每个解释器状态 (tstate->interp
) 和每个运行时(即真正全局)状态 (tstate->interp->runtime
)。
请注意这里略微令人困惑的术语。“解释器”是指字节码解释器,一个递归函数。“解释器状态”是指线程共享的状态,每个线程都可以运行自己的字节码解释器。一个进程甚至可以托管多个解释器,每个解释器都有自己的解释器状态,但共享运行时状态。多个解释器的主题由几个 PEP 涵盖,特别是 PEP 684、PEP 630 和 PEP 554(还有更多内容)。当前文档重点介绍字节码解释器。
代码对象¶
解释器使用代码对象 (frame->f_code
) 作为其起点。代码对象包含解释器使用的许多字段,以及一些供调试器和其他工具使用的字段。在 3.11 中,代码对象的最后一个字段是一个包含字节码的不定长数组 code->co_code_adaptive
。(在以前的版本中,代码对象是一个 bytes
对象 code->co_code
;它被更改为节省分配并允许它发生突变。)
代码对象通常由字节码 编译器 生成,尽管它们通常由一个进程写入磁盘并由另一个进程读回。代码对象的磁盘版本使用 marshal
协议进行序列化。一些代码对象使用 Tools/scripts/deepfreeze.py
预加载到解释器中,它会编写 Python/deepfreeze/deepfreeze.c
。
代码对象名义上是不可变的。一些字段(包括 co_code_adaptive
)是可变的,但在对代码对象进行哈希或比较时不包括可变字段。
指令解码¶
解释器的第一个任务是解码字节码指令。字节码存储为一个 16 位代码单元数组 (_Py_CODEUNIT
)。每个代码单元包含一个 8 位 opcode
和一个 8 位参数 (oparg
),两者都是无符号的。为了在存储在磁盘上时使字节码格式独立于机器字节顺序,opcode
始终是第一个字节,oparg
始终是第二个字节。宏用于从代码单元中提取 opcode
和 oparg
(_Py_OPCODE(word)
和 _Py_OPARG(word)
)。某些指令(例如 NOP
或 POP_TOP
)没有参数——在这种情况下,我们忽略 oparg
。
一个简单的指令解码循环如下所示
_Py_CODEUNIT *first_instr = code->co_code_adaptive;
_Py_CODEUNIT *next_instr = first_instr;
while (1) {
_Py_CODEUNIT word = *next_instr++;
unsigned char opcode = _Py_OPCODE(word);
unsigned int oparg = _Py_OPARG(word);
switch (opcode) {
// ... A case for each opcode ...
}
}
此格式支持 256 个不同的 opcode,这已足够。但是,它还将 oparg
限制为 8 位值,但并非如此。为了克服此问题,EXTENDED_ARG
opcode 允许我们使用一个或多个附加数据字节为任何指令添加前缀。例如,此代码单元序列
EXTENDED_ARG 1
EXTENDED_ARG 0
LOAD_CONST 2
将 opcode
设置为 LOAD_CONST
,将 oparg
设置为 65538
(即 0x1_00_02
)。编译器应将自身限制为最多三个 EXTENDED_ARG
前缀,以允许生成的 oparg
容纳在 32 位中,但解释器不会检查这一点。从零到三个 EXTENDED_ARG
opcode 开始,然后是主 opcode 的一系列代码单元称为完整指令,以将其与始终为两个字节的单个代码单元区分开来。以下循环将插入到 switch
语句正上方,将使上述代码段解码一个完整指令
while (opcode == EXTENDED_ARG) {
word = *next_instr++;
opcode = _Py_OPCODE(word);
oparg = (oparg << 8) | _Py_OPARG(word);
}
由于各种原因,我们稍后会讨论(主要是效率,因为 EXTENDED_ARG
很少见),实际代码有所不同。
跳转¶
请注意,当到达 switch
语句时,next_instr
(“指令偏移”)已指向下一条指令。因此,可以通过操作 next_instr
来实现跳转指令
绝对跳转 (
JUMP_ABSOLUTE
) 设置next_instr = first_instr + oparg
。向前相对跳转 (
JUMP_FORWARD
) 设置next_instr += oparg
。向后相对跳转设置
next_instr -= oparg
。
其 oparg
为零的相对跳转是无操作。
内联缓存条目¶
一些(专门或可专门化的)指令具有关联的“内联缓存”。内联缓存由一个或多个两字节条目组成,这些条目作为附加字词包含在字节码数组中,后跟 opcode
/oparg
对。特定指令的内联缓存大小仅由其 opcode
固定。此外,专门化/可专门化指令系列(例如,LOAD_ATTR
、LOAD_ATTR_SLOT
、LOAD_ATTR_MODULE
)的内联缓存大小必须全部相同。缓存条目由编译器保留并用零初始化。如果指令具有内联缓存,则其缓存的布局可以通过 struct
定义来描述,并且缓存的地址通过将 next_instr
转换为指向缓存 struct
的指针来给出。此类 struct
的大小必须独立于机器架构、字长和对齐要求。对于 32 位字段,struct
应使用 _Py_CODEUNIT field[2]
。即使内联缓存条目由代码单元表示,它们也不必符合 opcode
/ oparg
格式。
指令实现负责将 next_instr
推进到内联缓存之后。例如,如果指令的内联缓存大小为四个字节(即两个代码单元),则指令的代码必须包含 next_instr += 2;
。这等效于向前相对跳转那么多代码单元。(编码此内容的正确方法是 JUMPBY(n)
,其中 n
是要跳转的代码单元数,通常以命名常量给出。)
序列化非零缓存条目会带来问题,因为序列化 (marshal
) 格式必须独立于机器字节顺序。
有关内联缓存使用的更多信息 可以在 PEP 659 中找到.
求值栈¶
除了无条件跳转之外,几乎所有指令都以对象引用 (PyObject *
) 的形式读取或写入一些数据。CPython 3.11 字节码解释器是一个栈机器,这意味着它通过将数据压入栈并从栈中弹出数据来操作。栈是一个预先分配的对象引用数组。例如,“add”指令(在 3.10 中曾称为 BINARY_ADD
,现在是 BINARY_OP 0
)从栈中弹出两个对象并将结果推回栈中。CPython 字节码解释器的有趣特性是,求值给定函数所需的栈大小是预先已知的。栈大小由字节码编译器计算并存储在 code->co_stacksize
中。解释器使用此信息来分配栈。
栈在内存中向上增长;操作 PUSH(x)
等效于 *stack_pointer++ = x
,而 x = POP()
表示 x = *--stack_pointer
。没有溢出或下溢检查(除非在调试模式下编译)——这将过于昂贵,因此我们非常信任编译器。
在执行的任何时刻,仅根据指令指针就可以知道堆栈级别,并且堆栈上每个项目的某些属性也是已知的。特别是,只有少数指令可以将 NULL
推送到堆栈上,并且可能为 NULL
的位置是已知的。其他一些指令 (GET_ITER
、FOR_ITER
) 推送或弹出已知为迭代器的对象。
不允许静态知道堆栈深度的指令序列被视为非法。字节码编译器绝不会生成此类序列。例如,以下序列是非法的,因为它不断将项目推送到堆栈上
LOAD_FAST 0
JUMP_BACKWARD 2
不要将求值堆栈与调用堆栈混淆,后者用于实现函数的调用和返回。
错误处理¶
当 BINARY_OP
之类的指令遇到错误时,将引发异常。此时,将向异常添加一个回溯条目(通过 PyTraceBack_Here()
),并执行清理。在最简单的情况下(没有任何 try
块),这会导致剩余的对象从求值堆栈中弹出,并减少它们的引用计数(如果不是 NULL
)。然后解释器函数 (_PyEval_EvalFrameDefault()
) 返回 NULL
。
但是,如果在 try
块中引发异常,解释器必须跳转到相应的 except
或 finally
块。在 3.10 及之前版本中,有一个单独的“块堆栈”,用于跟踪嵌套的 try
块。在 3.11 中,此机制已被静态生成的表 code->co_exceptiontable
替换。这种方法的优点是,进入和离开 try
块通常不会执行任何代码,从而使执行速度更快。但当然,此表需要由编译器生成,并在发生异常时由 get_exception_handler
解码。
异常表格式¶
从概念上讲,该表是一个记录列表,每个记录包含四个可变长度的整数字段(采用独特的格式,见下文)
start:
try
块的开始,从字节码开始的代码单元length:
try
块的大小,以代码单元为单位target:
except
或finally
块的第一个指令的开始,从字节码开始的代码单元depth_and_lasti:低位给出“lasti”标志,其余位给出堆栈深度
堆栈深度用于清除高于此深度的求值堆栈条目。“lasti”标志指示在堆栈清理后,是否应将引发指令的指令偏移量作为 PyLongObject *
推送。有关设计的更多信息,请参阅 Objects/exception_handling_notes.txt
。
每个变长整数编码为一个或多个字节。高位(第 7 位)保留用于随机访问——它为记录的第一个变长整数设置。第二位(第 6 位)指示这是否是最后一个字节——它为变长整数的所有字节(最后一个字节除外)设置。低 6 位(第 0-5 位)用于整数值,采用大端序。
要查找给定指令偏移量的表条目(如果有),我们可以在不解码整个表的情况下使用二分法。我们在原始字节中进行二分,在每个探测中通过向后扫描以查找高位设置的字节来查找记录的开始,然后解码第一个变长整数。请参阅 Python/ceval.c
中的 get_exception_handler()
以获取确切的代码(与所有二分法算法一样,该代码有点微妙)。
位置表¶
每当引发异常时,我们都会向异常添加一个回溯条目。tb_lineno
回溯条目的字段必须设置为引发它的指令的行号。此字段使用位置表 co_linetable
(此名称是轻描淡写的)通过 PyCode_Addr2Line()
计算。此表为每个指令而不是每个 try
块都有一个条目,因此紧凑的格式非常重要。
3.11 locations 表的完整设计记录在 Objects/locations.md
中。虽然有传言称此文件略有过期,但它仍然是我们拥有的最佳参考。不要与 Objects/lnotab_notes.txt
混淆,后者描述了 3.10 格式。出于向后兼容性的考虑,co_lnotab
属性仍然支持此格式。
3.11 位置表格式不同,因为它不仅存储每条指令的起始行号,还存储结束行号、以及起始和结束列号。请注意,回溯对象不会存储所有这些信息——它们存储起始行号(出于向后兼容性的考虑)和“最后一条指令”值。其余信息可借助位置表从最后一条指令 (tb_lasti
) 计算得出。对于 Python 代码,存在一种便捷的方法 co_positions()
,它返回 (line, endline, column, endcolumn)
元组的迭代器,每个指令一个。还有 co_lines()
,它返回 (start, end, line)
元组的迭代器,其中 start
和 end
是字节码偏移量。后者由 PEP 626 描述;它更紧凑,但不会返回结束行号或列偏移量。从 C 代码中,您必须调用 PyCode_Addr2Location()
。
幸运的是,位置表仅由异常处理(设置 tb_lineno
)和跟踪(将行号传递给跟踪函数)使用。为了减少跟踪期间的开销,从指令偏移量到行号的映射缓存在 _co_linearray
字段中。
异常链接¶
在异常处理期间引发异常时,新异常将链接到旧异常。这是通过使新异常的 __context__
字段指向旧异常来实现的。这是 Python/errors.c
中 _PyErr_SetObject()
的职责(最终由所有 PyErr_Set*()
函数调用)。另外,如果执行 raise X from Y
形式的语句,则引发的异常 (X
) 的 __cause__
字段将设置为 Y
。这是由 PyException_SetCause()
完成的,它会响应所有 RAISE_VARARGS
指令进行调用。特殊情况是 raise X from None
,它将 __cause__
字段设置为 None
(在 C 级别,它将 cause
设置为 NULL
)。
(待办事项:其他异常详细信息。)
Python 到 Python 调用¶
函数 _PyEval_EvalFrameDefault()
是递归的,因为有时解释器会调用一些 C 函数,而这些函数会回调到解释器中。在 3.10 及更早版本中,即使 Python 函数调用另一个 Python 函数时也是如此:CALL
指令将调用被调用者的 tp_call
分派函数,该函数将提取代码对象,为调用堆栈创建一个新帧,然后回调到解释器中。这种方法非常通用,但会为每个嵌套的 Python 调用消耗几个 C 堆栈帧,从而增加了(不可恢复的)C 堆栈溢出的风险。
在 3.11 中,CALL
指令对函数对象进行特殊处理,以“内联”调用。当调用被内联时,一个新帧被推送到调用堆栈上,解释器“跳转”到被调用者字节码的开头。当内联的被调用者执行 RETURN_VALUE
指令时,帧从调用堆栈中弹出,解释器返回给其调用者,方法是从调用堆栈中弹出帧并“跳转”到返回地址。帧中有一个标志 (frame->is_entry
) 表示帧是否被内联(如果未内联则设置)。如果 RETURN_VALUE
发现此标志已设置,它将执行通常的清理并从 _PyEval_EvalFrameDefault()
完全返回到 C 调用者。
当发生未处理的异常时,也会执行类似的检查。
调用堆栈¶
在 3.10 之前,调用堆栈过去是实现为 PyFrameObject
对象的单链表。这很昂贵,因为每次调用都需要为堆栈帧分配堆空间。(使用空闲列表进行了一些优化,但这并不总是有效,因为帧的长度是可变的。)
在 3.11 中,帧不再是完全成熟的对象。相反,使用了一个精简的内部 _PyInterpreterFrame
结构,该结构使用自定义分配器 _PyThreadState_BumpFramePointer()
进行分配。通常,帧分配只是一个指针移动,这提高了内存局部性。函数 _PyEvalFramePushAndInit()
分配并初始化帧结构。
有时需要一个实际的 PyFrameObject
,通常是因为一些 Python 代码调用 sys._getframe()
或扩展模块调用 PyEval_GetFrame()
。在这种情况下,我们分配一个适当的 PyFrameObject
并从 _PyInterpreterFrame
初始化它。这是一个悲观处理,但幸运的是很少发生(因为内省帧不是一个常见的操作)。
当涉及到生成器时,事情变得更加复杂,因为它们不遵循 push/pop 模型。(对于使用相同基础设施实现的异步函数也是如此。)生成器对象有空间容纳 _PyInterpreterFrame
结构,包括可变大小部分(用于局部变量和 eval 堆栈)。当生成器(或异步)函数首次被调用时,执行一个特殊的 opcode RETURN_GENERATOR
,该 opcode 负责创建生成器对象。生成器对象的 _PyInterpreterFrame
使用当前堆栈帧的副本进行初始化。然后从堆栈中弹出当前堆栈帧,并返回生成器对象。(详细信息因 is_entry
标志而异。)当生成器恢复时,解释器将 _PyInterpreterFrame
推送到堆栈上并恢复执行。(对于生成器及其同类还有更多麻烦;我们将在后面的部分中更详细地讨论这些内容。)
(TODO:还需要框架布局和用法,以及“locals plus”。)
各种变量¶
字节码编译器确定每个变量名称定义的作用域,并相应地生成指令。例如,使用 LOAD_FAST
将局部变量加载到堆栈上,而使用 LOAD_GLOBAL
加载全局变量。主要变量类型有
快速局部变量:用于函数中
(慢速或常规)局部变量:用于类和顶层
全局变量和内置变量:编译器不区分全局变量和内置变量(尽管专门的解释器会区分)
单元格:用于非局部引用
(TODO:编写本节的其余部分。唉,作者分心了,暂时没有时间继续写下去。)
其他主题¶
(TODO:以下每一项可能都需要单独的章节。)
co_consts、co_names、co_varnames 及其同类
调用如何工作(如何传输参数、返回、异常)
生成器、异步函数、异步生成器和
yield from
(next、send、throw、close;以及 await;以及此代码如何打破解释器抽象)Eval 中断器(中断、GIL)
跟踪
设置当前行号(调试器引起的跳转)
专门化、内联缓存等。
引入新字节码¶
注意
如果您要向解释器添加新字节码,则本节相关。
有时,新功能需要新的操作码。但是,添加新字节码并不像在编译器的 AST -> 字节码步骤中突然引入新字节码那么简单。Python 中的几段代码依赖于有关现有字节码的正确信息。
首先,您必须选择一个名称,在 Python/bytecodes.c
中实现字节码,并在 Doc/library/dis.rst
中添加文档条目。然后运行 make regen-cases
为其分配一个数字(参见 Include/opcode_ids.h
),并使用字节码的实际实现重新生成多个文件(Python/generated_cases.c.h
)以及包含有关它们的元数据的其他文件。
使用新字节码,您还必须更改称为 .pyc 文件的魔数。变量 MAGIC_NUMBER
在 Lib/importlib/_bootstrap_external.py
中包含该数字。更改此数字将导致解释器在导入时重新编译所有具有旧 MAGIC_NUMBER
的 .pyc 文件。每当 MAGIC_NUMBER
更改时,PC/launcher.c
中 magic_values
数组中的范围也必须更新。对 Lib/importlib/_bootstrap_external.py
的更改仅在运行 make regen-importlib
之后才会生效。在将新字节码目标添加到 Python/bytecodes.c
(然后是 make regen-cases
)之前运行此命令将导致错误。您应该仅在添加新字节码目标后运行 make regen-importlib
。
注意
在 Windows 上,运行 ./build.bat
脚本将自动重新生成所需的文件,而无需其他参数。
最后,你需要介绍新字节码的使用。更改 Python/compile.c
、Python/bytecodes.c
将成为主要更改位置。优化 Python/flowgraph.c
可能也需要更新。如果新操作码影响控制流或块堆栈,你可能需要更新 Objects/frameobject.c
中的 frame_setlineno()
函数。Lib/dis.py
可能需要更新,如果新操作码以特殊方式解释其参数(如 FORMAT_VALUE
或 MAKE_FUNCTION
)。
如果你在此处进行更改,这可能会影响已存在字节码的输出,并且你不会持续更改魔术数字,请务必删除旧的 .py(c|o) 文件!即使更改字节码最终会更改魔术数字,但当你调试工作时,你将在不持续增加魔术数字的情况下更改字节码输出。这意味着你最终会得到陈旧的 .pyc 文件,这些文件不会被重新创建。运行 find . -name '*.py[co]' -exec rm -f '{}' +
应删除你拥有的所有 .pyc 文件,强制创建新文件,从而允许你正确测试新字节码。运行 make regen-importlib
以更新冻结的 importlib 文件的字节码。你必须在此之后再次运行 make
以重新编译生成的 C 文件。