PHP內核 - 玩轉php的編譯與執行

0x00 寫在開頭

曾幾何時php一不小心闖入了我生活,php語法竟然和C語言那麼莫名的相似,這是最初php給我的感受,當接觸的php時間越來越多的時候,php也沒有那般生澀難懂,但是偶爾一些的新的php 設計思想,也會思考許久,不知是從什麼時候開始了php另一個世界。我想應該是從那次的類型轉換開始的,"1e12"字符串類型在轉化爲數字類型變量時,不同的php版本下轉換結果截然不同,有的就變成了數字1,有的卻可以正常的識別爲科學計數法10^12,在這個地方就已經悄悄的埋下了一枚種子。

到後來的使用php://filter/string.strip_tags/resource包含文件時爲什麼會出現SegmentFault,在HCTF2017上初識orange帶來phar的metadata反序列化0day,溯源使用imap_open到底是如何繞過disable_function限制的,在WP5.0 RCE中mkdir的差異,到今年四月份在twitter看見的chdir 配合ini_set繞過open_basedir的限制。echo,eval 語法結構的分析,create_function的代碼注入,各種各樣的PHP內部的hook,php擴展的編寫,到最近的SG的zend擴展加密…

這一路看來,我早已經陷入php的魅力無法自拔。不知道在這篇文章面前的你們,是否也曾有過像我那般想要領略php神祕內部的衝動?有些人卻忘而生畏,無從下手。希望你們讀完此篇,能點燃那顆微弱甚至熄滅的嚮往,或者是在你們的衝動上再加一把火。讀完之後若有所感,便是對本文最大的肯定了。

0x01 概述

php是一門針對web的專屬語言,但是隨着這麼長時間發展,其實已經可以用php做很多事了,甚至語法結構的複雜度在趨近於java,還有即將出來的JIT,php的未來變的很難說。

儘管如此php還是一門解釋型語言。解釋型語言相對於靜態編譯型語言最大的特點就是他有一個特殊的解釋器。利用解釋器去執行相應的操作,例如php代碼是不會再去被翻譯成機器語言再去執行的。

例如在php 中

<?php
$a = 1+1;
?>

那麼在相應的解釋器裏面比如存在,一個與之相對應的解釋過程,可能是一個函數例如

int add(int a, int b){
    return a+b;
}

在這裏面就僅需要調用這個add函數去解釋這個加法表達式的賦值過程。那麼問題來了php的解釋器是怎樣的一種呈現過程呢?由此引出php的核心ZendVM(虛擬機)。

如果想要弄清楚我們寫的phpCode最後是如何被正確的運行的,就需要去了解Zend VM到底做了什麼?也正是因爲ZendVM賦予了php跨平臺的能力。所以相同的phpCode可以不需要修改就運行在處於不同平臺的解釋器上。這一點需要知道。

其實虛擬機大多都一樣,都是模擬了真實機器處理過程。不同是的運算符,數據類型的定義存在差異。在具體的語法邏輯結構上,大多都大同小異,例如if,switch,for這些流程控制,還有在函數的調用上。所以在探究一個虛擬機的內部結構時,你需要有一個明確的目標:

  • 虛擬機內部用來描述整個執行過程的指令集。
  • 單個指令對應的解釋過程。

清楚以上兩點,再來探究ZendVM。同樣ZendVM有編譯和執行兩個模塊。編譯過程就是將phpCode編譯爲ZendVM內部定義好的一條一條的指令集合,再通過執行器去一步一步的解釋指令集合。

單條的指令在php裏面被稱爲"opline",指令的定義內容可以結合彙編的相關知識理解。例如彙編語言中

add eax,edx
jmp    10000

其中有兩個關鍵字add和jmp,這是彙編語言內部定義的指令集合中的兩個。同樣在php也有像類似的指令關鍵字叫做opcode,指令關鍵字後面是改指令處理的數據,簡稱爲操作數。單條指令可能有兩個操作數op1,op2,也可能只有一個op1,也可能存在一個操作數都沒有的情況,但至多隻有兩個操作數。那麼指令是如何使用操作數,首先必須知道它的類型和具體的數據內容。這裏可以具體看一下ZendVM內部定義的單條opline結構:

Opline

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;
};
 
typedef struct _zend_op zend_op;

可以看到不僅有兩個操作數的op1和op2的定義,還有一個result變量,這個是變量是標識單條opline執行的返回值,當出現使用函數返回值賦值時,多個變量連續賦值,變量賦值出現在if判斷語句裏面時,在這幾種情況下result變量就會被用到。

