从底层简析Python程序的执行过程

2024-11-22 05:30:12

1、目标就是创建一个字节码级别的追踪 API,类似 sys.setrace 所提供的那样,但相对而言会有更好的粒度。这充分锻炼了我编写 Python 实现的 C 代码的编码能力。我们所需要的有如下几项,在这篇文章中所用的 Python 版本为 3.5。 一个新的 Cpython 解释器操作码 一种将操作码注入到 Python 字节码的方法 一些用于处理操作码的 Python 代码

2、一个新的 Cpython 操作码新操作码:DEBUG_OP这个新的操作码 DEBUG_OP 是我第一次尝试写 CPython 实现的 C 代码,我将尽可能的让它保持简单。 我们想要达成的目的是,当我们的操作码被执行的时候我能有一种方式来调用一些 Python 代码。同时,我们也想能够追踪一些与执行上下文有关的数据。我们的操作码会把这些信息当作参数传递给我们的回调函数。通过操作码能辨识出的有用信息如下: 堆栈的内容 执行 DEBUG_OP 的帧对象信息所以呢,我们的操作码需要做的事情是: 找到回调函数 创建一个包含堆栈内容的列表 调用回调函数,并将包含堆栈内容的列表和当前帧作为参数传递给它听起来挺简单的,现在开始动手吧!声明:下面所有的解释说明和代码是经过了大量段错误调试之后总结得到的结论。首先要做的是给操作码定义一个名字和相应的值,因此我们需要在 Include/opcode.h中添加代码。/** My own comments begin by '**' **//** From: Includes/opcode.h **//* Instruction opcodes for compiled code *//** We just have to define our opcode with a free value0 was the first one I found **/#define DEBUG_OP 0#define POP_TOP 1#define ROT_TWO 2#define ROT_THREE 3这部分工作就完成了,现在我们去编写操作码真正干活的代码。

