菜单

内核分析,从PHP语法糖剖析Zend

2020年1月11日 - 前端排行

PHP 是一门解释型的语言。诸如 Java、Python、Ruby、Javascript
等解释型语言,我们编写的代码不会被编译成机器码运行,而是会被编译中间码运行在虚拟机(VM)上。运行
PHP 的虚拟机,称之为 Zend 虚拟机,今天我们将深入内核,探究 Zend
虚拟机运行的原理。

原文链接:

1.

先说个PHP5.3+ 的语法糖,通常我们这样写:

<?php
    $a = 0;
    $b = $a ? $a : 1;

语法糖可以这样写:

<?php
    $a = 0;
    $b = $a ?: 1;

执行结果$b =
1,后面写法更简洁,但通常不太建议用太多语法糖,特别是容易理解混淆的,比如PHP
7 新增加??如下:

<?php
    $b = $a ?? 1;

相当于:

<?php
    $b = isset($a) ? $a : 1;

?: 和 ??
你是不是容易搞混,如果这样,我建议宁可不用,代码可读性强,易维护更重要。

语法糖不是本文的重点,我们的目的是从语法糖入手聊聊Zend VM的解析原理。

OPCODE

什么是 OPCODE?它是一种虚拟机能够识别并处理的指令。Zend
虚拟机包含了一系列的 OPCODE,通过 OPCODE 虚拟机能够做很多事情,列举几个
OPCODE 的例子:

诸如此类的操作,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和 op2handler 指针则指向了执行该 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 实现的。

原文:

2.

分析的PHP源码分支 =>
remotes/origin/PHP-5.6.14,关于如何通过vld查看opcode,请看我之前写的这篇文章:

<?php
    $a = 0;
    $b = $a ?: 1;

对应的opcdoe如下:

number of ops:  5
compiled vars:  !0 = $a, !1 = $b
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   ASSIGN                                                   !0, 0
   3     1        JMP_SET_VAR                                      $1      !0
         2        QM_ASSIGN_VAR                                    $1      1
         3        ASSIGN                                                   !1, $1
   4     4      > RETURN                                                   1

branch: #  0; line:     2-    4; sop:     0; eop:     4; out1:  -2
path #1: 0,

vim Zend/zend_language_parser.y +834

834 ›   |›  expr '?' ':' { zend_do_jmp_set(&$1, &$2, &$3 TSRMLS_CC); }
835 ›   ›   expr     { zend_do_jmp_set_else(&$$, &$5, &$2, &$3 TSRMLS_CC); }

如果你喜欢,可以自己动手,重新定义 ?:
的语法糖。遵循BNF文法规则,使用bison解析,有兴趣可以自行Google相关知识,继续深入了解。

从vld的opcode可以知道,执行了 zend_do_jmp_set_else,代码在
Zend/zend_compile.c 中:

void zend_do_jmp_set_else(znode *result, const znode *false_value, const znode *jmp_token, const znode *colon_token TSRMLS_DC)
{
›   zend_op *opline = get_next_op(CG(active_op_array) TSRMLS_CC);

›   SET_NODE(opline->result, colon_token);
›   if (colon_token->op_type == IS_TMP_VAR) {
›   ›   if (false_value->op_type == IS_VAR || false_value->op_type == IS_CV) {
›   ›   ›   CG(active_op_array)->opcodes[jmp_token->u.op.opline_num].opcode = ZEND_JMP_SET_VAR;
›   ›   ›   CG(active_op_array)->opcodes[jmp_token->u.op.opline_num].result_type = IS_VAR;
›   ›   ›   opline->opcode = ZEND_QM_ASSIGN_VAR;
›   ›   ›   opline->result_type = IS_VAR;
›   ›   } else {
›   ›   ›   opline->opcode = ZEND_QM_ASSIGN;
›   ›   }
›   } else {
›   ›   opline->opcode = ZEND_QM_ASSIGN_VAR;
›   }
›   opline->extended_value = 0;
›   SET_NODE(opline->op1, false_value);
›   SET_UNUSED(opline->op2);

›   GET_NODE(result, opline->result);

›   CG(active_op_array)->opcodes[jmp_token->u.op.opline_num].op2.opline_num = get_next_op_number(CG(active_op_array));

›   DEC_BPC(CG(active_op_array));
}

