打开APP
userphoto
未登录

开通VIP,畅享免费电子书等14项超值服

开通VIP
《源码探秘 CPython》49. 虚拟机是怎么执行字节码的?

人生总是充满着苦痛,但是仍然要笑脸相迎。


Python虚拟机的运行框架


感谢读者 "川味小炒肉",指出了这篇文章出现的一处错误,故重写。

当Python启动后,首先会进行运行时环境的初始化。注意这里的运行时环境,它和前面说的执行环境是不同的概念。运行时环境是一个全局的概念,而执行环境是一个栈帧,是一个与某个code block相对应的概念。现在不清楚两者的区别不要紧,后面会详细介绍。

关于运行时环境的初始化是一个很复杂的过程,涉及到Python进程、线程的创建,类型对象的完善等非常多的内容,我们后面会单独剖析。这里就假设初始化动作已经完成,我们已经站在了Python虚拟机的门槛外面,只需要轻轻推动第一张骨牌,整个执行过程就像多米诺骨牌一样,一环扣一环地展开。

之前说过,虚拟机执行的不是PyCodeObject对象,而是会在其之上动态构建PyFrameObject对象。构建的时候,会使用以下两个函数:

PyObject *PyEval_EvalCode(PyObject *co, PyObject *globals, PyObject *locals);
PyObject *PyEval_EvalCodeEx(PyObject *_co, PyObject *globals, PyObject *locals, PyObject *const *args, int argcount, PyObject *const *kws, int kwcount, PyObject *const *defs, int defcount, PyObject *kwdefs, PyObject *closure);

PyEval_EvalCodeEx是通用接口,一般用于函数这种带参数的执行场景;PyEval_EvalCode是更高层封装,用于模块等无参数的执行场景。

但这两个函数,最终都会调用_PyEval_EvalCodeWithName函数,创建并初始化栈帧对象。

栈帧对象将贯穿代码执行的整个生命周期,负责维护执行时所需要的一切上下文信息。

一旦栈帧对象初始化完毕,那么就要进行处理了,处理的时候会调用PyEval_EvalFramePyEval_EvalFrameEx函数。

PyObject *PyEval_EvalFrame(PyFrameObject *f);
PyObject *PyEval_EvalFrameEx(PyFrameObject *f, int throwflag);

当然啦,上面这两个函数最终会调用_PyEval_EvalFrameDefault函数,虚拟机执行的秘密就藏在这里。

PyObject* _Py_HOT_FUNCTION_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag);

_PyEval_EvalFrameDefault函数是虚拟机运行的核心,这一个函数大概在3100行左右。

可以说代码量非常大,但是逻辑并不难理解。

// 源代码位于 Python/ceval.c 中PyObject* _Py_HOT_FUNCTION_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag){      //......    co = f->f_code;    names = co->co_names;    consts = co->co_consts;    fastlocals = f->f_localsplus;    freevars = f->f_localsplus + co->co_nlocals;    next_instr = first_instr;    if (f->f_lasti >= 0) {        assert(f->f_lasti % sizeof(_Py_CODEUNIT) == 0);        next_instr += f->f_lasti / sizeof(_Py_CODEUNIT) + 1;    }    // 栈顶指针    stack_pointer = f->f_stacktop;    assert(stack_pointer != NULL);    f->f_stacktop = NULL;           //......}

该函数首先会初始化一些变量,PyCodeObject对象包含的信息不用多说,还有一个重要的动作就是初始化堆栈的栈顶指针stack_pointer,使其等于f->f_stacktop,关于stack_pointer后续会细说。

然后栈帧中的f_code就是PyCodeObject对象,PyCodeObject对象里面的co_code域则保存着字节码指令序列。而虚拟机执行字节码就是从头到尾遍历整个co_code、对指令逐条执行的过程。

至于字节码指令序列本身则是一个PyBytesObject对象,对于C而言就是一个普普通通的字符数组,一条指令就是一个字符、或者说一个整数。而在遍历的时候会使用以下两个变量:

  • first_instr:永远指向字节码指令序列的第一条字节码指令;

  • next_instr:永远指向下一条待执行的字节码指令;

当然别忘记 f_lasti,它记录了上一条已经执行过的字节码指令的偏移量。

那么这个动作是如何一步步完成的呢?其实就是一个for循环加上一个巨大的switch case结构。

