1. 程式人生 > >深入理解Zend執行引擎

深入理解Zend執行引擎

PHP:一種解釋型語言

PHP經常會被定義為“指令碼語言”或者是“解釋型語言”,什麼是“解釋型語言”呢?

所謂“解釋型語言”就是指用這種語言寫的程式不會被直接編譯為本地機器語言(native machine language),而是會被編譯為一種中間形式(程式碼),很顯然這種中間形式不可能直接在CPU上執行(因為CPU只能執行本地機器指令),但是這種中間形式可以在使用本地機器指令(如今大多是使用C語言)編寫的軟體上執行。

這個軟體就是所謂的軟體虛擬機器(software virtual machine)。我們先看下Wikipedia上對軟體虛擬機器的定義:

(…)程序虛擬機器(process virtual machine)通過提供一個抽象的平臺獨立的程式執行環境來執行某種計算機程式。程序VM(譯註:VM是virtual machine的縮寫)有時候也被稱為應用程式虛擬機器,或者是可管理執行環境(Manged Runtime Environment,簡稱MRE),它會以一個普通應用的形式執行在宿主作業系統中(host OS),在執行中,他是宿主作業系統中一個單獨的程序。在這個程序啟動時,VM會被建立,退出時則會被銷燬。它的目的是提供一種平臺獨立的程式執行環境,它可以對底層硬體或作業系統進行抽象處理,從而保證應用程式可以在任何平臺上以一致的行為執行。

跟任何解釋型語言一樣,PHP語言也被設計為一種可以跨平臺執行抽象指令的程式,儘可能地抽離掉底層作業系統的細節。這是技術上的解釋。在功能應用上,PHP的主要領域是Web應用(PHP的目的是解決Web應用的相關問題)。

當前市面上還有其他一些基於軟體虛擬機器的語言(不完全列表):Java、Python、C#、Ruby、Pascal、Lua、Perl、Javascript等等。基本上使用這些語言編寫的程式都不會被直接編譯為本地機器指令,這些程式都是執行在一個軟體虛擬機器上。為了效能考慮,有些軟體虛擬機器可以把部分(並非全部)語言的中間程式碼直接轉換為機器指令來執行:這個過程被稱為“JIT編譯”。在我寫這篇文章時PHP並未使用JIT編譯技術,不過有些實驗性的工作正在進行,PHP社群也經常會提及和探討這個話題。(譯註:這篇文章寫於15年2月份,此時HHVM應該已經發布,而HHVM就使用了JIT,另外最新版的PHP7貌似也使用了JIT)

軟體虛擬機器之所以如此流行,主要是因為我們不想為了在螢幕上輸出一個“Hello”而寫幾千行的C程式碼(譯註:這個有點誇張了吧,不過考慮到平臺相關性,使用C寫程式確實要考慮更多,至少是需要在不同的機器上編譯所寫的程式,但為了輸出一個“hello”寫幾千行的程式碼就太誇張了,因為輸出“hello”的主要工作都是由C執行庫提供的函式完成的,儘管不同平臺執行庫的程式碼肯定有差別,但介面還是基本上完全一致的)。使用軟體虛擬機器相對於基於本地平臺的程式開發有如下優點:

  • 使用簡單,便於開發
  • 基本上都支援自動記憶體管理
  • 支援抽象目標資料型別,沒必要進行底層的數學運算(譯註:這個應該指的是地址運算,這個在C中和彙編程式設計中都非常常見),沒必要因為切換目標硬體平臺而重新編寫程式碼

當然也有些不足之處:

  • 不可能精確地管理記憶體或全域性資源的使用(唯有信任VM)
  • 執行速度無法比擬本地機器程式碼:完成相同的任務需要更多的CPU週期(JIT就是用於減小這個差距的,但不可能消除)
  • 可能會抽象很多東西,通常程式設計師會遠離硬體,從而無法全面理解程式碼的確切影響,特別是在負載過大的情況下

最後一條就是我寫這篇文章的目的。隨著時間的推移,我越來越注意到一個事實:越來越少的程式設計師能夠確切地掌握他們所寫的程式碼對硬體和網路的影響,在我個人看來這並非是什麼好現象。這就像某個人把兩根電線連在一起,然後雙手合十祈禱整個系統不要掛掉。當然我的意思並不是希望大家能夠掌握整個鏈條上的所有東西,這不是人力可為的,我只是希望大家至少清楚我們所談論的這些東西的深層含義。

所以我會盡力向你展示PHP對你所寫的程式碼做了什麼。當你掌握了這些知識後,你可以從舉一反三,把這些知識應用到任何其他“解釋型”語言上,因為它們在設計上跟PHP的差別不大,它們有很多共通的概念。通常而言,你在學習其他的解釋型語言的過程中,你會發現它們之間的主要區別只是是否使用了JIT,或者是否可以並行執行(主要是使用執行緒,PHP沒有提供任何並行技術),其他的差別可能就是記憶體池(memory pooling)和垃圾回收演算法上的不同了。

Zend軟體虛擬機器

PHP使用主要虛擬機器(Zend虛擬機器,譯註:HHVM也是一種執行PHP程式碼的虛擬機器,但很顯然Zend虛擬機器還是目前的主流)可以分為兩大部分,它們是緊密相連的:

  • 編譯棧(compile stack):識別PHP語言指令,把它們轉換為中間形式
  • 執行棧(execution stack):獲取中間形式的程式碼指令並在引擎上執行,引擎是用C或者彙編編寫成的

這篇文章不會談論第一部分,而會專注於Zend虛擬機器的executor(譯註:executor可以翻譯為執行器,但似乎鮮有這種說法,所以後面直接使用這個英文單詞,而不作翻譯),executor是一個很意思的軟體,它具有高度優化、跨平臺、執行時hookable(譯註:這個詞不好翻譯,大概意思就是可以通過設定某種鉤子獲取執行時資訊,像VLD這種擴充套件就是這麼實現的)等優點,在技術上非常有挑戰性。它包含幾千行C程式碼,每次新發布的PHP版本都會對它做部分重寫。

我們在這篇文章中以PHP5.6為例來講解。

