原文地址http://nikic.github.io/2017/04/14/PHP-7-Virtual-machine.html

写这篇文章的目的是基于 php7,阐述 Zend Virtual Machine 的内部实现。这不是一篇综合描述,我将尽可能地覆盖到所有重要的部分和细节。

本文的描述对象是 php7.2 版本,但是几乎所有的特性都已经应用在了 php7.0/7.1 中了。然而,它们同 php5.x 系列 VM 的不同之处同样也很重要,我会很有耐心的同步描述。

这篇文章主要是从指令的角度来阐述,只有在末尾花了少量篇幅描述了 C 语言实现 VM 的细节。但是在文章开始之前,我想先提供一些实现 VM 的主要代码文件:

Opcodes

首先我们来聊聊 opcode。“Opcode"是用来表示整个 VM 指令集(包括操作数)的,但是也可能仅仅就是指“真实的”操作码,这些操作码是一个很小的整数用来区分不同的指令类型。其具体的含义需要结合代码的上下文才能清楚。在程式码中,指令通常被称作"oplines”。

下面是zend_op的结构

struct _zend_op {
    const void *handler;
    znode_op op1;
    znode_op op2;
    znode_op result;
    uint32_t extended_value;
    uint32_t lineno;
    zend_uchar opcode;
    zend_uchar op1_type;
    zend_uchar op2_type;
    zend_uchar result_type;
};

由此看来,opcodes 本质上就是一个“三地址码”格式的指令。有一个opcode代表指令的类型,有两个输入操作数op1op2和一个输出操作数result

并不是所有的指令都一定会使用全部的操作数。ADD指令(表示+操作符)会使用全部的操作数,BOOL_NOT指令(表示!操作符)只会用到op1result。而ECHO 指令只会用到op1。还有一些指令既可能用到也可能用不到操作数,例如DO_FCALL有没有结果操作数都是有可能的,这取决于调用的函数是否有返回值。还有一些指令 可能会需要使用超过2个输入操作数,在这种情况下,它们将使用一个虚设的指令(OP_DATA)来传递额外的操作数。

紧跟着三个标准操作数后面的是一个额外的数值字段extended_value,它可以用来存放一些额外的指令标识,例如CAST指令,它需要保存将要转换的目标类型。

每一个操作数都有一个类型,分别存放在op1_typeop2_typeresult_type当中。所有可能的类型有IS_UNUSEDIS_CONSTIS_TMPVARIS_VARIS_CV。 后三种类型用来指明变量操作数类型(有三种不同类型的 VM 变量),IS_COUNT表示一个常量操作数,而IS_UNUSED表示一个操作数是否被使用,或者操作数被用作一个 32 位数字类型(一个立即数,汇编中的术语)。例如 Jump 指令会将跳转的目标存放在一个UNUSED操作数中。

输出 Opcode

接下来,我将会频繁展示一些 php 示例代码生成的 opcode 序列。目前有三种方式来打印出 opcode.

# Opcache, since PHP 7.1
php -d opcache.opt_debug_level=0x10000 test.php

# phpdbg, since PHP 5.6
phpdbg -p* test.php

# vld, third-party extension
php -d vld.active=1 test.php

上述方法中,opcache 输出的 opcode 质量更高。本文使用的 opcode 就是基于 opcache 输出的,其中一些 opcode 做了少量的语法的调整。魔法数字0x10000表示“优化前”,使用这个级别输出的是 php 编译器直接生成的 opcodes,而0x20000会输出优化过的 opcodes。Opcache 还能生成更多的信息,例如使用0x40000将会生成CFG(Control flow graph),使用0x200000将会生成类型和范围推断的 SSA form(Static single assignment form,静态单赋值形式,常见于编译器原理),但是这些已经超出了本文的探讨范围,所以最原始的 opcodes 才最符合我们的需求。

变量类型

可能在处理 PHP 虚拟机时要理解的最重要的一点就是 VM 使用了三种不同的变量类型。在 PHP5 中,TMPVARVARCV在虚拟机栈中不仅含义上有着明显的区别,连访问方式都不一样。但是到了 PHP7,公用一套存储机制使得它们变得非常相似。而然,它们所包含的数值和它们的语义上却存在重要的差异。

CVcompiled variable的简写,代表的是真正的 PHP 变量。如果一个函数使用变量$a,就会使用CV类型的操作数表示$a。CVs 也可以有UNDEF类型,用以表示没有定义的变量。如果一个指令使用了 UNDEF CV,(在大多数情况下)会抛出一个熟悉的“undefined variable”警告。在 function entry 中,所有非参数 CVs 都会被初始化为 UNDEF。

CVs 不是被指令消费的,例如一个指令ADD $a, $b不会销毁存放在 CVs$a$b中的数据,取而代之的是 CVs 在作用域结束的时候一起被销毁。也就是说,所有 CVs“存活”于 整个函数期间,这里的“存活”指的是其包含一个合法的数值(并非存活于数据流层面)。

TMPVARsVARs从某种意义上说其实就是虚拟机的临时变量。他们通常产生于作为一些操作的结果操作数。例如$a = $b + $c + $d将会生成一个如下所示的 opcode 的序列

T0 = ADD $b, $c
T1 = ADD t0, $d
ASSIGN $a, T1

TMP/VARs总是在使用前被定义,所以不能持有UNDEF类型。不同于CVs,它们的值是被指令直接消费的。在上面的例子中,第二个 ADD 会销毁 T0 操作数中的值,至此以后 T0 将不能再被使用。同样的,ASSIGN 将会消费 T1 中的数值,然后并释放掉。

上述表明TMP/VARs通常都很短命。在多数情况下,临时变量仅仅存活于单个指令空间。在这个短暂的存活间隔之外,临时变量的值就是垃圾数据。 那么 TMP 和 VAR 的区别到底是什么呢?其实区别并不多,其差异继承自 PHP5,在 PHP5 中,TMPs 是存放在虚拟机栈中的,VMRs 是存放在堆中的。而 PHP7 中的所有变量都是存放在栈 当中的。因此,至今 TMPs 和 VARs 的主要区别是只有后者允许包含REFERENCEs。此外,VARSs 也能够存放两种特殊类型的数据,一个是 namely class entries,另一个是 INDIRECT values。后者也通常被用来处理非普通的赋值操作。

下表总结了三种数据类型的主要区别:

       | UNDEF | REF | INDIRECT | Consumed? | Named? |
-------|-------|-----|----------|-----------|--------|
CV     |  yes  | yes |    no    |     no    |  yes   |
TMPVAR |   no  |  no |    no    |    yes    |   no   |
VAR    |   no  | yes |   yes    |    yes    |   no   |

Op arrays

所有的 PHP 函数都代表了一个拥有相同zend_functionheader 的结构体。“Function"在这里被理解的很宽泛,包括了真实函数的所有一切,从方法到独立的伪代码,到 evel 代码。 用户层的函数使用zend_op_array结构体。它有超过 30 个成员,所以这里从一个简化版开始研究:

struct _zend_op_array {
    /* Common zend_function header here */

    /* ... */
    uint32_t last;
    zend_op *opcodes;
    int last_var;
    uint32_t T;
    zend_string **vars;
    /* ... */
    int last_literal;
    zval *literals;
    /* ... */
};

