深入源码,进一步考察字节码的执行流程

文摘   2024-10-21 10:13   北京  

楔子


上一篇文章我们介绍了虚拟机是怎么执行字节码的,并且还介绍了运行时栈,以及运行时栈的一些 API。相信你对字节码执行的整个流程应该有了清晰的认识,那么接下来我们就深入到源码中,进一步考察执行过程。



源码解析字节码的执行过程


之前说了,虚拟机就是把自己当成一个 CPU,在栈帧中执行字节码。面对不同的字节码指令,执行不同的处理逻辑。

具体实现由 Python/ceval.c 中的 _PyEval_EvalFrameDefault 函数负责,该函数比较长,并且存在很多的宏,我们会进行适当的简化。

_PyEval_EvalFrameDefault 函数的上方定义了一个宏,这里需要解释一下。对于 CPython 而言,一个 Python 函数调用在底层会涉及多个 C 函数调用,而 C 函数在调用时显然也要创建栈帧。

所以整个过程存在两个调用栈,一个是 Python 调用栈,另一个是 C 调用栈。而调用一个 Python 函数,底层的 C 调用栈会消耗 3 个单元。因此不难发现,随着 Python 调用栈在增长的同时,C 调用栈也在增长。

为此 CPython 引入了一个优化,对于递归函数而言,当执行新的递归调用时,C 调用栈不会再增长。换句话说,不会再次调用 _PyEval_EvalFrameDefault 函数,而是通过改变上下文,在同一个 _PyEval_EvalFrameDefault 里面重新开始。

好啦,下面我们就来分析一下这个长得跟巨无霸一样的函数,当然在 3.12 里面它已经不像之前那么大了,因为有一部分代码被拆分出来了

// Python/ceval.c
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, 
_PyInterpreterFrame *frame, 
int throwflag)
{   
// 检测线程状态对象是否不为 NULL
// 如果为 NULL,说明 GIL 被释放,而这是不允许的
// 字节码指令必须在当前线程持有 GIL 的情况下执行
    _Py_EnsureTstateNotNULL(tstate);
    
// _PyEval_EvalFrameDefault 的调用次数加 1
    CALL_STAT_INC(pyeval_calls);

// 如果使用 "计算跳转",导入静态跳转表
#if USE_COMPUTED_GOTOS
#include "opcode_targets.h"
#endif

    
// ...
}

由于该函数的代码量较大,并且很多内容都需要花费一定笔墨去解释,所以为了更加清晰,我们分段讲解。先看上面这段代码,里面出现了计算跳转需要解释一下它是什么意思。

_PyEval_EvalFrameDefault(后续简称为帧评估函数)虽然很复杂,但它核心不难理解,就是循环遍历字节码指令集,处理每一条指令。而当一条指令执行完毕时,虚拟机会有以下三种动作之一:

  • 停止循环、退出帧评估函数,当执行的指令为 RETURN_VALUE、YIELD_VALUE 等。

  • 指令执行过程中出错了,比如执行 GET_ITER 指令,但对象不具备可迭代的性质。执行出错也要退出帧评估函数,然后执行异常处理逻辑(或者直接抛出异常)。

  • 进入下一轮循环,执行下一条指令。


前面两种动作没啥好说的,关键是第三种,如何执行下一条指令。首先虚拟机内部有一个巨型的 switch 语句,伪代码如下:

for (;;) {
    // 循环遍历指令集,获取指令和指令参数
    uint8_t opcode = ...;  // 指令
    int oparg = ...;  // 指令参数
    // 执行对应的处理逻辑
    switch (opcode) {
        case LOAD_CONST:
            处理逻辑;
        case LOAD_FAST:
            处理逻辑;
        case LOAD_FAST:
            处理逻辑;
        case BUILD_LIST: 
            处理逻辑;
        case DICT_UPDATE: 
            处理逻辑;
        // ...
    }
}

一个 case 分支,对应一个字节码指令的实现,由于指令非常多,所以这个 switch 语句也非常庞大。然后遍历出的指令,会进入这个 switch 语句进行匹配,执行相应的处理逻辑。

所以循环遍历 co_code 得到字节码指令,然后交给内部的 switch 语句、执行匹配到的 case 分支,如此周而复始,最终完成了对整个 Python 程序的执行。

其实到这里,你应该已经了解了帧评估函数的整体结构。说白了就是将自己当成一个 CPU,在栈帧中执行一条条指令,而执行过程中所依赖的常量、变量等,则由栈帧的其它字段来维护。因此在虚拟机的执行流程进入了那个巨大的 for 循环,并取出第一条字节码指令交给里面的 switch 语句之后,第一张多米诺骨牌就已经被推倒,命运不可阻挡的降临了。一条接一条的指令如同潮水般涌来,浩浩荡荡,横无涯。

