0%

[python源码分析] 9.执行过程和字节码

.py文件是如何转换为机器指令被CPU执行呢?.pyc文件作用是什么?

执行原理#

C/C++之类的编译性语言编写的程序,是需要从源文件转换成计算机使用的机器语言,经过 链接器链接之后形成了二进制的可执行文件。运行该程序的时候,就可以把 二进制程序从硬盘载入到内存中并运行。

但是对于Python而言,python源码 不需要编译成二进制代码,它可以直接从源代码运行程序

从程序执行时的基本表示是 实际计算机上的机器语言 还是 虚拟机的机器语言维度,可以将程序设计语言划分为两大类:编译型语言和解释型语言

  • 编译实现的语言,如:C、C++、Fortran、Pascal、Ada。由编译型语言编写的源程序需要经过 编译,汇编和链接才能输出目标代码,然后 由机器执行目标代码。目标代码是由 机器指令组成,不能独立运行,因为源程序中可能使用了一些汇编程序不能解释引用的库函数,而库函数又不在源程序中,此时还需要链接程序完成外部引用和目标模板调用的链接任务,最后才能输出可执行代码。

  • 解释型语言,解释器 不产生目标机器代码,而是 产生中间代码,这种中间代码与机器代码不同,中间代码的解释是 由软件支持的不能直接使用在硬件上。该软件解释器通常会 导致执行效率较低,用解释型语言编写的程序是由另一个可以理解中间代码的解释程序执行的。和编译的程序不同的是, 解释程序的任务是 逐一将源代码的语句解释成可执行的机器指令,不需要将源程序翻译成目标代码再执行。对于解释型语言,需要一个专门的解释器 来执行该程序,每条语句只有在执行是才能被翻译,这种解释型语言每执行一次就翻译一次,因而效率低下

  • Java解释器,java很特殊,java是需要编译的,但是没有直接编译成机器语言,而是编译成字节码,然后在Java虚拟机上用解释的方式执行字节码。Python也使用了类似的方式,先将python编译成 python字节码,然后由一个专门的python字节码解释器负责解释执行字节码。

  • python是一门解释语言,但是出于效率的考虑,提供了一种编译的方法。编译之后就得到pyc文件,存储了字节码。python这点和java很类似,但是java与python不同的是,python是一个解释型的语言,所以编译字节码不是一个强制的操作,事实上,编译是一个自动的过程,一般不会在意它的存在。编译成字节码可以节省加载模块的时间,提高效率。除了效率之外,字节码的形式也增加了反向工程的难度,可以保护源代码。这个只是一定程度上的保护,反编译还是可以的。

执行过程#

Python 更像 Shell 脚本这样的解释性语言,实际上执行原理本质同Java一样,都可以归纳为 虚拟机字节码

虽然 python 命令也叫做 Python 解释器 ( Interpreter ),但跟其他脚本语言解释器有本质区别。实际上, Python 解释器包含 编译器 以及 虚拟机 两部分。当 Python 解释器启动后,主要执行以下两个步骤:

  1. 编译器 将 .py 文件中的 Python 源码 编译成 字节码
  2. 虚拟机 逐行执行编译器生成的 字节码

因此, .py 文件中的 Python 语句 并没有直接转换成机器指令,而是转换成 Python 字节码 。

字节码#

Python中有一个内置函数 compile(),可以将源文件 编译成codeobject,首先看这个函数的说明:

1
compile(source, filename, mode[, flags[, dont_inherit]]) -> code object
- source ,源文件的内容字符串 - filename ,源文件名称 - mode 编译模式 :exec-编译module,single-编译一个声明,eval-编译一个表达式

PyCodeObject#

定义 test.py文件

1
2
3
4
5
6
7
8
9
10
11
12
PI = 3.14

def circle_area(r):
return PI * r ** 2

class Dog(object):

def __init__(self, name):
self.name = name

def yelp(self):
print('woof, i am', self.name)
调用 compile 函数编译源码
1
2
3
4
5
>>> result = compile(open('C:\\Users\\liuwen03\\Desktop\\test.py').read(), 'test.py', 'exec')
>>> result
<code object <module> at 00000000036E27B0, file "test.py", line 1>
>>> result.__class__
<type 'code'>
看上去我们得到了一个 code对象 。 在 Include/code.h 中,可以找到代表代码对象的 C 结构体 PyCodeObject 。 PyCodeObject 定义如下:
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
/* Bytecode object */
struct PyCodeObject {
PyObject_HEAD
int co_argcount; /* #arguments, except *args 参数数量*/
int co_posonlyargcount; /* #positional only arguments */
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_linetable; /* 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;

/* Per opcodes just-in-time cache
*
* To reduce cache size, we use indirect mapping from opcode index to
* cache object:
* cache = co_opcache[co_opcache_map[next_instr - first_instr] - 1]
*/

// co_opcache_map is indexed by (next_instr - first_instr).
// * 0 means there is no cache for this opcode.
// * n > 0 means there is cache in co_opcache[n-1].
unsigned char *co_opcache_map;
_PyOpcache *co_opcache;
int co_opcache_flag; // used to determine when create a cache.
unsigned char co_opcache_size; // length of co_opcache.
};
从源码可以看出, 代码对象 PyCodeObject 用于存储编译结果,包括 字节码 以及代码涉及的 常量 名字 等等。

