PHP 内核:讲下 PHP 7 底层虚拟机工作原理 —— Zend Virtual Machine 7.2 版本

本文旨在提供Zend虚拟机的概述,如php 7所示。这不是一个全面的描述,但我试图涵盖大部分重要部分,以及一些更精细的细节。

此描述针对的是PHP 7.2版(目前正在开发中),但几乎所有内容都适用于PHP 7.0/7.1。然而,与php 5.x系列vm的区别是非常显著的,我一般不会费心去画平行线。

本文的大部分内容将考虑指令清单级别的内容,并且在最后的部分只讨论虚拟机的实际C级实现。但是,我想提供一些指向构成VM的主文件的链接:

Opcodes

一开始,有一个操作码。“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;
};

因此,操作码本质上是一种“三地址码”指令格式。有一个opcode决定指令类型,有两个输入操作数op1op2以及一个输出操作数result

并非所有指令都使用所有操作数。“add”指令(表示“+”运算符)将使用这三种指令。一条“bool-not”指令(代表“!` operator)仅使用op1和result。“echo”指令只使用op1。有些指令可以使用或不使用操作数。例如,根据是否使用函数调用的返回值,do fcall可能有结果操作数,也可能没有结果操作数。有些指令需要两个以上的输入操作数,在这种情况下,它们只需使用第二个伪指令(“op_data”)来携带额外的操作数。

在这三个标准操作数旁边,还有一个附加的数字“扩展值”字段,可用于保存附加的指令修饰符。例如,对于“cast”,它可能包含要强制转换到的目标类型。

每个操作数都有一个类型,分别存储在“op1_type”、“op2_type”和“result_type”中。可能的类型有“is-unused”、“is-const”、“is-tmpvar”、“is-var”和“is-cv”。

后三种类型指定了一个变量操作数(具有三种不同类型的VM变量),‘is const’表示一个常量操作数(‘5’或‘string’或甚至是[1,2,3]),‘is unused’表示一个实际未使用的操作数,或者是一个32位数值(汇编术语中的‘immediate’)。例如,跳转指令将跳转目标存储在“unused”操作数中。

获取 opcode 转储

在下面,我将会展示PHP为一些示例代码生成的操作码序列。目前有三种方法可以获得这种操作码转储:

# Opcache, 从PHP 7.1以后
php -d opcache.opt_debug_level=0x10000 test.php

# phpdbg, 从PHP 5.6以后
phpdbg -p* test.php

# vld, 第三方扩展
php -d vld.active=1 test.php

其中,opcache提供了最高质量的输出。本文中使用的清单基于opcache转储,只进行了少量语法调整。神奇的数字0x10000是“优化之前”的缩写,因此我们可以看到PHP编译器生成的操作码。0x20000将为您提供优化的操作码。Opcache还可以生成更多的信息,例如0x40000将生成一个CFG,而0x200000将生成类型和范围推断的SSA表单。但这超出了我们的范围:简单的旧线性化操作码转储就足以满足我们的目的。

变量类型

在处理 PHP 虚拟机时,需要理解的最重要的一点可能是它使用的三种不同的变量类型。在 PHP 5 TMPVAR 中,VAR 和 CV 在 VM 堆栈上有不同的表示,以及访问方式的不同。在 PHP7 中,它们变得非常相似,因为它们共享了相同的存储机制。但是,它们可以包含的值和它们的语义有重要的区别。
CV是“编译变量”的缩写,指的是“真实的”PHP变量。如果函数使用变量$a,则对应的CV值为$a
CVs可以具有UNDEF类型,以表示未定义的变量。如果在指令中使用UNDEF CV,它(在大多数情况下)将抛出众所周知的“未定义变量”通知。在函数输入时,所有非参数CVs都初始化为UNDEF

CVs不被指令使用,例如,指令ADD$a,$b破坏存储在CVs$a$b中的值。相反,所有的CVs在作用域退出时一起被销毁。这也意味着在整个函数持续时间内,所有的CVs都是“活动的”,其中“活动”在这里指的是包含一个有效值(不在数据流意义上)。

另一方面,TMPVARs和VARs是虚拟机的临时设备。它们通常作为某些操作的结果操作数引入。例如,代码$A=$B+$C+$D将产生类似以下的操作码序列:

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

TMP/VARs总是在使用前定义的,因此不能保存UNDEF值。与CVs不同,这些变量类型由它们所使用的指令使用。在上面的示例中,第二个ADD将破坏T0操作数的值,并且在该点之后不得使用T0(除非事先写入)。同样,这个ASSIGN将使用T1的值,从而使T1无效。

因此,tmp/vars通常寿命很短。在许多情况下,一个临时的指令只为一个指令的空间而存在。在这个短暂的活动性间隔之外,临时值是垃圾。

那么,tmp和var有什么区别呢?不多。区别是从php 5继承的,其中tmp是分配给vm堆栈的,var是分配给堆的。在php 7中,所有变量都是堆栈分配的。因此,现在tmps和vars之间的主要区别在于,只允许后者包含引用(这允许我们在tmps上省略derefs)。此外,var可以包含两种类型的特殊值,即类条目和间接值。后者用于处理非琐碎的分配。

下表试图总结主要差异:

       | 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_function'头的结构。这里的“函数”要理解得稍微宽泛一些,包括从“实”函数、over方法到独立的“伪主”代码和“eval”代码的所有内容。

userland函数使用“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;
    /* ... */
};

这里最重要的部分当然是“操作码”,它是一组操作码(指令)。last是此数组中的操作码数。请注意,这里的术语令人困惑,因为“last”听起来应该是最后一个操作码的索引,而实际上是操作码的数目(比最后一个索引大一个)。这同样适用于操作数组结构中的所有其他“last”值。

最后一个“var”是cvs的数量,“t”是tmp和var的数量(在大多数地方,我们对它们没有很强的区分)。 vars`在cvs的名称数组中。

literals是代码中出现的一个文本值数组。此数组是“const”操作数引用的。根据abi的不同,每个“const”操作数要么存储一个指向此“literals”表的指针,要么存储一个相对于其开始位置的偏移量。

OP数组结构的内容比此多,但它可以稍后等待。

Stack frame layout

除了一些执行器全局(例如),所有执行状态都存储在虚拟机堆栈上。虚拟机堆栈以256 kib的页面分配,各个页面通过链接列表连接。

在每个函数调用上,在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'包含函数参数。对于tmp/vars,cv槽后跟“t”槽。最后,有时在帧的末尾存储“额外”参数。这些用于处理'func_get_args()'。

指令中的cv和tmp/var操作数被编码为相对于堆栈帧起始点的偏移量,因此获取某个变量只是从'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”(当前执行的函数)。此外:

-`返回值是指向zval的指针,zval将存储返回值。

-这是$this`对象,但也会在一些未使用的zval空间中编码函数参数的数量和一些调用元数据标志。

-`在PHP代码中,“static::”引用的作用域称为“u scope”。

-prev_execute_data指向上一个堆栈帧,此函数运行完成后将返回到该堆栈帧。

-symbol_table是一个典型的未使用的符号表,用于一些疯狂的人实际使用变量或类似的功能。

-run-time-cache缓存操作数组运行时缓存,以便在访问此结构时避免一个指针间接指向(稍后将讨论)。

-` literals的缓存操作数组literals表的原因相同。

Function calls

我跳过了execute_数据结构中的一个字段,即“call”,因为它需要一些关于函数调用如何工作的进一步上下文。

所有调用都使用同一指令序列的变体。全局范围中的“var_dump($A,$B)”将编译为:

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

根据调用的类型,有八种不同类型的init指令。init_fcall用于调用我们在编译时识别的自由函数。同样,根据参数和函数的类型,有十种不同的send操作码。只有少量的四个do-call操作码,其中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

我缩进了 opcode 序列,方便看到哪些说明对应于哪个调用。

INIT opcode 在栈上推送一个调用框,它包含足够的空间用于函数中的所有变量以及我们知道的参数数量(如果涉及参数解包,我们可能会得到更多的参数)。 这个调用框使用被调用函数$ thiscalled_scope进行初始化(在这种情况下,后者都是NULL,因为我们在调用自由函数)。

指向新帧的指针存储在 execute_data->call,其中 execute_data 是调用函数的帧。在下面的代码中,我们将把这样的调用表示为 EX(call)。值得注意的是,新帧的 prev_execute_data 被设置为旧的 EX(call) 值。例如,调用 foo 时的 INIT_FCALL,将会把 prev_execute_data 设置为 var_dump 的堆栈帧(而不是其它函数的堆栈帧)。因此,在本例中,prev_execute_data 形成一个“未完成”的调用链列表,而通常它会提供回溯链。

然后 SEND 操作码将参数推入 EX(call) 的变量槽中。此时,所有参数都是连续的,并且可能会从为参数指定的部分溢出到其他 CV 或 TMP 中。这个问题稍后会解决。

最后,DO_FCALL 执行实际调用。 EX(call)成为当前函数,prev_execute_data被重新链接到调用函数。 除此之外,调用程序取决于它是什么类型的函数。 内部函数只需要调用处理函数,而用户空间函数需要完成堆栈的初始化。

此初始化涉及修复参数堆栈。PHP 允许向函数传递比预期更多的参数(func_get_args依赖于此)。但是,只有实际声明过的参数具有相应的编译变量。 除此之外的任何参数都将写入内存中,用来保存其他编译变量和临时变量。 因此,这些参数将被移动到临时变量之后,最终参数会分隔为两个非连续的块。

要明确说明的是,用户空间函数的调用不涉及虚拟机级别的递归。 它们只涉及从一个 execute_data 到另一个execute_data 的切换,但虚拟机会继续以线性循环运行。 仅当内部函数调用用户空间回调时(例如通过array_map),才会发生虚拟机级别的递归。 这就是为什么 PHP 中的无限递归通常会导致内存限制或内存不足错误的原因,但是可以通过回调函数或魔术方法的递归来触发堆栈溢出。

用参数传递 Opcode

PHP使用大量不同的参数传递 opcodes,因为不太好的参数命名,这些参数可能容易让人犯晕。

SEND_VAL 和 SEND_VAR就是一对最简单的变体,它们处理在编译时是按值的按值参数的传递。 SEND_VAL 用于常量和临时操作数,而 SEND_VAR 用于变量和编译变量。

相反,SEND_REF 用于编译时是按引用的参数。 由于变量只能通过引用传递,因此该 opcode 仅接受变量(VARs)和编译变量(CVs)。

SEND_VAL_EX 和 SEND_VAR_EX 是 SEND_VAL / SEND_VAR 的变体,用于我们无法确定参数是按值还是按引用的情况。 这些 opcodes 将根据 arginfo 检查参数的类型并作出相应的表现。 在大多数情况下,不会用到 arginfo,而是直接在函数体中使用紧凑的位向量表示。

然后是 SEND_VAR_NO_REF_EX。 只看命名的确难以看不出它是用来干嘛的。 这个 opcode 是用来传递那些不是真正的变量,但确实又返回了一个变量到某个未知的参数的。 使用它的两个特定场景分别是,将函数调用的结果作为参数传递,或传递某个赋值的结果。

以上 SEND_VAR_NO_REF_EX 的情况需要一个单独的 opcode 有两个原因:首先,如果你尝试传递按引用的赋值,它会报出我们熟悉的“只有变量可以通过引用传递”的通知级别的错误(如果使用 SEND_VAR_EX 代替 SEND_VAR_NO_REF_EX,那么是允许的,不会报错)。 其次,还有一种情况是,你可能想要传递某个引用返回函数的结果到一个按引用的参数(这个函数不抛出任何异常)。 SEND_VAR_NO_REF_EX 的变体 SEND_VAR_NO_REF 是当我们知道参数是按引用类型时(但我们不知道参数是否只有一个)专用的变体。

SEND_UNPACK 和 SEND_ARRAY 分别处理参数拆包和内联的call_user_func_array调用。 它们都将数组中的元素压进到参数堆栈,其中的细节却不尽相同(例如,参数拆包支持遍历,而 call_user_func_array 则不支持)。 如果使用参数拆包或者 call_user_func_array ,则可能需要将堆栈帧扩展超出其先前的大小(因为在初始化时不知道实际的函数参数数量)。 在大多数情况下,只需移动堆栈的顶部指针即可实现扩展。 但是,如果这会跨越堆栈的页面边界,则必须分配新页面,并且需要将整个调用(包括之前已经压进的参数)复制到新页面(我们无法处理跨页面边界的调用)。

最后一个 opcode 是 SEND_USER,它用于内联的call_user_func调用并处理它的一些特性。

虽然我们还没有讨论过不同的变量获取模式,不妨在这里介绍一下 FUNC_ARG 获取模式。 假定有一个简单的调用,如func($a[0][1][2]),在编译时不知道参数是按值传递还是按引用传递的。 这两种情况表现会大不相同。 如果是按值传递并且$a以前空的,则会报出很多 “undefined index” 通知级别的错误。 如果是按引用传递的,我们必须默默地初始化这个多维数组。

FUNC_ARG 获取模式将通过检查当前EX(call)函数的 arginfo 动态地选择两种情况之一(按值传递或按引用传递)。 对于func($a[0][1][2])示例,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虚拟机有四类获取操作码:

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

这些函数的作用与我们所期望的完全相同,但是需要注意的是,基本的FETCH_*变量只用于访问变量和超全局变量:普通的变量访问需要通过更快的CV机制。

这些获取操作码有六种变体:

_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:其他所有操作都通过特殊的操作码处理。注意ASSIGN_DIM和ASSIGN_ADD都使用一个额外的OP_DATA,因为它们需要两个以上的输入操作数。使用ASSIGN_DIM这样的特殊操作码而不是FETCH_DIM_W + ASSIGN这样的操作码的原因(除了性能)在于,这些操作可能会重载,例如在ASSIGN_DIM的情况下,通过一个实现ArrayAccess::offsetSet()的对象来重载。为了实际生成不同的获取类型,我们需要增加嵌套的级别:

// $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)

这里我们看到,虽然最外层的访问使用专门的操作码,但是嵌套索引将使用具有适当获取模式的FETCHes来处理。取值模式的本质区别在于a)如果索引不存在,是否生成“未定义偏移量”通知,是否取值用于写入:

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

