PHP传参原理深入解析

发布时间:2020-11-14编辑:脚本学堂
本文介绍了php参数传递原理,通过分析php源码深入理解php传参原理,感兴趣的朋友可以参考下。

在编写php扩展时,似乎参数(即传给zend_parse_parameters的变量)是不需要free的。
举例:
 

复制代码 代码示例:

PHP_FUNCTION(test)
{
    char*  str;
    int    str_len;

    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "s", &str, &str_len) == FAILURE) {
        RETURN_FALSE;
    }

    php_printf(str);
   
    // 无需free(str)  
}

运行正常:
test("Hello World"); // 打印Hello World
这里不用担心test函数会发生内存泄露,php会自动帮我们回收这些用于保存参数的变量。

那php究竟是如何做到的呢?要解释这个问题,还是得看php是怎么传递参数的。

EG(argument_stack)简介
简单来讲,在php中的EG中保存了一个专门用于存放参数的栈,名为argument_stack。每当发生函数调用时,php会将传入的参数压进EG(argument_stack)。一旦函数调用结束,则EG(argument_stack)被清理,并且等待下一次的函数调用。

关于EG(argument_stack)的struct结构、用途,php5.2和5.3实现有一些区别。本文主要以5.2为例,5.3+的变化后面抽空再说。
php传参原理

上图是5.2中argument_stack的大概示意图,看起来简单明了。其中,栈顶和栈底固定为NULL。函数接收的参数按照从左到右的顺序,依次被压入栈。注意,最后会被额外压入一个long型的值,表示栈里的参数个数(上图中为10)。

那被压入argument_stack的参数究竟是什么呢?其实是一个个zval类型的指针。
它们指向的zva有可能是CV变量,有可能是is_ref=1的变量,还有可能是一个常量数字,或者常量字符串。

EG(argument_stack)在php5.2中被具体实现为zend_ptr_stack类型:
 

复制代码 代码示例:
typedef struct _zend_ptr_stack {
    int top;                       // 栈中当前元素的个数
    int max;                       // 栈中最多存放元素的个数
    void **elements;               // 栈底
    void **top_element;            // 栈顶
} zend_ptr_stack;
 

初始化argument_stack
初始化argument_stack的工作是发生在php处理具体的请求之前,更准确说是处于php解释器的启动过程之中。

在init_executor函数里我们发现如下2行:
 

复制代码 代码示例:
zend_ptr_stack_init(&EG(argument_stack));
zend_ptr_stack_push(&EG(argument_stack), (void *) NULL);

这2行分别代表着,初始化EG(argument_stack),紧接着压入一个NULL。由于EG是个全局变量,因此在实际调用zend_ptr_stack_init之前,EG(argument_stack)中的所有数据全部为0。

zend_ptr_stack_init实现很简单。
 

复制代码 代码示例:
ZEND_API void zend_ptr_stack_init(zend_ptr_stack *stack)
{
    stack->top_element = stack->elements = (void **) emalloc(sizeof(void *)*PTR_STACK_BLOCK_SIZE);
    stack->max = PTR_STACK_BLOCK_SIZE;   // 栈的大小被初始化成64
    stack->top = 0;                      // 当前元素个数为0
}
 

一旦argument_stack被初始化完,则立即会被压入NULL。这里无须深究,这个NULL其实没有任何的含义。

NULL入栈之后,整个argument_stack的实际内存分布如下:
php传参原理

参数入栈
在压入第一个NULL之后,一旦再有参数入栈,则argument_stack会发生如下动作:
stack->top++;
*(stack->top_element++) = 参数;

用一段简单的php代码来说明问题:
 

复制代码 代码示例:
function foo( $str ){
    print_r(123);
}
foo("hello world");
 

上述代码在调用foo时,传入了一个字符串常量。因此,实际上被压入栈的是一个指向存储“hello world”的zval。用vld来查看编译之后的opcode:
 

