PHP7内核剖析 学习笔记 第九章 PHP基础语法的实现

9.1 静态变量

静态变量在函数返回时不会释放,它的结果会被保留到下次函数调用。

C语言中静态变量被分配在静态存储区,在程序启动时分配,直到程序退出才释放。

PHP中,静态变量不会像普通变量一样分配在zend_execute_data(该结构实现了类似C中的函数栈的功能,调用函数和函数返回时,相当于切换了zend_execute_data)上,而是保存在zend_op_array->static_variables中,它是一个哈希表。静态变量在编译阶段而不是执行阶段初始化,即编译阶段将静态变量插入zend_op_array->static_variables,这意味着静态变量的初始值不能是变量。zend_op_array->static_variables会在请求结束时释放。

PHP局部变量通过zend_execute_data+变量的offset获取对应变量。

静态变量在可以一条语句中同时声明多个,如static $a, $b, $c,编译时会创建一个ZEND_AST_STMT_LIST节点,它的每个子节点代表一个静态变量,它的子节点的类型为ZEND_AST_STATIC。ZEND_AST_STATIC节点有两个子节点,分别用于静态变量名、变量初始值。

ZEND_AST_STATIC节点由zend_compile_static_var()编译,其中首先对初始化值编译,得到一个固定值;然后调用zend_compile_static_var_common,其中先判断zend_op_array->static_variables是否已创建,如果为创建则分配一个HashTable,然后将静态变量插入哈希表;最后会生成两条opcode:

1.ZEND_FETCH_W

该指令的处理handler为ZEND_FETCH_W_SPEC_CONST_UNUSED_HANDLER(),其中调用了zend_fetch_var_address_helper_SPEC_CONST_UNUSED()。

zend_fetch_var_address_helper_SPEC_CONST_UNUSED()中,先根据静态变量名从zend_op_array->static_variables哈希表中查找到zval,然后将这个zval的地址保存到ZEND_FETCH_W指令的result返回值操作数中。由此可知,ZEND_FETCH_W指令用于根据静态变量名获取其值,取到的值的地址保存到了指令的result操作数指定的位置。

如果有一个名为count的静态变量,则ZEND_FETCH_W指令的处理如图9-2:

2.ZEND_ASSIGN_REF

该指令负责将ZEND_FETCH_W指令获取到的静态变量转为引用类型,然后赋值给局部变量$count,具体的赋值操作在zend_assign_to_variable_reference()中完成。由于是引用,所以修改$count时会直接修改zend_op_array->static_variables中对应的静态变量值。

该指令完成后,$count的作用就变为通过引用指向静态变量符号表中的值,即static $count = 4实际是$count = &zend_op_array->static_variables["count"]

9.2 常量

常量在脚本执行期间值不变。常量名默认大小写敏感,但通常用法是全大写的。常量名和其他PHP名字遵循相同的命名规则,即以字母或下划线开始,后面跟任意字母、数字、下划线。

常量存储在EG(zend_constant)哈希表中。常量的数据结构:

常量的标识flags可以是以下三种的任意组合:

1.CONST_CS:大小写敏感,默认开启。用户通过define()定义的常量始终是区分大小写的,通过扩展定义的可自由选择。

2.CONST_PERSISTENT:只有内核、扩展定义的常量才支持,这种持久化的常量在request结束时不会被清理掉。

3.CONST_CT_SUBST:只有内核、扩展定义的常量才支持,编译时直接将用到常量值的地方替换为值,而不是运行时再读取。

9.2.1 const

const关键字可用于声明常量:

php 复制代码
// 声明一个常量
const AA = 100;
// 声明多个常量
const AA = 100, BB = 200;

编译时每个常量声明生成一个ZEND_AST_CONST_ELEM节点,该节点有两个子节点,分别表示常量名、常量值。ZEND_AST_CONST_ELEM节点会被插入ZEND_AST_CONST_DECL列表节点中,编译时会遍历该list,依次编译每个ZEND_AST_CONST_ELEM节点。编译ZEND_AST_CONST_DECL节点的函数为zend_compile_const_decl。

