第13章 函数实现

319 阅读13分钟

我们已经了解了Zend引擎将PHP代码从文本,经过词法解析、语法解析,到执行产生结果的过程。本章将介绍PHP 7函数机制的实现。

13.1 基础知识

函数是可供重复调用的代码块,是编程语言可复用性的重要组成。当函数被调用时,调用者根据函数名找到函数定义的指令集合、执行指令并返回结果给调用者。

PHP 7的函数可以分为3大类:用户定义函数、内置函数和匿名函数。

  1. 用户定义函数:以关键字function定义的函数,在我们的代码中广泛使用。用户函数需要经过引擎的词法和语法解析才能最终生成可供调用的指令。
  2. 内置函数:包含默认编译进PHP的函数,如string系列;还包括其他选择编译的扩展函数,如常用的mysql_connect函数。内置函数无须经过Zend引擎的词法、语法解析即可直接调用。其速度优于用户定义函数。
  3. 匿名函数:以没有函数名的定义形式出现,实现了闭包的性质。以下为PHP官方手册匿名函数示例:
    <?php
    echo preg_replace_callback('~-([a-z])~', function ($match) {
        return strtoupper($match[1]);
    }, 'hello-world');
    ?>
    

下面我们开始介绍PHP 7中函数从PHP代码到AST再到opcode最后被执行的实现机制。

13.2 用户定义函数的编译

下面以脚本文件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 (stringsource)方法,供开发者查看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_lineno、end_lineno:分别表示函数代码的起止行。
  • flags:标记了函数返回类型是否为引用、是否有返回值、是否为类内成员函数等。
  • lex_pos:函数代码结束位置。
  • name:函数名。
  • child:存储4个AST节点,依次为参数列表节点(ZEND_AST_ARG_LIST)、use列表节点、函数实现表达式节点和返回值类型节点。其中,参数列表节点的类型为ZEND_AST_PARAM_LIST,由3个孩子组成,分别记录参数类型、参数名称和参数的默认值。

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

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

yyparse返回最终func.php文件生成的AST如图13-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_f lags标记为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_f lags函数用于对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所示。

表13-1 opcode及handler

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

13.3 用户定义函数的执行

本章前面介绍了PHP 7用户定义函数的编译,由PHP源码生成AST,再编译为opcodes的过程。本节将在函数编译结果opcodes的基础上,说明函数是如何被执行的。

函数提供了一种复用机制,即在代码执行流中可以从调用函数的代码行跳转到真正的代码实现行,并在调用过程中完成参数传递(输入参数和返回结果)。调用操作对应的是表13-1中生成的opcode集合中的ZEND_DO_UCALL,以本章示例代码为例,该指令对应的handler是ZEND_DO_UCALL_SPEC_HANDLER。

我们在Zend引擎部分已经接触过了PHP代码的运行原理,这里先暂别PHP,让我们看看计算机系统中普遍采用的函数机制的实现:首先要明确的是,要借助栈数据结构的LIFO(Last In First Out)特性,模拟函数调用的层级关系。函数调用发生时,被调用者压栈,函数内定义的局部变量依次压栈;接下来,将控制权转移给被调用函数,执行并计算结果;最后,结果和控制权返回给调用者,被调用函数内局部变量的生存周期随着函数执行完毕、临时栈空间的销毁而结束。函数调用机制示意图如图13-2所示。

图13-2 函数调用机制示意图

PHP函数的实现与其类似。不同的是,PHP不使用操作系统提供的栈,而是在堆上申请内存,用数据结构execute_data模拟栈帧,支持函数调用的层级、嵌套关系。在引擎执行过程中,该结构保存了执行器的现场环境,是执行器最重要的数据结构。

下面是execute_data的结构体简介,后面会结合实例对其每个成员涉及的操作进行详细说明。

