用户定义函数的编译

下面以脚本文件 func.php 中的函数为例,说明函数如何由 PHP 代码生成 opcode,完成执行前准备。

<?php
//示例代码文件func.php
$a = 123;
function my_func(string $m='hello') : string
{
    $n = $m . 'php';
    return $n;
}
echo my_func('hi');

以上 PHP 代码首先定义了一个变量,同时定义了一个具有返回值类型、函数名、参数列表(参数类型、参数名和参数默认值)和返回值的函数,并且在文件最后调用了 my_func 函数。

第 11 章介绍过,PHP 脚本的编译过程主要经历词法分析、语法分析和编译 3 个阶段,其中词法分析阶段把脚本内容切割为符合词法规则的 Token;语法分析阶段将词法分析产生的 Token 集合组装成 AST(抽象语法树);最后经过编译,由 AST 生成可调用指令集 opcodes

这里所例举的 func.php 脚本文件的逻辑较简单,下面的代码清单是从脚本文件经过词法分析生成的 Token 集合:

Line 1: T_OPEN_TAG ('<? php')
Line 3: T_FUNCTION ('function')
Line 3: T_STRING ('my_func')
Line 3: T_VARIABLE ('$m')
Line 3: T_CONSTANT_ENCAPSED_STRING (''hello'')
Line 3: T_STRING ('string')
Line 5: T_VARIABLE ('$n')
Line 5: T_CONSTANT_ENCAPSED_STRING (''php'')
Line 6: T_RETURN ('return')
Line 8: T_STRING ('my_func')
Line 8: T_CONSTANT_ENCAPSED_STRING (''hi '')

PHP 的官方标准函数库提供了 token_get_all(string source) 方法,供开发者查看 PHP 代码(source)生成的 Token 集合。

上述代码示例即为该方法返回值的一部分,感兴趣的读者可以自行尝试。

独立分散的 Token 是没有意义的,只有按照语法规则组装成 AST 之后才能表达语义。Token 生成 AST 需经过 yyparse 的解析,之后,AST 被存储到 complier_globals.ast 中,等待下一步的处理。

函数代码生成 AST 的过程与其他 PHP 代码几乎无差别,其他章节已经介绍过,这里不再赘述。我们知道 AST 的节点有多种类型,函数对应的 AST 节点类型为 ZEND_AST_FUNC_DECL。节点定义如下:

typedef struct _zend_ast_decl {
    zend_ast_kind kind;
    zend_ast_attr attr;
    uint32_t start_lineno;
    uint32_t end_lineno;
    uint32_t flags;
    unsigned char *lex_pos;
    zend_string *doc_comment;
    zend_string *name;
    zend_ast *child[4];
} zend_ast_decl;

各成员说明如下。

  • kind:节点类型。

  • attr:未使用成员,为兼容其他类型节点而定义。

  • start_linenoend_lineno:分别表示函数代码的起止行。

  • flags:标记了函数返回类型是否为引用、是否有返回值、是否为类内成员函数等。

  • lex_pos:函数代码结束位置。

  • name:函数名。

  • child:存储 4 个 AST 节点,依次为参数列表节点(ZEND_AST_ARG_LIST)、use 列表节点、函数实现表达式节点和返回值类型节点。其中,参数列表节点的类型为 ZEND_AST_PARAM_LIST,由 3 个孩子组成,分别记录参数类型、参数名称和参数的默认值。

函数的 AST 节点依然包含 kind 属性和 attr 属性,也就意味着它提供了和其他 AST 节点一样的对外访问接口;它还定义了起始行和终止行,这一点也容易理解,函数声明是代码块结构,start_linenoend_lineno 定义了代码块的边界。

在第 11 章中介绍过,yyparse 处理阶段生成 AST 时,会根据 Token 的类型定义,不断进行子树的创建和子树之间的合并。回到本章的函数代码,在解析过程中遇到 T_FUNCTION 常量表示开始函数定义,当前的 AST 子树被暂存到 yyvsp 全局缓冲区,等待与其他函数元素(如参数列表)的 AST 节点合并,最终合并成以 ZEND_AST_FUNC_DECL 为根节点的函数子树。

yyparse 返回最终 func.php 文件生成的 AST 如图13-1所示。

image 2024 06 10 23 37 42 681
Figure 1. 图13-1 脚本文件func.php转成的AST

这棵子树表示了 PHP 函数需要的所有要素:

  1. 根节点有 3 个孩子,第一个孩子是赋值语句,即脚本文件 func.php 中的第一行代码。

  2. 根节点的第三个孩子是最后的 echo 语句,输出函数调用的结果,也可以看到函数调用对应的节点的类型是 ZEND_AST_CALL。

第二个孩子是本章的主角——脚本文件中 my_func 函数生成的 AST 子树。

如果我们定义的函数中的某些要素有缺省,则 ZEND_AST_FUNC_DECL 子树相应的孩子节点为 NULL。例如 func.php 的示例代码,并没有使用 use 特性,所以子树中的 use 列表节点为 NULL。

