本文主要介绍 Python 字节码、Python 虚拟机内幕以及 dis 模块的简单应用。阅读本文预计 10 min.
初探Python字节码和dis模块
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
在 __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_VALUE
dis 模块待会介绍,这里先看看 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