truct _zend_execute_data {
    const zend_op       *opline; // 正在执行的opcode
    zend_execute_data   *call; // 当前调用
    zval                 *return_value; // 指向返回值的zval指针
    zend_function       *func; // 指向当前调用的函数
    zval                  This; // 记录对象信息以及num_args、call_info信息
    zend_execute_data   *prev_execute_data; // 前序调用 模拟实现控制权转移
    zend_array          *symbol_table; // 全局变量符号表
    #if ZEND_EX_USE_RUN_TIME_CACHE
    void                 **run_time_cache;
    #endif
    #if ZEND_EX_USE_LITERALS
    zval                 *literals; // 共享字面量数组
    #endif
};
  • opline:Zend引擎的输入是op_array,execute_data中自然少不了opline(op_array中的某一条opcode)。
  • call:引擎执行的当前作用域,函数调用中会切换作用域,其实就是对call成员的操作。
  • func:记录着函数相关的信息。
  • This:虽是类语法的关键字,在这里空间也被复用记录num_args。
  • prev_execute_data:zend_execute_data指针,存储着调用栈的前一次调用位置,用来恢复现场。
  • symbol_table:符号表。
  • run_time_cache:当开启了run_time cache后,会用到该成员。
  • literals:与op_array中的字面量数组一样。

PHP实现函数调用的过程共分为3个阶段。

第一阶段:调用栈空间初始化。这部分会分配函数执行期间需要的操作空间,并根据参数的实际调用情况(实际传入参数个数)对新分配的空间做进一步赋值。

第二阶段:切换作用域,执行函数实体。

第三阶段:传递执行结果,释放操作空间,引擎执行位置切换回原始调用位置。下面详细说明函数调用的过程。

下面详细说明函数调用的过程。

  1. 根据函数名在EG(function_table) 中进行查找,确认函数是否存在,若不存在则会提示“Call to undefinedfunction”。被查找的函数名由opline->op2获得。
  2. 分配运行时栈作为函数执行时的操作空间。当引擎执行到函数调用时,会创建新的zend_execute_data结构作为当前函数调用的运行栈。所以,对于递归调用的情况,会逐层创建递归调用栈,消耗大量的调用栈空间及执行时间,这也是我们在一些情况下规避递归的原因。

调用关系靠指针prev_execute_data维护——新生成的execute_data结构中的prev_execute_data指向新调用栈之前的zend_execute_data,由此建立调用关系,实现执行流的跳转。

call = zend_vm_stack_push_call_frame_ex(
opline->op1.num,  ZEND_CALL_NESTED_FUNCTION,  fbc,  opline->extended_value,  NULL,
    NULL);
call->prev_execute_data = EX(call);
EX(call) = call;

注意

在以上操作的最后一步,call被赋值给execute_data.call,即将作用域切换到新的调用栈。


上述步骤中,分配的新调用栈空间是通过zend_vm_stack_push_call_frame_ex完成的。在zend_vm_stack_push_call_frame_ex调用过程中,根据参数个数为函数调用栈预留了参数的空间。参数包含了代码中定义的变量和中间变量。这里我们以func.php为例,m是定义的IS_CV变量;而执行函数体的赋值语句$m.= "php",则会产生一个中间临时变量,在计算要生成的zend_execute_data空间大小时,这个中间临时变量也会计算在内。

zend_vm_stack_push_call_frame函数和zend_vm_stack_push_call_frame_ex函数的实现如下:

static zend_execute_data *zend_vm_stack_push_call_frame(uint32_t call_info, zend_
    function  *func,  uint32_t  num_args,  zend_class_entry  *called_scope,  zend_
    object *object)
{
    uint32_t used_stack = zend_vm_calc_used_stack(num_args, func);
    return zend_vm_stack_push_call_frame_ex(used_stack, call_info,
        func, num_args, called_scope, object);
}
zend_execute_data *zend_vm_stack_push_call_frame_ex(uint32_t used_stack, uint32_t
    call_info, zend_function *func, uint32_t num_args, zend_class_entry *called_
    scope, zend_object *object){
    zend_execute_data *call = (zend_execute_data*)EG(vm_stack_top);
    // 如果vm_stack不够用则扩容
    if (UNEXPECTED(used_stack  >  (size_t)(((char*)EG(vm_stack_end))  -  (char*)
        call))) {
        call = (zend_execute_data*)zend_vm_stack_extend(used_stack);
        ZEND_SET_CALL_INFO(call, call_info | ZEND_CALL_ALLOCATED);
    } else {
        EG(vm_stack_top) = (zval*)((char*)call + used_stack);
        ZEND_SET_CALL_INFO(call, call_info);
    }
    call->func = func;
    Z_OBJ(call->This) = object;
    ZEND_CALL_NUM_ARGS(call) = num_args;
    call->called_scope = called_scope;
    return call;
}

其中,zend_vm_calc_used_stack用来计算需要分配变量相关的空间。首先used_stack初始化为ZEND_CALL_FRAME_SLOT与num_args之和。其中ZEND_CALL_FRAME_SLOT为以zval对齐的zend_execute_data的空间大小,num_args是实际传参的个数。

