前言

读了读编译原理,用一句简单的 PHP 代码来理一遍 PHP 的编译执行过程。


简单的测试脚本

<?php
$Twings = 2333;

总览

PHP 的编译执行大概是这么一个流程:

  1. 读入代码字符串,词法分析得到 token 和 value。
  2. 语法分析 token,建立 AST 树。
  3. 遍历分析 AST 树,将数据写入内存,并生成 op_array。
  4. 遍历 op_array,根据 opcode 调用不同的 handler 执行操作。

入口

直接从 zend.c 的 zend_execute_scripts 函数开始看:

ZEND_API int zend_execute_scripts(int type, zval *retval, int file_count, ...) /* {{{ */
{
    ...
    for (i = 0; i < file_count; i++) {
        file_handle = va_arg(files, zend_file_handle *);
        if (!file_handle) {
            continue;
        }

        op_array = zend_compile_file(file_handle, type);
        ...
        if (op_array) {
            zend_execute(op_array, retval);
            zend_exception_restore();
            ...
            destroy_op_array(op_array);
            efree_size(op_array, sizeof(zend_op_array));
        }
        ...
    }
    ...
}

一般来说这里会编译执行 3 个文件,分别是 auto_prepend_file、要执行的 PHP 文件 和 auto_append_file。

这里的 zend_compile_file 是个全局函数,指向的是 compile_file 函数:

zend_compile_file = compile_file;

compile_file 先调用 open_file_for_scanning 做好词法分析前的准备(比如设置词法分析的 state 为 INITIAL),再调用 zend_compile 开始编译:

ZEND_API zend_op_array *compile_file(zend_file_handle *file_handle, int type)
{
    zend_lex_state original_lex_state;
    zend_op_array *op_array = NULL;
    zend_save_lexical_state(&original_lex_state);

    if (open_file_for_scanning(file_handle)==FAILURE) {
        ...
    } else {
        op_array = zend_compile(ZEND_USER_FUNCTION);
    }

    zend_restore_lexical_state(&original_lex_state);
    return op_array;
}

zend_compile 函数里面就是总览里面提到的 1-3 的处理过程了,首先是调用 zendparse 开始词法分析。

词法分析

PHP 的编译采用的是 re2c 词法分析 + Bison 语法分析。在默认情况下,调用 yyparse 开始语法分析,而 yyparse 则不断调用 yylex 从代码字符串中获取 token 和 value 再加以分析,而在 PHP 中这两个函数则分别被替换成了 zendparse 和 zendlex。所以在调用 zendparse 开始语法分析之后,Bison 会调用 zendlex 开始词法分析:

int ZEND_FASTCALL zendlex(zend_parser_stack_elem *elem) /* {{{ */
{
    zval zv;
    int ret;

    if (CG(increment_lineno)) {
        CG(zend_lineno)++;
        CG(increment_lineno) = 0;
    }

    ret = lex_scan(&zv, elem);
    ZEND_ASSERT(!EG(exception) || ret == T_ERROR);
    return ret;

}

这里的 zend_parser_stack_elem 结构体实际上是用来存放 value 的,因为后面语法分析才会用到所以这里暂且不提。

zendlex 调用 lex_scan,后面就是词法分析的语法了,很长这里就不贴了,如果想要自己看最好看 .l 的 re2c 规则文件(匹配规则使用正则表达式,还是挺好看的,简单来说规则就是:\<state> 正则表达式 { 匹配后返回 token 的处理代码 } ),如果看 re2c 编译后的 c 文件,里面的代码逻辑简直不能看的。

结合测试代码和词法规则,我们可以得出分析的过程:

/*!re2c
LNUM    [0-9]+(_[0-9]+)*
LABEL    [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*
TOKENS [;:,.\[\]()|^&+-/*=%!~$<>?@]
NEWLINE ("\r"|"\n"|"\r\n")
*/
// <?php\n
<INITIAL>"<?php"([ \t]|{NEWLINE}) {
    HANDLE_NEWLINE(yytext[yyleng-1]);
    BEGIN(ST_IN_SCRIPTING);
    RETURN_OR_SKIP_TOKEN(T_OPEN_TAG);
}
// $Twings
<ST_IN_SCRIPTING,ST_DOUBLE_QUOTES,ST_HEREDOC,ST_BACKQUOTE,ST_VAR_OFFSET>"$"{LABEL} {
    RETURN_TOKEN_WITH_STR(T_VARIABLE, 1);
}
// = 
<ST_IN_SCRIPTING>{TOKENS} {
    RETURN_TOKEN(yytext[0]);
}
// 2333
<ST_IN_SCRIPTING>{LNUM} {
    ...
    RETURN_TOKEN_WITH_VAL(T_LNUMBER);
}
// ;
<ST_IN_SCRIPTING>{TOKENS} {
    RETURN_TOKEN(yytext[0]);
}

匹配到 = 和 ; 会直接返回字符的,没有什么其他操作。

在匹配到 <?php\n 的 open tag 之后,会将 state 改写为 ST_IN_SCRIPTING 用于下一次的匹配,然后返回一个代表 open tag 的 token(一般是一个整型数)。

然后开始匹配变量 $Twings,这里符合的匹配规则是 “$”{LABEL}(LABEL 其实就是 PHP 的合法变量名构成),这个规则除了返回 token,还会用变量名生成一个 AST 节点存储在前面提到的 zend_parser_stack_elem 里面,在后面的语法分析部分会作为子节点组装成一个完整的操作节点:

#define RETURN_TOKEN_WITH_STR(_token, _offset) do { \
        token = _token; \
        offset = _offset; \
        goto emit_token_with_str; \
    } while (0)
...
emit_token_with_str:
    zend_copy_value(zendlval, (yytext + offset), (yyleng - offset));

emit_token_with_val:
    if (PARSER_MODE()) {
        ZEND_ASSERT(Z_TYPE_P(zendlval) != IS_UNDEF);
        elem->ast = zend_ast_create_zval_with_lineno(zendlval, start_line);
    }

elem -> ast 的生成代码如下:

ZEND_API zend_ast * ZEND_FASTCALL zend_ast_create_zval_with_lineno(zval *zv, uint32_t lineno) {
    return zend_ast_create_zval_int(zv, 0, lineno);
}

static zend_always_inline zend_ast * zend_ast_create_zval_int(zval *zv, uint32_t attr, uint32_t lineno) {
    zend_ast_zval *ast;

    ast = zend_ast_alloc(sizeof(zend_ast_zval));
    ast->kind = ZEND_AST_ZVAL;
    ast->attr = attr;
    ZVAL_COPY_VALUE(&ast->val, zv);
    Z_LINENO(ast->val) = lineno;
    return (zend_ast *) ast;
}

可以看到本来是个存放着 zval 的 zend_ast_zval 结构体,返回的时候为了适应 AST 树节点结构体而转换为了 zend_ast 类型的指针。

后半 LNUM 的操作里面有八进制和下划线的一些处理,想知道的可以自行阅读源码。

词法分析的过程大致如上,获取到 token 之后,接下来就是语法分析了。

语法分析

同样地,想要阅读源码建议看 zend_language_parser.y 里面 Bison 编译前的代码,更加人类可读一点。

在开头部分可以看到很多用 %token 声明的 token,比如说前面解析出的 T_VARIABLE:

%token <ast> T_VARIABLE  "variable (T_VARIABLE)"

\<ast> 指 T_VARIABLE 的类型为 ast,其实就是前面提到的用 union 声明的 _zend_parser_stack_elem 结构体里面的 ast 成员,这个结构体里面声明的就是语法分析里面 token 要用的类型:

typedef union _zend_parser_stack_elem {
    zend_ast *ast;
    zend_string *str;
    zend_ulong num;
    unsigned char *ptr;
} zend_parser_stack_elem;

zend_ast 的定义如下,主要的就是操作码和子节点两个成员:

struct _zend_ast {
    zend_ast_kind kind; /* Type of the node (ZEND_AST_* enum constant) */
    zend_ast_attr attr; /* Additional attribute, use depending on node type */
    uint32_t lineno;    /* Line number */
    zend_ast *child[1]; /* Array of children (using struct hack) */
};