复制代码 代码示例:
line     # *  op                           fetch          ext  return  operands
---------------------------------------------------------------------------------
   3     0  >   NOP
   6     1      SEND_VAL                                                  OP1[  IS_CONST (458754) 'hello world' ]
         2      DO_FCALL                                      1           OP1[  IS_CONST (458752) 'foo' ]
  15     3    > RETURN                                                    OP1[  IS_CONST (0) 1 ]
 

SEND_VAL指令实际上做的事情就是将“hello world”压入argument_stack。
 

复制代码 代码示例:

int ZEND_SEND_VAL_SPEC_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
        ……
        zval *valptr, *value;

        value = &opline->op1.u.constant;
        ALLOC_ZVAL(valptr);
        INIT_PZVAL_COPY(valptr, value);
        if (!0) {
            zval_copy_ctor(valptr);
        }

        // 入栈,valptr指向存放hello world的zval
        zend_ptr_stack_push(&EG(argument_stack), valptr); 
        ……
}

入栈完成之后的argument_stack为:

php传参原理3

参数个数
前文说到,实际上并非把所有参数入栈就完事了。php还会额外压入一个数字,表示参数的个数,这个工作并非发生在SEND_XXX指令时。实际上,在真正执行函数之前,php会将参数个数入栈。

继续沿用上面的例子,DO_FCALL 指令用于调用foo函数。在调用foo之前,php会自动填入argument_stack最后一块。
 

复制代码 代码示例:
static int zend_do_fcall_common_helper_SPEC(ZEND_OPCODE_HANDLER_ARGS)
{
    ……
    // 在argument_stack中压入2个值
    // 一个是参数个数(即opline->extended_value)
    // 一个是标识栈顶的NULL
    zend_ptr_stack_2_push(&EG(argument_stack), (void *)(zend_uintptr_t)opline->extended_value, NULL);
    ……
    if (EX(function_state).function->type == ZEND_INTERNAL_FUNCTION) {
        ……
    }
    else if (EX(function_state).function->type == ZEND_USER_FUNCTION) {
        ……
        // 调用foo函数
        zend_execute(EG(active_op_array) TSRMLS_CC);
    }
    else { /* ZEND_OVERLOADED_FUNCTION */
        ……
    }
    ……
    // 清理argument_stack
    zend_ptr_stack_clear_multiple(TSRMLS_C);
    ……
    ZEND_VM_NEXT_OPCODE();
}

压入参数个数和NULL之后,用于foo调用的整个argument_stack已然完成。
php传参原理 获取参数
继续跟进上面的例子。
深入到foo函数,看看foo的opcode是什么样子的。
 

复制代码 代码示例:
line     # *  op                           fetch          ext  return  operands
---------------------------------------------------------------------------------
   3     0  >   RECV                                                      OP1[  IS_CONST (0) 1 ]
   4     1      SEND_VAL                                                  OP1[  IS_CONST (5) 123 ]
         2      DO_FCALL                                      1           OP1[  IS_CONST (459027) 'print_r' ]
   5     3    > RETURN                                                    OP1[  IS_CONST (0) null ]
 

第一条指令是RECV,从字面上理解便是用于获取栈中参数的。实际上,SEND_VAL和RECV有点对应的感觉。每次函数调用之前SEND_VAL,在函数内部进行RECV。为什么不说是完全对应,实际上RECV指令并非一定需要。只有当用户定义的函数被调用是,才会产生RECV。我们编写的扩展函数,php自带的内建函数,都不会有RECV。

需要额外指出的是,每次SEND_VAL和RECV 均只能处理一个参数。也就是说如果传参的过程中有多个参数,那么会产生若干SEND_VAL以及若干RECV。这里引出一个很有趣的话题,传入参数和获取参数的顺序是怎样的呢?

答案是,SEND_VAL会将参数从左至右的进行压栈,而RECV一样的从左至右获取参数。
 

复制代码 代码示例:

static int ZEND_RECV_SPEC_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    ……// param拿参数的顺序是沿着栈顶-->栈底
    if (zend_ptr_stack_get_arg(arg_num, (void **) &param TSRMLS_CC)==FAILURE) {
        ……
    } else {
        zend_free_op free_res;
        zval **var_ptr;

        // 验证参数
        zend_verify_arg_type((zend_function *) EG(active_op_array), arg_num, *param TSRMLS_CC);
        var_ptr = get_zval_ptr_ptr(&opline->result, EX(Ts), &free_res, BP_VAR_W);
       
        // 获取参数
        if (PZVAL_IS_REF(*param)) {
            zend_assign_to_variable_reference(var_ptr, param TSRMLS_CC);
        } else {
            zend_receive(var_ptr, *param TSRMLS_CC);
        }
    }

    ZEND_VM_NEXT_OPCODE();
}

zend_assign_to_variable_reference 和 zend_receive 都会完成“获取参数” 。“获取参数”不太好理解,实际它究竟是做哪些事情呢?

说到底很简单,“获取参数”就是将这个参数添加到当前函数执行期间的“符号表”中,具体对应为EG(current_execute_data)->symbol_table。本示例中,RECV完成之后,函数体内的symbol_table中有了一个符号‘str’,它的值为“hello world”。

但argument_stack并没有发生一丝变化,因为RECV仅仅是读取参数,而不会对栈产生类似pop操作。

php传参原理5

清理argument_stack
foo内部的print_r也是一个函数调用,因此也会产生压栈-->清栈的操作。因此print_r执行之前的argument_stack为:
php传参原理6

print_r执行之后argument_stack又回到了foo刚RECV完的状态。

具体调用print_r的过程并非本文阐述的重点。我们关心的是当调用foo结束之后,php是如何清理argument_stack的。

上面展示的do_fcall代码片段中可以看出,清理工作由zend_ptr_stack_clear_multiple完成的。
 

复制代码 代码示例:
static inline void zend_ptr_stack_clear_multiple(TSRMLS_D)
{
    void **p = EG(argument_stack).top_element-2;
    // 取栈顶保存的参数个数
    int delete_count = (int)(zend_uintptr_t) *p;
    EG(argument_stack).top -= (delete_count+2);
   
    // 从上至下,依次清理
    while (--delete_count>=0) {
        zval *q = *(zval **)(--p);
        *p = NULL;
        zval_ptr_dtor(&q);
    }
    EG(argument_stack).top_element = p;
}

注意这里清理栈中zval指针,使用的是zval_ptr_dtor。zval_ptr_dtor会将refcount减1,一旦refcount减为0,则保存该变量的内存区域会被真正的回收掉。

在本文示例中,foo调用完毕之后,保存“hello world”的zval状态为:
 

复制代码 代码示例:
value        "hello world"
refcount     1
type         6
is_ref       0
 

由于refcount只剩1,因此,zval_ptr_dtor会将“hello world”真正从内存中销毁。

消栈完毕之后的argument_stack内存状态为:

php传参原理7

可以看出上图中的argument_stack与刚被初始化之后是一样的。此时argument_stack真正做好了迎接下一次函数调用的准备。

回到文章刚开始的问题...
为何无需free(str)呢?弄明白了argument_stack之后就很好理解这个问题了。

因为str指向的是zval中实际存放“hello world”的内存地址。假设扩展函数如下:
 

复制代码 代码示例:

PHP_FUNCTION(test)
{
    char*  str;
    int    str_len;

    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "s", &str, &str_len) == FAILURE) {
        RETURN_FALSE;
    }

    str[0] = 'H';
}

则调用
 

复制代码 代码示例:
$a = "hello world";
test($a);
echo $a;
 

会输出“Hello world”。尽管我们调用test时,并非是传$a的引用,但实际效果相当于test(&$a)。

简单来说,内存中只有一份$a,不管是CV数组中,还是在argument_stack中。而zend_parse_parameters并没有拷贝一份数据用于函数执行,事实上它也不能这么做。因此,当函数完毕之后,如果没有其他地方会用到$a,php清理argument_stack时会帮我们free。如果仍然其他代码在使用,就更加不能手动free了,否则会破坏$a的内存区域。

注意,并非写扩展函数中用到的每个变量,php都会自动回收。所以该free时,切勿手软:)