赞
踩
本文主要介绍 Python 字节码、Python 虚拟机内幕以及 dis 模块的简单应用。阅读本文预计 10 min.
了解 Python 字节码和 Python 虚拟机运行的知识,学会使用 dis 模块分析 Python 字节码,对于我们软件调试、漏洞分析等都非常有用。
它也可以帮助我们分析为什么这么写会更加高效,让我们更了解我们程序的运行过程,从而写出高效简洁的代码。
此外,这也可以帮助我们更好的回答一些 Python 问题,还可以在这个过程中理解面向栈的编程模型是如何工作的,开拓编程视野。
本文主要内容:
在了解什么是 Python 字节码之前,我们先学习两个概念:汇编(Assembly)和反汇编(Disassembly)。
汇编(Assembly)
:计算机只能执行 010101…这样的二进制代码,即机器语言,汇编是指把汇编语言转换为机器语言。
反汇编(Disassembly)
:汇编的反义词,把机器码(二进制代码)转为汇编代码的过程,也可以说是把机器语言转换为汇编语言代码、低级转高级的意思(via 百度百科)。
关于汇编语言入门的知识,我强烈推荐阮一峰老师的汇编语言入门教程,这篇博文写的非常通俗易懂,很实用!
此外有需要还可以看看:
以上 3 个初步了解计算机和程序非常不错,如果要深入学习汇编语言,可以看看王爽老师的《汇编语言》。以上我都看过,觉得很不错,推荐给大家。如果大家需要前两本书的电子版可以关注我公众号,联系我。
字节码(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 编译的。
我们为什么设计出来 Python 字节码?Python 字节码有什么好处呢?
字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。字节码的实现方式是通过编译器和虚拟机。编译器将源码编译成字节码,特定平台上的虚拟机将字节码转译为可以直接运行的指令。字节码的典型应用为 Java bytecode。(Via wiki)
Python 字节码的好处:
提升可移植性。其实设计字节码是为了实现跨平台运行代码,也就是具备可移植性。有了 Python 虚拟机,我们就可以在不同的操作系统平台运行同一个源代码,因为字节码会被 Python 虚拟机根据不同的操作系统平台翻译成相应的机器语言,从而执行。也就是说,我们有了 Python 虚拟机这个翻译官,只需要安心写代码,至于把我们的代码转化为二进制代码,就交给翻译官虚拟机去做就可以了。
提升代码的加载速度。有些教程说提升运行速度,这个说法其实不算准确。Python 源代码(.py
文件)和 Python 字节码的执行速度其实是一样的,它是快在省略了源代码的解析翻译过程,最后的交给 CPU 执行阶段所花的时间是一样的。
Python 通过检查源文件的修改日期,来确定源文件对应的 Python 字节码文件是否已过期,如果过期就会重新翻译解析,并更新相应的 Python 字节码文件。这是一个完全自动化的过程。
Python 在两种情况下不会检查 Python 字节码文件。首先,对于从命令行直接载入的模块(.py
源文件),它从来都是重新编译并且不存储编译结果;其次,如果没有源模块,它不会检查缓存(指 Python 字节码文件)。为了支持无源文件(仅编译)发行版本, 编译模块必须是在源目录下,并且绝对不能有源模块。
Python tutorial 官网给专业人士的一些小建议:
__doc__
字符串。由于有些程序可能依赖于这些,你应当只在清楚自己在做什么时才使用这个选项。“优化过的”模块有一个 opt-
标签,并且通常小些。将来的发行版本或许会更改优化的效果。.pyc
文件的目的是为了加快载入速度,并不会影响执行速度,.pyc
文件和 源文件执行的速度是一样的。.pyc
文件。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_VALUE
dis 模块待会介绍,这里先看看 Python 字节码指令:
*
或 **
操作符)使用 CALL_FUNCTION_EX 指令,不过使用的操作原则类似都是类似的。一旦 Python 拥有了这些之后,它将在调用栈上分配一个新帧,把函数调用的局部变量放进去,然后运行那个帧内的 my_function 字节码。Python 标准库中的 dis 模块可以对 Python 字节码反汇编,把 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__
来访问,并且携带了一些重要的属性:
当需要把变量或者属性值放到栈中时,就可以直接通过索引这些元组来找到相应的值。
我们用 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
说明:
这里我贴一个复杂一点的,大家可以试着分析一下,加深理解:
>>> 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 模块的用法,更多的用法还有待日后我慢慢发掘。
函数 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 模块是非常有用的!
以上就是目前总结的内容。
随着学习和思考的深入,每个知识点都可以扩展出非常多的东西,要花大量的时间和精力,受限于目前规划,这部分目前先浅浅涉猎,以后需要时,再继续深入探究。
这里也搜集整理了一些有关 Python 字节码、Python 虚拟机、以及它们是如何工作的资源:
推荐阅读:
后记:
我从本硕药学零基础转行计算机,自学路上,走过很多弯路,也庆幸自己喜欢记笔记,把知识点进行总结,帮助自己成功实现转行。
2020下半年进入职场,深感自己的不足,所以2021年给自己定了个计划,每日学一技,日积月累,厚积薄发。
如果你想和我一起交流学习,欢迎大家关注我的微信公众号每日学一技
,扫描下方二维码或者搜索每日学一技
关注。
这个公众号主要是分享和记录自己每日的技术学习,不定期整理子类分享,主要涉及 C – > Python – > Java,计算机基础知识,机器学习,职场技能等,简单说就是一句话,成长的见证!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。