这里边最重要的部分当然是opcodes了,它是一个 opcodes(指令)的数组。last表示 opcode 数组中元素的个数。说到这里你也许会感到非常的疑惑,last看起来像是 最后一个 opcode 的索引,然而它真的是 opcodes 的个数(比最后一个 opcode 的索引值大1)。同样的规则适用于其他以last_开头的字段。

last_var是 CVs 的数量,T表示 TMPs 和 VARs 的数量(在大多数情况下,我们并没有对它们做明显的区分)。vars是一个 CVs 的名字数组。literals是用于存放代码中字面量的值的数组,这个数组会被CONST操作数引用。根据 ABI(application binary interface),每一个CONST操作数要么存储一个字面量表的指针,要么存储一个相对于字面量表的起始位置的偏移量。

关于 op array 结构还有很多内容,将会在后边描述。

栈帧布局

不考虑一些 executor globals(EG),所有的执行状态都是存储在虚拟机栈中的。VM 栈每页 256KB,页与页通过链表连接起来。在每个函数调用中,会在 VM 栈中分配一个新的栈帧,它们的布局如下:

+----------------------------------------+
| zend_execute_data                      |
+----------------------------------------+
| VAR[0]                =         ARG[1] | arguments
| ...                                    |
| VAR[num_args-1]       =         ARG[N] |
| VAR[num_args]         =   CV[num_args] | remaining CVs
| ...                                    |
| VAR[last_var-1]       = CV[last_var-1] |
| VAR[last_var]         =         TMP[0] | TMP/VARs
| ...                                    |
| VAR[last_var+T-1]     =         TMP[T] |
| ARG[N+1] (extra_args)                  | extra arguments
| ...                                    |
+----------------------------------------+

栈帧以一个zend_execute_data结构开始,后边跟着一个存放变量的数组。数组中的每个位置存放的数据都是一样的(简单的 zval 数值),但是它们却有着不同的用途。第一个last_var之前存放的都是 CVs,第一个num_args之前存放的是函数参数。紧挨着 CV 后边的是T槽,用以存放 TMP/VARs。最后,如果有一些“额外的”参数的话会存放在栈帧的末尾,它们通常用来处理func_get_args()

CV 和 TMP/VAR 操作数在指令中会被编码成相对于栈帧起始位置的偏移量,因此访问一个确定的变量将变得非常容易,仅仅访问execute_data中的偏移位置。下面是zend_execute_data的结构:

struct _zend_execute_data {
    const zend_op       *opline;
    zend_execute_data   *call;
    zval                *return_value;
    zend_function       *func;
    zval                 This;             /* this + call_info + num_args    */
    zend_class_entry    *called_scope;
    zend_execute_data   *prev_execute_data;
    zend_array          *symbol_table;
    void               **run_time_cache;   /* cache op_array->run_time_cache */
    zval                *literals;         /* cache op_array->literals       */
};

其中最重要的是,这个结构体中包含了一个opline字段,它代表当前执行的指令,func是当前执行的函数。此外:

  • return_value是一个指向存放返回值变量的指针
  • This就是$this对象,但是同时也编码了函数参数个数和一些调用的元数据标记存放在此 zval 中没有使用的空间里
  • called_scopestatic::指向的 PHP 代码作用域
  • prev_execute_data指向前一个栈帧,以便当前函数执行完毕后返回到外层调用
  • symbol_table是一个典型的没有使用的符号表,用于某些疯狂的人实际使用中会用到变量或相关特性
  • run_time_cache缓存 op array 运行时缓存,用来防止指针通过间接寻址的方式来访问当前结构
  • literals缓存 op array 字面量表,目的同上

函数调用(Function call)

在介绍 execute_data 结构的时候,我跳过了call字段,因为在介绍它之前还需要先了解函数调用是如何工作的。

所有调用都使用同一指令序列上的变量。一个var_dump($1, $b)在全局作用域中会被编译成下面的指令序列:

INIT_FCALL (2 args) "var_dump"
SEND_VAR $a
SEND_VAR $b
V0 = DO_ICALL   # or just DO_ICALL if retval unused

根据不同的调用类型,总共有8种不同类型的 INIT 指令。INIT_FCALL用于调用后立即释放的函数调用。同理根据不同的参数类型和函数类型,共有 10 种不同的 SEND 指令。DO_CALL 指令只有区区 4 种,ICALL 用于调用内部函数。

尽管特定的指令不同,但是整个流程却一直如此:INIT,SEND,DO。现在调用序列需要解决的主要问题是嵌套调用,它们编译后的指令形如以下:

# var_dump(foo($a), bar($b))
INIT_FCALL (2 args) "var_dump"
    INIT_FCALL (1 arg) "foo"
    SEND_VAR $a
    V0 = DO_UCALL
SEND_VAR V0
    INIT_FCALL (1 arg) "bar"
    SEND_VAR $b
    V1 = DO_UCALL
SEND_VAR V1
V2 = DO_ICALL

我使用缩进来区分哪个指令代表哪个调用。

INIT opcode 将一个调用栈帧 push 到栈中,栈帧中包含了充足的空间来存放函数中的所有变量和已知数量的参数(如果涉及到参数解包,我们可能会得到更多参数)。这个调用栈帧伴随着函数调用被初始化,$thiscalled_scope(在上面情况下都是 NULL,因为它们是调用后就释放的函数)。

一个新的栈帧的指针被存放在execute_data->call中,其中execute_data是调用函数的栈帧。下面我们将分析一个形如EX(call)的访问形式。特别地,新栈帧的prev_execute_data会被设置成旧的EX(call)。例如,对于fooINIT_FCALL会把其prev_execute_data设置成var_dump的栈帧,如此以来,prev_execute_data 在这种形式下构成了一个未完成调用的链表,从而形成了一个回溯链。

SEND opcode 接下来将参数 push 到EX(call)的变量槽中。在这种情况下,参数都是连续的,而且也可能超出参数预设的存放区域到达 CVs 或 TMPs 区域,但是它们会在后边被修复。

接着 DO_FCALL 才是进行真正的调用。此时EX(call)变成当前执行的函数而prev_execute_data则重新指向外层调用函数。除此之外,调用过程也取决于被调用函数的类型。内部函数只需要执行一个 handler 函数,而用户层函数需要先初始化栈帧。初始化过程包含了对参数栈的修复。PHP 允许向一个函数传递的参数超过预期参数个数。然而,只有被声明过的参数才会对应到 CVs,超出的参数将会被写到记忆体中其他 CVs 和 TMPs 的位置,但是像这样的参数随后会被移动到 TMPs 后面的位置,最终的结果就是函数参数位于两个不连续的记忆体区块中。

这里需要清楚的是,用户端的函数调用不涉及到虚拟机级别的递归。它们只是从一个 execute_data 切换到另一个,但虚拟机在线性循环中继续运行。虚拟机级别的递归仅仅出现在内部函数中包含用户端回调的时候(例如:通过array_map)。这就是为什么在 PHP 中无限递归通常会导致内存限制或 OOM 错误,但是通过回调函数或魔术方法可能会引发栈溢出。

传送参数(Argument sending)

PHP 使用大量不同的参数传递 opcode,多亏了那些不幸的名字,让我们对它们的区别感到困惑。