在这个例子中,如果满足ZEND_USER_CODE(func->type) 为真的条件,还需要在used_stack基础上加两部分空间:一部分是op_array.last_var(脚本定义的变量数),另一部分是op_array.T(临时变量数)。两部分乘以zval的大小,即为该部分的计算结果,追加给zend_execute_data,作为运行时栈空间使用。

static  zend_always_inline  uint32_t  zend_vm_calc_used_stack(uint32_t  num_args,
    zend_function *func){
    uint32_t used_stack = ZEND_CALL_FRAME_SLOT + num_args;
    if (EXPECTED(ZEND_USER_CODE(func->type))) {
        used_stack  +=  func->op_array.last_var  +  func->op_array.T  -  MIN(func->
            op_array.num_args, num_args);
    }
    return used_stack * sizeof(zval);
}

其中有一点需要注意:从vm_stack申请空间创建zend_execute_data时,直接调用zend_vm_stack_push_call_frame_ex函数。其中,参数used_stack的值为上下文空间中的opline->op1.num。那么opline->op1.num是在何时赋值呢?其实在用户函数编译成opcode阶段,op1.num会依据ZEND_CALL_FRAME_SLOT与参数数量、临时变量的和进行赋值。所以,在本示例中,used_stack为(6+2+2)×16。临时变量为2的原因是语句n=m.‘php’有返回值;同时,$m.‘php’语句也需要临时变量存储。

新的操作空间分配完成后,引擎将执行handlerZEND_SEND_VAL_SPEC_CONST_HANDLER,传递参数到新的zend_execute_data中,参数传递遵循“写时复制”原则。

以上是函数真正执行前的准备工作。准备工作就绪后,进入第二部分,即开始函数的调用过程。Zend引擎此时执行的指令是ZEND_DO_UCALL,对应的handler为ZEND_DO_UCALL_SPEC_HANDLER,实现如下:

static ZEND_DO_UCALL_SPEC_HANDLER(ZEND_OPCODE_HANDLER_ARGS){
    zend_execute_data *call = EX(call);
    zend_function *fbc = call->func;
    zval *ret;
    EX(call) = call->prev_execute_data;
    EG(scope) = NULL;
    ret = NULL;
    call->symbol_table = NULL;
    call->prev_execute_data = execute_data;
    i_init_func_execute_data(call, &fbc->op_array, ret, 0);
    ZEND_VM_ENTER();
}

表13-1只给出了主程序编译后的opcode数组,并未给出函数体的opcode。其实,函数体的编译结果存储在call->func的op_array成员中,在函数执行时被取出,逐条执行。本章示例的函数体编译的opcode结果如表13-2所示。

表13-2 函数体opcode及handler

引擎通过执行上下文的切换,实现函数调用和返回跳转。在切换之前,要做一些现场保护工作。可以通过对call(当前调用栈)和EX(call)的prev_execute_data成员操作,建立调用关系。

如图13-7所示,引擎切换上下文,将EG(current_execute_data)切换到当前要执行的函数。

图13-3 函数执行过程初始化指令到调用指令当前栈空间的变化

完成了操作空间的切换,开始执行函数实现的opcode。表13-2的opcode集合比较简单,与Zend引擎处理的其他PHP代码无异,主要实现变量的赋值和返回,此处不再赘述,更多细节可以参考第11章Zend引擎的原理相关内容。

获取返回值对应的opcode是ZEND_RETURN, handler是ZEND_RETURN_SPEC_CV_HANDLER:

static ZEND_FASTCALL ZEND_RETURN_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS){
    zval *retval_ptr;
    zend_free_op free_op1;
    retval_ptr = _get_zval_ptr_cv_undef(execute_data, opline->op1.var);
        if (IS_CV == IS_CONST || IS_CV == IS_TMP_VAR) {
            ZVAL_COPY_VALUE(EX(return_value), retval_ptr);
        } else if (IS_CV == IS_CV) {
            ZVAL_DEREF(retval_ptr);
            ZVAL_COPY(EX(return_value), retval_ptr);
        } else /* if (IS_CV == IS_VAR) */ {
            if (UNEXPECTED(Z_ISREF_P(retval_ptr))) {
                zend_refcounted *ref = Z_COUNTED_P(retval_ptr);
                retval_ptr = Z_REFVAL_P(retval_ptr);
                ZVAL_COPY_VALUE(EX(return_value), retval_ptr);
                if (UNEXPECTED(--GC_REFCOUNT(ref) == 0)) {
                    efree_size(ref, sizeof(zend_reference));
                } else if (Z_OPT_REFCOUNTED_P(retval_ptr)) {
                    Z_ADDREF_P(retval_ptr);
                }
            }
    }
ZEND_VM_TAIL_CALL(zend_leave_helper_SPEC(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU));
}

