一点废话
这篇文章原作者是PHP开发者之一nikic。原文:PHP 7 Virtual Machine。已征得原作者的允许进行翻译。
因为文章篇幅较长,翻译会作为两部分分开发布。
这是上半部分。这部分包括内容:
Opcode简介
变量类型
Op数组
堆栈帧
函数调用
下半部分还在翻译中
正文
本文旨在提供一个对Zend虚拟机的概览,就如同PHP7中的一样。这不是一个全面描述,但是我尝试覆盖大部分重要的部分,及一些更精细的细节。
此文章针对PHP版本7.2(目前正在开发中),但几乎所有内容都适用于PHP 7.0/7.1。但是与PHP 5.x系列虚拟机的差异很大。
这篇文章的大部分内容考虑的是指令列表的层面上,最后有几个部分将处理VM的实际C级实现。在这之前,我提供一些链接到组成VM的主要文件:
zend_vm_def.h: VM定义文件
zend_vm_execute.h: 已生成的VM虚拟机
zend_vm_gen.php: 生成脚本
zend_execute.c: 大部分直接支持代码
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
确定指令类型,两个输入的操作对象op1
和op2
,及一个输出操作result
。
并非所有指令都使用所有操作对象。ADD
指令(+运算)将使用所有三个。BOOL_NOT
指令(!操作)仅使用op1
和result
。ECHO
指令仅使用op1
。一些指令可能使用或不使用操作对象。例如,DO_FCALL
可能有result
,也可能没有,这取决于是否使用所调用函数的返回值。一些指令需要两个以上的输入,在这种情况下,它们将简单地使用第二个虚拟指令(OP_DATA
)来承载附加的操作。
这三个标准操作对象后,有一个附加的数字extended_value
,可用于保存其他指令修饰符。例如,对于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位数字值(汇编术语中的”立即“)。例如跳转指令将跳转目标存储在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 */ };
最重要的是,这个结构包含opline
和func
,opline
是当前执行的指令,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操作码在堆栈中推送一个调用帧,它包含足够的空间用于函数中的所有变量和我们已知的参数数量(如果涉及到参数解包,我们可能会得到更多的参数)。该调用帧与被调用的函数、$this
和called_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_VAL
和SEND_VAR
是最简单的变体,用于处理在编译时已知是值的参数的发送。SEND_VAL
用于CONST和TMP操作数,而SEND_VAR
用于VAR和CV。
相反,SEND_REF
用于在编译期间已知是传递引用的参数。由于只能通过引用发送变量,因此此操作码仅接受VAR和CV。
SEND_VAL_EX
和SEND_VAR_EX
是SEND_VAL
和SEND_VAR
的变体,我们无法静态确定参数是值还是引用。这些操作码将通过arginfo检查参数的种类,并相应地执行。在大多数情况下,不使用实际的arginfo结构,而是直接使用函数结构中的紧凑位向量表示。
然后有SEND_VAR_NO_REF_EX
。不要试图从它的名字中读出任何东西,它是一个彻彻底底的谎言。当传递的不是真正的“变量”但会将VAR返回到静态(泷涯注:此处的静态指的是已经确定会有的参数)的未知参数时,将使用此操作码。使用它的两个具体例子:将函数调用的结果作为参数传递,将赋值的结果作为参数传递。
这种情况需要一个独立的操作码,其原因有两个:首先,如果您尝试通过ref传递这样的赋值,则会生成熟悉的“只有变量可以通过引用传递”通知(如果使用SEND_VAR_EX
,则会默认允许)。其次,该操作码处理此情况:您可能希望将一个返回为引用的函数的结果,传递给一个引用参数(不抛出任何内容)。这个操作码的SEND_VAR_NO_REF
变体(没有_EX
)是一个专门的变体,我们静态地知道,引用是我们所预期的(但是我们不知道参数是否是一个)。(其实我也没看懂“其次”到底是啥意思)
SEND_UNPACK
和SEND_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