SEND_VAL 和 SEND_VAR 是最简单的两个,它们用来传递按值传递的参数。SEND_VAL 用于 CONST 和 TMP 操作数,而 SEND_VAR 用于 VARs 和 CVs 的传递。

相反地,SEND_REF 用来处理按引用传递的参数。因为只有变量才能按引用传递,所以此 opcode 只能接收 VARs 和 CVs。

SEND_VAL_EX 和 SEND_VAR_EX 是 SEND_VAL 和 SEND_VAR 的变种,用于不能确定参数到底是按值传递还是按引用传递的情况。这两个 opcode 会根据 arginfo 来检查参数的类型然后进行相应操作。大多数情况下,arginfo 结构并没有使用,取而代之的是函数结构中的一个压缩的位向量。

接着是 SEND_VAR_NO_REF_EX。不要视图从它的名字里去获取什么信息,因为它完全是一个谎言。这个 opcode 用于当传递一个不是真实变量,但是会返回一个不确定参数类型的 VAR 的时候。两个典型的例子就是将一个函数调用的结果作为参数传递,或者将赋值的结果作为参数传递。这些情况下需要一个单独的 opcode 主要有两个原因:其一,如果你试图将类似于赋值操作的表达式按引用传递,它会生成熟悉的"Only variables should be passed by reference"警告(如果使用 SEND_VAR_EX 的话,就会悄悄的允许)。其二,这个 opcode 可以处理我们想把一个返回引用类型数据类型函数的返回值传递给按引用传递的参数的情况(它不会抛出任何信息)。这个 opcode 的一个变种 SEND_VAR_NO_REF 是一个特殊的用来处理我们明确知道参数是一个引用类型的情况。

SEND_UNPACK 和 SEND_ARRAY 这两个 opcodes 分别用来处理参数解包和内敛call_user_func_array调用。它们都能够将数组中的元素 push 到参数栈中,但是在处理细节上有一些不同(例如:unpacking 支持遍历,而 call_user_func_array 不支持)。如果 unpacking/cufa 被使用,就有可能适当地去扩展栈帧的大小。通常,可以通过移动栈帧顶部指针来扩展。然而,如果达到了栈 page 的边界,就需要分配一个新的 page,然后将整个调用栈帧拷贝到新的 page 当中(我们不能处理跨 page 的栈帧)

最后一个 opcode 是 SEND_USER,它是用于内敛函数call_user_func调用。

至此我们还没有讨论过不同的变量查询模式,这里是一个不错的地方来介绍 FUNC_ARG 查询模式。思考形如func($a[0][1][2])的调用,我们不知道在编译时期传入的参数是按值传递还是按引用传递。这两个情况下的行为是不同的。如果是按值传递,而且$a之前为空,那么会产生一些"undefined index"警告。如果按引用传递,那么会悄悄的初始化嵌套数组。FUNC_ARG 访问模式通过检查当前的EX(call)函数的 arginfo 来动态选择两种行为(R 或 W)中的一种,对于上面的例子,opcode 序列如下:

INIT_FCALL_BY_NAME "func"
V0 = FETCH_DIM_FUNC_ARG (arg 1) $a, 0
V1 = FETCH_DIM_FUNC_ARG (arg 1) V0, 1
V2 = FETCH_DIM_FUNC_ARG (arg 1) V1, 2
SEND_VAR_EX V2
DO_FCALL

查询模式(Fetch modes)

PHP 虚拟机有四类用于查询的 opcodes:

FETCH_*             // $_GET, $$var
FETCH_DIM_*         // $arr[0]
FETCH_OBJ_*         // $obj->prop
FETCH_STATIC_PROP_* // A::$prop

如注释中说明的,基础的FETCH_*用来访问变量变量和超全局变量。这些 fetch opcodes 每一类又分 6 种:

_R
_RW
_W
_IS
_UNSET
_FUNC_ARG

我们已经知道_FUNC_ARG会根据函数是按值传递还是按引用传递来选择_R_W模式。下面我们来举一些出现不同查询模式的例子:

// $arr[0];
V2 = FETCH_DIM_R $arr int(0)
FREE V2

// $arr[0] = $val;
ASSIGN_DIM $arr int(0)
OP_DATA $val

// $arr[0] += 1;
ASSIGN_ADD (dim) $arr int(0)
OP_DATA int(1)

// isset($arr[0]);
T5 = ISSET_ISEMPTY_DIM_OBJ (isset) $arr int(0)
FREE T5

// unset($arr[0]);
UNSET_DIM $arr int(0)

不幸的是,实际上产生的唯一查询就是FETCH_DIM_R:其他的操作都是通过特定的 opcodes 处理的。注意到ASSIGN_DIMASSIGN_ADD都使用一个额外的OP_DATA,因为它们都需要超过 2 个操作数。之所以用到像ASSIGN_DIM这样的特殊 opcodes 而没有用到FETCH_DIM_W+ASSIGN的原因是这些操作可能会被覆盖,例如,通过一个对象实现ArrayAccess::offsetSet()的方式构成ASSIGN_DIM的情形。为了真正产生不同的 fetch types,我们需要增加嵌套层级:

// $arr[0][1];
V2 = FETCH_DIM_R $arr int(0)
V3 = FETCH_DIM_R V2 int(1)
FREE V3

// $arr[0][1] = $val;
V4 = FETCH_DIM_W $arr int(0)
ASSIGN_DIM V4 int(1)
OP_DATA $val

// $arr[0][1] += 1;
V6 = FETCH_DIM_RW $arr int(0)
ASSIGN_ADD (dim) V6 int(1)
OP_DATA int(1)

// isset($arr[0][1]);
V8 = FETCH_DIM_IS $arr int(0)
T9 = ISSET_ISEMPTY_DIM_OBJ (isset) V8 int(1)
FREE T9

// unset($arr[0][1]);
V10 = FETCH_DIM_UNSET $arr int(0)
UNSET_DIM V10 int(1)

这里我们看到,最外层的访问通过特定的 opcodes,而嵌套的索引则使用特定模式的 FETCHes。访问不存在的索引是否产生"undefined offset"警告和是否会对查询的数据执行写操作,对于不同的 fetch modes 来说也是不同的:

      | Notice? | Write?
R     |  yes    |  no
W     |  no     |  yes
RW    |  yes    |  yes
IS    |  no     |  no
UNSET |  no     |  yes-ish

UNSET 的情况有点特殊,它只会对存在的索引值进行写操作,而跳过没有定义的部分。而一个普通的 write-fetch 操作会先初始化没有定义的变量。

Writes and memory safety

写查询模式会返回包含一个普通 zval 或者一个指向另一个 zval 的 INDIRECT 指针的 VARs。当然,前面任何对该 zval 的改变都是不可见的,因为这个值只能通过虚拟机临时变量访问。尽管 PHP 禁止形如[][0] = 42这样的表达式,但是我们仍然需要处理类似于call()[0] = 42这种操作。这取决于call()是否返回一个数值还是一个数值的引用。

