第12章 基本语法实现

352 阅读13分钟

目前编程语言已有近千种,每年仍在诞生新的编程语言。每种语言都有各自的语法,PHP也不例外。通过前面章节的学习,我们了解了PHP 7的生命周期、词法和语法分析以及opcodes的生成和执行,本章在此基础上介绍PHP 7基本语法的实现。

12.1 条件判断

条件判断是用来表达条件逻辑的常用语法。以下面这段伪代码为例:

1 if( condition1 ){
2     statement1
3 }elseif( condition2 ){
4     statement2
5 }else{
6     statement3
7 }
8

这段代码由3个PHP条件语句关键字if、elseif、else与各自关键字对应的表达式代码块构成。

程序执行时,从上至下顺序判断每个条件语句中的condition是否成立。如果condition成立,则执行该部分对应的statement,执行完statement后,跳出到条件判断部分代码块的最后(第8行)继续执行;如果condition不成立,则跳转到下一个condition继续判断。

条件判断语句的执行过程主要分成3种子过程:

  1. 条件判断:condition是否成立。
  2. 语句执行:如果condition成立,则执行当前condition对应的代码块内包含的statement。
  3. 跳转:包括两种跳转,第一种是条件跳转。如果condition不成立,需要跳转到下一个condition判断处。也就是说,如果上述代码的if语句中的condition1不成立,要跳转到elseif处继续判断;第二种是statement执行完后,要跳到条件判断的最外层。在本例中,如果if语句condition1成立,则执行statement1,执行完直接跳转到第8行。

在PHP的底层,也是如此实现条件判断逻辑。我们依照条件判断语句执行过程的子过程,分别介绍。

通过前面所学内容,我们知道Zend引擎会将关键字if、elseif、else等解析成对应的Token,再根据Token生成AST。

if、elseif、else对应的Token分别是T_IF、T_ELSEIF、T_ELSE。获取Token后,需要根据预先定义好的语法规则来生成AST,规则的定义在zend_language_parser.y文件中。条件语句的规则如下:

if_stmt_without_else:
    T_IF '(' expr ')' statement
        { ? = zend_ast_create_list(1, ZEND_AST_IF,
            zend_ast_create(ZEND_AST_IF_ELEM, $3, $5)); }
    if_stmt_without_else T_ELSEIF '(' expr ')' statement
        { ? = zend_AST_list_add($1,
            zend_ast_create(ZEND_AST_IF_ELEM, $4, $6)); };
if_stmt:
    if_stmt_without_else %prec T_NOELSE { ? = $1; }
    if_stmt_without_else T_ELSE statement
        { ? = zend_AST_list_add($1, zend_ast_create(ZEND_AST_IF_ELEM, NULL, $3)); }

if关键字会首先创建一个kind为ZEND_AST_IF的节点。该节点作为整个条件语句代码块生成的AST的根,其子节点的kind为ZEND_AST_IF_ELEM,存储着各条件分支的信息。

以以下条件语句代码为例,生成一棵AST:

<?php
$a = 'php5'
if($a == 'php7' ){
    echo 1;
}elseif($a == 'php5' ){
    echo 2;
    }else{
    echo 3;
}

以上代码包含if、elseif、else这3个关键字,每个关键字都有对应的condition和statement。生成的AST如图12-1所示。

图12-1 条件判断AST示意图

从图12-1可以看出,AST的根是kind为ZEND_AST_IF的节点。根的3个孩子节点分别存储着if、elseif和else条件分支的信息。每个条件分支节点的孩子,又分别记录着该分支的condition和statement。因为else语句没有条件表达式(condition),所以child0节点位置为空。

以上是条件语句生成AST的过程。

接下来,介绍AST将如何编译为opcode。本节开始提到了条件语句的执行过程有3种子过程,分别是条件判断、语句执行和跳转。

以前文的PHP代码为例,条件判断子过程为判等语句,而语句执行子过程为“echo”语句,对应的opcode分别是T_IS_EQUAL和T_ECHO,执行过程比较简单。这里将焦点集中到跳转子过程。

当if条件语句的condition不成立时,要跳转到elseif处继续判断;当condition成立时,执行statement,执行完成要跳转到代码最后。这个跳转过程是条件语句的关键,也是很多语法实现的关键。