首先我必須得承認關於這個話題有太多可討論的東西,以至於我不知道從哪裡開始,也不知道該給大家展示哪些知識,以怎樣的順序來展示。這種情況在我寫之前的文章時並不常見,但我也不打算把這個話題分成幾篇文章來講述,因為所有這些部分都是緊密相連的。另外需要提醒大家的時在沒有編譯器相關知識的前提下,也可以非常好地理解executor,儘管它們倆是緊密相連的;當我把巨大的executor分解為多個部分逐漸呈現在你眼前後,你就可以理解它的每一個概念。當然,完全理解executor並非易事,需要一些時間。

所以也希望你能夠意識到,如果你不瞭解PHP編譯器是怎麼工作的,這不會影響你學習和理解executor。也許以後我會再寫一篇PHP編譯器方面的文章。

好了,我們開始吧。

OPCode

如果你瞭解PHP內部機制方面的知識,或者是看過我之前的Blog,你也許已經很多次見到過這個詞。我們還是先看下Wikipedia上的解釋:

OPCode也會出現在所謂的位元組碼(byte codes)中,或者是被軟體直譯器(而不是硬體裝置)解釋執行的指令的其他形式中。這些軟體指令集通常會提供一些比對應的硬體指令集更高階(higher-level)的資料型別和操作,儘管它們每個指令執行的結果都是差不多的。

ByteCode和OPCode其實是兩個含義不同的詞,但我們經常會把它們當作同一個意思來互動使用。

我們假設Zend VM的一個OPCode對應虛擬機器的一個底層操作。Zend虛擬機器有很多OPCode:它們可以做很多事情。隨著PHP的發展,也引入了越來越多的OPCode,這都是源於PHP可以做越來越多的事情。你可以在PHP的原始碼檔案Zend/zend_vm_opcodes.h中看到所有的OPCode。

通常而言,OPCode的名稱是自描述的,例如:

  • ZEND_ADD :執行兩個運算元的算術加法運算
  • ZEND_NEW :建立一個物件(一個PHP物件)
  • ZEND_EXIT :退出PHP執行
  • ZEND_FETCH_DIM_W : 取一個運算元在某個維度(dimension)下的值,然後執行寫入操作(譯註:這裡的“維度”指的一維陣列,二維陣列的“維度”,給陣列中的某個元素賦值,或者是給字串所在某個位置的字元賦值都會用到這個OPCode)
  • 等等

PHP5.6有167個OPCode。因此我們可以說PHP5.6的虛擬機器的executor可以執行167種不同的(計算)操作。

PHP內部使用zend_op這個結構體來表示OPCode:

struct _zend_op {
    opcode_handler_t handler;   /* The true C function to run */
    znode_op op1; /* operand 1 */
    znode_op op2; /* operand 2 */
    znode_op result; /* result */
    ulong extended_value; /* additionnal little piece of information */
    uint lineno;
    zend_uchar opcode; /* opcode number */
    zend_uchar op1_type; /* operand 1 type */
    zend_uchar op2_type; /* operand 2 type */
    zend_uchar result_type; /* result type */
};

你可以通過設想一個簡單的計算器來理解OPCode(嚴肅點,我說真的):這個計算器可以接收兩個運算元(op1和op2),你請求它執行一個操作(handler),然後它返回一個結果(result)給你,如果算術運算出現了溢位則會把溢位的部分捨棄掉(extended_value)。

OPCode就是些東西,不需要新增任何其他的東西,它的概念很容易理解。

Zend VM的每個OPCode的工作方式都完全相同:它們都有一個handler(譯註:在Zend VM中,handler是一個函式指標,它指向OPCode對應的處理函式的地址,這個處理函式就是用於實現OPCode具體操作的,為了簡潔起見,這個單詞不做翻譯),這是一個C函式,這個函式就包含了執行這個OPCode時會執行的程式碼(例如“add”,它就會執行一個基本的加法運算)。每個handler都可以使用0、1或者2個運算元:op1和op2,這個函式執行後,它會後返回一個結果,有時也會返回一段資訊(extended_value)。

我們先來看看ZEND_ADD這個OPCode的handler:

ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV)
{
    USE_OPLINE
    zend_free_op free_op1, free_op2;
    SAVE_OPLINE();
    fast_add_function(&EX_T(opline->result.var).tmp_var,
        GET_OP1_ZVAL_PTR(BP_VAR_R),
        GET_OP2_ZVAL_PTR(BP_VAR_R) TSRMLS_CC);
    FREE_OP1();
    FREE_OP2();
    CHECK_EXCEPTION();
    ZEND_VM_NEXT_OPCODE();
}

你只需要注意其中你可以理解的行,上面的這段程式碼並不符合C語法(這一點等會會談到)。儘管如此,這段程式碼還是很容易理解的。

正如前面所說,ZEND_ADD這個OPCode的handler中會呼叫fast_add_function()函式(一個存放在其他地方的C函式),這段程式碼中給這個函式傳入了三個引數:result(結果)、op1和op2。從這段程式碼我們可以看出真正執行加法運算的程式碼是在fast_add_function()這個函式中,在此我們就不展示這個函式的程式碼了。

上面的handler程式碼的最後部分會呼叫CHECK_EXCEPTION()和ZEND_VM_NEXT_OPCODE()兩個指令(譯註:實際上是兩個C的巨集),我們先講解一下後一個指令(instruction)。

一個超大的迴圈(A giant loop)

當在編譯PHP指令碼時,指令碼中的PHP語法會被轉換為多個OPCode,一個接著一個。這是編譯器的工作,在此不詳述。

這意味著PHP編譯器會做一件事情:把PHP指令碼轉換為一個“OP陣列(OP array)”,它是一個包含多個OPCode的陣列。每個OPCode的handler都會以呼叫ZEND_VM_NEXT_OPCODE()結束,它會告訴executor提取(fetch)緊接著的下一個OPCode,然後執行它,這個過程會不斷進行。

所有這些都發生在一個迴圈裡,它的程式碼如下(經過簡化後的程式碼)(譯註:實際上這個while迴圈體中的程式碼也不復雜,而execute_ex整個函式也不大,作者說是一個超大的迴圈,他想表達的意思應該是這個迴圈的執行時間,因為基本上所有的OPCode的執行都是通過迴圈發起的,你可以通過這個連結檢視這個函式的程式碼):

