python反编译学习之字节码详解


Posted in Python onMay 19, 2019

前言

如果你曾经写过或者用过 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 字节码指令操作的是当前调用栈帧的计算栈,虽然,还有一些指令可以做其它的事情(比如跳转到指定指令,或者操作块栈)。

为了更好地理解,假设我们有一些调用函数的代码,比如这个:

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.

访问和理解字节码

输入如下内容,然后运行它:

def hello()
 print("Hello, World!")
import dis
dis.dis(hello)

函数 dis.dis() 将反汇编一个函数、方法、类、模块、编译过的 Python 代码对象、或者字符串包含的源代码,以及显示出一个人类可读的版本。dis 模块中另一个方便的功能是 distb()。你可以给它传递一个 Python 追溯对象,或者在发生预期外情况时调用它,然后它将在发生预期外情况时反汇编调用栈上最顶端的函数,并显示它的字节码,以及插入一个指向到引发意外情况的指令的指针。

它也可以用于查看 Python 为每个函数构建的编译后的代码对象,因为运行一个函数将会用到这些代码对象的属性。这里有一个查看 hello() 函数的示例:

>>> 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 之类的模块。

dis_simple.py
#!/usr/bin/env python3
# encoding: utf-8
my_dict = {'a': 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的模式进行反编译,就像下面这样。

#dis_function.py
def f(*args):
 nargs = len(args)
 print(nargs, args)

if __name__ == '__main__':
 import dis
 dis.dis(f)

运行命令

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的参数就是这个函数对象,代码如下:

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

可以看到返回的内容有函数,方法,参数等信息。

反汇编类

上面我们知道了如何反汇编一个函数的内部,同样的我们也可以用类似的方法反汇编一个类。

我们看一个例子:

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_VALUE

Disassembly 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改成以下内容:

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()传入任何参数,那么它会查找一个异常,并显示导致这个异常的栈顶元素的反汇编效果。

命令行上使用

打开我的命令行执行如下操作:

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异常;但是这个公式有两个除法,所以不清楚是哪一部分出错,此时我们就可以使用下面的方法:

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

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对三水点靠木的支持。

Python 相关文章推荐
Python中的字典遍历备忘
Jan 17 Python
详解使用 pyenv 管理多个版本 python 环境
Oct 19 Python
Python的log日志功能及设置方法
Jul 11 Python
如何使用python进行pdf文件分割
Nov 11 Python
python 实现多线程下载视频的代码
Nov 15 Python
Python包和模块的分发详细介绍
Jun 19 Python
Keras 切换后端方式(Theano和TensorFlow)
Jun 19 Python
使用Keras训练好的.h5模型来测试一个实例
Jul 06 Python
使用Python实现微信拍一拍功能的思路代码
Jul 09 Python
Python根据字典的值查询出对应的键的方法
Sep 30 Python
Python爬虫之Selenium鼠标事件的实现
Dec 04 Python
python OpenCV学习笔记
Mar 31 Python
python从入门到精通 windows安装python图文教程
May 18 #Python
详解用Python实现自动化监控远程服务器
May 18 #Python
Python实现打砖块小游戏代码实例
May 18 #Python
如何在Python中实现goto语句的方法
May 18 #Python
OpenCV搞定腾讯滑块验证码的实现代码
May 18 #Python
Python3匿名函数lambda介绍与使用示例
May 18 #Python
python中数组和矩阵乘法及使用总结(推荐)
May 18 #Python
You might like
phpmailer发送gmail邮件实例详解
2013/06/24 PHP
Twig模板引擎用法入门教程
2016/01/20 PHP
/etc/php-fpm.d/www.conf 配置注意事项
2017/02/04 PHP
PHP简单实现模拟登陆功能示例
2017/09/15 PHP
php创建类并调用的实例方法
2019/09/25 PHP
JavaScript.The.Good.Parts阅读笔记(一)假值与===运算符
2010/11/16 Javascript
JQueryEasyUI datagrid框架的基本使用
2013/04/08 Javascript
再谈Jquery Ajax方法传递到action(补充)
2014/05/12 Javascript
使用JS+plupload直接批量上传图片到又拍云
2014/12/01 Javascript
js创建jsonArray传输至后台及后台全面解析
2016/04/11 Javascript
设置点击文本框或图片弹出日历控件的实现代码
2016/05/12 Javascript
JavaScript中常用的验证reg
2016/10/13 Javascript
Nodejs中解决cluster模块的多进程如何共享数据问题
2016/11/10 NodeJs
在vue使用clipboard.js进行一键复制文本的实现示例
2019/01/15 Javascript
layui表格内放置图片,并点击放大的实例
2019/09/10 Javascript
jQuery实现购物车全功能
2021/01/11 jQuery
[01:35]辉夜杯战队访谈宣传片—iG.V
2015/12/25 DOTA
[00:12]DAC2018 天才少年转战三号位,他的SOLO是否仍如昔日般强大?
2018/04/06 DOTA
Python 初始化多维数组代码
2008/09/06 Python
Python和C/C++交互的几种方法总结
2017/05/11 Python
python抓取文件夹的所有文件
2018/02/27 Python
django 通过ajax完成邮箱用户注册、激活账号的方法
2018/04/17 Python
python使用正则表达式来获取文件名的前缀方法
2018/10/21 Python
在cmd中查看python的安装路径方法
2019/07/03 Python
Python调用Windows API函数编写录音机和音乐播放器功能
2020/01/05 Python
adidas爱尔兰官方网站:阿迪达斯运动鞋和运动服
2019/11/01 全球购物
工程现场管理求职自荐信
2013/10/02 职场文书
个人剖析材料范文
2014/09/30 职场文书
邓小平文选读书笔记
2015/06/29 职场文书
2015年思想品德教学工作总结
2015/07/22 职场文书
感恩老师主题班会
2015/08/12 职场文书
Python requests库参数提交的注意事项总结
2021/03/29 Python
微信小程序和php的登录实现
2021/04/01 PHP
MYSQL主从数据库同步备份配置的方法
2021/05/26 MySQL
Go timer如何调度
2021/06/09 Golang
Python使用openpyxl批量处理数据
2021/06/23 Python