虽然在概念上很好理解,但很多细节被忽略掉了,本篇文章就将它们深挖出来。还是之前的问题,当一个指令执行完毕时,怎么执行下一条指令。

估计有人对这个问题感到奇怪,在 case 分支的内部加一行 continue 进行下一轮循环不就行了吗?没错,这种做法是行得通的,但存在性能问题。因为 continue 会跳转到 for 循环所在位置,所以遍历出下一条指令之后,会再次进入 switch 语句进行匹配。尽管逻辑上是正确的,但 switch 里面有数百个 case 分支,如果每来一个指令,都要顺序匹配一遍的话,那么效率必然不高。

而事实上整个字节码指令集是已知的,所以不管执行哪个指令,我们都可以提前得知它的下一个指令,只需将指针向后偏移两个字节即可。

那么问题来了,既然知道下一条要执行的指令是什么,那么在当前指令执行完毕时,可不可以直接跳转到下一条指令对应的 case 分支中呢?

答案是可以的,这个过程就叫做计算跳转,通过标签作为值即可实现。关于什么是标签作为值,我们用一段 C 代码解释一下。

#include <stdio.h>

void label_as_value(int jump) {
    int num = 4;
    void *label;
    switch (num) {
        case 1:
            printf("%d\n"1);
            break;
        // 在 case 2 分支里面定义了一个标签叫 two
        case 2: two: {
            printf("%d\n"2);
            break;
        }
        // 在 case 3 分支里面定义了一个标签叫 three
        case 3: three: {
            printf("%d\n"3);
            break;
        }
        case 4:
            printf("%d\n"4);
            // 如果参数 jump 等于 2,保存 two 标签的地址
            // 如果参数 jump 等于 3,保存 three 标签的地址
            if (jump == 2) label = &&two;
            else if (jump == 3) label = &&three;
            // 跳转到指定标签
            goto *label;
        default:
            break;
    }
}

int main() {
    label_as_value(2);
    // 4
    // 2
    label_as_value(3);
    // 4
    // 3
}

由于变量 num 等于 4,所以会进入 case 4 分支,在里面有一个 goto *label。如果你对 C 不是特别熟悉的话,估计会有些奇怪,觉得不应该是 goto label 吗?如果是 goto label,那么需要显式地定义一个名为 label 的标签,但这里并没有。

我们的目的是跳转到 two 标签或 three 标签,具体跳转到哪一个,则由参数控制。因此可以使用 && 运算符,这是 GCC 的一个扩展特性,叫做标签作为值,它允许我们获取标签的地址作为一个值。

所以在开头声明了一个 void *label,然后让 label 保存标签地址,再通过 goto *label 跳转到指定标签。由于 *label 代表哪个标签是在运行时经过计算才能知晓,因此称为计算跳转(在运行时动态决定跳转目标)

注意:goto *&&标签名 属于高级的、非标准的 C 语言用法。

回到源码中来,在 Python/generated_cases.c.h 文件里面,我们可以看到标签的定义。

这个文件里面定义的便是每个指令的处理逻辑,总共 4800 行,之前内嵌在帧评估函数里面,现在单独拆出来了。执行帧评估函数时,直接 #include 进来即可。

另外我们看到每个指令都调用了 TARGET,显然这是一个宏,看一下它长什么样子。

// Python/ceval_macros.h
#define INSTRUCTION_START(op) (frame->prev_instr = next_instr++)

#if USE_COMPUTED_GOTOS
#  define TARGET(op) TARGET_##op: INSTRUCTION_START(op);
#  define DISPATCH_GOTO() goto *opcode_targets[opcode]
#else
#  define TARGET(op) case op: TARGET_##op: INSTRUCTION_START(op);
#  define DISPATCH_GOTO() goto dispatch_opcode
#endif

如果将宏展开的话,那么帧评估函数里面的 switch 语句等价于如下。

如果不使用计算跳转,那么展开之后就是一个 switch 语句,里面是每个指令的处理逻辑。而在逻辑的最后,会调用一个 DISPATCHER() 或 DISPATCH_GOTO()。

// Python/ceval_macros.h

// 会依次调用 3 个宏,中间第二个宏可以忽略掉
#define DISPATCH() \
    { \
        NEXTOPARG(); \
        PRE_DISPATCH_GOTO(); \
        DISPATCH_GOTO(); \
    }