编译过程调用zend_compile_if (),遍历AST根的孩子节点,逐个处理if、elseif和else生成的子树,大致过程如下。

  1. 编译条件表达式(condition)。在示例代码中,由条件表达式$a==‘php7’编译而成的opcode是T_IS_EQUAL。if语句编译结果还包括一条ZEND_JMPZ的opcode。当执行完T_IS_EQUAL的opcode,根据判等结果,决定跳转的位置。
  2. 编译statement。在statement编译完成之后,会生成一条类似JMPZ的opcode——ZEND_JMP。当statement执行完成后,JMP指令跳转到整个条件语句代码块的结束部分。
  3. 在前两个步骤中,JMPZ指令和JMP指令均完成跳转操作,但并未说明跳转位置如何确定。示例代码被编译为一组opcode组成的opcode集合,当Zend引擎执行时,依次执行。回到步骤1),当编译出JMPZ时,需要给JMPZ一个参数,用以决定要跳转的位置。这个参数是opcode集合的索引,当Zend引擎执行JMPZ跳转时,即可以根据该索引,确定将要执行的是opcode集合中的哪条opcode,从而完成跳转。但此时未编译statement,并不能确定statement会编译成多少条opcode,也就无法确定执行JMPZ时需要跳过的opcode条数。在步骤3中,已经可以得到当前statement结束后的opcode条数,在这里更新JMPZ中的位置,即完成了条件语句判断之间的跳转联系,由此完成if跳转elseif、elseif跳转else的实现。
  4. 在步骤3中我们完成了跳转子过程的第一种——条件语句之间的跳转。细心的读者想必还记得,当statement结束之后,还需要跳转到条件语句代码块的最后,也就是前文代码示例的最后一行。显然,3种条件分支的statement执行完都需要跳转到最后一行。与步骤3中描述的一样,在if分支的statement编译完成时,elseif和else的编译还没开始,所以并不能确定statement结束后的JMP指令要跳转的最后一行在哪里。PHP采取的办法是,每编译完一个分支(else不需要)的statement之后,生成一条JMP,并记录下JMP在opcode集合中的位置。
  5. 完成根节点的所有孩子分支的编译后,便可以得到条件判断代码块的opcode集合的全部opcode的条数。根据步骤4中记录的每个分支中JMP opcode在opcode集合中的位置,依次将每条JMP要跳转的位置更新为opcode集合的最后,就建立起了statement与跳出条件的跳转关系。

从上述步骤可以看出,与前序章节中其他语法相比,条件判断语法的核心是建立跳转关系。下面是编译条件判断代码块的核心逻辑:

void zend_compile_if(zend_AST *AST)
{
    if (list->children > 1) {
        jmp_opnums = safe_emalloc(sizeof(uint32_t), list->children -1, 0);
    }
    /*遍历孩子节点(每个条件分支节点,如if、elseif、else节点)*/
    for (i = 0; i < list->children; ++i) {
        uint32_t opnum_jmpz;
        if (cond_AST) {
            zend_compile_expr(&cond_node, cond_AST);  /*编译条件表达式 */
            /*记录JMPZ所在的位置,在编译完statement后再更新JMPZ要跳转的位置*/
            opnum_jmpz = zend_emit_cond_jump(ZEND_JMPZ, &cond_node, 0);
        }
        zend_compile_stmt(stmt_AST);   /*编译表达式*/
        /* 设置本组表达式JMP在opcode集合中的位置,全部子节点编译完成后,根据该值索引到该JMP,
          更新其要跳转的位置*/
        if (i ! = list->children -1) {
            jmp_opnums[i] = zend_emit_jump(0);
        }
        if (cond_AST) {
            /*更新条件表达式需要跳过的opcode数,以跳转到下一个条件表达式*/
            zend_update_jump_target_to_next(opnum_jmpz);
        }
    }
    if (list->children > 1) {
        /*更新每组表达式执行完成后,需要跳过的opcode数,以跳出条件判断代码块*/
        for (i = 0; i < list->children -1; ++i) {
            zend_update_jump_target_to_next(jmp_opnums[i]);
        }
        efree(jmp_opnums);
    }
}

以上便是opcode的生成过程。执行opcode的过程和其他章节无异——找到opcode对应的handler,调用执行。示例代码编译出的opcode中各handler对应函数名如下:

  1. ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER;
  2. ZEND_IS_EQUAL_SPEC_CV_CONST_HANDLER;
  3. ZEND_JMPZ_SPEC_TMPVAR_HANDLER;
  4. ZEND_ECHO_SPEC_CV_HANDLER;
  5. ZEND_JMP_SPEC_HANDLER;
  6. ZEND_IS_EQUAL_SPEC_CV_CONST_HANDLER;
  7. ZEND_JMPZ_SPEC_TMPVAR_HANDLER;
  8. ZEND_ECHO_SPEC_CV_HANDLER;
  9. ZEND_JMP_SPEC_HANDLER;
  10. ZEND_ECHO_SPEC_CV_HANDLER;
  11. ZEND_RETURN_SPEC_CONST_HANDLER。

Zend引擎执行本文示例代码生成的opcode、调用handler的流程如图12-2所示。

图12-2 条件判断执行过程示意图

12.2 循环语句

循环语句通常由条件表达式和普通表达式构成。在PHP中,循环语句有foreach、while、for、do while,下面分别介绍。

12.2.1 foreach语句

foreach是PHP提供的遍历数组或对象的方式,语法简洁明了:

<? php
foreach(expression as key => value ){
    statement
}
//还有一种写法  foreach(expression  as value )

foreach结构由3部分组成,即expression(可以是数组或者对象)和foreach变量key、value。这里不论是数组还是对象,本质上是对HashTable进行遍历,因为PHP 7中的数组实现就是HashTable,而遍历对象实际上是对对象的属性进行遍历。

foreach对应的Token是T_FOREACH, as对应的Token为T_AS。同样,根据Token查看其语法规则:

|   T_FOREACH '(' expr T_AS foreach_variable ')' foreach_statement
        { ? = zend_ast_create(ZEND_AST_FOREACH, $3, $5, NULL, $7); }
|   T_FOREACH '(' expr T_AS foreach_variable T_DOUBLE_ARROW foreach_variable ')'
    foreach_statement
        { ? = zend_ast_create(ZEND_AST_FOREACH, $3, $7, $5, $9); }

可以看出,foreach关键字会创建一个kind为ZEND_AST_FOREACH的节点。以该节点为根,共有4个孩子,分别对应为expression(需要遍历的变量)、variable1(foreach变量key)、variable(foreach变量value)和statement(普通表达式)。生成的AST结构如图12-3所示。

图12-3 foreach语法AST示意图


注意

节点存在的顺序是固定的。如果foreach中不需要key,生成的AST中的child1节点为空。


foreach遍历的过程是对变量key和value不断赋值,并且记录当前遍历位置的过程。可能读者已经猜到,与条件判断语句类似,foreach语法的关键点仍然是跳转——如何在一次遍历之后,跳转到下次遍历要开始的位置。编译的过程大致如下:

  1. 编译expression(对应图12-3中根节点的child0)生成复制数组或对象的opcode FE_RESET_R。这里如果value是引用类型,会生成opcodeZEND_FE_RESET_RW。如果数组或者对象为空,opcode还需要记录需要跳过的opcode数。
  2. 编译value(对应图12-3中根节点的child2)生成opcode为FE_FETCH_R的操作。如果value是引用类型,同样会生成opcode ZEND_FE_FETCH_RW。此时opcode还需要记录遍历指针到达末尾需要跳过的opcode数。
  3. 如果存在key(对应图12-3中根节点的child1)则编译一条ASSIGN,对key进行赋值操作。
  4. 编译循环体中的普通表达式列表statement。
  5. 生成一条ZEND_JMP。因为一次循环结束后,需要跳回遍历开始的位置,然后重新走流程。
  6. 全部opcode条数已经确定,开始设置步骤1和步骤2中需要跳过的opcode数。

编译foreach的函数是zend_compile_foreach,位于zend/zend_compile.c文件中,主要流程如下:

void zend_compile_foreach(zend_AST *AST) /* {{{ */
{
    if (by_ref) {
        value_AST = value_AST->child[0];
    }
    if (by_ref && is_variable) {
        zend_compile_var(&expr_node, expr_AST, BP_VAR_W);
    } else {
        zend_compile_expr(&expr_node, expr_AST);
    }
    /*如果是引用会编译一条ZEND_SEPARATE opcode*/
    if (by_ref) {
        zend_separate_if_call_and_write(&expr_node, expr_AST, BP_VAR_W);
    }
    opnum_reset = get_next_op_number(CG(active_op_array));
    opline = zend_emit_op(&reset_node, by_ref ? ZEND_FE_RESET_RW : ZEND_FE_RESET_
        R, &expr_node, NULL);
    zend_begin_loop(ZEND_FE_FREE, &reset_node);
    opnum_fetch = get_next_op_number(CG(active_op_array));
    // 编译生成FE_FETCH_R opcode
    opline  =  zend_emit_op(NULL,  by_ref  ?  ZEND_FE_FETCH_RW  :  ZEND_FE_FETCH_R,
        &reset_node, NULL);
    if (key_AST) {
        opline = &CG(active_op_array)->opcodes[opnum_fetch];
        zend_make_tmp_result(&key_node, opline);
        /*编译一条ASSIGN对key赋值*/
        zend_emit_assign_znode(key_AST, &key_node);
    }
    /*编译循环体里面的普通表达式列表*/
    zend_compile_stmt(stmt_AST);
    /*生成zend_jmp opcode,在一次循环结束后跳到遍历开始位置*/
    zend_emit_jump(opnum_fetch);
    /*设置跳过的opcode数*/
    opline = &CG(active_op_array)->opcodes[opnum_reset];
    opline->op2.opline_num = get_next_op_number(CG(active_op_array));
    opline = &CG(active_op_array)->opcodes[opnum_fetch];
    opline->extended_value = get_next_op_number(CG(active_op_array));

    zend_end_loop(opnum_fetch, &reset_node);
    opline = zend_emit_op(NULL, ZEND_FE_FREE, &reset_node, NULL);
}

关于opcode对应的handler,这里不再赘述。

Zend引擎在执行时,首先重置expression中遍历的位置,这时如果发现遍历的数组或者对象(expression)为空,跳过循环体statement,直接跳转到结束的位置;反之对key和value进行赋值,开始执行循环体statement部分。循环体statement执行完再执行ZEND_JMP,跳到循环体开始的位置,开始下次遍历,最终达到遍历数组或者对象中各个元素的目的。其执行流程如图12-4所示。

图12-4 foreach执行流程示意图

12.2.2 while语句

while语句也是一种常用的循环语句,关键字while生成的Token是T_WHILE。while语法规则相对简单:

    |   T_WHILE '(' expr ')' while_statement
        { ? = zend_ast_create(ZEND_AST_WHILE, $3, $5); }
    while_statement:
        statement { ? = $1; }
    |   ':' inner_statement_list T_ENDWHILE '; ' { ? = $2; }
;

while语法会创建一个kind为ZEND_AST_WHILE的AST节点。以其为根,有两个孩子节点。child0用来记录条件表达式(condition), child1节点存储普通循环体statement。AST示意图如图12-5所示。

condition语句一般会生成kind为ZEND_AST_BINARY_OP节点的子树。该节点在编译时会生成临时变量(true或false)。引擎根据临时变量结果,确定下一个要执行的opcode的位置。如果临时变量结果为true,则执行while循环体的statement;否则,跳出循环。这里与跳转逻辑相似。


注意

condition不同,生成的子节点的kind也是不同的。读者可以自行尝试,观察变化。


while语法的编译过程大致如下。

  1. 编译条件表达式(condition)部分,同时,需要生成一条ZEND_JMP的opcode,跳转到条件表达式(condition)生成的opcode的位置。目前还不能确定下一个opcode的位置(jmp_offset),需要在编译完循环体statement后更新。
  2. 编译循环体statement,编译后会更新步骤1中jmp_offset的值。
  3. 编译条件表达式condition。
  4. 编译生成ZEND_JMPNZ的opcode,如果条件成立跳到循环体开始的位置,否则继续执行。

从上面的AST可以看出,while的语法相对简单,因此它的编译过程也相对简单,编译函数是zend_compile_while,同样定义在zend/zend_compile.c文件中。为了方便读者理解,这里只展示函数的主要流程,具体如下:

void zend_compile_while(zend_AST *AST) /* {{{ */
{
    opnum_jmp = zend_emit_jump(0);
    zend_begin_loop(ZEND_NOP, NULL);
    opnum_start = get_next_op_number(CG(active_op_array));
    zend_compile_stmt(stmt_AST);  /*编译条件表达式*/

    opnum_cond = get_next_op_number(CG(active_op_array));
    zend_update_jump_target(opnum_jmp, opnum_cond);  /*更新zend_jmp跳的位置*/
    zend_compile_expr(&cond_node, cond_AST);   /*编译条件表达式*/
  /*编译ZEND_JMPNZ */
    zend_emit_cond_jump(ZEND_JMPNZ, &cond_node, opnum_start);

    zend_end_loop(opnum_cond, NULL);
}

关于while语句的执行跳转逻辑,这里不再详述。

12.2.3 for语句

for语句的PHP代码示例如下:

<?php
for( expressions1 ; expressions2 ; expressions3 ) {
      statement
}

for语句由两大部分组成,即多个表达式expressions和循环体statement。for关键字生成的Token为T_FOR,它的语法规则如下:

    T_FOR '(' for_exprs '; ' for_exprs '; ' for_exprs ')' for_statement
    { ? = zend_ast_create(ZEND_AST_FOR, $3, $5, $7, $9); }
for_statement:
    statement { ? = $1; }
        ':' inner_statement_list T_ENDFOR '; ' { ? = $2; }

for语法结构生成的AST根节点是kind为ZEND_AST_FOR的节点。其有4个子节点,其中前三个节点都是表达式列表(expressions),第四个节点保存循环体statement信息。这里值得注意的是,这4个节点都是zend_ast_list类型,意味着它们都支持多个表达式。本书第10章介绍过AST中list节点和普通节点的区别,读者可以回读了解。

for语法结构生成的AST示意图如图12-6所示。

图12-6 for语法AST示意图

Zend引擎编译for语法结构从ZEND_AST_FOR节点的child0开始。这里需要注意的是,前三个子节点虽都是条件表达式,却有所区别。child0用来初始化,child1用来条件判断,child2用来做循环控制。编译过程大致如下:

  1. 编译child0对应的表达式,生成ZEND_ASSGIN的opcode,该过程会生成临时变量。
  2. 生成ZEND_JMP的opcode,用来跳转到条件判断的位置(编译过程可以看出为何初始化之后不是条件判断的opcode)。当前阶段不能确定跳转的位置,需要后续更新。
  3. 编译循环体。
  4. 编译child2对应的表达式expressions。
  5. 编译child1对应的表达式列表expressions, for语句的判断条件在这里编译,并更新步骤2)中jmp_offset的值。
  6. 生成ZEND_JMPNZ。依据步骤6中条件判断的结果,决定下一条要执行的opcode的位置。

for语法结构的编译实现过程简化如下:

void zend_compile_for(zend_AST *AST) /* {{{ */
{
    //编译第一个for_exprs
    zend_compile_expr_list(&result, init_AST);
    zend_do_free(&result);
    /*生成ZEND_JMP*/
    opnum_jmp = zend_emit_jump(0);
    zend_begin_loop(ZEND_NOP, NULL);
    /*编译循环体*/
    opnum_start = get_next_op_number(CG(active_op_array));
    zend_compile_stmt(stmt_AST);
    opnum_loop = get_next_op_number(CG(active_op_array));
    /*编译第三个for_exprs*/
    zend_compile_expr_list(&result, loop_AST);
    zend_do_free(&result);
    /*更新zend_jmp需要跳过的opcode数*/
    zend_update_jump_target_to_next(opnum_jmp);
    /*编译第二个for_exprs*/
    zend_compile_expr_list(&result, cond_AST);
    zend_do_extended_info();
    /*生成ZEND_JMPNZ */
    zend_emit_cond_jump(ZEND_JMPNZ, &result, opnum_start);

    zend_end_loop(opnum_loop, NULL);
}

for语法的执行过程类似while语法的执行过程,首先通过ZEND_JMP跳到条件判断的opcode位置,如果条件返回true则跳转到循环体开始的位置,这里不再赘述。for语句执行过程如图12-7所示。

图12-7 for语法执行过程示意图

12.2.4 do while语句

do while与while基本一样,不同的是do while是先执行循环体,再判断while条件表达式,决定是否继续执行循环体。根据while的实现,不难想象do while的基本流程。以如下PHP代码为例:

<?php
do{
    //$i++; echo $i;
    statement
}while( condition );   //$i < 10

我们直接看下它生成的AST,与while语法生成的AST做下对比。


注意

在实际编译的PHP代码中以注释为例,这里为了便于理解用condition代替条件,用statement代替循环体。


对比图12-5中while结构生成的AST,可以发现do while结构中循环体statement生成的节点在左边child0位置,而条件condition生成的节点在右边child1位置。相同的是这两棵树下面只有两个节点。do while语法编译的过程跟while语法的流程基本一样,这里不再展开介绍。

图12-8 do while语法示意图

do while的执行示意图如图12-9所示。

图12-9 do while执行示意图

12.3 中断与跳转

前面简单分析了循环语法,本节介绍循环中的跳出语句——break和goto。

12.3.1 break的实现

break可以接受一个可选参数,表示跳出几重循环,默认为1。12.3节介绍了循环结构的实现原理。这里以while配合break语法为例,展开分析。代码示例如下:

<?php
$i = 1;
while($i == 1){
    while($i == 1){
        echo 1;
        while($i == 1){
            echo 1;
            break;
        }
        break;
    }
    break;
}

while语法解析生成AST的过程这里不再详述,需要注意的是,循环体中包含break关键字。示例代码生成的AST如图12-10所示。

图12-10 break语法AST示意图

如图12-10所示,在每个ZEND_AST_WHILE节点下,可以发现循环体statement生成的节点下多了一个kind为ZEND_AST_BREAK的节点,这个节点便是由break关键字生成的。该节点是while循环体statement生成的AST节点ZEND_AST_STMT_LIST的孩子。

在循环语法的编译中提到过,循环体中普通表达式列表开始编译前会先执行zend_begin_loop函数,并且在循环结束后执行zend_end_loop函数。在zend_begin_loop函数中,会为当前while循环体创建一个zend_brk_cont_element结构体,并将该结构体保存在上下文的全局变量compiler_globals->context->brk_cont_array数组中。每一层循环都对应一个zend_brk_cont_elemen结构。而zend_brk_cont_elemen结构记录着对应的跳转到下一个opcode的位置。先来看一下zend_brk_cont_elemen结构:

typedef struct _zend_brk_cont_element {
    int start;
    int cont;
    int brk;
    int parent;
} zend_brk_cont_element;

结构体说明如下。

  • parent:记录父层循环的brk_cont_elemen位置。
  • brk:记录当前循环结束时opcode的位置(break语句用到)。
  • cont:记录当前循环条件所在opcode的位置(continue语句用到)。

在了解了break语法的基本思路后,我们先通过gdb输出brk_cont_array数组中的数据信息,因为这里记录着break语句下一条opcode的位置。

(gdb) p compiler_globals.context.brk_cont_array[0]
$107 = {start = -1, cont = 13, brk = 15, parent = -1}
(gdb) p compiler_globals.context.brk_cont_array[1]
$108 = {start = -1, cont = 10, brk = 12, parent = 0}
(gdb) p compiler_globals.context.brk_cont_array[2]
$109 = {start = -1, cont = 7, brk = 9, parent = 1}

以当前PHP代码为例,在数组brk_cont_array中,第0个位置对应最外层的循环,第1个位置对应第二层循环,以此类推。在上面输出的信息中,brk、cont字段记录着下一条opcode的位置。为了方便观察,在生成的opcodes数组中的每条opcode->handler函数前面增加了序号,这个序号表示opcode在数组中的位置。

例如,brk值为15,表示break时跳转到序号为15的opcode继续执行。具体的handler函数名如下:

0 0xa2c2f0 <ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER>:  0xe5894855
1 0x985006 <ZEND_JMP_SPEC_HANDLER>:      0xe5894855
2 0x985006 <ZEND_JMP_SPEC_HANDLER>:      0xe5894855
3 0x98ff50 <ZEND_ECHO_SPEC_CONST_HANDLER>:       0xe5894855
4 0x985006 <ZEND_JMP_SPEC_HANDLER>:      0xe5894855
5 0x98ff50 <ZEND_ECHO_SPEC_CONST_HANDLER>:       0xe5894855
6 0x985006 <ZEND_JMP_SPEC_HANDLER>:      0xe5894855
7 0xa1d93b <ZEND_IS_EQUAL_SPEC_CV_CONST_HANDLER>:       0xe5894855
8 0xa65383 <ZEND_JMPNZ_SPEC_TMPVAR_HANDLER>:     0xe5894855
9 0x985006 <ZEND_JMP_SPEC_HANDLER>:      0xe5894855
10 0xa1d93b <ZEND_IS_EQUAL_SPEC_CV_CONST_HANDLER>:      0xe5894855
11 0xa65383 <ZEND_JMPNZ_SPEC_TMPVAR_HANDLER>:    0xe5894855
12 0x985006 <ZEND_JMP_SPEC_HANDLER>:     0xe5894855
13 0xa1d93b <ZEND_IS_EQUAL_SPEC_CV_CONST_HANDLER>:      0xe5894855
14 0xa65383 <ZEND_JMPNZ_SPEC_TMPVAR_HANDLER>:    0xe5894855
15 0x990d91 <ZEND_RETURN_SPEC_CONST_HANDLER>:    0xe5894855

