C语言、golang运行前将所有源代码一次性转换成二进制指令,供底层CPU识别,这类语言属于编译型语言。Python运行前不需要转换成机器码,而是一边执行一边转换,需要哪些源代码就转换哪些源代码,不会生成可执行程序。转换的中间结果叫做字节码(Python作者定义的数据格式,转换叫做编译),而识别并运行(这个过程叫解释)字节码的工具叫做解释器,又叫虚拟机,本质上就是C语言写的一个可执行程序。Python的编译器与解释权在同一个可执行程序内。
一、字节码与pyc文件
Python 程序执行时需要先由 编译器 编译成 代码 对象,然后再交由 虚拟机 来执行。不管程序执行多少次,只要源码没有变化,编译后得到的代码对象就肯定是一样的。因此,Python 将代码对象序列化并保存到 pyc 文件中。当程序再次执行时,Python 直接从 pyc 文件中加载代码对象字节码,省去编译环节。当然了,当 py 源码文件改动后,pyc 文件便失效了,这时 Python 必须重新编译 py 文件。Python的import机制会触发.pyc文件的生成,如果一个代码文件不被其他文件加载,只是作为主程序执行,则不会生成.pyc文件。.pyc文件保存的数据结构就是PyCodeObeject对象按照某一规则序列化的二进制结果,加载过程为二进制文件反序列化至PyCodeObject对象。
二、PyCodeObject对象
2.1 名字空间
#【demo.py】
Class A:
pass
def Fun():
pass
a=A()
Fun()
一个.py文件会包含多个PyCodeObject对象,每个Code Block会创建一个PyCodeObject对象与之对应。确定Code Block的规则很简单:进入一个新的名字空间或者作用域时,就算进入了一个新的Code Block。demo.py编译完成后会创建3个PyCodeObject,一个是对应整个demo.py文件的,一个是class A所代表的Code Block,最后一个是def Fun所代表的Code Block。
2.2 PyCodeObject数据结构
typedef struct {
PyObject_HEAD
int co_argcount; /* #arguments, except *args */
int co_kwonlyargcount; /* #keyword only arguments */
int co_nlocals; /* #local variables */
int co_stacksize; /* #entries needed for evaluation stack */
int co_flags; /* CO_..., see below */
int co_firstlineno; /* first source line number */
PyObject *co_code; /* instruction opcodes */
PyObject *co_consts; /* list (constants used) */
PyObject *co_names; /* list of strings (names used) */
PyObject *co_varnames; /* tuple of strings (local variable names) */
PyObject *co_freevars; /* tuple of strings (free variable names) */
PyObject *co_cellvars; /* tuple of strings (cell variable names) */
/* The rest aren't used in either hash or comparisons, except for co_name,
used in both. This is done to preserve the name and line number
for tracebacks and debuggers; otherwise, constant de-duplication
would collapse identical functions/lambdas defined on different lines.
*/
Py_ssize_t *co_cell2arg; /* Maps cell vars which are arguments. */
PyObject *co_filename; /* unicode (where it was loaded from) */
PyObject *co_name; /* unicode (name, for reference) */
PyObject *co_lnotab; /* string (encoding addr<->lineno mapping) See
Objects/lnotab_notes.txt for details. */
void *co_zombieframe; /* for optimization only (see frameobject.c) */
PyObject *co_weakreflist; /* to support weakrefs to code objects */
/* Scratch space for extra data relating to the code object.
Type is a void* to keep the format private in codeobject.c to force
people to go through the proper APIs. */
void *co_extra;
} PyCodeObject;
研究PyCodeObject对象的一个好方法是编译一个简单的函数,并检查由该函数生成的代码对象。 我们将简单的fizzbuzz
函数用作例子。
>>> def fizzbuzz(n):
... if n % 3 == 0 and n % 5 == 0:
... return 'FizzBuzz'
... elif n % 3 == 0:
... return 'Fizz'
... elif n % 5 == 0:
... return 'Buzz'
... else:
... return str(n)
...
>>> for attr in dir(fizzbuzz.__code__):
... if attr.startswith('co_'):
... print(f"{attr}:\t{getattr(fizzbuzz.__code__, attr)}")
...
co_argcount: 1
co_cellvars: ()
co_code: b'|\x00d\x01\x16\x00d\x02k\x02r\x1c|\x00d\x03\x16\x00d\x02k\x02r\x1cd\x04S\x00|\x00d\x01\x16\x00d\x02k\x02r,d\x05S\x00|\x00d\x03\x16\x00d\x02k\x02r<d\x06S\x00t\x00|\x00\x83\x01S\x00d\x00S\x00'
co_consts: (None, 3, 0, 5, 'FizzBuzz', 'Fizz', 'Buzz')
co_filename: <stdin>
co_firstlineno: 1
co_flags: 67
co_freevars: ()
co_kwonlyargcount: 0
co_lnotab: b'\x00\x01\x18\x01\x04\x01\x0c\x01\x04\x01\x0c\x01\x04\x02'
co_name: fizzbuzz
co_names: ('str',)
co_nlocals: 1
co_stacksize: 2
co_varnames: ('n',)
输出的字段似乎几乎是不言自明的,除了 co_lnotab
和 co_code
字段似乎包含乱码。 我们来继续说明这些字段及其对 python 虚拟机的重要性。
-
co_argcount
:代码块的参数数量。 仅对于函数代码块具有值。 它在编译过程中会被设置为代码块中 AST 参数集合的长度。 求值循环(evaluation loop)在 set-up 过程中利用这些变量进行代码求值,以进行完整性检查,例如检查所有自变量是否存在以及是否存储局部变量。co_code
:它保存了由求值循环执行的字节码指令序列。 这些字节码指令序列中的每一个都由一个操作码(opcode)和一个参数(opatg)组成。 例如,co.co_code[0]
返回指令的第一个字节124
,该字节对应于 python 的LOAD_FAST
操作码。co_const
:此字段是常量列表,例如代码对象中包含的字符串文字和数字值。 上面的示例显示了fizzbuzz
函数该字段的内容。 此列表中包含的值是代码执行必不可少的,因为它们是LOAD_CONST
操作码引用的值。 字节码指令(例如LOAD_CONST
)的操作数参数是此常数列表的索引。 例如,fizzbuzz
函数co_consts
的值为(None, 3, 0, 5, 'FizzBuzz', 'Fizz', 'Buzz')。
co_filename
:顾名思义,此字段包含文件的名称,该文件包含从中创建代码对象的源代码。co_firstlineno
:这给出了代码对象源代码在文件中所在的第一行的行号。 该值在诸如调试代码之类的情景下中起着非常重要的作用。co_flag
:该字段指示代码对象的种类。 例如,当代码对象是协程的对象时,该标志设置为0x0080
。 还有其他标志,例如CO_NESTED
指示一个代码对象是否嵌套在另一个代码块内,CO_VARARGS
指示一个代码块是否具有变量自变量,等等。 这些标志会影响字节码执行期间求值循环的行为。co_lnotab
:包含一串用于计算某个字节码偏移量处的指令所对应的源代码行号的字节。 例如,dis
函数在计算指令的行号时会使用此功能。co_varnames
:这是在代码块中局部定义的名称的数量。 我们将它与co_names
对比。co_names
:这是代码对象内使用的非局部名称的集合。 例如,的代码段引用了非局部变量p
。co_nlocals
:代表代码对象使用的局部名称的数量。 在fizzbuzz
函数的示例中,唯一使用的局部变量是x
,因此该函数的代码对象的此值为1。co_stacksize
:python 虚拟机是基于堆栈的计算机,即用于求值和求值结果的值可从执行栈读取或写入执行栈。 此co_stacksize
值是代码块执行期间任何时候求值堆栈上存在的最大项目数。co_freevars
:该字段是在代码块内定义的自由变量的集合。 该字段与形成闭包的嵌套函数最相关。 自由变量是在一个块内使用但未在该块内定义的变量。co_cellvars
:内部嵌套函数所引用的局部变量名集合。
2.3 co_code的细节
下面分析dis(fizzbuzz)反汇编的结果。
输出的第一列显示该指令的行号。 多个指令可以映射到同一行号。 使用来自代码对象的 co_lnotab
字段的信息来计算该值。 第二列是给定指令与字节码开头的偏移量。 假设字节码字符串包含在数组中,则此值是可以在该数组中找到给定指令的索引。 第三列是实际的人类可读指令操作码; 可以在 Include/opcode.h
文件中找到所有操作码。 第四列是指令的参数。
第一条 LOAD_FAST
指令使用参数 0
。此值是 co_varnames
数组的索引,对应值为n。 最后一列是参数的值由 dis
函数提供,以方便阅读。 一些参数不采用显式参数。
第二条LOAD_CONST指令使用参数1。此值是co_consts数组的索引,对应值为3。
三、总结
本文pyc文件到PyCodeObject数据结构,再到co_code字节码分析,简要介绍了python解释器编译后的数据结构。虽然代码对象包含可执行的字节代码,但缺少执行此类代码所需的上下文信息,下篇文章我将介绍PyFrameObject对象。
参考目录:
https://nanguage.gitbook.io/inside-python-vm-cn/5.-code-objects
《Python源码剖析:深度探索动态语言核心技术》
Python的运行机制--操作码(opcode)解析 - 开发者知识库
CPython-Internals/frame_cn.md at master · zpoint/CPython-Internals · GitHub
python 栈帧对象 frame 底层实现 源码分析 PyFrameObject_zp0int的博客-CSDN博客_python 帧对象