原来虚拟机是一颗软件CPU

上一小节,我们研究了 Python 程序的编译过程,以及编译产物 —— 代码对象 。 编译器 依照语法规则对源码进行 作用域 划分,并以此为单位编译源码,最终为每个作用域生成一个代码对象。代码对象则保存了 字节码 ,以及相关 名字 、 常量 等静态上下文信息。

编译器 将源码 编译 成代码对象后,便将接力棒传给 虚拟机 ,由虚拟机负责 执行 。那么, Python 虚拟机如何解析并执行字节码指令呢?与语法作用域相对应的运行时 名字空间 ,在虚拟机中又是如何动态维护的呢?带着这些疑问,我们开始本节关于 虚拟机 以及 字节码 执行过程的探索。

PyFrameObject

由于代码对象是静态的, Python 虚拟机在执行代码对象时,需要由一个辅助对象来维护执行上下文。那么,这个执行上下文需要包含什么信息呢?我们先根据已经掌握的知识大开脑洞猜测一下:

首先,我们需要一个动态容器,来存储代码对象作用域中的名字,这也就是 局部名字空间 ( Locals )。同理,上下文信息还需要记录 全局名字空间 ( Globals )以及 内建名字空间 ( Builtins )的具体位置,确保相关名字查找顺畅。

其次,虚拟机需要保存当前执行字节码指令的编号,就像 CPU 需要一个寄存器( IP )保存当前执行指令位置一样。

因此,执行上下文理论上至少要包括以下这两方面信息:

  • 名字空间
  • 当前字节码位置

接下来,我们请出执行上下文的真身—— 栈帧对象  PyFrameObject ,看看我们有没有猜对。 PyFrameObject 在头文件 Include/frameobject.h 中定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
typedef 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 */
    /* Next free slot in f_valuestack.  Frame creation sets to f_valuestack.
       Frame evaluation usually NULLs it, but a frame that yields sets it
       to the current stack top. */
    PyObject **f_stacktop;
    PyObject *f_trace;          /* Trace function */
    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 */
    /* Call PyFrame_GetLineNumber() instead of reading this field
       directly.  As of 2.3 f_lineno is only valid when tracing is
       active (i.e. when f_trace is set).  At other times we use
       PyCode_Addr2Line to calculate the line from the current
       bytecode index. */
    int f_lineno;               /* Current line number */
    int f_iblock;               /* index in f_blockstack */
    char f_executing;           /* whether the frame is still executing */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
    PyObject *f_localsplus[1];  /* locals+stack, dynamically sized */
} PyFrameObject;

哇,这么多字段!虽然代码很多,但目前我们需要研究的只有以下这些:

属性 描述
f_back 前一个栈帧对象,也就是调用者
f_builtins 内建名字空间
f_code 代码对象
f_globals 全局名字空间
f_lasti 上条已执行字节码指令编号
f_lineno 源码文件行数
f_locals 局部名字空间
f_localsplus 静态存储的局部名字空间和临时栈

看上去,关键字段我们基本都猜对了。至此,我们搞清楚了栈帧对象的结构以及在运行时所起的作用:

其中, f_code 字段保存了当前执行的代码对象,最核心的字节码就在代码对象中。而 f_lasti 字段则保存着上条已执行字节码的编号。虚拟机内部用一个 C 局部变量 next_instr 维护下条字节码的位置,并据此加载下一条待执行的字节码指令,原理跟 CPU 的 指令指针 寄存器( %rip )一样。

另外,注意到 f_back 字段指向前一个栈帧对象,也就是 调用者 的栈帧对象。这样一来,栈帧对象按照 调用关系 串成一个 调用链 !这不是跟 x86 CPU 栈帧布局如出一辙吗?我们先花点时间回顾一下 x86 CPU 栈帧布局与函数调用之间的关系:

x86 体系处理器通过栈维护调用关系,每次函数调用时在栈上分配一个帧用于保存调用上下文以及临时存储。 CPU 中有两个关键寄存器, %rsp 指向当前栈顶,而 %rbp 则指向当前栈帧。每次调用函数时, 调用者 ( Caller )负责准备参数、保存返回地址,并跳转到被调用函数代码;作为 被调用者 ( Callee ),函数先将当前 %rbp 寄存器压入栈(保存调用者栈帧位置),并将 %rbp 设置为当前栈顶(保存当前新栈帧位置)。由此, %rbp 寄存器与每个栈帧中保存的调用者栈帧地址一起完美地维护了函数调用关系链。

现在,我们回过头来继续考察 Python 栈帧对象链以及函数调用之前的关系。请看下面这个例子( demo.py ):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
pi = 3.14

def square(r):
    return r ** 2

def circle_area(r):
    return pi * square(r)

def main():
    print(circle_area(5))
    
if __name__ == '__main__':
    main()

Python 开始执行这个程序时,虚拟机先创建一个栈帧对象,用于执行模块代码对象:

当虚拟机执行到模块代码第 13 行时,发生了函数调用。这时,虚拟机新建一个栈帧对,并开始执行函数 main 的代码对象:

随着函数调用逐层深入,当调用 square 函数时,调用链达到最长:

当函数调用完毕后,虚拟机通过 f_back 字段找到前一个栈帧对象并回到调用者代码中继续执行。

栈帧获取

栈帧对象 PyFrameObject 中保存着 Python 运行时信息,在底层执行流控制以及程序调试中非常有用。那么,在 Python 代码层面,有没有办法获得栈帧对象呢?答案是肯定的。调用标准库 sys 模块中的 _getframe 函数,即可获得当前栈帧对象:

1
2
3
4
5
6
>>> import sys
>>> frame = sys._getframe()
>>> frame
<frame at 0x10e3706a8, file '<stdin>', line 1, code <module>>
>>> dir(frame)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'clear', 'f_back', 'f_builtins', 'f_code', 'f_globals', 'f_lasti', 'f_lineno', 'f_locals', 'f_trace', 'f_trace_lines', 'f_trace_opcodes']

获取栈帧对象后,有什么作用呢?举个例子,我们可以顺着 f_back 字段将调用关系和相关运行时信息打印出来:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import sys

pi = 3.14

def square(r):
    frame = sys._getframe()
    while frame:
        print('#', frame.f_code.co_name)
        print('Locals:', list(frame.f_locals.keys()))
        print('Globals:', list(frame.f_globals.keys()))
        print()

        frame = frame.f_back

    return r ** 2

def circle_area(r):
    return pi * square(r)

def main():
    print(circle_area(5))

if __name__ == '__main__':
    main()

例子程序在 square 函数中获取栈帧对象,然后逐层输出函数名以及对应的局部名字空间以及全局名字空间。程序执行后,你将看到这样的输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# square
Locals: ['r', 'frame']
Globals: ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', '__file__', '__cached__', 'sys', 'pi', 'square', 'circle_area', 'main']

# circle_area
Locals: ['r']
Globals: ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', '__file__', '__cached__', 'sys', 'pi', 'square', 'circle_area', 'main']

# main
Locals: []
Globals: ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', '__file__', '__cached__', 'sys', 'pi', 'square', 'circle_area', 'main']

# <module>
Locals: ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', '__file__', '__cached__', 'sys', 'pi', 'square', 'circle_area', 'main']
Globals: ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', '__file__', '__cached__', 'sys', 'pi', 'square', 'circle_area', 'main']

78.5

栈帧获取面试题

如果面试官问你,能不能写个函数实现 sys._getframe 一样的功能,你能应付吗?想知道具体的实现代码,请猛戳“阅读原文”,获取更多详情!

【Python源码剖析】系列文章首发于公众号【小菜学编程】,敬请关注:

【Python源码剖析】系列文章首发于公众号【小菜学编程】,敬请关注: