前言
如果你曾经写过或者用过 python,你可能已经习惯了看到 python 源代码文件;它们的名称以.py 结尾。你可能还见过另一种类型的文件是 .pyc 结尾的,它们就是 python “字节码”文件。(在 python3 的时候这个 .pyc 后缀的文件不太好找了,它在一个名为__pycache__的子目录下面。).pyc文件可以防止python每次运行时都重新解析源代码,该文件大大节省了时间。
python是如何工作的
python 通常被描述为一种解释语言,在这种语言中,你的源代码在程序运行时被翻译成cpu指令,但这只是说对了部分。和许多解释型语言一样,python 实际上将源代码编译为虚拟机的一组指令,python 解释器就是该虚拟机的实现。其中这种中间格式称为“字节码”。
因此,python留下的这些.pyc文件,是为了让运行的速快变得 “更快”,或者是针对你的源代码的”优化“的版本;它们是 python 虚拟机上运行的字节码指令。
python 虚拟机内幕
cpython使用基于堆栈的虚拟机。也就是说,它完全围绕堆栈数据结构(你可以将项目“推”到结构的“顶部”,或者将项目“弹出”到“顶部”)。
cpython 使用三种类型的栈:
1.调用堆栈。这是运行中的python程序的主要结构。对于每个当前活动的函数调用,它都有一个项目一“帧”,堆栈的底部是程序的入口点。每次函数调用都会将新的帧推到调用堆栈上,每次函数调用返回时,它的帧都会弹出
2.在每一帧中,都有一个评估堆栈(也称为数据堆栈)。这个堆栈是执行 python 函数的地方,执行python代码主要包括将东西推到这个堆栈上,操纵它们,然后将它们弹出。
3.同样在每一帧中,都有一个块堆栈。python使用它来跟踪某些类型的控制结构:循环、try /except块,以及 with 块都会导致条目被推送到块堆栈上,每当退出这些结构之一时,块堆栈就会弹出。这有助于python知道在任何给定时刻哪些块是活动的,例如,continue或break语句可以影响正确的块。
大多数 python 字节码指令操作的是当前调用栈帧的计算栈,虽然,还有一些指令可以做其它的事情(比如跳转到指定指令,或者操作块栈)。
为了更好地理解,假设我们有一些调用函数的代码,比如这个:
1
|
my_function(my_variable, 2 )。 |
python 将转换为一系列字节码指令:
1.一个load_name指令,用于查找函数对象 my_function,并将其推送到计算栈的顶部
2.另一个 load_name 指令去查找变量 my_variable,并将其推送到计算栈的顶部
3.一个 load_const 指令将一个整数 2 推送到计算栈的顶部
4.一个 call_function 指令
call_function 指令有2个参数,它表示 python 需要在堆栈顶部弹出两个位置参数; 然后函数将在它上面进行调用,并且它也同时被弹出(关键字参数的函数,使用指令-call_function_kw-类似的操作,并配合使用第三条指令call_function_ex,它适用于函数调用涉及到参数使用 * 或 ** 操作符的情况)
一旦 python 具备了这些,它将在调用堆栈上分配一个新的帧,填充到函数调用的本地变量,然后运行该帧内的 my_function 的字节码。一旦运行完成,帧将从调用堆栈中弹出,在原始帧中,my_function 的返回值将被推入到计算栈的顶部。
我们知道了这个东西了,也知道字节码了文件了,但是如何去使用字节码呢?ok不知道也没关系,接下来的时间我们所有的话题都将围绕字节码,在python有一个模块可以通过反编译python代码来生成字节码这个模块就是今天要说的--dis模块。
dis模块的使用
dis模块包括一些用于处理 python 字节码的函数,可以将字节码“反汇编”为更便于人阅读的形式。查看解释器运行的字节码还有助于优化代码。这个模块对于查找多线程中的竞态条件也很有用,因为可以用它评估代码中哪一点线程控制可能切换。参考源码include/opcode.h,可以找到字节码的正式列表。详细可以看官方文档。注意不同版本的python生成的字节码内容可能不一样,这里我用的python 3.8.
访问和理解字节码
输入如下内容,然后运行它:
1
2
3
4
|
def hello() print ( "hello, world!" ) import dis dis.dis(hello) |
函数 dis.dis() 将反汇编一个函数、方法、类、模块、编译过的 python 代码对象、或者字符串包含的源代码,以及显示出一个人类可读的版本。dis 模块中另一个方便的功能是 distb()。你可以给它传递一个 python 追溯对象,或者在发生预期外情况时调用它,然后它将在发生预期外情况时反汇编调用栈上最顶端的函数,并显示它的字节码,以及插入一个指向到引发意外情况的指令的指针。
它也可以用于查看 python 为每个函数构建的编译后的代码对象,因为运行一个函数将会用到这些代码对象的属性。这里有一个查看 hello() 函数的示例:
1
2
3
4
5
6
7
8
|
>>> hello.__code__ <code object hello at 0x104e46930 , file "<stdin>" , line 1 > >>> hello.__code__.co_consts (none, 'hello, world!' ) >>> hello.__code__.co_varnames () >>> hello.__code__.co_names ( 'print' ,) |
代码对象在函数中可以以属性 __code__ 来访问,并且携带了一些重要的属性:
co_consts 是存在于函数体内的任意实数的元组
co_varnames 是函数体内使用的包含任意本地变量名字的元组
co_names 是在函数体内引用的任意非本地名字的元组
许多字节码指令--尤其是那些推入到栈中的加载值,或者在变量和属性中的存储值--在这些元组中的索引作为它们参数。
因此,现在我们能够理解 hello() 函数中所列出的字节码:
1、load_global 0:告诉 python 通过 co_names (它是 print 函数)的索引 0 上的名字去查找它指向的全局对象,然后将它推入到计算栈
2、load_const 1:带入 co_consts 在索引 1 上的字面值,并将它推入(索引 0 上的字面值是 none,它表示在 co_consts 中,因为 python 函数调用有一个隐式的返回值 none,如果没有显式的返回表达式,就返回这个隐式的值 )。
3、call_function 1:告诉 python 去调用一个函数;它需要从栈中弹出一个位置参数,然后,新的栈顶将被函数调用。
“原始的” 字节码--是非人类可读格式的字节--也可以在代码对象上作为 co_code 属性可用。如果你有兴趣尝试手工反汇编一个函数时,你可以从它们的十进制字节值中,使用列出 dis.opname 的方式去查看字节码指令的名字。
基本反汇编
函数dis()可以打印 python 源代码(模块、类、方法、函数或代码对象)的反汇编表示。可以通过从命令行运行 dis 来反汇编 dis_simple.py 之类的模块。
1
2
3
4
|
dis_simple.py #!/usr/bin/env python3 # encoding: utf-8 my_dict = { 'a' : 1 } |
输出按列组织,包含原始源代码行号,代码对象中的指令地址,操作码名称以及传递给操作码的任何参数。
对于简单的代码我们可以通过命令行的形式执行下面的命令:
1
|
python3 - m dis dis_simple.py |
输出
1 0 load_const 0 ('a')
2 load_const 1 (1)
4 build_map 1
6 store_name 0 (my_dict)
8 load_const 2 (none)
10 return_value
在这里源代码转换为4个不同的操作来创建和填充字典,然后将结果保存到一个局部变量。
首先解释每一行各列参数的含义:
以第一条指令为例:
第一列 数字(1)表示对应源代码的行数。
第二列(可选)指示当前执行的指令(例如,当字节码来自帧对象时)【这个例子没有】
第三列 一个标签,表示从之前的指令到此可能的jump 【这个例子没有】
第四列 数字是字节码中对应于字节索引的地址(这些是2的倍数,因为python 3.6每条指令使用2个字节,而在以前的版本中可能会有所不同)指令load_const在0位置。
第五列 指令本身对应的人类可读的名字这里是"load_const"
第六列 python内部用于获取某些常量或变量,管理堆栈,跳转到特定指令等的指令的参数(如果有的话)。
第七列 计算后的实际参数。
然后让我们看看这个过程:
由于 python 解释器是基于栈的,所以前几步是用load_const将常量按正确顺序放入到栈中,然后使用 build_map 弹出要增加到字典的新键和值。用 store_name 将所得到的dict对象绑定名为my_dict.
反汇编函数
需要注意的是上面的命令行反编译的形式,不能自动的递归反编译函数,所以我们要使用在文件中导入dis的模式进行反编译,就像下面这样。
1
2
3
4
5
6
7
8
|
#dis_function.py def f( * args): nargs = len (args) print (nargs, args) if __name__ = = '__main__' : import dis dis.dis(f) |
运行命令
1
|
python3 dis_function.py |
然后得到以下结果
2 0 load_global 0 (len)
2 load_fast 0 (args)
4 call_function 1
6 store_fast 1 (nargs)3 8 load_global 1 (print)
10 load_fast 1 (nargs)
12 load_fast 0 (args)
14 call_function 2
16 pop_top
18 load_const 0 (none)
20 return_value
要查看函数的内部,必须把函数传递到dis().因为这里打印的是函数内部的东西,所以没有显示函数的在外层的行编号,而是从2开始的。
下面解析下每一行指令的含义:
1、load_global 用来加载全局变量,包括指定函数名,类名,模块名等全局符号,这里是len函数,load_fast 一般加载局部变量的值,也就是读取值,用于计算或者函数调用传参等,这里就是传入参数args。
2、一般是先指定要调用的函数,然后压参数,最后通过 call_function 调用。
3、store_fast 保存值到局部变量。也就是把结果赋值给 store_fast。
4、下面的print因为2个参数所以load_fast了2次,pop_top删除堆栈顶部(tos)项。load_const加载const变量,比如数值、字符串等等,这里因为是print所以值为none。
5、最后通过return_value来确定函数结尾。
要打印一个函数的总结信息我们可以使用dis的show_code的方法,它包含使用的参数和名的相关信息,show_code的参数就是这个函数对象,代码如下:
1
2
3
4
5
6
7
|
def f( * args): nargs = len (args) print (nargs, args) if __name__ = = '__main__' : import dis dis.show_code(f) |
运行之后,结果如下
name: f
filename: dis_function_showcode.py
argument count: 0
kw-only arguments: 0
number of locals: 2
stack size: 3
flags: optimized, newlocals, varargs, nofree
constants:
0: none
names:
0: len
1: print
variable names:
0: args
1: nargs
可以看到返回的内容有函数,方法,参数等信息。
反汇编类
上面我们知道了如何反汇编一个函数的内部,同样的我们也可以用类似的方法反汇编一个类。
我们看一个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
import dis class myobject: """example for dis.""" class_attribute = 'some value' def __str__( self ): return 'myobject({})' . format ( self .name) def __init__( self , name): self .name = name if __name__ = = '__main__' : dis.dis(myobject) |
运行之和得到如下结果
disassembly of __init__:
12 0 load_fast 1 (name)
2 load_fast 0 (self)
4 store_attr 0 (name)
6 load_const 0 (none)
8 return_valuedisassembly of __str__:
9 0 load_const 1 ('myobject({})')
2 load_method 0 (format)
4 load_fast 0 (self)
6 load_attr 1 (name)
8 call_method 1
10 return_value
从整体内容来看,结果分为了两部分disassembly of __init__和disassembly of __str__,disassembly就是反汇编的意思。
首先分析__init__部分:
1、然后需要注意的一点是,方法是按照字母的顺序列出的,所以在部分,先看到name再看到self,但是他们都是 load_fast。
2、store_attr实现self.name = name。
3、然后load_const一个none和return_value标志着函数结束。
接下来分析__str__部分:
1、load_const将'myobject({})'加载到栈
2、然后通过 load_method 调用字符串format方法。这个方法是python3.7新加入的。
3、load_fast 也就是到了self了。
4、load_attr 一般是调用某个对象的方法时。这里就是self.name的.name操作
5、call_method 是 python3.7 新增加的内容,这里是执行方法。
6、return_value表示函数的结束。
上面字符串的拼接我们用了format,之前我一直推荐用f-string,下面就让我们通过字节码来分析,为什么f-string比format要高快。
代码其他代码不变,把return改成以下内容:
1
|
return f 'myobject({self.name})' |
再次执行,下面我们只看__str__函数的部分。
disassembly of __str__: 9 0 load_const 1 ('myobject(') 2 load_fast 0 (self) 4 load_attr 0 (name) 6 format_value 0 8 load_const 2 (')') 10 build_string 3 12 return_value
对比发现我们这里没有了调用方法的操作load_method,取而代之使用了用于实现fstring的format_value指令。之后通过build_string连接堆栈中的计数字符串并将结果字符串推入堆栈.为什么format慢呢, python中的函数调用具有相当大的开销。 当使用str.format()时,call_method 中花费的额外时间是导致str.format()比fstring慢得多。
使用反汇编调试
调试一个异常时,有时要查看哪个字节码带来了问题。这个时候就很有用了,要对一个错误周围的代码反汇编,有多种方法。第一种策略是在交互解释器中使用dis()报告最后一个异常。
如果没有向dis()传入任何参数,那么它会查找一个异常,并显示导致这个异常的栈顶元素的反汇编效果。
命令行上使用
打开我的命令行执行如下操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
chennan@chennandemacbook - pro - 2 ~ python3 python 3.8 . 0a3 (v3. 8.0a3 : 9a448855b5 , mar 25 2019 , 17 : 05 : 20 ) [clang 6.0 (clang - 600.0 . 57 )] on darwin type "help" , "copyright" , "credits" or "license" for more information. >>> import dis >>> j = 4 >>> i = i + 4 traceback (most recent call last): file "<stdin>" , line 1 , in <module> nameerror: name 'i' is not defined >>> dis.dis() 1 - - > 0 load_name 0 (i) 2 load_const 0 ( 4 ) 4 binary_add 6 store_name 0 (i) 8 load_const 1 (none) 10 return_value >>> |
行号后面的-->就是导致错误的操作码,一个load_name指令,由于没有定义变量i,所以无法将与这个名关联的值加载到栈中。
代码中使用distb
程序还可以打印一个活动的traceback的有关信息,将它传递到distb()方法。
下面的程序中有个diviedbyzero异常;但是这个公式有两个除法,所以不清楚是哪一部分出错,此时我们就可以使用下面的方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
dis_traceback.py i = 1 j = 0 k = 3 try : result = k * (i / j) + (i / k) except exception: import dis import sys exc_type, exc_value, exc_tb = sys.exc_info() dis.distb(exc_tb) |
运行之后输出
1 0 load_const 0 (1)
2 store_name 0 (i)2 4 load_const 1 (0)
6 store_name 1 (j)3 8 load_const 2 (3)
10 store_name 2 (k)5 12 setup_finally 24 (to 38)
6 14 load_name 2 (k)
16 load_name 0 (i)
18 load_name 1 (j)
--> 20 binary_true_divide
22 binary_multiply
24 load_name 0 (i)
26 load_name 2 (k)
28 binary_true_divide
...
>> 96 end_finally
>> 98 load_const 3 (none)
100 return_value
结果反映的字节码很长我们不用全看了,看最开始出现--> 就可以知道错误的位置了。
其中setup_finally 字节码的含义是将try块从try-except子句推入块堆栈。
这里可以看出将load_name 将j压入栈之后就报错了。所以可以推断出在(i/j)就出错了。
参考资料
- https://docs.python.org/zh-cn/3.7/library/dis.html#opcode-store_fast
- https://opensource.com/article/18/4/introduction-python-bytecode
- https://hackernoon.com/a-closer-look-at-how-python-f-strings-work-f197736b3bdb
总结
以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对服务器之家的支持。
原文链接:https://www.cnblogs.com/c-x-a/p/10847501.html