PHP7 與 PHP5 之最大的不同,在於核心結構的 zval 大改版,連帶的 zval 取值所有 macro 也都一起改變了,以至於 PHP5 可使用的 Extension 需整個改寫,才可以在 PHP7 上正確執行,而無法單純只使用 C Macro 來向後相容。
筆者最近在研究 php7 的 getter method 優化,因此稍微更進一步的研究了 Zend Engine 的運作與修改
PHP 腳本的執行很簡單,就是剖析、編譯、執行。 編譯,就是從剖析出來的 AST 產生 Opcode,執行,就是從 Opcode 來解譯命令。
php 的原碼結構中,目錄 Zend/ 為 Zend Engine 的核心程式,其 zend_execute.* 為執行階段相關代碼、zend_compile.* 為編譯階段代碼、zend_ast.* 為剖析階段代碼。
Zend Op Array — Zend Engine 中的編譯單位
一段簡單的 PHP 腳本,經過 Joe Watkins 開發的 explain 工具,顯示出來的 Opcode 大概是這樣:
PHP 核心中,儲存 opcode 的容器是 zend_op_array struct,這個結構中被共用於各種不同的編譯單位,如: file, function, method, closure .. 等等。
舉例來說,在執行 Function 時,執行的是 Function 的 zend_op_array,在執行腳本時,執行的是 file 的 zend_op_array。
上圖中,可以看到有許多 ZEND_EXT_* 開頭的 opcode,這些是 compiler_options 的 extended_info 被啟用時,xdebug 或 xhprof 等 extension 可用以計算執行時間所使用的 opcode,若無使用 xdebug, xhprof 等 zend_extension,則不會出現這些 opcodes。
zend_compile.h 標頭檔中定義的 op_array 結構如下:
由於結構大,所以這邊就只先顯示最主要的幾種。
其中:
- type 定義了這個 op_array 的類型,可以是 ZEND_INTERNAL_FUNCTION (zend extension 中定義函式)、ZEND_USER_FUNCTION (使用者定義的函式)
- arg_flags 為定義參數相關資訊,譬如: 是否為 pass_by_reference
- fn_flags 為 op_array 為 function 時,定義函式的屬性,譬如 Public, Protected, Abstract … 等等
- function_name 為 op_array 為 function 時,定義該函式名稱。
- scope 在不同的語境下有不同的意義,在 method 中,scope 就是該 method 所屬 class 的 zend_class_entry,zend_class_entry 定義了類別所有的資訊。
- this_var 則為當下的 instance 變數,該變數使用 uint32 來表示主要是盡可能的讓 op_array 夠緊實~ 在執行期間需要透過 EX_VAR() 巨集,來取得實際的 zval 結構。
- num_args 為函式的參數數量。
- required_num_args 為函式的最小參數數量。
- literals 這個欄位指向一個 zval 型別的記憶體位址,存放的是剖析時期的資訊,譬如: variable names, constant names … 等等。
Zend Op — 運算子及運算元
一個 zend_op_array 又可包含許多個 opcodes,每個 opcode 都是被存在一個 zend_op_array 中型別為 zend_op* 的 opcodes 陣列中,在 zend_compile.c 或 zend_opcodes.c 中,每個 opcode 的單位通常又被叫做 opline。
每一個型別為 zend_op* 的 opcode 的結構都相當簡單,zend_compile.h 所定義的 zend_op struct 如下:
雖然看起來很多欄位,但最主要只有四種類型,一種是定義要做什麼 (opcode, handler) ,運算子1 operand (op1, op1_type) ,運算子2 operand (op2, op2_type) 以及運算結果 (result, result_type)
opcode 欄位,型別為 zend_uchar,這是一個常數,通常 opcode 可使用下方語法判斷:
如果 opcode 的資訊不夠使用,extended_value 這個欄位會在編譯時期,給予更多資訊,讓解譯器在執行的時候,可以分辨不同的條件。
以加法運算來說,opcode 是 ZEND_ADD,然後再依照運算子的類型,有不同的處理函式 (Handler)
運算子的類型大概有以下幾種:
- IS_CONST: 是指常數,不會在動態時期被修改的數值,如數字 123, 字串 “string” 或者浮點數: 1.234 等等,這邊所說的常數,不是指變數內的常數,而是如 $foo->getNumber() 其中的 method call “getNumber” 就是 IS_CONST,又或者 $a + 3 ,其中的 3 也是 IS_CONST
- IS_CV: 是指 PHP 腳本中所定義的變數,如: $var, $foo, $bar 等等。在 opcode 表示法中,通常以 “!” 表示。
- IS_TMP_VAR: 是指 PHP 腳本中未定義,但在運算過程中,需要暫時儲存的變數,通常為 non-assignment 且 non-terminal 的表達式使用,這些變數不會被共用,也因此不被 reference counting 保護。在 opcode 表示法中,通常以 “~” 符號表示。
- IS_VAR: 通常是在 ZEND_FETCH_(R|W|RW) 或者有 assignment ,通常是跟真實的變數緊密連結,也因此 reference counting 必須被考慮。 在 opcode 表示法中,通常以 “$” 符號表示。
- IS_UNUSED: 代表在這個 opline 中,這個 operand 沒有被使用到。
op_array 與 zend_op 結構中,不會儲存實際執行用的變數,這兩個結構只會儲存剖析完的資料,以及變數在變數陣列中的 array index。也因此,如果你使用 vld 這類的 extension 來將 oplines 印出來,你會發現 op1 可能是 $1, ~3 之類的代號,而不是實際的數據。
舉例來說,剖析完的語意相關資料,會被儲存在 op_array 裡頭的 literals field,要存取變數名稱、常數名稱都是使用 index 去存取,譬如:
執行時期可使用 EX_LITERALS() 來取得 literals,而編譯的時候,Zend Engine 透過如下代碼來對 Constant 做查找快取:
Z_CACHE_SLOT(op_array->literals[opline->op2.constant]) = -1
Opcode Handler — Opcode 處理函式
由於 opcode 可對應不同種類的運算子,因此在 opcode handler 中也要針對不同的運算子處理。 zend_vm_execute.h 這個標頭檔,定義了所有 opcode 對應的處理函式。
舉例來說 ZEND_ADD 可能會搭配 CV , CV 或 CV, CONST 做運算,在 zend_vm_execute.h 裡面,就會有 …ZEND_ADD_SPEC_CV_CV.. 這樣的函式做對應。
而為了要讓 opcode handler 的處理邏輯可以寫在一起,方便開發維護,因此 zend_vm_execute.h 是透過以 PHP 撰寫的 Zend/zend_gen_vm.php 程式,從 zend_vm_def.h 標頭檔產生出來的。
也因此,只要修改了 zend_vm_def.h ,就得執行一次 php Zend/zend_gen_vm.php 將 Zend/zend_vm_execute.h 做更新。
目前在 Zend Engine 所定義的 opcode 大約有 184 種,可參見 Zend/zend_vm_opcodes.h 標頭檔所定義的 opcode 對應的 constants:
編譯時期 (Compile-Time) 執行時期 (Run-time) 所調用的巨集
Zend Engine 中,不同時期所調用的巨集名稱,基本上都是有規則的。 譬如
- RT — Run-Time
- CT — Compile-time 或 Constant-time
- EX — 目前的 execution_data
- EG — Executor Globals
- CG — Compiler Globals
這些定義都可以在 Zend/zend_compile.h , Zend/zend_execute.h, Zend/zend_globals_macros.h 裡找到。
而在 zend_op 結構內
可以看到 ZEND_USE_ABS_CONST_ADDR 這個巨集檢查,當 PHP 是在 32 位元的機器上編譯時,使用 absolute address 做 jump targets,若在 64 位元,則使用 32bit 的 relative offset 做計算。
也因此,新增的 EX_CONTANTS() 巨集,通過不同的編譯條件,讓 VM Handler 可以更快的取到數值。 而在編譯時期,則採用 CT_CONSTANT() 巨集來取得數據。
關於執行時期的解析,下集待續。