VLD(Vulcan Logic Dumper)的简介如下:
The Vulcan Logic Dumper hooks into the Zend Engine and dumps all the opcodes (execution units) of a script. It can be used to see what is going on in the Zend Engine.
之前的文章 PHP解释器引擎执行流程 结尾处提到了VLD的原理,此扩展利用PHP对扩展模块提供的请求初始化钩子函数(PHP_RINIT_FUNCTION),在每此请求到来的时候将默认的编译函数指针zend_compile_file和执行函数指针zend_execute指向自己定义的vld_compile_file函数和vld_execute函数,这两个函数中,对原函数进行了封装,原编译函数能返回一个op_array的指针,所以在新的编译函数中可以截获这个op_array的指针,然后输出相关opcode信息。
关于PHP扩展模块的安装这里就不介绍了,网络上很多相关资料。
那么让我们看看这个扩展安装后的实际效果,以下为一个非常简单的PHP脚本,test.php:
<?php $a = "Hello world"; echo $a; ?>
在命令行下执行该脚本:
php -dvld.active=1 test.php
于是可以看到vld输出的内容:
希望看到跟详细的内容可以用以下方式:
php -dvld.active=1 -dvld.verbosity=3 test.php
这里简单的说说输出内容的含义:
这段代码一共有3个op分别是:
1:ASSIGN // #define ZEND_ASSIGN 38
2:ECHO // #define ZEND_ECHO 40
3:RETURN // #define ZEND_RETURN 62
第1个op ASSIGN的操作句柄是将OP2的值赋值给OP1,对应的就是$a = "Hello world"这句代码,那么OP2就是"Hello world"的,OP1应该就是$a,但是实际上输出的内容中显示的是!0,实际上$a属于编译后的变量,!0就代表了$a,可以在输出op list的上一行看到
compiled vars: !0 = $a
这样的优化可以避免每次查找变量$a都在变量符号表中去检索,起到一定的缓存的作用。在这条op执行结束之后,!0的值就等于"Hello world"了。
第2个op ECHO的操作句柄是将 OP1的内容送到标准输出,对应的就是echo $a这句代码,这样就把"Hello world"输出到终端了
第3个op RETURN 是在每个PHP文件结尾都会自动加上的,它的操作句柄是将OP1的常量值返回
这样我们就能很清晰的知道一段PHP代码会得到什么样的OP code,vld真的是一个不错的分析工具。
也许有人会问,你怎么知道每个op对应的执行句柄是什么呢,vld能输出这些信息吗?非常可惜,vld不能帮助我们输出OP对应的执行句柄信息。在默认以CALL方式执行op的模式下,每个op对应的handler都是一个函数,vld中截获的op中有这些handler的指针,但是无法通过这些指针知道相应的函数名,c语言没有一些更高级的语言那样的反射特性。所以如果想知道每个op对应的handler,就需要另外想办法了,目前为止,我只发现了两种方法可以得到这些信息。下面简单的介绍这两种方法。
方法一:
在之前的文章 PHP代码如何执行?中介绍过,op的handler都定义在{PHPSRC}/Zend/zend_vm_execute.h中,这是一个由PHP生成的极大的c源文件,其中有每个handler的函数定义以及op映射到handler的算法,在zend_init_opcodes_handlers函数中,初始化一个 static const opcode_handler_t labels[]数组,这个 labels数组就是handlers的一张表,这个表有近4000个项,每个项都是一个handler的函数指针,当然有大量的NULL指针,还有一些重复的指针。如果我们能有一个跟labels数组对应的数组handler_names,数组中的每一个项对应的是labels中相应项中函数指针的函数名,那么我们就可以通过现有的op到handler的映射算法从handler_names中得到该op的handler的函数名。但是事情没有想象的那么容易,我们如何正确生成这个拥有4000个项的数组handler_names,答案就在{PHPSRC}/Zend/zend_vm_gen.php,这个PHP文件是用来生成{PHPSRC}/Zend/zend_vm_execute.h,可以在其中找到生成labels数组的部分,只要添加相关代码通过类似方式生成handler_names数组就可以了。有兴趣的读者可以尝试生成这个handler_names数组文件,然后编译到vld扩展中,在输出op list的时候把每个op执行的句柄函数名也一并输出。
方法二:
此方法是我目前经常用到的,相对来说比较方便,还是在{PHPSRC}/Zend/zend_vm_gen.php这个文件里面想办法。这个文件会生成每个op的handler,所以如果想办法在每个handler函数的代码中输出该handler名字,那么就知道哪些handler被调用。这个并不太难,在zend_vm_gen.php第380行左右可以看到类似以下PHP代码:
if (0 && strpos($code, '{') === 0) {
...
}
实际上这个条件中的代码就是在每个handler开始的一行中输出内容,但是因为条件永远无法满足,所以实际条件中的代码无法执行,可以将if中的条件改成true,然后大括号输出函数的名字就可以了,具体的代码如下:
if (1) { $name = $name.($spec?"_SPEC":"").$prefix[$op1].$prefix[$op2]."_HANDLER"; $code = "{/n/tfprintf(stderr, /"$name//n/");/n" . substr($code, 1); }
代码具体的原理就不介绍了。在修改好zend_vm_gen.php之后,在命令行下执行该脚本,就会生成一个新的zend_vm_execute.h( 同时会生成zend_vm_opcodes.h),打开zend_vm_execute.h文件,可以看到很多函数开头都多出了这么一句:
fprintf(stderr, "ZEND_***/n");
这样每个函数开始执行的时候就会把自己的名字输出到标准错误。下面的工作,就是重新编译Zend/zend_execute.lo,然后重新链接sapi/cli/php,如果你不知道如何单独完成这些操作,那么也可以更暴力一点重新安装整个PHP,需要注意的是修改后的PHP千万不要用在正式环境,因为会输出一大量不需要的信息,自己单独为试验安装一个PHP吧。 另外这个方法也会输出一些非直接的hanlder的函数名,有可能一个handler会调用另外一个函数,这样可能会输出这个handler的名字和那个被调用的函数的名字,所以实际输出的函数名字会多于op的数量。
我们用方法二来查看前面的test.php的op handler的名字,直接用修改后的php 执行test.php得到以下内容:
ZEND_ASSIGN_SPEC_CV_CONST_HANDLER
ZEND_ECHO_SPEC_CV_HANDLER
Hello worldZEND_RETURN_SPEC_CONST_HANDLER
zend_leave_helper_SPEC_HANDLER
可以看到一共输出了4个函数的名字,其中ZEND_ASSIGN_SPEC_CV_CONST_HANDLER函数就是ASSIGN的handler,ZEND_ECHO_SPEC_CV_HANDLER就是ECHO的handler,ZEND_RETURN_SPEC_CONST_HANDLER是RETURN的handler,这个handler会调用zend_leave_helper_SPEC_HANDLER函数,所以会输出4个函数的名字,知道了这些函数的名字,我们就能在zend_vm_execute.h中去找到其具体定义,这样就知道每个op到底是怎么在执行了。