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_FC<a href="http://www.php.cn/wiki/1483.html" target="_blank">ALL</a>
和 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 再品味一番呢?
以上是PHP核心分析-Zend虛擬機器詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!

PHPSession失效的原因包括配置錯誤、Cookie問題和Session過期。 1.配置錯誤:檢查並設置正確的session.save_path。 2.Cookie問題:確保Cookie設置正確。 3.Session過期:調整session.gc_maxlifetime值以延長會話時間。

在PHP中調試會話問題的方法包括:1.檢查會話是否正確啟動;2.驗證會話ID的傳遞;3.檢查會話數據的存儲和讀取;4.查看服務器配置。通過輸出會話ID和數據、查看會話文件內容等方法,可以有效診斷和解決會話相關的問題。

多次調用session_start()會導致警告信息和可能的數據覆蓋。 1)PHP會發出警告,提示session已啟動。 2)可能導致session數據意外覆蓋。 3)使用session_status()檢查session狀態,避免重複調用。

在PHP中配置會話生命週期可以通過設置session.gc_maxlifetime和session.cookie_lifetime來實現。 1)session.gc_maxlifetime控制服務器端會話數據的存活時間,2)session.cookie_lifetime控制客戶端cookie的生命週期,設置為0時cookie在瀏覽器關閉時過期。

使用數據庫存儲會話的主要優勢包括持久性、可擴展性和安全性。 1.持久性:即使服務器重啟,會話數據也能保持不變。 2.可擴展性:適用於分佈式系統,確保會話數據在多服務器間同步。 3.安全性:數據庫提供加密存儲,保護敏感信息。

在PHP中實現自定義會話處理可以通過實現SessionHandlerInterface接口來完成。具體步驟包括:1)創建實現SessionHandlerInterface的類,如CustomSessionHandler;2)重寫接口中的方法(如open,close,read,write,destroy,gc)來定義會話數據的生命週期和存儲方式;3)在PHP腳本中註冊自定義會話處理器並啟動會話。這樣可以將數據存儲在MySQL、Redis等介質中,提升性能、安全性和可擴展性。

SessionID是網絡應用程序中用來跟踪用戶會話狀態的機制。 1.它是一個隨機生成的字符串,用於在用戶與服務器之間的多次交互中保持用戶的身份信息。 2.服務器生成並通過cookie或URL參數發送給客戶端,幫助在用戶的多次請求中識別和關聯這些請求。 3.生成通常使用隨機算法保證唯一性和不可預測性。 4.在實際開發中,可以使用內存數據庫如Redis來存儲session數據,提升性能和安全性。

在無狀態環境如API中管理會話可以通過使用JWT或cookies來實現。 1.JWT適合無狀態和可擴展性,但大數據時體積大。 2.Cookies更傳統且易實現,但需謹慎配置以確保安全性。


熱AI工具

Undresser.AI Undress
人工智慧驅動的應用程序,用於創建逼真的裸體照片

AI Clothes Remover
用於從照片中去除衣服的線上人工智慧工具。

Undress AI Tool
免費脫衣圖片

Clothoff.io
AI脫衣器

Video Face Swap
使用我們完全免費的人工智慧換臉工具,輕鬆在任何影片中換臉!

熱門文章

熱工具

SublimeText3漢化版
中文版,非常好用

SAP NetWeaver Server Adapter for Eclipse
將Eclipse與SAP NetWeaver應用伺服器整合。

WebStorm Mac版
好用的JavaScript開發工具

SublimeText3 Linux新版
SublimeText3 Linux最新版

MinGW - Minimalist GNU for Windows
這個專案正在遷移到osdn.net/projects/mingw的過程中,你可以繼續在那裡關注我們。 MinGW:GNU編譯器集合(GCC)的本機Windows移植版本,可自由分發的導入函式庫和用於建置本機Windows應用程式的頭檔;包括對MSVC執行時間的擴展,以支援C99功能。 MinGW的所有軟體都可以在64位元Windows平台上運作。