UNSET的情况有点特殊,因为它只获取用于编写的现有偏移量,而不处理未定义的偏移量。正常的写-取操作将初始化未定义的偏移量。

写入和内存安全

写获取返回VARs,它可能包含一个普通的zval或一个指向另一个zval的间接指针。当然,在前一种情况下,应用于zval的任何更改都是不可见的,因为该值只能通过VM临时访问。虽然PHP禁止[][0]= 42这样的表达式,但是我们仍然需要处理call()[0] = 42这样的情况。根据call()是按值返回还是按引用返回,这个表达式可能还需再观察。

更典型的情况是,fetch返回一个间接的,它包含一个指向正在修改的存储位置的指针,例如hashtable数据数组中的某个位置。不幸的是,这样的指针是脆弱的,很容易失效:任何对数组的并发写都可能触发重新分配,留下一个悬空指针。因此,关键是要防止在创建间接值和使用间接值之间执行用户代码。

考虑一下这个例子:

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

它会生成:

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

值得注意的是,这个序列将会先从左到右执行所有的调用指令,然后才执行一些必要的写取操作(这里我们将 FETCH_DIM_W 称为延迟的 opline)。这会确保写取操作跟执行指令是紧密相邻的。

思考另一个实例:

$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 将其转换为一个引用。最终 MAKE_REF 的结果不再是 INDIRECT,也不是无效约束,因为可以安全地执行 $arr[0] 的获取操作。