如果有想看到底定義了哪些opcode的同學,可以在zend/zend_vm_opcodes.h裏面去看,本文使用的php版本爲7.4.0-dev,一共有199條opcode。

下面簡單解釋一下,zend_op這個結構裏面znode_op,zend_uchar這些結構的含義。可以看到一個操作數是有前面這兩種結構定義的相關變量,分別指向的是操作數內容和操作數類型,操作數的類型可以分爲下面5種

#define IS_UNUSED    0        /* Unused operand */
#define IS_CONST    (1<<0)
#define IS_TMP_VAR    (1<<1)
#define IS_VAR        (1<<2)
#define IS_CV        (1<<3)    /* Compiled variable */
  • UNUSED 表示這個操作數並未使用
  • CONST 表示操作數類型是常量。
  • TMP_VAR爲臨時變量,是一種中間變量。出現再複雜表達式計算的時候,比如在進行字符串拼接(雙常量字符串拼接的時候是沒有臨時變量的)。
  • VAR
    一種PHP內的變量,大多數情況下表示的是單條opline的返回值,但是並沒有顯式的表現出來,列如在if判斷語句包含某個函數的返回值,if(random()){},在這種情況下random()的返回值就是VAR變量類型。
  • CV變量,是在php代碼裏面顯式的定義的出來的變量例如$a等。

Znode_op
接下來是操作數的內容znode_op

typedef union _znode_op {
    uint32_t      constant;
    uint32_t      var;
    uint32_t      num;
    uint32_t      opline_num; /*  Needs to be signed */
#if ZEND_USE_ABS_JMP_ADDR
    zend_op       *jmp_addr;
#else
    uint32_t      jmp_offset;
#endif
#if ZEND_USE_ABS_CONST_ADDR
    zval          *zv;
#endif
} znode_op;

znode_op其實一個union結構。其實可以分爲兩種情況來談,相對尋址和絕對尋址。從定義的宏分支裏面也可以看出來。這裏就需要先介紹一下,關於opline裏面的操作數是在哪分配的。先引出我們的zend_op_array

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 */
 
    int cache_size;     /* number of run_time_cache_slots * sizeof(void*) */
    int last_var;       /* number of CV variables */
    uint32_t T;         /* number of temporary variables */
    uint32_t last;      /* number of opcodes */
 
    zend_op *opcodes;
    ZEND_MAP_PTR_DEF(void **, run_time_cache);
    ZEND_MAP_PTR_DEF(HashTable *, static_variables_ptr);
    HashTable *static_variables;
    zend_string **vars; /* names of CV variables */
 
    uint32_t *refcount;
 
    int last_live_range;
    int last_try_catch;
    zend_live_range *live_range;
    zend_try_catch_element *try_catch_array;
 
    zend_string *filename;
    uint32_t line_start;
    uint32_t line_end;
    zend_string *doc_comment;
 
    int last_literal;
    zval *literals;
 
    void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};

zend_op_array是包含編譯過程中產生的所有單個opline的集合,不僅僅包含opline的集合數組同樣,還含有其他在編譯過程動態生成的關鍵數據,這裏先簡單介紹一下其中幾種。

  • vars變量包含CV變量名的指針數組。CV變量前面也已經提到過了就是,由$定義的php變量。這裏的vars相當於一張CV變量名組成的表,是不存在重複變量名的,對應的變量值存儲在另外一個結構上。
  • last_var 表示最後一個CV變量的序號。其實也可以代表CV變量的數量。
  • literals 是存儲編譯過程中產生的常量數組。根據編譯過程中依次出現的順序,存放在該數組中.
  • last_literal表示當前儲存的常量的數量。
  • T 表示的是TMP_VAR和VAR的數量。

Zend_execute_data
以上就是操作數部分信息儲存的地方。可以看到在zend_op_array裏面僅分配了CV變量名數組,但是這裏面並沒有儲存CV變量值的地方,同樣TMP_VAR和VAR變量亦是如此,也只有一個簡單數量統計。對應的變量值儲存在另外一個結構上,那麼他們的具體的值應該在什麼樣的結構上分配呢?接着又引出了zend_execute_data結構。