常量值的类型只能是CONST类型,因此常量定义时的值不能是变量。zend_compile_const_decl()中,会为每个常量生成一条ZEND_DECLARE_CONST指令,其作用是注册常量。

9.2.2 define()

define是一个内部函数,通过它可定义值为变量的常量,如:

php 复制代码
$a = array(1, 2);
define('AA', $a);

但用于定义常量的变量不能是对象,也不能是有对象的数组。

关于用于定义常量的值:

1.数组

数组中不能有对象,如数组合法,会将其深拷贝一份到EG(zend_constants)。

2.对象

不支持。但如果对象的操作handler定义了get、cast_object,则会调用它,将返回值作为常量值。

9.3 全局变量

定义在函数、类之外(即定义在主代码中)的变量为全局变量,这些变量可以在函数、方法中通过global关键字引入使用。

9.3.1 全局变量符号表

全局变量保存在全局变量符号表EG(symbol_table)中,它是一个哈希表。在执行前,内核会将全局变量(也可看成是主代码中的局部变量)导入EG(symbol_table)中。在ZendVM的执行入口zend_execute()中,导入过程i_init_execute_data()是在zend_execute_ex()前完成的。

i_init_execute_data()对zend_execute_date初始化过程中,会通过zend_attach_symbol_table()将主代码中的局部变量导入全局变量符号表。zend_attach_symbol_table()会将存有主代码的局部变量名的zend_op_array->vars数组,将变量名为key插入EG(symbol_table),value指向zend_execute_data上对应的主代码局部变量。

9.3.2 全局变量的访问

在函数或方法中,可通过global导入全局变量,可在一条导入语句中导入多个全局变量,但不能在导入时赋值,如global $id = 100。全局变量的访问机制也是将全局变量转换为引用类型,然后将函数或方法中导入的变量指向这个引用。global语句会编译生成ZEND_BIND_GLOBAL指令,它会将全局变量符号表中的值修改为引用类型,并将导入全局变量的函数或方法内的局部变量指向对应引用。如我们有全局变量$name$id,且在test()中导入了这两个全局变量,则执行完ZEND_BIND_GLOBAL指令后的引用关系如图9-5:

9.3.3 全局变量的销毁

全局变量在请求结束时销毁。销毁过程shutdown_destructor()在php_request_shutdown()中触发,销毁过程会遍历EG(symbol_table),对每个成员调用zval_call_destructor()来释放。

9.3.4 超全局变量

超全局变量不需要global引入就可直接使用,它是PHP内部定义的全局变量,也保存在EG(symbol_table)中,包括$GLOBAL$_SERVER$_REQUEST_$POST$_GET$_FILES$_ENV$_COOKIE$_SESSION$argv$argc

9.4 分支结构

分支结构依赖跳转指令。

9.4.1 if

编译if语句时会创建一个ZEND_AST_IF列表节点,其中保存每个分支的ZEND_AST_IF_ELEM节点。ZEND_AST_IF_ELEM节点有两个子节点,分别记录分支的进入条件(condition)和分支内的语句(statement)。

编译ZEND_AST_IF节点时,会按顺序编译每个分支的condition、statement。编译过程大致如下:

1.编译当前分支的condition。

2.编译一条ZEND_JMPZ指令,若当前condition成立则继续执行本分支statement,否则跳过当前的statement,需要跳过的指令数需要编译完本分支的statement后才能确定。

3.编译当前分支的statement列表,其节点类型为ZEND_AST_STMT_LIST。

4.编译一条ZEND_JMP指令,用于执行完分支语句后跳出分支,即跳过后面所有的condition、statement,需要跳过的指令数需要编译完全部分支后才能确定。

5.设置步骤2中条件不成立时ZEND_JMPZ应该跳过的指令数。

6.重复步骤1到5,直到编译完全部分支。最后设置每个步骤4中ZEND_JMP跳出if的指令位置。