PyObject* _Py_HOT_FUNCTION_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag){       //......       co = f->f_code;    names = co->co_names;    consts = co->co_consts;    fastlocals = f->f_localsplus;    freevars = f->f_localsplus + co->co_nlocals;    //......      // 死循环,不断遍历字节码指令    for (;;) {        if (_Py_atomic_load_relaxed(eval_breaker)) {        // 读取下一条字节码指令        // 字节码指令位于:f->f_code->co_code        // 偏移量由 f->f_lasti 决定            opcode = _Py_OPCODE(*next_instr);        //opcode就是字节码指令序列中的每一条指令        //在Include/opcode.h中定义了大量的指令            if (opcode == SETUP_FINALLY ||                opcode == SETUP_WITH ||                opcode == BEFORE_ASYNC_WITH ||                opcode == YIELD_FROM) {                goto fast_next_opcode;             }
fast_next_opcode: //...... //判断该指令属于什么操作,然后执行相应的逻辑 switch (opcode) { // 加载常量 case TARGET(LOAD_CONST): // .... break; // 加载变量 case TARGET(LOAD_NAME): // ... break; // ... } }}

在这个执行架构中,对字节码的遍历是通过宏来实现的:

#define INSTR_OFFSET()  \    (sizeof(_Py_CODEUNIT) * (int)(next_instr - first_instr))
#define NEXTOPARG() do { \ _Py_CODEUNIT word = *next_instr; \ opcode = _Py_OPCODE(word); \ oparg = _Py_OPARG(word); \ next_instr++; \ } while (0)

首先每条字节码指令都会带有唯一一个参数,co_code中索引为0 2 4 6 8...的整数便是指令,索引为1 3 5 7 9...的整数便是参数。所以 co_code 里面并不全是字节码指令,每条指令后面都还跟着一个参数。因此next_instr每次向后移动两个字节,便可跳到下一条指令。

next_instr和first_instr都是 _Py_CODEUNIT * 类型的变量,这个 _Py_CODEUNIT  是一个 uint16_t。所以只要执行 next_instr++,便可向后移动两字节,跳到下一条指令。

然后我们看一下上面的宏,INSTR_OFFSET计算的显然就是下一条待执行的指令和第一条指令之间的偏移量;然后是 NEXTOPARG,里面的变量 word 显然就是待执行的指令,当然,由于 word 占两字节,所以也包括了参数。其中 word 的前 8 位是指令 opcode,后 8 位是参数 oparg。然后在解析出来指令以及参数之后,再将 next_instr++,继续跳到下一条指令。

而接下来就要执行上面刚解析出来的字节码指令了,会利用switch语句对指令进行判断,根据判断的结果选择不同的case分支

每一个case分支,对应一个字节码指令的实现,不同的指令执行不同的case分支。所以这个switch case语句非常的长,函数总共3000多行,这个switch就占了2400行。因为指令非常多,比如:LOAD_CONST、LOAD_NAME、YIELD_FROM等等,而每一个指令都要对应一个case分支

然后当某个case分支执行完毕时,说明当前的这一条字节码指令执行完毕了,那么虚拟机的执行流程会跳转到标签fast_next_opcode所在位置,或者for循环所在位置。但不管如何,虚拟机接下来的动作就是获取下一条字节码指令和指令参数,完成对下一条指令的执行。

所以,通过for循环一条一条遍历co_code中包含的所有字节码指令,然后交给内部的switch语句、选择不同的case分支进行执行,如此周而复始,最终完成了对Python程序的执行。

尽管目前只是简单的分析,但相信你也能大体地了解Python执行引擎的整体结构。在虚拟机的执行流程进入了那个巨大的for循环,并取出第一条字节码指令交给里面的switch语句之后,第一张多米诺骨牌就已经被推倒,命运不可阻挡的降临了。一条接一条的字节码如同潮水般涌来,浩浩荡荡,横无际涯。


通过反编译的方式进行演示



指令分为很多种,我们这里就以简单的顺序执行为例,不涉及任何的跳转指令,看看Python是如何执行字节码的。

code = """pi = 3.14r = 3area = pi * r ** 2"""# 将上面的代码以模块的方式进行编译co = compile(code, "<file>", "exec")print(co.co_consts)  # (3.14, 3, 2, None)print(co.co_names)  # ('pi', 'r', 'area')print(co.co_varnames)  # ()

这里需要提一下里面的符号表,在介绍PyCodeObject的时候,我们说过co_names和co_varnames都表示符号表。但co_names指的是当前代码块引用的其它作用域的变量;co_varnames表示当前作用域的变量。

对于函数而言,内部有哪些变量在编译的时候就能确定。如果变量是在自身内部创建的,那么会静态存储在符号表co_varnames当中;如果是引用的其它作用域的变量,那么会静态存储在符号表co_names当中。

但我们上面的代码是以模块的方式编译的,而模块的局部作用域和全局作用域相同,所以符号要通过co_names获取,而co_varnames是一个空元组。

然后我们使用dis.dis(co)进行反编译,看看得到的字节码指令长什么样子:

  1           0 LOAD_CONST               0 (3.14)              2 STORE_NAME               0 (pi)
2 4 LOAD_CONST 1 (3) 6 STORE_NAME 1 (r)
3 8 LOAD_NAME 0 (pi) 10 LOAD_NAME 1 (r) 12 LOAD_CONST 2 (2) 14 BINARY_POWER 16 BINARY_MULTIPLY 18 STORE_NAME 2 (area) 20 LOAD_CONST 3 (None) 22 RETURN_VALUE
  • 第一列是源代码的行号

  • 第二列是指令的偏移量,或者说该指令在整个字节码指令序列中的索引。因为每条指令后面都跟着一个参数,所以偏移量是 0 2 4 6 8...;

  • 第三列是字节码指令,简称指令。指令也叫操作码,它们在宏定义中代表整数;

  • 第四列是字节码指令参数,简称指令参数、或者参数,指令参数也叫操作数;

  • 第五列是dis模块给我们额外提供的信息,一会说;

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

  • 0 LOAD_CONST:表示加载一个常量(指针),并压入"运行时栈"(关于运行时栈一会儿还会解释)。后面的 0 表示从常量池中加载索引为0的常量、或者说对象,至于 3.14 则表示加载的对象是3.14。所以最后面的括号里面的内容实际上起到的是一个提示作用,告诉你加载的对象是什么。

  • 2 STORE_NAME:表示将LOAD_CONST加载的对象用一个名字绑定起来。0 (pi) 则表示使用符号表中索引为0的名字(符号),且名字为"pi"。

  • 4 LOAD_CONST 和 6 STORE_NAME 的作用显然和上面是一样的,只不过后面的索引变成了1,表示加载常量池中索引为1的对象、符号表中索引为1的符号(名字)。另外从这里我们也能看出,一行赋值语句实际上对应两条字节码(加载常量、与名字绑定)。

  • 8 LOAD_NAME:表示加载符号表中 pi 对应的值。

  • 10 LOAD_NAME:表示加载符号表中 r 对应的值。

  • 12 LOAD_CONST:表示加载2这个常量,后面的 2 (2) 代表常量池中索引为2的对象是2。

  • 14 BINARY_POWER 表示进行幂运算;16 BINARY_MULTIPLY 表示进行乘法运算;18 STORE_NAME 表示用符号表中索引为2的符号(area)和上一步计算的结果进行绑定。

  • 20 LOAD_CONST:表示将None加载进来;22 RETURN_VALUE 表示将None返回,虽然它不是在函数里面,但也是有这一步的。

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

pi = 3.14r = 3area = pi * r ** 2

首先虚拟机刚开始执行时,会准备好栈帧对象用于保存执行上下文,关系如下(省略部分信息):

接下来就开始执行字节码了,由于next_instr初始状态指向字节码开头,所以虚拟机开始加载第一条字节码指令:LOAD_CONST LOAD_CONST指令表示将常量加载进运行时栈,常量下标由指令参数给出。

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

// 代码位于 Python/ceval.c 中case TARGET(LOAD_CONST): {    //调用元组的GETITEM方法    //从常量池中加载索引为oparg的对象(常量)    //当然啦,这里为了方便称其为对象,但其实是指向对象的指针    PREDICTED(LOAD_CONST);    PyObject *value = GETITEM(consts, oparg);    //增加引用计数    Py_INCREF(value);    //压入运行时栈    //这个运行时栈位于栈帧对象的尾部, 我们一会儿会说    PUSH(value);    FAST_DISPATCH();}

执行完之后,上面的关系图就变成了下面这样:

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

case TARGET(STORE_NAME): {    //从符号表中加载索引为oparg的符号      //符号本质上就是一个PyUnicodeObject对象    PyObject *name = GETITEM(names, oparg);    //从运行时栈的栈顶弹出元素    //显然是上一步压入的3.14(指针)    PyObject *v = POP();    //获取名字空间namespace    PyObject *ns = f->f_locals;    int err;    if (ns == NULL) {    //如果没有名字空间则报错    //这个tstate是和线程密切相关的, 我们后面会说        _PyErr_Format(tstate, PyExc_SystemError,                      "no locals found when storing %R", name);        Py_DECREF(v);        goto error;    }    //将符号和对象绑定起来放在ns中    //名字空间是一个字典,PyDict_CheckExact则检测ns是否为字典    //如果不是字典,那么其类对象一定要继承字典    if (PyDict_CheckExact(ns))        //PyDict_CheckExact(ns)类似于type(ns) is dict        //除此之外,还有PyDict_Check(ns)        //它类似于isinstance(ns, dict),检测标准相对要宽松一些        //另外,底层的所有对象都有类似的检测逻辑        err = PyDict_SetItem(ns, name, v);    else       //走到这里说明type(ns)不是dict,那么它应该继承dict       //如果不继承,err 会返回非 0       //此时调用的是PyObject_SetItem,也就是自己实现的__setitem__       //如果没有实现,并继承了字典,则最终调用的还是字典的__setitem__        err = PyObject_SetItem(ns, name, v);            //对象的引用计数减1,因为从运行时栈中弹出了    Py_DECREF(v);    //err!=0,证明设置元素出错了,跳转至error标签    if (err != 0)        goto error;    DISPATCH();}

执行完之后,关系图进一步变化,变成下面这样:

到这里你可能会好奇,变量赋值为啥不直接通过名字空间,而是要跑到临时栈绕一圈?主要原因在于: 每条操作码最多只有一个操作数,因此另一个操作数就只能通过临时栈给出。所以 Python 的字节码设计思想跟CPU精简指令集类似,指令尽量简化,复杂指令由多条简单指令组合完成。

同理,r = 2 对应的两条指令和 pi = 3.14 是类似的,只不过操作数不同。

然后8 LOAD_NAME10 LOAD_NAME12 LOAD_CONST,表示将符号pi对应的值、符号r对应的值,以及常量2压入运行时栈。

然后14 BINARY_POWER表示幂运算,16 BINARY_MULTIPLY表示乘法运算。其中,BINARY_POWER指令会从栈上弹出两个操作数(指数2 和 底数3)进行幂运算,并将结果 9 压回栈中;BINARY_MULTIPLY 指令则是从栈上弹出 9 和 3.14 进行乘积运算 ,步骤也是类似的。

case TARGET(BINARY_POWER): {    //从栈顶弹出元素, 这里是指数2    PyObject *exp = POP();    //我们看到这个是TOP    //所以它不是弹出底数3,而是获取底数3    //至于3这个元素,它依旧在栈里面,并且此时位于栈顶    PyObject *base = TOP();    //进行幂运算    PyObject *res = PyNumber_Power(base, exp, Py_None);    Py_DECREF(base);    Py_DECREF(exp);    //将幂运算的结果再设置为栈顶    //所以原来的3被计算之后的9给替换掉了    SET_TOP(res);    if (res == NULL)        goto error;        DISPATCH();}
case TARGET(BINARY_MULTIPLY): { //同理这里也是弹出元素9 PyObject *right = POP(); //获取元素3.14 PyObject *left = TOP(); //乘法运算 PyObject *res = PyNumber_Multiply(left, right); Py_DECREF(left); Py_DECREF(right); //将运算的结果28.26将原来的3.14给替换掉 SET_TOP(res); if (res == NULL) goto error; DISPATCH();}

整个运行时栈的变化如下:

最终栈帧关系图如下:

然后,虚拟机再执行指令18 STORE_NAME,会从符号表中加载索引为2的符号area,并弹出栈顶元素(此时是浮点数28.26),将两者绑定起来放到名字空间中。

整体的执行流程便如上面几张图所示,当然字节码指令有很多,比如除了LOAD_CONSTSTORE_NAME之外,还有LOAD_FASTLOAD_GLOBALSTORE_FAST,以及if语句、循环语句所使用的跳转指令,运算使用的指令等等等等,这些在后续会慢慢遇到。

指令都定义在Include/opcode.h中


栈帧的动态内存空间



在上面反复提到了运行时栈,我们说加载常量的时候会将常量(对象)从常量池中取出、并压入运行时栈;当进行计算或者使用变量绑定的时候,再将它从栈里面弹出来。那么这个运行时栈所需要的空间都保存在什么地方呢?

PyFrameObject中有这么一个属性f_localsplus(可以回头看一下栈帧的定义),我们说它是动态内存,用于维护局部变量+cell对象集合+free对象集合+运行时栈所需要的空间。因此可以看出这段内存不仅仅是用来给运行时栈使用的,还有别的对象使用。

PyFrameObject*PyFrame_New(PyThreadState *tstate, PyCodeObject *code,            PyObject *globals, PyObject *locals){      //本质上调用了_PyFrame_New_NoTrack    PyFrameObject *f = _PyFrame_New_NoTrack(tstate, code, globals, locals);    if (f)        _PyObject_GC_TRACK(f);    return f;}
PyFrameObject* _Py_HOT_FUNCTION_PyFrame_New_NoTrack(PyThreadState *tstate, PyCodeObject *code, PyObject *globals, PyObject *locals){ //上一级的栈帧, PyThreadState指的是线程对象 PyFrameObject *back = tstate->frame; //当前的栈帧 PyFrameObject *f; //builtin名字空间 PyObject *builtins; /* ... */ else { Py_ssize_t extras, ncells, nfrees; ncells = PyTuple_GET_SIZE(code->co_cellvars); nfrees = PyTuple_GET_SIZE(code->co_freevars); /* ... */ f->f_code = code; //extras:局部变量+cell对象集合+free对象集合 //剩下的那部分空间就是给运行时栈用的 extras = code->co_nlocals + ncells + nfrees; for (i=0; i<extras; i++) f->f_localsplus[i] = NULL; f->f_locals = NULL; f->f_trace = NULL; } //... return f;}

在介绍栈帧的时候,我们说过这样一段话:

其实栈帧里面的内存空间分为两部分,一部分是编译代码块需要的空间,另一部分是执行代码块所需要的空间,我们也称之为运行时栈。

实际上这段描述不太严谨,因为在创建栈帧对象时,额外申请的运行时栈对应的空间并不完全是给运行时栈使用的。

co_freevars、co_cellvars与闭包相关,后续系列介绍

f_localsplus这段连续的内存空间被分成了四份,前三份分别给局部变量co_freevarsco_cellvars使用,而剩下的那一份才是给真正的运行时栈使用的。


小结



这次我们深入 Python 虚拟机,研究了虚拟机是如何执行字节码的。虚拟机在执行PyCodeObject对象里面的字节码之前,需要先根据PyCodeObject对象创建栈帧对象 (PyFrameObject),用于维护运行时的上下文信息。然后在PyFrameObject的基础上,执行字节码。

而 PyFrameObject 的关键信息包括:

  • f_locals: 局部名字空间;

  • f_globals:全局名字空间;

  • f_builtins:内置名字空间;

  • f_code:PyCodeObject对象;

  • f_lasti:上一条已执行完毕的字节码指令的偏移量,或者说索引也可以。另外这个f_lasti和生成器的实现有着密不可分的关系,生成器之所以能够从中断的位置恢复执行,正是因为f_lasti。当然,这些内容就留到介绍生成器的时候再说吧;

  • f_back:该栈帧的上一级栈帧、即调用者栈帧;

  • f_localsplus:局部变量 + co_freevars + co_cellvars + 运行时栈,这四部分需要的空间;

栈帧对象通过f_back串成一个栈帧调用链,这与CPU栈帧调用链有异曲同工之妙。此外我们还借助 inspect 模块成功取得栈帧对象(底层是通过sys模块),并在此基础上输出整个函数调用链。

总的来说Python虚拟机的代码量不小,但是核心并不难理解,主要是_PyEval_EvalFrameDefault里面的一个巨大for循环,更准确的说for循环里面的那个巨型switch语句。该switch语句case了每一个操作指令,当出现什么指令就执行什么操作。

本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请点击举报
打开APP,阅读全文并永久保存 查看更多类似文章
猜你喜欢
类似文章
【热】打开小程序,算一算2024你的财运
理解Python之opcode及优化
Python程序的执行原理
Python yield与实现
[Python源码学习]之bytecode
UC头条:编译型or解释型? Python运行机制浅析
当我print时,Python做了什么
更多类似文章 >>
生活服务
热点新闻
分享 收藏 导长图 关注 下载文章
绑定账号成功
后续可登录账号畅享VIP特权!
如果VIP功能使用有故障,
可点击这里联系客服!

联系客服