/*
typedef union {
    uint16_t cache;
    struct {
        uint8_t code;
        uint8_t arg;
    } op;
} _Py_CODEUNIT;
*/

// next_instr 指向下一条待执行的指令,prev_instr 指向最近一条执行完毕的指令
// 所以 prev_instr 的下一条指令就是 next_instr
// 由于在处理指令时,先执行了 frame->prev_instr = next_instr++;
// 所以调用 NEXTOPARG() 时,next_instr 已经指向了新的待执行的字节码指令
// 这里获取指令和指令参数
#define NEXTOPARG()  do { \
        _Py_CODEUNIT word = *next_instr; \
        opcode = word.op.code; \
        oparg = word.op.arg; \
    } while (0)


// 跳转
#if USE_COMPUTED_GOTOS
#  define DISPATCH_GOTO() goto *opcode_targets[opcode]
#else
#  define DISPATCH_GOTO() goto dispatch_opcode
#endif

因此每个指令在执行完毕后,会调用 DISPATCHER_GOTO(),如果没使用计算跳转,那么会直接 goto 到 dispatch_opcode 标签,然后进入 switch。

所以在 CPython 3.12 版本中,switch 外层的 for 循环已经没有了,使用的是 goto,在获取下一条待执行的指令和参数之后,直接 goto 到 switch 语句所在的标签(dispatch_opcode)。当然这和 for 循环本质上没太大差异,因为在获取到新的指令时,都要重新走一遍 switch。

以上是不使用计算跳转,如果使用计算跳转,从图中可以看到是没有 switch 语句的,当然 TARGET 展开之后也不会出现 case 分支。以 TARGET(LOAD_FAST) 为例:

  • 不使用计算跳转,展开之后是 case LOAD_FAST: TARGET_LOAD_FAST:

  • 使用计算跳转,展开之后是 TARGET_LOAD_FAST:


可以看到使用计算跳转之后,留下的只是一堆标签,当然此时有没有 switch 已经无所谓了,重点是 DISPATCHER_GOTO() 之后可以直接跳转到指定位置,不需要挨个比较了。而根据宏定义,最后会 goto 到 *opcode_targets[opcode],这个 opcode_targets 便是一开始导入的静态跳转表。

它定义在 Python/opcode_targets.h 中,我们看一下。

每个指令的处理逻辑会对应一个标签,这些标签的地址全部保存在了数组中,执行帧评估函数时导入进来即可。这里可能有人会问,导入数组时,它里面的标签都还没有定义吧。确实如此,不过没关系,对于 C 来说,标签只要定义了,那么它在函数的任何一个位置都可以使用。

变量 opcode 就是指令(一个 uint8 整数),而最后跳转到了 *opcode_targets[opcode],那么我们有理由相信,指令和 opcode_targets 数组的索引之间存在某种关联。

这种关联也很好想,opcode_targets[opcode] 指向的标签,其内部的逻辑就是用来处理 opcode 指令的,我们来验证一下。

LOAD_CONST 的值是 100,那么 opcode_targets[100] 一定是 &&TARGET_LOAD_CONST。

结果没有问题,其它指令也是一样的,通过计算跳转,可以直接 goto 到指定的标签。

好,我们总结一下,早期的帧评估函数内部有一个巨型的 switch,每来一个指令都要顺序匹配数百个 case 分支,找到符合条件的那一个。尽管这些 case 分支内部也定义了标签,但对实现精确跳转没太大帮助。

而在 3.12 里面引入了计算跳转,此时 switch 已经没有了,只剩下了标签。当然有没有 switch 都无所谓,重点是每个标签的地址被保存在了数组 opcode_targets 中,并且标签在数组中的索引和对应的指令是相等的。

比如下一条要执行的指令是 YIELD_VALUE,它等于 150,那么 opcode_targets[150] 就等于 &&TARGET_YIELD_VALUE,指向的标签内部便是 YIELD_VALUE 的处理逻辑,至于其它指令也是同理。

因此读取完下一条指令之后,就不用像之前一样跳转到开头重新走一遍 switch 了。而是将指令作为索引,从 opcode_targets 拿到标签地址直接跳转即可,并且跳转后的标签内部的逻辑就是用来处理该指令的。

所以底层为每个指令的处理逻辑都定义了一个标签,而标签的地址在数组中的索引,和处理的指令是相等的。

不过要想实现计算跳转,需要 GCC 支持标签作为值这一特性,即 goto *标签地址,至于标签地址是哪一个标签的地址,则在运行时动态计算得出。比如 opcode_targets[opcode] 指向哪个标签无从得知,这取决于 opcode 的值。