struct _zend_execute_data {
    const zend_op       *opline;           /* executed opline                */
    zend_execute_data   *call;             /* current call                   */
    zval                *return_value;
    zend_function       *func;             /* executed function              */
    zval                 This;             /* this + call_info + num_args    */
    zend_execute_data   *prev_execute_data;
    zend_array          *symbol_table;
#if ZEND_EX_USE_RUN_TIME_CACHE
    void               **run_time_cache;   /* cache op_array->run_time_cache */
#endif
};

zend_execute_data相當於在執行編譯oplines的Context(上下文),是通過具體的某個zend_op_array的結構信息初始化產生的。所以一個zend_execute_data對應一個zend_op_array,這個結構用來存儲在解釋運行過程產生的局部變量,當前執行的opline,上下文之間調用的關係,調用者的信息,符號表等。所以我們想要知道的CV變量,TMP_VAR, VAR變量其實是分配在這個結構上面的,而且還是動態分配緊挨在這個結構後面的。接下來看一看這些變量是怎麼依附在這個結構後面的。

關於分配順序,首先是分配CV變量,然後就是依次出現的VAR,TMP_VAR變量。關於在動態分析取這個局部變量區裏面的值時,需要注意幾點,網上基本都是千篇一律的 (zval *)(((char *)(execute_data))+96)這樣去取第一個值對吧,其實有時候你發現你取的根本不正確,需要注意的是:

  • sizeof(zend_execute_data) 需要注意的是你用的php版本中zend_execute_data
    結構的大小,其實有時候並不是96,我這裏就是72。動態分配的變量在zend_execute_data結構的末尾,所以你需要提前知道這個結構的大小。
  • 如果你傻乎乎現在又+72,你發現取的是不對的,明明是在zend_data結尾取的值,爲什麼還是還不對?這過程需要注意的是,這中間存在一個16的對齊過程,如下,zend_execute_data分配的大小是按照sizeof(zval)的整數倍來分配的,即16對齊。
#define ZEND_CALL_FRAME_SLOT \
    ((int)((ZEND_MM_ALIGNED_SIZE(sizeof(zend_execute_data)) + ZEND_MM_ALIGNED_SIZE(sizeof(zval)) - 1) / ZEND_MM_ALIGNED_SIZE(sizeof(zval))))
 
static zend_always_inline uint32_t zend_vm_calc_used_stack(uint32_t num_args, zend_function *func)
{
    uint32_t used_stack = ZEND_CALL_FRAME_SLOT + num_args;
 
    if (EXPECTED(ZEND_USER_CODE(func->type))) {
        used_stack += func->op_array.last_var + func->op_array.T - MIN(func->op_array.num_args, num_args);
    }
    return used_stack * sizeof(zval);
}

綜上大概明白了CV變量,TMP_VAR變量,VAR變量儲存位置,再來談opline中操作數內容如何獲取。

  • 可以通過znode_op.var , znode_op.constant來相對尋址,var代表是CV,TMP_VAR,VAR相對位置,即這裏就是0x50,0x60,0x70這樣相對於zend_execute_data結構起始地址。一般情況下是這樣表示的
  • 同樣也可以直接尋址直接用zval *指針尋址。
  • 在jmp 跳轉裏面也存在直接跳轉和間接跳轉。

你會發現這裏面沒有講到opline裏面handler字段,關於opline中 handler的具體細節會在後面詳細介紹。概要也差不多介紹到這裏,主要需要對這些經常用到結構有一個印象(zend_op,znode, opcode_array,execute_data)。下面就開始具體的介紹細節的實現過程,這些結構具體應用在哪些地方。

0x02 編譯過程

整個編譯過程是整個PHP代碼範圍的從開始到結束,在PHP裏面沒有main函數一說,直接從頭編譯到尾,其實從到開始到結尾已經算是main函數的範圍了,除了函數,類的定義以外。編譯的結果是一條一條對應的opline集合。編譯原理其實和大多數語言的編譯器一樣,都需要進行詞法分析和語法分析。PHP開始階段也是如此,在php7.0的版本中在這個兩個步驟之後增加了一步生成AST語法樹,目的是將PHP的編譯過程和執行過程解耦。抽象語法樹就處於了編譯器和執行器的中間,如果只需要調整相關的語法規則,僅僅需要修改編譯器生成抽象語法樹的相關規則就行,抽象語法樹生成的opline不變。相反你修改新的opcode但是語法規則並不變,只需要修改抽象語法樹編譯成opline的過程即可。

