PHP7 虚拟机(一)【翻译】

PHP7 虚拟机(一)【翻译】

一点废话

这篇文章原作者是 PHP 开发者之一nikic。原文:PHP 7 Virtual Machine。已征得原作者的允许进行翻译。

因为文章篇幅较长,翻译会作为两部分分开发布。

这是上半部分。这部分包括内容:

  • Opcode 简介

  • 变量类型

  • Op 数组

  • 堆栈帧

  • 函数调用

下半部分还在翻译中

正文

本文旨在提供一个对 Zend 虚拟机的概览,就如同 PHP7 中的一样。这不是一个全面描述,但是我尝试覆盖大部分重要的部分,及一些更精细的细节。

此文章针对 PHP 版本 7.2(目前正在开发中),但几乎所有内容都适用于 PHP 7.0/7.1。但是与 PHP 5.x 系列虚拟机的差异很大。

这篇文章的大部分内容考虑的是指令列表的层面上,最后有几个部分将处理 VM 的实际 C 级实现。在这之前,我提供一些链接到组成 VM 的主要文件:

Opcodes

泷涯注:下文中的操作码意思就是 opcode

一开始我们先说说 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;
};

因此,Opcode 本质上是一种 “三地址代码” 的指令格式。有一个opcode确定指令类型,两个输入的操作对象op1op2,及一个输出操作result

并非所有指令都使用所有操作对象。ADD指令(+ 运算)将使用所有三个。BOOL_NOT指令(! 操作)仅使用op1resultECHO指令仅使用op1。一些指令可能使用或不使用操作对象。例如,DO_FCALL可能有result,也可能没有,这取决于是否使用所调用函数的返回值。一些指令需要两个以上的输入,在这种情况下,它们将简单地使用第二个虚拟指令(OP_DATA)来承载附加的操作。

这三个标准操作对象后,有一个附加的数字extended_value,可用于保存其他指令修饰符。例如,对于CAST,它可能包含要转换到的目标类型。

每个操作对象都有一个类型,分别存储在op1_typeop2_typeresult_type中。可能的类型是IS_UNUSEDIS_CONSTIS_TMPVARIS_VARIS_CV

后三种类型指定了一个可变操作(三种不同类型的 VM 变量),IS_CONST表示一个常量操作对象(例如,5"string"[1, 2, 3]),而IS_UNUSED表示实际未使用的操作,或者用作一个 32 位数字值(汇编术语中的” 立即 “)。例如跳转指令将跳转目标存储在UNUSED操作中。

获得操作码转储

在下面的例子中,我会经常显示 PHP 为某些示例代码生成的操作码序列。目前有三种方法可以获得操作码转储:

# 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 提供最高质量的输出。本文中使用的列表基于 opcache 转储,具有较小的语法调整。魔术数字 0x10000 是 “优化前” 的缩写,所以我们看到操作码是 PHP 编译器生成的。0x20000 将给您优化后的 opcode。Opcache 也可以生成更多的信息,例如 0x40000 将产生一个 CFG,而 0x200000 将产生类型和范围推断的 SSA 形式。但是,这已经超越了我们自己:普通的旧的线性化操作码转储对于我们的目的来说已经足够了。

变量类型

在了解 PHP 虚拟机中,最重要的一点,就是它所使用的三种不同的变量类型。PHP5 中,TMPVAR、VAR 和 CV 在 VM 堆栈上的表示有非常大的区别。在 PHP 7 中,它们变得非常相似,因为它们共享相同的存储机制。然而,它们可以包含的值和它们的语义有重要的差异。

CV 是 “编译变量” 的缩写,是指 “真实” 的 PHP 变量。如果一个函数使用变量$a,则$a将有一个对应的 CV。

CV 可以具有UNDEF类型,以表示未定义的变量。如果在指令中使用 UNDEF CV,则会抛出著名的的 “undefined variable”(在大多数情况下)。在 function 的输入上,所有非参数 CV 都被初始化为 UNDEF。

CV 不会被指令 “消耗” 掉,例如,指令ADD $a,$b不会破坏 $a 和 $b 的 CV 中存储的值。不过,所有 CV 都会在该范围退出时一起销毁。这也意味着所有 CV 在整个 function 的生存时间都是 “活着的”,其中 “活着的” 是指包含有效值(但不会存活于数据流上)。

