深入python解释器理解python中的字节码

我最近在参与python字节码相关的工作,想与大家分享一些这方面的经验。更准确的说,我正在参与2.6到2.7版本的cpython解释器字节码的工作。

python是一门动态语言,在命令行工具下运行时,本质上执行了下面的步骤:

当第一次执行到一段代码时,这段代码会被编译(如,作为一个模块加载,或者直接执行)。根据操作系统的不同,这一步生成后缀名是pyc或者pyo的二进制文件。
解释器读取二进制文件,并依次执行指令(opcodes)。

python解释器是基于栈的。要理解数据流向,我们需要知道每条指令的栈效应(如,操作码和参数)。

探索python二进制文件

得到一个二进制文件字节码的最简单方式,是对codetype结构进行解码:

import marshal
fd = open(‘path/to/my.pyc’, ‘rb’)
magic = fd.read(4) # 魔术数,与python版本相关
date = fd.read(4) # 编译日期
code_object = marshal.load(fd)
fd.close()

code_object包含了一个codetype对象,它代表被加载文件的整个模块。为了查看这个模块的类定义、方法等的所有嵌套编码对象(编码对象,原文为code object),我们需要递归地检查codetype的常量池。就像下面的代码:

import types
def inspect_code_object(co_obj, indent=”):
print indent, “%s(lineno:%d)” % (co_obj.co_name, co_obj.co_firstlineno)
for c in co_obj.co_consts:
if isinstance(c, types.codetype):
inspect_code_object(c, indent + ‘ ‘)
inspect_code_object(code_object) # 从第一个对象开始

这个案例中,我们打印出一颗编码对象树,每个编码对象是其父对象的子节点。对下面的代码:

class a:
def __init__(self):
pass
def __repr__(self):
return ‘a()’
a = a()
print a

我们得到的树形结果是:

(lineno:2)
a(lineno:2)
__init__(lineno:3)
__repr__(lineno:5)

为了测试,我们可以通过compile指令,编译一个包含python源码的字符串,从而能够得到一个编码对象:

co_obj = compile(python_source_code, ”, ‘exec’)

要获取更多关于编码对象的信息,我们可以查阅python文档的co_* fields 部分。

初见字节码

一旦我们得到了编码对象,我们就可以开始对它进行拆解了(在co_code字段)。从字节码中解析出它的含义:

? 解释操作码的含义
? 提取任意参数

dis模块的disassemble函数展示了是如何做到的。对我们前面例子,它输出的结果是:

2 0 load_const 0 (‘a’)
3 load_const 3 (())
6 load_const 1 (“, line 2>)
9 make_function 0
12 call_function 0
15 build_class
16 store_name 0 (a)
8 19 load_name 0 (a)
22 call_function 0
25 store_name 1 (a)
9 28 load_name 1 (a)
31 print_item
32 print_newline
33 load_const 2 (none)
36 return_value

我们得到了:

行号(当它改变时)
指令的序号
当前指令的操作码
操作参数(oparg),操作码用它来计算实际的参数。例如,对于load_name操作码,操作参数指向tuple co_names的索引。
计算后的实际参数(圆括号内)

对于序号为6的指令,操作码load_const的操作参数,指向需要从tuple co_consts加载的对象。这里,它指向a的类型定义。同样的,我们能够继续并反编译所有的代码对象,得到模块的全部字节码。

字节码的第一部分(序号0到16),与a的类型定义有关;其他的部分是我们实例化a,并打印它的代码。

有趣的字节码构造

所有的操作码都是相当直接易懂的,但是由于下面的原因,在个别情况下会显得奇怪:

编译器优化
解释器优化(因此会导致加入额外的操作码)

顺序变量赋值

首先,我们看看顺序地对多个元素赋值,会发生什么:

(1) a, b = 1, ‘2’
(2) a, b = 1, e
(3) a, b, c = 1, 2, e
(4) a, b, c, d = 1, 2, 3, e

这4中语句,会产生差别相当大的字节码。

第一种情况最简单,因为赋值操作的右值(rhs)只包含常量。这种情况下,cpython会创建一个(1, ‘a’) 的t uple,使用unpack_sequence操作码,把两个元素压到栈上,并对变量a和b分别执行store_fast操作:

0 load_const 5 ((1, ‘2’))
3 unpack_sequence 2
6 store_fast 0 (a)
9 store_fast 1 (b)

而第二种情况,则在右值引入了一个变量,因此一般情况下,会调用一条取值指令(这里简单地调用了load_global指令)。但是,编译器不需要在栈上为这些值创建一个新的tuple,也不需要调用unpack_sequence(序号18);调用rot_two就足够了,它用来交换栈顶的两个元素(虽然交换指令19和22也可以达到目的)。