另个一个更加特殊的情形是查询返回一个 INDIRECT,其中包含的指向一个记忆体的指针被修改了,例如 hashtable 数组中一个确定的位置,不行的是,这样的指针是很脆弱的,很容易被失效:任何对于该数组并发写操作都可能触发 reallocation,留下一个迷途(dangling)指针,因此在创建 INDIRECT 值的地方和它被消费之间阻止用户代码执行是至关重要的。

考虑如下例子:

$arr[a()][b()] = c();

将会产生如下 opcode 序列:

INIT_FCALL_BY_NAME (0 args) "a"
V1 = DO_FCALL_BY_NAME
INIT_FCALL_BY_NAME (0 args) "b"
V3 = DO_FCALL_BY_NAME
INIT_FCALL_BY_NAME (0 args) "c"
V5 = DO_FCALL_BY_NAME
V2 = FETCH_DIM_W $arr V1
ASSIGN_DIM V2 V3
OP_DATA V5

显然,上述 opcode 序列先是从左到右执行函数调用,然后才执行一些必要的写查询操作(我们称这里的 FETCH_DIM_W 操作是“延迟操作”)。这样能确保写查询操作和消费指令能够直接相邻。

再来思考另一个例子:

$arr[0] = &$arr[1];

这里有一个小问题:赋值语句两边都必须使用写查询操作。然而,如果我们先查询$arr[0]进行写操作,然后再对$arr[1]进行写操作,后者将会使前者失效。这个问题的解决如下:

v2 = FETCH_DIM_W $arr 1
v3 = MAKE_REF V2
V1 = FETCH_DIM_W $arr 0
ASSIGN_REF V1 V3

这里首先对$arr[1]进行写查询操作,然后通过使用MAKE_REF转换成一个引用,转换的结果不再是一个 INDIRECT,也不会遵循失效的规则,这样的话,查询$arr[0]就变得很安全。

异常处理(Exception handling)

异常是万恶之源。

异常是通过向EG(exception)中写入异常而产生的,这里EG代表的是执行全局变量(executor globals)。在 C 语言中抛出异常并不会导致堆栈展开,相反,错误信息会通过返回失败值或检查EG(exception)向上传播。异常只有当控制权重新进入到虚拟机代码中的时候才会被处理。几乎所有的 VM 指令都能够在某些情形下直接或间接产生异常。例如一些"undefined variable"警告可能会产生异常,如果使用了自定义的错误处理的话。我们想避免在每一个 VM 指令执行后都去检查EG(exception)是否被设置,这里用到了一个小技巧:

当异常被抛出的时候,当前的执行数据中的 opline 会被替换成一个虚设的HANDLE_EXCEPTIONopline(这显然不会改变 op array,它仅仅是一个直接的指针)。搜集 exception 处的 opline 会回到EG(opline_before_exception)。也就是说,当控制权返回到虚拟机调度循环中的时候,HANDLE_EXCEPTIONopline 会被执行。这种模式下有一个小问题:存放在 execute data 中的 opline 必须是当前执行的 opline(否则的话 opline_before_exception 就是错的),其次虚拟机使用 execute data 中的 opline 来继续执行(否则 HANDLE_EXCEPTION 将不会被执行)。

尽管这些条件看起来不那么重要,其实不然。原因就是虚拟机可能因为存储在 execute data 中的变量跟 opline 不同步而工作在不同的 opline。在 PHP7 之前,只有少数 GOTO 和 SWITCH 可能导致上述情况,而在 PHP7 中,这是操作的默认模式:如果编译器支持的话,opline 被存放在全局寄存器中。

在执行一些操作之前可能会抛出异常,本地 opline 必须被写回到 execute data(SAVE_OPLINE 操作)。类似的,潜在的异常抛出之后,本地 opline 必须从 execute data 中移出(通常是一个 CHECK_EXCEPTION 操作)。

现在我们知道了当一个异常抛出后,通过执行 HANDLE_EXCEPTION opcode 的机制来处理,但是它到底做了些什么呢?首先,它会确定异常是否在一个 try 代码块中抛出。为此,op array 包含了一个数组来跟踪 opline 相对于 try,catche,和 finally 代码块的偏移。

typedef struct _zend_try_catch_element {
	uint32_t try_op;
	uint32_t catch_op;  /* ketchup! */
	uint32_t finally_op;
	uint32_t finally_end;
} zend_try_catch_element;

我们假设 finally 代码块不存在,因为它是一个完全不同的兔子洞(rabbit hole)。假如我们确实在一个 try 代码块中,VM 需要清理从开始抛出异常之后 try 代码块结束之前的所有的未完成的操作。这个操作会释放栈帧和相关正在执使用的数据,同时也会释放活跃状态的临时变量。大多数临时变量都是短命的,因为消费指令通常紧跟产生临时变量指令之后。然而可能会有临时变量生存期跨越多个指令的时候,潜在的异常被抛出:

# (array)[] + throwing()
L0:   T0 = CAST (array) []
L1:   INIT_FCALL (0 args) "throwing"
L2:   V1 = DO_FCALL
L3:   T2 = ADD T0, V1

在上面的例子中,变量T0存活于指令L1L2,在这种情况下,如果抛出异常,就需要将其释放。有一种典型的情况会产生长命的临时变量,那就是迭代中的变量,例如:

# foreach ($array as $value) throw $ex;
L0:   V0 = FE_RESET_R $array, ->L4
L1:   FE_FETCH_R V0, $value, ->L4
L2:   THROW $ex
L3:   JMP ->L1
L4:   FE_FREE V0

这里有一个"loop variable” V0,从L1一直到L3(通常会延续整个迭代体内)。临时变量的生命周期使用如下的结构存放在 op array 中:

typedef struct _zend_live_range {
    uint32_t var; /* low bits are used for variable type (ZEND_LIVE_* macros) */
    uint32_t start;
    uint32_t end;
} zend_live_range;

这里var是这个周期描述的(operand encoded)变量,start是起始 opline 偏移量(不包括自动生成的指令),end是终止 opline 的偏移(包括消费指令)。当然只有当临时变量没有被立即消费的时候才会有声明周期存在。

var的低位被用来存放变量类型,它们可能是下面类型中的一种:

  • ZEND_LIVE_TEMVAR:这是一个普通的变量。它里边包含了一个原始的 zval 值。释放这种变量的行为类似于FREEopcode
  • ZEND_LIVE_LOOP:这是一个 foreach 迭代变量,它包含的不仅仅是一个简单的 zval。它的释放对应的是FE_FREEopcode
  • ZEND_LIVE_SILENCE:这种类型的变量用于实现错误抑制操作。将一个旧的错误备份到一个临时变量中,如果后边有异常抛出,显然我们希望能够还原它。这种临时变量的释放对应的是END_SILENCE
  • ZEND_LIVE_ROPE:这中类型的变量是用来连接一串字符串的,在这种情况下临时变量是一个位于栈中的存放zend_string*指针的固定大小的数组,从中移出的 strings 必须被释放。对应的释放操作大约是END_ROPE

有一个很滑稽的问题需要考虑,就是当临时变量的产生和消费操作之一抛出异常了,那么这个临时变量还需要被释放吗。例如下面的简单代码:

T2 = ADD T0, T1
ASSIGN $v, T2