执行过程

准确的来说,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
数组外加执行过程中所需要的环境数据的集合。介绍几个相对来说比较重要的字段:

为什么需要生成这样庞大的数据?因为编译时期生成的信息越多,执行时期所需要的时间就越少。

接下来,我们看下 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
数组中,最后有一项指令为 ZEND_RETURN 的 OPCODE。但我们编写的 PHP
代码中并没有这样的语句。在编译时期,虚拟机会自动将这个指令加到 OPCODE
数组的结尾。ZEND_RETURN 指令对应的函数会返回
-1,判断执行的结果小于0时,就会退出循环,从而结束程序的运行。

    

3.

重点两个opcode,ZEND_JMP_SET_VAR 和
ZEND_QM_ASSIGN_VAR,怎么接着读代码呢?下面说下PHP的opcode。

PHP5.6有167个opcode,意味着可以执行167种不同的计算操作,官方文档看这里

PHP内部使用_zend_op 这个结构体来表示opcode, vim Zend/zend_compile.h
+111

111 struct _zend_op {
112 ›   opcode_handler_t handler;
113 ›   znode_op op1;
114 ›   znode_op op2;
115 ›   znode_op result;
116 ›   ulong extended_value;
117 ›   uint lineno;
118 ›   zend_uchar opcode;
119 ›   zend_uchar op1_type;
120 ›   zend_uchar op2_type;
121 ›   zend_uchar result_type;
122 }

PHP 7.0略有不同,主要区别在针对64位系统
uint换成uint32_t,明确指定字节数。

你把opcode当成一个计算器,只接受两个操作数(op1,
op2),执行一个操作(handler,
比如加减乘除),然后它返回一个结果(result)给你,再稍加处理算术溢出的情况(extended_value)。

Zend的VM对每个opcode的工作方式完全相同,都有一个handler(函数指针),指向处理函数的地址。这是一个C函数,包含了执行opcode对应的代码,使用op1,op2做为参数,执行完成后,会返回一个结果(result),有时也会附加一段信息(extended_value)。

用我们例子中的操作数 ZEND_JMP_SET_VAR 说明,vim Zend/zend_vm_def.h
+4995

4942 ZEND_VM_HANDLER(158, ZEND_JMP_SET_VAR, CONST|TMP|VAR|CV, ANY)
4943 {
4944 ›   USE_OPLINE
4945 ›   zend_free_op free_op1;
4946 ›   zval *value, *ret;
4947
4948 ›   SAVE_OPLINE();
4949 ›   value = GET_OP1_ZVAL_PTR(BP_VAR_R);
4950
4951 ›   if (i_zend_is_true(value)) {
4952 ›   ›   if (OP1_TYPE == IS_VAR || OP1_TYPE == IS_CV) {
4953 ›   ›   ›   Z_ADDREF_P(value);
4954 ›   ›   ›   EX_T(opline->result.var).var.ptr = value;
4955 ›   ›   ›   EX_T(opline->result.var).var.ptr_ptr = &EX_T(opline->result.var).var.ptr;
4956 ›   ›   } else {
4957 ›   ›   ›   ALLOC_ZVAL(ret);
4958 ›   ›   ›   INIT_PZVAL_COPY(ret, value);
4959 ›   ›   ›   EX_T(opline->result.var).var.ptr = ret;
4960 ›   ›   ›   EX_T(opline->result.var).var.ptr_ptr = &EX_T(opline->result.var).var.ptr;
4961 ›   ›   ›   if (!IS_OP1_TMP_FREE()) {
4962 ›   ›   ›   ›   zval_copy_ctor(EX_T(opline->result.var).var.ptr);
4963 ›   ›   ›   }
4964 ›   ›   }
4965 ›   ›   FREE_OP1_IF_VAR();
4966 #if DEBUG_ZEND>=2
4967 ›   ›   printf("Conditional jmp to %d\n", opline->op2.opline_num);
4968 #endif
4969 ›   ›   ZEND_VM_JMP(opline->op2.jmp_addr);
4970 ›   }
4971
4972 ›   FREE_OP1();
4973 ›   CHECK_EXCEPTION();
4974 ›   ZEND_VM_NEXT_OPCODE();
4975 }

