10-Python中的PyCodeObject对象与pyc文件

楔子

当我们想要执行一个py文件的时候,只需要python xxx.py即可,但是你有没有想过这背后的流程是怎么样的呢?从这里开始我们就开始进入到Python虚拟机的环节了,之前都是在介绍Python中的一些内置对象,不过虚拟机的执行流程、以及背后的原理却更是值得我们关注的。

这里我们先来说一下Python执行py文件的流程:

  • 1. 首先将文件里面的内容读取出来, 所以从这个角度上讲, 文件名不一定非要是.py结尾, .txt也是可以的, 只要文件里面的内容符合Python代码规范即可
  • 2. 读取文件里面的内容之后会对其进行分词, 将源代码切分成一个一个的token
  • 3. 然后Python编译器会对token进行语法解析, 建立抽象语法树(AST, abstract syntax tree)
  • 4. 编译器再将得到AST编译成字节码
  • 5. 最终由Python虚拟机来执行字节码

首先我们从中看到了Python编译器、Python虚拟机,而且我们平常还会说Python解释器,那么三者之间有什么区别呢?

实际上Python解释器 = Python编译器 + Python虚拟机,Python编译器负责将Python源代码编译成字节码(包括文件读取、分词、建立AST、编译成字节码),Python虚拟机负责执行这些字节码。

那么Python编译器和Python虚拟机都在什么地方呢?如果打开Python的安装目录,会发现有一个python.exe,点击的时候会通过它来启动一个终端。

img

但问题是这个文件大小还不到100K,不可能容纳一个解释器加一个虚拟机,所以下面还有一个python38.dll,没错,编译器、虚拟机都藏身于python38.dll当中。

因此Python代码并不是直接就一步到胃、变成机器码,而是先被Python编译器编译成字节码,中间存在一个编译的过程。而之所以要存在编译,是为了能够让虚拟机更快速的执行,而且还可以尽早的检测出语法上的错误。

那么下面我们就来看看Python中的字节码长什么样子。

Python编译器的编译结果–PyCodeObject对象

我们知道Python代码的编译结果是字节码,里面必然隐藏了Python运行的秘密,因此不管是深入理解虚拟机还是调优Python的运行效率,字节码都是绕不过去的一个坎。

注意:我们这里会研究字节码,但是不会研究Python是怎么编译得到字节码。因为Python编译器的工作原理和其它语言基本类似,很多关于编译原理的书籍都有介绍,编译这个过程不是Python特有的。并且研究Python的编译过程,对于我们开发帮助不是很大。

所以我们只需要知道Python解释器的背后有一个编译器会通过”读取文件”、”对源代码分词”、”分词之后会语法解析建立AST”、”对AST编译”得到字节码即可,至于这一列步骤是怎么做的、是怎么将源代码变成的字节码不是我们需要关心的,我们的重点是研究字节码本身以及虚拟机。

PyCodeObject对象和pyc文件

首先做Python开发的肯定都见过这个pyc文件,它一般位于__pycache__目录中,那么这个pyc文件和字节码之间有什么关系呢?

首先我们知道,Python执行这个文件首先要进行的动作就是编译,编译会得到字节码。然而除了字节码之外,还应该包含一些其它的信息,这些信息也是Python运行的时候所必须的。

在编译过程中,像常量值、字符串这些源代码当中的静态信息都会被Python编译器收集起来,并且这些静态信息也都会体现在编译之后的结果里面。在Python运行期间,这些源文件提供的静态信息都会被存储在一个运行时的对象当中,当Python运行结束时,这个运行时对象中所包含的信息还会被存储在一种文件中。这个对象和文件就是我们接下来要探讨的重点:PyCodeObject对象和pyc文件。

Python中的字节码只是一个PyBytesObject对象、或者说一段字节序列,PyCodeObject对象中有一个成员co_code,它是一个指针,指向了这段字节序列。但是这个对象除了有co_code指向字节码之外,还有很多其它成员,负责保存代码涉及到的常量、变量(名字、符号)等等

所以我们知道了,pyc文件里面的内容是PyCodeObject对象。对于Python编译器来说,PyCodeObject对象才是其真正的编译结果,而pyc文件是这个对象在硬盘上表现形式。

在程序运行期间,编译结果存在于内存的PyCodeObject对象当中,而Python结束运行之后,编译结果又被保存到了pyc文件当中。当下一次运行的时候,Python会根据pyc文件中记录的编译结果直接建立内存中的PyCodeObject对象,而不需要再度重新编译了。

python源码中的PyCodeObject对象

我们说Python编译器会将Python源代码编译成字节码,虚拟机执行的也是字节码,所以要理解虚拟机的运行时(runtime)行为,就必须要先掌握字节码。而我们说字节码是被底层结构体PyCodeObject的成员co_code指向,那么我们就必须来看看这个结构体了,它的定义位于 *Include/code.h* 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
typedef struct {
PyObject_HEAD /* 头部信息, 我们看到真的一切皆对象, 字节码也是个对象 */
int co_argcount; /* 可以通过位置参数传递的参数个数 */
int co_posonlyargcount; /* 只能通过位置参数传递的参数个数, Python3.8新增 */
int co_kwonlyargcount; /* 只能通过关键字参数传递的参数个数 */
int co_nlocals; /* 代码块中局部变量的个数,也包括参数 */
int co_stacksize; /* 执行该段代码块需要的栈空间 */
int co_flags; /* 参数类型标识 */
int co_firstlineno; /* 代码块在对应文件的行号 */
PyObject *co_code; /* 指令集, 也就是字节码, 它是一个bytes对象 */
PyObject *co_consts; /* 常量池, 一个元组,保存代码块中的所有常量。 */
PyObject *co_names; /* 一个元组,保存代码块中引用的其它作用域的变量 */
PyObject *co_varnames; /* 一个元组,保存当前作用域中的变量 */
PyObject *co_freevars; /* 内层函数引用的外层函数的作用域中的变量 */
PyObject *co_cellvars; /* 外层函数中作用域中被内层函数引用的变量,本质上和co_freevars是一样的 */

Py_ssize_t *co_cell2arg; /* 无需关注 */
PyObject *co_filename; /* 代码块所在的文件名 */
PyObject *co_name; /* 代码块的名字,通常是函数名或者类名 */
PyObject *co_lnotab; /* 字节码指令与python源代码的行号之间的对应关系,以PyByteObject的形式存在 */

//剩下的无需关注了
void *co_zombieframe; /* for optimization only (see frameobject.c) */
PyObject *co_weakreflist; /* to support weakrefs to code objects */
void *co_extra;
unsigned char *co_opcache_map;
_PyOpcache *co_opcache;
int co_opcache_flag;
unsigned char co_opcache_size;
} PyCodeObject;

这里面的每一个成员,我们后面都会逐一演示进行说明。总之Python编译器在对Python源代码进行编译的时候,对于代码中的每一个block,都会创建一个PyCodeObject与之对应。但是多少代码才算得上是一个block呢?事实上,Python有一个简单而清晰的规则:当进入一个新的名字空间,或者说作用域时,我们就算是进入了一个新的block了。这里又引出了名字空间,别急,我们后面会一点一点说,总之先举个栗子:

1
2
3
4
5
6
class A:
a = 123


def foo():
a = []

我们仔细观察一下上面这个文件,它在编译完之后会有三个PyCodeObject对象,一个是对应整个py文件的,一个是对应class A的,一个是对应def foo的。因为这是三个不同的作用域,所以会有三个PyCodeObject对象。

在这里,我们开始提及Python中一个至关重要的概念–名字空间(name space)、也叫命名空间、名称空间,都是一个东西。名字空间是符号的上下文环境,符号的含义取决于名字空间。更具体的说,一个变量名对应的变量值什么,在Python中是不确定的,需要命名空间来决定。

对于某个符号、或者名字(我们在前面系列中说过Python的变量只是一个名字),比如说上面代码中的a,在某个名字空间中,它可能指向一个PyLongObject对象;而在另一个名字空间中,它可能指向一个PyListObject对象。但是在一个名字空间中,一个符号只能有一种含义。而且名字空间可以一层套一层的形成一条名字空间链,Python虚拟机在执行的时候,会有很大一部分时间消耗在从名字空间链中确定一个符号所对应的对象是什么。这也侧面说明了,Python为什么比较慢。

如果你现在名字空间还不是很了解,不要紧,随着剖析的深入,你一定会对名字空间和Python在名字空间链上的行为有着越来越深刻的理解。总之现在需要记住的是:一个code block对应一个名字空间(或者说作用域)、同时也对应一个PyCodeObject对象。在Python中,类、函数、module都对应着一个独自的名字空间,因此都会有一个PyCodeObject与之对应。

如何在Python中访问PyCodeObject对象

那么我们如何才能在Python中获取到PyCodeObject对象呢?PyCodeObject对象在Python中也是一个对象,它的类型对象是<class 'code'>。但是这个类,底层没有暴露给我们,所以code对于Python来说只是一个没有定义的变量罢了。

但是我们可以通过其它的方式进行获取,首先来看看如何通过函数来获取该函数对应的字节码。