詞法分析過程就是一個把PHP代碼拆分的過程,按照定義好的token去匹配分割。詞法分析就是將分割出來的token再按照語法規則重新組合到一起。PHP內詞法分析和語法分析分別使用的是re2c和yacc來完成的。其實準確來說一個應該是re2c和bison。

在研究和探索這個方面的同學一定要注意,不要去細看經過re2c和bison預處理生成的.c文件。這部分都是自動生成,看起來其實有點費時費力也毫無意義。但是你可以對比起來看,最重要是明白re2c和yacc的語法,如果你想要了解這個過程真正做了什麼。

re2c
首先從大的方向來看re2c就是一個用正則來分割token的東西,將我們的php代碼分割一個個在php代碼裏面會用到的關鍵字或者是關鍵符號,如果你想快速的瞭解是如何分割token的,其實也不用去看re2c的處理過程。可直接用php 的內置函數token_get_all,通過傳入指定的php代碼,將會指定的token數組,如下

<?php
var_dump(token_get_all('<?php print(1);'));
 
array(6) {
  [0] =>
  array(3) {
    [0] =>
    int(379)
    [1] =>
    string(6) "<?php "
    [2] =>
       int(1)
  }
  [1] =>
  array(3) {
    [0] =>
    int(266)
    [1] =>
    string(5) "print"
    [2] =>
    int(1)
  }
  [2] =>
  string(1) "("
  [3] =>
  array(3) {
    [0] =>
    int(317)
    [1] =>
    string(1) "1"
    [2] =>
    int(1)
  }
  [4] =>
  string(1) ")"
  [5] =>
  string(1) ";"
}

可以看到是返回的token數組又是一個一個的數組單元,其中依次返回是token對應的整數值,token內容,行號。注意到其中有幾個token ();並不是以數組返回的,而是是直接返回的內容,這裏是因爲;:,.[]()|^&±/*=%!~$<>?@這樣簡單的單字符都是以原字符返回。如果想要得到token的標識符名稱,可以通過token_name內置函數來轉換。如果有同學知道php-parser的話,其實php-parser中的lexer也是應用這兩個內置函數,php-parser是一個很不錯的工具,可以解決絕大部分在php層面上的混淆,後面會簡單的介紹一下。

具體去看看用re2c寫的語法,其實你會發現其實可以解決很多在你心中的困惑,php裏面對應的lexer函數是lex_scan,re2c核心的語法也在其中。

/* php-src/Zend/zend_language_scanner.l lex_scan() */
/*!re2c
re2c:yyfill:check = 0;
LNUM    [0-9]+
DNUM    ([0-9]*"."[0-9]+)|([0-9]+"."[0-9]*)
EXPONENT_DNUM    (({LNUM}|{DNUM})[eE][+-]?{LNUM})
HNUM    "0x"[0-9a-fA-F]+
BNUM    "0b"[01]+
LABEL    [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*
WHITESPACE [ \n\r\t]+
TABS_AND_SPACES [ \t]*
TOKENS [;:,.\[\]()|^&+-/*=%!~$<>?@]
ANY_CHAR [^]
NEWLINE ("\r"|"\n"|"\r\n")
...
*/

在這裏我挑幾處有意思的語法講一講,re2c並不是一個全自動的詞法分析器,用戶需要給它提供一些接口,這裏的yyfill就是一個動態填充輸入值的接口,在這裏表示不需要在分割的過程中動態分配輸入值,即不要考慮在掃描的過程中填充用來繼續被分割的值,因爲在獲取文件內容的時候,是一次性把文件的全部內容映射到了內存中。有興趣的同學可以去看一看open_file_for_scanning()中的具體實現過程。

re2c語法看起來是不是和正則特別像,其實就是正則,只不過是通過C中goto 和 switch 或者if語法組合起來呈現。從定義的字面類型來看,整形,浮點型,指數表示,十六進制,二進制等這些都是php可能會用到的數據類型,其中定義了LABEL類型,可能有些同學就不知道這是用來表示什麼的,其實這就是php裏面變量名的定義,除了不能用數字開頭以外,你會發現php變量名竟然也可以用[\x80-\xff]這些ascii裏面的擴展字符來定義變量名,其實這個東西已經應用到了一些php的變量名混淆上,你有時候可能會發現有些變量名根本不可讀,可能就採用擴展字符來重新定義。細心的你可能會發現,在上面一行定義16進制和2進制這些轉義類型的時候,用的是雙引號,用雙引號括起來的字符串,在re2c的語法裏面表示是對大小寫敏感,爲什麼這裏是雙引號呢?在php裏面0Xff這樣表示也是可以的,這就涉及到re2c預處理時候的傳參了,關於re2c和bison在使用過程中指定的參數可以在/php-src/Zend/Makefile.fragments找到。裏面re2c的參數選項裏面多了一個–case-inverted大小寫敏感的翻轉,即現在是雙引號表示對大小寫不敏感。在後面也可看到是php對關鍵字的大小寫都是不敏感的。