异常处理

异常是万恶之源。

异常是通过将异常写入 EG(exception) 生成的,其中 EG 引用的是执行器的全局变量。从 C 代码抛出的异常没有涉及到堆栈解退,而是通过返回的失败代码或者检查 EG(exception) 从而终止向上传播。只有当控制重载虚拟机代码时,才会处理异常。

在某些情况下,几乎所有的 VM 指令都可以直接或间接地导致异常。例如,如果使用自定义的异常处理程序,任何“未定义变量”都可能会导致异常。我们可能想要避免检查 EG(exception) 是否已经在每个 VM 指令后设置了。而这里使用了一个小技巧:

当抛出异常时,当前执行的 opline 将替换为一个伪 HANDLE_EXCEPTION opline(这显然不会修改 op 数组,它只是重置了一个指针)。来源于异常的 opline 被修改为 EG(opline_before_exception)

这意味着当控件返回到主虚拟机调度循环时,将调用HANDLE_EXCEPTION操作码。这个计划有一个小问题:它要求a)存储的opline执行数据实际上是当前执行opline(否则opline_before_exception就错了)和b)的虚拟机使用opline执行数据继续执行(否则HANDLE_EXCEPTION将不会被调用)。

虽然这些需求听起来微不足道,但它们并非如此。原因是虚拟机可能正在处理与存储在执行数据中的opline不同步的另一个opline变量。在PHP 7之前,这只发生在很少使用的GOTO和SWITCH虚拟机中,而在PHP 7中,这实际上是默认的操作模式:如果编译器支持它,那么opline将存储在全局寄存器中。

