异常/错误处理

异常指的是在程序运行过程中发生的异常事件,通常由硬件问题或者程序设计问题引起,需要由程序捕获或者处理。在 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所示。

image 2024 06 10 23 28 00 776
Figure 1. 图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 的位置。执行流程与前文所述语法大同小异,此处不再赘述。