另一方面,TMPVAR 和 VAR 是虚拟机的临时变量。它们通常作为某些操作的结果操作引入。例如代码$a = $b + $c + $d将产生类似于以下内容的操作码序列:

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

TMP/VAR 始终在使用前定义,因此无法保存 UNDEF 值。与 CV 不同,这些类型的变量会被使用它们的指令所 “消耗” 掉。在上面的例子中,第二个 ADD 将会破坏 T0 的值,此时 T0 不能被使用(除非事先写入)。类似地,ASSIGN 将 “消耗” T1 的值,使 T1 无效。

因此,TMP/VAR 通常是非常短暂的。在大量情况下,一个临时的变量只能存活于单个指令空间中。在这个短暂存活时间外,临时变量就是垃圾。

那么 TMP 和 VAR 有什么区别呢?并不多。这个区别是继承于 PHP 5 的,其中 TMP 是 VM 栈分配的,而 VAR 是堆分配的。在 PHP 7 中,所有变量都是栈分配的。因此,现在 TMP 和 VAR 之间的主要区别在于,只有后者可以包含 REFERENCE(这允许我们在 TMP 上删除 DEREF)。此外,VAR 可以保存两种类型的特殊值,即类入口和 INDIRECT 值。后者用于处理重大作业。

下表尝试总结一下主要差异:

|UNDEF|REF|INDIRECT | 会被 “消耗”| 有名称 | |---|---|---|---|---|---| |CV|yes|yes|no|no|yes|| |TMPVAR|no|no|no|yes|no|| |VAR|no|yes|yes|yes|no||

Op 数组

所有 PHP 函数都表示为具有公共zend_function头的结构。这里的 “函数” 应该被概括的理解为 “真正” 的函数中的一切,上到方法,下到独立的 “伪主代码” 和 “eval” 代码。

用户空间中的函数使用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,它是一组操作码(指令)。last是此数组中的操作码数量。请注意,在这里,这些术语可能会令人困惑,因为last听起来应该是最后一个操作码的索引,而它其实是操作码的数量(比最后一个索引大)。这同样适用于 op 数组结构中的所有其他last_*的值。

last_var是 CV 的数量,T是 TMP 和 VAR 的数量(在大多数情况下,我们不区分它们)。数组中的vars是 CV 的名称。

literals是一个包含在代码中出现的文字值的数组。这个数组是一个CONST操作对象引用。根据 ABI,每个CONST操作对象将存储一个指向此文字表的指针,或存储相对于其开始位置的偏移量。

op 数组结构比这多,我们可以稍后再说。

堆栈帧布局

泷涯注:堆栈帧指的是一个被推到堆栈的数据帧。例如一个调用堆栈,堆栈帧将表示一个函数调用及其参数数据。

除了一些执行器全局变量(EG)之外,所有执行状态都存储在虚拟机堆栈上。VM 堆栈为每个页面分配 256KiB 内存,每个页面间通过链接列表链接。

在每个函数调用中,在 VM 堆栈上分配一个新的堆栈帧,并具有以下布局:

+----------------------------------------+
| zend_execute_data                      |
+----------------------------------------+
| VAR[0]                =         ARG[1] | 参数
| ...                                    |
| VAR[num_args-1]       =         ARG[N] |
| VAR[num_args]         =   CV[num_args] | 其余的CV
| ...                                    |
| VAR[last_var-1]       = CV[last_var-1] |
| VAR[last_var]         =         TMP[0] | TMP/VAR
| ...                                    |
| VAR[last_var+T-1]     =         TMP[T] |
| ARG[N+1] (extra_args)                  | 额外的参数
| ...                                    |
+----------------------------------------+

该帧以zend_execute_data结构开始,后跟一个可变槽的数组。槽都是相同的(简单的 zvals),但用于不同的目的。第一个last_var的槽是 CV,第一个num_args保存函数的参数。CV 槽后是 TMP/VAR 的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       */
};

