当前位置:   article > 正文

初探Python字节码和dis模块_dis.dis 部分

dis.dis 部分

本文主要介绍 Python 字节码、Python 虚拟机内幕以及 dis 模块的简单应用。阅读本文预计 10 min.

1. 前言

了解 Python 字节码和 Python 虚拟机运行的知识,学会使用 dis 模块分析 Python 字节码,对于我们软件调试、漏洞分析等都非常有用。

它也可以帮助我们分析为什么这么写会更加高效,让我们更了解我们程序的运行过程,从而写出高效简洁的代码。

此外,这也可以帮助我们更好的回答一些 Python 问题,还可以在这个过程中理解面向栈的编程模型是如何工作的,开拓编程视野。

本文主要内容:

  • Python 字节码
  • Python 虚拟机内幕简述
  • dis 模块简单应用
  • 学习资源整理

2. Python 字节码

在了解什么是 Python 字节码之前,我们先学习两个概念:汇编(Assembly)和反汇编(Disassembly)。

2.1 汇编与反汇编

汇编(Assembly):计算机只能执行 010101…这样的二进制代码,即机器语言,汇编是指把汇编语言转换为机器语言。
反汇编(Disassembly):汇编的反义词,把机器码(二进制代码)转为汇编代码的过程,也可以说是把机器语言转换为汇编语言代码、低级转高级的意思(via 百度百科)。

关于汇编语言入门的知识,我强烈推荐阮一峰老师的汇编语言入门教程,这篇博文写的非常通俗易懂,很实用!
此外有需要还可以看看:

  • 《计算机是怎样跑起来的》
  • 《程序是怎样跑起来的》
  • B 站的《计算机科学速成课》

以上 3 个初步了解计算机和程序非常不错,如果要深入学习汇编语言,可以看看王爽老师的《汇编语言》。以上我都看过,觉得很不错,推荐给大家。如果大家需要前两本书的电子版可以关注我公众号,联系我。

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
  • 1
  • 2

__pycache__ 目录下,同时存在 hello.py 模块的两个字节码版本,一个是 Python 3.7 编译的,一个是 Python 3.8 编译的。

2.3 为什么需要 Python 字节码?

我们为什么设计出来 Python 字节码?Python 字节码有什么好处呢?

字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。字节码的实现方式是通过编译器和虚拟机。编译器将源码编译成字节码,特定平台上的虚拟机将字节码转译为可以直接运行的指令。字节码的典型应用为 Java bytecode。(Via wiki)

Python 字节码的好处:

  1. 提升可移植性。其实设计字节码是为了实现跨平台运行代码,也就是具备可移植性。有了 Python 虚拟机,我们就可以在不同的操作系统平台运行同一个源代码,因为字节码会被 Python 虚拟机根据不同的操作系统平台翻译成相应的机器语言,从而执行。也就是说,我们有了 Python 虚拟机这个翻译官,只需要安心写代码,至于把我们的代码转化为二进制代码,就交给翻译官虚拟机去做就可以了。

  2. 提升代码的加载速度。有些教程说提升运行速度,这个说法其实不算准确。Python 源代码(.py文件)和 Python 字节码的执行速度其实是一样的,它是快在省略了源代码的解析翻译过程,最后的交给 CPU 执行阶段所花的时间是一样的。

Python 通过检查源文件的修改日期,来确定源文件对应的 Python 字节码文件是否已过期,如果过期就会重新翻译解析,并更新相应的 Python 字节码文件。这是一个完全自动化的过程。

Python 在两种情况下不会检查 Python 字节码文件。首先,对于从命令行直接载入的模块(.py源文件),它从来都是重新编译并且不存储编译结果;其次,如果没有源模块,它不会检查缓存(指 Python 字节码文件)。为了支持无源文件(仅编译)发行版本, 编译模块必须是在源目录下,并且绝对不能有源模块。

Python tutorial 官网给专业人士的一些小建议:

  1. 可以在 Python 命令中使用 -O 或者 -OO 开关, 以减小编译后 Python 字节码文件的大小。 -O 开关去除断言语句,-OO 开关同时去除断言语句和 __doc__ 字符串。由于有些程序可能依赖于这些,你应当只在清楚自己在做什么时才使用这个选项。“优化过的”模块有一个 opt- 标签,并且通常小些。将来的发行版本或许会更改优化的效果。
  2. 引入.pyc 文件的目的是为了加快载入速度,并不会影响执行速度,.pyc 文件和 源文件执行的速度是一样的。
  3. compileall 模块可以为一个目录下的所有模块创建 .pyc 文件。
  4. 更多的细节可以参看,PEP 3147

3. Python 虚拟机内幕

CPython,即我们通常使用的 Python 版本,使用一个基于栈(Stack)的虚拟机。也就是说,它完全面向栈数据结构的,栈是后进先出的数据结构。