ZEND_API void execute_ex(zend_execute_data *execute_data TSRMLS_DC)
{
    zend_bool original_in_execution;
    original_in_execution = EG(in_execution);
    EG(in_execution) = 1;
zend_vm_enter:
    execute_data = i_create_execute_data_from_op_array(EG(active_op_array), 1 TSRMLS_CC);
    while (1) {  /* infinite dispatch loop */
        int ret;
        if ((ret = execute_data->opline->handler(execute_data TSRMLS_CC)) > 0) { /* do the job */
            switch (ret) {
                case 1:
                    EG(in_execution) = original_in_execution;
                    return; /* exit from the infinite loop */
                case 2:
                    goto zend_vm_enter;
                    break;
                case 3:
                    execute_data = EG(current_execute_data);
                    break;
                default:
                    break;
            }
        }
    } /* end of infinite dispatch loop */
    zend_error_noreturn(E_ERROR, "Arrived at end of main loop which shouldn't happen");
}

上面程式碼中的迴圈被稱為的Zend executor的主分發迴圈(dispatch loop)。一個while(true)的死迴圈,它會執行一個handler函式,這個函式以ZEND_VM_NEXT_OPCODE()這個指令結束,這個指令會告訴executor把execute_data->opline指向到OPArray中的下一個OPCode。

#define ZEND_VM_NEXT_OPCODE() \
CHECK_SYMBOL_TABLES() \
ZEND_VM_INC_OPCODE(); \
ZEND_VM_CONTINUE()
#define ZEND_VM_INC_OPCODE() \
OPLINE++
#define OPLINE execute_data->opline
#define ZEND_VM_CONTINUE()         return 0
#define ZEND_VM_RETURN()           return 1
#define ZEND_VM_ENTER()            return 2
#define ZEND_VM_LEAVE()            return 3

我們可以把整個執行場景簡化為:“執行操作1,執行操作2,執行操作3,…,返回退出(return and exit)”。等會再探討這個迴圈是怎麼實現的,目前只需要把它理解為執行一系列操作就可以。

(譯註:這裡有必要說明一下,實際上並非所有的handler最後都會呼叫ZEND_VM_NEXT_OPCODE()這個巨集,例如ZEND_GENERATOR_RETURN這個OPCode的handler最後呼叫的就是ZEND_VM_RETURN()這個巨集,它會返回1,這種情況下會退出迴圈,我們通過上面的巨集的定義也可以看出,所有呼叫ZEND_VM_NEXT_OPCODE()這個巨集的handler都會ZEND_VM_CONTINUE()這個巨集返回0,這種情況下會進入下一次迴圈,也就是執行下一個OPCode)

一個簡單的示例

我們先看一個簡單的示例:

$a = 8;
$b = 'foo';
echo $a + $b;

這個簡單的指令碼會被編譯成如下所示的OPArray(由ext/vld擴充套件生成)。

compiled vars:  !0 = $a, !1 = $b
line     #* I O op                           fetch          ext  return  operands
-----------------------------------------------------------------------------------
   2     0  >   ASSIGN                                                   !0, 8
   3     1      ASSIGN                                                   !1, 'foo'
   4     2      ADD                                              ~2      !0, !1
         3      ECHO                                                     ~2
   5     4    > RETURN                                                   1

github

相信大家可以理解上面輸出的OPCode,我在此簡單說明下:

  • 把8賦值(assign)給$a
  • 把’foo’賦值給$b
  • ab中的值相加然後儲存在一個臨時變數“~2”中
  • echo臨時變數“~2”
  • 返回(return)
    你可能已經注意到一個最後一行的OPCode:RETURN,這個OPCode出現在這裡似乎有點奇怪。它是幹嘛的呢?它是從哪裡來的呢?這個問題實際上很簡單。

還記得上面那個超大的while()迴圈麼?它是一個無限迴圈:while(1),再回去分析一下這個簡單的迴圈,你會注意到結束這個迴圈的唯一方式是handler()函式執行後返回1,它會將while中的程式碼導向switch的case 1分支,這個分支中有一個return語句,它會導致迴圈退出。RETURN這個OPCode不做任何事情, 除了返回1,終止Zend VM Executor的分發迴圈(dispatch loop),然後返回。所以很顯然:每個指令碼都會以一個RETURN結束,如果不是這樣的話:整個迴圈會無限執行下去,這顯然是不合理的。

所以PHP編譯器被設計為不管編譯什麼程式碼都會在編譯出的OP陣列的最後加一個RETURN的OPCode。這意味著如果編譯一個空的PHP指令碼(不包含任何程式碼),所產生的OPArray中也會包含一個唯一的OPCode:ZEND_RETURN。當它被載入到VM的執行分發迴圈時,它會執行這個OPCode的handler的程式碼,那就是讓VM返回:空PHP指令碼不會做其他任何事情。

OPArray

我們已經多次使用到“OPArray”這個詞了,現在來看看它的定義。之前使用這個詞時,我建議把它簡單地理解成一個包含順序執行的OPCode的陣列。就像下圖所示的樣子:

github

然而這種說法並不完全正確,儘管它離真實情況也差得不遠。下面是表示OPArray的結構體:

struct _zend_op_array {
    /* Common elements */
    zend_uchar type;
    const char *function_name;
    zend_class_entry *scope;
    zend_uint fn_flags;
    union _zend_function *prototype;
    zend_uint num_args;
    zend_uint required_num_args;
    zend_arg_info *arg_info;
    /* END of common elements */

    zend_uint *refcount;

    zend_op *opcodes;
    zend_uint last;

    zend_compiled_variable *vars;
    int last_var;

    zend_uint T;

    zend_uint nested_calls;
    zend_uint used_stack;

    zend_brk_cont_element *brk_cont_array;
    int last_brk_cont;

    zend_try_catch_element *try_catch_array;
    int last_try_catch;
    zend_bool has_finally_block;

    HashTable *static_variables;

    zend_uint this_var;

    const char *filename;
    zend_uint line_start;
    zend_uint line_end;
    const char *doc_comment;
    zend_uint doc_comment_len;
    zend_uint early_binding;

    zend_literal *literals;
    int last_literal;

    void **run_time_cache;
    int  last_cache_slot;

    void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};