goto 标签:静态跳转,标签需要显式地定义好,跳转位置在编译期间便已经固定。

goto *标签地址:动态跳转(计算跳转),跳转位置不固定,可以是已有标签中的任意一个。至于具体是哪一个,需要在运行时经过计算才能确定。

关于计算跳转我们就解释完了,当然还解释了很多其它内容,下面继续看源码。

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, 
                         _PyInterpreterFrame *frame, 
                         int throwflag)
{
    _Py_EnsureTstateNotNULL(tstate);
    CALL_STAT_INC(pyeval_calls);

#if USE_COMPUTED_GOTOS
#include "opcode_targets.h"
#endif

    uint8_t opcode;  // 指令
    int oparg;       // 指令参数
    
    /*
    typedef struct _PyCFrame {
        struct _PyInterpreterFrame *current_frame;
        struct _PyCFrame *previous;
    } _PyCFrame;

    结构体位于 Include/cpython/pystate.h 中
    */

    // _PyCFrame 相当于对 _PyInterpreterFrame 做了一层封装
    // 为了方便描述,我们也称 _PyCFrame 实例为栈桢
    // cframe 会在 C 语言的调用栈中传递,从而提高性能
    _PyCFrame cframe;
    // 入口帧
    _PyInterpreterFrame  entry_frame;
    PyObject *kwnames = NULL;

    // tstate 表示线程状态对象,tstate->cframe 指向正在执行的栈桢
    // 那么显然要将 tstate->cframe 更新为 &cframe
    // 不过更新之前,要先将目前的 tstate->cframe 保存起来
    // 因为进入到帧评估函数中,说明开启了新的帧,也意味着它成为了上一个帧
    _PyCFrame *prev_cframe = tstate->cframe;
    cframe.previous = prev_cframe;  // 通过 previous 保存上一个帧
    tstate->cframe = &cframe;       // 指向当前帧

    // 对入口帧内部的属性初始化
    entry_frame.f_code = tstate->interp->interpreter_trampoline;
    entry_frame.prev_instr =
        _PyCode_CODE(tstate->interp->interpreter_trampoline);
    entry_frame.stacktop = 0;
    entry_frame.owner = FRAME_OWNED_BY_CSTACK;
    entry_frame.return_offset = 0;
    /* Push frame */
    entry_frame.previous = prev_cframe->current_frame;
    // frame 是 _PyEval_Vector 内部创建的栈桢,也就是当前正在执行的帧
    // _PyEval_Vector 创建完栈桢后,会将它作为参数,调用帧评估函数
    // 而 frame->previous 同样指向上一个 _PyInterpreterFrame
    // 这里将它赋值为 &entry_frame,即入口帧
    frame->previous = &entry_frame;
    // cframe 和 frame 都可以理解为正在执行的栈桢
    // 但前者对后者做了一层封装,这里将 cframe.current_frame 赋值为 frame 
    cframe.current_frame = frame;

    // 判断还能否安全地进行递归调用
    // 如果不能,就减少递归计数并跳转到退出逻辑
    tstate->c_recursion_remaining -= (PY_EVAL_C_STACK_UNITS - 1);
    if (_Py_EnterRecursiveCallTstate(tstate, "")) {
        tstate->c_recursion_remaining--;
        tstate->py_recursion_remaining--;
        goto exit_unwind;
    }

    // next_instr 刚才说过的,它指向下一条待执行的指令
    // 通过不断执行 frame->prev_instr = next_instr++
    // 最终完成整个字节码指令集的遍历
    _Py_CODEUNIT *next_instr;
    // stack_pointer 指向运行时栈的栈顶
    // 元素的入栈和出栈,都是通过操作 stack_pointer 实现的
    PyObject **stack_pointer;

// 一个宏,负责初始化 next_instr 和 stack_pointer
// 但里面有一个 _PyInterpreterFrame_LASTI 函数需要说一下
// 我们说 prev_instr 指向上一条、或者最近一条执行完毕的字节码指令
// 那么 prev_instr 的偏移量是多少呢?这个还是很简单的
// 用 frame->prev_instr - _PyCode_CODE(f_code) 即可
// 而这个偏移量在以前的版本中,会有一个专门的栈桢字段(f_lasti)保存
#define SET_LOCALS_FROM_FRAME() \
    assert(_PyInterpreterFrame_LASTI(frame) >= -1); \
    next_instr = frame->prev_instr + 1; \
    stack_pointer = _PyFrame_GetStackPointer(frame);


// 开始在栈桢中执行字节码了,但还要做一下检测
// 判断函数的调用层级是否超过了最大深度(默认 1000)
start_frame:
    if (_Py_EnterRecursivePy(tstate)) {
        goto exit_unwind;
    }

// 调用宏 SET_LOCALS_FROM_FRAME,初始化 next_instr 和 stack_pointer
resume_frame:
    SET_LOCALS_FROM_FRAME();

// ...
}