1
2
3
4
5
def func():
pass


print(type(func.__code__)) # <class 'code'>

我们可以通过函数的__code__拿到底层对应的PyCodeObject对象,当然也可以获取里面的属性,我们来演示一下。

co_argcount:可以通过位置参数传递的参数个数

1
2
3
4
5
6
7
8
9
10
11
12
13
def foo(a, b, c=3):
pass
print(foo.__code__.co_argcount) # 3


def bar(a, b, *args):
pass
print(bar.__code__.co_argcount) # 2


def func(a, b, *args, c):
pass
print(func.__code__.co_argcount) # 2

foo中的参数a、b、c都可以通过位置参数传递,所以结果是3;对于bar,显然是两个,这里不包括\*args;而函数func,显然是两个,因为参数c只能通过关键字参数传递。

co_posonlyargcount:只能通过位置参数传递的参数个数,python3.8新增

1
2
3
4
5
6
7
8
9
10
def foo(a, b, c):
pass

print(foo.__code__.co_posonlyargcount) # 0


def bar(a, b, /, c):
pass

print(bar.__code__.co_posonlyargcount) # 2

注意:这里是只能通过位置参数传递的参数个数。

co_kwonlyargcount:只能通过关键字参数传递的参数个数

1
2
3
4
5
def foo(a, b=1, c=2, *, d, e):
pass


print(foo.__code__.co_kwonlyargcount) # 2

这里是d和e,它们必须通过关键字参数传递。

co_nlocals:代码块中局部变量的个数,也包括参数

1
2
3
4
5
6
7
def foo(a, b, *, c):
name = "xxx"
age = 16
gender = "f"
c = 33

print(foo.__code__.co_nlocals) # 6

局部变量:a、b、c、name、age、gender,所以我们看到在编译成字节码的时候函数内局部变量的个数就已经确定了,因为它是静态存储的。

co_stacksize:执行该段代码块需要的栈空间

1
2
3
4
5
6
7
def foo(a, b, *, c):
name = "xxx"
age = 16
gender = "f"
c = 33

print(foo.__code__.co_stacksize) # 1

这个不需要关注

co_firstlineno:代码块在对应文件的起始行

1
2
3
4
5
def foo(a, b, *, c):
pass

# 显然是文件的第一行
print(foo.__code__.co_firstlineno) # 1

如果函数出现了调用呢?

1
2
3
4
5
6
7
8
9
def foo():
return bar


def bar():
pass


print(foo().__code__.co_firstlineno) # 5

如果执行foo,那么会返回函数bar,调用的就是bar函数的字节码,那么得到就是def bar():所在的行数。因为每个函数都有自己独自的命名空间,以及PyCodeObject对象。

co_names:一个元组,保存代码块中不在当前作用域的变量

1
2
3
4
5
6
7
c = 1

def foo(a, b):
print(a, b, c)
d = (list, int, str)

print(foo.__code__.co_names) # ('print', 'c', 'list', 'int', 'str')

我们看到print、c、list、int、str都是全局或者内置变量,函数、类也可以看成是变量,它们都不在当前foo函数的作用域中。

co_varnames:一个元组,保存在当前作用域中的变量

1
2
3
4
5
6
7
8
c = 1

def foo(a, b):
print(a, b, c)
d = (list, int, str)


print(foo.__code__.co_varnames) # ('a', 'b', 'd')

a、b、d是位于当前foo函数的作用域当中的,所以编译阶段便确定了局部变量是什么。

co_consts:常量池,一个元组对象,保存代码块中的所有常量。

1
2
3
4
5
6
7
8
9
10
11
12
x = 123


def foo(a, b):
c = "abc"
print(x)
print(True, False, list, [1, 2, 3], {"a": 1})
return ">>>"


# list不属于常量
print(foo.__code__.co_consts) # (None, 'abc', True, False, 1, 2, 3, 'a', '>>>')

co_consts里面出现的都是常量,而[1, 2, 3]{"a": 1},则是将里面元素单独拿出来了。不过可能有人好奇里面的None是从哪里来的。首先a和b是不是函数的参数啊,所以co_consts里面还要有两个常量,但是我们还没传参呢,所以使用None来代替。

co_freevars:内层函数引用的外层函数的作用域中的变量

1
2
3
4
5
6
7
8
9
def f1():
a = 1
b = 2
def f2():
print(a)
return f2

# 这里调用的是f2的字节码
print(f1().__code__.co_freevars) # ('a',)

co_cellvars:外层函数中作用域中被内层函数引用的变量,本质上和co_freevars是一样的

