用户定义函数的执行

本章前面介绍了 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所示。

image 2024 06 10 23 46 35 310
Figure 1. 图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 中自然少不了 oplineop_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 undefined function”。被查找的函数名由 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' 语句也需要临时变量存储。新的操作空间分配完成后,引擎将执行 handler ZEND_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所示。

image 2024 06 10 23 52 31 486
Figure 2. 表13-2 函数体 opcode 及 handler

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

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

image 2024 06 10 23 53 10 942
Figure 3. 图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 函数执行的关键就是自定义数据结构来模拟栈,完成函数调用,好处是能避免操作系统提供的栈的内存大小限制。