这部分代码主要是负责初始化一些属性,其中最重要的两个属性是 next_instr 和 stack_pointer,字节码的遍历由 next_instr 负责,运行时栈的操作由 stack_pointer 负责。

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, 
                         _PyInterpreterFrame *frame, 
                         int throwflag)
{
    // ...  
    // ...

    // 开始进行调度,准备执行字节码  
    DISPATCH();
handle_eval_breaker:
    if (_Py_HandlePending(tstate) != 0) {
        goto error;
    }
    DISPATCH();

    {
    /* Start instructions */
    // 如果不使用计算跳转,那么这里就是带标签的 switch 语句
    // 如果使用计算跳转,那么这两行无效
#if !USE_COMPUTED_GOTOS
    dispatch_opcode:
        switch (opcode)
#endif
        {

// 导入 generated_cases.c.h,还记得这个文件吗?里面包含了指令的处理逻辑
// 如果不使用计算跳转,那么宏 TARGET 展开之后会生成一个带标签的 case 语句 
// 如果使用计算跳转,那么宏 TARGET 展开之后就只有一个标签
#include "generated_cases.c.h"
        
        // ...

        } /* End instructions */
        Py_UNREACHABLE();
    }
    // ...
}

这一部分是虚拟机开始执行字节码,如果程序没有出现错误,那么会将所有字节码指令执行完毕。

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, 
                         _PyInterpreterFrame *frame, 
                         int throwflag)
{
    // ...
    {
        // ...

// 如果执行时出现 UnboundLocalError,会跳转到此标签
unbound_local_error:
        {
            format_exc_check_arg(tstate, PyExc_UnboundLocalError,
                UNBOUNDLOCAL_ERROR_MSG,
                PyTuple_GetItem(frame->f_code->co_localsplusnames, oparg)
            );
            goto error;
        }

// 如果字节码执行时报错了,但运行时栈里面还有元素
// 那么要将运行时栈里的元素弹出
// 当运行时栈里包含 4 个元素时,跳转到此标签
pop_4_error:
    STACK_SHRINK(1);
// 当运行时栈里包含 3 个元素时,跳转到此标签
pop_3_error:
    STACK_SHRINK(1);
// 当运行时栈里包含 2 个元素时,跳转到此标签
pop_2_error:
    STACK_SHRINK(1);
// 当运行时栈里包含 1 个元素时,跳转到此标签
pop_1_error:
    STACK_SHRINK(1);
error:
        kwnames = NULL;

        assert(_PyErr_Occurred(tstate));
        assert(frame != &entry_frame);
        // 报错时,要生成 traceback
        // 关于 traceback,等介绍异常捕获的时候再说
        if (!_PyFrame_IsIncomplete(frame)) {
            PyFrameObject *f = _PyFrame_GetFrameObject(frame);
            if (f != NULL) {
                PyTraceBack_Here(f);
            }
        }
        monitor_raise(tstate, frame, next_instr-1);
exception_unwind:
        // ...
        // 后续介绍
    }

// ...
}

以上就是帧评估函数的源码逻辑,因为涉及到后面的内容,所以我们省略掉了一部分。



通过反编译查看字节码


源码部分我们就看完了,可能有小伙伴会觉得有些枯燥吧,但我相信认真读完一定会有很大收获。下面我们实际操作一波,通过反编译一段简单的代码,来观察虚拟机执行字节码的整个过程。

code_string = """
chinese = 89
math = 99
english = 91
avg = (chinese + math + english) / 3
"""


# 将上面的代码以模块的方式进行编译
code_obj = compile(code_string, "...""exec")
# 查看常量池
print(code_obj.co_consts)  # (89, 99, 91, 3, None)
# 查看符号表
print(
    code_obj.co_names
)  # ('chinese', 'math', 'english', 'avg')

