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

前言

如果你曾经写过或者用过 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

总结

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

(0)

相关推荐

  • Python使用dis模块把Python反编译为字节码的用法详解

    dis - Disassembler for Python bytecode,即把python代码反汇编为字节码指令. 使用超级简单: python -m dis xxx.py Python 代码是先被编译为字节码后,再由Python虚拟机来执行字节码, Python的字节码是一种类似汇编指令的中间语言, 一个Python语句会对应若干字节码指令,虚拟机一条一条执行字节码指令, 从而完成程序执行. Python dis 模块支持对Python代码进行反汇编, 生成字节码指令. 当我在网上看到wh

  • 详解python字节码

    Python对不可变序列进行重复拼接操作效率会很低,因为每次都会生成一个新的对象,解释器需要把原来对象中的元素先复制到新的对象里,然后再追加新的元素. 但是CPython对字符串操作进行了优化,因为对字符串做+=操作实在是太普遍了.因此,初始化str时会预留出额外的可扩展空间,从而进行增量操作的时候不会有复制再追加的这个步骤. 通过字节码研究一下这个过程. >>> s_code = 'a += "b"' >>> c = compile(s_code,

  • 深入Python解释器理解Python中的字节码

    我最近在参与Python字节码相关的工作,想与大家分享一些这方面的经验.更准确的说,我正在参与2.6到2.7版本的CPython解释器字节码的工作. Python是一门动态语言,在命令行工具下运行时,本质上执行了下面的步骤: 当第一次执行到一段代码时,这段代码会被编译(如,作为一个模块加载,或者直接执行).根据操作系统的不同,这一步生成后缀名是pyc或者pyo的二进制文件. 解释器读取二进制文件,并依次执行指令(opcodes). Python解释器是基于栈的.要理解数据流向,我们需要知道每条指

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

    前言 如果你曾经写过或者用过 Python,你可能已经习惯了看到 Python 源代码文件:它们的名称以.Py 结尾.你可能还见过另一种类型的文件是 .pyc 结尾的,它们就是 Python "字节码"文件.(在 Python3 的时候这个 .pyc 后缀的文件不太好找了,它在一个名为__pycache__的子目录下面.).pyc文件可以防止Python每次运行时都重新解析源代码,该文件大大节省了时间. Python是如何工作的 Python 通常被描述为一种解释语言,在这种语言中,你

  • Python标准库学习之psutil内存详解

    目录 查询CPU信息 查询内存信息 查询磁盘信息 查询网络信息 查询进程信息 人生苦短,快学Python! 今天介绍的是psutil模块,它是一个跨平台库 https://github.com/giampaolo/psutil 命令行下通过pip安装: pip install psutil 如果跟我一样安装的是Anaconda,则剩下这步了,因为自带了. 顾名思义 psutil = process and system utilities 它专门用来获取操作系统以及硬件相关的信息,比如:CPU.

  • python标准库学习之sys模块详解

    目录 前言 处理命令行参数 sys.exit(n) 退出程序,正常退出时exit(0) sys.version 获取Python解释程序的版本信息 sys.platform 返回操作系统平台名称 sys.stdin.readline()与input sys.stdout与print 总结 补充:sys 模块的实例 前言 sys模块是与python解释器交互的一个接口.sys 模块提供了许多函数和变量来处理 Python 运行时环境的不同部分. 处理命令行参数 在解释器启动后, argv 列表包含

  • django文档学习之applications使用详解

    本文研究的主要是Django1.10文档的深入学习,Applications基础部分的相关内容,具体介绍如下. Applications应用 Django包含一个安装的应用程序的注册表,存储配置并提供内省. 它还保留了可用模型的列表. 这个注册表简单称为应用程序,它可以在django.apps中使用: >>> from django.apps import apps >>> apps.get_app_config('admin').verbose_name 'Admin

  • PyInstaller将Python文件打包为exe后如何反编译(破解源码)以及防止反编译

    环境: win7+python3.5(anaconda3) 理论上,win7及以上的系统和python任意版本均可. 一.基础脚本 首先我们构建一个简单的脚本,比如输出一串数字.文本之类,这里我们输出一串文字的同时计算一下3次方好了. # -*- coding: utf-8 -*- """ Created on Wed Aug 29 09:18:13 2018 @author: Li Zeng hai """ def test(num): pri

  • 在Python反编译中批量pyc转 py的实现代码

    什么是pyc文件 pyc是一种二进制文件,是由py文件经过编译后,生成的文件,是一种byte code,py文件变成pyc文件后,加载的速度有所提高,而且pyc是一种跨平台的字节码,是由python的虚拟机来执行的,这个是类似于JAVA或者.NET的虚拟机的概念. 使用uncompyle6可以将pyc文件转换为py文件,因此,也可以调用CMD进行批量操作,代码如下: import os import sys def walk_dir(dir,topdown=True): words=[] wor

  • android apk反编译到java源码的实现方法

    Android由于其代码是放在dalvik虚拟机上的托管代码,所以能够很容易的将其反编译为我们可以识别的代码. 之前我写过一篇文章反编译Android的apk包到smali文件 然后再重新编译签名后打包实现篡改apk的功能. 最近又有一种新的方法来实现直接从Android apk包里的classes.dex文件,把dex码反编译到java的.class二进制码,然后从.class二进制码反编译到java源码想必就不用我来多说了吧. 首先我们需要的工具是dex2jar和jd-gui 其中第一个工具

  • Python学习之异常断言详解

    该章节我们来学习 异常的最后一个知识点 - 断言 ,断言是判断一个表达式,在表达式为 False 的时候触发异常.表达式我们可以对号入座,可以是条件语句中的声明,也可以是是 while 循环中的声明. 它们都是对一件事情进行 True 或者 False 的判断, 断言 也是如此,断言发现后面的表达式为 False 的时候 就会主动抛出异常. 在 Python 中 assert 就是断言的关键字,乍一听起来 似乎和 raise 关键字 的功能一样.其实 assert 断言的使用要比 raise 更

  • python目标检测SSD算法预测部分源码详解

    目录 学习前言 什么是SSD算法 ssd_vgg_300主体的源码 学习前言 ……学习了很多有关目标检测的概念呀,咕噜咕噜,可是要怎么才能进行预测呢,我看了好久的SSD源码,将其中的预测部分提取了出来,训练部分我还没看懂 什么是SSD算法 SSD是一种非常优秀的one-stage方法,one-stage算法就是目标检测和分类是同时完成的,其主要思路是均匀地在图片的不同位置进行密集抽样,抽样时可以采用不同尺度和长宽比,然后利用CNN提取特征后直接进行分类与回归,整个过程只需要一步,所以其优势是速度

随机推荐