做一个字节码追踪器,从内部理解 Python 的执行过程

最近我在研究 Python 的执行模型。我对 Python 内部的东西挺好奇,比如:类似 YIELDVALUE 和 YIELDFROM 此类操作码的实现;列表表达式、生成器表达式以及一些有趣的Python 特性是怎么编译的;异常触发之时,字节码层面发生了什么。

阅读 CPython 代码是相当有益的,但是我觉得要完全理解字节码的执行和堆栈的变化,光读源码是远远不够的。GDB 是个好选择,但我很懒,只想写一些高级的接口和 Python 代码。

因此我想做一个字节码级别的追踪 API,就像 sys.settrace 所提供的那样,但颗粒度更出色。这种练习完美地锻炼了我将 C 转化为 Python 的能力。我们所需的有以下几点:

  • 一个新的CPython解释器操作码
  • 一种将操作码注入Python字节码的方法
  • 一些Python代码,用于在Python的角度处理操作码

注:在这篇文章中,Python版本是3.5

一种新的CPython操作码

我们的新操作码:DEBUG_OP

这个新的操作码DEBUG_OP是我第一次尝试用C代码来实现CPython。我会尽量使之保持简洁。

我想要达到的目的是,无论我的操作码何时执行,都有一种方式调用一些Python代码,与此同时,我们也想能够追踪一些与执行上下文有关的数据。我们的操作码会把这些信息当作参数传递给我们的回调函数。我能辨识出的有用信息如下:

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

因此我们 DEBUG_OP 所需做的所有事情是:

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

听起来挺简单啊,让我们开始吧!

声明:以下的解释和代码都是经过大量段错误得到的。首先要做的事情,就是给我们的操作码命名并赋值,因此我们需要在Include/opcode.h中添加

这简单的部分是完成了,现在我们必须真正去编写我们的操作码。

实现 DEBUG_OP

在考虑实现DEBUG_OP之前,我们需要问我们自己的第一个问题是:“我的接口应该是什么样的?”

拥有一个可以调用其他代码的新操作码是很酷的,但是它实际上会调用哪些代码呢?这个操作码怎么找到回调函数呢?我选择了一种看起来最简单的解决方案,在帧的全局区域写死函数名。

现在问题就变成了:“我怎么从一个字典中找到一个不变的C字符串?”

为了回答这个问题,我们可以寻找一些用在Python的main循环中的用到的和上下文管理相关的标识符**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。

将操作码 DEBUG_OP 注入到 Python 字节码中

下面是一些把新的操作码插入 Python 字节码中的方法。

  • 我们可以像 Quarkslab 那样用 peephole optimizer
  • 我们可以在生成字节码时做些改变
  • 我们可以仅仅修改一些运行时的函数的字节码(这其实就是我们将要做的)

为了编写出新的操作码,有了上面的C代码就足够了,让我们回到起点,理解奇怪而神奇的Python!

So, what we are going to do is:

因此,我们将要做下面这些事儿:

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

关于 code object 的提示

如果你听说过 code object,在我第一篇文章里有一点介绍。在网上也有一些相关文档,可以直接用 Ctrl+F 查找“code objects”

在这篇文章中,还有一件需要注意的事情是,code objects不能改变:

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

所用工具

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

  • dist模块用来反编译和分析字节码
  • dis.Bytecode是Python3.4的新特性,对于反编译和分析字节码特别有用
  • 简单修改code object的工具dis.

dis.Bytecode反编译一个code object,可以给我们一些关于操作码,参数和上下文有用的信息。

为了能够修改code objects,我创建了一个class,用来复制code object,并允许根据我们的需要修改相应的值,然后生成新的code object。

很容易使用,并解决了上面说的 code object 不可变的问题

测试新的操作码

现在我们有了注入DEBUG_OP的基本工具,我们来验证实现是否可用。

将操作码加入到一个最简单的函数中:

好像成功了!有一行代码需要解释一下:new_nop_code.co_stacksize += 3:

  • Co_stacksize表示code object所需的堆栈大小
  • DEBUG_OP增加了3个值到堆栈中,因此我们需要增加预留空间

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

重写字节码

就像我们在上一个例子中看到的,重写Python字节码听起来很简单!为了在每一操作码之间注入DEBUG _OP,所有我们必须获取每一个操作码的偏移量(把我们操作码注入到参数上是有问题的),然后将操作码注入到这些偏移量中。偏移量很容易获取,使用dis.Bytecode就行。

如下所示:

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

Python 的分支一共有两种:

  • 绝对分支:看起来是这样的 Instruction_Pointer = argument(instruction)
  • 相对分支:看起来是这样的 Instruction_Pointer += argument(instruction)
  • 相对分支总是向前的

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

对于每一个在插入偏移量之前的相对分支而言:

  • 如果目标地址是严格大于我们的插入偏移量,将指令参数增加 1
  • 如果相等,则不需要增加 1 就能够在跳转操作和目标地址之间执行DEBUG_OP
  • 如果小于,插入DEBUG_OP并不会影响到跳转操作和目标地址之间的距离

对于 code object 中的每一个绝对分支而言

  • 如果目标地址是严格大于我们的插入偏移量的话,将指令参数增加 1
  • 如果相等,那么不需要任何修改,理由和相对分支部分是一样的
  • 如果小于,插入DEBUG_OP并不会影响到跳转操作和目标地址之间的距离

下面是实现:

我们可以看到结果如下:

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

增加 Python 封装

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

首先我们来看一下帧的参数所能提供的信息,如果我们看到帧中存储的信息,我们将会看到下面这些:

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

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

我们可以新建一个用于追踪函数内部机制的class:

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

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

现在我们必须要做的是,用方法callback和do_report实现一个的子类,其中callback 方法将在每一个操作之后被调用。doreport 方法将我们收集到的信息打印出来。

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

这里有一些实现的例子和使用方法。格式有些不方便观看,毕竟我并不擅长写这种对用户友好的报告。

例1:自动追踪堆栈信息和已经执行的指令

例2:上下文管理

最后是列表表达式的工作示例

例3:伪追踪器的输出

例4:输出收集的堆栈信息

总结

了解 Python 底层的好方法,解释器的 main 循环,Python 实现的 C 代码编程、Python 字节码,这个项目是一个好方法。同时,也让我们看到了 Python 的一些有趣的构造函数(诸如生成器、上下文管理器和列表表达式)的字节码行为。

这里有完整代码

另外,我们还可以修改所追踪的函数的堆栈。虽然不确定这个是否有用,但一定会充满乐趣。

1 收藏 1 评论

关于作者:jasper

运维开发一枚,关注devops、运维监控、python、开源、大数据、web开发、互联网; 个人主页 · 我的文章 · 11 ·     

相关文章

可能感兴趣的话题



直接登录
最新评论
跳到底部
返回顶部