12 load_const 1 (1)
15 load_global 0 (e)
18 rot_two
19 store_fast 0 (a)
22 store_fast 1 (b)

第三种情况变得很奇怪。把表达式放到栈上与前一种情况的处理方式相同,但是在交换栈顶的3个元素后,它再次交换了栈顶的2个元素:

25 load_const 1 (1)
28 load_const 3 (2)
31 load_global 0 (e)
34 rot_three
35 rot_two
36 store_fast 0 (a)
39 store_fast 1 (b)
42 store_fast 2 (c)

最后一种情况是通用的处理方式,rot_*操作看起来行不通了,编译器创建了一个tuple,然后调用unpack_sequence把元素放到栈上:

45 load_const 1 (1)
48 load_const 3 (2)
51 load_const 4 (3)
54 load_global 0 (e)
57 build_tuple 4
60 unpack_sequence 4
63 store_fast 0 (a)
66 store_fast 1 (b)
69 store_fast 2 (c)
72 store_fast 3 (d)

函数调用构造

最后一组有趣的例子是关于函数调用构造,以及创建调用的4个操作码。我猜测这些操作码的数量是为了优化解释器代码,因为它不像java,有invokedynamic,invokeinterface,invokespecial,invokestatic或者invokevirtual之一。

java中,invokeinterface,invokespecial和invokevirtual都是从静态类型语言中借鉴来的(invokespecial只被用来调用构造函数和父类afaik)。invokestatic是自我描述的(不需要把接收方放在栈上),在python中没有类似的概念(在解释器层面上,而不是装饰者)。简短的说,python调用都能被转换成invokedynamic。

在python中,不同的call_*操作码确实不存在,原因是类型系统,静态方法,或者特殊访问构造器的需求。它们都指向了python中一个函数调用是如何确定的。从语法来看:

调用结构允许代码这些写:

func(arg1, arg2, keyword=some_value, *unpack_list, **unpack_dict)

关键字参数允许通过形式参数的名称来传递参数,而不仅仅是通过位置。*符号从一个可迭代的容器中取出所有元素,作为参数传入(逐个元素,不是以tuple的形式),而**符号处理一个包含关键字和值的字典。

这个例子用到了调用构造的几乎所有特性:
? 传递变量参数列表(_var):call_function_var, call_function_var_kw
? 传递基于字典的关键字(_kw):call_function_kw, call_function_var_kw

字节码是这样的:

0 load_name 0 (func)
3 load_name 1 (arg1)
6 load_name 2 (arg2)
9 load_const 0 (‘keyword’)
12 load_name 3 (some_value)
15 load_name 4 (unpack_list)
18 load_name 5 (unpack_dict)
21 call_function_var_kw 258

通常,call_function调用将oparg解析为参数个数。但是,更多的信息被编码。第一个字节(0xff掩码)存储参数的个数,第二个字节((value >> 8) & 0xff)存储传递的关键字参数个数。为了要计算需要从栈顶弹出的元素个数,我们需要这么做:

na = arg & 0xff # num args
nk = (arg >> 8) & 0xff # num keywords
n_to_pop = na + 2 * nk + call_extra_arg_offset[op]

call_extra_arg_offset包含了一个偏移量,由调用操作码确定(对call_function_var_kw来说,是2)。这里,在访问函数名称前,我们需要弹出6个元素。

对于其他的call_*调用,完全依赖于代码是否使用列表或者字典传递参数。只需要简单的组合即可。

构造一个极小的cfg

为了理解代码是如何运行的,我们可以构造一个控制流程图(control-flow graph,cfg),这个过程非常有趣。我们通过它,查看在什么条件下,哪些无条件判断的操作码(基本单元)序列会被执行。

即使字节码是一门真正的小型语言,构造一个运行稳定的cfg需要大量的细节工作,远超出本博客的范围。因此如果需要一个真实的cfg实现,你可以看看这里equip。

在这里,我们只关注没有循环和异常的代码,因此控制流程只依赖与if语句。

只有少数几个操作码能够执行地址跳转(对没有循环和异常的情况);它们是:

jump_forward:在字节码中跳转到一个相对位置。参数是跳过的字节数。
jump_if_false_or_pop,jump_if_true_or_pop,jump_absolute,pop_jump_if_false,以及pop_jump_if_true:参数都是字节码中的绝对地址。

为一个函数够造cfg,意味着要创建基本的单元(不包含条件判断的操作码序列——除非有异常发生),并且把它们与条件和分支连在一起,构成一个图。在我们的例子中,我们只有true、false和无条件分支。

让我们来考虑下面的代码示例(在实际中绝对不要这样用):

def factorial(n):
if n

Posted in 未分类

发表评论