Code前端首页关于Code前端联系我们

深入PHP内核,探究Zend虚拟机的工作原理

terry 2年前 (2023-09-25) 阅读数 58 #后端开发

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包含两个操作数,op1op2handler指针指向执行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是变量$bop2是数值常量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_FCALLDO_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();
}

可以看到,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()),本质上和用户自定义函数是一样的。

逻辑跳转

我们知道指令是顺序执行的,我们的程序一般都会包含很多逻辑判断和循环,这部分是如何通过OPCODE来实现的呢?

<?php
$a = 10;
if ($a == 10) {
    echo 'success';
} else {
    echo 'failure';
}

我们还是通过vld来查看OPCODE(应该说vld扩展是分析PHP的神器)。

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

我们看到JMPZJMP控制执行流程。 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 来确定是否已到达数组末尾。如果是,则退出循环。

结论

通过了解Zend虚拟机,我想你会对PHP的工作原理有更深入的了解。如果我们想一想,我们编写的代码行,当机器执行它们时,它们会变成无数的指令,而每条指令都基于复杂的处理逻辑。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门