如果ADD中抛出异常,临时变量T2会被自动释放吗,或者说ADD指令对它负责吗?同理如果ASSIGN抛出异常,T2会自动释放吗,或者ASSIGN必须考虑做这件事?后面的情况中,答案是很显然的:指令总是负责释放它的操作数,即使有异常抛出。而对于产生操作数的情况,就不那么寻常,因为在 PHP7.1 和 PHP7.2 中,答案是不一样的:在 PHP7.1 中指令负责在产生异常的时候释放临时变量,而在 PHP7.2 中,它们会被自动释放(而指令总是负责确保临时变量总是被移出栈)。这种改变的原因是这种方式能够实现许多基本指令(例如 ADD),它们通常的结构如下:

1. 读取输入操作数
2. 执行操作,将结果写入result操作数中
3. 释放输入操作数(如果有必要的话)

这里是有问题的,因为 PHP 有一个很不幸的地方就是它不仅支持异常处理和析构,还支持在析构中抛出异常(这也正式令编译器工程师们感到恐惧的地方)。如此以来,在第三步中可以抛出异常,此时的结果已经移出栈,为了避免这种临界情况下造成内存泄露,释放结果操作数的责任就从指令转移给了异常处理机制。

一旦我们执行了那些清理操作,我们就能继续执行 catch 代码块。如果没有 catch(也没有 finally)的话,就会展开堆栈,也就是说销毁当前栈帧,并且在异常处理时给父栈帧一个快照(原文: i.e. destroy the current stack frame and give the parent frame a shot at handling the exception.)。

你已经欣赏到了异常处理的整个丑态,我将介绍另一个跟析构函数异常处理相关的部分,虽然这跟实践无关,但是我们仍然需要保证它处理的正确性。看下面代码:

foreach (new Dtor as $value) {
  try {
    echo "Return";
    return;
  } catch (Exception $e) {
    echo "Catch";
  }
}

想象一下,Dtor是一个可遍历的类,并且有一个可能抛出异常的析构函数。上面的代码将会得到下面的 opcode 序列:

L0:   V0 = NEW 'Dtor', ->L2
L1:   DO_FCALL
L2:   V2 = FE_RESET_R V0, ->L11
L3:   FE_FETCH_R V2, $value
L4:       ECHO 'Return'
L5:       FE_FREE (free on return) V2   # <- return
L6:       RETURN null                   # <- return
L7:       JMP ->L10
L8:       CATCH 'Exception' $e
L9:       ECHO 'Catch'
L10:  JMP ->L3
L11:  FE_FREE V2                        # <- the duplicated instr

特别注意的是,“return"被编译成了一个FE_FREE和一个RETURN。由于Dtor有一个能够抛出异常的析构函数,如果FE_FREE抛出异常会发生什么呢?通常情况下,我们可能会说这个指令在 try 代码块内部,所以会执行 catch。然而,这种情况下,循环变量已经被销毁了。catch 丢弃了异常然后尝试继续遍历一个已经破坏掉的循环变量。造成这种情况的原因是因为当抛出的FE_FREE在 try 代码块内部的时候,它其实是 L11 中FE_RETURN的一个拷贝,逻辑上来说,那才是异常真正发生的地方。这就是为什么中断产生的FE_FREE被注释为FREE_ON_RETURN,这样能指示异常处理机制将异常代码移动到最初的释放指令。因此上书代码不会执行 catch 代码块,而是会生成一个未捕获的异常。

Finally handling

PHP 中 finally 的历史可谓是历经坎坷。在 PHP5.5 中首次实现 finally 特性,然而那实在是一个 bug 很多的实现。随后在 PHP5.6,7.0,7.1 每一次版本变更的时候,对 finally 的核心代码都进行了重构,每一次都修复了一些 bug,但是却没有达到完全正确的 finally 实现。看起来 PHP7.1 终于成功了。

当我在写这一节的时候,我惊奇的发现,透过当前 PHP 的实现和我的理解,finally 处理并不是那么复杂。而且,通过不同的迭代来实现从某种成都上使得问题变得更加简单而不是更复杂。下面我来告诉大家对问题的不充分理解是如何导致一个既复杂又 bug 居多的结果的(虽然,公平的说,PHP5 中实现的一部分复杂性是由于缺乏 AST(abstract syntax tree,抽象语法树)直接造成的)。

无论是正常的(例如:使用 return)还是不正常的(例如抛出异常)运行流程下,Finally 代码块总是运行在 try 代码块之后。有一些临界情况需要考虑,在描述实现原理之前我会做一些简单说明:

try {
	throw new Exception();
} finally {
	return 42;
}

上面的代码会有怎样的执行结果呢?Finally 赢了,最后会返回 42。那么再思考下面的代码:

try {
	return 24;
} finally {
	return 42;
}

同样的还是 Finally 赢了,结果依然是 42,Finally 总是会赢。

PHP 会禁止跳出 finally 代码块。例如下面的代码是不允许的:

foreach ($array as $value) {
	try {
		return 42;
	} finally {
		continue;
	}
}

上面代码中的"continue"会产生一个编译错误。但是需要理解的是,这种限制其实就是一种装饰,因为我们很容易使用一种众所周知的 catch 控制代理模式来绕过它:

foreach ($array as $value) {
	try {
		try {
			return 42;
		} finally {
			throw new JumpException;
		}
	} catch (JumpException $e) {
		continue;
	}
}

唯一真正存在的限制就是我们不能跳到一个 finally 代码块中,例如使用一个 goto 语句从 finally 外部跳到一个 finally 内部的标签是不允许的。

通过一些简单的方式,我们可以看到 finally 是如何工作的。它的实现上是使用了两个 opcodes,FAST_CALLFAST_RET。大体上,FAST_CALL是用来跳到 finally 代码块中,而FAST_RET是用来跳出的。我们来分析下面的例子:

try {
	echo "try";
} finally {
	echo "finally";
}

echo "finished";

上述代码会生成下面的 opcode 序列:

L0:   ECHO string("try")
L1:   T0 = FAST_CALL ->L3
L2:   JMP ->L5
L3:   ECHO string("finally")
L4:   FAST_RET T0
L5:   ECHO string("finished")
L6:   RETURN int(1)

FAST_CALL将它自己的位置保存在T0中,然后跳到 finally 代码块L3处。当执行到FAST_RET的时候,它会跳到之前保存在T0的位置之后一个位置,也就是上述代码中的L2处。这就是一个最基本的情况,没有 return 和 exception 发生。下面我们来分析下异常发生的情况:

try {
    throw new Exception("try");
} catch (Exception $e) {
    throw new Exception("catch");
} finally {
    throw new Exception("finally");
}

当处理异常的时候,我们需要考虑抛出异常的位置相对于 try/catch/finally 代码块的偏移:

  1. 如果在 try 中抛出异常:移出$e然后跳到 catch.
  2. 如果在 catch 中或者在 try 中抛出但是没有匹配到合适的 catch,如果有 finally 代码块:跳到 finally 代码块,然后将 exception 备份到FAST_CALL临时变量中
  3. 如果在 finally 中抛出异常:如果有一个备份的异常存在于FAST_CALL临时变量中,将其关联到成当前异常的上一个异常。继续向外抛出异常到下一个 try/catch/finally。
  4. 否则:继续向外抛出异常到下一个 try/catch/finally。