while语法执行opcode的过程前面已经讲过,这里需要注意的是break语法生成的ZEND_JMP的opcode,而ZEND_JMP跳转的下一条opcode位置便从zend_brk_cont_elemen中取brk。

假设Zend引擎执行到最内层的break语法位置,opcode跳转如图12-11所示。

图12-11 break2执行示意图(一)

在图12-11中,为了清楚地知道break生成的ZEND_JMP的opcode,这里用break0、break1、break2来区分循环体的层级,break0表示最外层循环体,break2表示最内层的循环体。

当执行第二层循环中的break语句时,它的opcode跳转如图12-12所示。

图12-12 break1执行示意图(二)

以此类推,对于最外层的break语句,相信大家已经知道它的opcode如何跳转了。

实际上,在循环体中,经常还用到另外一种语法——continue。continue用来表示跳过循环体中continue后面的程序,从条件表达式开始重新执行当前循环体。而continue的实现跟break的实现基本相同,不同的是,如果是contiune, ZEND_JMP需要跳到的opcode位置取的是brk_cont_array数组中zend_brk_cont_element->cont的值,而break取的是zend_brk_cont_element->brk的值。

12.3.2 goto的实现

goto操作符可以用来跳转到程序中的另一个位置,该目标位置可以用目标名称加上冒号来标记,而跳转指令是goto之后接上目标位置的标记。但是PHP对goto有一定的限制,goto不可以跳出当前文件,不能跳出函数或方法,不能跳进循环结构或者switch结构,但是可以跳出循环或switch结构。本节研究goto语法的实现。首先以一段简单的PHP代码为例:

<?php
goto A;
echo "hi~";
A:
echo "php7";

goto关键字解析出来的Token为T_GOTO,根据Token可以查到它的语法规则。goto语法定义在普通表达式列表中,后面是标签A,标签A的定义形式是“A”。语法规则的定义如下:

|  T_GOTO T_STRING '; ' { ? = zend_ast_create(ZEND_AST_GOTO, $2); }
|  T_STRING ':' { ? = zend_ast_create(ZEND_AST_LABEL, $1); }

从上面的规则可以看出,goto语法并不会生成zend_ast_list类型的节点,它是普通类型的ast节点,并且只有一个子节点,该子节点记录需要跳转的标签,标签为生成一个kind为ZEND_AST_LABEL类型的节点。所以上面的PHP代码最终生成的AST如图12-13所示。

图12-13 goto语法AST示意图

由于ZEND_AST_GOTO和ZEND_AST_LABEL通常都在普通的语句中,所以其编译的过程相对简单一些,大致如下。

  1. 编译ZEND_JMP的opcode,这里会将标签(label)插入到上下文中的标签表(compiler_globals.context.labels)这个HashTable中。这时还不知道另外一个标签的位置,所以不能确定opcode跳的位置,这里的做法是通过在上下文的标签表中插一条记录,编译AST中的标签节点(ZEND_AST_LABEL),会将需要跳的opcode位置更新。
  2. 编译普通表达式列表。
  3. 编译标签(ZEND_AST_LABEL),更新上下文标签表中标签A的数据,标签中记录步骤1中循环跳出的位置(goto也可以用来跳出循环结构)和opcode跳的位置。
  4. 编译普通表达式列表。通过上面的编译步骤可以发现,goto语法主要通过生成ZEND_JMP来进行跳转。为了方便观察,通过gdb输出每个opcode的handler的函数名:
0x985006 <ZEND_JMP_SPEC_HANDLER>:                0xe5894855
0x98ff50 <ZEND_ECHO_SPEC_CONST_HANDLER>:         0xe5894855
0x98ff50 <ZEND_ECHO_SPEC_CONST_HANDLER>:         0xe5894855
0x990d91 <ZEND_RETURN_SPEC_CONST_HANDLER>:       0xe5894855

这里的函数相对比较简单,主要是ZEND_JMP的opcode,该条opcode是由goto语法生成的。为了便于理解,通过gdb输出详细的信息:

(gdb) p op_array.opcodes[0]
$111 = {handler = 0x985006, op1 = {constant = 64, var = 64, num = 64, opline_num
    = 64, jmp_offset = 64}, op2 = {constant = 0, var = 0, num = 0,
    opline_num = 0, jmp_offset = 0}, result = {constant = 0, var = 0, num = 0,
        opline_num = 0, jmp_offset = 0}, extended_value = 0, lineno = 2,
  opcode = 42 '*', op1_type = 8 '\b', op2_type = 8 '\b', result_type = 8 '\b'}

ZEND_JMP_SPEC_HANDLER通过op1.jmp_offset确定下一条opcode位置。比如在上面的PHP示例中,goto会跳过第一条echo语句。opcode的调用如图12-14所示。

图12-14 goto执行示意图

12.4 文件引入

在PHP中引入文件有两种方式,即include和require,配对的还有include_once和require_once。本节将详细介绍这两种方式的底层实现。以下面的PHP代码为例:

<?php
include "a.php";
require "c.php";
echo "require c.php";

关键字include和require生成的Token分别是T_INCLUDE和T_REQUIRE。各自的语法规则同样定义在zend_language_parser.y文件中:

|  T_INCLUDE expr
        { ? = zend_ast_create_ex(ZEND_AST_INCLUDE_OR_EVAL, ZEND_INCLUDE, $2); }
|  T_INCLUDE_ONCE expr
        { ? = zend_ast_create_ex(ZEND_AST_INCLUDE_OR_EVAL, ZEND_INCLUDE_ONCE, $2); }
|  T_REQUIRE expr
        { ? = zend_ast_create_ex(ZEND_AST_INCLUDE_OR_EVAL, ZEND_REQUIRE, $2); }
|  T_REQUIRE_ONCE expr
        { ? = zend_ast_create_ex(ZEND_AST_INCLUDE_OR_EVAL, ZEND_REQUIRE_ONCE, $2); }

从规则的定义中可以看出,在PHP中,关键字include、include_once、require、require_once生成的AST节点的kind均为ZEND_AST_INCLUDE_OR_EVAL,并且该类型的节点只有一个孩子节点。子节点保存着引入文件的路径信息。通过gdb可以发现,生成的opcodes数组中的每条opcode->handler的函数名如下:

  1. ZEND_INCLUDE_OR_EVAL_SPEC_CONST_HANDLER;
  2. ZEND_INCLUDE_OR_EVAL_SPEC_CONST_HANDLER;
  3. ZEND_ECHO_SPEC_CONST_HANDLER;
  4. ZEND_RETURN_SPEC_CONST_HANDLER。

可以发现,两个关键字对应的opcode的handler函数均是ZEND_INCLUDE_OR_EVAL_SPEC_CONST_HANDLER。该函数解析文件的绝对路径并将该文件编译生成新的op_array。如果新的op_array不为空,则重新执行一遍文件扫描、解析、编译、执行的过程。如果同个文件中有多条include语句和require语句,每条include语句和require语句都重新执行一遍这个流程。

include_once和require_once会将解析出的文件路径加入到全局的hashtable表EG(included_files)中,下次再被引入时,会优先从EG(included_files)中查找。如果文件已经编译过,则不再对文件进行编译。在包含文件的错误处理上,PHP支持两种模式:对于必要文件的错误,报错并终止(require或require_once);对于非必要文件的错误,抛出警告(include或include_once)。

12.5 异常/错误处理

异常指的是在程序运行过程中发生的异常事件,通常由硬件问题或者程序设计问题引起,需要由程序捕获或者处理。在PHP 7之前,处理致命错误几乎是不可能的,致命错误不会调用由set_error_handler()设置的处理方式,只是停止脚本的执行。PHP 7对此进行了改进,一些错误也会抛出异常,不捕获仍然报fatal错误。PHP 7对异常或者错误的处理与其他语言类似,使用try/catch代码结构,在catch中捕获并处理。以下面的PHP代码为例,介绍异常机制的底层实现:

<?php
//$a = 1;
try{
    //$a->test();
    try_statement1
} catch(Exception $e) {
    catch_statement2      //echo "exception";
} catch(Throwable $e) {
    catch_statement3       // echo "throwable";
} catch(Error $e) {
    catch_statement4      //  echo "error";
} finally {
    finally_statement     //  echo "finally";
}

如以上代码所示,try结构由普通表达statement构成,catch和finally也有表达式列表。try、catch、finally关键字会生成T_TRY、T_CATCH和T_FINALLY的Token。


注意

为了便于理解,这里用statement代替注释中的语句,实际的代码以注释的为准。


try、catch、finally的语法规则如下:

    T_TRY '{' inner_statement_list '}' catch_list finally_statement
    { ? = zend_ast_create(ZEND_AST_TRY, $3, $5, $6); }

catch_list:
    /* empty */
    { ? = zend_ast_create_list(0, ZEND_AST_CATCH_LIST); }
        catch_list T_CATCH '(' catch_name_list T_VARIABLE ')' '{' inner_statement_
            list '}'
            { ? = zend_AST_list_add($1, zend_ast_create(ZEND_AST_CATCH, $4, $5, $8)); }

finally_statement:
    /* empty */ { ? = NULL; }
|   T_FINALLY '{' inner_statement_list '}' { ? = $3; }

从规则定义可以看出,try关键字会创建以ZEND_AST_TRY节点为根的AST,根节点有3个孩子节点。catch关键字会创建一个kind为ZEND_AST_CATCH的AST节点,它有3个子节点,用于保存接口名称、变量和catch_statement表达式。finally关键字由于其特殊性,最终都会执行,所以作为普通表达式(statement)挂在try的最后一个子节点上。以本节开始的PHP代码为例,其最终生成的AST如图12-15所示。

图12-15 异常处理AST示意图

