PHP内核 - 玩转php的编译与执行

109 阅读5分钟

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语法分析进一步建立完整的抽象语法树。