当前位置: 首页 > 科技观察

PHP内核分析:Zend虚拟机

时间:2023-03-12 06:59:17 科技观察

PHP是一种解释型语言。对于Java、Python、Ruby、Javascript等解释型语言,我们写的代码不会编译成机器码运行,而是编译成中间代码在虚拟机(VM)上运行。运行PHP的虚拟机称为Zend虚拟机。今天我们就深入内核,探究一下Zend虚拟机运行的原理。操作码什么是操作码?它是虚拟机可以识别和处理的指令。Zend虚拟机包括一系列的OPCODE,虚拟机通过它们可以做很多事情。下面是一些OPCODE的例子:ZEND_ADD添加了两个操作数。ZEND_NEW创建一个PHP对象。ZEND_ECHO将内容输出到标准输出。ZEND_EXIT退出PHP。对于这样的操作,PHP定义了186个(随着PHP的更新,肯定会支持更多类型的OPCODE),所有OPCODE的定义和实现都可以在源码的zend/zend_vm_def.h文件中找到(内容为这个文件不是NotnativeCcode,而是一个模板,原因后面会解释)。看看PHP是如何设计OPCODE数据结构的:观察一下OPCODE的数据结构,你能找到汇编语言的感觉吗?每个OPCODE包含两个操作数op1和op2,handler指针指向执行OPCODE操作的函数。函数处理后的结果会保存在result中。让我们举一个简单的例子:ASSIGN!0,131ADD~3!0,22ASSIGN!1,~383>RETURN1其中,第二行是ZEND_ADD指令的OPCODE.我们看到它接收了2个操作数,op1是变量$b,op2是数值常量1,返回的结果存放在一个临时变量中。在zend/zend_vm_def.h文件中,我们可以找到ZEND_ADD指令对应的函数实现:ZEND_VM_HANDLER(1,ZEND_ADD,CONST|TMPVAR|CV,CONST|TMPVAR|CV){USE_OPLINEzend_free_opfree_op1,free_op2;zval*op1,*op2,*result;op1=GE***_ZVAL_PTR_UNDEF(BP_VAR_R);op2=GET_OP2_ZVAL_PTR_UNDEF(BP_VAR_R);if(EXPECTED(Z_TYPE_INFO_P(op1)==IS_LONG)){if(EXPECTED(Z_TYPE_INFO_P(op2)==IS_LONG)){result=EX_VAR(opline->result.var);fast_long_add_function(result,op1,op2);ZEND_VM_NEXT_OPCODE();}elseif(EXPECTED(Z_TYPE_INFO_P(op2)==IS_DOUBLE)){result=EX_VAR(opline->result.var);ZVAL_DOUBLE(result,((double)Z_LVAL_P(op1))+Z_DVAL_P(op2));ZEND_VM_NEXT_OPCODE();}}elseif(EXPECTED(Z_TYPE_INFO_P(op1)==IS_DOUBLE)){...}上面的代码不是本机C代码,而是一个模板。你为什么要这么做?因为PHP是弱类型语言,而它实现的C是强类型语言。弱类型语言支持自动类型匹配,自动类型匹配的实现,就像上面的代码一样,通过判断来处理不同类型的参数。试想一下,如果每次OPCODE处理都需要判断传入参数的类型,那么性能必然会成为一个巨大的问题(一次请求中需要处理的OPCODE数量可能达到数万个)。有什么办法吗?我们发现在编译时,每个操作数的类型(可能是常量也可能是变量)就已经确定了。因此,PHP在实际执行C代码时,会将不同类型的操作数分成不同的函数供虚拟机直接调用。这部分代码放在zend/zend_vm_execute.h中,展开后的文件挺大的,我们注意到有这么一段代码:if(IS_CONST==IS_CV){没有任何意义,对吧?不过没关系,C编译器会自动优化这个判断。大多数情况下,我们想了解某个OPCODE的逻辑,阅读模板文件zend/zend_vm_def.h会更容易一些。顺便说一句,从模板生成C代码的程序是用PHP实现的。执行过程准确的说,PHP的执行分为编译和执行两部分。这里我就不详细展开编译部分了,重点介绍执行过程。经过语法、词法分析等一系列编译过程,我们得到一个名为OPArray的数据,其结构如下:struct_zend_op_array{/*Commonelements*/zend_uchartype;zend_uchararg_flags[3];/*arg_info.pass_by_reference*/uint32_tfn_flags;zend_string*function_name;zend_class_entry*scope;zend_function*prototype;uint32_tnum_args;uint32_trequired_num_args;zend_arg_info*arg_info;/*公共元素结束*/uint32_t*refcount;uint32_optlast;*opcodes;intlast_var;uint32_tT;zend_string**vars;intlast_live_range;intlast_try_catch;zend_live_range*live_range;zend_try_catch_element*try_catch_array;/*静态变量支持*/HashTable*static_variables;zend_string*filename;uint32_tline_start;uint32_tline_end;zend_string*doc_comment;uint32_tearly_binding;/*延迟声明的链表*/intlast_literal;zval*literals;intcache_size;void**run_time_cache;void*reserved[ZEND_MAX_RESERVED_RESOURCES];};简单理解,其实质就是一个OPCODE数组加上执行时需要的环境数据的集合。介绍几个比较重要的字段:opcodes存放的是OPCODE的数组。filename当前执行脚本的文件名。function_name当前执行的方法名。static_variables静态变量列表。last_try_catchtry_catch_array在当前上下文中,如果发生异常,try-catch-finally跳转到需要的信息。literals常量字面量的集合,例如字符串foo或数字23。为什么需要生成如此庞大的数据量?因为编译时产生的信息越多,执行时花费的时间就越少。接下来我们看看PHP是如何执行OPCODE的。OPCODE的执行放在一个大循环中,位于zend/zend_vm_execute.h中的execute_ex函数中:1){if(UNEXPECTED((ret=((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU))!=0)){if(EXPECTED(ret>0)){execute_data=EG(current_execute_data);ZEND_VM_LOOP_INTERRUPT_CHECK();}else{return;}}}zend_error_noreturn(E_CORE_ERROR,"Arrivedatendofmainloopwhichshouldn'thappen");}这里我去掉了一些环境变量判断分支,保留了主进程的运行。可以看出,在一个***循环中,虚拟机不断调用OPCODE指定的处理函数对指令集进行处理,直到某条指令处理结果ret小于0。注意,OPCODE数组的当前指针在主进程中并没有移动,而是将这个进程放在了指令执行的具体函数的末尾。因此,我们可以看到在大多数OPCODE实现函数的最后都会调用这个宏:ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();在前面的简单例子中,我们看到在vld打印的执行OPCODE数组中,***有一条指令OPCODE为ZEND_RETURN。但是我们写的PHP代码中并没有这样的语句。在编译过程中,虚拟机会自动将这条指令添加到OPCODE数组的末尾。ZEND_RETURN指令对应的函数会返回-1,当判断执行结果小于0时,退出循环,从而结束程序的运行。方法调用如果我们调用一个自定义函数,虚拟机是如何处理的呢?NOP61INIT_FCALL'foo'2DO_FCALL03>RETURN1compiledvars:noneline#*EIOopfetchextreturnoperands----------------------------------------------------------------------------------30E>ECHO'test'41>RETURNnull其中,INIT_FCALL准备执行函数所需的上下文数据。DO_FCALL负责执行函数。DO_FCALL的处理函数根据不同的调用情况处理了很多逻辑。我提取了执行用户定义函数的逻辑部分:ZEND_VM_HANDLER(60,ZEND_DO_FCALL,ANY,ANY,SPEC(RETVAL)){USE_OPLINEzend_execute_data*call=EX(call);zend_function*fbc=call->func;zend_object*对象;zval*ret;...if(EXPECTED(fbc->type==ZEND_USER_FUNCTION)){ret=NULL;if(RETURN_VALUE_USED(opline)){ret=EX_VAR(opline->result.var);ZVAL_NULL(ret);}call->prev_execute_data=execute_data;i_init_func_execute_data(call,&fbc->op_array,ret);if(EXPECTED(zend_execute_ex==execute_ex)){ZEND_VM_ENTER();}else{ZEND_ADD_CALL_FLAG(call,ZEND_CALL_TOP);zend_execute_ex(调用);}}...ZEND_VM_SET_OPCODE(opline+1);ZEND_VM_CONTINUE();}可以看到,DO_FCALL先保存调用函数前的上下文数据到调用->prev_execute_data,然后调用i_init_func_execute_data函数,自定义函数对象中的op_array(每个自定义函数在编译时会生成对应的数据,它的数据结构包含函数的OPCODE数组)将其分配给新的执行上下文对象,然后调用zend_execute_ex函数开始执行自定义函数。zend_execute_ex其实就是上面提到的execute_ex函数(默认是这个,但是扩展可能会改写zend_execute_ex指针,这个API可以让PHP扩展开发者通过重写函数来达到扩展函数的目的,不是本文的主题,并且我不打算深入讨论),只是将上下文数据替换为当前函数所在的上下文数据。我们可以理解为最外层代码是一个默认存在的函数(类似于C语言中的main()函数),本质上与用户自定义函数没有区别。逻辑跳转我们知道指令是按顺序执行的,我们的程序一般都会包含很多逻辑判断和循环。这部分是如何通过OPCODE实现的呢?ASSIGN!0,1031IS_EQUAL~2!0,102>JMPZ~2,->543>ECHO'成功'4>JMP->665>ECHO'失败'76>>RETURN1我们看到JMPZ和JMP控制着执行流程。JMP的逻辑很简单,将当前OPCODE指针指向需要跳转的OPCODE。ZEND_VM_HANDLER(42,ZEND_JMP,JMP_ADDR,ANY){USE_OPLINEZEND_VM_SET_OPCODE(OP_JMP_ADDR(opline,opline->op1));ZEND_VM_CONTINUE();}JMPZ只是多了一个判断,根据结果选择是否跳转,不再重复它在这里列出。循环的处理方式和判断基本类似。ASSIGN!0,31>FE_RESET_R$3!0,->52>>FE_FETCH_R$3,!1,->543>ECHO!14>JMP->25>FE_FREE$356>RETURN1个循环只需要JMP指令即可完成,通过FE_FETCH_R指令判断是否到达数组末尾,到达则退出循环。结束语通过了解Zend虚拟机,相信您会对PHP的工作原理有更深入的了解。想着我们写的那一行行代码,当***机执行的时候,会变成无数条指令,每条指令都是基于复杂的处理逻辑。那些以前随便写的代码会不会在脑海里不知不觉的转换成OPCODE再去尝尝?