前面的小例子能够覆盖到前三步:先 try,抛出异常,触发一个 jump 到 catch,catch 中继续抛出异常,触发一个 jump 到 finally,并且将 catch 中的异常备份到 FAST_CALL 中。finally 中续集抛出,“finally"异常会将"catch"链接成自己前一个异常继续向外抛出。

我们对上面的代码做一些小的改动:

try {
    try {
        throw new Exception("try");
    } finally {}
} catch (Exception $e) {
    try {
        throw new Exception("catch");
    } finally {}
} finally {
    try {
        throw new Exception("finally");
    } finally {}
}

上面代码中,所有的内层 finally 都有异常进入,但是都正常退出了(通过 FAST_RET)。在这种情况下,前面描述的异常处理过程从外层 try/catch/finally 处恢复执行。外层的 try/catch 被存放在 FAST_RET opcode 中。

了解到了 finally 和异常互动的本质,那么 return 和 finally 又会是什么样的呢?

try {
    throw new Exception("try");
} finally {
    return 42;
}

上面的代码会生成下面的 opcode 序列:

L4:   T0 = FAST_CALL ->L6
L5:   JMP ->L9
L6:   DISCARD_EXCEPTION T0
L7:   RETURN 42
L8:   FAST_RET T0

这里的DISCARD_EXCEPTION opcode 是用来忽略 try 代码块中的异常的(记住:最终 finally 中的 return 赢了)。那么如果 return 在 try 里边呢?

try {
    $a = 42;
    return $a;
} finally {
    ++$a;
}

这里返回的值是 42 而不是 43。返回值发生在return $a这行,任何后续对$a的修改都不用考虑。生成的 opcode 序列如下:

L0:   ASSIGN $a, 42
L1:   T3 = QM_ASSIGN $a
L2:   T1 = FAST_CALL ->L6, T3
L3:   RETURN T3
L4:   T1 = FAST_CALL ->L6      # unreachable
L5:   JMP ->L8                 # unreachable
L6:   PRE_INC $a
L7:   FAST_RET T1
L8:   RETURN null

有两个 opcodes 是不可达的,因为它们发生在 return 之后,通过优化将会移除它们,但是这里展示的是没有优化过的代码。这里有两个很有意思的地方:首先$a通过QM_ASSIGN(这是一个基本的拷贝到临时变量的指令)拷贝到T3中,这就是为什么能防止后续对$a的操作影响到返回值,其次就是T3也被传递给了FAST_CALL,它的值会在T1中备份,如果 try 中的 return 在后面的操作中被忽略了(例如 finally 中抛出异常或出现 return),这种机制将会用来释放没有使用的返回值。

所有的这些案例机制都很简单,但是当他们组合到一起的时候就需要注意了。考虑下面的例子,如果Dtor又是一个可遍历的类,而且有一个会抛出异常的析构函数:

try {
    foreach (new Dtor as $v) {
        try {
            return 1;
        } finally {
            return 2;
        }
    }
} finally {
    echo "finally";
}

生成的 opcode 序列为:

L0:   V2 = NEW (0 args) "Dtor"
L1:   DO_FCALL
L2:   V4 = FE_RESET_R V2 ->L16
L3:   FE_FETCH_R V4 $v ->L16
L4:       T5 = FAST_CALL ->L10         # inner try
L5:       FE_FREE (free on return) V4
L6:       T1 = FAST_CALL ->L19
L7:       RETURN 1
L8:       T5 = FAST_CALL ->L10         # unreachable
L9:       JMP ->L15
L10:      DISCARD_EXCEPTION T5         # inner finally
L11:      FE_FREE (free on return) V4
L12:      T1 = FAST_CALL ->L19
L13:      RETURN 2
L14:      FAST_RET T5 try-catch(0)
L15:  JMP ->L3
L16:  FE_FREE V4
L17:  T1 = FAST_CALL ->L19
L18:  JMP ->L21
L19:  ECHO "finally"                   # outer finally
L20:  FAST_RET T1

执行第一个 return 的序列是FAST_CALLL10FE_FREE V4FAST_CALL L19RETURN,然后就会执行到内部的 finally 中,接着释放 foreach loop variable,然后进入到外层的 finally,然后再 return。执行第二次 return 的序列是DISCARD_EXCEPTION T5FE_FREE V4FAST_CALL L19。这里先忽略了内部 try 代码块中的 exception(或者这里是 return value),然后释放了 foreach loop variable 最后执行外部的 finally 代码块。要注意的是到所有情况中这些指令的顺序相对于实际的代码块是如何颠倒的。

生成器 (Generators)

生成器函数可以暂停和恢复执行,而且需要特殊的 VM 栈来管理。下面是一个简单的生成器:

function gen($x) {
    foo(yield $x);
}

生成如下 opcode 序列:

$x = RECV 1
GENERATOR_CREATE
INIT_FCALL_BY_NAME (1 args) string("foo")
V1 = YIELD $x
SEND_VAR_NO_REF_EX V1 1
DO_FCALL_BY_NAME
GENERATOR_RETURN null

GENERATOR_CREATE到达之前,代码在普通的函数中执行在普通的 VM 栈里。接着GENERATOR_CREATE创建一个Generator对象,同时创建一个堆分配的 execute_data 结构,里边拷贝了 VM 栈中 execute_data。当生成器再次恢复的时候,执行器会使用堆分配的 execute_data,否则的话就继续将调用栈帧压入主 VM 栈中。一个明显的问题就是当一个调用正在进行的时候,可能会中断生成器,正如前面的例子中展示的那样,YIELD执行的时候调用foo()的栈帧已经被压入到 VM 栈中。这些相关的不常见的情形都是通过在控制权让出的时候将当前活跃的调用栈帧拷贝到生成器结构中,在生成器恢复的时候再恢复它们来处理的。

这种设计直到 PHP7.1 才被使用,之前的版本中每个生成器都有它自己的 4KB 大小的虚拟机 page,它们会在生成器恢复的时候交换到执行器里。这样避免了对调用栈帧的拷贝,否则就需要使用更多的记忆体。

Smart branches

一个比较指令后面跟一个跳转指令的情况很常见,就想下面这样:

L0:   T2 = IS_EQUAL $a, $b
L1:   JMPZ T2 ->L3
L2:   ECHO "equal"

由于这种模式太常见了,所有的比较指令(例如 IS_EQUAL)实现了一个只能分支机制:它们会检查它们后面的指令是否是 JMPZ 或 JMPNZ 指令,如果是的话,自动执行后续的相应的跳转指令。

智能分支机制只会校验跟在它后面的指令是否为 JMPZ/JMPNZ,而不会校验它们的操作数是否是比较指令的结果。需要特别注意的是比较操作和跳转操作并不是直接相邻的情况,例如:($a == $b) + ($d ? $e : $f)会生成下面的执行序列:

L0:   T5 = IS_EQUAL $a, $b
L1:   NOP
L2:   JMPZ $d ->L5
L3:   T6 = QM_ASSIGN $e
L4:   JMP ->L6
L5:   T6 = QM_ASSIGN $f
L6:   T7 = ADD T5 T6
L7:   FREE T7

注意到NOP被插入到了IS_EQUALJMPZ之间。如果没有这里的NOP的话,分支最终就会使用IS_EQUAL的结果,而不是 JMPZ 操作数。