1
2
3
4
5
6
7
8
9
def f1():    
a = 1
b = 2
def f2():
print(a)
return f2

# 但这里调用的是f1的字节码
print(f1.__code__.co_cellvars) # ('a',)

co_filename:代码块所在的文件名

1
2
3
4
5
def foo():
pass


print(foo.__code__.co_filename) # D:/satori/1.py

co_name:代码块的名字,通常是函数名或者类名

1
2
3
4
5
def foo():
pass


print(foo.__code__.co_name) # foo

co_code:字节码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def foo(a, b, /, c, *, d, e):
f = 123
g = list()
g.extend([tuple, getattr, print])


print(foo.__code__.co_code)
"""
b'd\x01}\x05t\x00\x83\x00}\x06|\x06\xa0\x01t\x02t\x03t\x04g\x03\xa1\x01\x01\x00d\x00S\x00'
"""
# 这便是字节码, 当然单单是这些字节码肯定不够的, 所以还需要其它的静态信息
# 其它的信息显然连同字节码一样, 都位于PyCodeObject中

# co_lnotab: 字节码指令与python源代码的行号之间的对应关系,以PyByteObject的形式存在
print(foo.__code__.co_lnotab) # b'\x00\x01\x04\x01\x06\x01'
"""
然而事实上,Python不会直接记录这些信息,而是会记录增量值。比如说:
字节码在co_code中的偏移量 .py文件中源代码的行号
0 1
6 2
50 7

那么co_lnotab就应该是: 0 1 6 1 44 5
0和1很好理解, 就是co_code和.py文件的起始位置
而6和1表示字节码的偏移量是6, .py文件的行号增加了1
而44和5表示字节码的偏移量是44, .py文件的行号增加了5
"""

pyc文件

每一个PyCodeObject对象中都包含了一个co_code指针,指向code block中所有代码经过编译后得到的byte code序列。前面我们说到,Python会将字节码序列和PyCodeObject对象一起存储在pyc文件中。但不幸的是,事实并不总是这样。有时,当我们运行一个简单的程序时并没有产生pyc文件,因此我们猜测:有些python程序只是临时完成一些琐碎的工作,这样的程序仅仅只会运行一次,然后就不会再使用了,因此也就没有保存至pyc文件的必要。

如果我们在代码中加上了一个import abc这样语句,再执行你就会发现Python为其生成了pyc文件,这就说明import会触发pyc的生成。实际上,在运行过程中,如果碰到import abc这样的语句,那么Python会在设定好的path中寻找abc.pyc或者abc.dll文件,如果没有这些文件,而是只发现了abc.py,那么Python会先将abc.py编译成PyCodeObject,然后创建pyc文件,并将PyCodeObject写到pyc文件里面去。接下来,再对abc.pyc进行import动作,对,并不是编译成PyCodeObject对象之后直接使用,而是先写到pyc里面去,然后将pyc文件的PyCodeObject对象重新在内存中复制出来。

关于python的import机制,我们后面章节会剖析,这里只是用来完成pyc文件的触发。当然得到pyc文件有很多方法,比如使用py_compile模块。

1
2
3
4
5
# a.py
class A:
a = 1
# b.py
import a

执行b.py的时候,会发现创建了a.cpython-38.pyc。另外关于pyc文件的创建位置,会在当前文件的同级目录下的__pycache__目录中创建,名字就叫做:py文件名.cpython-版本号.pyc

通过compile获取PyCodeObject对象

事实上我们已经介绍了一种方法去获取相应的PyCodeObject对象,但是还有没有其他的方法呢?答案是通过内置函数compile,但是在介绍compile之前,先介绍一下eval和exec。

eval:传入一个字符串,然后把字符串里面的内容拿出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
a = 1
# 所以eval("a")就等价于a
print(eval("a")) # 1

print(eval("1 + 1 + 1")) # 3

# 注意:eval是有返回值的,返回值就是字符串里面内容。
# 或者说eval是可以作为右值的,比如a = eval("xxx")
# 所以eval里面绝不可以出现诸如赋值之类的,比如 print(eval("a = 3")),那么这个语句等价于print(a = 3),这样显然会出现语法错误的
# 因此eval里面把字符串剥掉之后就是一个普通的值,不可以出现诸如if、def等语句


try:
eval("xxx")
except NameError as e:
print(e) # name 'xxx' is not defined

exec:传入一个字符串,把字符串里面的内容当成语句来执行,这个是没有返回值,或者说返回值是None。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
exec("a = 1")  # 等价于把a = 1这个字符串里面的内容当成语句来执行
print(a) # 1

