下面博文将带你创建一个字节码级别的追踪API以追踪Python的一些内部机制,比如类似YIELDVALUE、YIELDFROM操作码的实现,推式构造列表(List Comprehensions)、生成器表达式(generator expressions)以及其他一些有趣Python的编译。

以下为译文

最近我在学习 Python 的运行模型。我对 Python 的一些内部机制很是好奇,比如 Python 是怎么实现类似 YIELDVALUE、YIELDFROM 这样的操作码的;对于 递推式构造列表(List Comprehensions)、生成器表达式(generator expressions)以及其他一些有趣的 Python 特性是怎么编译的;从字节码的层面来看,当异常抛出的时候都发生了什么事情。翻阅 CPython 的代码对于解答这些问题当然是很有帮助的,但我仍然觉得以这样的方式来做的话对于理解字节码的执行和堆栈的变化还是缺少点什么。GDB 是个好选择,但是我懒,而且只想使用一些比较高阶的接口写点 Python 代码来完成这件事。

所以呢,我的目标就是创建一个字节码级别的追踪 API,类似 sys.setrace 所提供的那样,但相对而言会有更好的粒度。这充分锻炼了我编写 Python 实现的 C 代码的编码能力。我们所需要的有如下几项,在这篇文章中所用的 Python 版本为 3.5。

  • 一个新的 Cpython 解释器操作码
  • 一种将操作码注入到 Python 字节码的方法
  • 一些用于处理操作码的 Python 代码

一个新的 Cpython 操作码

新操作码:DEBUG_OP

这个新的操作码 DEBUG_OP 是我第一次尝试写 CPython 实现的 C 代码,我将尽可能的让它保持简单。 我们想要达成的目的是,当我们的操作码被执行的时候我能有一种方式来调用一些 Python 代码。同时,我们也想能够追踪一些与执行上下文有关的数据。我们的操作码会把这些信息当作参数传递给我们的回调函数。通过操作码能辨识出的有用信息如下:

  • 堆栈的内容
  • 执行 DEBUG_OP 的帧对象信息

所以呢,我们的操作码需要做的事情是:

  • 找到回调函数
  • 创建一个包含堆栈内容的列表
  • 调用回调函数,并将包含堆栈内容的列表和当前帧作为参数传递给它

听起来挺简单的,现在开始动手吧!声明:下面所有的解释说明和代码是经过了大量段错误调试之后总结得到的结论。首先要做的是给操作码定义一个名字和相应的值,因此我们需要在Include/opcode.h中添加代码。

这部分工作就完成了,现在我们去编写操作码真正干活的代码。

实现 DEBUG_OP

在考虑如何实现DEBUG_OP之前我们需要了解的是DEBUG_OP提供的接口将长什么样。 拥有一个可以调用其他代码的新操作码是相当酷眩的,但是究竟它将调用哪些代码捏?这个操作码如何找到回调函数的捏?我选择了一种最简单的方法:在帧的全局区域写死函数名。那么问题就变成了,我该怎么从字典中找到一个固定的 C 字符串?为了回答这个问题我们来看看在 Python 的 main loop 中使用到的和上下文管理相关的标识符__enter__和__exit__。

我们可以看到这两标识符被使用在操作码SETUP_WITH中:

现在,看一眼宏_Py_IDENTIFIER的定义

嗯,注释部分已经说明得很清楚了。通过一番查找,我们发现了可以用来从字典找固定字符串的函数_PyDict_GetItemId,所以我们操作码的查找部分的代码就是长这样滴。

为了方便理解,对这一段代码做一些说明:

  • f是当前的帧,f->f_globals是它的全局区域
  • 如果我们没有找到op_target,我们将会检查这个异常是不是KeyError
  • goto error;是一种在 main loop 中抛出异常的方法
  • PyErr_Clear()抑制了当前异常的抛出,而DISPATCH()触发了下一个操作码的执行

下一步就是收集我们想要的堆栈信息。

最后一步就是调用我们的回调函数!我们用call_function来搞定这件事,我们通过研究操作码CALL_FUNCTION的实现来学习怎么使用call_function。

有了上面这些信息,我们终于可以捣鼓出一个操作码DEBUG_OP的草稿了:

在编写 CPython 实现的 C 代码方面我确实没有什么经验,有可能我漏掉了些细节。如果您有什么建议还请您纠正,我期待您的反馈。

编译它,成了!

一切看起来很顺利,但是当我们尝试去使用我们定义的操作码DEBUG_OP的时候却失败了。自从 2008 年之后,Python 使用预先写好的 goto(你也可以从 这里获取更多的讯息)。故,我们需要更新下 goto jump table,我们在 Python/opcode_targets.h 中做如下修改。

这就完事了,我们现在就有了一个可以工作的新操作码。唯一的问题就是这货虽然存在,但是没有被人调用过。接下来,我们将DEBUG_OP注入到函数的字节码中。

在 Python 字节码中注入操作码 DEBUG_OP

