PHP內核分析:Zend虛擬機
PHP 是一門解釋型的語言。諸如 Java、Python、Ruby、Javascript 等解釋型語言,我們編寫的代碼不會被編譯成機器碼運行,而是會被編譯中間碼運行在虛擬機(VM)上。運行 PHP 的虛擬機,稱之為 Zend 虛擬機,今天我們將深入內核,探究 Zend 虛擬機運行的原理。
OPCODE
什么是 OPCODE?它是一種虛擬機能夠識別并處理的指令。Zend 虛擬機包含了一系列的 OPCODE,通過 OPCODE 虛擬機能夠做很多事情,列舉幾個 OPCODE 的例子:
ZEND_ADD
將兩個操作數相加。ZEND_NEW
創建一個 PHP 對象。ZEND_ECHO
將內容輸出到標準輸出中。ZEND_EXIT
退出 PHP。
諸如此類的操作,PHP 定義了186個(隨著 PHP 的更新,肯定會支持更多種類的 OPCODE),所有的 OPCODE 的定義和實現都可以在源碼的 zend/zend_vm_def.h
文件(這個文件的內容并不是原生的 C 代碼,而是一個模板,后面會說明原因)中查閱到。
我們來看下 PHP 是如何設計 OPCODE 數據結構:
struct _zend_op {
const void *handler;
znode_op op1;
znode_op op2;
znode_op result;
uint32_t extended_value;
uint32_t lineno;
zend_uchar opcode;
zend_uchar op1_type;
zend_uchar op2_type;
zend_uchar result_type;
};
仔細觀察 OPCODE 的數據結構,是不是能找到匯編語言的感覺。每一個 OPCODE 都包含兩個操作數,op1
和 op2
,handler
指針則指向了執行該 OPCODE 操作的函數,函數處理后的結果,會被保存在 result
中。
我們舉一個簡單的例子:
<?php
$b = 1;
$a = $b + 2;
我們通過 vld 擴展看到,經過編譯的后,上面的代碼生成了 ZEND_ADD 指令的 OPCODE。
compiled vars: !0 = $b, !1 = $a
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
2 0 E > ASSIGN !0, 1
3 1 ADD ~3 !0, 2
2 ASSIGN !1, ~3
8 3 > RETURN 1
其中,第二行是 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_OPLINE
zend_free_op free_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();
} else if (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();
}
} else if (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 {
/* Common elements */
zend_uchar type;
zend_uchar arg_flags[3]; /* bitset of arg_info.pass_by_reference */
uint32_t fn_flags;
zend_string *function_name;
zend_class_entry *scope;
zend_function *prototype;
uint32_t num_args;
uint32_t required_num_args;
zend_arg_info *arg_info;
/* END of common elements */
uint32_t *refcount;
uint32_t last;
zend_op *opcodes;
int last_var;
uint32_t T;
zend_string **vars;
int last_live_range;
int last_try_catch;
zend_live_range *live_range;
zend_try_catch_element *try_catch_array;
/* static variables support */
HashTable *static_variables;
zend_string *filename;
uint32_t line_start;
uint32_t line_end;
zend_string *doc_comment;
uint32_t early_binding; /* the linked list of delayed declarations */
int last_literal;
zval *literals;
int cache_size;
void **run_time_cache;
void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};
內容超多對吧?簡單的理解,其本質就是一個 OPCODE 數組外加執行過程中所需要的環境數據的集合。介紹幾個相對來說比較重要的字段:
opcodes
存放 OPCODE 的數組。filename
當前執行的腳本的文件名。function_name
當前執行的方法名稱。static_variables
靜態變量列表。last_try_catch
try_catch_array
當前上下文中,如果出現異常 try-catch-finally 跳轉所需的信息。literals
所有諸如字符串 foo 或者數字23,這樣的常量字面量集合。
為什么需要生成這樣龐大的數據?因為編譯時期生成的信息越多,執行時期所需要的時間就越少。
接下來,我們看下 PHP 是如何執行 OPCODE。OPCODE 的執行被放在一個大循環中,這個循環位于 zend/zend_vm_execute.h
中的 execute_ex
函數:
ZEND_API void execute_ex(zend_execute_data *ex) {
DCL_OPLINE
zend_execute_data *execute_data = ex;
LOAD_OPLINE();
ZEND_VM_LOOP_INTERRUPT_CHECK();
while (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, "Arrived at end of main loop which shouldn't happen");
}
這里,我去掉了一些環境變量判斷分支,保留了運行的主流程??梢钥吹?,在一個***循環中,虛擬機會不斷調用 OPCODE 指定的 handler
函數處理指令集,直到某次指令處理的結果 ret
小于0。注意到,在主流程中并沒有移動 OPCODE 數組的當前指針,而是把這個過程放到指令執行的具體函數的結尾。所以,我們在大多數 OPCODE 的實現函數的末尾,都能看到調用這個宏:
ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
在之前那個簡單例子中,我們看到 vld 打印出的執行 OPCODE 數組中,***有一項指令為 ZEND_RETURN
的 OPCODE。但我們編寫的 PHP 代碼中并沒有這樣的語句。在編譯時期,虛擬機會自動將這個指令加到 OPCODE 數組的結尾。ZEND_RETURN
指令對應的函數會返回 -1,判斷執行的結果小于0時,就會退出循環,從而結束程序的運行。
方法調用
如果我們調用一個自定義的函數,虛擬機會如何處理呢?
<?php
function foo() {
echo 'test';
}
foo();
我們通過 vld 查看生成的 OPCODE。出現了兩個 OPCODE 指令執行棧,是因為我們自定義了一個 PHP 函數。在***個執行棧上,調用自定義函數會執行兩個 OPCODE 指令:INIT_FCALL
和 DO_FCALL
。
compiled vars: none
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
2 0 E > NOP
6 1 INIT_FCALL 'foo'
2 DO_FCALL 0
3 > RETURN 1
compiled vars: none
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
3 0 E > ECHO 'test'
4 1 > RETURN null
其中,INIT_FCALL
準備了執行函數時所需要的上下文數據。DO_FCALL
負責執行函數。DO_FCALL
的處理函數根據不同的調用情況處理了大量邏輯,我摘取了其中執行用戶定義的函數的邏輯部分:
ZEND_VM_HANDLER(60, ZEND_DO_FCALL, ANY, ANY, SPEC(RETVAL))
{
USE_OPLINE
zend_execute_data *call = EX(call);
zend_function *fbc = call->func;
zend_object *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(call);
}
}
...
ZEND_VM_SET_OPCODE(opline + 1);
ZEND_VM_CONTINUE();
}
可以看到,DO_FCALL
首先將調用函數前的上下文數據保存到 call->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 實現的呢?
<?php
$a = 10;
if ($a == 10) {
echo 'success';
} else {
echo 'failure';
}
我們還是通過 vld 查看 OPCODE(不得不說 vld 擴展是分析 PHP 的神器)。
compiled vars: !0 = $a
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
2 0 E > ASSIGN !0, 10
3 1 IS_EQUAL ~2 !0, 10
2 > JMPZ ~2, ->5
4 3 > ECHO 'success'
4 > JMP ->6
6 5 > ECHO 'failure'
7 6 > > RETURN 1
我們看到,JMPZ
和 JMP
控制了執行流程。JMP
的邏輯非常簡單,將當前的 OPCODE 指針指向需要跳轉的 OPCODE。
ZEND_VM_HANDLER(42, ZEND_JMP, JMP_ADDR, ANY)
{
USE_OPLINE ZEND_VM_SET_OPCODE(OP_JMP_ADDR(opline, opline->op1));
ZEND_VM_CONTINUE();
}
JMPZ
僅僅是多了一次判斷,根據結果選擇是否跳轉,這里就不再重復列舉了。而處理循環的方式與判斷基本上是類似的。
<?php
$a = [1, 2, 3];
foreach ($a as $n) {
echo $n;
}
compiled vars: !0 = $a, !1 = $n
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
2 0 E > ASSIGN !0, <array>
3 1 > FE_RESET_R $3 !0, ->5
2 > > FE_FETCH_R $3, !1, ->5
4 3 > ECHO !1
4 > JMP ->2
5 > FE_FREE $3
5 6 > RETURN 1
循環只需要 JMP
指令即可完成,通過 FE_FETCH_R
指令判斷是否已經到達數組的結尾,如果到達則退出循環。
結語
通過了解 Zend 虛擬機,相信你對 PHP 是如何運行的,會有更深刻的理解。想到我們寫的一行行代碼,***機器執行的時候會變成數不勝數的指令,每個指令又建立在復雜的處理邏輯之上。那些從前隨意寫下的代碼,現在會不會在腦海里不自覺的轉換成 OPCODE 再品味一番呢?