本文主要介绍 Python 字节码、Python 虚拟机内幕以及 dis 模块的简单应用。阅读本文预计 10 min.
初探Python字节码和dis模块
- 1. 前言
- 2. Python 字节码
- 2.1 汇编与反汇编
- 2.2 什么是 Python 字节码呢?
- 2.3 为什么需要 Python 字节码?
- 3. Python 虚拟机内幕
- 4. dis 模块
- 4.1 访问和理解 Python 字节码
- 4.2 dis() 方法
- 5. 更多的推荐
- 6. 巨人的肩膀
1. 前言
了解 Python 字节码和 Python 虚拟机运行的知识,学会使用 dis 模块分析 Python 字节码,对于我们软件调试、漏洞分析等都非常有用。
它也可以帮助我们分析为什么这么写会更加高效,让我们更了解我们程序的运行过程,从而写出高效简洁的代码。
此外,这也可以帮助我们更好的回答一些 Python 问题,还可以在这个过程中理解面向栈的编程模型是如何工作的,开拓编程视野。
本文主要内容:
- Python 字节码
- Python 虚拟机内幕简述
- dis 模块简单应用
- 学习资源整理
2. Python 字节码
在了解什么是 Python 字节码之前,我们先学习两个概念:汇编(Assembly)和反汇编(Disassembly)。
2.1 汇编与反汇编
汇编(Assembly):计算机只能执行 010101…这样的二进制代码,即机器语言,汇编是指把汇编语言转换为机器语言。反汇编(Disassembly):汇编的反义词,把机器码(二进制代码)转为汇编代码的过程,也可以说是把机器语言转换为汇编语言代码、低级转高级的意思(via 百度百科)。
2.2 什么是 Python 字节码呢?
字节码(Bytecode):通常指的是已经经过编译,但与特定机器代码无关,需要解释器转译后才能成为机器代码的中间代码。字节码通常不像源码一样可以让人阅读,而是编码后的数值常量、引用、指令(也称操作码,Operation Code)等构成的序列。(Via wiki)
拿 Python 说明,Python 解释器先翻译 Python 源代码( .py 文件)为 Python 字节码( .pyc 文件),然后再由 Python 虚拟机来执行 Python 字节码。Python 字节码是一种类似于汇编指令的中间语言,一条 Python 语句会对应若干条字节码指令,虚拟机一条条执行字节码指令,将其翻译成机器代码,并交个 CPU 执行,从而完成程序的执行。
在 Python 3 中,Python 会自动在 __pycache__ 目录里,缓存每个模块编译后的版本,名称为 module.version.pyc ,这就是 Python 字节码文件。其中 version 一般使用 Python 版本号。例如,在 CPython 版本 3.7 中,spam.py 模块的编译版本将被缓存为 __pycache__/spam.cpython-37.pyc。此命名约定允许来自不同发行版和不同版本的 Python 的已编译模块共存。简单说就是一个源文件,可以存在多个版本的 Python 字节码,如:
hello.cpython-38.pyc
hello.cpython-37.pyc在 __pycache__ 目录下,同时存在 hello.py 模块的两个字节码版本,一个是 Python 3.7 编译的,一个是 Python 3.8 编译的。
2.3 为什么需要 Python 字节码?
我们为什么设计出来 Python 字节码?Python 字节码有什么好处呢?
字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。字节码的实现方式是通过编译器和虚拟机。编译器将源码编译成字节码,特定平台上的虚拟机将字节码转译为可以直接运行的指令。字节码的典型应用为 Java bytecode。(Via wiki)
Python 字节码的好处:
- 提升可移植性。其实设计字节码是为了实现跨平台运行代码,也就是具备可移植性。有了 Python 虚拟机,我们就可以在不同的操作系统平台运行同一个源代码,因为字节码会被 Python 虚拟机根据不同的操作系统平台翻译成相应的机器语言,从而执行。也就是说,我们有了 Python 虚拟机这个翻译官,只需要安心写代码,至于把我们的代码转化为二进制代码,就交给翻译官虚拟机去做就可以了。
- 提升代码的加载速度。有些教程说提升运行速度,这个说法其实不算准确。Python 源代码(
.py文件)和 Python 字节码的执行速度其实是一样的,它是快在省略了源代码的解析翻译过程,最后的交给 CPU 执行阶段所花的时间是一样的。
Python 通过检查源文件的修改日期,来确定源文件对应的 Python 字节码文件是否已过期,如果过期就会重新翻译解析,并更新相应的 Python 字节码文件。这是一个完全自动化的过程。
Python 在两种情况下不会检查 Python 字节码文件。首先,对于从命令行直接载入的模块(.py源文件),它从来都是重新编译并且不存储编译结果;其次,如果没有源模块,它不会检查缓存(指 Python 字节码文件)。为了支持无源文件(仅编译)发行版本, 编译模块必须是在源目录下,并且绝对不能有源模块。
Python tutorial 官网给专业人士的一些小建议:
- 可以在 Python 命令中使用 -O 或者 -OO 开关, 以减小编译后 Python 字节码文件的大小。 -O 开关去除断言语句,-OO 开关同时去除断言语句和
__doc__字符串。由于有些程序可能依赖于这些,你应当只在清楚自己在做什么时才使用这个选项。“优化过的”模块有一个opt-标签,并且通常小些。将来的发行版本或许会更改优化的效果。 - 引入
.pyc文件的目的是为了加快载入速度,并不会影响执行速度,.pyc文件和 源文件执行的速度是一样的。 - compileall 模块可以为一个目录下的所有模块创建
.pyc文件。 - 更多的细节可以参看,PEP 3147。
3. Python 虚拟机内幕
CPython,即我们通常使用的 Python 版本,使用一个基于栈(Stack)的虚拟机。也就是说,它完全面向栈数据结构的,栈是后进先出的数据结构。
CPython 使用三种类型的栈:
-
调用栈(Call stack)。这是运行 Python 程序的主要结构。每个当前活动函数调用都有一个叫帧(Frame)的东西,栈底是程序的入口点。每次函数调用都会推送一个新的帧到调用栈,当函数调用返回后,这个帧就会被销毁。其实就是函数调用一层一层压入调用栈,随着函数返回,再一层层把相应的帧给释放。 - 在每个帧中,有一个
计算栈(Evaluation stack),也称为数据栈(data stack),这个栈就是 Python 函数运行的地方,运行的 Python 代码大多数是由推入到这个栈中的东西组成的,操作它们,最后在返回结果后,销毁它们。 - 在每个帧中,还有一个
块栈(block stack)。它被 Python 用于去跟踪某些类型的控制结构:循环、try/except 块、以及 with 块,这些全部推入到块栈中,当退出这些控制结构时,块栈会被销毁。这将帮助 Python 了解任意给定时刻哪个块是活动的,比如,一个 continue 或者 break 语句可能影响正确的块。
大多数 Python 字节码指令操作的是当前调用栈帧的计算栈,虽然,还有一些指令可以做其它的事情(比如跳转到指定指令,或者操作块栈)。
为了更好地理解,假设我们有一些调用函数的代码,比如这个:my_function(my_variable, 2)。用 dis 模块将 Python 代码将转换为一系列字节码指令:
import dis
dis.dis('my_function(my_variable, 2)')
结果输出:
1 0 LOAD_NAME 0 (my_function)
2 LOAD_NAME 1 (my_variable)
4 LOAD_CONST 0 (2)
6 CALL_FUNCTION 2
8 RETURN_VALUEdis 模块待会介绍,这里先看看 Python 字节码指令:
- 第一个 LOAD_NAME 指令去查找函数对象 my_function,然后将它推入到计算栈的顶部
- 第二个 LOAD_NAME 指令去查找变量 my_variable,然后将它推入到计算栈的顶部
- 接着 LOAD_CONST 指令去推入一个实整数值 2 到计算栈的顶
- CALL_FUNCTION 指令调用函数,并且这个函数是有 2 个位置参数,它表示 Python 需要从栈顶弹出两个位置参数;然后函数将在它上面进行调用,并且它也会被弹出。此外,函数关键字参数使用 CALL_FUNCTION_KW 指令,可变参数和可变关键字参数使用(
*或**操作符)使用 CALL_FUNCTION_EX 指令,不过使用的操作原则类似都是类似的。一旦 Python 拥有了这些之后,它将在调用栈上分配一个新帧,把函数调用的局部变量放进去,然后运行那个帧内的 my_function 字节码。 - RETURN_VALUE 返回值,运行完成后,这个帧将被调用栈销毁,而在最初的帧内,my_function 的返回值将被推入到计算栈的顶部。
4. dis 模块
Python 标准库中的 dis 模块可以对 Python 字节码反汇编,把 Python 字节码变为人类可读的版本。
4.1 访问和理解 Python 字节码
Python 会为每个函数构建一个编译后的代码对象,运行一个函数将会用到这些代码对象的属性。看 hello() 函数示例:
>>> def hello():
... print('Hello world!')
...
>>> hello.__code__
<code object hello at 0x000002C7C5005C90, file "<stdin>", line 1>
>>> hello.__code__.co_consts
(None, 'Hello world!')
>>> hello.__code__.co_varnames
()
>>> hello.__code__.co_names
('print',)
>>>代码对象在函数中可以用属性 __code__ 来访问,并且携带了一些重要的属性:
- co_consts 返回一个包含函数体中的任意文字(literals)的元组。应该指的任意常量的意思。
- co_varnames 返回一个包含函数体中使用的本地变量名字的元组。
- co_names 返回一个包含函数体中引用的所有非本地名称(变量名、函数名等)的元组,即所有全局对象的引用名称元组。
当需要把变量或者属性值放到栈中时,就可以直接通过索引这些元组来找到相应的值。
我们用 dis 模块来看看反汇编后的 hello() 指令:
import dis
def hello():
print('Hello world!')
dis.dis(hello)
输出结果:
4 0 LOAD_GLOBAL 0 (print)
2 LOAD_CONST 1 ('Hello world!')
4 CALL_FUNCTION 1
6 POP_TOP
8 LOAD_CONST 0 (None)
10 RETURN_VALUE说明:
- LOAD_GLOBAL 0:告诉 Python 在全局对象引用名称元组 co_names 中取出索引为 0 的元素,即 print,然后将它推入到计算栈。
- LOAD_CONST 1:在常量元组 co_consts 中取出索引为 1 的元素,即 ‘Hello world!’ ,并将它推入栈。索引为 0 的元素是 None,这是因为 Python 中 return 语句默认情况下返回 None,当我们不写 return 语句时,默认就是返回这个索引为 0 的 None。
- CALL_FUNCTION 1:告诉 Python 去调用一个函数;它需要从栈中弹出一个位置参数,然后,新的栈顶将被函数调用。
- POP_TOP:删除栈顶部,这里说明 print 函数使用完毕,返回 hello 函数。
- LOAD_CONST 0:这里取出常量 None,因为 hello 没有写 return,默认返回 None。None 在索引为 0 的位置。
- RETURN_VALUE:返回值,函数调用结束。
这里我贴一个复杂一点的,大家可以试着分析一下,加深理解:
>>> n = 1 # 全局变量
>>> def hello():
... m = 2 # 局部变量
... print(n)
... print(m)
... print('Hello world!')
...
>>> hello.__code__
<code object hello at 0x0000018454705C90, file "<stdin>", line 1>
>>> hello.__code__.co_consts
(None, 2, 'Hello world!')
>>> hello.__code__.co_varnames
('m',)
>>> hello.__code__.co_names
('print', 'n')
>>>import dis
n = 1
def hello():
m = 2
print(n)
print(m)
print('Hello World!')
dis.dis(hello)
结果输出:
6 0 LOAD_CONST 1 (2)
2 STORE_FAST 0 (m)
7 4 LOAD_GLOBAL 0 (print)
6 LOAD_GLOBAL 1 (n)
8 CALL_FUNCTION 1
10 POP_TOP
8 12 LOAD_GLOBAL 0 (print)
14 LOAD_FAST 0 (m)
16 CALL_FUNCTION 1
18 POP_TOP
9 20 LOAD_GLOBAL 0 (print)
22 LOAD_CONST 2 ('Hello World!')
24 CALL_FUNCTION 1
26 POP_TOP
28 LOAD_CONST 0 (None)
30 RETURN_VALUE这里我就不一个一个解释了,多尝试,验证自己想法。更多 Python 的操作码指令含义,我们直接访问官网查看就好了,大部分通过英文单词的缩写就看懂了,dis 模块官网在这里,尽量看英文,中文翻译差点意思,不是很准确。通过上面,我们基本知道如何访问和理解 Python 字节码了。
通过查看 Python 3.8 官方文档,可以发现 dis 模块这几次更新还是比较多的,比如:Python 3.6 开始每条指令使用 2 个字节,以前是因指令而异,所以多多翻阅官方文档进行使用。这里简略地介绍一下 dis 模块的用法,更多的用法还有待日后我慢慢发掘。
4.2 dis() 方法
函数 dis.dis() 可以将一个函数、生成器、异步生成器、协程、方法、类、模块、编译过的 Python 代码对象、或者源代码字符串,反汇编为一个人类可读的版本。Python 3.7 开始增加了递归反汇编以及设置递归深度参数,还允许对协程和异步生成器对象反汇编。
除了前面的例子的使用例子,这里我们来用反汇编分析,新建一个列表,到底是用 list_a = [] 快,还是用 list_a = list()快?
import dis
dis.dis('list_a = []')
print('-' * 50)
dis.dis('list_a = list()')
输出结果:
1 0 BUILD_LIST 0
2 STORE_NAME 0 (list_a)
4 LOAD_CONST 0 (None)
6 RETURN_VALUE
--------------------------------------------------
1 0 LOAD_NAME 0 (list)
2 CALL_FUNCTION 0
4 STORE_NAME 1 (list_a)
6 LOAD_CONST 0 (None)
8 RETURN_VALUE从上面可以清楚看到,创建一个新列表,采用 list_a = [] 只需要 4 步,而用 list_a = list() 需要 5 步,所以 list_a = [] 创建空列表更快,使用 list() 创建空列表,既会增加一步函数调用的过程,也会增加了内存开销,因为调用函数会占用更多的系统资源。所以创建空列表和空字典,都推荐使用 l = [] 或者 d = {},而不是用内置函数 list()或者 dict()。
下面我们也通过 dis 模块分析一下格式化字符串用 f-string 好,还是用 format() 方法好。
import dis
name = 'Jock'
dis.dis("f'{name}'")
print('-' * 50)
dis.dis("'{}'.format(name)")
print('-' * 50)
dis.dis("'%s' % name")
输出结果:
1 0 LOAD_NAME 0 (name)
2 FORMAT_VALUE 0
4 RETURN_VALUE
--------------------------------------------------
1 0 LOAD_CONST 0 ('{}')
2 LOAD_METHOD 0 (format)
4 LOAD_NAME 1 (name)
6 CALL_METHOD 1
8 RETURN_VALUE
--------------------------------------------------
1 0 LOAD_CONST 0 ('%s')
2 LOAD_NAME 0 (name)
4 BINARY_MODULO
6 RETURN_VALUE同样可以发现,f-string 3 步解决;format()方法要 5 步,并且还调用了函数,即增加了时间,也增加了开销;% 需要 4 步,不过没有调用函数。综上,所以推荐使用 f-string 格式化方式速度快,开销小,还优雅!
发现学习 Python 字节码,用 dis 模块是非常有用的!
5. 更多的推荐
以上就是目前总结的内容。
随着学习和思考的深入,每个知识点都可以扩展出非常多的东西,要花大量的时间和精力,受限于目前规划,这部分目前先浅浅涉猎,以后需要时,再继续深入探究。
















