中断与跳转
前面简单分析了循环语法,本节介绍循环中的跳出语句——break 和 goto。
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所示,在每个 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_element 结构。而 zend_brk_cont_element 结构记录着对应的跳转到下一个 opcode 的位置。先来看一下 zend_brk_cont_element 结构:
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_element 中取 brk。
假设 Zend 引擎执行到最内层的 break 语法位置,opcode 跳转如图12-11所示。

在图12-11中,为了清楚地知道 break 生成的 ZEND_JMP 的 opcode,这里用 break0、break1、break2 来区分循环体的层级,break0 表示最外层循环体,break2 表示最内层的循环体。
当执行第二层循环中的 break 语句时,它的 opcode 跳转如图12-12所示。

以此类推,对于最外层的 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
的值。
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所示。

由于 ZEND_AST_GOTO 和 ZEND_AST_LABEL 通常都在普通的语句中,所以其编译的过程相对简单一些,大致如下。
-
编译 ZEND_JMP 的 opcode,这里会将标签(label)插入到上下文中的标签表(compiler_globals.context.labels)这个 HashTable 中。这时还不知道另外一个标签的位置,所以不能确定 opcode 跳的位置,这里的做法是通过在上下文的标签表中插一条记录,编译 AST 中的标签节点(ZEND_AST_LABEL),会将需要跳的 opcode 位置更新。
-
编译普通表达式列表。
-
编译标签(ZEND_AST_LABEL),更新上下文标签表中标签A的数据,标签中记录步骤1)中循环跳出的位置(goto 也可以用来跳出循环结构)和 opcode 跳的位置。
-
编译普通表达式列表。
通过上面的编译步骤可以发现,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所示。
