09月17, 2020

55. VM 是如何执行 ByteCode 的?(2)

终于,我们进入了 _PyEval_EvalFrameDefault 函数,这是 Python VM 最核心部分,在这里我们将于 VM 的执行零距离接触。

opcode 和 opargs

ByteCode 的最小单元是 word,类型是 uinit16_t,一个 16 位的无符号整数。Python 充分的利用这 16 位空间,在其中压缩了两类数据:

image.png

  • 操作码 opcode;
  • 和操作参数 oparg

各 8 位。通过下面两个宏快速获得两个部分。

#ifdef WORDS_BIGENDIAN
#  define _Py_OPCODE(word) ((word) >> 8)
#  define _Py_OPARG(word) ((word) & 255)
#else
#  define _Py_OPCODE(word) ((word) & 255)
#  define _Py_OPARG(word) ((word) >> 8)
#endif

ByteCode 是 VM 指令,实际情况下,直接存储指令名过于冗余,存储的是这些指令对应的编码,具体的编码定义在:

Include/opcode.h

Python 3.8 共计 122 个 opcode,或者说指令。

查找 print 函数

其实应该在开始之前对 Python 的 VM 模型有一定的了解,不过我觉得现在不用那么精确,有个大概影响就可以了,就边走边看吧。

我将 _PyEval_EvalFrameDefault 做了精简:

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag){
    // ··· ···

    // 获取 co_code 中的第一条指令
    first_instr = (_Py_CODEUNIT *) PyBytes_AS_STRING(co->co_code);
    next_instr = first_instr

    // ··· ···

    // 获得操作码
    opcode = _Py_OPCODE(*next_instr);

    // ··· ···
    // 获得操作参数
    oparg = _Py_OPARG(*next_instr)

    switch(opcode){
        // cases here
    }

随着 GDB 的指引,第一个 opcode 是 101,其定义是:

#define LOAD_NAME               101

我们看看这个 case 分支是如何处理的。LOAD_NAME 指令是加载一个名称到 VM 的运行栈上,根据 GDB 的提示,oparg 是 0, 首先从 names TUPLE 中取出索引为 0 的对象,一个 string: “print”,希望你还记得测试 py 文件里的代码,就是一行 print("hello world."), 显然这加载的是 print 函数。

Python 有作用域的概念,不同作用域可能有相同的 name,表示完全不同的对象,作用域的实现已经大致可以看出:

            // 首先查找 local 域
            PyObject *locals = f->f_locals;
            // ··· ···

            if (PyDict_CheckExact(locals)) {
                v = PyDict_GetItemWithError(locals, name);
                // ··· ···
            }
            else {
                v = PyObject_GetItem(locals, name);
                // ··· ···
            }

            // 否则
            if (v == NULL) {
                // 查找全局域
                v = PyDict_GetItemWithError(f->f_globals, name);
                // ··· ···
                else {
                    // 查找 buildin 域
                    if (PyDict_CheckExact(f->f_builtins)) {
                        v = PyDict_GetItemWithError(f->f_builtins, name);
                        // ··· ···
                    }
                    // ··· ···
                }
            }

LOAD_NAME 操作码的处理过程表明, print 函数的查找,按照下图的先后顺序执行:

image.png

我们知道 print 函数是 Python 的内建对象,实际上它最终也是在 buildin 中被查找到。

压栈

Python VM 是栈式虚拟机,也就是计算过程伴随着一系列的入栈、出栈操作,除此之外有比较流行的寄存器式虚拟机。栈式虚拟机的优势是实现简单,但相比寄存器式虚拟机的字节码会更加冗余,寄存器式虚拟机则相反,早起 Java 采用的是栈式虚拟机,后来改为寄存器式,另外最快的脚本语言 Lua 采用的也是寄存器式虚拟机。这都是题外话,暂时按下不表。

查找到 print 函数的目的,是为了将其压入 VM 的运行栈中,以便稍后继续压入它的参数:一个 string object “hello world.”, 然后等待被调用。

使用下面的宏,将 print 函数对象压入 VM 运行栈:

// v 是前面找到的 print 函数对象
PUSH(v);

此时 VM 运行栈看起来像这样:

image.png

本文链接:http://www.thinkinpython.com/post/deep_python_vm_55.html

-- EOF --