有很多方式可以在 Python 字节码中注入新的操作码:

  • 使用 peephole optimizer, Quarkslab就是这么干的
  • 在生成字节码的代码中动些手脚
  • 在运行时直接修改函数的字节码(这就是我们将要干的事儿)

为了创造出一个新操作码,有了上面的那一堆 C 代码就够了。现在让我们回到原点,开始理解奇怪甚至神奇的 Python!

我们将要做的事儿有:

  • 得到我们想要追踪函数的 code object
  • 重写字节码来注入DEBUG_OP
  • 将新生成的 code object 替换回去

和 code object 有关的小贴士

如果你从没听说过 code object,这里有一个简单的 介绍网路上也有一些相关的文档可供查阅,可以直接Ctrl+F查找 code object

还有一件事情需要注意的是在这篇文章所指的环境中 code object 是不可变的:

但是不用担心,我们将会找到方法绕过这个问题的。

使用的工具

为了修改字节码我们需要一些工具:

  • dis模块用来反编译和分析字节码
  • dis.BytecodePython 3.4 新增的一个特性,对于反编译和分析字节码特别有用
  • 一个能够简单修改 code object 的方法

用dis.Bytecode反编译 code bject 能告诉我们一些有关操作码、参数和上下文的信息。

为了能够修改 code object,我定义了一个很小的类用来复制 code object,同时能够按我们的需求修改相应的值,然后重新生成一个新的 code object。

这个类用起来很方便,解决了上面提到的 code object 不可变的问题。

测试我们的新操作码

我们现在拥有了注入DEBUG_OP的所有工具,让我们来验证下我们的实现是否可用。我们将我们的操作码注入到一个最简单的函数中:

看起来它成功了!有一行代码需要说明一下new_nop_code.co_stacksize += 3

  • co_stacksize 表示 code object 所需要的堆栈的大小
  • 操作码DEBUG_OP往堆栈中增加了三项,所以我们需要为这些增加的项预留些空间

现在我们可以将我们的操作码注入到每一个 Python 函数中了!

重写字节码

正如我们在上面的例子中所看到的那样,重写 Pyhton 的字节码似乎 so easy。为了在每一个操作码之间注入我们的操作码,我们需要获取每一个操作码的偏移量,然后将我们的操作码注入到这些位置上(把我们操作码注入到参数上是有坏处大大滴)。这些偏移量也很容易获取,使用dis.Bytecode ,就像这样 。

基于上面的例子,有人可能会想我们的insert_op_debug会在指定的偏移量增加一个"\x00",这尼玛是个坑啊!我们第一个DEBUG_OP注入的例子中被注入的函数是没有任何的分支的,为了能够实现完美一个函数注入函数insert_op_debug我们需要考虑到存在分支操作码的情况。

Python 的分支一共有两种:

  • 绝对分支:看起来是类似这样子的Instruction_Pointer = argument(instruction)
  • 相对分支:看起来是类似这样子的Instruction_Pointer += argument(instruction)

相对分支总是向前的

我们希望这些分支在我们插入操作码之后仍然能够正常工作,为此我们需要修改一些指令参数。以下是其逻辑流程:

  • 对于每一个在插入偏移量之前的相对分支而言
  • 如果目标地址是严格大于我们的插入偏移量的话,将指令参数增加 1
  • 如果相等,则不需要增加 1 就能够在跳转操作和目标地址之间执行我们的操作码DEBUG_OP
  • 如果小于,插入我们的操作码的话并不会影响到跳转操作和目标地址之间的距离
  • 对于 code object 中的每一个绝对分支而言
  • 如果目标地址是严格大于我们的插入偏移量的话,将指令参数增加 1
  • 如果相等,那么不需要任何修改,理由和相对分支部分是一样的
  • 如果小于,插入我们的操作码的话并不会影响到跳转操作和目标地址之间的距离

下面是实现:

让我们看一下效果如何:

甚好!现在我们知道了如何获取堆栈信息和 Python 中每一个操作对应的帧信息。上面结果所展示的结果目前而言并不是很实用。在最后一部分中让我们对注入做进一步的封装。

增加 Python 封装

正如您所见到的,所有的底层接口都是好用的。我们最后要做的一件事是让 op_target 更加方便使用(这部分相对而言比较空泛一些,毕竟在我看来这不是整个项目中最有趣的部分)。

首先我们来看一下帧的参数所能提供的信息,如下所示:

  • f_code当前帧将执行的 code object
  • f_lasti当前的操作(code object 中的字节码字符串的索引)

经过我们的处理我们可以得知DEBUG_OP之后要被执行的操作码,这对我们聚合数据并展示是相当有用的。

新建一个用于追踪函数内部机制的类:

  • 改变函数自身的co_code
  • 设置回调函数作为op_debug的目标函数

一旦我们知道下一个操作,我们就可以分析它并修改它的参数。举例来说我们可以增加一个auto-follow-called-functions的特性。

现在我们实现一个 Trace 的子类,在这个子类中增加 callback 和 doreport 这两个方法。callback 方法将在每一个操作之后被调用。doreport 方法将我们收集到的信息打印出来。

这是一个伪函数追踪器实现:

从底层带你理解Python中的一些内部机制的更多相关文章

  1. 【转】你真的理解Python中MRO算法吗?

    你真的理解Python中MRO算法吗? MRO(Method Resolution Order):方法解析顺序. Python语言包含了很多优秀的特性,其中多重继承就是其中之一,但是多重继承会引发很多 ...

  2. 理解 Python 中的可变参数 *args 和 **kwargs:

    默认参数:  Python是支持可变参数的,最简单的方法莫过于使用默认参数,例如: def getSum(x,y=5): print "x:", x print "y:& ...

  3. [转]深刻理解Python中的元类(metaclass)以及元类实现单例模式

    使用元类 深刻理解Python中的元类(metaclass)以及元类实现单例模式 在看一些框架源代码的过程中碰到很多元类的实例,看起来很吃力很晦涩:在看python cookbook中关于元类创建单例 ...

  4. 深入理解Python中的yield和send

    send方法和next方法唯一的区别是在执行send方法会首先把上一次挂起的yield语句的返回值通过参数设定,从而实现与生成器方法的交互. 但是需要注意,在一个生成器对象没有执行next方法之前,由 ...

  5. 如何理解python中的if __name__=='main'的作用

    一. 一个浅显易懂的比喻 我们在学习python编程时,不可避免的会遇到if __name__=='main'这样的语句,它到底有什么作用呢? <如何简单地理解Python中的if __name ...

  6. 深入理解Python中的GIL(全局解释器锁)

    深入理解Python中的GIL(全局解释器锁) Python是门古老的语言,要想了解这门语言的多线程和多进程以及协程,以及明白什么时候应该用多线程,什么时候应该使用多进程或协程,我们不得不谈到的一个东 ...

  7. 深入理解python中函数传递参数是值传递还是引用传递

    深入理解python中函数传递参数是值传递还是引用传递 目前网络上大部分博客的结论都是这样的: Python不允许程序员选择采用传值还是传 引用.Python参数传递采用的肯定是"传对象引用 ...

  8. 全面理解python中self的用法

    self代表类的实例,而非类. class Test: def prt(self): print(self) print(self.__class__) t = Test() t.prt() 执行结果 ...

  9. 深刻理解Python中的元类metaclass(转)

    本文由 伯乐在线 - bigship 翻译 英文出处:stackoverflow 译文:http://blog.jobbole.com/21351/ 译注:这是一篇在Stack overflow上很热 ...

随机推荐

  1. U盘安装CentOS7.3教程

    0.准备工作: 一台没系统的普通电脑u盘一个(大于1G,最小安装的话不超过1G,根据选择系统大小匹配U盘即可)CentOS7.3 iso文件一个UltraISO工具 1.制作U盘 ①使用UltraIS ...

  2. Python条件判断

    计算机之所以能做很多自动化的任务,因为它可以自己做条件判断. 比如,输入用户年龄,根据年龄打印不同的内容,在Python程序中,用if语句实现: age = 20 if age >= 18: p ...

  3. python的格式化输出(format,%)

    皇城PK Python中格式化字符串目前有两种阵营:%和format,我们应该选择哪种呢? 自从Python2.6引入了format这个格式化字符串的方法之后,我认为%还是format这根本就不算个问 ...

  4. Redis集群整合到springboot框架

    整合步骤 1 配置application.properties spring.redis.cluster.nodes=192.168.60.131:8000,192.168.60.131:8001,1 ...

  5. 在Oracle中计算两个日期间隔的天数、月数和年数

    一.天数: 在Oracle中,两个日期直接相减,便可以得到天数: select to_date('08/06/2015','mm/dd/yyyy')-to_date('07/01/2015','mm/ ...

  6. java8新特性学习2

    六.Stream API Java8中有两大最为重要的改变.第一个是 Lambda 表达式:另外一个则是 Stream API(java.util.stream.*).Stream 是 Java8 中 ...

  7. 选课(树形DP)

    题目描述 在大学里每个学生,为了达到一定的学分,必须从很多课程里选择一些课程来学习,在课程里有些课程必须在某些课程之前学习,如高等数学总是在其它课程之前学习.现在有N门功课,每门课有个学分,每门课有一 ...

  8. 困扰我的c++语法

    以下是我上周学习c++ primer的心得: 1 数组引用作为形参 c++允许将变量定义成数组的引用,以下列代码为例.形参为int (&arr)[10],该参数需分成两部分说明,引用名和 引用 ...

  9. CentOS7 minimal 没有netstat命令

    在CentOS 7 minimal中使用netstat 时,发现显示如下,明显没有了netstat 命令 [root@localhost ~]# netstat -a -bash: netstat: ...

  10. java递归 斐波那契数列递归与非递归实现

    递归简单来说就是自己调用自己, 递归构造包括两个部分: 1.定义递归头:什么时候需要调用自身方法,如果没有头,将陷入死循环 2.递归体:调用自身方法干什么 递归是自己调用自己的方法,用条件来判断调用什 ...