CPython 使用三种类型的栈:

  1. 调用栈(Call stack)。这是运行 Python 程序的主要结构。每个当前活动函数调用都有一个叫 帧(Frame) 的东西,栈底是程序的入口点。每次函数调用都会推送一个新的帧到调用栈,当函数调用返回后,这个帧就会被销毁。其实就是函数调用一层一层压入调用栈,随着函数返回,再一层层把相应的帧给释放。
  2. 在每个帧中,有一个 计算栈(Evaluation stack),也称为 数据栈(data stack),这个栈就是 Python 函数运行的地方,运行的 Python 代码大多数是由推入到这个栈中的东西组成的,操作它们,最后在返回结果后,销毁它们。
  3. 在每个帧中,还有一个 块栈(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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

dis 模块待会介绍,这里先看看 Python 字节码指令:

  1. 第一个 LOAD_NAME 指令去查找函数对象 my_function,然后将它推入到计算栈的顶部
  2. 第二个 LOAD_NAME 指令去查找变量 my_variable,然后将它推入到计算栈的顶部
  3. 接着 LOAD_CONST 指令去推入一个实整数值 2 到计算栈的顶
  4. CALL_FUNCTION 指令调用函数,并且这个函数是有 2 个位置参数,它表示 Python 需要从栈顶弹出两个位置参数;然后函数将在它上面进行调用,并且它也会被弹出。此外,函数关键字参数使用 CALL_FUNCTION_KW 指令,可变参数和可变关键字参数使用( *** 操作符)使用 CALL_FUNCTION_EX 指令,不过使用的操作原则类似都是类似的。一旦 Python 拥有了这些之后,它将在调用栈上分配一个新帧,把函数调用的局部变量放进去,然后运行那个帧内的 my_function 字节码。
  5. 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',)
>>>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

代码对象在函数中可以用属性 __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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

说明:

  1. LOAD_GLOBAL 0:告诉 Python 在全局对象引用名称元组 co_names 中取出索引为 0 的元素,即 print,然后将它推入到计算栈。
  2. LOAD_CONST 1:在常量元组 co_consts 中取出索引为 1 的元素,即 ‘Hello world!’ ,并将它推入栈。索引为 0 的元素是 None,这是因为 Python 中 return 语句默认情况下返回 None,当我们不写 return 语句时,默认就是返回这个索引为 0 的 None。
  3. CALL_FUNCTION 1:告诉 Python 去调用一个函数;它需要从栈中弹出一个位置参数,然后,新的栈顶将被函数调用。
  4. POP_TOP:删除栈顶部,这里说明 print 函数使用完毕,返回 hello 函数。
  5. LOAD_CONST 0:这里取出常量 None,因为 hello 没有写 return,默认返回 None。None 在索引为 0 的位置。
  6. 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')
>>>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
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
  • 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

这里我就不一个一个解释了,多尝试,验证自己想法。更多 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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

从上面可以清楚看到,创建一个新列表,采用 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
  • 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

同样可以发现,f-string 3 步解决;format()方法要 5 步,并且还调用了函数,即增加了时间,也增加了开销;% 需要 4 步,不过没有调用函数。综上,所以推荐使用 f-string 格式化方式速度快,开销小,还优雅!

发现学习 Python 字节码,用 dis 模块是非常有用的!

5. 更多的推荐

以上就是目前总结的内容。
随着学习和思考的深入,每个知识点都可以扩展出非常多的东西,要花大量的时间和精力,受限于目前规划,这部分目前先浅浅涉猎,以后需要时,再继续深入探究。

这里也搜集整理了一些有关 Python 字节码、Python 虚拟机、以及它们是如何工作的资源:

  1. 《Python 虚拟机内幕》,它是 Obi Ike-Nwosu 写的一本免费在线英文电子书,它深入 Python 解析器,解释了 Python 如何工作的细节。Python 虚拟机内幕在线电子书
  2. 一个用 Python 编写的 Python 解析器,它是由 Allison Kaptur 写的一个教程,它是用 Python 构建的 Python 字节码解析器,并且它实现了运行 Python 字节码的全部构件。一个用 Python 编写的 Python 解析器
  3. CPython 解析器在 Python/ceval.c 中实现了字节码解析器,源码地址在这里,Python 3.8 版本 字节码解析器源码
  4. 此外还有 Python 3.8 dis 模块官方文档 以及 Python 3.8 dis 模块源码
  5. 最后推荐一个社区,这里可以参考 dis 模块的更多实例用法,尽管文档有些些滞后了。dis — 反汇编 Python 字节码

6. 巨人的肩膀

  1. 阮一峰老师的:汇编语言入门教程
  2. Python Tutorial 模块一节提及字节码:“Compiled” Python files
  3. 英文版:Python 字节码介绍 An introduction to Python bytecode 或者 知乎翻译版本:Python 字节码介绍
  4. dis 模块官方文档:dis — Disassembler for Python bytecode

推荐阅读:

  1. 编程小白安装Python开发环境及PyCharm的基本用法
  2. 一文了解Python基础知识
  3. 一文了解Python数据结构
  4. 一文了解Python流程控制
  5. 一文了解Python函数
  6. 一文了解Python部分高级特性
  7. 一文了解Python的模块和包
  8. 一文了解 Python 中的命名空间和作用域
  9. 一文了解Python面向对象
  10. 一文了解Python错误、异常和文件读写

后记:
我从本硕药学零基础转行计算机,自学路上,走过很多弯路,也庆幸自己喜欢记笔记,把知识点进行总结,帮助自己成功实现转行。
2020下半年进入职场,深感自己的不足,所以2021年给自己定了个计划,每日学一技,日积月累,厚积薄发。
如果你想和我一起交流学习,欢迎大家关注我的微信公众号每日学一技,扫描下方二维码或者搜索每日学一技关注。
这个公众号主要是分享和记录自己每日的技术学习,不定期整理子类分享,主要涉及 C – > Python – > Java,计算机基础知识,机器学习,职场技能等,简单说就是一句话,成长的见证!
每日学一技

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/繁依Fanyi0/article/detail/199130
推荐阅读
相关标签
  

闽ICP备14008679号