如你所見,這個結構體所包含的東西絕不僅僅只有一個包含OPCode的陣列。包含OPCode的陣列只是這個結構體中的一個欄位:

struct _zend_op_array {
    /* ... */
    zend_op *opcodes; /* Here is the array of OPCodes */
    /* ... */
}

記住當引擎編譯PHP指令碼時,編譯器會返回一個上面所示的OPArray,這是它的唯一工作。

所以一個“OPArray”不是我們通常所理解的一個包含zend_op(OPCodes)元素的C陣列,實際上它還包含一些統計資訊,以及所有有利於OPCode以最高效的方式執行所需的資訊:executor必須得儘可能高效地執行OPCode,唯有如此,PHP指令碼的執行才可能花費盡可能少的時間。

下面詳細說明下OPArray中包含的一些資訊(只講最重要的幾個):

  • 當前指令碼的檔名(const char *filename),以及會被編譯為OPArray的PHP指令碼在檔案中的開始行數(zend_uint line_start)和結束行數(zend_uint line_end)
  • 文件註釋的資訊(const char doc_comment):PHP指令碼中使用“/*”註釋的資訊
  • 引用計數(zend_uint *refcount),OPArray本身也可能會在其他地方共享,所以需要記錄它的引用情況
  • 編譯變數列表(zend_compiled_variable *vars)。編譯變數(compiled variable)是所有PHP指令碼中使用的變數(以$something的形式出現)
  • 臨時變數列表:臨時變數用於儲存運算的臨時結果,這些結果不會顯示地在PHP指令碼中使用(不是使用$something的形式在PHP指令碼中出現,但是是會被真正用到的中間資料)(譯註:從上面的結構體中我看不出哪個是儲存臨時變數的欄位)
  • try-catch-finally的資訊(zend_try_catch_element *try_catch_array),executor需要這些資訊來實現正確的跳轉
  • break-continue的資訊(zend_brk_cont_element *brk_cont_array),executor需要這些資訊來實現正確的跳轉
  • 靜態變數的列表(HashTable *static_variables)。靜態變數會被特殊處理,因為它們的資訊需要維持到PHP生命週期的最後時刻(大體上是這樣)
  • 字面量(zend_literal *literals)。字面量是指任何在編譯期就知道它的值的東西(譯註:實際上就是常量),例如我們在程式碼中使用的字串’foo’,或者是整型42
  • 執行時快取槽(cache slot):這個地方用於快取引擎執行過程中還用會用到的東西
    OK,看起來似乎這個結構體中被塞進了很多東西?

還有件重要的事情我沒有提到:OPArray結構體會在編譯指令碼、PHP使用者自定義的函式和所有傳入evel()函式的字串這幾個PHP語言結構時使用。當你編寫一個PHP函式,它的整個函式體會被編譯為一個單獨的OPArray,這個OPArray會包含在函式體中使用的編譯變數(compiled variable),在函式體中使用的try-catch-finally語句等等。(譯註:這段話的意思是說明哪些PHP程式碼會被編譯為OPArray這個結構體,PHP編譯器並不是只編譯出一個OPArray,而且也不是把任何程式碼都編譯為OPArray,哪些語言結構需要被編譯為OPArray是有原則的。對於PHP編譯器而言,它會把PHP指令碼(就是除開函式和傳入eval()的字串的所有PHP程式碼都是PHP指令碼)、使用者自定義函式和傳給eval()的字串這三個東西單獨編譯為一個OPArray結構體)

OPArray結構體是Zend編譯器在編譯PHP指令碼和PHP使用者定義的函式或方法(function/method)後的輸出結果。這也為什麼你可以從OPArray中讀取只跟一個PHP函式相關而跟PHP指令碼無關的資訊的原因:例如你可以只讀取一個函式的文件註釋塊(documentor comment block)。(譯註:實際上我們在使用vld檢視OPCode的時候,它輸出的OPCode分成幾塊,每一塊就代表一個OPArray,而且通常第一塊都是PHP指令碼的OPArray,這個總是存在的,你可以自己用vld試試)

Ok,回到前面的示例程式碼,我們看看它被編譯後生成的OPArray是什麼樣子:

$a = 8;
$b = 'foo';
echo $a + $b;

github

從這些圖片中你可以看到這個OPArray現在包含所有需要傳給executor的東西。記住一個原則:在編譯期(生成OPArray的時候)進行的計算越多,那麼executor所要進行的計算就越少(譯註:這就是執行期),這樣executor就可以專注於它“真正”的工作:執行編譯後的PHP程式碼。我們可以看到所有的字面量都會編譯進了literals陣列(你可能會注意到literals陣列中的整型1,它是來自ZEND_RETURN這個OPCode,它會返回1)。

其他的zend_op_array欄位基本都是空的(值為0,譯註:指標欄位的值會為NULL,NULL也是一種0值),這是因為我們所編譯的指令碼非常小:裡面沒有任何函式呼叫,也沒有任何try-catch結構,或者是break-continue語句。這就是編譯一段PHP指令碼後得到的結果,而不是編譯一個PHP函式的結果。在其他情況下會生成不同的OPArray,有些情況下生成的OPArray中的很多欄位都會被填充。

Zend VM的運算元型別

在講解不同的OPCode的handler之前,我們還需要理解另外一個重要概念:運算元。

我們知道每個OPCode的handler最多可以使用兩個運算元:op1和op2。每個運算元都表示一個OPCode的“引數(parameter)”。例如,ZEND_ASSIGN這個OPCode的第一個引數是你要賦值的PHP變數,第二個運算元是你要給第一個運算元賦的值。這個OPCode的結果不會用到。(譯註:這一點很有意思,賦值語句會返回一個值,這就是我們可以使用a=b=1這個語言結構的原因,基本很多語言都是這麼做的,但是理論上語句(statement)是不返回值的,例如if語句或者for語句都是不會返回值的,這也是不能把它們賦值給某個變數的原因,在程式設計語言中,能夠返回值的都是表示式(expression),有些人吐槽這種設計不合理,因為這違反了一致性原則)

這兩個運算元的型別可能不同,這依賴於它們所表示的東西,以及它們是怎麼被使用的,我們下面看一下Zend VM所支援的所有運算元型別:

  • IS_CV :編譯變數(Compiled Variable):這個運算元型別表示一個PHP變數:以$something形式在PHP指令碼中出現的變數
  • IS_VAR : 供VM內部使用的變數,它可以被其他的OPCode重用,跟$php_variable很像,只是只能供VM內部使用
  • IS_TMP_VAR : VM內部使用的變數,但是不能被其他的OPCode重用
  • IS_CONST : 表示一個常量,它們都是隻讀的,它們的值不可改變
  • IS_UNUSED :這個表示運算元沒有值:這個運算元沒有包含任何有意義的東西,可以忽略
    ZEND VM的這些型別規範很重要,它們會直接影響整個executor的效能,以及在executor的記憶體管理中扮演重要的角色。當某個OPCode的handler想讀取(fetch/read)儲存在某個運算元中的資訊時,executor不會執行同樣的程式碼來讀取這些資訊:而是會針對不同的運算元型別呼叫不同的(讀取)程式碼。

這是什麼意思呢?我們假設某個OPCode的handler要讀取的運算元(op1或者op2)的型別是IS_CV,這表示這個運算元是某個在PHP程式碼中定義過的$variable,這個handler會首先會查詢符號表(symbol table),符號表裡存放了每個程式碼中已宣告的變數。查詢符號表的工作一旦結束,在此我們假設查詢是成功的——找到了一個編譯變數(Compiled Variable),那麼跟當前執行的OPCode處在同一個OPArray中的OPCode(位於當前OPCode後面)非常非常有可能會再次用到這個運算元的資訊。所以當第一次讀取成功後,executor會把讀取的資訊快取在OPArray中,這樣之後要再次讀取這個運算元的資訊時就快很多。

上面是對IS_CV這個型別的解釋說明,這也同樣適用於其他的型別:我們可以對任意OPCode的handler的運算元訪問進行優化,只要我們知道它們的型別資訊(這個運算元可共享麼?它需要被釋放麼?之後還可能重用它麼?等等)。

從下面這個簡單的加法示例中你可以看到PHP編譯器是怎麼使用每個型別的:

$a + $b; // IS_CV + IS_CV
1 + $a;  // IS_CONST + IS_CV
foo() + 3 // IS_VAR + IS_CONST
!$a + 3;  // IS_TMP + IS_CONST (此處會產生兩個OPCode,但只顯示了一個)

OPCode的專用handler(specialized handlers)

現在我們知道每個OPCode的handler最多可以接受兩個運算元(引數),並且它會根據運算元的型別來獲取它們的值。如果每個OPCode的handler程式碼中都使用switch()語句來選擇每個運算元的型別,再根據不同的型別執行不同的讀取運算元的值的程式碼,那麼這會導致嚴重的效能問題,這是由於CPU無法對每個handler中的分支跳轉程式碼進行優化,因為分支跳轉在本質上是一種高度動態化的過程。(譯註:這種優化跟CPU執行機器碼指令的方式相關的,現代CPU都支援流水線技術(pipeline)(當然最新的CPU使用的是亂序執行的方式,它跟流水線的目的是一樣的,都是希望能夠並行執行多條指令),這個技術可以讓CPU在一個時鐘週期裡面執行多個指令的不同階段(CPU在執行一個指令時會把每個指令分解為多個微操作,一個時鐘週期只能執行一個微操作),從而到達多條指令的並行執行的效果,但是流水線技術只有在指令程式碼執行的順序是確定的時候才有效,而當存在分支跳轉的時候,指令的執行順序是不確定的,所以此時無法並行執行多條指令,當然現代CPU還有一種被稱為分支預測的技術,它可以根據一些條件來預測分支往那個方向跳轉,如果預測成功則可以繼續並行執行會跳轉到的分支的程式碼,不過如果預測失敗則會執行一個回退的過程,這個會增加CPU的開銷,儘管目前CPU的分支預測成功的概率還是挺高的,但離100%還是有一些差距的,有興趣的同學可以自行搜尋CPU流水線和分支預測方面的知識)

如果不考慮效能問題,那麼ZEND_ADD的handler程式碼將如下所示(經過簡化的虛擬碼):

int ZEND_ADD(zend_op *op1, zend_op *op2)
{
    void *op1_value;
    void *op2_value;
    switch (op1->type) {
        case IS_CV:
            op1_value = read_op_as_a_cv(op1);
        break;
        case IS_VAR:
            op1_value = read_op_as_a_var(op1);
        break;
        case IS_CONST:
            op1_value = read_op_as_a_const(op1);
        break;
        case IS_TMP_VAR:
            op1_value = read_op_as_a_tmp(op1);
        break;
        case IS_UNUSED:
            op1_value = NULL;
        break;
    }
    /* ... 對op2做同樣的事情 .../
    /* 對op1_value和op2_value做一些事情 (執行一個算術加法運算?) */
}