statement = """a = 123
if a == 123:
print("a等于123")
else:
print("a不等于123")
"""
exec(statement) # a等于123
# 注意:'a等于123'并不是exec返回的,而是把上面那坨字符串当成普通代码执行的时候print出来的
# 这便是exec的作用。


# 那么它和eval的区别就显而易见的,eval是要求字符串里面的内容能够当成一个值来打印,返回值就是里面的值
# 而exec则是直接执行里面的内容
# 举个例子
print(eval("1 + 1")) # 2
print(exec("1 + 1")) # None

exec("a = 1 + 1")
print(a) # 2

try:
eval("a = 1 + 1")
except SyntaxError as e:
print(e) # invalid syntax (<string>, line 1)

compile:关键来了,它执行后返回的就是一个code对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
statement = "a, b = 1, 2"
# 参数一:代码
# 参数二:可以为这些代码起一个文件名
# 参数三:执行方式,可以选择三种方式。exec: 将源码当做一个模块来编译;single: 用于编译一个单独d的Python语句(交互式下);eval:用于编译一个eval表达式
# 这里显然是exec
co = compile(statement, "夏色祭", "exec")
print(co.co_firstlineno) # 1
print(co.co_filename) # 夏色祭
print(co.co_argcount) # 0

# 这里是一个元组,因为我们是a, b = 1, 2这种方式赋值的,所以加载的是一个元组
print(co.co_consts) # ((1, 2), None)

statement = "a = 1;b = 2"
co = compile(statement, "夏色祭", "exec")
print(co.co_consts) # (1, 2, None)
print(co.co_names) # ('a', 'b')

我们后面在分析PyCodeObject的时候,会经常使用compile的方式。

pyc文件的生成

创建pyc文件的具体过程

前面我们提到,Python通过import module进行加载时,如果没有找到相应的pyc或者dll文件,就会在py文件的基础上自动创建pyc文件。所以想要了解pyc文件是怎么创建的,只需要了解PyCodeObject是如何写入的即可。关于写入pyc文件,主要写入三个内容:

1. magic number

这是Python定义的一个整数值,不同版本的Python会定义不同的magic number,这个值是为了保证Python能够加载正确的pyc。比如Python3.7不会加载3.6版本的pyc,因为Python在加载这个pyc文件的时候会首先检测该pyc的magic number,如果和自身的magic number不一致,则拒绝加载。

2. pyc的创建时间

这个很好理解,因为编译完之后要是把源代码修改了怎么办呢?因此会判断源代码的最后修改时间和pyc文件的创建时间,如果pyc文件的创建时间比源代码修改时间要早,说明在生成pyc之后,源代码被修改了,那么会重新编译新的pyc,而反之则会直接加载pyc。

3.PyCodeObject对象

这个不用说了,肯定是要存储的。当然还有字节码,不过PyCodeObject里面的co_code指向了这个字节码,所以我们就直接说PyCodeObject对象了。

文件的写入

既然要写入,那么肯定要有文件句柄,我们来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//位置:Python/marshal.c

//FILE是一个文件句柄,可以把WFILE看成是FILE的包装
typedef struct {
FILE *fp; //文件句柄
//下面的字段在写入信息的时候会看到
int error;
int depth;
PyObject *str;
char *ptr;
char *end;
char *buf;
_Py_hashtable_t *hashtable;
int version;
} WFILE;

写入magic number和时间:

写入magic number和时间都是调用了PyMarshal_WriteLongToFile,我们来看看长什么样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void
PyMarshal_WriteLongToFile(long x, FILE *fp, int version)
{
//声明char型的数组,元素个数为4个
char buf[4];
//声明一个WFILE类型变量wf
WFILE wf;
//内存初始化
memset(&wf, 0, sizeof(wf));
//设置fp,文件句柄
wf.fp = fp;
//将buf数组的指针赋值给wf.ptr和wf.buf
wf.ptr = wf.buf = buf;
//相当于buf的最后一个元素的指针
wf.end = wf.ptr + sizeof(buf);
//写错误
wf.error = WFERR_OK;
//写入版本信息
wf.version = version;
//调用w_long将x也就是版本信息或者时间写到wf里面去
w_long(x, &wf);
//刷到磁盘上
w_flush(&wf);
}


//所以我们看到这一步只是初始化一个WFILE对象,真正写入则是调用w_long
static void
w_long(long x, WFILE *p)
{
w_byte((char)( x & 0xff), p);
w_byte((char)((x>> 8) & 0xff), p);
w_byte((char)((x>>16) & 0xff), p);
w_byte((char)((x>>24) & 0xff), p);
}
//w_long则是将要写入的x一个字节一个字节写到文件里面去。

