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。

相关推荐
源码师傅15 分钟前
PHP+MySQL开发语言 在线下单订水送水小程序源码及搭建指南
php·送水小程序·桶装水小程序·在线下单送水小程序源码·桶装水送货上门小程序·订水线上商城
Chef_Chen40 分钟前
从0开始学习R语言--Day12--泊松分布
开发语言·学习·r语言
专注代码七年1 小时前
php:5.6-apache Docker镜像中安装 gd mysqli 库 【亲测可用】
php·apache
z人间防沉迷k1 小时前
MySQL事务和索引原理
数据库·笔记·sql·mysql
golitter.2 小时前
langchain学习 01
python·学习·langchain
houliabc2 小时前
【2025年软考中级】第二章2.2 程序设计语言的基本成分
笔记·学习·证书·软考
夕水2 小时前
分享一些实用的PHP函数(对比js/ts实现)(1)
后端·php
✎ ﹏梦醒͜ღ҉繁华落℘3 小时前
WPF学习
c语言·开发语言·笔记
大筒木老辈子3 小时前
Linux笔记---线程
笔记
杨DaB3 小时前
【JavaWeb】基本概念、web服务器、Tomcat、HTTP协议
java·笔记·学习·java-ee