現在你要意識到我們是在設計某個OPCode的handler,這個handler可能會在執行PHP指令碼的時候被呼叫很多次。如果每次呼叫這個handler時都不得不先獲取它的運算元的型別,然後根據不同的型別執行不同的讀取(fetch/read)程式碼,這顯然不利於程式的效能(不是非常誇張,但仍然存在)。

對於這個問題有一個非常棒的替代方案。

還記得上面提到的PHP原始碼中對ZEND_ADD這個OPCode的handler的定義麼:

ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV)
{
    USE_OPLINE
    zend_free_op free_op1, free_op2;
    SAVE_OPLINE();
    fast_add_function(&EX_T(opline->result.var).tmp_var,
        GET_OP1_ZVAL_PTR(BP_VAR_R),
        GET_OP2_ZVAL_PTR(BP_VAR_R) TSRMLS_CC);
    FREE_OP1();
    FREE_OP2();
    CHECK_EXCEPTION();
    ZEND_VM_NEXT_OPCODE();
}

看看這個奇怪的函式的簽名,它甚至都不符合有效C語法(因此它不可能通過C編譯器的編譯)。

ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV)

這行程式碼表示ZEND_ADD的handler的第一個運算元op1可以接受CONST、TMP、VAR或者CV型別,op2也是如此。

現在我們再介紹一個神奇的東西:包含這段程式碼的原始檔是zend_vm_def.h,這只是一個模板檔案,它會被傳給一個處理工具(processor),這個工具會生成每個handler的程式碼(符合C語言語法的程式),它會對所有運算元型別進行排列組合,然後生成專屬於組合中的每一項的handler函式。

