深入PHP内核,探究Zend虚拟机的工作原理
PHP是一种解释性语言。对于Java、Python、Ruby、JavaScript等解释型语言来说,我们编写的代码并不是被编译并执行为机器代码,而是被编译为中间代码并在虚拟机(VM)上执行。运行 PHP 的虚拟机称为 Zend 虚拟机。今天我们就深入内核,探究Zend虚拟机的工作原理。
操作码
什么是操作码?它是虚拟机能够识别和处理的指令。 Zend 虚拟机包含一系列操作码。 OPCODE 虚拟机可以做很多事情。以下是操作码的一些示例:
ZEND_ADD
添加两个操作数。ZEND_NEW
创建一个 PHP 对象。ZEND_ECHO
将内容输出到标准输出。ZEND_EXIT
退出 PHP。
对于这样的操作,PHP定义了186种(随着PHP的更新,肯定会支持更多的OPCODE类型)。所有OPCODE的定义和实现都可以在源代码zend/zend_vm_def.h
文件中找到(该文件的内容不是原生C代码,而是一个模板,原因稍后会解释)。
我们来看看PHP是如何设计OPCODE数据结构的:
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
。 handler
指针指向执行OPCODE操作的函数,函数处理后的结果存储在result
中。
举个简单的例子:
<?php
$b = 1;
$a = $b + 2;
通过使用vld扩展,我们看到上面的代码编译后生成了ZEND_ADD指令的OPCODE。
compiled vars: !0 = $b, !1 = $a
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
2 0 E > ASSIGN !0, 1
3 1 ADD ~3 !0, 2
2 ASSIGN !1, ~3
8 3 > RETURN 1
其中,第二行是ZEND_ADD
指令的OPCODE。我们看到它接收2个操作数,op1
是变量$b
,op2
是数值常量1,返回结果存储在临时变量中。在文件zend/zend_vm_def.h
中我们可以找到ZEND_ADD指令对应的函数实现:
ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMPVAR|CV, CONST|TMPVAR|CV)
{
USE_OPLINE
zend_free_op free_op1, free_op2;
zval *op1, *op2, *result;
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)) {
if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_LONG)) {
result = EX_VAR(opline->result.var);
fast_long_add_function(result, op1, op2);
ZEND_VM_NEXT_OPCODE();
} else if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_DOUBLE)) {
result = EX_VAR(opline->result.var);
ZVAL_DOUBLE(result, ((double)Z_LVAL_P(op1)) + Z_DVAL_P(op2));
ZEND_VM_NEXT_OPCODE();
}
} else if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_DOUBLE)) {
...
}
上面的代码不是原生C代码,而是一个模板。
你为什么要这样做?因为PHP是弱类型语言,而用它实现的C是强类型语言。弱类型语言支持自动类型匹配,自动类型匹配的实现就像上面的代码一样,通过评估来处理不同类型的参数。想象一下,如果每个OPCODE在处理时都要判断传入参数的类型,那么性能必然会成为一个大问题(一个请求可能要处理上万个OPCODE)。
有办法吗?我们发现在编译时我们已经可以确定每个操作数的类型(可以是常量或变量)。因此,当PHP实际执行C代码时,不同类型的操作数会被分成不同的函数供虚拟机直接调用。这部分代码放在zend/zend_vm_execute.h
。扩展文件相当大,我们注意到还有这样一段代码:
if (IS_CONST == IS_CV) {
根本没有任何意义吧?不过没关系,C编译器会自动以这种方式进行优化和评估。如果我们想了解特定OPCODE处理的逻辑,大多数情况下阅读模板文件zend/zend_vm_def.h
会更容易。基于模板生成C代码的程序是用PHP实现的。
执行流程
准确的说,PHP的执行分为两部分:编译和执行。这里不再详细介绍编译部分,而是重点介绍执行过程。
经过语法、词法分析等一系列编译过程,我们得到了名为OPArray的数据,其结构如下:
struct _zend_op_array {
/* Common elements */
zend_uchar type;
zend_uchar arg_flags[3]; /* bitset of arg_info.pass_by_reference */
uint32_t fn_flags;
zend_string *function_name;
zend_class_entry *scope;
zend_function *prototype;
uint32_t num_args;
uint32_t required_num_args;
zend_arg_info *arg_info;
/* END of common elements */
uint32_t *refcount;
uint32_t last;
zend_op *opcodes;
int last_var;
uint32_t T;
zend_string **vars;
int last_live_range;
int last_try_catch;
zend_live_range *live_range;
zend_try_catch_element *try_catch_array;
/* static variables support */
HashTable *static_variables;
zend_string *filename;
uint32_t line_start;
uint32_t line_end;
zend_string *doc_comment;
uint32_t early_binding; /* the linked list of delayed declarations */
int last_literal;
zval *literals;
int cache_size;
void **run_time_cache;
void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};
内容很多吧?简单理解,其本质就是一个OPCODE数组加上执行过程中需要的环境数据的集合。介绍一些比较重要的字段:
opcodes
存储OPCODE的数组。文件名
当前运行脚本的文件名。function_name
当前正在执行的方法的名称。static_variables
静态变量列表。last_try_catch
try_catch_array
如果当前上下文发生异常,try-catch-finally会跳转到需要的信息。literals
常量文字的集合,例如字符串foo或数字23。
为什么我们需要生成如此庞大的数据?因为编译期间生成的信息越多,执行期间所需的时间就越少。
现在让我们看看PHP如何执行OPCODE。 OPCODE的执行放在一个大循环中,位于zend/zend_vm_execute.h
中的函数execute_ex
中:
ZEND_API void execute_ex(zend_execute_data *ex)
{
DCL_OPLINE
zend_execute_data *execute_data = ex;
LOAD_OPLINE();
ZEND_VM_LOOP_INTERRUPT_CHECK();
while (1) {
if (UNEXPECTED((ret = ((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU)) != 0)) {
if (EXPECTED(ret > 0)) {
execute_data = EG(current_execute_data);
ZEND_VM_LOOP_INTERRUPT_CHECK();
} else {
return;
}
}
}
zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen");
}
这里我去掉了一些环境变量的评估分支,其运行的主要过程被保留。可以看到,在无限循环中,虚拟机不断调用OPCODE指定的函数handler
来处理指令集,直到给定指令处理的结果ret
为小于0。注意,OPCODE数组的当前指针并没有移到主进程中,而是将该进程放置在指令执行的特定函数的末尾。因此,我们可以看到,大多数OPCODE实现函数的最后都会调用这个宏:
ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
在前面的简单例子中,我们看到vld打印的执行OPCODE数组中,最后一条指令 OPCODE是针对ZEND_RETURN 。但我们写的PHP代码中并没有这样的语句。在编译时,虚拟机自动将此语句添加到 OPCODE 数组的末尾。语句ZEND_RETURN
对应的函数返回-1。当判断执行结果小于0时,退出循环,程序终止。
方法调用
如果我们调用自定义函数,虚拟机会如何处理?
<?php
function foo() {
echo 'test';
}
foo();
我们通过vld查看生成的OPCODE。因为我们修改了一个PHP函数,所以出现了两个OPCODE语句执行堆栈。在第一个执行堆栈上,调用自定义函数会执行两个 OPCODE 语句:INIT_FCALL
和 DO_FCALL
。
compiled vars: none
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
2 0 E > NOP
6 1 INIT_FCALL 'foo'
2 DO_FCALL 0
3 > RETURN 1
compiled vars: none
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
3 0 E > ECHO 'test'
4 1 > RETURN null
其中,INIT_FCALL
准备执行函数所需的上下文数据。 DO_FCALL
负责执行功能。DO_FCALL
的处理函数根据不同的调用情况,处理了很多逻辑。我提取了执行用户定义函数的逻辑部分:
ZEND_VM_HANDLER(60, ZEND_DO_FCALL, ANY, ANY, SPEC(RETVAL))
{
USE_OPLINE
zend_execute_data *call = EX(call);
zend_function *fbc = call->func;
zend_object *object;
zval *ret;
...
if (EXPECTED(fbc->type == ZEND_USER_FUNCTION)) {
ret = NULL;
if (RETURN_VALUE_USED(opline)) {
ret = EX_VAR(opline->result.var);
ZVAL_NULL(ret);
}
call->prev_execute_data = execute_data;
i_init_func_execute_data(call, &fbc->op_array, ret);
if (EXPECTED(zend_execute_ex == execute_ex)) {
ZEND_VM_ENTER();
} else {
ZEND_ADD_CALL_FLAG(call, ZEND_CALL_TOP);
zend_execute_ex(call);
}
}
...
ZEND_VM_SET_OPCODE(opline + 1);
ZEND_VM_CONTINUE();
}
可以看到, 然后调用函数 我们可以理解,外层代码是一个函数是默认存在的(类似于函数 我们知道指令是顺序执行的,我们的程序一般都会包含很多逻辑判断和循环,这部分是如何通过OPCODE来实现的呢? 我们还是通过vld来查看OPCODE(应该说vld扩展是分析PHP的神器)。 我们看到 循环只需要 通过了解Zend虚拟机,我想你会对PHP的工作原理有更深入的了解。如果我们想一想,我们编写的代码行,当机器执行它们时,它们会变成无数的指令,而每条指令都基于复杂的处理逻辑。DO_FCALL
首先,调用函数之前的上下文数据存储在call->prev_execute_data
,然后调用函数i_init_func_execute_data
到自定义函数对象中的op_array(每个自定义函数都会在编译时生成对应的数据,其数据结构中包含该函数的OPCODE数组)并赋值到新的执行上下文对象。
zend_execute_ex
来执行自定义函数。 zend_execute_ex
其实就是前面提到的execute_ex
函数(默认是这个,但是扩展可以覆盖zend_execute_ex
,这个API允许PHP扩展。开发者可以使用重写函数扩展函数不是本文的主题,我们不会深入探讨,只是将上下文数据替换为当前函数所在的上下文数据。main()
),本质上和用户自定义函数是一样的。逻辑跳转
<?php
$a = 10;
if ($a == 10) {
echo 'success';
} else {
echo 'failure';
}
compiled vars: !0 = $a
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
2 0 E > ASSIGN !0, 10
3 1 IS_EQUAL ~2 !0, 10
2 > JMPZ ~2, ->5
4 3 > ECHO 'success'
4 > JMP ->6
6 5 > ECHO 'failure'
7 6 > > RETURN 1
JMPZ
和JMP
控制执行流程。 JMP
的逻辑非常简单。将当前 OPCODE 指针指向要跳转到的 OPCODE。 ZEND_VM_HANDLER(42, ZEND_JMP, JMP_ADDR, ANY)
{
USE_OPLINE
ZEND_VM_SET_OPCODE(OP_JMP_ADDR(opline, opline->op1));
ZEND_VM_CONTINUE();
}
JMPZ
只是多了一个判断,根据结果选择是否跳转。这里我就不再提了。处理循环的方式基本上和判断类似。 <?php
$a = [1, 2, 3];
foreach ($a as $n) {
echo $n;
}
compiled vars: !0 = $a, !1 = $n
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
2 0 E > ASSIGN !0, <array>
3 1 > FE_RESET_R $3 !0, ->5
2 > > FE_FETCH_R $3, !1, ->5
4 3 > ECHO !1
4 > JMP ->2
5 > FE_FREE $3
5 6 > RETURN 1
JMP
语句即可完成。使用语句FE_FETCH_R
来确定是否已到达数组末尾。如果是,则退出循环。结论
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。