因此,在执行任何可能抛出的操作之前,必须将本地opline写回执行数据(SAVE_OPLINE操作)。类似地,在任何潜在的抛出操作之后,必须从执行数据填充本地联机(主要是CHECK_EXCEPTION操作)。

现在,这个机制是导致抛出异常后执行HANDLE_EXCEPTION操作码的原因。但是它是做什么的呢?首先,它确定异常是否在try块中抛出。为此,op数组包含一个try_catch_elements数组,用于跟踪try,catch和finally块的在线偏移量:

typedef struct _zend_try_catch_element {
    uint32_t try_op;
    uint32_t catch_op;  /* 番茄酱! */
    uint32_t finally_op;
    uint32_t finally_end;
} zend_try_catch_element;

现在,我们模拟finally块不存在,因为它们是一个完全不同的rabbit hole。假设我们确实在一个try块中,VM需要清理在抛出opline之前启动的所有未完成的操作,并且不要跨越try块的末端。

这包括释放当前正在运行的所有调用的堆栈帧和相关数据,以及释放实时临时调用。在大多数情况下,临时指令是短暂的,以至于消费指令直接跟随生成指令。然而,也可能发生的情况是,实时范围跨越多个,潜在的抛出指令:

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

在本例中,T0变量在指令L1和L2期间是活动的,因此,如果函数调用引发,则需要销毁T0变量。一种特殊的临时类型往往具有特别长的生存范围:循环变量。例如:

# 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

这里的“循环变量”V0从L1到L3(通常总是跨越整个循环体)。活动范围使用以下结构存储在op数组中:

typedef struct _zend_live_range {
    uint32_t var; /* 低比特用于变量类型(ZEND_LIVE_* macros) */
    uint32_t start;
    uint32_t end;
} zend_live_range;

这里,var是(操作数编码的)变量,start是起始opline偏移量(不包括生成指令),而end是结束opline偏移量(包括消费指令)。当然,只有在临时没有立即使用时,才会存储活动范围。

var的下半部分用来存储变量的类型,可以是:

  • ZEND_LIVE_TMPVAR:这是一个“普通”变量。它持有一个普通的zval值。释放这个变量的行为就像释放一个操作码。
  • ZEND_LIVE_LOOP:这是一个foreach循环变量,它包含的不仅仅是一个简单的zval。这对应于一个FE_FREE操作码。
  • ZEND_LIVE_SILENCE:这用于实现错误抑制操作符。旧的错误报告级别备份为临时级别,稍后恢复。如果抛出异常,我们显然也希望恢复它。这对应于END_SILENCE。
  • ZEND_LIVE_ROPE:这用于rope字符串连接,在这种情况下,临时是堆栈上的zend_string*指针的固定大小的数组。在这种情况下,必须释放所有已经填充的字符串。近似地对应于END_ROPE。

在此上下文中需要考虑的一个棘手问题是,如果临时指令的生成或消费指令抛出,是否应该释放临时指令。考虑以下简单的代码:

T2 = ADD T0, T1
ASSIGN $v, T2

如果ADD引发异常,T2临时是否应该被自动释放,或者ADD指令是否对此负责?类似地,如果ASSIGN抛出,T2应该被自动释放,还是ASSIGN必须自己处理这个问题?在后一种情况下,答案很清楚:一条指令总是负责释放它的操作数,即使抛出异常也是如此。

结果操作数的情况比较复杂,因为这里的答案在PHP 7.1和7.2之间发生了变化:在PHP 7.1中,指令负责在异常情况下释放结果。在PHP 7.2中,它被自动释放(该指令负责确保结果总是填充)。这种变化的动机是实现许多基本指令(如ADD)的方式。它们通常的结构大致如下:

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

这是有问题的,因为PHP处于非常不幸的境地,不仅支持异常和析构函数,而且还支持抛出析构函数(这是编译器工程师害怕的地方)。因此,步骤3可以抛出,此时结果已经被填充。为了避免这种情况下的内存泄漏,释放结果操作数的责任已经从指令转移到异常处理机制。

一旦执行了这些清理操作,就可以继续执行catch块。如果没有捕获(最后也没有),我们将展开堆栈,即销毁当前堆栈帧,并让父帧尝试处理异常。

因此,你将充分了解整个异常处理业务是多么糟糕,我将介绍另一个与抛出析构函数相关的小技巧。在实践中,它并没有什么关系,但是我们仍然需要处理它来确保正确性。考虑这段代码:

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

现在假设Dtor是一个可遍历的类,带有一个抛出析构函数。这段代码将产生如下操作码序列,循环体缩进以保证可读性:

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。如果抛出了FE_FREE,因为Dtor有一个抛出析构函数,会发生什么?通常,我们会说这条指令在try块中,所以我们应该调用catch。然而,此时循环变量已经被销毁了!catch丢弃异常,我们将继续迭代一个已经死循环变量。

这个问题的原因是,虽然抛出的FE_FREE在try块中,但它是L11中FE_FREE的副本。从逻辑上讲,异常“真正”发生在这里。这就是为什么break生成的FE_FREE被标注为FREE_ON_RETURN。这指示异常处理机制将异常的源移动到原始释放指令。因此,上面的代码不会运行catch块,而是生成一个未捕获的异常。

Finally处理

PHP使用finally块的历史有点麻烦。PHP 5.5首先引入了finally块,或者说:finally块的一个有bug的实现。每个PHP 5.6、7.0和7.1都附带了最终实现的主要重写,每个都修复了大量bug,但都没有完全实现完全正确。看起来PHP 7.1终于成功地击中了要害(手指交叉)。

在编写本节时,我惊奇地发现,从当前实现和我目前的理解来看,最终的处理实际上并不那么复杂。实际上,在许多方面,通过不同的迭代实现变得更简单,而不是更复杂。这说明了对问题理解不足会导致实现过于复杂和错误(尽管公平地说,PHP 5实现的部分复杂性直接源于缺乏AST)。