在编译的时候,常量和符号(变量)都会被静态收集起来,然后我们反编译一下看看字节码,直接通过 dis.dis(code_obj) 即可。结果如下:

  0           0 RESUME                   0

  2           2 LOAD_CONST               0 (89)
              4 STORE_NAME               0 (chinese)

  3           6 LOAD_CONST               1 (99)
              8 STORE_NAME               1 (math)

  4          10 LOAD_CONST               2 (91)
             12 STORE_NAME               2 (english)

  5          14 LOAD_NAME                0 (chinese)
             16 LOAD_NAME                1 (math)
             18 BINARY_OP                0 (+)
             22 LOAD_NAME                2 (english)
             24 BINARY_OP                0 (+)
             28 LOAD_CONST               3 (3)
             30 BINARY_OP               11 (/)
             34 STORE_NAME               3 (avg)
             36 RETURN_CONST             4 (None)

我们从上到下依次解释每条指令都干了什么?

2 LOAD_CONST:表示加载一个常量(地址),并压入运行时栈。后面的指令参数 0 表示从常量池中加载索引为 0 的常量。

4 STORE_NAME:表示将 LOAD_CONST 加载的常量用一个名字绑定起来,放入所在的名字空间中。后面的 0 (chinese) 表示使用符号表中索引为 0 的名字(符号),且名字为 "chinese"。

所以像 chinese = 89 这种简单的赋值语句,会对应两条字节码指令。

然后 6 LOAD_CONST、8 STORE_NAME、10 LOAD_CONST、12 STORE_NAME 的作用显然和上面是一样的,都是加载一个常量,然后和指定的符号绑定起来,并放入名字空间中。

14 LOAD_NAME:加载一个变量,并压入运行时栈。后面的 0 (chinese) 表示加载符号表中索引为 0 的变量的值,然后这个变量叫 chinese。

16 LOAD_NAME:同理,将符号表中索引为 1 的变量的值压入运行时栈,并且变量叫 math。此时栈里面有两个元素,从栈底到栈顶分别是 chinese 和 math。

18 BINARY_OP:将上面两个变量从运行时栈弹出,然后执行加法操作,并将结果压入运行时栈。

22 LOAD_NAME:将符号表中索引为 2 的变量 english 的值压入运行时栈,此时栈里面有两个元素,从栈底到栈顶分别是 chinese + math 的返回结果和 english

24 BINARY_OP:将运行时栈里的两个元素弹出,然后执行加法操作,并将结果压入运行时栈。此时栈里面有一个元素,就是 chinese + math + english 的运算结果。

28 LOAD_CONST:将常量 3 压入运行时栈,此时栈里面有两个元素。

30 BINARY_OP:将运行时栈里的两个元素弹出,然后执行除法操作,并将结果压入运行时栈,此时栈里面有一个元素。

34 STORE_NAME:将元素从运行时栈里面弹出,并用符号表中索引为 3 的符号 avg 和它绑定起来,然后放在名字空间中。

36 RETURN_CONST:将常量 None 压入运行时栈,然后弹出并返回。

所以 Python 虚拟机就是把自己想象成一颗 CPU,在栈帧中一条条执行字节码指令,当指令执行完毕或执行出错时,停止执行。

我们通过几张图展示一下上面的过程,为了阅读方便,这里将相应的源代码再贴一份:

chinese = 89
math = 99
english = 91
avg = (chinese + math + english) / 3

上面的代码位于最外层的模块中,由于模块也有自己的作用域,并且是全局作用域,所以虚拟机也会为它创建栈帧。而在代码还没有执行的时候,栈帧就已经创建好了,整个布局如下。

f_localsplus 下面的箭头方向,代表运行时栈从栈底到栈顶的方向。

这里再强调一个重要的知识点,我们看到栈帧里面有一个 f_localsplus 属性,它是一个数组。虽然声明的时候写着长度为 1,但实际使用时,长度不受限制,和 Go 语言不同,C 数组的长度不属于类型的一部分。

所以 f_localsplus 是一个动态内存,运行时栈所需要的空间就存储在里面,但这块内存并不光给运行时栈使用,它被分成了四块。

函数的局部变量是静态存储的,那么都存在哪呢?答案是在 f_localsplus 里面,而且是开头的位置。在获取的时候直接基于索引操作即可,因此速度会更快。所以源码内部还有两个宏:

函数在编译的时候就知道每个局部变量在 f_localsplus 中的索引,所以直接通过索引操作即可,关于局部变量的具体操作细节,后续再聊。

然后 cell 变量和 free 变量是用来处理闭包的,而 f_localsplus 的最后一块内存则用于运行时栈。