写入PyCodeObject对象:

写入PyCodeObject对象则是调用了PyMarshal_WriteObjectToFile,我们也来看看长什么样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void
PyMarshal_WriteObjectToFile(PyObject *x, FILE *fp, int version)
{
char buf[BUFSIZ];
WFILE wf;
memset(&wf, 0, sizeof(wf));
wf.fp = fp;
wf.ptr = wf.buf = buf;
wf.end = wf.ptr + sizeof(buf);
wf.error = WFERR_OK;
wf.version = version;
if (w_init_refs(&wf, version))
return; /* caller mush check PyErr_Occurred() */
w_object(x, &wf);
w_clear_refs(&wf);
w_flush(&wf);
}
//可以看到,和PyMarshal_WriteLongToFile基本是类似的
//只不过PyMarshal_WriteLongToFile调用的是w_long,而PyMarshal_WriteObjectToFile调用的是w_object


static void
w_object(PyObject *v, WFILE *p)
{
char flag = '\0';

p->depth++;

if (p->depth > MAX_MARSHAL_STACK_DEPTH) {
p->error = WFERR_NESTEDTOODEEP;
}
else if (v == NULL) {
w_byte(TYPE_NULL, p);
}
else if (v == Py_None) {
w_byte(TYPE_NONE, p);
}
else if (v == PyExc_StopIteration) {
w_byte(TYPE_STOPITER, p);
}
else if (v == Py_Ellipsis) {
w_byte(TYPE_ELLIPSIS, p);
}
else if (v == Py_False) {
w_byte(TYPE_FALSE, p);
}
else if (v == Py_True) {
w_byte(TYPE_TRUE, p);
}
else if (!w_ref(v, &flag, p))
w_complex_object(v, flag, p);

p->depth--;
}

可以看到本质上还是调用了w_byte,但是在这里面我们并没有看到诸如:列表、元组之类的数据的存储过程,注意最后的w_complex_object,关键来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
//源代码很长, 具体逻辑就不贴了
//我们后面会单独截取一部分 进行分析

static void
w_complex_object(PyObject *v, char flag, WFILE *p)
{
Py_ssize_t i, n;

if (PyLong_CheckExact(v)) {
//......
}
else if (PyFloat_CheckExact(v)) {
if (p->version > 1) {
//......
}
else {
//......
}
}
else if (PyComplex_CheckExact(v)) {
if (p->version > 1) {
//......
}
else {
//......
}
}
else if (PyBytes_CheckExact(v)) {
//......
}
else if (PyUnicode_CheckExact(v)) {
if (p->version >= 4 && PyUnicode_IS_ASCII(v)) {
//......
}
else {
//......
}
}
else {
//......
}
}
else if (PyTuple_CheckExact(v)) {
//......
}
else if (PyList_CheckExact(v)) {
//......
}
else if (PyDict_CheckExact(v)) {
//......
}
else if (PyAnySet_CheckExact(v)) {
//......
}
else if (PyCode_Check(v)) {
PyCodeObject *co = (PyCodeObject *)v;
W_TYPE(TYPE_CODE, p);
w_long(co->co_argcount, p);
w_long(co->co_kwonlyargcount, p);
w_long(co->co_nlocals, p);
w_long(co->co_stacksize, p);
w_long(co->co_flags, p);
w_object(co->co_code, p);
w_object(co->co_consts, p);
w_object(co->co_names, p);
w_object(co->co_varnames, p);
w_object(co->co_freevars, p);
w_object(co->co_cellvars, p);
w_object(co->co_filename, p);
w_object(co->co_name, p);
w_long(co->co_firstlineno, p);
w_object(co->co_lnotab, p);
}
else if (PyObject_CheckBuffer(v)) {
//......
}
else {
W_TYPE(TYPE_UNKNOWN, p);
p->error = WFERR_UNMARSHALLABLE;
}
}

源代码很长,这里就不一一分析了,可以自行查看。不过虽然长,但是逻辑很简单,就是对不同的对象、执行不同的写动作。然而其最终目的都是通过w_byte写到pyc文件中。换句话说,Python在往pyc写入list对象时,只是将list中包含的数值或者字符串等对象写到了pyc文件中。同时这也意味着,Python在加载pyc文件时,必须基于这些数值或字符串重新构造出list对象。

