天天看點

八十四、PHP核心探索:虛拟機的詞法解析 ☞ 将PHP指令轉變成C語言指令

語言從廣義上來講是人們進行溝通交流的各種表達符号。每種語言都有專屬于自己的符号,表達方式和規則。 就程式設計語言來說,它也是由特定的符号,特定的表達方式和規則組成。 語言的作用是溝通,不管是自然語言,還是程式設計語言,它們的差別在于自然語言是人與人之間溝通的工具, 而程式設計語言是人與機器之間的溝通管道。相對于自然語言,程式設計語言的曆史還非常短, 雖然程式設計語言是站在曆史巨人的基礎上建立的,但是它還很小,還是一個小孩。 它隻能按程式設計人員所給的指令翻譯成對應的機器可以識别的語言。它就相當于一個轉化工具, 将人們的知識或者業務邏輯轉化成機器碼(機器的語言),讓其執行對應的的操作。 而這些指令是一些規則,一些約定,這些規則約定都是由程式設計語言來處理。

就PHP語言來說,它也是一組符合一定規則的約定的指令。 在程式設計人員将自己的想法以PHP語言實作後,通過PHP的虛拟機将這些PHP指令轉變成C語言 (可以了解為更底層的一種指令集)指令,而C語言又會轉變成彙編語言, 最後彙編語言将根據處理器的規則轉變成機器碼執行。這是一個更高層次抽象的不斷具體化,不斷細化的過程。

這裡,我們讨論PHP虛拟機是如何将PHP語言轉化成C語言。 從一種語言到另一種語言的轉化稱之為編譯,這兩種語言分别可以稱之為源語言和目智語言。 這種編譯過程通過發生在目智語言比源語言更低級(或者說更底層)。 語言轉化的編譯過程是由編譯器來完成, 編碼器通常被分為一系列的過程:詞法分析、文法分析、語義分析、中間代碼生成、代碼優化、目标代碼生成等。 前面幾個階段(詞法分析、文法分析和語義分析)的作用是分析源程式,我們可以稱之為編譯器的前端。 後面的幾個階段(中間代碼生成、代碼優化和目标代碼生成)的作用是構造目标程式,我們可以稱之為編譯器的後端。 一種語言被稱為編譯類語言,一般是由于在程式執行之前有一個翻譯的過程, 其中關鍵點是有一個形式上完全不同的等價程式生成。 而PHP之是以被稱為解釋類語言,就是因為并沒有這樣的一個程式生成, 它生成的是中間代碼,這隻是PHP的一種内部資料結構。

這裡我們會介紹PHP編譯器的前端的兩個階段,文法分析、文法分析;後端的一個階段,中間代碼生成。 

在前面我們提到語言轉化的編譯過程一般分為詞法分析、文法分析、語義分析、中間代碼生成、代碼優化、目标代碼生成等六個階段。 不管是編譯型語言還是解釋型語言,掃描(詞法分析)總是将程式轉化成目智語言的第一步。 詞法分析的作用就是将整個源程式分解成一個一個的單詞, 這樣做可以在一定程度上減少後面分析工作需要處理的個體數量,為文法分析等做準備。 除了拆分工作,更多的時候它還承擔着清洗源程式的過程,比如清除空格,清除注釋等。 詞法分析作為編譯過程的第一步,在業界已經有多種成熟工具,如PHP在開始使用的是Flex,之後改為re2c, MySQL的詞法分析使用的Flex,除此之外還有作為UNIX系統标準詞法分析器的Lex等。 這些工具都會讀進一個代表詞法分析器規則的輸入字元串流,然後輸出以C語言實做的詞法分析器源代碼。 這裡我們隻介紹PHP的現版詞法分析器,re2c。

​​re2c​​​是一個掃描器制作工具,可以建立非常快速靈活的掃描器。 它可以産生高效代碼,基于C語言,可以支援C/C++代碼。與其它類似的掃描器不同, 它偏重于為正規表達式産生高效代碼(和他的名字一樣)。是以,這比傳統的詞法分析器有更廣泛的應用範圍。 你可以在​​sourceforge.net​​擷取源碼。