以上步骤的实现在zend_compile_if()中。最后编译生成的指令集合:

在编译、执行过程中,elseifelse if是有区别的,上面介绍的是elseif的编译,而else if相当于:

php 复制代码
if (condition1)
{
    statement1;
}
else if (condition 2)
{
    statement2;
}
// 等价于
if (condition1) 
{
    statement1;
}
else 
{
    if (condition 2)
	{
	    statement2;
	}
}

在使用时elseif和else if区别不大,但如果使用的是冒号语法,则只能用elseif,因为else if后不能跟冒号语法:

php 复制代码
// 正确:使用 elseif
if ($a):
    // ...
elseif ($b):
    // ...
endif;

// 错误:使用 else if(冒号语法不允许)
if ($a):
    // ...
else if ($b): // 触发 Parse Error
    // ...
endif;

9.4.2 switch

语法:

break不属于switch语句,它属于中断语法,因此上图中没有break。如果switch中没有使用break,则会从命中的那个case开始一直执行到结束。

switch会被解析为一个ZEND_AST_SWITCH节点,其中包含两个子节点,expression和case列表。case列表节点类型为ZEND_AST_SWITCH_LIST,其子节点类型为ZEND_AST_SWITCH_CASE,每个子节点代表一个case。ZEND_AST_SWITCH_CASE节点包括两个子节点,表示value和statement。

switch的编译过程:

1.编译expression。

2.编译每个case,但其中的statement暂时不会编译。如果value是一个表达式则像编译expression一样编译它。每个case都会生成一条ZEND_CASE指令,用于比较对应value与expression是否相等,并将比较结果写入一个变量。此外,每个case还会编译出一条ZEND_JMPNZ指令,当value与expression相等时(通过存放了比较结果的变量判断),通过ZEND_JMPNZ跳转到该case的statement处,但statement此时还未编译,跳转值还不确定。

3.为default分支编译生成一条ZEND_JMP指令。

4.编译每个case的statement,编译前先设置步骤2中ZEND_JMPNZ的跳转值为当前statement起始位置。

以上步骤的编译由zend_compile_switch()完成。

switch语句最终编译的指令集合:

9.5 循环结构

9.5.1 while

语法:

while在解析时会创建一个ZEND_AST_WHILE节点,express、statement分别保存在两个子节点中:

while的编译过程:

1.首先编译一条ZEND_JMP的opcode,用来跳到循环判断条件expression的位置,由于while是先编译循环体再编译循环条件,因此现在还无法确定跳转值。

2.编译循环体expression,编译完成后,更新步骤1中ZEND_JMP的跳转值。

3.编译循环判断条件expression。

4.编译一条ZEND_JMPNZ的opcode,用于循环判断条件为真时跳转到循环体,如果为假则继续向下执行(相当于跳出循环)。

编译while生成的指令集合:

实际运行时可能会省略ZEND_JMPNZ,因为很多expression执行完后会主动判断下一条指令,如果是ZEND_JMPNZ,就会直接根据跳转,而不需要再由ZEND_JMPNZ进行判断跳转,这是一种性能上的优化。

9.5.2 do while

语法:

do while编译过程与while相似,但没有ZEND_JMP指令,do while编译生成的指令集如图9-12:

9.5.3 for

语法:

for被编译为ZEND_AST_FOR节点,其包含四个子节点,分别表示init expr、condition expr、loop expr、statement,前三个节点为表达式节点ZEND_AST_EXPR_LIST。编译生成的抽象语法树如图9-13:

for语句的编译过程:

1.编译初始化表达式init expr。

2.编译一条ZEND_JMP指令,用于跳到condition expr的位置,具体跳转值需要编译完循环体才能确定。

3.编译循环体statement。

4.编译loop expr,然后设置步骤2中ZEND_JMP指令的跳转值。

5.编译循环条件condition expr。

6.编译一条ZEND_JMPNZ指令,用于当condition expr为true时,跳回循环体起始位置。

for编译生成的指令集合如图9-14:

9.5.4 foreach

foreach用于遍历数组、对象,语法如下:

foreach被编译为ZEND_AST_FOREACH节点,其中包含4个子节点,分别为:要遍历的数组或对象、元素的value、元素的key、循环体。foreach生成的抽象语法树如图9-15:

如果value是引用,则value的节点的类型会变为ZEND_AST_REF。

foreach的实现:key、value实际是两个局部变量,遍历的过程就是对两个局部变量不断赋值。以数组为例,首先复制一份数组用于遍历,然后从arData(即bucket)中第一个元素开始:把Bucket.val.value的值赋给$value;把Bucket->key(或Bucket->h,其中存放key的哈希值,或存放数值索引)赋值给$key;之后更新下一个元素的位置到数组的zval.u2.fe_pos,以便遍历下一个元素,这也是为什么遍历前要复制一份数组变量。遍历直到zval.u2.fe_pos到达arData的末尾,然后销毁用于遍历的复制的变量。如果遍历的是对象,则使用zval.u2.fe_iter_idx保存迭代位置。

一个例子:

php 复制代码
$arr = array(1, 2, 3);
foreach ($arr as $k => $v) 
{
    echo $v;
}

foreach时上例对应的内存结构如图9-16:

如果value是引用,则在循环前会先将原数组或对象转为引用类型,之后的遍历过程同上,这种情况下的内存结构:

foreach的编译过程:

1.生成复制数组(对象)的指令ZEND_FE_RESET_R,如果value是引用类型则生成ZEND_FE_RESET_RW指令。如发现遍历的变量不是数组或对象,则抛出一个warning,然后跳出循环,跳出循环需要跳出的位置,该位置当前未知。

2.生成获取数组(对象)当前key、value的指令ZEND_FE_FETCH_R,如果value是引用则生成ZEND_FE_FETCH_RW指令。这两个指令有两个作用:一是获取遍历元素的key、value,并将value赋值给局部变量;二是更新遍历位置,在到达结尾时结束遍历,结束遍历即跳出foreach,要跳出的位置当前未知。此时只赋值了value,key还保存在一个TMPVAR中。

3.如果foreach定义了key,则编译一条赋值指令,将步骤2中ZEND_FW_FETCH_R取到的key赋值给局部变量。

4.编译循环体。

5.编译跳回步骤2的ZEND_JMP指令,进行下一轮遍历。

6.设置步骤1、2中生成的ZEND_FE_RESET_R、ZEND_FE_FETCH_R跳过的指令数。

7.编译ZEND_FE_FREE,用于释放步骤1中复制的数组(对象)。

foreach编译生成的指令集如图9-18:

9.6 中断及跳转

9.6.1 break/continue

break用于结束for、foreach、while、do-while、switch结构的执行;continue用于跳过循环结构中本次循环的剩余代码。break/continue都可接受一个可选数字参数来决定跳过的循环层数。

循环编译时有两个特殊操作zend_begin_loop()、zend_end_loop(),分别在该循环的编译前和编译后调用,这两个操作就是为break、continue服务的。编译多层嵌套的循环时,每层在编译时都会创建一个zend_brk_cont_element结构:

cont是循环条件判断指令的起始位置(continue执行时会跳到下一轮循环的条件判断指令);brk是循环结束的位置(break执行时会跳到循环结束位置)。parent是父层循环的zend_brk_cont_element的位置。多层嵌套的循环构成一个zend_brk_cont_element链表,每层循环在编译结束时更新自己的zend_brk_cont_element结构。break、continue实际就是根据要跳出的层级索引到指定层的zend_brk_cont_element结构,然后根据其brk或cont进行跳转。

zend_brk_cont_element结构保存在zend_op_array->brk_cont_array数组中(每个循环的结构都存在里面,包括非嵌套的循环和每层嵌套的循环),zend_op_array->last_brk_cont是此数组中第一个可用位置,每申请一个zend_brk_cont_element结构,last_brk_cont就加1,然后将数组扩容。zend_brk_cont_element的parent字段记录的就是父层循环的zend_brk_cont_element在数组中的位置。