每当控件退出一个try块时(通常是使用return)或异常(通过抛出),就会运行finally块。有几个有趣的边缘情况需要考虑,在开始实现之前,我将快速说明这些情况。考虑一下:

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 control委托模式轻松解决:

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

唯一真正存在的限制是不可能跳转finally块,例如,从finally外部执行goto到finally内部的标签是被禁止的。

准备工作完成之后,我们可以看看最后是如何工作的。实现使用两个操作码,FAST_CALL和FAST_RET。基本上,FAST_CALL用于跳转到finally块,FAST_RET用于跳出它。让我们考虑最简单的情况:

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

此代码编译成以下操作码序列:

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中,并跳转到L3处的finally块。当到达FAST_RET时,它会跳转回(1之后)存储在T0中的位置。在这种情况下,这是L2,也就是在最后一个块上的跳跃。这是没有发生特殊控制流(返回或异常)的基本情况。现在让我们考虑一个特例:

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

当处理异常时,我们必须考虑抛出异常相对于最近的try/catch/finally块的位置:

  1. 抛出try使用匹配的catch:填充$e并跳转到catch。
  2. 如果有一个finally块,则从catch抛出或尝试而不匹配catch:跳转到finally块,这次将异常备份到FAST_CALL临时处(而不是将返回地址存储在那里)。
  3. 从finally抛出:如果FAST_CALL临时处中有一个备份的异常,将其链接为抛出异常的前一个异常。继续冒泡异常直到下一次try/catch/finally。
  4. 否则:继续冒泡异常直到下一次try/catch/finally。

在本例中,我们将经历前三个步骤:第一次尝试抛出,触发跳转到catch。Catch也抛出,触发跳转到finally块,但在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操作码中(这里是“try-catch(0)”)。

这基本上涵盖了finally和exception的交互。但finally的返回呢?

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

操作码序列的相关部分如下:

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

额外的DISCARD_EXCEPTION操作码负责丢弃try块中抛出的异常(请记住: finally的返回将获胜)。那么try中的返回呢?

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

这里的异常返回值是42,而不是43。返回值由return $a行决定,对$a的任何进一步修改都无关紧要。代码的结果是:

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

其中两个操作码是不可访问的,因为它们直接发生在返回之后。这些将在优化过程中删除,但我在这里显示的是未优化的操作码。这里有两件有趣的事情:首先,使用QM_ASSIGN将$a复制到T3中(QM_ASSIGN基本上是一条“复制到临时处”指令)。这是防止$a的后续修改影响返回值的原因。其次,T3也被传递给FAST_CALL, FAST_CALL将备份T1中的值。如果try块的返回稍后被丢弃(e.g,因为finally抛出或返回),此机制将用于释放未使用的返回值。

所有这些单独的机制都很简单,但是在组合时需要注意一些。考虑下面的例子,其中Dtor又是一个可遍历的类,带有一个抛出析构函数:

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

此代码生成以下操作码:

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

第一个返回序列(来自inner try)是FAST_CALL L10,FE_FREE V4,FAST_CALL L19,RETURN。这将首先调用内部finally块,然后释放foreach循环变量,然后调用外部finally块,然后返回。第二个返回序列(来自inner finally)是DISCARD_EXCEPTION T5,FE_FREE V4,FAST_CALL L19。这首先丢弃内部try块的异常(或这里:返回值),然后释放foreach循环变量,最后调用外部finally块。注意,在这两种情况下,这些指令的顺序与源代码中相关块的顺序相反。

生成器

生成器函数可能会暂停和恢复,因此需要特殊的VM堆栈管理。这里有一个简单的生成器:

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

这将产生以下操作码:

$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堆栈来推送调用帧。一个明显的问题是,在调用过程中可能会中断生成器,如前面的示例所示。当foo()调用的调用帧已经被推送到VM堆栈上时,这个YIELD就会在一个指针被执行。

这种相对不常见的情况是在生成控件时将活动调用帧复制到生成器结构中,并在恢复生成器时恢复它们。

此设计从PHP 7.1开始使用。以前,每个生成器都有自己的4KiB VM页面,当恢复生成器时,该页面将被交换到执行器中。这避免了复制调用帧的需要,但是增加了内存使用。

智能分支

比较指令之后紧接着条件跳转是非常常见的。例如:

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

注意,在IS_EQUAL和JMPZ之间插入了NOP。如果这个NOP不存在,分支最终将使用IS_EQUAL结果,而不是JMPZ操作数。

运行缓存

因为操作码数组在多个进程之间是共享的(没有锁),所以它们严格来说是不可变的。然而,运行时值可以缓存在一个单独的“运行缓存”中,它基本上是一个指针数组。文字可能有一个相关的运行时缓存条目(或多个),存储在它们的u2插槽中。