对于PyCodeObject对象,很显然,w_object会遍历PyCodeObject中的所有域,将这些域依次写入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PyCodeObject *co = (PyCodeObject *)v;
W_TYPE(TYPE_CODE, p);
w_long(co->co_argcount, p);
w_long(co->co_kwonlyargcount, p);
w_long(co->co_nlocals, p);
w_long(co->co_stacksize, p);
w_long(co->co_flags, p);
w_object(co->co_code, p);
w_object(co->co_consts, p);
w_object(co->co_names, p);
w_object(co->co_varnames, p);
w_object(co->co_freevars, p);
w_object(co->co_cellvars, p);
w_object(co->co_filename, p);
w_object(co->co_name, p);
w_long(co->co_firstlineno, p);
w_object(co->co_lnotab, p);

但是当面对一个PyListObject对象时,会有什么变化呢?没错,会和PyCodeObject一样,w_object还是会遍历,然后将PyListObject对象中的每一个元素依次写入到pyc文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//可以看到PyTupleObject、PyListObject、PyDictObject都是采用了相同的姿势
//注意里面的W_TYPE
else if (PyTuple_CheckExact(v)) {
n = PyTuple_Size(v);
if (p->version >= 4 && n < 256) {
W_TYPE(TYPE_SMALL_TUPLE, p);
w_byte((unsigned char)n, p);
}
else {
W_TYPE(TYPE_TUPLE, p);
W_SIZE(n, p);
}
for (i = 0; i < n; i++) {
w_object(PyTuple_GET_ITEM(v, i), p);
}
}
else if (PyList_CheckExact(v)) {
W_TYPE(TYPE_LIST, p);
n = PyList_GET_SIZE(v);
W_SIZE(n, p);
for (i = 0; i < n; i++) {
w_object(PyList_GET_ITEM(v, i), p);
}
}
else if (PyDict_CheckExact(v)) {
Py_ssize_t pos;
PyObject *key, *value;
W_TYPE(TYPE_DICT, p);
/* This one is NULL object terminated! */
pos = 0;
while (PyDict_Next(v, &pos, &key, &value)) {
w_object(key, p);
w_object(value, p);
}
w_object((PyObject *)NULL, p);
}

我们看到无论对于哪一个对象,在写入之前,都会先调用W_TYPE写一个类似于类型的东西,是的,诸如TYPE_LIST、TYPE_TUPLE、TYPE_DICT这样的标识,对于pyc文件的加载起着至关重要的作用。

之前说过,Python仅仅将容器里面的数值和字符串写入到pyc文件。当PyCodeObject写入到pyc之后,所有的数据就变成了字节流,类型信息就丢失了。

1
2
3
4
5
6
def func():
lst = [1, 2, 3]


# 从当前来看,常量池中没有任何关于列表的信息
print(func.__code__.co_consts) # (None, 1, 2, 3)

然鹅如果没有类型信息,那么当python再次加载pyc文件的时候,就没办法知道字节流中隐藏的结构和蕴含的信息,所以Python必须往pyc文件写入一个标识,这些标识正是Python定义的类型信息。如果Python在pyc中发现了这样的标识,则预示着上一个对象结束,新的对象开始,并且也知道新对象是什么样的对象,从而也知道该执行什么样的加载动作,这些标识也是可以看到的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//marshal.c
#define TYPE_NULL '0'
#define TYPE_NONE 'N'
#define TYPE_FALSE 'F'
#define TYPE_TRUE 'T'
#define TYPE_STOPITER 'S'
#define TYPE_ELLIPSIS '.'
#define TYPE_INT 'i'
/* TYPE_INT64 is not generated anymore.
Supported for backward compatibility only. */
#define TYPE_INT64 'I'
#define TYPE_FLOAT 'f'
#define TYPE_BINARY_FLOAT 'g'
#define TYPE_COMPLEX 'x'
#define TYPE_BINARY_COMPLEX 'y'
#define TYPE_LONG 'l'
#define TYPE_STRING 's'
#define TYPE_INTERNED 't'
#define TYPE_REF 'r'
#define TYPE_TUPLE '('
#define TYPE_LIST '['
#define TYPE_DICT '{'
#define TYPE_CODE 'c'
#define TYPE_UNICODE 'u'
#define TYPE_UNKNOWN '?'
#define TYPE_SET '<'
#define TYPE_FROZENSET '>'

到了这里可以看到,其实Python对于PyCodeObject对象的导出实际上是不复杂的,因为不管什么对象,最后都为归结为两种简单的形式,一种是数值写入,一种是字符串写入。上面都是对数值的写入,比较简单,仅仅需要按照字节一次写入pyc即可。然而在写入字符串的时候,Python设计了一种比较复杂的机制,有兴趣可以自己阅读源码,这里不再介绍。

1
2
3
4
5
6
# a.py
class A:
pass

def foo():
pass