最重要的是,这个结构包含oplinefuncopline是当前执行的指令,func是当前执行的函数。除此之外:

  • return_value是一个指向 zval 的指针,返回值将被存储到该值中。

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

  • called_scope是 PHP 代码中static::指向的范围。

  • prev_execute_data指向前一个堆栈帧,在该函数运行结束后将返回并执行。

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

  • run_time_cache是 op 数组运行时缓存,以便在访问此结构时减少一个间接的指针(稍后讨论)。

  • 同样的原因,literals缓存了 op 数组的文字表。

函数调用

我跳过了 execute_data 结构中的一个字段,即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

我缩进了操作码序列,可以看出哪个指令对应于哪个调用。

INIT 操作码在堆栈中推送一个调用帧,它包含足够的空间用于函数中的所有变量和我们已知的参数数量(如果涉及到参数解包,我们可能会得到更多的参数)。该调用帧与被调用的函数、$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依赖于此)。但是,只有实际声明的参数有相应的 CV。除此之外的任何参数都将写入为其他 CV 和 TMP 保留的内存。因此,这些参数将在 TMP 之后移动,最后参数将被分为两个不连续的块。

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

参数传递

PHP 有大量不同的参数发送操作码,其差异可能令人困惑,这也得 “感谢” 于那不幸的命名。

SEND_VALSEND_VAR是最简单的变体,用于处理在编译时已知是值的参数的发送。SEND_VAL用于 CONST 和 TMP 操作数,而SEND_VAR用于 VAR 和 CV。

相反,SEND_REF用于在编译期间已知是传递引用的参数。由于只能通过引用发送变量,因此此操作码仅接受 VAR 和 CV。

SEND_VAL_EXSEND_VAR_EXSEND_VALSEND_VAR的变体,我们无法静态确定参数是值还是引用。这些操作码将通过 arginfo 检查参数的种类,并相应地执行。在大多数情况下,不使用实际的 arginfo 结构,而是直接使用函数结构中的紧凑位向量表示。

然后有SEND_VAR_NO_REF_EX。不要试图从它的名字中读出任何东西,它是一个彻彻底底的谎言。当传递的不是真正的 “变量” 但会将 VAR 返回到静态(泷涯注:此处的静态指的是已经确定会有的参数)的未知参数时,将使用此操作码。使用它的两个具体例子:将函数调用的结果作为参数传递,将赋值的结果作为参数传递。

这种情况需要一个独立的操作码,其原因有两个:首先,如果您尝试通过 ref 传递这样的赋值,则会生成熟悉的 “只有变量可以通过引用传递” 通知(如果使用SEND_VAR_EX,则会默认允许)。其次,该操作码处理此情况:您可能希望将一个返回为引用的函数的结果,传递给一个引用参数(不抛出任何内容)。这个操作码的SEND_VAR_NO_REF变体(没有_EX)是一个专门的变体,我们静态地知道,引用是我们所预期的(但是我们不知道参数是否是一个)。(其实我也没看懂 “其次” 到底是啥意思)

SEND_UNPACKSEND_ARRAY操作码分别处理参数解包和内联的call_user_func_array调用。他们都将数组中的元素推到参数堆栈上,但在细节上有所不同(例如,unpacking 支持 Traversables,而call_user_func_array则不支持)。如果使用 unpacking/cufa,可能需要将堆栈帧扩展到其先前的大小外(因为在初始化时,实际的函数参数数量是未知的)。在大多数情况下,这种扩展可以通过移动堆栈顶部指针来实现。但是,如果这将跨越堆栈的页面边界,则必须分配一个新页面,并且整个调用帧(包括已经被推入的参数)需要被复制到新的页面(我们无法处理跨越页面的调用帧边界)。

最后一个操作码是SEND_USER,用于内联的call_user_func调用,并处理其一些特性。

虽然我们尚未讨论不同的变量提取模式,但这似乎是引入FUNC_ARG提取模式的好地方。考虑一个简单的调用,如func($a[0][1][2]),我们在编译时不知道参数是通过值还是由引用传递。在这两种情况下,行为有很多不同之处。如果是按值传递,$a以前是空的,那么这可能会产生一堆 “未定义的索引” 通知。如果是引用传递,那么我们不得不默认地初始化嵌套数组。

通过检查当前EX(call)函数的 arginfo,FUNC_ARG 提取模式将动态选择两种行为(R 或 W)之一。对于func($a[0][1][2]),操作码序列可能如下所示:

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