1
2
>>> result.co_code
'd\x00\x00Z\x00\x00d\x01\x00\x84\x00\x00Z\x01\x00d\x02\x00e\x02\x00f\x01\x00d\x03\x00\x84\x00\x00\x83\x00\x00YZ\x03\x00d\x04\x00S'

字节码现在看上去如同天书一般。看看代码对象涉及的所有名字 和 常量列表:

1
2
3
4
5
>>> result.co_names
('PI', 'circle_area', 'object', 'Dog')

>>> result.co_consts
(3.14, <code object circle_area at 00000000036DBEB0, file "test.py", line 3>, 'Dog', <code object Dog at 00000000036E2A30, file "test.py", line 6>, None)

常量列表里 还有两个代码对象!其中一个是 circle_area 函数体,另一个是 Dog 类定义体。回忆一下 Python 作用域 的划分方式: 每个作用域对应着一个代码对象 !若假设成立, Dog 代码对象的常量列表应该还藏着两个代码对象,分别代表 init 方法和 yelp 方法的函数体:

事实确实如此:

1
2
3
4
>>> dog_code = result.co_consts[3]
>>> dog_code.co_consts
(<code object __init__ at 00000000036D48B0, file "test.py", line 8>, <code object yelp at 00000000036D49B0, file "test.py", line 11>)
>>>
因此,我们得到以下结论: Python 源码编译后,每个作用域都对应着一个代码对象子作用域代码对象位于父作用域代码对象常量列表里,层级一一对应。

反编译#

从上面我们可以看到,字节码是一堆长得跟天书一样的不可读的字节序列,跟二进制机器码一样。 用dis反编译字节码,让它变得可读

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> import dis
>>> dis.dis(result.co_code)
0 LOAD_CONST 0 (0)
3 STORE_NAME 0 (0)
6 LOAD_CONST 1 (1)
9 MAKE_FUNCTION 0
12 STORE_NAME 1 (1)
15 LOAD_CONST 2 (2)
18 LOAD_NAME 2 (2)
21 BUILD_TUPLE 1
24 LOAD_CONST 3 (3)
27 MAKE_FUNCTION 0
30 CALL_FUNCTION 0
33 BUILD_CLASS
34 STORE_NAME 3 (3)
37 LOAD_CONST 4 (4)
40 RETURN_VALUE
第一列是字节码偏移量 ,第二列是 指令 ,第三列是 操作数

以第一条字节码为例, LOAD_CONST 指令将常量加载进栈,常量下标由操作数给出。而下标为 0 的常量是:

1
2
>>> result.co_consts[0]
3.14
因此,第一条字节码就是将常量 3.14 加载到栈。

由于代码对象 保存了 常量、名字等上下文信息,因此直接对代码对象进行反编译可以得到更为清晰的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> dis.dis(result)
1 0 LOAD_CONST 0 (3.14)
3 STORE_NAME 0 (PI)

3 6 LOAD_CONST 1 (<code object circle_area at 00000000036DBEB0, file "test.py", line 3>)
9 MAKE_FUNCTION 0
12 STORE_NAME 1 (circle_area)

6 15 LOAD_CONST 2 ('Dog')
18 LOAD_NAME 2 (object)
21 BUILD_TUPLE 1
24 LOAD_CONST 3 (<code object Dog at 00000000036E2A30, file "test.py", line 6>)
27 MAKE_FUNCTION 0
30 CALL_FUNCTION 0
33 BUILD_CLASS
34 STORE_NAME 3 (Dog)
37 LOAD_CONST 4 (None)
注意到,操作数 指定的 常量或名字的 实际值在旁边的括号内列出。另外,字节码以语句为单位进行分组,中间以空行隔开语句行号在字节码前面给出。 PI = 3.14 这个语句编译成以下两条字节码:
1
2
1           0 LOAD_CONST               0 (3.14)
3 STORE_NAME 0 (PI)

pyc#

如果将 test 作为模块导入, Python 将在 test.py 文件所在目录下生成 .pyc 文件:

import test pyc 文件保存 经过序列化处理的代码对象 PyCodeObject 。这样一来, Python 后续导入 test 模块时,直接读取 pyc 文件并反序列化即可得到代码对象 ,避免了重复编译导致的开销。只有 test.py 有新修改(时间戳比 pyc 文件新), Python 才会重新编译。

reference#

  1. python编译过程和执行原理
  2. Python 源码深度剖析/18 Python 程执行过程与字节码