AST 到 Opcodes 的编译过程,由 zend_compile_func_decl 函数完成。

我们将 zend_compile_func_decl 函数的核心逻辑拆分成5个部分,下面分别介绍其实现细节。

第一部分:准备工作,如初始化局部变量等。

void zend_compile_func_decl(znode *result, zend_ast *ast) {
    // 第一部分:准备工作,保存现场
    zend_ast_decl *decl = (zend_ast_decl *) ast;
    zend_ast *params_ast = decl->child[0];
    zend_ast *uses_ast = decl->child[1];
    zend_ast *stmt_ast = decl->child[2];
    zend_ast *return_type_ast = decl->child[3];
    zend_bool is_method = decl->kind == ZEND_AST_METHOD;

    zend_op_array *orig_op_array = CG(active_op_array);
    zend_op_array *op_array = zend_arena_alloc(&CG(arena), sizeof(zend_op_array));
    init_op_array(op_array, ZEND_USER_FUNCTION, INITIAL_OP_ARRAY_SIZE);
}

变量 is_method 标记函数是否为类的成员函数(类内成员函数也使用 zend_compile_func_decl 函数进行编译)。

暂存全局变量 CG(zend_compiler_globals) 中的 active_op_array;同时,在 CG(arena) 上分配临时 zend_op_array,并进行初始化,存储当前编译阶段之前生成的 zend_op_array,保存引擎编译的整体进度。

第二部分:编译函数声明。

// 第二部分:编译函数声明。这里生成func_decl指令
zend_begin_func_decl(result, op_array, decl);
CG(active_op_array) = op_array;
zend_oparray_context_begin(&orig_oparray_context);

这部分调用 zend_begin_func_decl 函数,生成一条 ZEND_DECLARE_FUNCTION 类型的 opcode。在执行阶段,对于引擎来讲,ZEND_DECLARE_FUNCTION 类型的 opcode 标志着函数声明的开始。这里会根据 is_method 函数判断是否为类方法,选择使用 zend_begin_method_decl 函数还是 zend_begin_func_decl 函数。

初始化阶段提到的正在编译的 op_array 的 function_name 在这里被赋值为 decl->name,即函数名。

我们知道,PHP 函数名对大小写不敏感,这部分会根据要编译函数的小写形式函数名,做一系列合法性校验。例如,定义 _autoload 函数,必须且只能有 1 个参数。

ZEND_DECLARE_FUNCTION 类型的 opcode 会保存到 CG(active_op_array) 的下一条 opcode 位置,并将操作数 2 设为函数名:

opline = get_next_op(CG(active_op_array));
opline->opcode = ZEND_DECLARE_FUNCTION;
opline->op2_type = IS_CONST;
LITERAL_STR(opline->op2, zend_string_copy(lcname));

注意,CG(active_op_array) 代表的是当前编译阶段的 zend_op_array。

第三部分:编译参数列表。

// 第三部分:编译参数列表
zend_compile_params(params_ast, return_type_ast);
if (uses_ast) {
    zend_compile_closure_uses(uses_ast);
}

这部分调用 zend_compile_params 函数。PHP 7 的参数列表使用 zend_arg_info 结构存储,其定义如下:

typedef struct _zend_arg_info {
    zend_string *name; // 参数名
    zend_string *class_name; //class名
    zend_uchar type_hint; // 标记位:IS_UNDEF、IS_ARRAY、IS_OBJECT等
    zend_uchar pass_by_reference; // 是否传引用
    zend_bool allow_null; // 是否允许为空,参数存在默认值则允许为空
    zend_bool is_variadic; // 可变数量参数列表
} zend_arg_info;

其中,可变数量参数列表标记 is_variadic 在 PHP 5.6 及以上的版本中由 “…​ ” 语法实现,不允许声明默认值。

zend_compile_params 的输入有两个,分别是参数列表节点(函数节点的 child[0])和 func_decl 的返回值类型节点(函数节点的 child[3])。

函数参数的个数可以根据 AST 中参数列表节点的孩子个数判断。在编译过程,还可以根据参数列表 children 的个数,确定 arg_infos 数组申请内存的大小。此外,函数声明的返回值类型会存储到 arg_info[-1]。

if (return_type_ast) {
    /* 使用op_array->arg_info[-1]存储返回值类型 */
    arg_infos = safe_emalloc(sizeof(zend_arg_info), list->children + 1, 0);

    arg_infos++;
    op_array->fn_flags |= ZEND_ACC_HAS_RETURN_TYPE;
}