可以看出,try、catch、finally语法生成的AST相对复杂,因此编译的过程也相对复杂一些,编译的步骤大致如下。

  1. 在当前zend_op_array->try_catch_array数组中增加一个zend_try_catch_element结构的元素,通过数组编译计算当前try在try_catch_array数组中的位置,实现try的嵌套。新增的接口记录的有try/catch的opcode相关信息,这里还不能确定一些信息,后面编译时更新。zend_try_catch_element的结构如下:

    typedef struct _zend_try_catch_element {
        uint32_t try_op;
        uint32_t catch_op;
        uint32_t finally_op;
        uint32_t finally_end;
    } zend_try_catch_element;
    
  2. 编译try_statement。如果存在catch子节点,会生成一条ZEND_JMP的opcode,用来跳过catch节点。前文已多次提到JMP指令,想必读者已经对该指令的处理逻辑比较熟悉。

  3. 如果存在catch子节点,遍历编译catch的每个子节点。编译catch的子节点的步骤大致如下。

    ① 检查其是否为第一个catch,如果是则将当前的opcode位置更新到步骤1)中的zend_try_catch_element->catch_op中,建立跳转联系。

    ② 编译ZEND_CATCH的opcode。该opcode保存着exception class相关信息。从图12-15可以看出,exception class类可以有多个。ZEND_AST_NAME_LIST是一个list类型的节点,这里会检查当前exception class是否是最后一个,如果不是,会再生成一条ZEND_JMP的opcode,用来跳到其他的exception class的位置。此时并不能确定下一个exception class的opcode位置。如下情况就包含两种exception class:

    <?php
    $a = 1;
    try{
        $a->test();
    } catch(Exception | Error $e) {
        echo "exception or error";
    }
    

    ③ 编译catch_statement,即catch节点中的普通表达式。

    ④ 检查其是否是最后一个catch节点,如果不是则编译ZEND_JMP的opcode,用来跳过后面的catch节点。

  4. catch子树编译完成后,更新步骤2和步骤3中需要跳过的catch节点的opcode数。

  5. 检查是否存在finally子树,如果不存在,编译结束;否则编译finally节点。编译生成ZEND_FAST_CALL和ZEND_JMP的opcode,然后编译finally_statement,即finally中的普通表达式,最后编译ZEND_FAST_RET的opcode。

try catch finally语法编译生成opcodes的函数是zend_compile_try,这里仍然只保留关键代码,感兴趣的读者可以自行阅读完整代码。

void zend_compile_try(zend_AST *AST) /* {{{ */
{
    /*在zend_op_array里面为try增加一个zend_try_catch_element元素*/
    try_catch_offset  =  zend_add_try_element(get_next_op_number(CG(active_op_
        array)));
    /*编译try节点*/
    zend_compile_stmt(try_AST);
    /*编译zend_jmp*/
    if (catches->children ! = 0) {
    jmp_opnums[0] = zend_emit_jump(0);
    }
    /*遍历每个catch节点,对catch节点进行编译*/
    for (i = 0; i < catches->children; ++i) {
    /*为每个exception class编译ZEND_CATCH的opcode,除了最后一个exception class之外,额
      外编译一条ZEND_JMP的opcode*/
    for (j = 0; j < classes->children; j++) {
        /*更新zend_try_catch_element结构中的catch_op*/
        opnum_catch = get_next_op_number(CG(active_op_array));
        if (i == 0 && j == 0) {
            CG(active_op_array)->try_catch_array[try_catch_offset].catch_op = opnum_
                catch;
            }
            /*编译ZEND_CATCH*/
            opline = get_next_op(CG(active_op_array));
            opline->opcode = ZEND_CATCH;
            if (! is_lAST_class) {
                /*非最后一个exception class,编译ZEND_JMP*/
                jmp_multicatch[j] = zend_emit_jump(0);
                opline->extended_value = get_next_op_number(CG(active_op_array));
            }
        }
        /*如果当前catch的节点存在多个exception class更新ZEND_JMP跳的opcode的数*/
        for (j = 0; j < classes->children -1; j++) {
            zend_update_jump_target_to_next(jmp_multicatch[j]);
        }
        efree(jmp_multicatch);
        /*编译catch中的表达式*/
        zend_compile_stmt(stmt_AST);
        /*如果不是最后一个catch,编译ZEND_JMP*/
        if (! is_lAST_catch) {
            jmp_opnums[i + 1] = zend_emit_jump(0);
        }
    }
    /*更新每个catch中ZEND_JMP跳的opcode数*/
    for (i = 0; i < catches->children; ++i) {
    zend_update_jump_target_to_next(jmp_opnums[i]);
    }

    if (finally_AST) {  //编译finally节点
        /*编译ZEND_FAST_CALL*/
        opline = zend_emit_op(NULL, ZEND_FAST_CALL, NULL, NULL);
        opline->op1.num = try_catch_offset;
        /*编译ZEND_JMP*/
        zend_emit_op(NULL, ZEND_JMP, NULL, NULL);
        /*编译ZEND_FAST_RET*/
        opline = zend_emit_op(NULL, ZEND_FAST_RET, NULL, NULL);
        opline->op1_type = IS_TMP_VAR;
    }
}

虽然异常处理语法编译生成opcodes的过程比较复杂,但是执行逻辑比较简单,仍然是顺序执行与跳转逻辑配合执行。先执行try中的表达式,如果发生异常,通过ZEND_JMP跳转到第一个catch生成的opcode的位置执行;如果catch到异常,执行对应的catch_statement表达式,否则跳到下一个catch生成的opcode的位置继续执行,如此反复。如果存在finally,在跳出try语法之前,会先跳到finally生成的opcode的位置。执行流程与前文所述语法大同小异,此处不再赘述。

12.6 本章小结

本章主要介绍了PHP中的条件判断、循环语句、中断跳转和异常处理等基本语法的实现原理,想必读者在了解一些常用语法的实现后,再应用PHP,一定会更加享受编程的乐趣。