循环编译前调用zend_begin_loop申请一个zend_brk_cont_element结构,并将该结构位置保存到CG(context).current_brk_cont,循环编译完成后,在zend_end_loop()中根据该位置取出它的zend_brk_cont_element,然后更新cont、brk。

一个例子:

上例编译完后的zend_brk_cont_element结构如图9-19:

上图中有一个错误,内层循环的cont应指向循环条件,而不是循环体。

break、continue的编译主要是生成临时指令ZEND_BRK、ZEND_CONT,这两条指令不是ZendVM执行的指令,它们最终会被编译为跳转指令。这条opcode(指ZEND_BRK或ZEND_CONT)记录两个信息:

1.op1记录当前循环的zend_brk_cont_element的存储位置,编译过程中,当前循环的zend_brk_cont_element的位置通过CG(context).current_brk_cont保存。

2.op2记录要跳出的层级,如果break、continue后没有加数字,则为1。

break、continue的编译通过zend_compile_break_continue()处理。

zend_compile_break_continue()完成后,break、continue的编译并没有完成,因为此时属于编译循环体的环节(break、continue是循环体的一部分)。在整个脚本编译完成后,会在pass_two()中把ZEND_BRK、ZEND_CONT指令修改为ZEND_JMP,跳转值根据zend_brk_cont_element结构获得。

编译ZEND_JMP指令时,会根据ZEND_BRK、ZEND_CONT的op1、op2取出break、continue所在循环的zend_brk_cont_element结构和要跳过的层级,然后遍历zend_brk_cont_element链表,即沿着parent成员向上遍历op2次,从而找到continue、break的目标循环的zend_brk_cont_element结构。

最终执行时的指令如图9-20:

9.6.2 goto

PHP的goto的目标位置只能位于同一个文件和作用域,无法跳出一个函数或方法,无法跳入另一函数,可以跳出循环但不可跳入循环。

语法:

goto需要与标签label组合使用,其最终会被优化为ZEND_JMP。

标签会被编译为ZEND_AST_LABEL节点,该节点只有一个子节点,用于保存标签名。编译时会把标签插入CG(context).labels哈希表中,key是标签名,value是一个zend_label结构:

brk_cont记录当前标签所在循环,即循环在zend_op_array->brk_cont_array数组中的位置,用于判断是否goto到了循环中;opline_num是标签下面第一条指令的位置。goto的处理方式:先根据标签名在CG(context).labels中查找标签的zend_label结构,然后跳到opline_num的位置。

goto初步会被编译为ZEND_GOTO,其标签名保存在op2,其opline->extended_value记录的是goto所在循环,如果不在循环则该值为-1。ZEND_GOTO的op1保存的是goto语句包含的指令数,但goto只编译了一条指令,其他的指令来源于可能的foreach语句,foreach在遍历前会赋值要遍历的zval,这个zval在循环结束时才会释放,如果goto直接跳出了foreach,会导致这个zval副本不会释放,因此会在zend_handle_loops_and_finally()中为goto再生成释放这个zval副本的指令。

之后在pass_two()中,ZEND_GOTO被重置为ZEND_JMP。

9.7 include/require

include和require没有本质区别,唯一不同在于错误级别,当文件无法加载时,include会抛出warning,而require会抛出error。

关于include:

1.如include前定义了某变量,则该变量能在被包含的文件中使用;反之,include的文件中的变量能在include处开始被使用。

2.被include的文件中定义的类、函数在include执行后可被使用,具有全局作用域。

3.include是在运行时加载并执行文件的,而不是编译时。

可简单理解为,include就是把其他文件的内容复制到了当前文件中的include处。例如:

则打印出的var_2的值为array(),且var_3也可用了。

Zend引擎的编译、执行过程:PHP代码→抽象语法树→opline指令→execute。编译过程的输入是一个文件,输出是zend_op_array,输出接着成为执行过程的输入。执行include时会把被包含的文件像主脚本一样编译、执行,然后再回到include的位置继续执行,如图9-21:

include编译为ZEND_INCLUDE_OR_EVAL指令,执行时将调用ZEND_INCLUDE_OR_EVAL_SPEC_CONST_HANDLER进行处理,处理过程就是PHP脚本完整的编译和执行过程。

ZEND_INCLUDE_OR_EVAL_SPEC_CONST_HANDLER中的执行过程与函数的调用过程很相似,也是先分配一个zend_execute_data,然后将执行器切换到新的zend_execute_data,执行完再切回调用处。如果include的文件中只定义了函数、类,没有定义全局变量,则编译阶段会将函数、类注册到EG(function_table)、EG(class_table),而执行过程会直接return。

如果include的文件中有全局变量,如何将其中的全局变量和调用include的文件中的全局变量进行合并呢?include执行中会调用i_init_code_execute_data(),该函数除了进行一些上下文的设置,还会调用zend_attach_symbol_table()把当前zend_op_array(每个PHP脚本都会被编译为独立的zend_op_array结构)下的全局变量移到EG(symbol_table)全局符号表中去,value指向zend_execute_data局部变量的zval(实际是全局变量,因为是主代码中的局部变量)。上例中执行include前,a.php中变量的关系如图9-22:

而include时会在i_init_code_execute_data()中调用zend_attach_symbol_table()进行全局变量注册,如果注册时发现已经有该全局变量,如上例,则会把被包含的b.php中的同名全局变量的值改为EG(symbol_table)中全局变量的值:

上图中var_2的引用数为1,因为从逻辑上讲只有一个var_2变量。然后执行include的脚本,执行到$var_2 = array()时会将原array(1, 2, 3)的引用数从1减为0,此时将其释放,然后将新的值赋给var_2。注意,此时a.php中的var_2仍指向被释放的值:

被包含文件执行完后,将执行return返回include语句的位置,return时会把var_2的值更新到EG(symbol_table),即之前EG(symbol_table)中存的是var_2的地址,而现在存的是值。这个过程由zend_detach_symbol_table()处理。zend_detach_symbol_table()和zend_attach_symbol_table()是一对相反的操作,attach在EG(symbol_table)中保存一个指向变量的指针,而detach在EG(symbol_table)中保存一个变量值。

return时,除了回到a.php的zend_execute_data,还会重新执行zend_attach_symbol_table()进行全局变量的注册:

include_once、require_once相比include、require,一次请求中同一文件只会被加载一次,第一次加载时会把文件名插入EG(included_files)哈希表中,再次加载时如发现已经加载过,则直接跳过。

9.8 异常处理

在try块中可捕获异常,抛异常后,不再执行try中抛异常之后的代码。异常会被最近的try捕获,如果当前执行空间没有try操作,会沿调用栈一直向上抛。

9.8.1 PHP中的try catch

语法:

抛出异常后,会依次检查catch的异常类是否与抛出的匹配,只有实现了Throwable接口的类才能被catch到,而php的类无法直接实现Throwable接口,只能通过继承Exception类来间接实现它。finally statement不管是否有异常抛出都会执行,即使在try或catch中return了,它也会执行。

try-catch-finally最终编译为一个ZEND_AST_TRY节点,该节点有三个子节点:try statement、catch list、finally statement。try statement、finally statement为ZEND_AST_STMT_LIST节点;catch list包含多个ZEND_AST_CATCH节点,每个节点都有三个子节点:exception class、exception object、catch statement。最终生成的抽象语法树如图9-26:

ZEND_AST_TRY节点的编译步骤:

1.向所属zend_op_array->try_catch_array成员数组中注册一个zend_try_catch_element结构,每个try都会注册一个该结构,用来记录try、catch、finally指令的开始位置:

2.编译try statement,如果定义了catch块,则紧接着编译一条ZEND_JMP指令,该指令用于无异常抛出时跳过所有catch,由于catch块还未编译,因此当前跳转值未定。