接着後面就是一個規則對應一個處理過程,一般的處理過程就是匹配規則,返回對應的token標識符。有一些會做特殊處理例如雙引號單引號等這些包裹字符串的字符可能不會返回單字符,可能會接着掃描至完整的字符串,返回常量的token標誌。可能有同學不理解每一個規則之前都有一部分用<>包裹的內容:

<INITIAL>"<?php"([ \t]|{NEWLINE}) {
    HANDLE_NEWLINE(yytext[yyleng-1]);
    BEGIN(ST_IN_SCRIPTING);
    if (PARSER_MODE()) {
        SKIP_TOKEN(T_OPEN_TAG);
    }
    RETURN_TOKEN(T_OPEN_TAG);
}
 
<ST_IN_SCRIPTING>"function" {
    RETURN_TOKEN(T_FUNCTION);
}

這一部分表示lexer 當前狀態,開始是初始化狀態,需要找到php代碼的起始符,接着進入<ST_IN_SCRIPTING>狀態,纔會接着去掃描php代碼內的token,相當於一種lexer的嵌套。lex_scan有兩種返回方式,token的標識符會通過lex_token函數值返回。一些token僅需要返回token標識符就就夠了,有一些需要返回token對應的具體的內容,內容的返回值是以抽象語法數的節點類型返回,通過在調用lex_scan時傳遞的elem參數,elem是個union結構

typedef union _zend_parser_stack_elem {
    zend_ast *ast;
    zend_string *str;
    zend_ulong num;
} zend_parser_stack_elem;

把分割出來的token放到後面語法分析用來存儲token的棧中,這個類型在yyac匹配語法時的指定爲YYSTYPE,在匹配語法會根據定義的%type,轉化爲指定zend_parser_stack_elem中的一種類型。到此re2c也再無神祕之處,理一下大概可分爲,正則規則對應處理過程,在處理的過程中一定會返回token,可能會切換lexer的狀態或者返回具體的token內容。其中還有一個SCNG宏,是對定義的scanner_global全局變量的取值操作。這個變量結構如下包含了lexer當前處理的指針位置,狀態,結束指針,記錄的最後一次token位置等。

struct _zend_php_scanner_globals {
    zend_file_handle *yy_in;
    zend_file_handle *yy_out;
 
    unsigned int yy_leng;
    unsigned char *yy_start;
    unsigned char *yy_text;
    unsigned char *yy_cursor;
    unsigned char *yy_marker;
    unsigned char *yy_limit;
    int yy_state;
    zend_stack state_stack;
    zend_ptr_stack heredoc_label_stack;
    zend_bool heredoc_scan_ahead;
    int heredoc_indentation;
    zend_bool heredoc_indentation_uses_spaces;
 
    /* original (unfiltered) script */
    unsigned char *script_org;
    size_t script_org_size;
 
    /* filtered script */
     unsigned char *script_filtered;
    size_t script_filtered_size;
 
    /* input/output filters */
    zend_encoding_filter input_filter;
    zend_encoding_filter output_filter;
    const zend_encoding *script_encoding;
 
    /* initial string length after scanning to first variable */
    int scanned_string_len;
 
    /* hooks */
    void (*on_event)(zend_php_scanner_event event, int token, int line, void *context);
    void *on_event_context;
};

yacc && bison
接下來就是yacc語法分析器,yacc對應的功能函數在php裏面爲zendparse(),這個函數其實預處理自動生成的,在這個函數通過不斷的調用lex_scan返回token,根據定義的語法規則動態的生成抽象語法數,挑出一些有代表性的yacc語法規則來描述一下

%left '|'
%left '^'
%left '&'
%nonassoc T_IS_EQUAL T_IS_NOT_EQUAL T_IS_IDENTICAL T_IS_NOT_IDENTICAL T_SPACESHIP
%nonassoc '<' T_IS_SMALLER_OR_EQUAL '>' T_IS_GREATER_OR_EQUAL
%left T_SL T_SR
%left '+' '-' '.'
%left '*' '/' '%'