我們來算個數,op1可接受5種不同的型別,op2也可以接受5種不同的型別,那個op1和op2的型別組合就有25種情況:上面的工具會為ZEND_ADD生成25個不同的專用於處理特定型別組合的handler函式,這些函式會被寫入一個檔案中,這個檔案會作為PHP原始碼的一部分被編譯。

最終生成的檔名為zend_vm_execute.h,也許你現在正打算點開這個連結,不過你還是建議你三思而後行:因為它真XX的大,別怪我沒提醒你噢;-)

現在繼續我們算數工作,PHP5.6支援167個OPCode,假設這167個OPCode每個都有可以接受5種運算元型別的op1和op2,那麼最終生成的檔案會包含4175個C函式。

實際上並非每個OPCode都支援5種不同的運算元型別,所以最終生成的函式個數會小於上面的數字。例如:

ZEND_VM_HANDLER(84, ZEND_FETCH_DIM_W, VAR|CV, CONST|TMP|VAR|UNUSED|CV)

ZEND_FETCH_DIM_W(對某個組合實體(array/object)某個維度的元素進行寫操作)的op1只支援兩種型別:IS_VAR和IS_CV。

但是zend_vm_execute.h這個檔案仍然有大概45000行的C程式碼,所以正如我之前所建議的,開啟這個檔案之前請三思,因為開啟它可能需要花一點時間。

現在我們小結一下:

  • zend_vm_def.h並非有效的C檔案,它描述了每個OPCode的handler的特點(使用一種與C接近的自定義語法),每個handler的特點依賴於它的op1和op2的型別,每個運算元最多支援5種類型
  • zend_vm_def.h會被傳遞給一個名為zend_vm_gen.php的PHP指令碼,這個指令碼位於PHP原始碼中,它會分析zend_vm_def.h中的特殊語法,會用到很多正則表示式匹配,最終會生成出zend_vm_execute.h這個檔案
  • zend_vm_def.h在編譯PHP原始碼時不會被處理(這是很顯然的)
  • zend_vm_execute.h是解析zend_vm_def.h後的輸出檔案,它包含了符合C語法的程式碼,它是VM executor的心臟:每個OPCode的專有handler函式都存放在這個檔案裡,很顯然這是一個非常重要的檔案

當你從原始碼編譯PHP時,PHP原始碼會提供一個預設的zend_vm_execute.h檔案,不過如果你想修改(hack)PHP原始碼,例如你想新增一個新的OPCode,或者是修改一個已存在的OPCode的行為,你必須先修改(hack)zend_vm_def.h,然後再重新生成zend_vm_execute.h檔案

有意思的是:PHP虛擬機器的Executor是通過PHP語言自身生成的,哈哈!

我們再來看一個示例:

下面是zend_vm_def.h中定義的ZEND_ADD這個OPCode的handler:

ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV)

把zend_vm_def.h這個檔案傳給zend_vm_en.php指令碼,將會生成一個新的zend_vm_execute.h檔案,這個檔案中會包含這個OPCode的專有handler,它們看起來是下面這個樣子:

static int ZEND_FASTCALL  ZEND_ADD_SPEC_CONST_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { /* handler code */ }
static int ZEND_FASTCALL  ZEND_ADD_SPEC_CONST_TMP_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { /* handler code */ }
static int ZEND_FASTCALL  ZEND_ADD_SPEC_CONST_VAR_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { /* handler code */ }
static int ZEND_FASTCALL  ZEND_ADD_SPEC_CONST_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { /* handler code */ }
static int ZEND_FASTCALL  ZEND_ADD_SPEC_TMP_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { /* handler code */ }
static int ZEND_FASTCALL  ZEND_ADD_SPEC_TMP_TMP_HANDLER(ZEND_OPCODE_HANDLER_ARGS)  { /* handler code */ }
/* 等等...我們就不列出所有25個函數了! */

所以最終到底執行哪個專有handler是根據op1和op2的型別來確定的,例如:

$a + 2;  /* IS_CV + IS_CONST */
/* ZEND_ADD_SPEC_CV_CONST_HANDLER() 這個handler函式會在VM中執行 */

這個函式名是動態生成的,它的生成模式是:ZEND_{OPCODE-NAME}SPEC{OP1-TYPE}_{OP2-TYPE}_HANDLER()。

你現在也許會尋思:既然我們必須根據op1和op2的型別來選擇專有的handler函式,那麼我們豈不是還是要通過一大段switch程式碼來選擇正確的專有handler函式,這跟之前說的使用switch會影響效能有什麼差別呢?

我只能告訴你:必須有差別啊,因為運算元的型別可以在編譯期解析出,所以編譯器會確定在執行期該呼叫哪個專有handler函式,另外如果你使用了OPCode快取的話,那編譯期的解析工作也會免了。

當PHP編譯器在把PHP語言寫的程式編譯成OPCode時,它知道每個OPCode所接受的op1和op2的型別(因為它是編譯器所以它必須知道,這是它的職責)。所以PHP編譯器會直接生成一個使用正確的專有handler的OPArray:在執行過程中不會存在其他的選擇,不需要使用switch():在執行期直接執行OPCode的專有handler顯然會更高效一些。不過如果你修改你的PHP程式,那你必須得重新編譯生成一個新的OPArray,這就是OPCode快取要解決的問題。

Ok,我們現在為何不看下這些專有handler之間的差別呢?

其實也沒有什麼特別之處,對於同一個OPCode的每一個專有handler,它們唯一的差別是讀取op1和op2的方式。看下面的程式碼:

static int ZEND_FASTCALL  ZEND_ADD_SPEC_CONST_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS) /* CONST_CONST */
{
    USE_OPLINE
    SAVE_OPLINE();
    fast_add_function(&EX_T(opline->result.var).tmp_var,
        opline->op1.zv, /* fetch op1 value */
        opline->op2.zv TSRMLS_CC); /* fetch op2 value */
    CHECK_EXCEPTION();
    ZEND_VM_NEXT_OPCODE();
}
static int ZEND_FASTCALL  ZEND_ADD_SPEC_CV_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS) /* CV_CV */
{
    USE_OPLINE
    SAVE_OPLINE();
    fast_add_function(&EX_T(opline->result.var).tmp_var,
        _get_zval_ptr_cv_BP_VAR_R(execute_data, opline->op1.var TSRMLS_CC), /* fetch op1 value */
        _get_zval_ptr_cv_BP_VAR_R(execute_data, opline->op2.var TSRMLS_CC) TSRMLS_CC); /* fetch op2 value */
    CHECK_EXCEPTION();
    ZEND_VM_NEXT_OPCODE();
}

在CONST_CONST的handler中(op1和op2都是CONST),我們直接獲取這個運算元的zval值。此時不需要做任何其他事情,例如增加或者減少一個引用計數的值,或者是釋放運算元的值:這個值是不可變的,只需要讀取它,這樣就可以收工了。

不過對於CV_CV的handler(op1和op2都是CV,編譯變數),我們必須訪問它們的值,增加它們的引用計數(refcount)(這是由於我們現在要用到了它們),並且為了方便以後使用而把它們的值快取起來:_get_zval_ptr_cv_BP_VAR_R()就是做這些事情的。我們從這個函式的命名可以看出這是一個“R”讀取操作:這表示只讀取運算元的值,如果這個變數不存在,這個函式會產生一個notice:未定義變數(undefined variable)。對於”W“訪問情況則會有些不同,如果這個變數不存在,我們只需要建立它,而不會產生任何警告或者notice,PHP不就是這麼工作的麼?;-)

其他資訊

編譯器優化(Compiler optimizations)

zend_vm_gen.php有時候會在zend_vm_execute.h中生成一些奇怪的程式碼。例如:

static int ZEND_FASTCALL  ZEND_INIT_ARRAY_SPEC_CONST_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    USE_OPLINE
    array_init(&EX_T(opline->result.var).tmp_var);
    if (IS_CONST == IS_UNUSED) {
        ZEND_VM_NEXT_OPCODE();
#if 0 || IS_CONST != IS_UNUSED
    } else {
        return ZEND_ADD_ARRAY_ELEMENT_SPEC_CONST_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
#endif
    }
}

你可能已經注意到了上面的程式碼中的 if (IS_CONST == IS_UNUSED) 和#if 0 || IS_CONST != IS_UNUSED這兩行程式碼,它們看起來似乎很2。

為什麼會生成這麼2的程式碼呢?這是因為用於生成專有handler的zend_vm_def.h的模板程式碼就是這麼寫的,我們來看看:

ZEND_VM_HANDLER(71, ZEND_INIT_ARRAY, CONST|TMP|VAR|UNUSED|CV, CONST|TMP|VAR|UNUSED|CV)
{
    USE_OPLINE
    array_init(&EX_T(opline->result.var).tmp_var);
    if (OP1_TYPE == IS_UNUSED) {
        ZEND_VM_NEXT_OPCODE();
#if !defined(ZEND_VM_SPEC) || OP1_TYPE != IS_UNUSED
    } else {
        ZEND_VM_DISPATCH_TO_HANDLER(ZEND_ADD_ARRAY_ELEMENT);
#endif
    }
}

當這個OPCode生成它的所有專有handler函式時,OP1_TYPE會被替換為每個專有handler會接受的運算元的型別,所以才會生成if (IS_CONST == IS_UNUSED)這種奇怪的程式碼。

不過最終生成的zend_vm_execute.h是要經過C編譯器編譯的,C編譯器會優化掉這些沒用的語句,它會直接刪除這些程式碼,所以C編譯器在把它們編譯為機器碼時會對它們進行優化,它們會被優化成下面這個樣子:

static int ZEND_FASTCALL  ZEND_INIT_ARRAY_SPEC_CONST_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    array_init(&EX_T(opline->result.var).tmp_var);
}

自定義生成的Zend VM executor

zend_vm_gen.php這個PHP指令碼被於生成VM的executor,這個指令碼可以接受一些引數,所以你可以通過設定這些引數來生成不同版本的executor。例如,當你在執行zend_vm_en.php時傳入–without-specializer這個引數時,它會生成一個不使用專有handler的executor。這意味著每個OPCode的handler只有一個版本(不管op1和op2是什麼型別),這個handler會使用一個switch()來選擇op1/op2的型別,再根據不同的型別來提取它們的值。

static int ZEND_FASTCALL  ZEND_ADD_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    USE_OPLINE
    zend_free_op free_op1, free_op2;
    SAVE_OPLINE();
    fast_add_function(&EX_T(opline->result.var).tmp_var,
        get_zval_ptr(opline->op1_type, &opline->op1, execute_data, &free_op1, BP_VAR_R),
        get_zval_ptr(opline->op2_type, &opline->op2, execute_data, &free_op2, BP_VAR_R) TSRMLS_CC);
    FREE_OP(free_op1);
    FREE_OP(free_op2);
    CHECK_EXCEPTION();
    ZEND_VM_NEXT_OPCODE();
}
static inline zval *_get_zval_ptr(int op_type, const znode_op *node, const zend_execute_data *execute_data, zend_free_op *should_free, int type TSRMLS_DC)
{
/*  should_free->is_var = 0; */
    switch (op_type) {
        case IS_CONST:
            should_free->var = 0;
            return node->zv;
            break;
        case IS_TMP_VAR:
            should_free->var = TMP_FREE(&EX_T(node->var).tmp_var);
            return &EX_T(node->var).tmp_var;
            break;
        case IS_VAR:
            return _get_zval_ptr_var(node->var, execute_data, should_free TSRMLS_CC);
            break;
        case IS_UNUSED:
            should_free->var = 0;
            return NULL;
            break;
        case IS_CV:
            should_free->var = 0;
            return _get_zval_ptr_cv(node->var, type TSRMLS_CC);
            break;
        EMPTY_SWITCH_DEFAULT_CASE()
    }
    return NULL;
}

為什麼要這麼做呢?這主要是為了便於調式以及更易於理解executor的程式碼。沒有使用專有handler的zend_vm_execute.h檔案的大小會是使用專有handler檔案的十分之一。不過當你使用這個executor執行PHP程式時,它的執行效率要比使用專有handler的低10%到15%。

