PHP-Zend引擎剖析之詞法分析(一)____PHP
來源:互聯網
上載者:User
前言 閑來研究一下PHP底層的Zend引擎源碼,Zend引擎是PHP指令碼的虛擬機器。 在PHP上層有SAPI介面,負責對各個接入層的抽象,例如PHP在Apache模組裡邊的實現,Fast-CGI的實現,命令列的實現。在PHP底層便是Zend虛擬機器,Zend虛擬機器負責解析PHP文法的檔案,上層可以在虛擬機器中註冊函數/變數提供給虛擬機器調用,例如從Apache分發過來的HTTP請求經過PHP的Apache SAPI介面後,便會註冊一些$_COOKIE、$_GET等全域變數,而在命令列模式下便沒有這些跟HTTP相關的全域變數。 Zend引擎跟其他編譯器跟解譯器一樣,會經曆詞法分析/文法分析,文法分析後會產生op code,也就是PHP的中間代碼,最終Zend虛擬機器執行的是op code。第一篇貢獻給Zend引擎的理當是詞法分析的源碼剖析。 PS:分析的代碼是PHP-5.5.5的源碼包,下載地址: http://windows.php.net/downloads/releases/php-5.5.5-src.zip。
詞法分析 詞法分析階段就是從輸入資料流裡邊一個字元一個字元的掃描,識別出對應的詞素,最後把源檔案轉換成為一個TOKEN序列,然後丟給文法分析器。 從詞法分析階段中,詞法分析器也能檢測到原始碼裡邊的一些錯誤。例如在Zend引擎的詞法分析階段就有這樣一段代碼: zend_error(E_COMPILE_WARNING, "Unterminated comment starting line %d", CG(zend_lineno)); 當檢測到/*開頭,但是沒有*/結尾時,Zend引擎會拋出一個Waring提示,但是並不影響接下來的詞法解析,詞法分析階段一般都不會造成嚴重的解析錯誤,因為詞法分析階段的職責就是識別出Token序列而已,它並不需要知道Token跟Token之間是否具備什麼聯絡(那個應該是文法分析階段的職責)。在Zend引擎的詞法分析器中也會拋出致命的解析錯誤而終止詞法分析階段,如下代碼: zend_error_noreturn(E_COMPILE_ERROR, "Could not convert the script from the detected " "encoding \"%s\" to a compatible encoding", zend_multibyte_get_encoding_name(LANG_SCNG(script_encoding))); 這個解析錯誤是因為從輸入資料流裡邊檢測到的代碼的編碼不合法,顯然,這裡是應該終止掉整個解析過程的。 Zend引擎的詞法分析器re2c來產生,詞法分析的階段會涉及到各個狀態,其變數命名均為yy開頭(下文會說明)。
源碼高亮 我找了一個清晰的流程來分析怎麼進入到詞法分析階段的。 我們以命令列的PHP為入口來研究一下,以HelloWorld的例子來看,我們在命令列執行:php -s HelloWorld.php,結果如下: php -s是高亮原始碼的命令,所謂高亮原始碼其實就是對詞素進行一個顏色高亮,我們通過入口檔案分析到在$PHPSRC/sapi/cli/php_cli.c中的do_cli函數裡邊接收了命令列的參數輸入。 -s的輸入對應的是高亮源碼。 緊接著,便是調用了Zend引擎的代碼高亮的函數:zend_highlight。 在$PHPSRC/Zend/zend_highlight.c中,我們找到了zend_highlight的定義,zend_highlight()調用的就是詞法分析器lex_scan來擷取Token,然後加入對應的顏色。 到了這裡,就真正進入詞法分析的流程了。
lex詞法分析器 Zend引擎的lex檔案位於$PHPSRC/Zend/zend_language_scanner.l,如果你安裝了re2c,可以通過以下命令來產生c檔案: re2c -F -c -o zend_language_scanner.c zend_language_scanner.l 我們主要剖析的是zend_language_scanner.l檔案。在re2c產生的詞法解析器中,我認為有兩個維度狀態機器。第一個維度是字串的維度來維護的狀態,第二個是字元的維度來維護狀態。第二個維度狀態機器就是字元間狀態的跳轉,在這裡我們忽略之。 例如在Zend引擎中,當掃描到"<?php"時,Zend會將當前第一維度狀態設定為ST_IN_SCRIPTING,表示現在我們已經進入了PHP指令碼解析的狀態了。這個維度狀態可以很方便的在lex檔案中作為各種前置條件,例如在lex檔案中有很多這樣的聲明: 其表達的意思就是:當我們詞法解析器處於ST_IN_SCRIPTING這個狀態時,遇到"exit"這個字串就返回一個T_EXIT的Token標誌(在Zend引擎中Token的宏都是以T_開頭,其實際對應是一個數字)。你可以經常從語法錯誤提示資訊中看到T_開頭的提示資訊,例如在:echo "Hello" World!\n";字串中加多了一個雙引號,運行時就會出現編譯錯誤,這裡邊就有一個T_STRING的Token錯誤: Parse error: syntax error, unexpected 'World' ( T_STRING), expecting ',' or ';' in /home/raphealguo/tmp/HelloWorld.php on line 2 在詞法解析器掃描字元的過程中,需要記錄掃描過程的各個參數以及目前狀態,這些變數都是以yy開頭命名。常用到的就是:yy_state, yy_text, yyleng, yy_cursor, yy_limit 各個變數的狀態掃描前後的變化示意圖。 掃描echo前: 掃描echo後: 通過一個字元一個字元的掃描最終會得到一個Token序列,然後交由文法分析器去解析,接著就是剖析Zend引擎的lex檔案規則是怎麼寫的了。
lex檔案剖析
Zend詞法解析狀態 Zend引擎在做詞法解析時會自己維護掃描過程的狀態,其實就是將yy_text等變數自己封裝一個結構體,我們可以在lex檔案中看到很多SCNG的宏調用,例如:SCNG(yy_start) = YYCURSOR; 定位一下#define SCNG,可以發現在lex檔案的91行有這樣的宏定義: /* Globals Macros */#define SCNG LANG_SCNG 我們重新置放到#define LANG_SCNG在檔案$PHPSRC/Zend/zend_globals_macros.h中的第56行(我們忽略52行ZTS的判斷,這是一個安全執行緒的宏定義): # define LANG_SCNG(v) (language_scanner_globals.v) //這裡可以看到實際上在掃描過程中 都是調全域掃描狀態的屬性,例如SCNG(yy_start)相當於language_scanner_globals.yy_startextern ZEND_API zend_php_scanner_globals language_scanner_globals;#endif 可以看到Zend引擎維護了一個zend_php_scanner_globals的結構體(實際上在27行裡邊是一個typedef的重新命名,本來是叫做_zend_php_scanner_globals這個結構體),_zend_php_scanner_globals這個結構體的定義在$PHPSRC/Zend/zend_globals.h,可以看到其結構有部分跟原來lex掃描器的變數是一致的,但是它好封裝了一些堆棧,還有輸入輸出資料流(解析PHP檔案時不一定是檔案輸入資料流,也有可能從終端輸入的命令,所以這裡封裝一個輸入輸出資料流是很合理的)。 關鍵字Token 回到lex詞法描述檔案上,前邊說到詞法掃描的入口在zend_language_scanner.l的第999行int lex_scan(zval *zendlval TSRMLS_DC)裡。 先定義一些前置的正則匹配: 對於一些無需複雜處理的關鍵字,我們掃描到對應的關鍵字,直接產生對應的Token標誌即可,例如: 在lex檔案中可以看到很多這樣的規則聲明,<ST_IN_SCRIPTING>是指掃描到這個關鍵字的前置條件是詞法解析器要處於ST_IN_SCRIPTING這個狀態,在lex檔案裡邊有以下幾種方式可以設定當前的詞法解析器狀態 #define YYGETCONDITION() SCNG(yy_state)#define YYSETCONDITION(s) SCNG(yy_state) = s #define BEGIN(state) YYSETCONDITION(STATE(state))static void _yy_push_state(int new_state TSRMLS_DC){//將目前狀態壓棧,然後重設目前狀態為新狀態zend_stack_push(&SCNG(state_stack), (void *) &YYGETCONDITION(), sizeof(int));YYSETCONDITION(new_state);}
進入PHP解析狀態 我們知道PHP是嵌入式的,只有包含在<?php ?>或者<? ?>標籤中的字元才會被執行解析,在lex檔案的1732-1805行就是掃描<?php這樣起始標籤的規則聲明,源碼如下: 當掃描到<?php時,在1790行設定了當前詞法解析器的狀態為ST_IN_SCRIPTING,其中HANDLE_NEWLINE是為了遞增當前的zend_lineno,這個變數是用來記錄當前解析到第幾行。最後return一個T_OPEN_TAG出去。 當遇到短標籤<?=時,會先檢查全域屬性裡邊的short_tags有沒有開啟,沒有的話就goto到inline_char_handler去處理,inline_char_handler對應的就是掃描不在PHP標籤裡邊的字元了。 在1732行行定義了另外一種PHP文法開啟標籤,就是:<script language="php">echo 2;</script> 可以通過這個規則看出,如果在script裡邊加入其他屬性就會導致這條規則失效,例如:<script language="php">echo 2;</script>就不會進行PHP文法解析了。
PHP注釋 接著我們看一下PHP裡邊注釋是怎麼掃描的。先找到1919行關於單行注釋的規則聲明: 可以看出,PHP是支援#以及//兩種方式的單行注釋。處於ST_IN_SCRIPTING狀態下,遇到"#"|"//",變觸發了單行注釋的掃描,從當前字元開始一直掃描到流緩衝區的末尾(也即是while(YYCURSOR < YYLIMIT))。 遇到\r\n以及\n時,遞增記錄當前解析的行(zend_lineno++),為了更好容錯性,PHP還相容了//?>這樣的文法,也即是說當行注釋是不會注釋到?>的,可以從case '?'這個分支看出Zend的處理,先讓當前指標YYCURSOR--,回到?>前一個字元,然後跳出迴圈,這樣才不會吃掉"?>"導致後邊認不到PHP的關閉標籤。 多行注釋的規則稍微複雜那麼一點點: 首先可以看到/**是對應PHP文檔聲明的解析(在文檔中是可以書寫PHP變數,在變數解析那裡可以看到這個問題),緊接著一個while迴圈掃描到*/的位置,如果一直到檔案結尾都沒掃到*/,那就zend_error一個Waring錯誤,但是不會影響接下去的解析。
PHP數字類型 從一開始的正則規則裡邊可以知道PHP支援5中類型的數字常量聲明: 其實對於代碼來說,數字其實也是字元,詞法分析器掃描到這5個規則的時候,需要把當前的zendlval對應的解析成數字存起來,同時返回一個數字類型的Token標誌,看最簡單的LNUM規則處理: 首先檢查一下當前的字串是否超出C語言的long類型長度,如果不超過,直接接調用strtol把字串轉換成long int類型。 如果超出了long的範圍,Zend還是嘗試看看能不能轉,如果發生溢出(error == ERANGE)那就把當前數字轉成double類型。 至於DNUM、BNUM等就不佔篇幅了。
PHP變數類型 PHP的變數是以美元符$開頭,從詞法規則裡邊可以看到: 有三種變數的聲明調用方式,$var, $var->prop, $var["key"]。 注意到yyless調用,yyless的宏定義聲明在69行: 因為詞法掃描的時候已經吃掉了"$var->",而我們只需要提取出變數名"var",因此我們需要讓YYCURSOR指標重新回到"var->"的"-"位置,因此調用了yyless(yyleng-3)。 緊接著都是通過zend_copy_value拷貝變數名到zendlval裡邊記錄起來供之後文法解析階段插入到符號表裡邊去。 這裡再討論一個關於$var->prop的規則, 我們留意到1193行有個奇怪的規則,為什麼在ST_LOOKING_FOR_PROPERTY下還可以再有->呢,研究了一下,原來這裡是為了檢驗$var->prop1->prop2這第2+個的->。
PHP字串類型 PHP的字串類型在詞法分析階段應該是最複雜的,PHP裡邊的字串可以由單引號跟雙引號來圍住,單引號的字串比雙引號的字串效率會更高,一會我們可以看到為什麼。 先來看一下單引號的規則: 首先留意到b?['],字串前邊能加上b聲明。但是在之後的代碼中壓根沒看出這個b的聲明對字串有什麼影響。在 http://php.net/manual/zh/language.types.string.php裡邊有這樣一句描述: 原來這b是為了聲明一個二進位字串用的。 再留意到2022行,為什麼遇到'\\'要讓YYCURSOR++呢。因為在字串中\後邊帶的是逸出字元,這裡讓YYCURSOR++的目的就是為了跳過下一個字元,例如:'\'',如果不跳過第二個單引號的話,我們掃描到第二個引號就會認為字串結束了。 接下去的處理就比較簡單了,從輸入資料流中取出字串的內容,返回一個T_CONSTANT_ENCAPSED_STRING的Token標誌。 雙引號的字串處理就複雜一點了: 雙引號裡邊是支援變數的。$hello = "Hello"; $str = "${hello} World"; 留意到2085行,如果雙引號字串裡邊沒有變數,直接就返回一個字串了,從這裡看出,其實雙引號字串在沒有包含$的情況下的效率跟單引號字串是差不多的。 如果遇到了變數。這個時候就要切換到ST_DOUBLE_QUOTES狀態了: 現在又回到了尋找變數的規則,其他的規則就不佔篇幅了,討論一個細節,我們回到1871行: 注意到掃描到"$var["這種情況的時候,會壓入一個新的狀態ST_VAR_OFFSET,同時在1889這條規則裡邊有前置條件ST_VAR_OFFSET的存在,這個是為了掃描到$var[$key][$key]這樣的情況,細心點還可以留意到字串裡邊的陣列變數的key是不允許用->的,例如:$str = "$var[$a->s]";這樣是不符合文法的,會出現一個解析錯誤:Parse error: syntax error, unexpected '-', expecting ']' in xxx.php
PHP魔術變數 PHP魔術變數分為編譯時間替換以及運行時替換,詞法規則檔案裡邊的1593-1722行定義了以下魔術變數: __CLASS__, __TRAIT__, __FUNCTION__, __METHOD__, __LINE__, __FILE__, __DIR__, __NAMESPACE__ 魔術變數的剖析留到之後再寫,留意到__contruct這類並不在詞法聲明的規則裡邊出現。
PHP的容錯機制 在前邊說單行注釋的時候已經描述了一種容錯機制,在文法檔案的1490行,2432行均有詞法分析階段的容錯機制。
結語 文章中還忽略了單字元的詞素(規則位於1454行)以及強制類型轉換的規則(例如:(int)$str, 規則位於1230行),Zend引擎的在詞法分析階段開始前還會檢查檔案的編碼問題以及檔案流的操作問題,之後再找篇文章細細研究一下這兩塊的內容。最後不由得不感歎一下,儘管對編譯原理的熟悉程度不高,但是re2c的書寫出來的規則真心容易懂。