3.依次编译各个catch块,如没有catch块则跳过此步骤。编译各个catch时,先编译一条ZEND_CATCH指令,该指令保存着此catch的exception class、exception object、下一个catch块开始的位置。编译第1个catch时,将ZEND_CATCH指令的位置更新到zend_try_catch_element->catch_op,接着编译catch statement,最后编译一条ZEND_JMP指令(最后一个catch不用),如果定义了finally,则用于跳到finally,否则用于跳转到try catch后,由于后面可能还有catch块,因此当前跳转值未定。

4.先更新步骤2、3中的跳转值。如没有finally,则结束编译;如有,则继续编译它。编译它时,先编译一条ZEND_FAST_CALL和ZEND_JMP指令,接着编译finally statement,最后编译一条ZEND_FAST_RET指令。

编译finally时生成的ZEND_FAST_CALL指令用于跳到finally statement指令处,finally statement执行完后,会通过ZEND_FAST_RET跳转到ZEND_FAST_CALL的下一条指令,即ZEND_JMP处,ZEND_JMP会跳转到try catch外。ZEND_FAST_CALL不仅仅在finally中会生成,如果try statement、catch statement中有return语句,也会在return指令前生成该指令。即ZEND_FAST_CALL的作用是劫持return,在return前先执行finally statement,然后再跳回去执行return。

异常的抛出通过throw关键字来完成,throw语句被编译为ZEND_THROW指令,抛出后将由ZEND_CATCH指令检查是否被catch捕获。整体流程如下:

1.throw时检查抛出的是否是object对象,如不是则导致error错误。

2.将抛出的异常对象保存到EG(exception),同时将下一条执行的指令更新为ZEND_HANDLE_EXCEPTION。

3.执行ZEND_HANDLE_EXCEPTION,先查找抛出异常的位置是否在try catch之间,查找过程就是遍历zend_op_array->try_catch_array数组,根据throw的位置、try开始的位置、catch开始的位置、finally开始的位置进行比较。如果是在try catch内抛出的,则进入步骤4检查是否被捕获;否则进入步骤5处理。如果找到了多个try catch,则选择最后那个,这是try catch嵌套的情况,选择最里层那个。

4.首先进入第一个catch块起始位置(即zend_try_catch_element->catch_op)执行ZEND_CATCH指令,如捕获成功,执行该catch的statement,同时将EG(exception)清空;如捕获失败,则跳到下一个catch位置继续判断。如最后一个catch还没有捕获,则当前try catch没有捕获到该异常,此时再次将该异常抛出,抛出位置为最后一个catch处,这是为了避免下一轮try catch匹配时重复匹配到同一个try catch。

5.将异常抛给上一层调用方,在调用方的空间的调用位置再次执行ZEND_HANDLE_EXCEPTION,此时回到步骤3。如果到最终主脚本也没有捕获异常,则结束执行并导致error错误。

以上过程没有提到finally的执行时机。命令catch时,会在catch statement执行完后跳到finally执行;没有命中catch时,finally实际会在步骤3中执行:当步骤4中最后一个catch也没有匹配到时,会更新异常抛出位置EG(opline_before_exception)为最后一个catch的位置,然后再次回到步骤3执行ZEND_HANDLE_EXCEPTION,此时检测到异常抛出位置不在try catch之间,但在finally之前,就知道上次try catch没有命中任何catch了,这时会先将异常对象保存在finally块中,然后执行finally statement,执行完后再将存在finally块中的异常对象重新抛出。

9.8.2 内核中的异常处理

上一节介绍的异常处理是PHP语言层面的实现,内核中也有一套供内核使用的异常模型(使用C语言实现的)。语法:

C语言层面没有提供try catch机制,PHP内核的异常处理主要利用sigsetjmp()、siglongjmp()实现堆栈的保存、还原,在zend_try的位置通过sigsetjmp()将当前位置的堆栈保存在一个变量中,异常抛出通过siglongjmp()跳回原位置。具体宏定义:


将zend_try、zend_catch展开后:

上图异常抛出后的分支里,在EG(bailout) = __orig_bailout;后还可以写用户代码。这种异常处理不会出现catch不到的情况。且catch里不能再次抛出异常,如果再次抛出了,LONGJMP()还是跳回当前层的SETJMP,之后SETJMP返回非0,导致又回到当前catch,从而无限循环。

PHP的exit语句就是利用这个功能退出的,exit对应的执行指令为ZEND_EXIT,该指令会调用zend_bailout()。

zend_bailout()宏中会调用LONGJMP(),相当于执行exit时内核抛出了一个异常。在PHP脚本最初执行位置php_execute_script()中,会在zend_try中执行PHP请求,如果执行中抛异常,会直接跳到catch的地方:

9.9 break/continue LABEL语法的实现

PHP中的break、continue只能根据数字指定目标循环,当循环嵌套较多时维护起来不方便,例如:

在Go语言中,break、continue后面可以跟一个标签:

但PHP不支持Go中的这种语法,我们接下来在PHP中实现这种功能。PHP中,需明确:

1.无论哪种循环,其编译时都生成了一个zend_brk_cont_element结构,其中记录着该循环作为break、continue的目标时,分别要跳转的位置,以及该循环的父层循环。

2.break/continue编译时,首先初步编译为临时指令,该指令记录着break/continue所在循环以及要中断的循环;然后在脚本编译完后调用的pass_two中,根据zend_brk_cont_element结构查找break/continue对应的跳转位置,生成一条ZEND_JMP指令。

嵌套循环之间是链表结构,目前break + 数字的处理就是从break/continue所在循环层沿链表向上查找对应的层数,从而找到要操作的那层循环。

标签在内核中通过CG(context).labels哈希表保存,key是标签名,值中会记录当前标签位置,我们要实现break标签的语法就要根据标签取到循环,因此我们需要找到标签后紧挨着循环的标签,称其为循环标签:

既然要按标签进行break、continue,我们可以修改标签结构,在其中加上循环标签对应的循环层级id。编译标签break、continue时先查找标签,然后就找到了目标循环的zend_brk_cont_element结构。实现思路:

1.循环编译前先编译一条空指令ZEND_NOP,用于标识它是一个循环,并把这个循环zend_brk_cont_element的存储位置记录在该指令中。由于标签编译时不会生成任何指令,因此循环结构无法通过上一条指令判断它是不是循环标签。但CG(context).labels中的标签记录了标签位置,可通过标签位置后是否是ZEND_NOP来判断标签后面是否是循环,从而知道了该标签是否是循环标签,这就是步骤1的作用。

2.break编译时,如果发现后面跟的是标签,则从CG(context).labels中取出标签结构,然后判断此标签的下一条指令是否是ZEND_NOP,如果不是则说明此标签不是循环标签,无法break、continue;如果是则取出循环结构。

3.现在由于循环可能尚未编译完成,跳转值未知,因此只能将break、continue编译为临时指令,还不能将其编译为ZEND_JMP。这里可以把循环结构的存储下标记录在临时指令中,然后在pass_two()中再重新获取,但这需要对pass_two()进行改动。为减少改动,可计算label标记的循环相对break/continue所在循环的相对位置,从而将其转为现有的break n。

相关推荐
BingoGo1 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php
JaguarJack1 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php·服务端
BingoGo2 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php
JaguarJack2 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php·服务端
JaguarJack3 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
后端·php·服务端
BingoGo3 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
php
JaguarJack4 天前
告别 Laravel 缓慢的 Blade!Livewire Blaze 来了,为你的 Laravel 性能提速
后端·php·laravel
郑州光合科技余经理5 天前
代码展示:PHP搭建海外版外卖系统源码解析
java·开发语言·前端·后端·系统架构·uni-app·php
西岸行者5 天前
学习笔记:SKILLS 能帮助更好的vibe coding
笔记·学习
starlaky5 天前
Django入门笔记
笔记·django