這裏定義的是運算符類的token的優先級和結合性。後定義的優先級要高,在同行定義的優先級相同,結合性就看是%left還是%right,%left代表從左到右,同理%right反之,其實結合性就相當於同級之間的優先級。這些都會在yacc狀態機裏面體現出

%token <ast> T_LNUMBER   "integer number (T_LNUMBER)"
%token <ast> T_DNUMBER   "floating-point number (T_DNUMBER)"
%token <ast> T_STRING    "identifier (T_STRING)"
%token <ast> T_VARIABLE  "variable (T_VARIABLE)"
%token <ast> T_INLINE_HTML
%token <ast> T_ENCAPSED_AND_WHITESPACE  "quoted-string and whitespace (T_ENCAPSED_AND_WHITESPACE)"
%token <ast> T_CONSTANT_ENCAPSED_STRING "quoted-string (T_CONSTANT_ENCAPSED_STRING)"
%token <ast> T_STRING_VARNAME "variable name (T_STRING_VARNAME)"
%token <ast> T_NUM_STRING "number (T_NUM_STRING)"

%token開頭定義的表示語法規則裏面會用到的token,也是語法規則的終結符。其中 表示在使用token時候會進行類型的轉換,所有的token類型定義在YYSTYPE中,這個結構前面也說過了是一個聯合體,在yacc自動的生成yyparse函數下,獲取的token對應的內容會保留在yylval中,所以在使用的時候,會進行yylval.ast類似的操作。

%type <ast> top_statement namespace_name name statement function_declaration_statement
%type <ast> class_declaration_statement trait_declaration_statement
%type <ast> interface_declaration_statement interface_extends_list
%% /* Rules */
start:
    top_statement_list    { CG(ast) = $1; }
;
 
top_statement_list:
        top_statement_list top_statement { $$ = zend_ast_list_add($1, $2); }
    |    /* empty */ { $$ = zend_ast_create_list(0, ZEND_AST_STMT_LIST); }
;

%type定義就是非終結符,非終結字符常常是自己和token組合在一起的遞歸嵌套符。同樣它也有類型的定義。後面就是描述非終結字符是如何嵌套的,有一個特殊的start節點,yacc在開始掃描語法的規則的時候只關注它,相當於入口點。可以看到起始是以top_statement_list標識符,它是可以爲空的,所以每次語法掃描的第一步就是CG(ast) = zend_ast_create_list(0, ZEND_AST_STMT_LIST),建立一個根節點,但是這個根節點也不做。如果你真的想看看yacc內部掃描語法的,不要去看經過bison預處理之後的.c文件,同級目錄下有一個.output後綴相同文件名的文件,裏面描述了yacc裏面的狀態機是如何工作的。可能還是有點看不懂,重新拿bison處理一遍,把trace打開,再重新把php編譯一遍,再用php運行代碼的過程中就會輸出狀態機的狀態和轉移。

bison -p zend -v -d -t $(srcdir)/zend_language_parser.y -o zend_language_parser.c

最好用bison的版本和你在看php版本使用的相同,在zend_language_parser.c中開頭會顯示bison的版本,翻譯完成替換原來的zend_language_parser.c 和 zend_language_parser.h,這個時候需要再處理一下,再加點東西,在輸出debug過程中,它不會自己輸出相對於的token的值,因爲前面說道過了token的值類型是zend_parser_stack_elem,是我們自定義的,同樣如果我們想要打印token具體的值,需要自己提供接口,yacc也一個宏YYPRINT,在這裏可以不用爲它這個宏提供個函數。如果你只想看每次從lex_scan拿來的token對應的內容是什麼,可以這樣寫。

static void
yy_symbol_print (FILE *yyoutput, int yytype, YYSTYPE const * const yyvaluep)
{
  YYFPRINTF (yyoutput, "%s %s (",
             yytype < YYNTOKENS ? "token" : "nterm", yytname[yytype]);
  char *ztext = LANG_SCNG(yy_text); //+
  unsigned int zlen = LANG_SCNG(yy_leng);//+
  unsigned int i = 0;//+
  for(i;i<zlen;i++){//+
    php_printf("%c",*(ztext+i));//+
  }+
  //yy_symbol_value_print (yyoutput, yytype, yyvaluep);//-
  YYFPRINTF (yyoutput, ")");
}