i_zend_is_true
来判断操作数是否为true,所以ZEND_JMP_SET_VAR是一种条件赋值,相信大家都能看明白,下面讲重点。

注意zend_vm_def.h这并不是一个可以直接编译的C的头文件,只能说是一个模板,具体可编译的头为zend_vm_execute.h(这个文件可有45000多行哦),它并非手动生成,而是由zend_vm_gen.php这个PHP脚本解析zend_vm_def.h后生成(有意思吧,先有鸡还是先有蛋,没有PHP
哪来的这个脚本?),猜测这个是后期产物,早期php版本应该不会用这个。

上面ZEND_JMP_SET_VAR的代码,根据不同参数 CONST|TMP|VAR|CV
最终会生成不同类型的,但功能一致的handler函数:

static int ZEND_FASTCALL  ZEND_JMP_SET_VAR_SPEC_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
static int ZEND_FASTCALL  ZEND_JMP_SET_VAR_SPEC_TMP_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
static int ZEND_FASTCALL  ZEND_JMP_SET_VAR_SPEC_VAR_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
static int ZEND_FASTCALL  ZEND_JMP_SET_VAR_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS)

这么做的目的是为了在编译期确定handler,提升运行期的性能。不这么做,在运行期根据参数类型选择,也可以做到,但性能不好。当然这么做有时也会生成一些垃圾代码(看似无用),不用担心,C的编译器会进一步优化处理。

zend_vm_gen.php 也可以接受一些参数,细节在PHP源码中的README文件
Zend/README.ZEND_VM 有详细说明。

方法调用

如果我们调用一个自定义的函数,虚拟机会如何处理呢?

<?php
function foo() {
    echo 'test';
}

foo();

我们通过 vld 查看生成的 OPCODE。出现了两个 OPCODE
指令执行栈,是因为我们自定义了一个 PHP
函数。在第一个执行栈上,调用自定义函数会执行两个 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();
}

可以看到,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
扩展开发者可以通过覆写函数达到扩展功能的目的,不是本篇的主题,不准备深入探讨),只是上下文数据被替换成当前函数所在的上下文数据。

我们可以这样理解,最外层的代码就是一个默认存在的函数(类似 C
语言中的 main()函数),和用户自定义的函数本质上是没有区别的。

假如我们现在使用的是CLI模式,直接在SAPI/cli/php_cli.c文件中找到main函数,
默认情况下PHP的CLI模式的行为模式为PHP_MODE_STANDARD。
此行为模式中PHP内核会调用php_execute_script(&file_handle
TSRMLS_CC);来执行PHP文件。
顺着这条执行的线路,可以看到一个PHP文件在经过词法分析,语法分析,编译后生成中间代码的过程:

4.

讲到这里,我们知道opcode怎么和handler对应了。但是在整体上还有一个过程,就是语法解析,解析后所有的opcode是怎么串联起来的呢?

语法解析的细节就不说了,解析过后,会有个包含所有opcode的大数组(说链表可能更准确),从上面代码我们可以看到,每个handler执行完后,都会调用
ZEND_VM_NEXT_OPCODE(),取出下一个opcode,继续执行,直到最后退出,循环的代码
vim Zend/zend_vm_execute.h +337:

ZEND_API void execute_ex(zend_execute_data *execute_data TSRMLS_DC)
{
›   DCL_OPLINE
›   zend_bool original_in_execution;



›   original_in_execution = EG(in_execution);
›   EG(in_execution) = 1;

›   if (0) {
zend_vm_enter:
›   ›   execute_data = i_create_execute_data_from_op_array(EG(active_op_array), 1 TSRMLS_CC);
›   }

›   LOAD_REGS();
›   LOAD_OPLINE();

›   while (1) {
    ›   int ret;
#ifdef ZEND_WIN32
›   ›   if (EG(timed_out)) {
›   ›   ›   zend_timeout(0);
›   ›   }
#endif

›   ›   if ((ret = OPLINE->handler(execute_data TSRMLS_CC)) > 0) {
›   ›   ›   switch (ret) {
›   ›   ›   ›   case 1:
›   ›   ›   ›   ›   EG(in_execution) = original_in_execution;
›   ›   ›   ›   ›   return;
›   ›   ›   ›   case 2:
›   ›   ›   ›   ›   goto zend_vm_enter;
›   ›   ›   ›   ›   break;
›   ›   ›   ›   case 3:
›   ›   ›   ›   ›   execute_data = EG(current_execute_data);
›   ›   ›   ›   ›   break;
›   ›   ›   ›   default:
›   ›   ›   ›   ›   break;
›   ›   ›   }
›   ›   }

›   }
›   zend_error_noreturn(E_ERROR, "Arrived at end of main loop which shouldn't happen");
}

宏定义, vim Zend/zend_execute.c +1772

1772 #define ZEND_VM_NEXT_OPCODE() \
1773 ›   CHECK_SYMBOL_TABLES() \
1774 ›   ZEND_VM_INC_OPCODE(); \
1775 ›   ZEND_VM_CONTINUE()

329 #define ZEND_VM_CONTINUE()         return 0
330 #define ZEND_VM_RETURN()           return 1
331 #define ZEND_VM_ENTER()            return 2
332 #define ZEND_VM_LEAVE()            return 3

while是一个死循环,执行一个handler函数,除个别情况,多数handler函数末尾都调用ZEND_VM_NEXT_OPCODE()
-> ZEND_VM_CONTINUE(),return 0,继续循环。

注:比如 yield
协程是个例外,它会返回1,直接return出循环。以后有机会我们再单独对yield做分析。

希望你看完上面内容,对PHP Zend
引擎的解析过程有个详细的了解,下面我们基于原理的分析,再简单聊聊PHP的优化。

逻辑跳转

我们知道指令都是顺序执行的,而我们的程序,一般都包含不少的逻辑判断和循环,这部分又是如何通过
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

我们看到,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 指令判断是否已经到达数组的结尾,如果到达则退出循环。

1 EG(active_op_array) = zend_compile_file(file_handle, type TSRMLS_CC);

5. PHP优化注意事项

结语

通过了解 Zend 虚拟机,相信你对 PHP
是如何运行的,会有更深刻的理解。想到我们写的一行行代码,最后机器执行的时候会变成数不胜数的指令,每个指令又建立在复杂的处理逻辑之上。那些从前随意写下的代码,现在会不会在脑海里不自觉的转换成
OPCODE 再品味一番呢?

在销毁了文件所在的handler后,如果存在中间代码,则PHP虚拟机将通过以下代码执行中间代码:

5.1 echo 输出

<?php
    $foo = 'foo';
    $bar = 'bar';
    echo $foo . $bar;

vld 查看opcode:

number of ops:  5
compiled vars:  !0 = $foo, !1 = $bar
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   ASSIGN                                                   !0, 'foo'
   3     1        ASSIGN                                                   !1, 'bar'
   4     2        CONCAT                                           ~2      !0, !1
         3        ECHO                                                     ~2
   5     4      > RETURN                                                   1