处理完返回值类型,循环处理参数列表的每个参数项。会对每个参数项的参数名做校验,并分别处理可变数量参数列表、参数默认值、参数类型。

  1. 参数名校验。超级全局变量($_GET 等)、this 不允许作为参数名;同时,参数的最后一个参数才允许为可变参数。

  2. 可变数量参数列表或者参数默认值处理。可变数量参数列表不允许有默认值。如果函数有可变数量参数列表,则当前参数的 opcode 的类型被设为 ZEND_RECV_VARIADIC,同时 op_array->fn_flags 标记为 ZEND_ACC_VARIADIC;如果当前参数有默认值,则 opcode 的类型为 ZEND_RECV_INIT。类型和标记的作用在执行阶段会有所说明。如果函数既没有可变数量参数列表,也没有声明返回值类型,则 opcode 的类型为 ZEND_RECV。

  3. 参数类型的处理。这里会结合参数类型,对参数默认值进行进一步校验。以 array 类型参数为例,如果其默认值不是 array 类型或者不是 NULL,便会抛出语法错误。这一步还会对 arg_info 结构体的其他成员(如 class_name)进行相应赋值。

最后将所有计算结果复制给 op_array(CG(active_op_array)) 的 arg_infos,num_args 的个数,即参数个数。

op_array->num_args = list->children; op_array->arg_info = arg_infos;
/* 可变参数不计 */
if (op_array->fn_flags & ZEND_ACC_VARIADIC) {
    op_array->num_args--;
}
zend_set_function_arg_flags((zend_function*)op_array);

以上代码清单的最后还要调用 zend_set_function_arg_flags 函数。传入参数是强转为 zend_function 类型的 op_array。zend_set_function_arg_flags 函数的功能是处理 zend_function 中 common 成员的一些标记位。zend_function 是 PHP 7 中用来存储函数编译结果的结构体,而 op_array 和 zend_function 有着如下 “巧合”:

union _zend_function {
    zend_uchar type;
    struct {
        zend_uchar type;  /* never used */
        zend_uchar arg_flags[3];
        uint32_t fn_flags;
        zend_string *function_name;
        zend_class_entry *scope;
        union _zend_function *prototype;
        uint32_t num_args;
        uint32_t required_num_args;
        zend_arg_info *arg_info;
    } common;
    zend_op_array op_array;
    zend_internal_function internal_function;
};

union_zend_function 存储了函数名(function_name)、参数信息(arg_info)及一些标记属性(fn_flags,标记是否有返回值、是否有可变数量参数列表等)等成员。需要特别注意的是,zend_function 与 op_array、zend_internal_function 有相同的头部。这样在编译函数参数时,可以对函数类型进行透明操作,通过一致的方式快速访问到 common 的任何成员。这里 zend_set_function_arg_flags 函数用于对 common 中的 arg_flags 进行处理。至此,参数的编译阶段完成。

第四部分:编译函数体。

// 第四部分:编译函数体
zend_compile_stmt(stmt_ast);
if (is_method) {
    zend_check_magic_method_implementation(CG(active_class_entry),
        (zend_function *) op_array, E_COMPILE_ERROR);
}
/* 存储代码结尾(标识符‘; ')的行号 */
CG(zend_lineno) = decl->end_lineno;
zend_do_extended_info();
zend_emit_final_return(NULL);

函数体的 AST 节点类型为 ZEND_AST_STMT_LIST,执行编译的函数是 zend_compile_stmt。其编译过程与普通 PHP 语法的编译过程几乎无异,根据其孩子 AST 节点的类型跳转到对应类型节点的编译 handler 即可。例如,对于函数体内的赋值语句,会调用 zend_compile_assign 进行处理:

void zend_compile_stmt(zend_ast *ast)
{
    CG(zend_lineno) = ast->lineno;
    switch (ast->kind) {
    …
    case ZEND_AST_ASSIGN:
    zend_compile_assign(result, ast);
    return;
    }
}

第五部分:恢复现场。

// 第五部分:恢复现场
pass_two(CG(active_op_array));
zend_oparray_context_end(&orig_oparray_context);
/* 循环变量分隔符出栈 */
zend_stack_del_top(&CG(loop_var_stack));
CG(active_op_array) = orig_op_array; }

这部分会调用 pass_two 函数。其主要操作是处理操作数和 opcode 的 handler,其中的细节已经在 Zend 引擎原理部分详细说明过,这里不再赘述。执行完 pass_two 函数,新的 op_array 被函数的编译结果填充,CG(active_op_array) 赋值为函数编译开始前的值,继续编译后续代码。以 func.php 为例的 PHP 7 代码,最终编译成的 op_array(多条 opcode)如表13-1所示。

image 2024 06 10 23 45 38 459
Figure 2. 表13-1 opcode及handler

在函数执行阶段,表13-1所示的 opcode 集合便作为引擎的输入,逐条被执行。细心的读者会发现,这些 opcode 中没有函数体相关的指令。事实上,该表为 func.php 脚本的编译结果,函数会单独编译成独立的一组 opcode,存储到 zend_function 中。当函数执行时,会通过 zend_execute_data 的 func(zend_function 类型)成员,获取函数实现的具体指令,完成调用。