Runtime cache

由于 opcode array 在多进程中是共享的(没有锁),它们是绝度不可变的。但是,运行时的数值可能会被缓存在独立的“runtime cache”中,它们基本上就是一个指针数组。字面量通常有一个相关的 runtime cache 的入口(也可能是多个),被存放在它们的 u2 槽中。

runtime cache entries 有两种类型:一个是原始的 cache entries,例如 INIT_FCALL 使用的那种,当 INIT_FCALL 查找到调用的函数后,函数指针就会被缓存在一个相关的 runtime cache 里。另一个是 polymorphic cache entries,它有两个连续的缓存槽,第一个存放 class entry,第二个用来存放资料。像 FETCH_OBJ_R 这样的操作会使用这种 cache entry。当一个确定类的属性相对于属性表的偏移被缓存后,如果接下来有对这个类有同样的访问时,就会使用缓存,否则就会再次执行昂贵的查找操作,然后将新的结果缓存起来。

VM interrupts

在 PHP7.0 以前,执行超时通常的处理是使用一个longjump直接从信号处理跳转到 shutdown 执行序。你也许能够想象到,这样造成了各种不愉快的行为。直到 PHP7.0,超时会延迟直到控制权重新还给虚拟机。如果它不在一定宽限的时期返回,进程就会终止。到了 PHP7.1,pcntl 信号处理使用同样的机制来处理执行超时。

当收到一个等待信号,VM 中断标记就会被设置,而且此标记会在一个确定的地方被虚拟机校验。校验只会在 jumps 和 calls 指令中进行,而不会在所有指令中发生。因此,中断并不会立刻被处理并返回控制权给 VM,而是在线性控制流的当前段结束的时候处理。

Specialization

如果你看过 VM 的定义文件,你就会发现 opcode handler 的定义长得是这个样子的:

ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMPVAR|CV, CONST|TMPVAR|CV)

这里的1是 opcode number,ZEND_ADD是名字,其他两个参数是可接受的操作数类型。自动生成的虚拟机代码会包含所有可能的操作数类型的 handler,它们会被大致命名为形如ZEND_ADD_SPEC_CONST_CONST_HANDLER的格式。

特定的 handlers 在自动生成的时候,handler body 里会被替换成一些特殊的宏,一个很明显的例子就是OP1_TYPEOP2_TYPE,而像GET_OP1_ZVAL_PTR()FREE_OP1()这样的操作同样是特定的。

ADD handler 接收CONST|TMPVAR|CV类型的操作数。TMPVAR在这里表示 opcode 既能接收TMPs也能接收VARs,只是没有将他们特别的区分开。再强调一遍,大多数情况下,TMPVAR的唯一区别在于后者能够包含引用类型的数据。对于像ADD这样的操作,将它们区分开是没有意义的。其他一些确实需要将它们区分对待的操作会在它们的操作数列表中使用TMP|VAR

不仅可以特定操作数类型,handlers 也可以特定其他的元素,例如它是否有返回值。例如ASSIGN_DIM

ZEND_VM_HANDLER(147, ZEND_ASSIGN_DIM,
    VAR|CV, CONST|TMPVAR|UNUSED|NEXT|CV, SPEC(OP_DATA=CONST|TMP|VAR|CV))

在这样的签名下,$ 2 * 4 * 4 $种不同的ASSIGN_DIM会被自动生成。在上面的定义中,第二个操作数包含了一个NEXT,它跟限定因素无关,而是表明一个UNUSED操作数存在于它的上下文中:也就是说这是一个 append 操作($arr[])。另一个例子:

ZEND_VM_HANDLER(23, ZEND_ASSIGN_ADD,
    VAR|UNUSED|THIS|CV, CONST|TMPVAR|UNUSED|NEXT|CV, DIM_OBJ, SPEC(DIM_OBJ))

这里我们的第一个操作数有一个UNUSED标识通常表示访问一个$this。这是对象相关 opcode 的惯例,例如FETCH_OBJ_R_UNUSED, 'prop'表示的是$this->prop。而第二个UNUSED操作数表示一个 append 操作。这里的第三个参数是扩展操作数:它包含了用以区分$a += 1$a[$b] += 1$a->b +=1的标记。最后的SPEC(DIM_OBJ)表明应该为它们每一个都生成一个专门的 handler。(这种情况下生成的 handler 的数量是未知的,因为 VM 不可能知道确定的组合,例如一个UNUSED op1 只能和 OBJ 相关)

最后虚拟机生成器还会做一些额外的支持和更加复杂的特定机制。在 VM 定义文件的最后,你可以发现一些类似于下面的 handlers:

ZEND_VM_TYPE_SPEC_HANDLER(
    ZEND_ADD,
    (res_info == MAY_BE_LONG && op1_info == MAY_BE_LONG && op2_info == MAY_BE_LONG),
    ZEND_ADD_LONG_NO_OVERFLOW,
    CONST|TMPVARCV, CONST|TMPVARCV, SPEC(NO_CONST_CONST,COMMUTATIVE)
)

这些特定的 handler 不仅根据 VM 操作数类型,还会根据操作数在运行时可能存在的类型。这种可能的操作数类型机制属于 opcache 优化设施的一部分而且已经超出了本文的范围。但是,假设这个信息可以被获取到,那么我们就能清楚明白这是一个形如int + int -> int的额外 handler。此外,SPEC声明说明两种 CONST 操作数类型的 handler 不被生成,而且两个操作数可以交换(加法交换律),因此,如果我们已经有一个CONST+TEMPVARCV的设定,就不必再生成一个TMPVARCV+CONST了。

Fast-path / slow-path split

一些 opcode handlers 的实现都做了 fast-path 和 slow-path 的区分,首先会处理一些常见案例,其次才会进入到泛型实现中。是时候看看真实的代码是如何实现的了,下面是我粘贴的关于 SL(shift-left)的实现:

ZEND_VM_HANDLER(6, ZEND_SL, CONST|TMPVAR|CV, CONST|TMPVAR|CV)
{
	USE_OPLINE
	zend_free_op free_op1, free_op2;
	zval *op1, *op2;

	op1 = GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R);
	op2 = GET_OP2_ZVAL_PTR_UNDEF(BP_VAR_R);
	if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_LONG)
			&& EXPECTED(Z_TYPE_INFO_P(op2) == IS_LONG)
			&& EXPECTED((zend_ulong)Z_LVAL_P(op2) < SIZEOF_ZEND_LONG * 8)) {
		ZVAL_LONG(EX_VAR(opline->result.var), Z_LVAL_P(op1) << Z_LVAL_P(op2));
		ZEND_VM_NEXT_OPCODE();
	}

	SAVE_OPLINE();
	if (OP1_TYPE == IS_CV && UNEXPECTED(Z_TYPE_INFO_P(op1) == IS_UNDEF)) {
		op1 = GET_OP1_UNDEF_CV(op1, BP_VAR_R);
	}
	if (OP2_TYPE == IS_CV && UNEXPECTED(Z_TYPE_INFO_P(op2) == IS_UNDEF)) {
		op2 = GET_OP2_UNDEF_CV(op2, BP_VAR_R);
	}
	shift_left_function(EX_VAR(opline->result.var), op1, op2);
	FREE_OP1();
	FREE_OP2();
	ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
}

