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 = GET_OP1_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 再品味一番呢?
相關文章
- 【PHP7原始碼分析】PHP7原始碼研究之淺談Zend虛擬機器PHP原始碼虛擬機
- PHP 核心分析(2):ZTS和zend_tryPHP
- Dalvik虛擬機器、Java虛擬機器與ART虛擬機器虛擬機Java
- java虛擬機器和Dalvik虛擬機器Java虛擬機
- Android 虛擬機器 Vs Java 虛擬機器Android虛擬機Java
- 虛擬機器虛擬機
- 連線虛擬機器oracle 和虛擬機器KEY虛擬機Oracle
- 虛擬機器(三)虛擬機器配置靜態Ip虛擬機
- PD虛擬機器 18 for Mac(Mac虛擬機器軟體)虛擬機Mac
- [php]apache虛擬主機配置PHPApache
- JVM 虛擬機器JVM虛擬機
- JVM虛擬機器JVM虛擬機
- Neo 虛擬機器虛擬機
- VMware虛擬機器虛擬機
- 虛擬機器arm虛擬環境搭建虛擬機
- LUA指令碼虛擬機器逃逸技術分析指令碼虛擬機
- JVM虛擬機器Class類檔案研究分析JVM虛擬機
- VMware虛擬機器如何設定使主機和虛擬機器不同IP虛擬機
- 深入理解虛擬機器之虛擬機器類載入機制虛擬機
- 虛擬機器突然沒網了,虛擬機器突然滑鼠失靈虛擬機
- vmware克隆虛擬機器centos6.5,虛擬機器從新配置虛擬機CentOS
- 從ubuntu虛擬機器外部訪問虛擬機器內部資訊Ubuntu虛擬機
- 【虛擬機器】Windows(x86)上部署ARM虛擬機器(Ubuntu)虛擬機WindowsUbuntu
- xen安裝半虛擬化虛擬機器虛擬機
- 虛擬機器可以ping同宿主機,宿主機ping不通虛擬機器虛擬機
- 虛擬主機php.ini在哪PHP
- JVM 虛擬機器 GCJVM虛擬機GC
- Java虛擬機器(JVM)Java虛擬機JVM
- 虛擬機器的概念虛擬機
- Python 虛擬機器Python虛擬機
- mac 裝虛擬機器Mac虛擬機
- 虛擬機器的克隆虛擬機
- kvm虛擬機器快照虛擬機
- 求助,虛擬機器崩了。。虛擬機
- Java虛擬機器(轉)Java虛擬機
- 安裝虛擬機器虛擬機
- 虛擬機器的搭建虛擬機
- 用Jcmd命令分析虛擬機器Metaspace元空間虛擬機