运行缓存条目有两种类型:第一种是普通的缓存条目,比如INIT_FCALL使用的缓存条目。在INIT_FCALL查询了被调用的函数一次之后(根据它的名称),函数指针将被缓存在关联的运行缓存槽中。

第二种类型是多态缓存条目,它只是两个连续的缓存槽,其中第一个存储类条目,第二个存储实际数据。这些用于诸如FETCH_OBJ_R之类的操作,其中缓存了某个类的属性表中的属性偏移量。如果下一次访问发生在同一个类上(很有可能),那么将使用缓存的值。否则将执行更昂贵的查找操作,并为新类条目缓存结果。

VM中断

在PHP 7.0之前,执行超时通常由一个longjump直接从信号处理程序处理到关机序列。你可以想象,这引起了各种各样的不快。因为PHP 7.0超时被延迟,直到控件返回到虚拟机。如果在一定的宽限期内没有返回,则进程将终止。因为PHP 7.1 pcntl信号处理程序使用与执行超时相同的机制。

当一个信号挂起时,会设置一个VM中断标志,并在特定的时间点由虚拟机检查这个标志。不是每条指令都执行检查,而是只对跳转和调用执行检查。因此,中断不会在返回VM时立即处理,而是在当前线性控制流部分的末尾处理。

专业化

如果你查看一下VM定义文件,你会发现操作码处理程序的定义如下:

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

这里的1是操作码号,ZEND_ADD是它的名称,而其他两个参数指定指令接受哪个操作数类型。生成的虚拟机代码(由zend_vm_gen.php生成)将包含每个可能的操作数类型组合的专用处理程序。它们的名称将类似于ZEND_ADD_SPEC_CONST_CONST_HANDLER。

专门化处理程序是通过替换处理程序主体中的某些宏生成的。最明显的是OP1_TYPE和OP2_TYPE,但是GET_OP1_ZVAL_PTR()和FREE_OP1()等操作也是专门化的。

ADD的处理程序指定它接受CONST|TMPVAR|CV操作数。这里的TMPVAR意味着操作码同时接受TMPs和VARs,但要求它们不能单独专门化。请记住,在大多数情况下,TMP和VAR之间的唯一区别是后者可以包含引用。对于像ADD这样的操作码(无论如何引用的速度很慢),不值得为此进行单独的专门化。一些其他的操作码会在它们的操作数列表中使用TMP|VAR

除了基于操作数类型的专门化之外,处理程序还可以专门化其他因素,比如是否使用它们的返回值。ASSIGN_DIM基于以下OP_DATA操作码的操作数类型进行专门化:

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

根据这个签名,将生成244=32个ASSIGN_DIM的不同变体。第二个操作数的规范还包含一个NEXT条目。这与专门化无关,相反,它指定了未使用的操作数在上下文中是什么意思:它意味着这是一个追加操作($arr[])。另一个例子:

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

这里,第一个未使用的操作数意味着对$this的访问。这是对象相关操作码的通用约定,例如FETCH_OBJ_R used, 'prop'对应于$this->prop。未使用的第二个操作数同样意味着追加操作。这里的第三个参数指定extended_value操作数的含义:它包含一个标志,用于区分$a += 1$a[$b] += 1$a->b += 1。最后,SPEC(DIM_OBJ)指示应该为每个处理程序生成一个专门的处理程序。(在这种情况下,将生成的总处理程序的数量不是微不足道的,因为VM生成器知道某些组合是不可能的。例如,一个未使用的op1只与OBJ情况相关,等等。)

最后,虚拟机生成器支持一个附加的、更复杂的专门化机制。在定义文件的末尾,你会发现许多这样的处理程序:

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)
)

这些处理程序不仅基于VM操作数类型进行专门化,还基于操作数在运行时可能使用的类型。确定可能的操作数类型的机制是opcache优化基础结构的一部分,完全超出了本文的范围。然而,假设这些信息是可用的,那么应该很清楚,这是一个附加形式int + int -> int的处理程序。此外,SPEC注释告诉专门化器,不应该生成两个const操作数的变体,并且该操作是可交换的,因此,如果我们已经有了CONST +TMPVARCV专门化,我们也不需要生成TMPVARCV+ CONST。

快路径/慢路径分割

许多操作码处理程序都是使用快路径/慢路径分割实现的,在返回到通用实现之前,先处理一些常见的情况。是时候看看一些实际的代码了,所以我将把整个SL(左移)实现粘贴到这里:

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();
}

实现首先在BP_VAR_R模式下使用GET_OPn_ZVAL_PTR_UNDEF获取操作数。这里的UNDEF部分意味着在CV中不执行对未定义变量的检查,而是返回一个UNDEF值。一旦我们有了操作数,我们检查它们是否都是整数,并且移位宽度在范围内,在这种情况下,结果可以直接计算,然后我们进入下一个操作码。值得注意的是,这里的类型检查并不关心操作数是否是UNDEF,因此使用GET_OPn_ZVAL_PTR_UNDEF是合理的。