branch: #  0; line:     2-    5; sop:     0; eop:     4; out1:  -2
path #1: 0,

ZEND_CONCAT 连接 $a和$b的值,保存到临时变量~2中,然后echo
出来。这个过程中涉及要分配一块内存,用于临时变量,用完后还要释放,还需要调用拼接函数,执行拼接过程。

如果换成这样写:

<?php
    $foo = 'foo';
    $bar = 'bar';
    echo $foo, $bar;

对应的opcode:

number of ops:  5
compiled vars:  !0 = $foo, !1 = $bar
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   ASSIGN                                                   !0, 'foo'
   3     1        ASSIGN                                                   !1, 'bar'
   4     2        ECHO                                                     !0
         3        ECHO                                                     !1
   5     4      > RETURN                                                   1

branch: #  0; line:     2-    5; sop:     0; eop:     4; out1:  -2
path #1: 0,

不需要分配内存,也不需要执行拼接函数,是不是效率更好呢!想了解拼接过程,可以根据本文讲的内容,自行查找
ZEND_CONCAT 这个opcode对应的handler,做了好多事情哦。

1 zend_execute(EG(active_op_array) TSRMLS_CC);

5.2 define()和const

const关键字是从5.3开始引入的,和define有很大差别,和C语言的#define倒是含义差不多。

const 的值是死的,运行时不可以改变,所以说类似C语言的
#define,属于编译期间就确定的内容,而且对数值类型有限制。

直接看代码,对比opcode:

define例子:

<?php
    define('FOO', 'foo');
    echo FOO;

define opcode:

number of ops:  6
compiled vars:  none
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   SEND_VAL                                                 'FOO'
         1        SEND_VAL                                                 'foo'
         2        DO_FCALL                                      2          'define'
   3     3        FETCH_CONSTANT                                   ~1      'FOO'
         4        ECHO                                                     ~1
   4     5      > RETURN                                                   1

const例子:

<?php
    const FOO = 'foo';
    echo FOO;

const opcode:

number of ops:  4
compiled vars:  none
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   DECLARE_CONST                                            'FOO', 'foo'
   3     1        FETCH_CONSTANT                                   ~0      'FOO'
         2        ECHO                                                     ~0
   4     3      > RETURN                                                   1

如果你是使用VS查看源码的话,将光标移到zend_execute并直接按F12,
你会发现zend_execute的定义跳转到了一个指针函数的声明(Zend/zend_execute_API.c)。

5.3 动态函数的代价

<?php
    function foo() { }
    foo();

对应opcode:

number of ops:  3
compiled vars:  none
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   NOP
   3     1        DO_FCALL                                      0          'foo'
   4     2      > RETURN                                                   1

动态调用的代码:

<?php
    function foo() { }
    $a = 'foo';
    $a();

opcode:

number of ops:  5
compiled vars:  !0 = $a
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   NOP
   3     1        ASSIGN                                                   !0, 'foo'
   4     2        INIT_FCALL_BY_NAME                                       !0
         3        DO_FCALL_BY_NAME                              0
   5     4      > RETURN                                                   1

可以 vim Zend/zend_vm_def.h
+2630,看看INIT_FCALL_BY_NAME做的事情,代码太长,这里不列出来了。动态特性虽然方便,但一定会牺牲性能,所以使用前要平衡利弊。

1 ZEND_API void (*zend_execute)(zend_op_array *op_array TSRMLS_DC);

5.4 类的延迟声明的代价

还是先看代码:

<?php
    class Bar { }
    class Foo extends Bar { }

对应opcode:

number of ops:  4
compiled vars:  none
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   NOP
   3     1        NOP
         2        NOP
   4     3      > RETURN                                                   1

调换声明顺序:

<?php
    class Foo extends Bar { }
    class Bar { }

对应opcode:

