8.3 Python虚拟机的运行框架
当Python启动后,首先会进行Python运行时环境的 初始化。注意这里的运行时环境是一个与上一节剖析的执行环境不同的概念。运行时环境是一个全局的概念,而执行环境实际就是一个栈帧,是一个与某个Code Block对应的概念。这里不明白两者的区别不要紧,在以后剖析运行时环境初始化时我们就能弄清楚两者的区别和联系。运行时环境的初始化过程非常地复杂, 后面将用单独的一章来剖析,这里假设初始化的动作已经完成,我们已经站在了Python虚拟机的门槛外,只需要轻轻推动一下第一张骨牌,整个执行过程就像 多米诺骨牌一样,一环扣一环地展开。
这个推动第一张骨牌的地方在一个名叫PyEval_EvalFramEx的函数中,这个函数实际上就是Python的虚拟机的具体实现,它是一个非常巨大的函数,因此我们在列出其中的源代码时和以前有些不同。
PyEval_EvalFrameEx首先会初始化一些变量,其中PyFrameObject对象中的PyCodeObject对象包含的重要信息都被照顾到了。当然,另一个重要的动作就是初始化了堆栈的栈顶指针,使其指向f->f_stacktop:
[PyEval_EvalFrameEx in ceval.c]
co = f->f_code;
names = co->co_names;
consts = co->co_consts;
fastlocals = f->f_localsplus;
freevars = f->f_localsplus + co->co_nlocals;
first_instr = (unsigned char*)PyString_AS_STRING(co->co_code);
next_instr = first_instr + f->f_lasti + 1;
stack_pointer = f->f_stacktop;
f->f_stacktop = NULL; /* remains NULL unless yield suspends frame */
前面我们说过,在PyCodeObject对象的 co_code域中保存着字节码指令和字节码指令的参数,Python虚拟机执行字节码指令序列的过程就是从头到尾遍历整个co_code、依次执行字节 码指令的过程。在Python的虚拟机中,利用3个变量来完成整个遍历过程。co_code实际上是一个PyStringObject对象,而其中的字符 数组才是真正有意义的东西,这也就是说,整个字节码指令序列实际上就是一个在C中普普通通的字符数组。因此,遍历过程中所使用的这3个变量都是char* 类型的变量:first_instr永远指向字节码指令序列的开始位置;next_instr永远指向下一条待执行的字节码指令的位置;f_lasti指 向上一条已经执行过的字节码指令的位置。图8-5展示了这3个变量在遍历中某时刻的情形:
图8-5 遍历字节码指令序列
那么这个一步一步的动作是如何完成的呢,我们来看一看 Python虚拟机执行字节码指令的整体架构,其实就是一个for循环加上一个巨大的switch/case结构,熟悉Windows SDK编程的朋友可以想象一下Windows下那个巨大的消息循环,就是那样的结构:
[ceval.c]
/* Interpreter main loop */
PyObject* PyEval_EvalFrameEx(PyFrameObject *f, int throwflag)
{
……
why = WHY_NOT;
……
for (;;) {
……
fast_next_opcode:
f->f_lasti = INSTR_OFFSET();
//获得字节码指令
opcode = NEXTOP();
oparg = 0;
//如果指令需要参数,获得指令参数
if (HAS_ARG(opcode))
oparg = NEXTARG();
dispatch_opcode:
switch (opcode) {
case NOP:
goto fast_next_opcode;
case LOAD_FAST:
……
}
}
注意,这只是一个极度简化之后的Python虚拟机的样子,如果想一睹Python虚拟机的尊容,请参考ceval.c中的源码。
在这个执行架构中,对字节码的一步一步地遍历是通过几个宏来实现的:
[PyEval_EvalFrameEx in ceval.c]
#define INSTR_OFFSET() (int(next_instr - first_instr))
#define NEXTOP() (*next_instr++)
#define NEXTARG() (next_instr += 2, (next_instr[-1]<<8) + next_instr[-2])
在对PyCodeObject对象的分析中我们说过, Python的字节码有的是带参数的,有的是没有参数的,而判断是否带参字节码是通过HAS_ARG这个宏实现的。注意,对不同的字节码指令,由于存在是 否需要指令参数的区别,所以next_instr的位移可能是不同的。但是无论如何,next_instr总是指向Python下一条要执行的字节码,这 很像x86平台上的那个PC寄存器。
Python在获得了一条字节码指令和其需要的指令参数后,会对字节码指令利用switch进行判断,根据判断的结果选择不同的case语句,每一条字节码指令都会对应一个case语句。在case语句中,就是Python对字节码指令的实现。
在成功执行完一条字节码指令后,Python的执行流程会跳 转到fast_next_opcode处,或者是for循环处,不管如何,Python接下来的动作都是获得下一条字节码指令和指令参数,完成对下一条指 令的执行。如此一条一条地遍历co_code中包含的所有字节码指令,最终完成了对Python程序的执行。
需要提到的一点是那个名叫“why”的神秘变量,它指示了在 退出这个巨大的for循环时Python执行引擎的状态。因为Python执行引擎不一定每次执行都会正确无误,很有可能在执行到某条字节码的时候,产生 了错误,这就是我们熟悉的那个“异常”——exception。所以在Python退出了执行引擎的时候,就需要知道执行引擎到底是因为什么原因结束了对 字节码指令的执行。是正常结束呢?还是因为有错误发生,实在是执行不下去了?why义无反顾地担负起这一重任。关于why在Python虚拟机中作用的详 细剖析,我们留到剖析异常机制时详细讲述。
变量why的取值范围在ceval.c中被定义,其实也就是Python结束字节码执行时的状态:
[ceval.c]
/* Status code for main loop (reason for stack unwind) */
enum why_code {
WHY_NOT = 0x0001, /* No error */
WHY_EXCEPTION = 0x0002, /* Exception occurred */
WHY_RERAISE = 0x0004, /* Exception re-raised by 'finally' */
WHY_RETURN = 0x0008, /* 'return' statement */
WHY_BREAK = 0x0010, /* 'break' statement */
WHY_CONTINUE = 0x0020, /* 'continue' statement */
WHY_YIELD = 0x0040 /* 'yield' operator */
};
现在,想必大家已经对Python的执行引擎的大体框架了然 于胸了。在Python的执行流程进入了PyEval_EvalFrameEx中的那个for循环,取出第一条字节码之后,第一张多米诺骨牌已经被推倒, 命运不可阻挡地降临了。一条接一条的字节码像潮水一样汹涌而来,浩浩荡荡,横无际涯。