PHP在最開始的詞法解析器是使用的是Flex,後來改為使用re2c。 在源碼目錄下的Zend/zend_language_scanner.l 檔案是re2c的規則檔案, 如果需要修改該規則檔案需要安裝re2c才能重新編譯,生成新的規則檔案。

re2c調用方式:

re2c [-bdefFghisuvVw1] [-o output] [-c [-t header]] file      

我們通過一個簡單的例子來看下re2c。如下是一個簡單的掃描器,它的作用是判斷所給的字元串是數字/小寫字母/大小字母。 當然,這裡沒有做一些輸入錯誤判斷等異常操作處理。示例如下:

#include <stdio.h>
 
char *scan(char *p){
#define YYCTYPE char
#define YYCURSOR p
#define YYLIMIT p
#define YYMARKER q
#define YYFILL(n)
    /*!re2c
      [0-9]+ {return "number";}
      [a-z]+ {return "lower";}
      [A-Z]+ {return "upper";}
      [^] {return "unkown";}
     */
}
 
int main(int argc, char* argv[])
{
    printf("%s\n", scan(argv[1]));
 
    return 0;
}      

如果你是在ubuntu環境下,可以執行下面的指令生成可執行檔案。

re2c -o a.c a.l
gcc a.c -o a
chmod +x a
./a 1000      

此時程式會輸出number。

我們解釋一下我們用到的幾個re2c約定的宏。

  • YYCTYPE 用于儲存輸入符号的類型,通常為char型和unsigned char型
  • YYCURSOR 指向目前輸入标記, -當開始時,它指向目前标記的第一個字元,當結束時,它指向下一個标記的第一個字元
  • YYFILL(n) 當生成的代碼需要重新加載緩存的标記時,則會調用YYFILL(n)。
  • YYLIMIT 緩存的最後一個字元,生成的代碼會反複比較YYCURSOR和YYLIMIT,以确定是否需要重新填充緩沖區。

參照如上幾個辨別的說明,可以較清楚的了解生成的a.c檔案,當然,re2c不會僅僅隻有上面代碼所顯示的标記, 這隻是一個簡單示例,更多的辨別說明和幫助資訊請移步 ​​re2c幫助文檔​​​:​​http://re2c.org/manual.html​​。

我們回過頭來看PHP的詞法規則檔案zend_language_scanner.l。 你會發現前面的簡單示例與它最大的差別在于每個規則前面都會有一個條件表達式。

NOTE re2c中條件表達式相關的宏為YYSETCONDITION和YYGETCONDITION,分别表示設定條件範圍和擷取條件範圍。 在PHP的詞法規則中共有10種,其全部在zend_language_scanner_def.h檔案中。此檔案并非手寫, 而是re2c自動生成的。如果需要生成和使用條件表達式,在編譯成c時需要添加-c 和-t參數。

在PHP的詞法解析中,它有一個全局變量:language_scanner_globals,此變量為一結構體,記錄目前re2c解析的狀态,檔案資訊,解析過程資訊等。 它在zend_language_scanner.l檔案中直接定義如下:

#ifdef ZTS
ZEND_API ts_rsrc_id language_scanner_globals_id;
#else
ZEND_API zend_php_scanner_globals language_scanner_globals;
#endif      

在zend_language_scanner.l檔案中寫的C代碼在使用re2c生成C代碼時會直接複制到新生成的C代碼檔案中。 這個變量貫穿了PHP詞法解析的全過程,并且一些re2c的實作也依賴于此, 比如前面說到的條件表達式的存儲及擷取,就需要此變量的協助,我們看這兩個宏在PHP詞法中的定義:

//  存在于zend_language_scanner.l檔案中
#define YYGETCONDITION()  SCNG(yy_state)
#define YYSETCONDITION(s) SCNG(yy_state) = s
#define SCNG    LANG_SCNG
 
//  存在于zend_globals_macros.h檔案中
# define LANG_SCNG(v) (language_scanner_globals.v)      

結合前面的全局變量和條件表達式宏的定義,我們可以知道PHP的詞法解析是通過全局變量在一次解析過程中存在。 那麼這個條件表達式具體是怎麼使用的呢?我們看下面一個例子。這是一個可以識别為結束, 識别字元,數字等的簡單字元串識别器。它使用了re2c的條件表達式,代碼如下:

#include <stdio.h>
#include "demo_def.h"
#include "demo.h"
 
Scanner scanner_globals;
 
#define YYCTYPE char
#define YYFILL(n) 
#define STATE(name)  yyc##name
#define BEGIN(state) YYSETCONDITION(STATE(state))
#define LANG_SCNG(v) (scanner_globals.v)
#define SCNG    LANG_SCNG
 
#define YYGETCONDITION()  SCNG(yy_state)
#define YYSETCONDITION(s) SCNG(yy_state) = s
#define YYCURSOR  SCNG(yy_cursor)
#define YYLIMIT   SCNG(yy_limit)
#define YYMARKER  SCNG(yy_marker)
 
int scan(){
    /*!re2c
 
      <INITIAL>"<?php" {BEGIN(ST_IN_SCRIPTING); return T_BEGIN;}
      <ST_IN_SCRIPTING>[0-9]+ {return T_NUMBER;}
      <ST_IN_SCRIPTING>[ \n\t\r]+ {return T_WHITESPACE;}
      <ST_IN_SCRIPTING>"exit" { return T_EXIT; }
      <ST_IN_SCRIPTING>[a-z]+ {return T_LOWER_CHAR;}
      <ST_IN_SCRIPTING>[A-Z]+ {return T_UPPER_CHAR;}
      <ST_IN_SCRIPTING>"?>" {return T_END;}
 
      <ST_IN_SCRIPTING>[^] {return T_UNKNOWN;}
      <*>[^] {return T_INPUT_ERROR;}
     */
}
 
void print_token(int token) {
    switch (token) {
        case T_BEGIN: printf("%s\n", "begin");break;
        case T_NUMBER: printf("%s\n", "number");break;
        case T_LOWER_CHAR: printf("%s\n", "lower char");break;
        case T_UPPER_CHAR: printf("%s\n", "upper char");break;
        case T_EXIT: printf("%s\n", "exit");break;
        case T_UNKNOWN: printf("%s\n", "unknown");break;
        case T_INPUT_ERROR: printf("%s\n", "input error");break;
        case T_END: printf("%s\n", "end");break;
    }
}
 
int main(int argc, char* argv[])
{
    int token;
    BEGIN(INITIAL); //  全局初始化,需要放在scan調用之前
    scanner_globals.yy_cursor = argv[1];    //将輸入的第一個參數作為要解析的字元串
 
    while(token = scan()) {
        if (token == T_INPUT_ERROR) {
            printf("%s\n", "input error");
            break;
        }
        if (token == T_END) {
            printf("%s\n", "end");
            break;
        }
        print_token(token);
    }
 
    return 0;
}      

和前面的簡單示例一樣,如果你是在linux環境下,可以使用如下指令生成可執行檔案

re2c -o demo.c -c -t demo_def.h demo.l
gcc demo.c -o demo -g
chmod +x demo      

在使用re2c生成C代碼時我們使用了-c -t demo_def.h參數,這表示我們使用了條件表達式模式,生成條件的定義頭檔案。 main函數中,在調用scan函數之前我們需要初始化條件狀态,将其設定為INITIAL狀态。 然後在掃描過程中會直接識别出INITIAL狀态,然後比對<?php字元串識别為開始,如果開始不為<?php,則輸出input error。 在掃描的正常流程中,當掃描出<?php後,while循環繼續向下走,此時會再次調用scan函數,目前條件狀态為ST_IN_SCRIPTING, 此時會跳過INITIAL狀态,直接比對<ST_IN_SCRIPTING>狀态後的規則。如果所有的<ST_IN_SCRIPTING>後的規則都無法比對,輸出unkwon。 這隻是一個簡單的識别示例,但是它是從PHP的詞法掃描器中抽離出來的,其實作過程和原理類似。

那麼這種條件狀态是如何實作的呢?我們檢視demo.c檔案,發現在scan函數開始後有一個跳轉語句:

int scan(){
 
#line 25 "demo.c"
{
    YYCTYPE yych;
    switch (YYGETCONDITION()) {
    case yycINITIAL: goto yyc_INITIAL;
    case yycST_IN_SCRIPTING: goto yyc_ST_IN_SCRIPTING;
    }
...
}