如果操作数不恰好满足快路径,我们将返回到泛型实现,它从SAVE_OPLINE()开始。这是我们为“潜在的抛出操作跟随”发出的信号。在进一步讨论之前,先处理未定义变量的情况。在这种情况下,GET_OPn_UNDEF_CV将发出一个未定义的变量通知并返回一个空值。

接下来,调用泛型shift_left_function并将其结果写入EX_VAR(opline->result.var)。最后,释放输入操作数(如果必要的话),然后我们进行异常检查(这意味着在继续之前要重新加载opline),然后进入下一个操作码。

因此,这里的快路径保存了对未定义变量的两次检查、对泛型操作符函数的调用、释放操作数以及保存和重新加载opline以进行异常处理。大多数性能敏感的操作码以类似的方式

VM宏

从前面的代码清单中可以看出,虚拟机实现大量使用宏。其中一些是普通的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_*指令相同的模式(FUNC_ARG除外)。

GET_OPn_ZVAL_PTR()是基本的操作数获取。它将在未定义的CV上抛出一个通知,并且不会取消对操作数的引用。GET_OPn_ZVAL_PTR_UNDEF()是一个变体,它不检查未定义的CVs。GET_OPn_ZVAL_PTR_DEREF()包含了一个zval的DEREF。这是专门的GET操作的一部分,因为解引用仅对CVs和VARs是必要的,而对CONSTs和TMPs则不是。因为这个宏需要区分TMP和VARs,所以它只能与TMP|VAR专门化一起使用(但不能与TMPVAR一起使用)。

GET_OPn_OBJ_ZVAL_PTR*()变量还处理未使用操作数的情况。如前所述,按照惯例$this访问使用一个未使用的操作数,因此GET_OPn_OBJ_ZVAL_PTR*()宏将为未使用的操作数返回对EX(this)的引用。

最后,还有一些PTR_PTR变体。这里的命名是从PHP 5中遗留下来的,它实际上使用了双指向zval指针。这些宏用于写操作,因此只支持CV和VAR类型(其他任何类型都返回NULL)。它们不同于普通的PTR获取,因为它们去间接的VAR操作数。

然后使用FREE_OP*()宏释放获取的操作数。要进行操作,它们需要定义一个zend_free_op free_opN变量,GET操作将要释放的值存储在这个变量中。基线FREE_OPn()操作将释放TMPs和VARs,但不会释放CVs和CONSTs。FREE_OPn_IF_VAR()完全按照它说的做:只有当操作数是VAR时才释放操作数。

FREE_OP*_VAR_PTR()变量与PTR_PTR获取一起使用。它将释放VAR操作数,而且只有当它们没有被重定向。

FREE_UNFETCHED_OP*()变量用于在使用GET获取操作数之前必须释放操作数的情况。如果在获取操作数之前抛出异常,通常会发生这种情况。

除了这些专用宏之外,还有一些更普通的宏。VM定义了三个宏来控制操作码处理程序运行后发生的事情:

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

CONTINUE将像往常一样继续执行操作码,而ENTER和LEAVE用于输入/离开嵌套函数调用。这些操作的具体方式取决于VM是如何编译的(例如,是否使用全局寄存器,如果使用,使用哪个寄存器)。从广义上说,这些操作将在继续之前同步全局变量的一些状态。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() 在操作码处理程序中第一个潜在的抛出操作之前使用。如果需要,它将 VM 使用的oline (可能在全局寄存器中)写回执行数据。LOAD_OPLINE() 是相反的操作,但是现在它几乎没有什么用处,因为它已经被有效地卷到了ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION() 和 ZEND_VM_JMP() 中。
HANDLE_EXCEPTION()用 于在您已经知道抛出异常之后从操作码处理程序返回。它执行 LOAD_OPLINE 和 CONTINUE 的组合,这将有效地分派到HANDLE_EXCEPTION 操作。
当然,有更多的宏(总是有更多的宏……),但是这应该涵盖最重要的部分。

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://nikic.github.io/2017/04/14/PHP-7...

译文地址:https://learnku.com/php/t/30603

本帖已被设为精华帖!
本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
讨论数量: 3
suhanyujie

译文有些地方格式比较乱,还请译者细心一些

file

file

4年前 评论
taozywu

@suhanyujie
非常赞同!

目前已翻译的有不少问题,请大家一起努力建立一个高质量的翻译小团队。感谢!!

file

4年前 评论
lmaster

7.2 的重点问题

4年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!