所以 f_localsplus 是一个数组,它是一段连续内存,只不过从逻辑上讲,它被分成了四份,每一份用在不同的地方。但它们整体是连续的,都是数组的一部分。但当前示例中的代码是以模块的方式编译的,里面所有的变量都是全局变量,而且也不涉及闭包啥的,所以这里就把 f_localsplus 理解为运行时栈即可。

接下来就开始执行字节码了,虚拟机会执行:2 LOAD_CONST,该指令表示将常量加载进运行时栈,而要加载的常量在常量池中的索引,由指令参数表示。

在源码中,指令对应的变量是 opcode,指令参数对应的变量是 oparg

// Python/generated_cases.c.h
TARGET(LOAD_CONST) {
    PREDICTED(LOAD_CONST);
    PyObject *value;
    #line 204 "Python/bytecodes.c"
    // 从常量池中加载索引为 oparg 的常量
    value = GETITEM(frame->f_code->co_consts, oparg);
    // 增加引用计数
    Py_INCREF(value);
    #line 114 "Python/generated_cases.c.h"
    // stack_pointer++,为入栈的元素留出一个空间
    // 我们上一篇专门介绍了这些运行时栈的 API
    STACK_GROW(1);
    // 将栈顶元素设置为 value
    stack_pointer[-1] = value;
    // STACK_GROW(1) 和 stack_pointer[-1] = value 组合起来等价于 PUSH(value)
    // 调用 DISPATCHER,读取下一条指令,并跳转到指定位置进行处理
    DISPATCH();
}


// Python/ceval_macros.h
static inline PyObject *
GETITEM(PyObject *v, Py_ssize_t i) 
{
    // 从元组 v 中获取索引为 i 的元素
    assert(PyTuple_Check(v));
    assert(i >= 0);
    assert(i < PyTuple_GET_SIZE(v));
    return PyTuple_GET_ITEM(v, i);
}

该指令的参数为 0,所以会将常量池中索引为 0 的元素 89 压入运行时栈,执行完之后,栈帧的布局就变成了下面这样:

接着虚拟机执行 4 STORE_NAME 指令,从符号表中获取索引为 0 的符号、即 chinese。然后将栈顶元素 89 弹出,再将符号 chinese 整数对象 89 绑定起来保存到 local 名字空间中。