添加里面其中一段代碼就行,把yy_symbol_value_print註釋掉,這是在用bison預處理之後在zend_language_parser.c裏面添加的哦。你會發現這樣做,不僅不僅在從lex_scan拿到token會用到這個函數,後面語法規則匹配以後也會用這個函數來輸出匹配字符的token值,這樣會導致一直輸出同樣的token值,直到下次再次從lex_scan中拿到新token值。再稍微改一下,

static void
yy_symbol_value_print (FILE *yyoutput, int yytype, YYSTYPE const * const yyvaluep)
{
  FILE *yyo = yyoutput;
  YYUSE (yyo);
  if (!yyvaluep)
    return;
#ifdef YYPRINT
  if (yytype < YYNTOKENS){
     zval sym;
      sym =((zend_ast_zval *)(yyvaluep->ast))->val;
      switch(yytoknum[yytype]){
      case 317:
        php_printf("%d",sym.value.lval);
        break;
      case 325:
          if (sym.u1.v.type==IS_LONG){
            php_printf("%d",sym.value.lval);
            break;
          }
      case 321:
      case 323:
        for(int i=0;i<(sym.value.str)->len;i++){
            php_printf("%c",*(((sym.value.str)->val)+i));
        }
        break;
      case 318:
        php_printf("%d",sym.value.dval);
        break;
        default:
        php_printf("%d",yytoknum[yytype]);
    }
  }
# endif
  YYUSE (yytype);
}

注意這次改的地方是yy_symbol_value_print,記得要在前面在簡單定義一下YYPRINT這個宏,因爲需要yytoken這個映射表,這裏根據映射表返回的token數字量,token的數字量在zend_language_parser.h定義,判斷token類型,可以看到帶返回值的token其實也只有三種,IS_SRTING,IS_LONG,IS_DOUBLE。字符串類型上出現了3個不一樣的token,323就是字符串常量,321也好理解內聯的php標籤外的html字符串。這個325處T_NUM_STRING有點意思,我這地方發現了php一個一直存在的語法錯誤?可以看到其實這個token的返回值zval有兩種不同的類型整形和字符串。具體的我們去看看re2c是怎麼匹配返回這個token的

<ST_VAR_OFFSET>[0]|([1-9][0-9]*) { /* Offset could be treated as a long */
    if (yyleng < MAX_LENGTH_OF_LONG - 1 || (yyleng == MAX_LENGTH_OF_LONG - 1 && strcmp(yytext, long_min_digits) < 0)) {
        char *end;
        errno = 0;
        ZVAL_LONG(zendlval, ZEND_STRTOL(yytext, &end, 10));
        if (errno == ERANGE) {
            goto string;
        }
        ZEND_ASSERT(end == yytext + yyleng);
    } else {
    string:
        ZVAL_STRINGL(zendlval, yytext, yyleng);
    }
    RETURN_TOKEN_WITH_VAL(T_NUM_STRING);
}
 
 
<ST_VAR_OFFSET>{LNUM}|{HNUM}|{BNUM} { /* Offset must be treated as a string */
    if (yyleng == 1) {
        ZVAL_INTERNED_STR(zendlval, ZSTR_CHAR((zend_uchar)*(yytext)));
    } else {
        ZVAL_STRINGL(zendlval, yytext, yyleng);
    }
    RETURN_TOKEN_WITH_VAL(T_NUM_STRING);
}
 
<ST_DOUBLE_QUOTES,ST_HEREDOC,ST_BACKQUOTE>"$"{LABEL}"[" {
    yyless(yyleng - 1);
    yy_push_state(ST_VAR_OFFSET);
    RETURN_TOKEN_WITH_STR(T_VARIABLE, 1);
}

可以看到匹配返回這個token必須得在"$a[offset]"得在這種類似的情況纔行,而且得在雙引號或者<<<或者反引號的包裹下,就是能進行字符串轉義。在匹配offset內容的時候,第一條規則是匹配10進制的純數字,第二條規則是匹配0,0x,0b這樣開頭不同進制的數字類型。這樣看來是比較合理的,在offset的選擇上是支持不同進制的,但是在處理上確是不一樣的。例如我下面的PHP代碼

<?php
$a="123456";
echo "$a[0x2]";

在語法上是通過的,但是出現結果確是不一樣的。對應的opcode爲FETCH_DIM_R !0 , ‘0x2’,操作數1是CV變量,操作數爲CONST字面量,找到相應的hanlder

ZEND_FETCH_DIM_R_SPEC_CV_CONST_HANDLER()

這裏我不再累贅,只看最後的處理,具體的調用棧如下

