天天看點

八十六、PHP核心探索:中間代碼opcode的執行 ☞ 詞法分析,文法分析,編譯生成中間代碼

假如我們現在使用的是CLI模式,直接在SAPI/cli/php_cli.c檔案中找到main函數, 預設情況下PHP的CLI模式的行為模式為PHP_MODE_STANDARD。 此行為模式中PHP核心會調用php_execute_script(&file_handle TSRMLS_CC);來執行PHP檔案。 順着這條執行的線路,可以看到一個PHP檔案在經過詞法分析,文法分析,編譯後生成中間代碼的過程:

EG(active_op_array) = zend_compile_file(file_handle, type TSRMLS_CC);      

在銷毀了檔案所在的handler後,如果存在中間代碼,則PHP虛拟機将通過以下代碼執行中間代碼:

zend_execute(EG(active_op_array) TSRMLS_CC);      

如果你是使用VS檢視源碼的話,将光标移到zend_execute并直接按F12, 你會發現zend_execute的定義跳轉到了一個指針函數的聲明(Zend/zend_execute_API.c)。

ZEND_API void (*zend_execute)(zend_op_array *op_array TSRMLS_DC);      

這是一個全局的函數指針,它的作用就是執行PHP代碼檔案解析完的轉成的zend_op_array。 和zend_execute相同的還有一個zedn_execute_internal函數,它用來執行内部函數。 在PHP核心啟動時(zend_startup)時,這個全局函數指針将會指向execute函數。 注意函數指針前面的修飾符ZEND_API,這是ZendAPI的一部分。 在zend_execute函數指針指派時,還有PHP的中間代碼編譯函數zend_compile_file(檔案形式)和zend_compile_string(字元串形式)。

zend_compile_file = compile_file;
zend_compile_string = compile_string;
zend_execute = execute;
zend_execute_internal = NULL;
zend_throw_exception_hook = NULL;      

這幾個全局的函數指針均隻調用了系統預設實作的幾個函數,比如compile_file和compile_string函數, 他們都是以全局函數指針存在,這種實作方式在PHP核心中比比皆是,其優勢在于更低的耦合度,甚至可以定制這些函數。 比如在APC等opcode優化擴充中就是通過替換系統預設的zend_compile_file函數指針為自己的函數指針my_compile_file, 并且在my_compile_file中增加緩存等功能。

到這裡我們找到了中間代碼執行的最終函數:execute(Zend/zend_vm_execure.h)。 在這個函數中所有的中間代碼的執行最終都會調用handler。這個handler是什麼呢?

if ((ret = EX(opline)->handler(execute_data TSRMLS_CC)) > 0) {
}      

這裡的handler是一個函數指針,它指向執行該opcode時調用的處理函數。 此時我們需要看看handler函數指針是如何被設定的。 在前面我們有提到和execute一起設定的全局指針函數:zend_compile_string。 它的作用是編譯字元串為中間代碼。在Zend/zend_language_scanner.c檔案中有compile_string函數的實作。 在此函數中,當解析完中間代碼後,一般情況下,它會執行pass_two(Zend/zend_opcode.c)函數。 pass_two這個函數,從其命名上真有點看不出其意義是什麼。 但是我們關注的是在函數内部,它周遊整個中間代碼集合, 調用ZEND_VM_SET_OPCODE_HANDLER(opline);為每個中間代碼設定處理函數。 ZEND_VM_SET_OPCODE_HANDLER是zend_vm_set_opcode_handler函數的接口宏, zend_vm_set_opcode_handler函數定義在Zend/zend_vm_execute.h檔案。 其代碼如下:

static opcode_handler_t zend_vm_get_opcode_handler(zend_uchar opcode, zend_op* op)
{
        static const int zend_vm_decode[] = {
            _UNUSED_CODE, /* 0              */
            _CONST_CODE,  /* 1 = IS_CONST   */
            _TMP_CODE,    /* 2 = IS_TMP_VAR */
            _UNUSED_CODE, /* 3              */
            _VAR_CODE,    /* 4 = IS_VAR     */
            _UNUSED_CODE, /* 5              */
            _UNUSED_CODE, /* 6              */
            _UNUSED_CODE, /* 7              */
            _UNUSED_CODE, /* 8 = IS_UNUSED  */
            _UNUSED_CODE, /* 9              */
            _UNUSED_CODE, /* 10             */
            _UNUSED_CODE, /* 11             */
            _UNUSED_CODE, /* 12             */
            _UNUSED_CODE, /* 13             */
            _UNUSED_CODE, /* 14             */
            _UNUSED_CODE, /* 15             */
            _CV_CODE      /* 16 = IS_CV     */
        };
        return zend_opcode_handlers[opcode * 25 
                + zend_vm_decode[op->op1.op_type] * 5 
                + zend_vm_decode[op->op2.op_type]];
}
 
ZEND_API void zend_vm_set_opcode_handler(zend_op* op)
{
    op->handler = zend_vm_get_opcode_handler(zend_user_opcodes[op->opcode], op);
}      

前面介紹了四種查找opcode處理函數的方法, 而根據其本質實作查找也在其中,隻是這種方法對于計算機來說比較容易識别,而對于自然人來說卻不太友好。 比如一個簡單的A + B的加法運算,如果你想用這種方法查找其中間代碼的實作位置的話, 首先你需要知道中間代碼的代表的值,然後知道第一個表達式和第二個表達式結果的類型所代表的值, 然後計算得到一個數值的結果,然後從數組zend_opcode_handlers找這個位置,位置所在的函數就是中間代碼的函數。 這對閱讀代碼的速度沒有好處,但是在開始閱讀代碼的時候根據代碼的邏輯走這樣一個流程卻是大有好處。

回到正題。 handler所指向的方法基本都存在于Zend/zend_vm_execute.h檔案檔案。 知道了handler的由來,我們就知道每個opcode調用handler指針函數時最終調用的位置。

在opcode的處理函數執行完它的本職工作後,正常的opcode都會在函數的最後面添加一句:ZEND_VM_NEXT_OPCODE();。 這是一個宏,它的作用是将目前的opcode指針指向下一條opcode,并且傳回0。如下代碼:

#define ZEND_VM_NEXT_OPCODE() \
CHECK_SYMBOL_TABLES() \
EX(opline)++; \
ZEND_VM_CONTINUE()
 
#define ZEND_VM_CONTINUE()   return 0      

在execute函數中,處理函數的執行是在一個while(1)循環作用範圍中。如下:

while (1) {
        int ret;
#ifdef ZEND_WIN32
        if (EG(timed_out)) {
            zend_timeout(0);
        }
#endif
 
        if ((ret = EX(opline)->handler(execute_data TSRMLS_CC)) > 0) {
            switch (ret) {
                case 1:
                    EG(in_execution) = original_in_execution;
                    return;
                case 2:
                    op_array = EG(active_op_array);
                    goto zend_vm_enter;
                case 3:
                    execute_data = EG(current_execute_data);
                default:
                    break;
            }
        }
 
    }      

前面說到每個中間代碼在執行完後都會将中間代碼的指針指向下一條指令,并且傳回0。 當傳回0時,while 循環中的if語句都不滿足條件,進而使得中間代碼可以繼續執行下去。 正是這個while(1)的循環使得PHP核心中的opcode可以從第一條執行到最後一條, 當然這中間也有一些函數的跳轉或類方法的執行等。

以上是一條中間代碼的執行,那麼對于函數的遞歸調用,PHP核心是如何處理的呢? 看如下一段PHP代碼:

function t($c) {
    echo $c, "\n";
    if ($c > 2) {
            return ;
    }
    t($c + 1);
}
t(1);      

這是一個簡單的遞歸調用函數實作,它遞歸調用了兩次,這個遞歸調用是如何進行的呢? 我們知道函數的調用所在的中間代碼最終是調用zend_do_fcall_common_helper_SPEC(Zend/zend_vm_execute.h)。 在此函數中有如下一段:

if (zend_execute == execute && !EG(exception)) {
    EX(call_opline) = opline;
    ZEND_VM_ENTER();
} else {
    zend_execute(EG(active_op_array) TSRMLS_CC);
}      

前面提到zend_execute API可能會被覆寫,這裡就進行了簡單的判斷,如果擴充覆寫了opcode執行函數, 則進行特殊的邏輯處理。

上一段代碼中的ZEND_VM_ENTER()定義在Zend/zend_vm_execute.h的開頭,如下:

#define ZEND_VM_CONTINUE()   return 0 
#define ZEND_VM_RETURN()     return 1 
#define ZEND_VM_ENTER()      return 2 
#define ZEND_VM_LEAVE()      return 3      

這些在中間代碼的執行函數中都有用到,這裡的ZEND_VM_ENTER()表示return 2。 在前面的内容中我們有說到在調用了EX(opline)->handler(execute_data TSRMLS_CC))後會将傳回值指派給ret。 然後根據ret判斷下一步操作,這裡的遞歸函數是傳回2,于是下一步操作是:

op_array = EG(active_op_array);
goto zend_vm_enter;      

這裡将EG(active_op_array)的值賦給op_array後,直接跳轉到execute函數的定義的zend_vm_enter标簽, 此時的EG(active_op_array)的值已經在zend_do_fcall_common_helper_SPEC中被換成了目前函數的中間代碼集合, 其實作代碼為:

if (EX(function_state).function->type == ZEND_USER_FUNCTION) {  //  使用者自定義的函數
            EX(original_return_value) = EG(return_value_ptr_ptr);
            EG(active_symbol_table) = NULL;
            EG(active_op_array) = &EX(function_state).function->op_array;   //  将目前活動的中間代碼指針指向使用者自定義函數的中間代碼數組
            EG(return_value_ptr_ptr) = NULL;      

當核心執行完使用者自定義的函數後,怎麼傳回之前的中間代碼代碼主幹路徑呢? 這是由于在execute函數中初始化資料時已經将目前的路徑記錄在EX(op_array)中了(EX(op_array) = op_array;) 當使用者函數傳回時程式會将之前儲存的路徑重新恢複到EG(active_op_array)中(EG(active_op_array) = EX(op_array);)。 可能此時你會問如果函數沒有傳回呢?這種情況在使用者自定義的函數中不會發生的, 就算是你沒有寫return語句,PHP核心也會自動給加上一個return語句。

整個調用路徑如下圖所示:

​​

八十六、PHP核心探索:中間代碼opcode的執行 ☞ 詞法分析,文法分析,編譯生成中間代碼

​​

Zend中間代碼調用路徑圖

以上是opcode的執行過程,與過程相比,過程中的資料會更加重要,那麼在執行過程中的核心資料結構有哪些呢? 在Zend/zend_vm_execute.h檔案中的execute函數實作中,zend_execute_data類型的execute_data變量貫穿整個中間代碼的執行過程, 其在調用時并沒有直接使用execute_data,而是使用EX宏代替,其定義在Zend/zend_compile.h檔案中,如下:

#define EX(element) execute_data.element      

是以我們在execute函數或在opcode的實作函數中會看到EX(fbc),EX(object)等宏調用, 它們是調用函數局部變量execute_data的元素:execute_data.fbc和execute_data.object。 execute_data不僅僅隻有fbc、object等元素,它包含了執行過程中的中間代碼,上一次執行的函數,函數執行的目前作用域,類等資訊。 其結構如下:

typedef struct _zend_execute_data zend_execute_data;
 
struct _zend_execute_data {
    struct _zend_op *opline;
    zend_function_state function_state;
    zend_function *fbc; /* Function Being Called */
    zend_class_entry *called_scope; 
    zend_op_array *op_array;  /* 目前執行的中間代碼 */
    zval *object;
    union _temp_variable *Ts;
    zval ***CVs;
    HashTable *symbol_table; /* 符号表 */
    struct _zend_execute_data *prev_execute_data;   /* 前一條中間代碼執行的環境*/
    zval *old_error_reporting;
    zend_bool nested;
    zval **original_return_value; /* */
    zend_class_entry *current_scope;
    zend_class_entry *current_called_scope;
    zval *current_this;
    zval *current_object;
    struct _zend_op *call_opline;
};      

在前面的中間代碼執行過程中有介紹:中間代碼的執行最終是通過EX(opline)->handler(execute_data TSRMLS_CC)來調用最終的中間代碼程式。 在這裡會将主管中間代碼執行的execute函數中初始化好的execture_data傳遞給執行程式。

zend_execute_data結構體部分字段說明如下:

  • opline字段:struct _zend_op類型,目前執行的中間代碼
  • op_array字段: zend_op_array類型,目前執行的中間代碼隊列
  • fbc字段:zend_function類型,已調用的函數
  • called_scope字段:zend_class_entry類型,目前調用對象作用域,常用操作是EX(called_scope) = Z_OBJCE_P(EX(object)), 即将剛剛調用的對象指派給它。
  • symbol_table字段: 符号表,存放局部變量。 在execute_data初始時,EX(symbol_table) = EG(active_symbol_table);
  • prev_execute_data字段:前一條中間代碼執行的中間資料,用于函數調用等操作的運作環境恢複。

在execute函數中初始化時,會調用zend_vm_stack_alloc函數配置設定記憶體。 這是一個棧的配置設定操作,對于一段PHP代碼的上下文環境,它存在于這樣一個配置設定的空間作放置中間資料用,并作為棧頂元素。 當有其它上下文環境的切換(如函數調用),此時會有一個新的元素生成,上一個上下文環境會被新的元素壓下去, 新的上下文環境所在的元素作為棧頂元素存在。

在zend_vm_stack_alloc函數中我們可以看到一些PHP核心中的優化。 比如在配置設定時,這裡會存在一個最小配置設定單元,在zend_vm_stack_extend函數中, 配置設定的最小機關是ZEND_VM_STACK_PAGE_SIZE((64 * 1024) - 64),這樣可以在一定範圍内控制記憶體碎片的大小。 又比如判斷棧元素是否為空,在PHP5.3.1之前版本(如5.3.0)是通過第四個元素elelments與top的位置比較來實作, 而從PHP5.3.1版本開始,struct _zend_vm_stack結構就沒有第四個元素,直接通過在目前位址上增加整個結構體的長度與top的位址比較實作。 兩個版本結構代碼及比較代碼如下:

// PHP5.3.0
struct _zend_vm_stack {
    void **top;
    void **end;
    zend_vm_stack prev;
    void *elements[1];
};
 
if (UNEXPECTED(EG(argument_stack)->top == EG(argument_stack)->elements)) {
}
 
//  PHP5.3.1
struct _zend_vm_stack {
    void **top;
    void **end;
    zend_vm_stack prev;
};
 
if (UNEXPECTED(EG(argument_stack)->top == ZEND_VM_STACK_ELEMETS(EG(argument_stack)))) {
}
 
#define ZEND_VM_STACK_ELEMETS(stack) \
((void**)(((char*)(stack)) + ZEND_MM_ALIGNED_SIZE(sizeof(struct _zend_vm_stack))))      
  • 上下文環境的切換:這裡的關鍵代碼是:EG(current_execute_data) = EX(prev_execute_data);。 EX(prev_execute_data)用于保留目前函數調用前的上下文環境,進而達到恢複和切換的目的。
  • 目前上下文環境所占用記憶體空間的釋放:這裡的關鍵代碼是:zend_vm_stack_free(execute_data TSRMLS_CC);。 zend_vm_stack_free函數的實作存在于Zend/zend_execute.h檔案,它的作用就是釋放棧元素所占用的記憶體。
  • 傳回到之前的中間代碼執行路徑中:這裡的關鍵代碼是:ZEND_VM_LEAVE();。 我們從zend_vm_execute.h檔案的開始部分就知道ZEND_VM_LEAVE宏的效果是傳回3。 在執行中間代碼的while循環當中,當ret=3時,這個執行過程就會恢複之前上下文環境,繼續執行。