TARGET(STORE_NAME) {
    // 获取运行时栈的栈顶元素,显然是上一步压入的 89
    PyObject *v = stack_pointer[-1];
    #line 1013 "Python/bytecodes.c"
    // 从符号表中加载索引为 oparg 的符号  
    // 符号本质上就是一个 PyUnicodeObject 对象
    // 这里就是字符串 "chinese"
    PyObject *name = GETITEM(frame->f_code->co_names, oparg);
    // #define LOCALS() frame->f_locals
    // 获取名字空间(namespace)
    PyObject *ns = LOCALS();
    int err;
    if (ns == NULL) {
        // 如果没有名字空间则报错,设置异常
        _PyErr_Format(tstate, PyExc_SystemError,
                      "no locals found when storing %R", name);
    #line 1405 "Python/generated_cases.c.h"
        Py_DECREF(v);
    #line 1020 "Python/bytecodes.c"
        if (truegoto pop_1_error;
    }
    // 将符号和对象绑定起来放在名字空间 ns 中
    // 名字空间是一个字典,PyDict_CheckExact 负责检测 ns 是否为字典
    if (PyDict_CheckExact(ns))
        err = PyDict_SetItem(ns, name, v);
    else
        err = PyObject_SetItem(ns, name, v);
    #line 1414 "Python/generated_cases.c.h"
    // 对象的引用计数减 1
    Py_DECREF(v);
    #line 1027 "Python/bytecodes.c"
    if (err) goto pop_1_error;
    #line 1418 "Python/generated_cases.c.h"
    // 栈收缩,v = stack_pointer[-1] 和 STACK_SHRINK(1)
    // 两者组合起来就等价于 v = POP(),即弹出运行时栈的栈顶元素
    STACK_SHRINK(1);
    DISPATCH();
}

执行完之后,栈帧的布局就变成了下面这样:

此时运行时栈为空,local 名字空间多了个键值对。

同理剩余的两个赋值语句也是类似的,只不过指令参数不同,比如 8 STORE_NAME 加载的是符号表中索引为 1 的符号,12 STORE_NAME 加载的是符号表中索引为 2 的符号,分别是 math 和 english。执行完之后,栈桢的布局如下:

然后 14 LOAD_NAME 和 16 LOAD_NAME 负责将符号表中索引为 0 和 1 的变量的值压入运行时栈:

TARGET(LOAD_NAME) {
    PyObject *v;
    #line 1236 "Python/bytecodes.c"
    // 获取全局名字空间,对于模块来说
    // f->f_locals 和 f->f_globals 指向同一个字典
    PyObject *mod_or_class_dict = LOCALS();
    if (mod_or_class_dict == NULL) {
        _PyErr_SetString(tstate, PyExc_SystemError,
                         "no locals found");
        if (truegoto error;
    }
    // 从符号表 co_names 中加载索引为 oparg 的符号
    // 但是注意:全局变量是通过字典存储的
    // 所以这里的 name 只是一个字符串罢了,比如 "chinese"
    // 然后还要再根据这个字符串从字典里面查找对应的 value
    PyObject *name = GETITEM(frame->f_code->co_names, oparg);
    // 根据 name 从字典中获取 value
    if (PyDict_CheckExact(mod_or_class_dict)) {
        v = PyDict_GetItemWithError(mod_or_class_dict, name);
        if (v != NULL) {
            Py_INCREF(v);
        }
        else if (_PyErr_Occurred(tstate)) {
            goto error;
        }
    }
    
    // ...

    #line 1763 "Python/generated_cases.c.h"
    // 将变量的值压入运行时栈
    STACK_GROW(1);
    stack_pointer[-1] = v;
    DISPATCH();
}

上面两条指令执行完之后,栈帧的布局就变成了下面这样:

接下来执行 18 BINARY_OP,它会将栈里的两个元素弹出,然后执行加法操作,最后再将结果入栈。

TARGET(BINARY_OP) {
    PREDICTED(BINARY_OP);
    // 获取栈里的两个元素
    PyObject *rhs = stack_pointer[-1];
    PyObject *lhs = stack_pointer[-2];
    // 计算结果
    PyObject *res;
    // ...
    // binary_ops[oparg] 会拿到对应的函数指针(这里是加法)
    // 然后传入 lhs 和 rhs 进行计算
    res = binary_ops[oparg](lhs, rhs);
    #line 4663 "Python/generated_cases.c.h"
    // 减少引用计数
    Py_DECREF(lhs);
    Py_DECREF(rhs);
    #line 3384 "Python/bytecodes.c"
    if (res == NULLgoto pop_2_error;
    #line 4668 "Python/generated_cases.c.h"
    // 栈里面有两个元素,应该将它们弹出,然后再将 res 入栈
    // 但事实上只需弹出一个,然后再用 res 将栈顶元素替换掉即可
    STACK_SHRINK(1);
    stack_pointer[-1] = res;
    next_instr += 1;
    DISPATCH();
}

BINARY_OP 指令执行完之后,栈帧的布局就变成了下面这样:

然后 22 LOAD_NAME 负责将符号表中索引为 2 的变量 english 的值压入运行时栈;而指令 24 BINARY_OP 则是继续执行加法操作,并将结果设置在栈顶;然后 28 LOAD_CONST 将常量 3 再压入运行时栈;接着再执行 30 BINARY_OP,此时对应的是除法操作。

这四条指令执行时,运行时栈变化如下:

此时运行时栈里面只剩下一个元素 93.0,然后 34 STORE_NAME 将栈顶元素 93.0 弹出,并将符号表中索引为 3 的符号 avg 和它绑定起来,放到名字空间中。因此最终栈帧关系图如下:

以上就是虚拟机对这几行代码的执行流程,整个过程就像 CPU 执行指令一样。

我们再用 Python 代码描述一遍上面的逻辑:

# LOAD_CONST 将 89 压入栈中
# STORE_NAME 将 89 从栈中弹出
# 并将符号 "chinese" 和 89 绑定起来,放在名字空间中
chinese = 89
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89}

math = 99
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89, 'math': 99}

english = 91
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89, 'math': 99, 'english': 91}

avg = (chinese + math + english) / 3
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89, 'math': 99, 'english': 91, 'avg': 93.0}

现在你是不是对虚拟机执行字节码有一个更深的了解了呢?当然字节码指令有很多,不止我们上面看到的那几个。你可以随便写一些代码,然后分析一下它的字节码指令是什么样的。



小结


到此,我们就深入源码,考察了虚拟机执行字节码的流程,帧评估函数虽然很长,也有那么一些复杂,但是核心逻辑不难理解。就是把自己当成一颗 CPU,在栈帧中执行指令。

下一篇文章我们来介绍一下常见的几个指令,并探讨不同的变量赋值语句的背后原理。

古明地觉的编程教室
Python、Rust 程序猿,你感兴趣的内容我都会写,点个关注吧(#^.^#)
 最新文章