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