双引号里面的是这个 token 人类可读的字面表示。

语法规则跟词法分析里面的相似,同样是匹配 + 处理,不同的是语法分析里面还有套娃匹配。

阅读规则,我们可以得出分析的过程:

start:
    top_statement_list    { CG(ast) = $1; }
;

top_statement_list:
        top_statement_list top_statement { $$ = zend_ast_list_add($1, $2); }
    |    /* empty */ { $$ = zend_ast_create_list(0, ZEND_AST_STMT_LIST); }
;

top_statement:
        statement                            { $$ = $1; }
    |    ...
;

statement:
        ...
    |    expr ';' { $$ = $1; }
    |    ...
;

expr:
        ...
    |    variable '=' expr
            { $$ = zend_ast_create(ZEND_AST_ASSIGN, $1, $3); }
    |    scalar { $$ = $1; }
    |    ...
;

variable:
        callable_variable
            { $$ = $1; }
    |    ...
;

callable_variable:
        simple_variable
            { $$ = zend_ast_create(ZEND_AST_VAR, $1); }
    |    ...
;

simple_variable:
        T_VARIABLE            { $$ = $1; }
    |    ....
;

scalar:
        T_LNUMBER     { $$ = $1; }
    |
;

$$ 代表本次处理完成后返回给上一级的值,$1 代表匹配规则中第一个符号的值。

这样看来就比较直观了,递归套娃,expr 中调用 zend_ast_create_list,用之前词法分析生成的两个 AST 节点组装成一个 ASSIGN 操作的节点,因为 ASSIGN 操作需要 2 个操作数,所以最后调用的其实是 zend_ast_create_2 函数:

ZEND_API zend_ast * ZEND_FASTCALL zend_ast_create_2(zend_ast_kind kind, zend_ast *child1, zend_ast *child2) {
    zend_ast *ast;
    uint32_t lineno;

    ZEND_ASSERT(kind >> ZEND_AST_NUM_CHILDREN_SHIFT == 2);
    ast = zend_ast_alloc(zend_ast_size(2));
    ast->kind = kind;
    ast->attr = 0;
    ast->child[0] = child1;
    ast->child[1] = child2;
    if (child1) {
        lineno = zend_ast_get_lineno(child1);
    } else if (child2) {
        lineno = zend_ast_get_lineno(child2);
    } else {
        lineno = CG(zend_lineno);
    }
    ast->lineno = lineno;

    return ast;
}

简单来说就是将操作码和两个子节点放进一个新的节点里。完成一整部分代码(top_statement)的分析之后,会将这一部分生成的节点挂到前面(top_statement_list)生成的 AST 树的子节点上,后面通过遍历这棵树来依次处理这些 AST 节点。

遍历 AST 树

zend_compile 在调用 zendparse 完成语法分析之后,会调用 zend_compile_top_stmt 开始处理 AST 树:

void zend_compile_top_stmt(zend_ast *ast) /* {{{ */
{
    if (!ast) {
        return;
    }

    if (ast->kind == ZEND_AST_STMT_LIST) {
        zend_ast_list *list = zend_ast_get_list(ast);
        uint32_t i;
        for (i = 0; i < list->children; ++i) {
            zend_compile_top_stmt(list->child[i]);
        }
        return;
    }

    if (ast->kind == ZEND_AST_FUNC_DECL) {
        ...
    } else if (ast->kind == ZEND_AST_CLASS) {
        ...
    } else {
        zend_compile_stmt(ast);
    }
    if (ast->kind != ZEND_AST_NAMESPACE && ast->kind != ZEND_AST_HALT_COMPILER) {
        zend_verify_namespace();
    }
}

如果是根节点,则遍历它的子节点重新调用本函数。

如果是 ASSIGN 类型的节点,则经过 zend_compile_stmt 和 zend_compile_expr 函数的 switch 之后会调用 zend_compile_assign:

void zend_compile_assign(znode *result, zend_ast *ast) /* {{{ */
{
    zend_ast *var_ast = ast->child[0];
    zend_ast *expr_ast = ast->child[1];

    znode var_node, expr_node;
    zend_op *opline;
    uint32_t offset;

    if (is_this_fetch(var_ast)) {
        zend_error_noreturn(E_COMPILE_ERROR, "Cannot re-assign $this");
    }

    zend_ensure_writable_variable(var_ast);

    switch (var_ast->kind) {
        case ZEND_AST_VAR:
            offset = zend_delayed_compile_begin();
            zend_delayed_compile_var(&var_node, var_ast, BP_VAR_W, 0);
            zend_compile_expr(&expr_node, expr_ast);
            zend_delayed_compile_end(offset);
            zend_emit_op(result, ZEND_ASSIGN, &var_node, &expr_node);
            return;
        case ZEND_AST_STATIC_PROP:
            ...
        case ZEND_AST_DIM:
            ...
        case ZEND_AST_PROP:
            ...
        case ZEND_AST_ARRAY:
            ...
        EMPTY_SWITCH_DEFAULT_CASE();
    }
}

主要看 case ZEND_AST_VAR 里面的处理,这里调用 zend_emit_op 将 AST 节点处理成了 opcode,其结构体为 zend_op,里面存放了该 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;
};

顺带一提的是这里 var_node 的类型,即 op1_type 表示的是 cv(编译期缓存),还有就是这个时候,opcode 中的处理函数还是空的,赋值操作还在后面。然后将这个 opcode 放入 opcode 数组中:

static zend_op *zend_emit_op(znode *result, zend_uchar opcode, znode *op1, znode *op2) /* {{{ */
{
    zend_op *opline = get_next_op();
    opline->opcode = opcode;

    if (op1 != NULL) {
        SET_NODE(opline->op1, op1);
    }

    if (op2 != NULL) {
        SET_NODE(opline->op2, op2);
    }

    if (result) {
        zend_make_var_result(result, opline);
    }
    return opline;
}

opcode 数组的结构体比较长,这里就不贴了。

zend_compile_top_stmt 遍历完 AST 树生成 opcode 之后,会调用 pass_two 函数为每个 opcode 加上处理函数,因为查找的方式比较复杂,这里就不提了,想要研究的可以自行阅读 zend_vm_execute.h 的 zend_vm_get_opcode_handler_ex 函数。

执行

这时回到 zend_execute_scripts 函数中,生成 op_array,即 opcode 数组之后,会调用 zend_execute 开始执行,因为这个函数代码量过大,这里只简单叙述一下。

opcode 的执行过程是一个死循环,每次调用该 opcode 的处理函数之后会对返回值 ret 进行判断,如果 <= 0 就会终止循环,而 zend_compile 函数在调用 zend_compile_top_stmt 处理完 AST 树之后,还会调用 zend_emit_final_return 在末尾加上一个 opcode 用于终止循环。

在测试脚本中只有一个 opcode,相应的处理函数是 ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER:

static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    USE_OPLINE

    zval *value;
    zval *variable_ptr;

    SAVE_OPLINE();
    value = RT_CONSTANT(opline, opline->op2);
    variable_ptr = EX_VAR(opline->op1.var);

    if (IS_CV == IS_VAR && UNEXPECTED(Z_ISERROR_P(variable_ptr))) {

        if (UNEXPECTED(0)) {
            ZVAL_NULL(EX_VAR(opline->result.var));
        }
    } else {
        value = zend_assign_to_variable(variable_ptr, value, IS_CONST, EX_USES_STRICT_TYPES());
        if (UNEXPECTED(0)) {
            ZVAL_COPY(EX_VAR(opline->result.var), value);
        }

        /* zend_assign_to_variable() always takes care of op2, never free it! */
    }

    ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
}

在执行结束之后,会调用 ZEND_VM_NEXT_OPCODE_EX 移动 opcode 指针指向下一条 opcode。


后记

简单理了一遍过程,以后有时间再看看其他感兴趣的东西,比如变量哈希表如何存放,函数定义与执行,函数递归等等。


参考:

https://www.php.cn/php-weizijiaocheng-357239.html

https://www.kancloud.cn/nickbai/php7/363255

https://www.bookstack.cn/read/php-internals/0.md


Web PHP

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

Python从编译到执行
unserialize时PHP干了什么