Python frameobject 栈帧对象


frameobject

python虚拟机编译文件后,PyCodeObject存储了程序所有的静态信息和字节码指令,但由于python虚拟机实际上在模拟操作系统执行可执行程序的过程,但在PyCodeObject中不包括程序执行环境等信息,所以python的执行操作实际上不在PyCodeObject,而在PyFrameObject对象上。

模拟CPU栈帧

栈帧

首先查看x64栈帧。

我们通过观察以下c语言代码来看栈帧。

#include "stdio.h"

int f(int a, int b){
    return a+b;
}

int main(){
    int c = 0;
    f(1, 2);
}
  • 当程序执行到函数f时,当前帧为函数f的栈帧,调用者的帧是函数main的栈帧。每调用一个函数,程序都会创建一个栈帧,每个函数都在自己的栈帧中活动,在自己的栈帧中完成对局部变量的操作。
  • 运行时栈的地址空间通常从高地址向低地址延时,当函数main调用函数f时,系统就会在main函数的栈帧后创建一个新的栈帧,main函数的栈帧地址也会被保存。
  • c语言汇编后结果如下,我们从标号main:开始看。

        .file    "test.c"
        .text
        .globl    f
        .type    f, @function
    f:
    .LFB0:
        .cfi_startproc
        pushq    %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movl    %edi, -4(%rbp)
        movl    %esi, -8(%rbp)
        movl    -8(%rbp), %eax
        movl    -4(%rbp), %edx
        addl    %edx, %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
    .LFE0:
        .size    f, .-f
        .globl    main
        .type    main, @function
    main:
    .LFB1:
        .cfi_startproc
        pushq    %rbp  ;rbp是指向当前栈桢底部的基指针,rsp是指向当前栈桢顶部的堆栈指针。
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6 
        subq    $16, %rsp
        movl    $0, -4(%rbp)  ;寄存器的寻址操作来的,括号外面允许提供一个偏移量,即访问指针指向内存,可加适当的偏移量,无需改变指针值
        movl    $2, %esi
        movl    $1, %edi
        call    f
        leave
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
    .LFE1:
        .size    main, .-main
        .ident    "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
        .section    .note.GNU-stack,"",@progbits
    • .cfi_开头的汇编指示符用来告诉汇编器生成相应的DWARF调试信息,主要是和函数有关。
    • pushq %rbp:main函数一定也是被某个函数调用的,此代码保存了调用main的函数的栈桢底部的地址。
    • movq %rsp, %rbp:跟新当前栈桢指向顶部的堆栈指针。
    • subq $16, %rsp:减法运算,使rsp指针向下偏移16字节,变量c占4字节,call会压入cs:ip的地址,占8字节,pushq %rbp会占4字节,共4+12+4=16字节。
    • movl $0, -4(%rbp):将c变量放入内存中。
    • movl $2, %esimovl $1, %edi:常数送入寄存器。
    • call f:调用函数f,压入cs:ip地址。
    • pushq %rbp:保存了main函数的栈桢底部的地址。
    • movq %rsp, %rbp:寄存器rsp的值移入rbp,跟新当前栈桢指向顶部的堆栈指针,在逻辑上,新的栈帧已经创建了。
    • movl %edi, -4(%rbp)movl %esi, -8(%rbp)movl -8(%rbp), %eaxmovl -4(%rbp), %edxaddl %edx, %eax:把数据放入内存,然后又放入寄存器,执行加法运算,结果存储在%eax中。
    • popq %rbp:弹出栈值,放入rbp中,准备返回到main函数中。
    • ret:返回,弹出栈值,放入cs:ip中。
    • leave:等效于 movl %ebp, %esppopl %ebp,先恢复原栈顶指针,然后再根据栈顶指针恢复原栈帧的ebp。
    • ret:返回指令。

PyFrameObject

PyCodeObject包含了程序的关键信息字节码及静态信息,但PyCodeObject不可能包含程序的动态信息,即python的执行环境信息。在以下,代码中,print(num)的字节码一定是一样的,但会产生不同的结果,这正是由于两个print所在执行环境不同造成的。

num = 0
def func():
    num = 1
    print(num)
func()
print(num)

以下可以看出,Python栈帧包含了比x64栈帧更多的信息

struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;      /* previous frame, or NULL */
    PyCodeObject *f_code;       /* code segment */
    PyObject *f_builtins;       /* builtin symbol table (PyDictObject) */
    PyObject *f_globals;        /* global symbol table (PyDictObject) */
    PyObject *f_locals;         /* local symbol table (any mapping) */
    PyObject **f_valuestack;    /* points after the last local */
    PyObject *f_trace;          /* Trace function */
    int f_stackdepth;           /* Depth of value stack */
    char f_trace_lines;         /* Emit per-line trace events? */
    char f_trace_opcodes;       /* Emit per-opcode trace events? */
    /* Borrowed reference to a generator, or NULL */
    PyObject *f_gen;
    int f_lasti;                /* Last instruction if called */
    int f_lineno;               /* Current line number. Only valid if non-zero */
    int f_iblock;               /* index in f_blockstack */
    PyFrameState f_state;       /* What state the frame is in */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
    PyObject *f_localsplus[1];  /* locals+stack, dynamically sized */
};
  • struct _frame *f_back;:在python的执行过程中,会形成很多个栈帧,这些栈帧以链表的方式被链接起来。在x64上,栈帧间通过rsp,rbp指针联系起来,而python正是通过f_back模拟了这一过程。
  • PyCodeObject *f_code;:每一个python frame对应了一个PyCodeObject,在编译PyCodeObject时会计算co_stacksize的大小,即执行字段代码的栈空间大小,由于栈空间不固定,所以PyFrameObject使用变长对象头部。
  • PyObject *f_builtins;:维护内建变量的键值对关系。
  • PyObject *f_globals;:维护全局变量的键值对关系。
  • PyObject *f_locals;:维护局部变量的键值对关系。
  • 在python中可以使用sys._getframe()获取当前函数的栈帧对象。

    >>> import sys
    >>>
    >>>
    >>> def func0():
    ...     func0_var0 = 0
    ...     func0_var1 = 1
    ...     return func1()
    ...
    >>>
    >>> def func1():
    ...     func1_var0 = 10
    ...     func1_var1 = 11
    ...     return func2()
    ...
    >>>
    >>> def func2():
    ...     func2_var0 = 20
    ...     func2_var1 = 21
    ...     return sys._getframe()
    ...
    >>> f = func0()
    >>>
    >>> print(f)
    <frame at 0x000001E6EBF9F650, file '<stdin>', line 4, code func2>
    >>> print(f.f_back)
    <frame at 0x000001E6EBF9F4A0, file '<stdin>', line 4, code func1>
    >>> print(f.f_back.f_back)
    <frame at 0x000001E6EBF9F2F0, file '<stdin>', line 4, code func0>
    >>>
    >>> print(f.f_locals)
    {'func2_var0': 20, 'func2_var1': 21}
    >>> print(f.f_back.f_locals)
    {'func1_var0': 10, 'func1_var1': 11}
    >>> print(f.f_back.f_back.f_locals)
    {'func0_var0': 0, 'func0_var1': 1}
    >>>

    _PyEval_EvalFrameDefault

    在ceval.c中使用_PyEval_EvalFrameDefault函数执行栈帧。该函数约3000行,但大体逻辑简单,使用for(;;)循环,判断指令码,执行指令码。

    PyObject* _Py_HOT_FUNCTION
    _PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
    {   
      ...
      ...   
      PyObject **stack_pointer;  /* Next free slot in value stack */
      const _Py_CODEUNIT *next_instr;
      int opcode;        /* Current opcode */
      int oparg;         /* Current opcode argument, if any */
      PyObject **fastlocals, **freevars;
      PyObject *retval = NULL;            /* Return value */
      _Py_atomic_int * const eval_breaker = &tstate->interp->ceval.eval_breaker;
      PyCodeObject *co;
    
      const _Py_CODEUNIT *first_instr;
      PyObject *names;
      PyObject *consts;
      _PyOpcache *co_opcache;
      ...
      ...
      
      // 逐条取出字节码来执行
       for (;;) {
          assert(stack_pointer >= f->f_valuestack);
          assert(STACK_LEVEL() <= co->co_stacksize);
          assert(!_PyErr_Occurred(tstate));
          if (_Py_atomic_load_relaxed(eval_breaker)) {
              opcode = _Py_OPCODE(*next_instr);
              ...
          }
          ...
          ...
          //判断该指令属于什么操作,然后执行相应的逻辑
          switch (opcode) {
              // 加载常量
              case LOAD_CONST:
                  ...
                  break;
              // 加载名字
              case LOAD_NAME:
                  ...
                  break;
                  ...
          }
      }
    }
    
  • PyCodeObject对象里面的co_code域则保存着字节码指令和字节码指令参数,python执行字节码指令序列的过程就是从头到尾遍历整个co_code、依次执行字节码指令的过程。co_code在实质上是一个PyBytesObject,底层维护着一个char数组,在Include/opcode.h中定义了所有的字节码指令,python同时使用以下三个变量维护字节码遍历的过程。

    • first_instr:永远指向字节码指令序列的开始位置
    • next_instr:永远指向下一条待执行的字节码指令的位置
    • f_lasti:指向上一条已经执行过的字节码指令的位置,f_lasti在PyCodeObject中
  • Python的字节码有的是带有参数的,有的是没有参数的,而判断字节码是否带有参数是通过HAS_AGR这个宏来实现的。对于不同的字节码指令,由于存在是否需要指令参数的区别,所以next_instr的位移可以是不同的,但无论如何,next_instr总是指向python下一条要执行的字节码。
  • Python在获得了一条字节码指令和其需要的参数指令之后,会对字节码进行switch判断,根据判断的结果选择不同的case语句,每一条指令都会对应一个case语句。在case语句中,就是Python对字节码指令的实现。因为指令有121个,所以这个switch语句非常的长,比如:LOAD_CONST、LOAD_NAME、YIELD_FROM等等,而每一个指令都要对应一个case语句。

声明:Hello World|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA协议进行授权

转载:转载请注明原文链接 - Python frameobject 栈帧对象


我的朋友,理论是灰色的,而生活之树是常青的!