美文网首页IT狗工作室
第4篇:通过gdb走进CPython的内核

第4篇:通过gdb走进CPython的内核

作者: 铁甲万能狗 | 来源:发表于2020-07-03 21:51 被阅读0次

    我们了解一些CPython一些内部机制,本篇会以gdb为例开始切入CPython的执行流程,我们现在用一个简单的示例开始吧,当你下载源代码后并在安装必要的C编译器和系统库后,在通过下面的命令编译源代码

    ./configure --with-pydebug
    make -j3 -s
    

    我们会得到一个带有调试模式的python解释器


    我们通过在源代码的目录下,执行如下命令,一定要添加“./”,以指示shell执行当前目录下的python解释器
    gdb ./python
    

    然后在gdb环境下执行run命令就已经进入可调试的python解释器环境,如下图所示

    我们在带调试模式的python交互命令行中,定义一个简单的函数

    >>> def num(j):  
    ...     m=int(22)
    ...     return j*m
    ... 
    >>> 
    

    导入反编译模块dis,并通过dis模块的dis函数查看,num函数对应的操作码。如下图所示。


    我们知道从LOAD_GLOBAL到STORE_FAST对应的是函数中m=int(22)这条python语句,这里需要说明的是字节码文件中的每个操作码(opcode)的具体行为定义在源代码Python/ceval.c文件中的_PyEval_EvalFrameDefault函数内部。该函数内部有一个python解释主循环(具体源码定义请参考这里),该主循环的具体定义如下代码轮廓,那么我们就知道在主循环内部的巨型switch控制结构中,任一个case分支的C代码定义就对应某个操作码。
    _PyEval_EvalFrameDefault(....){
        ......
    //解释器主循环
    mainloop:
      for (;;) {
          ....
          switch (opcode) {
              case TARGET(操作码1):{
                ....
              }
              case TARGET(操作码2){
                ....
              }
              ....
             case TARGET(操作码2){
                ....
              }
          }
      }
    }
    

    我们回归正题,现在我们如何为python解释器底层的C代码设置断点呢?因为目前调试模式的python解释器运行在内存中,在设置断点前,我们需要对当前python进程发送一个挂起信号。操作步骤很简单。只需在开另一个shell命令行窗口,并执行

    pkill python -SIGTRAP
    

    那么上一个打开python进程的shell命令指示符会接受到一个SIGTRAP信号,该python进程会暂时被挂起

    我们这里需要知道num函数内m=int(22)语句对应在CPython内部的操作流程。那么接下来只需在Python/ceval.c源码文件中找到该语句对应的操作码的C代码定义并执行断点即可。然则对应的操作码的C代码定义,我们只举其中一个操作码定义的断点过程。

    首先、使用你喜欢的编辑器在Python/ceval.c源码文件以LOAD_GLOBAL为关键字进行查找,我们知道该操作码的case分支位于第2570行,如下图所示

    那么,在python进程挂起的所在shell窗口,执行所有相关操作码定义的断点命令,如下图所示

    设定所有断点后,我们在gdb中执行continue命令,此时会重回python命令行的交互环境,并且python解析器自动执行到底层的C代码的第一个断点。

    那么,剩下的问题就是我们通过gdb的next指令或step指令来单步执行断电之后的每条C代码,但我这里更希望知道当前python进程执行到第一个断点时的整个python进程的函数栈状态。那么我们执行bt指令,会得到一个非常详细函数栈上下文


    正如我所料,我们查看到当Python解释器执行到第一个断点时,目前整个函数栈从栈底的第一个main函数到栈顶的_PyEval_EvalFrameDefault函数,一个存在16个栈帧,这样你当然能够对每个被执行的操作码它目前的栈状态一目了然,不至于迷失阅读C代码的方向。

    需要注意的是我们这里目前还没有在Python交互环境中手动执行num函数,也即自调用continue命令后,CPython主循环驱动下执行断点的位置所产生函数栈帧状态跟我们num函数封装的操作码指令毫无关系。此时,需要做的是使用next指令条件跳过这些断点,直到从gdb命令行指示符(gdb)返回到python命令行指示符>>>

    在第一个python命令指示符>>>出现后,此时我们可以手动执行num函数,如下图,随便传入一个整数,即num(32)后,回车,看看C代码的执行

    在分析函数执行流程,不妨回顾一下num函数对应的代码对象细节

    >>> 
    >>> def num(j):
    ...     m=int(22)
    ...     return j*m
    ... 
    >>> num.__code__.co_name    #函数名
    'num'
    >>> num.__code__.co_names  #函数内Python关键字标签
    ('int',)
    >>> num.__code__.co_consts  #函数内的所有常量
    (None, 22)
    >>> num.__code__.co_varnames #函数内的变量名标签
    ('j', 'm')
    

    函数中第一条语,m=int(22),我们对其每个C底层的操作码定义深入的剖析

    首先、LOAD_GLOBAL 0(int),我们知道0是一个字节码参数,而括号中的int是一个Python关键字,实际上就是num.__code __.co_names元祖中的一个字符串常量,我们称为关键字标签.

    LOAD_GLOBAL 0(int)就等价于下面简化后的C代码逻辑,下面代码省略了很多检测逻辑,保留了一些关键的执行流程和关键变量。

    • 参数f是一个PyFrameObject指针类型,它指向堆内存的一个PyFrameObject的结构体,PyFrameObject下文再说。
    • names是一个PyObject类型的指针,它指向了堆内存中由一个或多个Python语义的关键字标签组成的元祖,对于C来说实际上是一些特定的字符串聚合而成的数组,例如这里是“int”字符串是其中的一个元素。
    • consts是一个PyObject类型的指针,它指向堆内存中由一个或多个C常量(数字或字符串)组成的元祖,对于C来说,通常组成该元祖的元素是一个右值。
    PyObject* _Py_HOT_FUNCTION
    _PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag){
        ...
        PyObject *names;
        PyObject *consts;
        _PyOpcache *co_opcache;
        ......
        //当前字节码
        int opcode; 
        //当前字节码参数
        int oparg;        
        ....
        //代码对象
        co = f->f_code;
        //从代码对象中获取num函数内部Python关键字标签
        names = co->co_names;
        //从代码对象获取num函数内部的常量集
        consts = co->co_consts;
    //解释器主循环
    mainloop:
      for (;;) {
          ....
          switch (opcode) {
            ....
             case TARGET(LOAD_GLOBAL): {
              
                PyObject *name; 
                PyObject *v;
                if (PyDict_CheckExact(f->f_globals)
                    && PyDict_CheckExact(f->f_builtins))
                {
                    ....
                    //获取关键字标签,这里是int
                    
                    name = GETITEM(names, oparg);
                    v = _PyDict_LoadGlobal((PyDictObject *)f->f_globals,
                                           (PyDictObject *)f->f_builtins,
                                           name);
                     ...
                    Py_INCREF(v);
                }
                else {
                    /* namespace 1: globals */
                    name = GETITEM(names, oparg);
                    v = PyObject_GetItem(f->f_globals, name);
                ......
                PUSH(v);
                DISPATCH();
            }
            ....
      }
    }       
    

    主要关注的的关键点是__PyEval_EvalFrameDefault参数的的第二个参数是一个PyFrameObject类型的对象,而PyFrameObject是一个实现Python语义栈对象

    好了,我没必要说太多,本篇是假定你有相当的C编程经验,少废话,读者自己去尝试吧。

    更新中.....

    相关文章

      网友评论

        本文标题:第4篇:通过gdb走进CPython的内核

        本文链接:https://www.haomeiwen.com/subject/reesqktx.html