Zend VM executor的專有handler最開始是在PHP5.1加入的(2005)

另外一個引數是 –with-vm-kind=CALL|SWITCH|GOTO。CALL(函式呼叫)是它的預設值。

還記得我們之前介紹的executor中的while(1)這個迴圈麼?為了重新整理的記憶我把這段程式碼再貼一次(簡化後的版本):

ZEND_API void execute_ex(zend_execute_data *execute_data TSRMLS_DC)
{
    /* ... simplified ... */
    while (1) {
        int ret;
        if ((ret = execute_data->opline->handler(execute_data TSRMLS_CC)) > 0) {
            switch (ret) {
                case 1:
                    EG(in_execution) = original_in_execution;
                    return;
                case 2:
                    goto zend_vm_enter;
                    break;
                case 3:
                    execute_data = EG(current_execute_data);
                    break;
                default:
                    break;
            }
        }
    }
    zend_error_noreturn(E_ERROR, "Arrived at end of main loop which shouldn't happen");
}

上面這段程式碼使用了CALL策略(strategy),它會在每個OPCode的handler的最後增加execute_data->opline指標,然後再進入while(1)的下一次迴圈。就這樣,executor可以順序地一個OPCode接一個OPCode的執行,直到遇到ZEND_RETURN這個OPCode。

除了CALL策略外,還有其他的方式來到達順序執行OPCode的目的。例如使用C語言中的goto語句,或者是使用switch()語句。

這就是–with-vm-kind的作用:它會控制zend_vm_gen.php生成3種使用不同方式進行流程控制的executor。我們看下使用C語言中的goto語句的情況:

ZEND_API void execute_ex(zend_execute_data *execute_data TSRMLS_DC)
{
    /* ... simplified ... */
    while (1) {
        goto *(void**)(execute_data->opline->handler);
    }
}

你會看到while(1)依舊存在,但是這次是使用goto來跳轉到一個函式指標。在這種情況下,某個OPCode的handler會在處理完後增加execute_data->opline指標,這個指標會指向下一個OPCode,然後再使用goto語句跳轉到下一個handler函式的入口,我們可以看下此時的ZEND_VM_NEXT_OPCODE()這個巨集展開後的程式碼:

#define ZEND_VM_INC_OPCODE() execute_data->opline++
#define ZEND_VM_CONTINUE() goto *(void**)(OPLINE->handler) /* 這裡使用了goto */
#define ZEND_VM_NEXT_OPCODE() \
CHECK_SYMBOL_TABLES() \
ZEND_VM_INC_OPCODE(); \
ZEND_VM_CONTINUE()

CALL是Zend Executor分發迴圈的預設策略,這是因為使用這種策略時,C編譯器可以在大多數目標平臺下編譯出效能良好的本地機器程式碼。不過你可以根據你自己的平臺和C編譯器的特性來選擇可以達到最近效能的方式,例如goto這種方式,有些CPU家族(CPU families)會為它提供專有的彙編指令。(譯註:這個的意思是如果你使用goto語句,C編譯器會把它編譯為特定CPU上的專有彙編指令,而不是通常的JMP之類的指令,這種情況下goto的效果會更好)

Executor中的跳轉(jumps)

你知道Zend VM executor怎麼執行PHP指令碼中的if語句嗎?很簡單:對於if語句生成的OPCode,它的handler函式不會使用ZEND_VM_NEXT_OPCODE()巨集,這個巨集只會使executor一條OPCode一條OPCode地線性執行,它不能修改executor的執行路徑,所以如果要實現if或者迴圈跳轉——我們需要能夠跳轉(jump)到一個特定的OPCode。

$a = 8;
if ($a == 9) {
    echo "foo";
} else {
    echo "bar";
}
compiled vars:  !0 = $a
line     #* I O op                           fetch          ext  return  operands
-----------------------------------------------------------------------------------
   3     0  >   ASSIGN                                                   !0, 8
   5     1      IS_EQUAL                                         ~1      !0, 9
         2    > JMPZ                                                     ~1, ->5
   6     3  >   ECHO                                                     'foo'
   7     4    > JMP                                                      ->6
   8     5  >   ECHO                                                     'bar'
  10     6  > > RETURN                                                   1

注意到ZEND_JMP和ZEND_JMPZ這兩個OPCode沒?它們只會改變程式的控制流程(control flow):

static int ZEND_FASTCALL  ZEND_JMP_SPEC_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    USE_OPLINE
    ZEND_VM_SET_OPCODE(opline->op1.jmp_addr);
    ZEND_VM_CONTINUE();
}
#define ZEND_VM_SET_OPCODE(new_op) \
CHECK_SYMBOL_TABLES() \
execute_data->opline = new_op

ZEND_VM_SET_OPCODE這個巨集會告訴executor的主迴圈不要增加opline指標——這麼做只會立即執行下一個OPCode,而是將opline跳轉到一個新地址(jpm_addr),這個地址儲存在ZEND_JMP的handler的運算元op1中。這個地址是在編譯期計算出的。

效能優化小貼士(Performance tips)

現在我要介紹幾個根據OPCode來優化PHP指令碼的小技巧。

實話說我並不喜歡寫這些內容,因為有些人看了這些內容後總喜歡不加思考地使用這些規則,他們一般不會意識到這些小技巧不會給他們自己的那些一個頁面會包含1200個SQL查詢的應用帶來任何實質性的改善:-p。我們需要根據具體上下文來使用這些技巧。

不過話說回來,如果你的程式碼中有一些迭代次數非常多的迴圈,那麼這些小技巧還是很有用的。

echo一個字串連線

你可能已經看到過很多這樣的程式碼(也許也寫了不少):

$foo = 'foo';
$bar = 'bar';
echo $foo . $bar;

下面是這段程式碼編譯後生成的OPArray:

compiled vars:  !0 = $foo, !1 = $bar
line     #* I O op                           fetch          ext  return  operands
-----------------------------------------------------------------------------------
   3     0  >   ASSIGN                                                   !0, 'foo'
   4     1      ASSIGN                                                   !1, 'bar'
   6     2      CONCAT                                           ~2      !0, !1
         3      ECHO                                                     ~2
   7     4    > RETURN                                                   1

zend引擎會連線(ZEND_CONCAT)a<