3、实现 DEBUG_OP在考虑如何实现DEBUG_OP之前我们需要了解的是 DEBUG_OP 提供的接口将长什么样。 拥有一个可以调用其他代码的新操作码是相当酷眩的,但是究竟它将调用哪些代码捏?这个操作码如何找到回调函数的捏?我选择了一种最简单的方法:在帧的全局区域写死函数名。那么问题就变成了,我该怎么从字典中找到一个固定的 C 字符串?为了回答这个问题我们来看看在 Python 的 main loop 中使用到的和上下文管理相关的标识符 enter 和 exit。我们可以看到这两标识符被使用在操作码 SETUP_WITH 中:/** From: Python/ceval.c **/TARGET(SETUP_WITH) {_Py_IDENTIFIER(__exit__);_Py_IDENTIFIER(__enter__);PyObject *mgr = TOP();PyObject *exit = special_lookup(mgr, &PyId___exit__), *enter;PyObject *res;现在,看一眼宏 _Py_IDENTIFIER 定义/** From: Include/object.h **//********************* String Literals ****************************************//* This structure helps managing static strings. The basic usage goes like this:Instead of doingr = PyObject_CallMethod(o, "foo", "args", ...);do_Py_IDENTIFIER(foo);...r = _PyObject_CallMethodId(o, &PyId_foo, "args", ...);PyId_foo is a static variable, either on block level or file level. On firstusage, the string "foo" is interned, and the structures are linked. On interpretershutdown, all strings are released (through _PyUnicode_ClearStaticStrings).Alternatively, _Py_static_string allows to choose the variable name._PyUnicode_FromId returns a borrowed reference to the interned string._PyObject_{Get,Set,Has}AttrId are __getattr__ versions using _Py_Identifier*.*/typedef struct _Py_Identifier {struct _Py_Identifier *next;const char* string;PyObject *object;} _Py_Identifier;#define _Py_static_string_init(value) { 0, value, 0 }#define _Py_static_string(varname, value) static _Py_Identifier varname = _Py_static_string_init(value)#define _Py_IDENTIFIER(varname) _Py_static_string(PyId_##varname, #varname)

4、注释部分已经说明得很清楚了。通过一番查找,我们发现了可以用来从字典找固定字符串的函数 _PyDict_GetItemId,所以我们操作码的查找部分的代码就是长这样滴。/** Our callback function will be named op_target **/PyObject *target = NULL;_Py_IDENTIFIER(op_target);target = _PyDict_GetItemId(f->f_globals, &PyId_op_target);if (target == NULL && _PyErr_OCCURRED()) {if (!PyErr_ExceptionMatches(PyExc_KeyError))goto error;PyErr_Clear();DISPATCH();}为了方便理解,对这一段代码做一些说明: f 是当前的帧,f->f_globals 是它的全局区域 如果我们没有找到 op_target,我们将会检查这个异常是不是 KeyError goto error; 是一种在 main loop 中抛出异常的方法 PyErr_Clear() 抑制了当前异常的抛出,而 DISPATCH() 触发了下一个操作码的执行下一步就是收集我们想要的堆栈信息。/** This code create a list with all the values on the current stack **/PyObject *value = PyList_New(0);(www.wenbangcai.com)for (i = 1 ; i <= STACK_LEVEL(); i++) {tmp = PEEK(i);if (tmp == NULL) {tmp = Py_None;}PyList_Append(value, tmp);}

5、最后一步就是调用我们的回调函数!我们用 call_function 来搞定这件事,我们通过研究操作码 CALL_FUNCTION 的实现来学习怎么使用 call_function 。/** From: Python/ceval.c **/TARGET(CALL_FUNCTION) {PyObject **sp, *res;/** stack_pointer is a local of the main loop.It's the pointer to the stacktop of our frame **/sp = stack_pointer;res = call_function(&sp, oparg);/** call_function handles the args it consummed on the stack for us **/stack_pointer = sp;PUSH(res);/** Standard exception handling **/if (res == NULL)goto error;DISPATCH();}有了上面这些信息,我们终于可以捣鼓出一个操作码DEBUG_OP的草稿了:TARGET(DEBUG_OP) {PyObject *value = NULL;PyObject *target = NULL;PyObject *res = NULL;PyObject **sp = NULL;PyObject *tmp;int i;_Py_IDENTIFIER(op_target);target = _PyDict_GetItemId(f->f_globals, &PyId_op_target);if (target == NULL && _PyErr_OCCURRED()) {if (!PyErr_ExceptionMatches(PyExc_KeyError))goto error;PyErr_Clear();DISPATCH();}value = PyList_New(0);Py_INCREF(target);for (i = 1 ; i <= STACK_LEVEL(); i++) {tmp = PEEK(i);if (tmp == NULL)tmp = Py_None;PyList_Append(value, tmp);}PUSH(target);PUSH(value);Py_INCREF(f);PUSH(f);sp = stack_pointer;res = call_function(&sp, 2);stack_pointer = sp;if (res == NULL)goto error;Py_DECREF(res);DISPATCH(www.aseer.cn);}在编写 CPython 实现的 C 代码方面我确实没有什么经验,有可能我漏掉了些细节。如果您有什么建议还请您纠正,我期待您的反馈。编译它,成了!一切看起来很顺利,但是当我们尝试去使用我们定义的操作码 DEBUG_OP 的时候却失败了。自从 2008 年之后,Python 使用预先写好的 goto(你也可以从 这里获取更多的讯息)。故,我们需要更新下 goto jump table,我们在 Python/opcode_targets.h 中做如下修改。/** From: Python/opcode_targets.h **//** Easy change since DEBUG_OP is the opcode number 1 **/static void *opcode_targets[256] = {//&&_unknown_opcode,&&TARGET_DEBUG_OP,&&TARGET_POP_TOP,/** ... **/这就完事了,我们现在就有了一个可以工作的新操作码。唯一的问题就是这货虽然存在,但是没有被人调用过。接下来,我们将DEBUG_OP注入到函数的字节码中。

6、在 Python 字节码中注入操作码 DEBUG_OP有很多方式可以在 Python 字节码中注入新的操作码: 使用 peephole optimizer, Quarkslab就是这么干的 在生成字节码的代码中动些手脚 在运行时直接修改函数的字节码(这就是我们将要干的事儿)为了创造出一个新操作码,有了上面的那一堆 C 代码就够了。现在让我们回到原点,开始理解奇怪甚至神奇的 Python!我们将要做的事儿有: 得到我们想要追踪函数的 code object 重写字节码来注入 DEBUG_OP 将新生成的 code object 替换回去

7、和 code object 有关的小贴士如果你从没听说过 code object,这里有一个简单的介绍网路上也有一些相关的文档可供查阅,可以直接 Ctrl+F 查找 code object还有一件事情需要注意的是在这篇文章所指的环境中 code object 是不可变的:Python 3.4.2 (default, Oct 8 2014, 10:45:20)[GCC 4.9.1] on linuxType "help", "copyright", "credits" or "license" for more information.>>> x = lambda y : 2>>> x.__code__<code object <lambda> at 0x7f481fd88390, file "<stdin>", line 1>>>> x.__code__.co_name'<lambda>'>>> x.__code__.co_name = 'truc'Traceback (most recent call last):File "<stdin>", line 1, in <module>AttributeError: readonly attribute>>> x.__code__.co_consts = ('truc',)Traceback (most recent call last):File "<stdin>", line 1, in <module>AttributeError: readonly attribute但是不用担心,我们将会找到方法绕过这个问题的。

8、使用的工具为了修改字节码我们需要一些工具: dis模块用来反编译和分析字节码 dis.BytecodePython 3.4新增的一个特性,对于反编译和分析字节码特别有用 一个能够简单修改 code object 的方法用 dis.Bytecode 反编译 code object 能告诉我们一些有关操作码、参数和上下文的信息。# Python3.4>>> import dis>>> f = lambda x: x + 3>>> for i in dis.Bytecode(f.__code__): print (i)...Instruction(opname='LOAD_FAST', opcode=124, arg=0, argval='x', argrepr='x', offset=0, starts_line=1, is_jump_target=False)Instruction(opname='LOAD_CONST', opcode=100, arg=1, argval=3, argrepr='3', offset=3, starts_line=None, is_jump_target=False)Instruction(opname='BINARY_ADD', opcode=23, arg=None, argval=None, argrepr='', offset=6, starts_line=None, is_jump_target=False)Instruction(opname='RETURN_VALUE', opcode=83, arg=None, argval=None, argrepr='', offset=7, starts_line=None, is_jump_target=False)为了能够修改 code object,我定义了一个很小的类用来复制 code object,同时能够按我们的需求修改相应的值,然后重新生成一个新的 code object。class MutableCodeObject(object):args_name = ("co_argcount", "co_kwonlyargcount", "co_nlocals", "co_stacksize", "co_flags", "co_code","co_consts", "co_names", "co_varnames", "co_filename", "co_name", "co_firstlineno","co_lnotab", "co_freevars", "co_cellvars")def __init__(self, initial_code):self.initial_code = initial_codefor attr_name in self.args_name:attr = getattr(self.initial_code, attr_name)if isinstance(attr, tuple):attr = list(attr)setattr(self, attr_name, attr)def get_code(self):args = []for attr_name in self.args_name:attr = getattr(self, attr_name)if isinstance(attr, list):attr = tuple(attr)args.append(attr)return self.initial_code.__class__(*args)这个类用起来很方便,解决了上面提到的 code object 不可变的问题。>>> x = lambda y : 2>>> m = MutableCodeObject(x.__code__)>>> m<new_code.MutableCodeObject object at 0x7f3f0ea546a0>>>> m.co_consts[None, 2]>>> m.co_consts[1] = '3'>>> m.co_name = 'truc'>>> m.get_code()<code object truc at 0x7f3f0ea2bc90, file "<stdin>", line 1>

9、测试我们的新操作码我们现在拥有了注入 DEBUG_OP 的所有工具,让我们来验证下我们的实现是否可用。我们将我们的操作码注入到一个最简单的函数中:from new_code import MutableCodeObjectdef op_target(*args):print("WOOT")print("op_target called with args <{0}>".format(args))def nop():passnew_nop_code = MutableCodeObject(nop.__code__)new_nop_code.co_code = b"\x00" + new_nop_code.co_code[0:3] + b"\x00" + new_nop_code.co_code[-1:]new_nop_code.co_stacksize += 3nop.__code__ = new_nop_code.get_code()import disdis.dis(nop)nop()# Don't forget that ./python is our custom Python implementing DEBUG_OPhakril@computer ~/python/CPython3.5 % ./python proof.py8 0 <0>1 LOAD_CONST 0 (None)4 <0>5 RETURN_VALUEWOOTop_target called with args <([], <frame object at 0x7fde9eaebdb0>)>WOOTop_target called with args <([None], <frame object at 0x7fde9eaebdb0>)>看起来它成功了!有一行代码需要说明一下 new_nop_code.co_stacksize += 3 co_stacksize 表示 code object 所需要的堆栈的大小 操作码DEBUG_OP往堆栈中增加了三项,所以我们需要为这些增加的项预留些空间现在我们可以将我们的操作码注入到每一个 Python 函数中了!

猜你喜欢