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<br />$a = "Hello world";<br />echo $a;<br />?>
在命令列下執行該指令碼:
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) {<br />$name = $name.($spec?"_SPEC":"").$prefix[$op1].$prefix[$op2]."_HANDLER";<br />$code = "{/n/tfprintf(stderr, /"$name//n/");/n" . substr($code, 1);<br />}
代碼具體的原理就不介紹了。在修改好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到底是怎麼在執行了。