#0  is_numeric_string (str=0x7ffff5402* "0x2", length=0x3, lval=0x0, dval=0x0, allow_errors=0xffffffff) at /root/php-src/Zend/zend_operators.h:142
#1  0x0000555555b99d9b in zend_fetch_dimension_address_read (result=0x7ffff541f090, container=0x7ffff541f070, dim=0x7ffff54824b0, dim_type=0x8, type=0x0, support_strings=0x1, slow=0x1) at /root/php-src/Zend/zend_execute.c:1882
#2  0x0000555555b9a285 in zend_fetch_dimension_address_read_R_slow (container=0x7ffff541f070, dim=0x7ffff54824b0) at /root/php-src/Zend/zend_execute.c:1971
#3  0x0000555555bede6a in ZEND_FETCH_DIM_R_SPEC_CV_CONST_HANDLER () at /root/php-src/Zend/zend_vm_execute.h:39187
#4  0x0000555555c0a694 in execute_ex (ex=0x7ffff541f020) at /root/php-src/Zend/zend_vm_execute.h:59035
#5  0x0000555555c0b971 in zend_execute (op_array=0x7ffff5482300, return_value=0x0) at /root/php-src/Zend/zend_vm_execute.h:60223
#6  0x0000555555b3a65d in zend_execute_scripts (type=0x8, retval=0x0, file_count=0x3) at /root/php-src/Zend/zend.c:1608
#7  0x0000555555aaa5a7 in php_execute_script (primary_file=0x7fffffffdd80) at /root/php-src/main/main.c:2643
#8  0x0000555555c0e3f9 in do_cli (argc=0x2, argv=0x55555654b060) at /root/php-src/sapi/cli/php_cli.c:997
#9  0x0000555555c0f379 in main (argc=0x2, argv=0x55555654b060) at /root/php-src/sapi/cli/php_cli.c:1390

最後是用is_numeric_string處理的我們的0x2偏移量,這個過程竟然只是一個php內部弱類型轉換,從字符串到數值的類型轉換,也就是說並不會對除10進制以外的數字變量進行轉換。其他進制的數字串永遠置零,那在語法上爲什麼還要匹配呢? php內部是有一個zend_strtod,卻並沒有在此處使用,明顯的handler沒有與語法對應上。php7.0在此處會給出警告,5.x版本不會給警告,但是結果依然都是錯的。。。

上面相當於一個小插曲。yacc和re2c的介紹到這裏也差不多了,也應該可以上手改一改語法了吧,在這裏再講一個有趣的語法結構print,我不知道有多少人看過鳥哥博客那段

print(1) && print(2) && print(3) && print(4);

在不運行之前,你是否知道它的結果?你可以先不看下面的解答,先自己想想爲什麼會這樣?

其實這個問題需要在語法分析這個階段來看,可以先去yacc裏面關於print的語法結構。

expr : T_PRINT expr { $$ = zend_ast_create(ZEND_AST_PRINT, $2); }

可以看到T_PRINT 是在expr遞歸的語法裏面的,T_PRINT左邊是expr,無論多麼複雜最後都會遞歸成最後一個expr,並且T_BOOLEAN_AND (&&)優先級 大於 T_PRINT,且T_BOOLEAN_AND (&&)結合性是從左到右。

停止遞歸的點

expr1 : print (4)    // expr:T_PRINT expr:scalar
expr2 : 3 && expr1      // expr: expr '&&' expr
expr3 : print expr2  // expr:T_PRINT expr
expr4 : 2 && expr2   // expr:expr '&&' expr
expr5 :print expr4  // expr:T_PRINT expr
expr6 : 1 && expr5  // expr:expr '&&' expr
expr7 : print expr6  //expr: T_PRINT expr
statement1 : expr7 ;  // statement: expr ';'
top_statement1: statement1 // op_statement : statement
top_statement_list: top_statement_list top_statement1 // zend_ast_list_add($1, $2);

簡單的寫了一遍yacc狀態機走的過程,現在看起來應該再清晰不過了吧。print這個語法結構應該是最像function的一個結構。如果有興趣也可以去分析分析echo,include 這些語法結構。

yacc和re2c到這裏真的就結束了。抽象語法樹其實是和它們耦合在一起的,雖然把編譯器和執行器隔開了。re2c在返回的token對應的值的時候,就是以抽象語法樹節點返回的。再通過yacc語法分析進一步建立完整的抽象語法樹。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章