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


Posted in Python onApril 01, 2015

我最近在参与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

我们得到的树形结果是:

<module>(lineno:2)
 A(lineno:2)
 __init__(lineno:3)
 __repr__(lineno:5)

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

co_obj = compile(python_source_code, '<string>', 'exec')

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

初见字节码

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

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

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

2 0 LOAD_CONST 0 ('A')
 3 LOAD_CONST 3 (())
 6 LOAD_CONST 1 (<code object A at 0x42424242, file "<string>", 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 <= 1:
return 1
elif n == 2:
return 2
return n * factorial(n - 1)

如前所述,我们得到factorial方法的代码对象:
 

module_co = compile(python_source, '', 'exec')
meth_co = module_co.co_consts[0]

反汇编结果是这样的(<<<后是我的注释):
 

3  0 LOAD_FAST  0 (n)
  3 LOAD_CONST  1 (1)
  6 COMPARE_OP  1 (<=)
  9 POP_JUMP_IF_FALSE 16  <<< control flow
 
4  12 LOAD_CONST  1 (1)
  15 RETURN_VALUE    <<< control flow
 
5 >> 16 LOAD_FAST  0 (n)
  19 LOAD_CONST  2 (2)
  22 COMPARE_OP  2 (==)
  25 POP_JUMP_IF_FALSE 32  <<< control flow
 
6  28 LOAD_CONST  2 (2)
  31 RETURN_VALUE    <<< control flow
 
7 >> 32 LOAD_FAST  0 (n)
  35 LOAD_GLOBAL  0 (factorial)
  38 LOAD_FAST  0 (n)
  41 LOAD_CONST  1 (1)
  44 BINARY_SUBTRACT
  45 CALL_FUNCTION  1
  48 BINARY_MULTIPLY
  49 RETURN_VALUE    <<< control flow

在这个字节码中,我们有5条改变CFG结构的指令(添加约束条件,或者允许快速退出):

    POP_JUMP_IF_FALSE:跳转到绝对地址16和32;
    RETURN_VALUE:从栈顶弹出一个元素,并返回。

提取基本单元很简单,因为我们只关心那些改变控制流程的指令。在我们的例子中,我们没有遇到强制跳转指令,如JUMP_FORWARD或JUMP_ABSOLUTE。

提取这类结构的代码示例:

import opcode
RETURN_VALUE = 83
JUMP_FORWARD, JUMP_ABSOLUTE = 110, 113
FALSE_BRANCH_JUMPS = (111, 114) # JUMP_IF_FALSE_OR_POP, POP_JUMP_IF_FALSE
 
def find_blocks(meth_co):
 blocks = {}
 code = meth_co.co_code
 finger_start_block = 0
 i, length = 0, len(code)
 while i < length:
 op = ord(code[i])
 i += 1
 if op == RETURN_VALUE: # We force finishing the block after the return,
    # dead code might still exist after though...
 blocks[finger_start_block] = {
 'length': i - finger_start_block - 1,
 'exit': True
 }
 finger_start_block = i
 elif op >= opcode.HAVE_ARGUMENT:
 oparg = ord(code[i]) + (ord(code[i+1]) << 8)
 i += 2
 if op in opcode.hasjabs: # Absolute jump to oparg
 blocks[finger_start_block] = {
  'length': i - finger_start_block
 }
 if op == JUMP_ABSOLUTE: # Only uncond absolute jump
  blocks[finger_start_block]['conditions'] = {
  'uncond': oparg
  }
 else:
  false_index, true_index = (oparg, i) if op in FALSE_BRANCH_JUMPS else (i, oparg)
  blocks[finger_start_block]['conditions'] = {
  'true': true_index,
  'false': false_index
  }
 finger_start_block = i
 elif op in opcode.hasjrel:
 # Essentially do the same...
 pass
 
 return blocks

我们得到了下面的基本单元:
 

Block 0: {'length': 12, 'conditions': {'false': 16, 'true': 12}}
Block 12: {'length': 3, 'exit': True}
Block 16: {'length': 12, 'conditions': {'false': 32, 'true': 28}}
Block 28: {'length': 3, 'exit': True}
Block 32: {'length': 17, 'exit': True}

以及单元的当前结构:
 

Basic blocks
 start_block_index :=
 length := size of instructions
 condition := true | false | uncond -> target_index
 exit* := true

我们得到了控制流程图(除了入口和隐式的退出单元),之后我们可以把它转化成可视化的图形:
 

def to_dot(blocks):
cache = {}
 
def get_node_id(idx, buf):
if idx not in cache:
cache[idx] = 'node_%d' % idx
buf.append('%s [label="Block Index %d"];' % (cache[idx], idx))
return cache[idx]
 
buffer = ['digraph CFG {']
buffer.append('entry [label="CFG Entry"]; ')
buffer.append('exit [label="CFG Implicit Return"]; ')
 
for block_idx in blocks:
node_id = get_node_id(block_idx, buffer)
if block_idx == 0:
buffer.append('entry -> %s;' % node_id)
if 'conditions' in blocks[block_idx]:
for cond_kind in blocks[block_idx]['conditions']:
target_id = get_node_id(blocks[block_idx]['conditions'][cond_kind], buffer)
buffer.append('%s -> %s [label="%s"];' % (node_id, target_id, cond_kind))
if 'exit' in blocks[block_idx]:
buffer.append('%s -> exit;' % node_id)
 
buffer.append('}')
return 'n'.join(buffer)

可视化的流程控制图:

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

为什么有这篇文章?

需要访问Python字节码的情况确实很少见,但是我已经遇到过几次这种情形了。我希望,这篇文章能够帮助那些开始研究Python逆向工程的人们。

然而现在,我正在研究Python代码,尤其是它的字节码。由于目前在Python中尚不存在这样的工具(并且检测源代码通常会留下非常低效的装饰器检测代码),这就是为什么equip会出现的原因。

Python 相关文章推荐
Python转换HTML到Text纯文本的方法
Jan 15 Python
Python使用xlrd读取Excel格式文件的方法
Mar 10 Python
python使用pil生成缩略图的方法
Mar 26 Python
CentOS安装pillow报错的解决方法
Jan 27 Python
python之Socket网络编程详解
Sep 29 Python
详解Python 切片语法
Jun 10 Python
python爬虫的一个常见简单js反爬详解
Jul 09 Python
django2笔记之路由path语法的实现
Jul 17 Python
python实现XML解析的方法解析
Nov 16 Python
Python算法中的时间复杂度问题
Nov 19 Python
python的sys.path模块路径添加方式
Mar 09 Python
VScode连接远程服务器上的jupyter notebook的实现
Apr 23 Python
Python中的defaultdict模块和namedtuple模块的简单入门指南
Apr 01 #Python
Python进行数据科学工作的简单入门教程
Apr 01 #Python
10个易被忽视但应掌握的Python基本用法
Apr 01 #Python
用Python制作检测Linux运行信息的工具的教程
Apr 01 #Python
用Python的pandas框架操作Excel文件中的数据教程
Mar 31 #Python
Python实现国外赌场热门游戏Craps(双骰子)
Mar 31 #Python
通过代码实例展示Python中列表生成式的用法
Mar 31 #Python
You might like
php 目录遍历、删除 函数的使用介绍
2013/04/28 PHP
PHP实现从PostgreSQL数据库检索数据分页显示及根据条件查找数据示例
2018/06/09 PHP
laravel框架select2多选插件初始化默认选中项操作示例
2020/02/18 PHP
javascript:以前写的xmlhttp池,代码
2008/05/18 Javascript
JavaScript 事件的一些重要说明
2009/10/25 Javascript
jquery插件tytabs.jquery.min.js实现渐变TAB选项卡效果
2015/08/25 Javascript
jQuery Validate初步体验(一)
2015/12/12 Javascript
JS+Canvas绘制时钟效果
2020/08/20 Javascript
JavaScript通过HTML的class来获取HTML元素的方法总结
2016/05/24 Javascript
基于jQuery实现弹出可关闭遮罩提示框实例代码
2016/07/18 Javascript
详解Node.js中的事件机制
2016/09/22 Javascript
JavaScript 身份证号有效验证详解及实例代码
2016/10/20 Javascript
jquery二级目录选中当前页的css样式
2016/12/08 Javascript
从零开始学习Node.js系列教程四:多页面实现数学运算的client端和server端示例
2017/04/13 Javascript
Vue 页面跳转不用router-link的实现代码
2018/04/12 Javascript
详解React 的几种条件渲染以及选择
2018/10/23 Javascript
详解Vue中watch对象内属性的方法
2019/02/01 Javascript
JavaScript实现轮播图效果
2020/10/30 Javascript
用vue写一个日历
2020/11/02 Javascript
nuxt 页面路由配置,主页轮播组件开发操作
2020/11/05 Javascript
python使用正则搜索字符串或文件中的浮点数代码实例
2014/07/11 Python
Python中的rfind()方法使用详解
2015/05/19 Python
Python实现读取及写入csv文件的方法示例
2018/01/12 Python
Python抽象和自定义类定义与用法示例
2018/08/23 Python
Python寻找路径和查找文件路径的示例
2019/07/10 Python
Python递归函数 二分查找算法实现解析
2019/08/12 Python
CSS3 @font-face属性使用指南
2014/12/12 HTML / CSS
HTML5 canvas基本绘图之图形组合
2016/06/27 HTML / CSS
常务副总经理岗位职责
2014/04/12 职场文书
文明美德伴我成长演讲稿
2014/05/12 职场文书
园林系毕业生求职信
2014/06/23 职场文书
人力资源管理专业自荐书
2014/07/07 职场文书
招商引资工作汇报材料
2014/10/28 职场文书
民事辩护词范文
2015/05/21 职场文书
新生儿未入户证明
2015/06/23 职场文书
Golang 对es的操作实例
2022/04/20 Golang