从以上代码可以看出,获取返回值的同时,变量的引用计数也会加以处理;引用计数减至零时,会释放资源。

返回值传递完毕后,函数执行的第三个部分为清理和现场恢复工作,虚拟机通过调用zend_leave_helper_SPEC来完成。首先,将引擎的执行位置恢复到调用前的位置;而后,i_free_compiled_variables负责释放局部变量,处理变量相应引用计数。

综合前面所讲内容可以看出,PHP函数执行的关键就是自定义数据结构来模拟栈,完成函数调用,好处是能避免操作系统提供的栈的内存大小限制。

13.4 内置函数

前面主要从用户定义函数的角度,介绍了PHP 7中函数机制的实现。本节将焦点转移到内置函数,并说明内置函数与用户定义函数的不同之处。

13.4.1 内置函数的注册

内置函数定义、注册后,CG(function_table)会将之载入,而无须经过用户定义函数必需的词法、语法解析等编译过程,效率自然更高。PHP 7内核开发者提供了很多核心内置函数。同时,数量庞大的社区开发者也在PHP核心能力之上,开发了众多功能丰富的扩展,供开发者选择。

通过方法get_extension_funcs可以获得安装成功的扩展模块相关的内置函数列表。例如,获取xml模块相关函数:

<?php
print_r(get_extension_funcs("xml"));

那么内置函数如何被开发者直接调用呢?

在第7章中,我们介绍过php_module_startup的过程。PHP内置函数的注册在php_register_internal_extensions中完成:

#define EXTCOUNT (sizeof(php_builtin_extensions)/sizeof(zend_module_entry *))
PHPAPI int php_register_internal_extensions(void) {
    return php_register_extensions(php_builtin_extensions, EXTCOUNT);
}

php_builtin_extensions包含了需要初始化的所有内部扩展。内部函数的注册最终在zend_API.c文件的zend_register_functions方法中完成。整体注册流程如图13-4所示。

图13-4 函数执行过程初始化指令到调用指令当前栈空间的变化

注册过程会处理函数类型、作用域等诸多细节,如果忽略细节,函数注册的精简过程是将每一个内置函数存储进CG(function_table)。保存内部函数信息的结构体是zend_internal_function,想必读者对这个名字不会陌生(在13.2节讲解用户定义函数的实现机制时已经介绍过,它与结构体op_array和zend_function拥有共同的成员common,这样便可以通过通用接口快速访问成员。

13.4.2 内置函数的执行

在用户定义函数的执行部分,我们介绍过用户定义函数的调用会编译成ZEND_DO_UCALL类型opcode;而内置函数对应的opcode为ZEND_DO_ICALL。在ZEND_DO_UCALL和ZEND_DO_ICALL对应的handler中,再完成具体操作。

内置函数还有一种情况,即不生成ZEND_DO_ICALL,直接执行函数对应的opcode。如PHP的内置函数strlen:

<?php
$a = 'hi php';
strlen($a);

生成的opcodes如下:

ZEND_ASSIGN
ZEND_STRLEN
ZEND_FREE
ZEND_RETURN

如果代码为

strlen('123456');

则生成的opcodes为

ZEND_RETURN

可以看到,在第一种情况中,参数为变量,对应的opcode为ZEND_STRLEN;而在第二种情况中,同样为strlen函数,不过参数变成了常量,对应的opcode只有ZEND_RETURN。

用户自定义函数的执行通过引擎执行函数的opcode集合来获得结果;而内置函数的执行,通过internal_function.handler的调用完成,并将返回结果赋值给opline的result.var。其他地方与用户定义函数类似,这里不再赘述。

13.5 本章小结

本章从用户定义函数的方向分析了PHP 7函数机制的原理,并简单介绍了内置函数的实现和两者的不同之处。第14章将对PHP扩展有完整的说明,便于在用户自定义函数的性能无法满足项目需求时,开发定制化的扩展函数,提升应用性能。