number of ops:  4
compiled vars:  none
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   FETCH_CLASS                                   0  :0      'Bar'
         1        DECLARE_INHERITED_CLASS                                  '%00foo%2FUsers%2Fqisen%2Ftmp%2Fvld.php0x103d58020', 'foo'
   3     2        NOP
   4     3      > RETURN                                                   1

如果在强语言中,后面的写法会产生编译错误,但PHP这种动态语言,会把类的声明推迟到运行时,如果你不注意,就很可能踩到这个雷。

所以在我们了解Zend
VM原理后,就更应该注意少用动态特性,可有可无的时候,就一定不要用。

转自:

这是一个全局的函数指针,它的作用就是执行PHP代码文件解析完的转成的zend_op_array。
和zend_execute相同的还有一个zedn_execute_internal函数,它用来执行内部函数。
在PHP内核启动时(zend_startup)时,这个全局函数指针将会指向execute函数。
注意函数指针前面的修饰符ZEND_API,这是ZendAPI的一部分。
在zend_execute函数指针赋值时,还有PHP的中间代码编译函数zend_compile_file(文件形式)和zend_compile_string(字符串形式)。

1 zend_compile_file = compile_file;
2 zend_compile_string = compile_string;
3 zend_execute = execute;
4 zend_execute_internal = NULL;
5 zend_throw_exception_hook = NULL;

这几个全局的函数指针均只调用了系统默认实现的几个函数,比如compile_file和compile_string函数,
他们都是以全局函数指针存在,这种实现方式在PHP内核中比比皆是,其优势在于更低的耦合度,甚至可以定制这些函数。
比如在APC等opcode优化扩展中就是通过替换系统默认的zend_compile_file函数指针为自己的函数指针my_compile_file,
并且在my_compile_file中增加缓存等功能。

到这里我们找到了中间代码执行的最终函数:execute(Zend/zend_vm_execure.h)。
在这个函数中所有的中间代码的执行最终都会调用handler。这个handler是什么呢?

1 if ((ret = EX(opline)->handler(execute_data TSRMLS_CC)) > 0) {
2 }

这里的handler是一个函数指针,它指向执行该opcode时调用的处理函数。
此时我们需要看看handler函数指针是如何被设置的。
在前面我们有提到和execute一起设置的全局指针函数:zend_compile_string。
它的作用是编译字符串为中间代码。在Zend/zend_language_scanner.c文件中有compile_string函数的实现。
在此函数中,当解析完中间代码后,一般情况下,它会执行pass_two(Zend/zend_opcode.c)函数。
pass_two这个函数,从其命名上真有点看不出其意义是什么。
但是我们关注的是在函数内部,它遍历整个中间代码集合,
调用ZEND_VM_SET_OPCODE_HANDLER(opline);为每个中间代码设置处理函数。
ZEND_VM_SET_OPCODE_HANDLER是zend_vm_set_opcode_handler函数的接口宏,
zend_vm_set_opcode_handler函数定义在Zend/zend_vm_execute.h文件。
其代码如下:

01 static opcode_handler_t zend_vm_get_opcode_handler(zend_uchar opcode, zend_op* op)
02 {
03         static const int zend_vm_decode[] = {
04             _UNUSED_CODE, /* 0              */
05             _CONST_CODE,  /* 1 = IS_CONST   */
06             _TMP_CODE,    /* 2 = IS_TMP_VAR */
07             _UNUSED_CODE, /* 3              */
08             _VAR_CODE,    /* 4 = IS_VAR     */
09             _UNUSED_CODE, /* 5              */
10             _UNUSED_CODE, /* 6              */
11             _UNUSED_CODE, /* 7              */
12             _UNUSED_CODE, /* 8 = IS_UNUSED  */
13             _UNUSED_CODE, /* 9              */
14             _UNUSED_CODE, /* 10             */
15             _UNUSED_CODE, /* 11             */
16             _UNUSED_CODE, /* 12             */

相关文章

发表评论

电子邮件地址不会被公开。 必填项已用*标注

网站地图xml地图