我们之前说对于这样的一个py文件,会创建三个PyCodeObject对象,但是写到pyc文件里面的只有一个PyCodeObject对象,这难道不就意味着有两个PyCodeObject丢失了吗?其实很明显,有两个PyCodeObject对象是位于另一个PyCodeObject对象当中的。因此foo和A对应的PyCodeObject对象位于a.py这个PyCodeObject对象当中,准确的说是位于co_consts指向的常量池当中。

1
2
3
4
5
6
7
8
def f1():
def f2():
pass
pass


print(f1.__code__.co_consts)
# (None, <code object f2 at 0x000001BC5DF3D450, file "D:/satori/1.py", line 2>, 'f1.<locals>.f2')

我们看到f2对应的PyCodeObject确实位于f1的常量池当中,但其实说白了不过f1的常量池当中有一个指针在指向f2对应PyCodeObject罢了。只不过在写入的时候,也会把指针的内容一块写进去,所以也可以理解就是写了3个PyCodeObject对象。不过这都不是重点,重点是PyCodeObject对象是可以嵌套的,当在一个作用域内部发现了一个新的作用域,那么新的作用域对应的PyCodeObject对象会位于外层作用域的PyCodeObject对象的常量池中,或者说被常量池中的一个指针指向。

而在写入pyc的时候会从最外层、也就是模块的PyCodeObject对象开始写入,如果碰到了包含的另一个PyCodeObject对象,那么就会递归地执行写入新的PyCodeObject对象的操作。如此下去,最终所有的PyCodeObject对象都会写入到pyc文件当中,因此pyc文件当中的PyCodeObject对象也是以一种嵌套的关系联系在一起的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def foo():
pass



def bar():
pass


class A:
def foo(self):
pass

def bar(self):
pass

这里问一下,上面那段代码中创建了几个PyCodeObject对象呢?

答案是6个,首先全局是一个,foo函数一个,bar函数一个,类A一个,类A里面的foo函数一个,类A里面的bar函数一个,所以一共是6个。

而且这里的PyCodeObject对象是层层嵌套的,一开始是对整个全局创建PyCodeObject对象,然后遇到了函数foo,那么再为函数foo创建一个PyCodeObject对象,依次往下。所以如果是常量值、字符串等等,则相当于是静态信息,直接存储起来便可;可如果是函数、类,那么会为其创建新的PyCodeObject对象,然后收集起来,所以A里面的foo函数对应的PyCodeObject对象是存在A对应PyCodeObject对象里面的;而A对应的PyCodeObject对象则是存在全局对应的PyCodeObject对象里面,当然此时还有外层的foo、bar函数。。

Python的字节码与反编译

关于Python的字节码,是后面章节剖析虚拟机的重点,现在先来看一下。我们知道Python执行源代码之前会对其进行编译得到PyCodeObject对象,里面的co_code指向了字节码序列,Python虚拟机会根据这些字节码序列来进行一系列的操作(当然也依赖其它的静态信息),从而完成对程序的执行。

当然每一个操作在python中都对应一个操作指令、或者操作数,总共一共定义了121个。其实说白了每个指令不过是一个整数罢了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define POP_TOP                   1
#define ROT_TWO 2
#define ROT_THREE 3
#define DUP_TOP 4
#define DUP_TOP_TWO 5
#define NOP 9
#define UNARY_POSITIVE 10
#define UNARY_NEGATIVE 11
#define UNARY_NOT 12
#define UNARY_INVERT 15
#define BINARY_MATRIX_MULTIPLY 16
#define INPLACE_MATRIX_MULTIPLY 17
#define BINARY_POWER 19
#define BINARY_MULTIPLY 20
#define BINARY_MODULO 22
#define BINARY_ADD 23
#define BINARY_SUBTRACT 24
#define BINARY_SUBSCR 25
#define BINARY_FLOOR_DIVIDE 26
#define BINARY_TRUE_DIVIDE 27
#define INPLACE_FLOOR_DIVIDE 28
...
...

然后我们可以通过反编译的方式查看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Python中的dis模块是专门干这件事情
import dis

def foo(a, b):
c = a + b
return c

# 里面可以接收一个字节码对象, 当然函数也是可以的, 会自动获取co_code
dis.dis(foo)
"""
5 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 BINARY_ADD
6 STORE_FAST 2 (c)

6 8 LOAD_FAST 2 (c)
10 RETURN_VALUE
"""

字节码反编译后的结果多么像汇编语言!其中,第一列是源代码行号,第二列是字节码偏移量,第三列是操作数。

关于反编译的内容,我们会在剖析函数的时候,深入介绍。