实现以使用GET_OPn_ZVAL_PTR_UNDEFBP_VAR_R模式下查询操作数开始,这里的UNDEF部分表示的是在处理 CV 的时候不需要检查变量是否被定义,而是只需要返回一个 UNDEF 数值。当我们拿到了操作数,我们校验它们是否都是整数类型和移动的长度是超出范围,然后操作的结果会被直接计算出来,我们会继续执行下一个 opcode。因为这里的类型校验不会处理 UNDEF 的操作数,所以这里使用GET_OPn_ZVAL_PTR_UNDEF是恰当的。

如果操作数不能满足 fast-path,我们就会进入到一般的实现当中,这种实现以SAVE_OPLINE()开始,这就是一个信号表明了“潜在的操作如下”。在进行其他操作之前,需要处理未定义的变量的强开。GET_OPn_UNDEF_CV在这种情况下会发出一个未定义变量的警告并且返回 NULL。接着普通的shift_left_function被调用,并且将结果写入到EX_VAR(opline->result.var)中,最后将输入操作数释放掉(如果有必要的话),然后检查异常后推进执行下一个 opcode(这表明,在推进前 opline 会被重置)。

对于上面的代码,fast-path 节省了两次对未定义变量的校验和一次函数调用,释放操作,还有保存和重置 opline 给异常处理的操作。大多数对性能敏感的 opcode 都是以这种方式呈现的。

VM macros

正如前面代码清单中看到的那样,虚拟机实现了许多可以自由使用的宏。其中一些是普通的 C 语言宏,另一些在虚拟机被生成的时候才会确定。特别地,这些宏包含了一些查找和释放指令的操作:

OPn_TYPE
OP_DATA_TYPE

GET_OPn_ZVAL_PTR(BP_VAR_*)
GET_OPn_ZVAL_PTR_DEREF(BP_VAR_*)
GET_OPn_ZVAL_PTR_UNDEF(BP_VAR_*)
GET_OPn_ZVAL_PTR_PTR(BP_VAR_*)
GET_OPn_ZVAL_PTR_PTR_UNDEF(BP_VAR_*)
GET_OPn_OBJ_ZVAL_PTR(BP_VAR_*)
GET_OPn_OBJ_ZVAL_PTR_UNDEF(BP_VAR_*)
GET_OPn_OBJ_ZVAL_PTR_DEREF(BP_VAR_*)
GET_OPn_OBJ_ZVAL_PTR_PTR(BP_VAR_*)
GET_OPn_OBJ_ZVAL_PTR_PTR_UNDEF(BP_VAR_*)
GET_OP_DATA_ZVAL_PTR()
GET_OP_DATA_ZVAL_PTR_DEREF()

FREE_OPn()
FREE_OPn_IF_VAR()
FREE_OPn_VAR_PTR()
FREE_UNFETCHED_OPn()
FREE_OP_DATA()
FREE_UNFETCHED_OP_DATA()

如你所见,这些宏有很多类型。BP_VAR_*参数指定了查找模式,支持同样模式的还有FETCH_*指令。

GET_OPn_ZVAL_PTR()是一个基本的操作数查找指令。如果遇到未定义的 CV,会抛出一个警告,并且不会解引操作数。GET_OPn_ZVAL_PTR_UNDEF()不会校验 CVs 是否为未定义,GET_OPn_ZVAL_PTR_DEREF()包含了对 zval 的DEREF操作,这是 GET 操作的一部分,因为解引对 CVs 和 VARs 是很有必要的,但是不适用于 CONSTs 和 TMPs。由于这个宏需要区分 TMPs 和 VARs,所以只能被用于TMP|VAR类型操作数中(而不能用于TMPVAR)。

GET_OPn_OBJ_ZVAL_PTR*()是一类会额外处理 UNUSED 操作数的宏。正如前面提到的,在访问$this的情况下,使用一个 UNUSED 操作数,GET_OPn_OBJ_ZVAL_PTR*()宏会为 UNUSED 操作数返回一个EX(This)的引用。

最后,还有一类PTR_PTR的宏,这种名字是 PHP5 时代的残存物,它们实际上用于对 zval 双重取址的指针。这些宏在被用于写操作的时候只适用于 CV 和 VAR 类型的操作数(其他的一律返回 NULL)。

FREE_OP*()宏用于释放查询到的操作数。操作的时候,它们需要一个定义为zend_free_op free_opN的变量,其中 GET 操作存放的数据会被释放。FREE_OPn()操作会释放 TMPs 和 VARs,但是不会释放 CONSTs 和 CVs。FREE_OPn_IF_VAR()顾名思义:如果操作数是一个 VAR 的话就释放。

FREE_OP*_VAR_PTR()是跟PTR_PTR查询结合使用的,它只会释放 VAR 操作数并且它们不能是 INDIRECTed。

FREE_UNFETCHED_OP*()用于操作数在被 GET 查找之前就必须被释放的情况。典型的使用场景就是当异常发生在操作数查找前面的时候。

除了这些特定的宏,还有一些更普通的宏。VM 定义了一些用于控制一个 opcode handler 执行完之后的行为的宏:

ZEND_VM_CONTINUE()
ZEND_VM_ENTER()
ZEND_VM_LEAVE()
ZEND_VM_RETURN()

CONTINUE 会继续执行正常的 opcodes,ENTER/LEAVE 用于进入或退出一个嵌套函数调用。这些操作的具体细节取决于编译器是如何编译的。从广义上讲,它们在继续执行前会同步一些全局状态。RETURN 用于退出主 VM 循环。

ZEND_VM_CONTINUE()要求 opline 事先更新完。当然也有一些其他的相关的宏:

                                        | Continue? | Check exception? | Check interrupt?
ZEND_VM_NEXT_OPCODE()                   |   yes     |       no         |       no
ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION()   |   yes     |       yes        |       no
ZEND_VM_SET_NEXT_OPCODE(op)             |   no      |       no         |       no
ZEND_VM_SET_OPCODE(op)                  |   no      |       no         |       yes
ZEND_VM_SET_RELATIVE_OPCODE(op, offset) |   no      |       no         |       yes
ZEND_VM_JMP(op)                         |   yes     |       yes        |       yes

这个表格展示了哪些宏当中隐含了 ZEND_VM_CONTINUE()操作,它们是否需要做异常校验和是否校验 VM 中断。

接着是SAVE_OPLINE()LOAD_OPLINE()HANDLE_EXCEPTION()。正如前面提到的,SAVE_OPLINE()用于 opcode handler 中第一次进入 slow-path 操作之前。如果有必要的话,它会将 VM 使用的 opline 备份(通常在一个全局寄存器)到 execute data 里。LOAD_OPLINE()是一个逆操作,但是现今它已经很少被使用,因为它被有效的柔和到 ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION()和 ZEND_VM_JMP()里了。

HANDLE_EXCEPTION()用于当你在一个 opcode handler 返回前已经明确知道有异常被抛出的情况下。它会同时执行 LOAD_OPLINE 和 CONTINUE,它们被有效的分配到了 HANDLE_EXCEPTION opcode 里。

当然,还有很多宏没有介绍到,但是我想这里应该已经覆盖到最重要的部分了吧。