第10章 词法和语法分析

1,348 阅读14分钟

PHP如何执行一段代码呢?以PHP 7为例,当PHP收到一个请求或执行命令时,会根据参数去加载对应的PHP代码,进行词法和语法分析,生成AST,再生成字节码,PHP中称为opcodes,继而在Zend虚拟机中逐行执行字节码,得到结果返回。本章将讨论PHP 7的词法和语法分析的实现。

  • 在PHP 7中,词法分析器代码是使用Re2c(Re2c是一款词法分析器。)生成的,Re2c将PHP7源码中的zend_language_scanner.l文件编译为zend_language_scanner.c。
  • PHP 7的语法分析器是使用Bison生成的,Bison将PHP 7源码中的zend_language_parser.y编译为zend_language_parser.c。

为了更好地理解词法和语法分析的过程,我们首先学习编译原理的基础知识,然后学习Re2c和Bison的语法规则与原理,接着详细展开PHP 7的词法和语法分析的过程与原理。

另外一个重要概念是AST(抽象语法树),这是PHP 7新引入的特性,而AST是编译原理中的基础概念,可以用来表达PHP代码的文法含义,同时,对AST进行深度遍历可以生成对应的opcode,以便在Zend虚拟机中执行,AST的引入为PHP解决了很多语法上的问题。本章会详细介绍AST的数据结构以及生成过程。AST深度遍历生成opcode的过程会在11章详细展开。

10.1 基础知识

在学习PHP 7词法和语法分析之前,我们需要掌握一些编译原理的基本知识,本节会从编译器、语言处理系统、词法分析、语法分析等方面做一些知识准备工作,为后面详细分析PHP 7的词法和语法分析做一个铺垫。

10.1.1 编译器

图10-1 编译器示意图

编译器是一个程序,是可以读取某种语言(称作源语言)编写的程序并将其翻译成一个与之等价的另一种语言(称作目标语言)的程序,如图10-1所示。

10.1.2 源程序分析

整个语言处理过程需要几个程序,分别是预处理器、编译器、汇编器以及装载器/连接器。其中,预处理器程序会把存储在不同文件中的程序模块集成为一个完整的源程序;编译器会把源程序编译为目标汇编程序;而汇编器会继续将目标汇编程序转换为可重定位的机器码;然后经过装载器和连接器转为绝对的机器代码。源程序分析的3个阶段如下。

1)词法分析:又叫线性分析,从左到右读取源程序的字符,并将字符转换为一个又一个的记号,称作Token。Token是具有整体含义的字符序列。

2)语法分析:这个过程会把字符串或者Token在层次上划分为有一定层次的组,每个组有整体的含义。

  1. 词法分析

    在编译器中,词法分析称为线性分析,举个例子,对于如下代码:

    a = b + c * 2
    

    词法分析会将其分成如下几部分:

    1. Token a;
    2. 赋值符号=;
    3. Token b;
    4. 加号+;
    5. Token c;
    6. 乘号*;
    7. 数字2。

    对于表达式里面的空格,词法分析器会将其删除。

    词法分析是编译的第一个阶段,主要任务是读取源程序,生成Token(其中Token可以理解为特殊的标识),提交给语法分析器使用。词法分析器收到语法分析器发出的“取下一个Token”的命令时,词法分析器继续读入源程序的字符,直到识别出下一个Token。示例如图10-2所示。

    图10-2 词法分析器与语法分析器之间的交互

    在词法分析中,经常会使用术语“记号”(Token)、“模式”和“词素”表示特定的含义,通过表10-1可以比较清楚地理解这三者的关系。

    表10-1 Token、模式、词素对照表

    那么如何快速识别一个记号呢?词法分析是用正则表达式来表达和识别记号的,比如PHP 7词法分析中的正则表示如下:

    LNUM [0-9]+    //十进制整型
    DNUM ([0-9]*"."[0-9]+)|([0-9]+"."[0-9]*)  //浮点型
    EXPONENT_DNUM (({LNUM}|{DNUM})[eE][+-]? {LNUM}) //e的幂
    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]* //Tab和空格
    TOKENS [; :, .\[\]()|^&+-/*=%! ~$<>? @]
    ANY_CHAR [^]
    NEWLINE ("\r"|"\n"|"\r\n") //新的行
    

    那么如何快速地判断一段文本是不是满足正则表达呢?下面我们引出编译原理中的状态转换图的概念,举个例子,对于下面的正则表达式:

    (a|b)*abb
    

    我们可以建立一个不确定的有穷自动机(NFA),如图10-3所示。

    图10-3 不确定有穷自动机

    在图10-3所示的不确定有穷自动机中,start表示开始,有两个圈的3表示结束,状态0、1、2表示中间状态,箭头表示根据输入得到的状态流转。比如对于状态0,输入a的时候,可以流转到状态0,也可以流转到状态1。因此,对于状态0,输入a,可以流转的集合为{0,1},为了方便理解,举个例子如下。

    1. 比如输入ababb,对于a, start可以到状态0,对于b继续到状态0,对于a到状态1,对于b到状态2,对于b到状态3,3是结束状态,因此,ababb字符串满足正则表达式(a|b)*abb。
    2. 比如输入aba,对于a到状态0,对于b到状态0,对于a到状态1,结束时不是最终状态,因此aba不满足正则表达式(a|b)*abb。

    根据转换图,我们可以建立对应的转换表,如表10-2所示。

    表10-2 不确定有穷自动机的状态转换表

    如表10-2所示,对于状态0,如果输入a,则可以流转到0或者1,记为{0,1};如果输入b,则可以流转到0,记为{0}。

    对于状态1,如果输入a,则无法进行流转;如果输入b,则可以流转到2,记为{2}。

    对于状态2,如果输入a,则无法进行流转;如果输入b,则可以流转到3,记为{3}。

    NFA为什么叫不确定有穷自动机呢?比如对于状态0,在输入a的时候流转的状态是不确定的,有可能流转到状态0,也有可能流转到状态1,所以对于ababb,有可能一直在状态0中循环而达不到最终状态。因此,编译原理中有对应的算法可以将不确定的有穷自动机(NFA)转换为确定有穷自动机(DFA),具体算法可以参考编译原理相关的书籍,这里不再展开,转换后的DFA如图10-4所示。

    图10-4 确定有穷自动机

    这个将正则转换为NFA/DFA的工作是复杂的,但又是有规律和算法可遵循的,因此可以由转换工具,比如像Re2c这种词法分析器来完成。感兴趣的读者可以阅读一下Re2c的源码。词法分析器的核心工作就是将编写好的正则表达式转换为有穷自动机,将这部分工作解放出来,后面我们会在10.2.2节中对Re2c展开阐述。

  2. 语法分析

    语法分析是进行的层次分析,称为parsing或者syntax analysis,语法分析用于进一步把源程序分组,将源程序语法短语用语法树来表示,以如下代码为例:

    a = b + c * 2
    

    生成的语法分析树如图10-5所示。

    图10-5 语法分析树示意图

    整个代码的层次可以使用递归规则来表达,对于上面的语法树,我们可以定义如下规则:

    1. 任何一个标识符都是表达式。
    2. 任何一个数字(包括a\b\c\2)都是表达式。
    3. 如果exp1和exp2是表达式,则exp1+exp2、exp1*exp2、{epx1}也是表达式。

    规则1和规则2是非递归的基本规则,而规则3使用递归定义了表达式,因此,c2是表达式,a+c2也是表达式。

    通常情况下,词法分析和语法分析的界限是不确定的,决定词法分析和语法分析界限的因素是源语言是否具有递归结构,词法结构一般不要求递归,语法结构常常需要递归。那么如何规范表达语法呢?那就是BNF范式(巴科斯范式)。

10.1.3 BNF范式

BNF范式是一种用递归的思想来表述计算机语言符号集的定义规范,其法则如下:

  1. ::=表示定义。
  2. “ ”双引号里的内容表示字符。
  3. <>尖括号里的内容表示语法单位。
  4. | 竖线两边的是可选内容,相当于or。

如何理解BNF呢?下面举一个英语语法的例子:

The woman has a cat. (这个妇女有一只猫)

上面的<句子>的语法规则可以表达如下:

1. <句子>::=<主语><谓语>
2. <主语>::=<冠词><名词>
3. <冠词>::=the | a
4. <名词>::=woman |  cat
5. <谓语>::=<动词><宾语>
6. <动词>::=has
7. <宾语>::=<冠词><名词>

通过这个简单的例子,相信读者对BNF有了一定的理解,那么我们举一个计算机中的例子,对于包含+/-的表达式,我们可以用下面的BNF表示:

expr:
NUM { ? = $1; }
| expr '+' expr { ? = $1 + $3; }
| expr '-' expr { ? = $1- $3; }
;

对于语句1+2-3,根据规则expr->NUM,可以得出expr+2-3,继续得到expr+expr-3,因为expr+expr又是expr,因此得出expr-3,同样的道理得出expr-expr,最后的结果为expr。

BNF范式在语法分析器Bison中有使用,具体我们会在10.2.2节阐述。


注意

正则表达式能够表达词法,对于正则的判断可以使用有穷自动机来实现,这是后面词法分析器的基础;而BNF范式能够很好地表达文法,是语法分析的基础。


10.2 词法与语法分析器

基于10.1节中词法和语法分析,我们了解了词法和语法的基本知识,词法和语法可以使用正则表达式和BNF范式表达,而最终描述文法含义的是状态转换图,那么在做词法分析和语法分析时,我们需要维护复杂的状态转换图吗?答案是否定的,很多开源软件已经把这个工作做了,开发者只需要编写正则或者BNF范式来维护词法和语法分析代码即可,生成状态转换图的工作交给这些开源软件即可,本节会详细介绍一下词法分析器Lex、Re2c,以及语法分析器YACC和Bison的使用方法。

10.2.1 Lex与YACC

  1. 词法分析器Lex

    词法分析器Lex,是一种生成词法分析的工具,扫描器是识别文本中词汇模式的程序,这些词汇模式是在特殊的句子结构中定义的。Lex接收到文件或文本形式的输入时,会将文本与常规表达式进行匹配:一次读入一个输入字符,直到找到一个匹配的模式。如果能够找到一个匹配的模式,Lex就执行相关的动作(比如返回一个标记Token)。另外,如果没有可以匹配的常规表达式,将会停止进一步的处理,Lex将显示一个错误消息。Lex和C语言是强耦合的,一个.lex文件(Lex文件的扩展名为.lex)通过Lex解析并生成C的输出文件,这些文件被编译为词法分析器的可执行版本。

    lex或者.l文件在格式上分为以下3段。

    1. 全局变量声明部分。
    2. 词法规则部分。
    3. 函数定义部分。

    Lex语法提供了5个变量,如表10-3所示。

    表10-3 Lex变量表

    Lex语法还提供了几个函数,如表10-4所示。

    表10-4 Lex函数

    根据Lex文件的格式、Lex提供的变量和函数,我们可以编写一个简单的示例文件test.l来体会一下Lex的使用,代码如下:

    %{
    #include <stdio.h>
    extern char *yytext;
    extern FILE *yyin;
    int count = 0;
    %}
    %%//两个百分号标记指出了Lex程序中这一段的结束和第二段的开始。
    \$[a-zA-Z][a-zA-Z0-9]*    {count++; printf(" 变量%s", yytext); }
    [0-9\/.-]+     printf("数字%s", yytext);
    =               printf("被赋值为");
    \n              printf("\n");
    [ \t]+        /* 忽略空格 */;
    %%
    //函数定义部分
    int main(int avgs, char *avgr[])
    {
        yyin = fopen(avgr[1], "r");
        if (! yyin)
        {
            return 0;
        }
        yylex();
        printf("变量总数为: %d\n", count);
        fclose(yyin);
        return 1;
    }
    

    对于这段代码,解释如下。

    1. 全局变量声明部分:声明了一个int型全局变量count,用来记录变量的个数。
    2. 规则部分:第1个规则是找符号开头的、第2个字符为字母且后面为字符或数字的变量,类似于a,并计数加1,同时,将满足条件的yytext输出;第2个规则是找数字;第3个规则是找“=”号;第4个规则是输出“\n”;第5个规则是忽略空格。
    3. 函数定义部分:打开一个文件,然后调用yylex函数进行词法解析,输出变量的计数,最后调用fclose关闭文件。代码非常简单,我们执行以下命令,将其编译成可执行文件:
    lex a.l
    gcc lex.yy.c -o test -ll
    

    编写一个测试文件file如下:

    $a = 1
    $b =2
    

    执行如下命令:

    ./test file
    变量$a被赋值为数字1
    变量$b被赋值为数字2
    变量总数为: 2
    

    可以看出,我们只需要写正则表达式,Lex就可以帮我们按照需要找出对应规则的Token。比如变量a、b,数字1和2,以及“=”。Lex给词法分析工作带来了极大的解放。PHP 7并没有直接使用Lex做词法解析,而是用了升级版本Re2c。

  2. 语法分析器YACC

    PHP 7除了词法解析之外,另外的工作就是语法分析,这里介绍一下YACC。YACC (Yet Another Compiler-Compiler)是UNIX/Linux上一个用来生成编译器的编译器(编译器代码生成器)。YACC使用BNF范式定义语法,能处理上下文无关文法。

    YACC语法包括3部分,即定义段、规则段和用户代码段。

    ...定义段...
    %%
    ...规则段...
    %%
    ...用户代码段...
    

    各部分由以两个百分号开头的行分开,前两部分是必需的,第三部分和前面的百分号可以省略。

    举个例子,我们编写一个简单的计算器,YACC代码calc.y如下:

    %{
    #include <string.h>
    #include <math.h>
    %}
    %union {
        double dval;
    }
    %token <dval> NUMBER
    %left '-' '+'
    %left '*' '/'
    %nonassoc UMINUS
    %type <dval> expression
    %%
    statement_list: statement '\n'
        |   statement_list statement '\n'
        
    

    从代码中可以看出,规则部分使用BNF范式。

    1. expression最终是NUMBER,以及使用+、-、*、/和()的组合,对加、减、乘、除、括号、负号进行表达。
    2. statement是由expression组合而成的,可以输出计算结果。
    3. statement_list是statement的组合。

    然后配合Lex进行Token的获取,Lex的代码calc.l如下:

    %{
    #include "y.tab.h"
    #include <math.h>
    %}
    %%
    ([0-9]+|([0-9]*\.[0-9]+)([eE][-+]? [0-9]+)? ) {
            yylval.dval = atof(yytext);
            return NUMBER;
        }
    [ \t]   ;
    \n  |
    .   return yytext[0];
    %%
    

    接下来执行如下命令,使用Lex对calc.l进行处理:

    lex cal.l
    

    会生成lex.yy.c,里面维护了获取NUMBER这个Token的有穷自动机。

    使用YACC对calc.y进行处理:

    yacc -d calc.y
    

    生成的文件为y.tab.c、y.tab.h,可以查看其中的内容,语法分析的入口如下:

    int
    yyparse(void)
    {
        //代码省略//
        switch (yyn)
        {
    case 1:
    #line 14 "calc.y"
    { printf("= %g\n", yyvsp[0].dval); }
    break;
    case 2:
    #line 16 "calc.y"
    { yyval.dval = yyvsp[-2].dval + yyvsp[0].dval; }
    break;
    case 3:
    #line 17 "calc.y"
    { yyval.dval = yyvsp[-2].dval - yyvsp[0].dval; }
    break;
    case 4:
    #line 18 "calc.y"
    { yyval.dval = yyvsp[-2].dval * yyvsp[0].dval; }
    break;
    case 5:
    #line 20 "calc.y"
    {   if(yyvsp[0].dval == 0.0)
            yyerror("divide by zero");
        else
            yyval.dval = yyvsp[-2].dval / yyvsp[0].dval;
    }
    break;
    //代码省略//
    

    通过YACC生成的代码,实际上也是状态机,同时维护了状态转换表。感兴趣的读者可以参考《编译原理》一书,里面有对语法解析的详细阐述,这里不再展开。

    介绍完Lex词法分析器和YACC语法分析器,相信读者对词法分析和语法分析的基本原理有了一定了解。词法分析使用正则表达式来描述Token, Lex会将正则表达式转换为有穷自动机,对语言进行词法分析,就是根据自动机的流转图找到对应的Token。语法分析的基本原理是将文法通过BNF范式来表达,YACC会将对应的文法翻译成状态转换表,以及文法对应的状态机,返回对应的Action。

    不过PHP 7并没有使用Lex和YACC,而是使用了升级版本Re2C和Bison,下面我们使用一些简单的例子来理解一下Re2c和Bison。

10.2.2 Re2c与Bison

  1. 词法分析器Re2c

    Re2c是一个词法编译器,可以将符合Re2c规范的代码生成高效的C/C++代码。跟10.1.2.1节阐述的词法分析一样,Re2c会将正则表达式生成对应的有穷状态机。PHP最开始使用的Flex,后来改为了Re2c,本节会通过一段示例代码来让大家对Re2c有一个初步的认识。

    示例代码num.l如下:

    #include <stdio.h>
    enum num_t { ERR, DEC };
    
    static num_t lex(const char *YYCURSOR)
    {
        const char *YYMARKER;
        /*! re2c
            re2c:define:YYCTYPE = char;
            re2c:yyfill:enable = 0;
    
            end = "\x00";
            dec = [1-9][0-9]*;
    
            *       { return ERR; }
            dec end { return DEC; }
        */
    }
    
    int main(int argc, char **argv)
    {
        for (int i = 1; i < argc; ++i) {
            switch (lex(argv[i])) {
                case ERR: printf("error\n"); break;
                case DEC: printf("十进制表示\n"); break;
            }
        }
        return 0;
    }
    

    按照Re2c的规范,我们定义了十进制的正则表达式,在代码段/*! re2c */中,运行如下命令:

    re2c num.l -o num.c
    

    此时Re2c将.l文件生成了.c文件。打开num.c,我们可以看到,Re2c其实就是帮我们生成了状态机的流转流程:

    /* Generated by re2c 1.0.1 on Sun Nov 26 17:53:532017 */
    #line 1 "num.l"
    #include <stdio.h>
    enum num_t { ERR, DEC };
    
    static num_t lex(const char *YYCURSOR)
    {
        const char *YYMARKER;
    
    #line 10 "num.c"
    {
        char yych;
        yych = *YYCURSOR;
        switch (yych) {
        case '1': case '2': case '3':case '4':
        case '5': case '6': case '7': case '8':
        case '9':   goto yy4;
        default:    goto yy2;
        }
    yy2:
        ++YYCURSOR;
    yy3:
    #line 14 "num.l"
        { return ERR; }
    #line 32 "num.c"
    yy4:
        yych = *(YYMARKER = ++YYCURSOR);
        switch (yych) {
        case 0x00:  goto yy5;
        case '0': case '1': case '2': case '3':
        case '4': case '5': case '6': case '7':
        case '8': case '9':   goto yy7;
        default:    goto yy3;
        }
    yy5:
        ++YYCURSOR;
    #line 15 "num.l"
        { return DEC; }
    #line 53 "num.c"
    yy7:
        yych = *++YYCURSOR;
        switch (yych) {
        case 0x00:  goto yy5;
        case '0': case '1': case '2': case '3':
        case '4': case '5': case '6': case '7':
        case '8': case '9':   goto yy7;
        default:    goto yy9;
    yy9:
        YYCURSOR = YYMARKER;
        goto yy3;
    }
    #line 16 "num.l"
    }
    
    int main(int argc, char **argv)
    {
        for (int i = 1; i < argc; ++i) {
            switch (lex(argv[i])) {
                case ERR: printf("error\n"); break;
                case DEC: printf("十进制表示\n"); break;
            }
        }
        return 0;
    }
    

    从上面代码中可以看出,这个状态机一共有8种状态,分别是开始状态、yy3至yy9状态,其中yy3状态是错误输出状态,返回ERR, yy5状态是对应的正则匹配状态,返回DEC,状态图如图10-6所示。

    图10-6 Re2c生成的DFA

    从图10-6可以看出,YYCURSOR是指向输入的指针,根据状态的流转,指针加1。Re2c的主要功能是将复杂的正则表达式转换为清晰的状态机,而这个状态机的执行速度是非常快的。PHP 7的词法分析状态机,同样由符合Re2c的代码,经过Re2c编译后生成,这个状态机包含了800多个状态,可以参见Zend/zend_language_scanner.c文件。到此,我们可以理解Re2c都做了什么工作,其核心是使用状态机来描述正则表达式,提高分析速度。

    介绍完Re2c,接下来我们介绍Bison,我们依然使用Re2c来生成词法分析器。Re2c可以识别正则表达式,而Bison可以识别语法;Re2c可以把源程序分解为若干个片段(Token),而Bison进一步分析这些Token并基于逻辑进行组合。

  2. 语法编译器Bison

    利用10.1.3节介绍的BNF写出的文法规则,可以对输入的文本进行文法分析。对于一条BNF文法规则,其左边是一个非终结符(symbol或者non-terminal),其右边则定义该非终结符是如何构成的,也称为产生式(production)。产生式可能包含非终结符,也可能包含终结符(terminal),还可能二者都有。利用BNF文法来分析目标文本,比较流行的算法有LL分析(自顶向下的分析,top-down parsing)、LR分析(自底向上的分析,bottom-up parsing;或者叫移进-归约分析,shift-reduceparsing)。其中LR算法有很多不同的变种,按照复杂度和能力递增的顺序依次是LR(0)、SLR、LALR和LR(1)。感兴趣的读者可以参考编译原理相关书籍,这里不再展开。

    Bison是基于LALR分析法实现的,适合上下文无关文法。当Bison读入一个终结符TOKEN时,会将该终结符及其语意值一起压栈,其中这个栈叫作分析器栈(parser stack)。把一个TOKEN压入栈叫作移进。举个例子,对于计算1+23,假设现已经读入了1+2,那么下一个准备读入的是3,这个栈当前就有4个元素,即1、+、2和*。当已经移进的后n个终结符和组(groupings)与一个文法规则相匹配时,它们会根据该规则结合起来,这叫作归约(reduction)。栈中的那些终结符和组会被单个的组(grouping)替换。同样,以1+2*3;为例,最后一个输入的字符为分号,表示结束,那么会按照下面的规则进行规约:

    expression '*' expression { ? = $1$3; }
    

    结果得到1+6,分析器栈中就只保留1、+、6这3个元素了。这样比较容易理解语法分析。Bison与YACC的规则类似,下面我们编写一个Bison文件,来体会一下Bison的使用方法:

    %{
    #define YYSTYPE double
    #include <math.h>
    #include <ctype.h>
    #include <stdio.h>
    %}
    /* 定义部分 */
    %token NUM
    %left '-' '+'
    %left '*' '/'
    %left NEG
    %right '^'
    
    /* 语法部分 */
    %%
    input:    /* empty string */
            | input line
    
    ;
    
    line:     '\n'
            | exp '\n'  { printf ("\t%.10g\n", $1); }
    ;
    
    exp:      NUM                 { ? = $1;         }
            | exp '+' exp        { ? = $1 + $3;    }
            | exp '-' exp        { ? = $1- $3;    }
            | exp '*' exp        { ? = $1$3;    }
            | exp '/' exp        { ? = $1 / $3;    }
            | '-' exp  %prec NEG { ? = -$2;        }
            | exp '^' exp        { ? = pow ($1, $3); }
            | '(' exp ')'        { ? = $2;         }
    ;
    /*代码部分*/
    %%
    yylex ()
    {
        int c;
    
        /* 跳过空格 */
        while ((c = getchar ()) == ' ' || c == '\t')
            
    
        if (c == '.' || isdigit (c))
        {
            ungetc (c, stdin);
            scanf ("%lf", &yylval);
            return NUM;
        }
        if (c == EOF)
            return 0;
        return c;
    }
    yyerror (s)  /* 错误是被yyparse调用*/
        char *s;
    {
        printf ("%s\n", s);
    }
    main ()
    {
        yyparse ();
    }
    

    通过Bison对calc.y进行编译:

    bison -d  calc.y
    

    会生成calc.tab.h和calc.tab.c,查看calc.tab.c,入口函数为yyparse,该函数生成了一个分析器栈:

    YYSTYPE yyvsa[YYINITDEPTH];
    

    其中,YYINITDEPTH=200,而YYSTYPE为calc.y里面的定义:

    #define YYSTYPE double
    

    执行下面的命令可以生成对应的可执行程序calc:

    gcc -o  calc calc.tab.c calc.tab.h -lm
    

    感兴趣的读者可以看一下calc.tab.c的内容,其使用LALR分析法维护了对BNF的解析。了解了词法和语法分析器的原理和使用后,我们来研究一下PHP 7中词法和语法分析的过程,首先先从Token和词法、语法相关的数据结构入手。

10.3 Token类型

PHP 7的Token是在代码zend_language_parser.h里面定义的,具体如下:

enum yytokentype
{
    END = 0,
    T_INCLUDE = 258,        //对应include关键字
    T_INCLUDE_ONCE = 259,   //对应include_once关键字
    T_EVAL = 260,           //对应eval关键字
    T_REQUIRE = 261,        //对应require关键字
    T_REQUIRE_ONCE = 262,   //对应require_once关键字
    T_LOGICAL_OR = 263,     //对应or关键字
    //代码省略//

这些Token对应的表达可以参见zend_language_scanner.l,比如T_INCLUDE对应的内容如下:

<ST_IN_SCRIPTING>"include" {
    RETURN_TOKEN(T_INCLUDE);
}

PHP 7.1.0共定义了136种Token。在对PHP代码进行词法分析时,可根据正则找到对应的Token。为了更好地理解Token,下面举个例子:

<?php
$a = 1;
  1. “<? php”对应的Token为T_OPEN_TAG。
  2. “$a”与“=”之间的空格对应的Token为T_WHITESPACE。
  3. 文件末尾对应的是END。PHP 7中所有的Token的对应关系见附录B。

PHP 7的词法和语法分析用到了很多数据结构,最核心的是维护了一个全局变量compiler_globals,该变量维护了词法和语法分析的核心数据,同时为了方便存取,定义了CG的宏,即CG(v)可以存取compiler_globals中的成员变量。整个compiler_globals占用了2616字节,用到了zend_stack、zend_ast、zend_arena等数据结构。为了后面能够比较好地理解词法和语法分析的过程,本节首先对基础数据结构做一些阐述,为后面的详细过程做一个铺垫。

10.4 PHP 7词法与语法相关数据结构

10.4.1 CG(v)宏

在PHP 7源码中,存在一个宏CG(v),取的是compiler_globals.v,而compiler_globals对应的是zend_compiler_globals,其结构如图10-7所示。

图10-7 compiler_globals示意图

从图10-7可以看出,compiler_globals用到了一些数据结构,下面我们一一分析,然后看各自对应的是什么内容。

  1. loop_var_stack:对应zend_stack栈,主要用在循环语法的支持上,如while/do while/for/foreach/switch中。
  2. active_class_entry:对应类的实现。
  3. compiled_filename:编译文件的名称。
  4. zend_lineno:记录编译文件的行号。
  5. active_op_array:对应op_array,此部分内容会在第11章重点阐述。
  6. function_table和class_table:都是HashTable,分别存储函数和类的列表。
  7. ast:对应zend_ast,存放抽象语法树的数组。
  8. ast_arena:对应zend_arena,用来存放ast。

10.4.2 zend_stack

CG多处用到了zend_stack,这是一个栈结构,用来存储相关的数据,对应的定义如下:

typedef struct _zend_stack {
    int size, top, max;
    void *elements;
} zend_stack;

如何理解这个数据结构呢,我们看一下对应的push函数和pop函数:

ZEND_API int zend_stack_push(zend_stack *stack, const void *element)
{
    /* We need to allocate more memory */
    if (stack->top >= stack->max) {
        stack->max += STACK_BLOCK_SIZE;
        stack->elements = safe_erealloc(stack->elements, stack->size, stack->max, 0);
    }
    memcpy(ZEND_STACK_ELEMENT(stack, stack->top), element, stack->size);
    return stack->top++;
}
ZEND_API void *zend_stack_top(const zend_stack *stack)
{
    if (stack->top > 0) {
        return ZEND_STACK_ELEMENT(stack, stack->top -1);
    } else {
        return NULL;
    }
}

图10-8 zend_stack示意图

由上面的函数可以看出zend_stack结构非常简单,该结构可以存储任意类型的数据,通过size来确定每个数据的大小,而top指栈顶的位置,max指栈的最大容量,如图10-8所示。

在图10-8中,虚线代表的不是指针,而是逻辑的位置。我们可以看出,size代表的是elements对应的数据的大小,top指向下一个可以入栈的位置,max指的是栈的最大位置。

10.4.3 zend_ast相关结构

PHP 7源码根据AST中节点子女的个数,定义了zend_ast、zend_ast_list、zend_ast_zval, zend_ast_znode以及zend_ast_decl,具体结构如图10-9所示。

图10-9 zend_ast示意图

从图10-9中可以看出,zend_ast有如下变量。

  1. kind:表示AST的类型。
  2. attr:后面在AST转opcodes时,会细分对应不同的操作,比如当kind=ZEND_AST_BINARY_OP时,根据attr对应不同的操作(+、-、*、/等),示例代码如下:
case ZEND_AST_BINARY_OP:
    switch (ast->attr) {
        case ZEND_ADD:                  BINARY_OP(" + ",   200, 200, 201);
        case ZEND_SUB:                  BINARY_OP(" - ",   200, 200, 201);
        case ZEND_MUL:                  BINARY_OP(" * ",   210, 210, 210);
        case ZEND_DIV:                  BINARY_OP(" / ",   210, 210, 210);
        ……
  1. lineno:代表PHP文件的行号。

zend_ast的child的个数是与kind相关的,其中只有1个child的kind对应的enum的值如下:

/* 1 child node */
ZEND_AST_VAR = 256,
ZEND_AST_CONST,
ZEND_AST_UNPACK,
ZEND_AST_UNARY_PLUS,
ZEND_AST_UNARY_MINUS,
ZEND_AST_CAST,
ZEND_AST_EMPTY,
ZEND_AST_ISSET,
ZEND_AST_SILENCE,
ZEND_AST_SHELL_EXEC,
ZEND_AST_CLONE,
ZEND_AST_EXIT,
ZEND_AST_PRINT,
ZEND_AST_INCLUDE_OR_EVAL,
ZEND_AST_UNARY_OP,
ZEND_AST_PRE_INC,
ZEND_AST_PRE_DEC,
ZEND_AST_POST_INC,
ZEND_AST_POST_DEC,
ZEND_AST_YIELD_FROM,
ZEND_AST_GLOBAL,
ZEND_AST_UNSET,
ZEND_AST_RETURN,
ZEND_AST_LABEL,
ZEND_AST_REF,
ZEND_AST_HALT_COMPILER,
ZEND_AST_ECHO,
ZEND_AST_THROW,
ZEND_AST_GOTO,
ZEND_AST_BREAK,
ZEND_AST_CONTINUE,

有2个child的kind对应的enum的值如下:

/* 2 child nodes */
ZEND_AST_DIM = 512,
ZEND_AST_PROP,
ZEND_AST_STATIC_PROP,
ZEND_AST_CALL,
ZEND_AST_CLASS_CONST,
ZEND_AST_ASSIGN,
ZEND_AST_ASSIGN_REF,
ZEND_AST_ASSIGN_OP,
ZEND_AST_BINARY_OP,
ZEND_AST_GREATER,
ZEND_AST_GREATER_EQUAL,
ZEND_AST_AND,
ZEND_AST_OR,
ZEND_AST_ARRAY_ELEM,
ZEND_AST_NEW,
ZEND_AST_INSTANCEOF,
ZEND_AST_YIELD,
ZEND_AST_COALESCE,
ZEND_AST_STATIC,
ZEND_AST_WHILE,
ZEND_AST_DO_WHILE,
ZEND_AST_IF_ELEM,
ZEND_AST_SWITCH,
ZEND_AST_SWITCH_CASE,
ZEND_AST_DECLARE,
ZEND_AST_USE_TRAIT,
ZEND_AST_TRAIT_PRECEDENCE,
ZEND_AST_METHOD_REFERENCE,
ZEND_AST_NAMESPACE,
ZEND_AST_USE_ELEM,
ZEND_AST_TRAIT_ALIAS,
ZEND_AST_GROUP_USE,

有3个child的kind对应的enum的值如下:

/* 3 child nodes */
ZEND_AST_METHOD_CALL = 768,
ZEND_AST_STATIC_CALL,
ZEND_AST_CONDITIONAL,

ZEND_AST_TRY,
ZEND_AST_CATCH,
ZEND_AST_PARAM,
ZEND_AST_PROP_ELEM,
ZEND_AST_CONST_ELEM,

有4个child的kind对应的enum的值如下:

/* 4 child nodes */
ZEND_AST_FOR = 1024,
ZEND_AST_FOREACH,

这样,根据kind可以获取到child的个数,然后在child中存取;在词法和语法分析时,可以根据child的个数存入child[1]的柔性数组中;同样在后面的AST转Opcodes的过程中,可以根据child的个数从柔性数组中取child的值。这部分会在10.5节详细展开。

对于不确定子女个数的kind,采用zend_ast_list结构体,这些kind如下:

/* list nodes */
ZEND_AST_ARG_LIST = 128,
ZEND_AST_ARRAY,
ZEND_AST_ENCAPS_LIST,
ZEND_AST_EXPR_LIST,
ZEND_AST_STMT_LIST,
ZEND_AST_IF,
ZEND_AST_SWITCH_LIST,
ZEND_AST_CATCH_LIST,
ZEND_AST_PARAM_LIST,
ZEND_AST_CLOSURE_USES,
ZEND_AST_PROP_DECL,
ZEND_AST_CONST_DECL,
ZEND_AST_CLASS_CONST_DECL,
ZEND_AST_NAME_LIST,
ZEND_AST_TRAIT_ADAPTATIONS,
ZEND_AST_USE,

对于这些kind,可以将zend_ast强转为zend_ast_list,根据zend_ast_lis中的children值确定child的个数。

与函数、闭包、方法和类相关的kind如下:

/* declaration nodes */
ZEND_AST_FUNC_DECL=66,
ZEND_AST_CLOSURE,
ZEND_AST_METHOD,
ZEND_AST_CLASS,

对于这些kind,需要将zend_ast强转为zend_ast_decl,该结构中的start_lineno和end_lineno分别是函数等的起始行号和结束行号。

另外还有两个特殊的kind,分别对应zend_ast_zval和zend_ast_znode。代码如下:

/* special nodes */
ZEND_AST_ZVAL = 1 ,
ZEND_AST_ZNODE,

如何理解这几个结构体呢?下面我们举一个例子来理解一下,编写如下PHP代码t.php:

<?php
$a = 1;

代码非常简单,下面我们看一下生成的AST, gdb如下:

$gdb ./php
(gdb) b zend_compile
Breakpoint 1 at 0x87682f: file Zend/zend_language_scanner.l, line 578.
(gdb) r t.php
Breakpoint 1, zend_compile (type=2) at Zend/zend_language_scanner.l:578
(gdb) n
……
(gdb) n
585        if (! zendparse()) {//这里进行了词法和语法的分析
(gdb) p *compiler_globals.ast
$1 = {kind = 132, attr = 0, lineno = 1, child = {0x1}}

可以看到kind=132,对应ZEND_AST_STMT_LIST,因此需要将其强转为zend_ast_list, gdb如下:

(gdb) p *(zend_ast_list*)compiler_globals.ast
$2 = {kind = 132, attr = 0, lineno = 1, children = 1, child = {
    0x7ffff7c7b088}}

由此可见,kind=132的节点有1个child,我们可以输出这个child:

(gdb) p *((zend_ast_list*)compiler_globals.ast).child[0]
$3 = {kind = 517, attr = 0, lineno = 2, child = {0x7ffff7c7b060}}

可以看到第一个child的kind为517,对应ZEND_AST_ASSIGN。从上面的代码中,我们知道ZEND_AST_ASSIGN是有两个child的kind,我们分别输出一下:

(gdb) p *(((zend_ast_list*)compiler_globals.ast).child[0]).child[0]
$4 = {kind = 256, attr = 0, lineno = 2, child = {0x7ffff7c7b048}}
(gdb) p *(((zend_ast_list*)compiler_globals.ast).child[0]).child[1]
$5 = {kind = 64, attr = 0, lineno = 0, child = {0x1}}

对于第二个child, kind是64,对应ZEND_AST_ZVAL,需要强转为zend_ast_zval,输出一下:

(gdb) p *(zend_ast_zval*)(((zend_ast_list*)compiler_globals.ast).child[0]).child[1]
$6 = {kind = 64, attr = 0, val = {value = {lval = 1,
    dval = 4.9406564584124654e-324, counted = 0x1, str = 0x1, arr = 0x1,
    obj = 0x1, res = 0x1, ref = 0x1, ast = 0x1, zv = 0x1, ptr = 0x1,
    ce = 0x1, func = 0x1, ww = {w1 = 1, w2 = 0}}, u1 = {v = {
        type = 4 '\004', type_flags = 0 '\000', const_flags = 0 '\000',
        reserved = 0 '\000'}, type_info = 4}, u2 = {next = 2,
    cache_slot = 2, lineno = 2, num_args = 2, fe_pos = 2, fe_iter_idx = 2,
    access_flags = 2, property_guard = 2}}}

从上面输出中,我们可以看出zend_ast_zval中的val是一个zval,其中u1.v.type=IS_LONG(值为4),因此lval=1,对应PHP代码中的1。

对于第一个child, kind是256,对应ZEND_AST_VAR,有1个child,我们输出一下:

(gdb) p *(((zend_ast_list*)compiler_globals.ast).child[0]).child[0].child[0]
$7 = {kind = 64, attr = 0, lineno = 0, child = {0x7ffff7c5c700}}

对应的kind=64,同样将其强转为zend_ast_zval,输出如下:

(gdb) p *(zend_ast_zval*)(((zend_ast_list*)compiler_globals.ast).child[0]).child[0].
    child[0]
$8 = {kind = 64, attr = 0, val = {value = {lval = 140737350321920,
    dval = 6.9533489880785172e-310, counted = 0x7ffff7c5c700,
    str = 0x7ffff7c5c700, arr = 0x7ffff7c5c700, obj = 0x7ffff7c5c700,
    res = 0x7ffff7c5c700, ref = 0x7ffff7c5c700, ast = 0x7ffff7c5c700,
    zv = 0x7ffff7c5c700, ptr = 0x7ffff7c5c700, ce = 0x7ffff7c5c700,
    func = 0x7ffff7c5c700, ww = {w1 = 4156933888, w2 = 32767}}, u1 = {v = {
        type = 6 '\006', type_flags = 20 '\024', const_flags = 0 '\000',
        reserved = 0 '\000'}, type_info = 5126}, u2 = {next = 2,
    cache_slot = 2, lineno = 2, num_args = 2, fe_pos = 2, fe_iter_idx = 2,
    access_flags = 2, property_guard = 2}}}

从上面输出中,我们可以看出zend_ast_zval中的val是一个zval,其中u1.v.type=IS_STRING(值为6),我们看一下对应的value.str,输出如下:

(gdb) p *($8).val.value.str$10 = {gc = {refcount = 1, u = {v = {type = 6 '\006', flags =
    0 '\000',
    gc_info = 0}, type_info = 6}}, h = 0, len = 1, val = "a"}

可以看到长度len=1,对应的值为a,即PHP代码中的$a。根据上面的输出过程,我们可以绘制AST示意图,如图10-10所示。

图10-10 AST示意图

这样我们学习了zend_ast相关的5种数据结构,简单生成了一个ASSIGN操作的AST,10.5节会详细阐述AST的生成过程。

10.4.4 zend_arena

对于zend_ast的存放位置,PHP 7定义了一个称为zend_arena的结构体,其定义如下:

struct _zend_arena {
    char           *ptr;
    char           *end;
    zend_arena     *prev;
};

从定义中可以看出,该结构体非常简单,本身是一个链表,而每个链表会占一大块内存;有两个指针ptr和end,其中指针ptr指向将要使用的内存地址,指针end指向内存的最后位置。zend_arena示意图如图10-11所示。

图10-11 zend_arena示意图

词法、语法分析过程中生成的AST节点都会存放在CG(ast_arena)。

10.4.5 zend_parser_stack_elem

语法分析会用到一个数据结构——zend_parser_stack_elem,其定义如下:

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

zend_parser_stack_elem是一个联合体,可以存放zend_ast指针或zend_string指针,或者zend_ulong类型的数据。该结构体在语法分析中用的是zend_ast* ast。

10.5 PHP 7词法与语法分析

了解了PHP 7的Token和相关的数据结构,下面分析一下PHP 7词法和语法分析得到AST的具体过程。

10.5.1 整体过程

PHP 7词法和语法分析的入口函数在zend_language_scanner.c的zend_compile中,具体步骤如下。

  1. 申请1024× 32字节大小的空间,赋值给compiler_globals的ast_arena,用以存放AST。
  2. 调用zendparse(yyparse)进行词法与语法分析,生成AST。
  3. 将AST赋值给CG(ast)。

10.5.2 词法与语法分析阶段

该阶段的入口函数为zendparse,使用Re2c生成的词法分析文件和Bison生成的语法分析文件。PHP 7源码中编写了zend_language_scanner.l文件,这个是符合Re2c规范的,根据10.2.2节介绍的Re2c,对照PHP 7的MakeFile,我们可以看到使用Re2c编译这个文件的语句:

@(cd  $(top_srcdir);  $(RE2C)  $(RE2C_FLAGS)  --no-generation-date  --case-inverted
    -cbdFt Zend/zend_language_scanner_defs.h -oZend/zend_language_scanner.c Zend/
    zend_language_scanner.l)

读者可以执行下面的命令,体会一下生成zend_language_scanner.c的过程:

re2c --no-generation-date --case-inverted -cbdFt Zend/zend_language_scanner_defs.
    h -otest/zend_language_scanner.c Zend/zend_language_scanner.l

zend_language_scanner.l文件中的正则表达式如下:

/*! 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")

通过对10.2.2节的学习,我们知道Re2c会将其转换为有穷状态机,我们以PHP的入口Tag(<? php)为例,看一下判断的过程。状态机起始位置为yyc_INITIAL,对应的入口Tag有“<? =”、“<? php”和“<? ”, .l文件中对应的代码如下:

<INITIAL>"<? =" {
    BEGIN(ST_IN_SCRIPTING);
    RETURN_TOKEN(T_OPEN_TAG_WITH_ECHO);
}
<INITIAL>"<? php"([ \t]|{NEWLINE}) {
    HANDLE_NEWLINE(yytext[yyleng-1]);
    BEGIN(ST_IN_SCRIPTING);
    RETURN_TOKEN(T_OPEN_TAG);
}
<INITIAL>"<? " {
    if (CG(short_tags)) {
        BEGIN(ST_IN_SCRIPTING);
        RETURN_TOKEN(T_OPEN_TAG);
    } else {
        goto inline_char_handler;
    }
}

生成的.c文件中,状态对应yyc_INITIAL:

yyc_INITIAL:
    YYDEBUG(0, *YYCURSOR);
    YYFILL(7);
    yych = *YYCURSOR;
    if (yych ! = '<') goto yy4;
    YYDEBUG(2, *YYCURSOR);
    ++YYCURSOR;
    if ((yych = *YYCURSOR) == '? ') goto yy5;
    //代码省略//
  1. 从起始状态开始,到状态0,如果不是“<”,则跳到状态yy4,最终到状态,yy3, RETURN_TOKEN(END);
  2. 对于状态0,如果是“<? ”,则跳转到状态yy5;
  3. 对于状态yy5,如果是“=”,则跳到状态yy7,RETURN_TOKEN(T_OPEN_TAG_WITH_ECHO);
  4. 对于状态yy5,如果是“p”或者“P”,则跳到状态yy9,并最终跳到状态yy6, RETURN_TOKEN(T_OPEN_TAG)。

该部分对应的状态机转换图如图10-12所示。

图10-12 “<? =”和“<? php”两个TOKEN状态转换图示例

图10-12展示了对PHP代码入口“<? =”和“<? php”的识别过程。通过这个过程,相信大家很容易理解词法分析做了什么工作。接下来详细阐述一下语法分析工作。

  1. 准备工作

PHP 7的语法分析使用Bison对zend_language_parser.y进行编辑,生成了zend_language.parse.c文件。整个词法和语法分析的入口为Zend/zend_language_parser.c的zendparse函数,下面我们以一段简单的PHP代码为例来分析一下整个词法和语法分析的过程。PHP代码如下:

<?php
$a = 1;

代码非常简单,我们可以在zendparse函数打个断点,然后运行这段代码:

(gdb) b zendparse

在zendparse中,首先初始化一个200大小的栈yyvsa和一个200大小的状态数组yyssa,并初始化指针yyvs和yyvsp指向yyvsa的第0个位置,yyval指向yyvsa的-2位置,同样初始化指针yyss和yyssp指向yyssa的第0个位置,如图10-13所示。

图10-13 词法和语法分析初始化

  1. 初始状态

在初始状态,语法分析会在yyvsa的第1个位置创建一个kind为ZEND_AST_STMT_LIST的AST,代码如下:

{ (yyval.ast) = zend_ast_create_list(0, ZEND_AST_STMT_LIST); }

生成AST后的结构如图10-14所示。

图10-14 插入kind为ZEND_AST_STMT_LIST的示意图

从图10-14可以看出,初始状态会生成一个kind为ZEND_AST_STMT_LIST的AST,并把其地址赋值给yyvsa[1]以及yyval。其中,yyval为yyvsa[-2];同时yyparse将yyssa[1]置为2,用来判断栈的步长。


注意

yyval对应的是yyvsa[-2],这个在C语言中可以使用。


kind为ZEND_AST_STMT_LIST是整棵抽象语法树的根节点,下面的过程会基于这个根节点扩展整棵抽象语法树。

  1. 分析过程

接下来进入词法分析过程,根据获取“<? php”的状态转换图,词法分析首先找到的是“<? php”对应的Token为T_OPEN_TAG,对于返回的Token处理代码如下:

int zendlex(zend_parser_stack_elem *elem) /* {{{ */
{
    zval zv;  //声明一个zval,用来存储PHP代码中的变量和常量
    int retval; //返回的Token值
    //…省略代码…//
again:
    ZVAL_UNDEF(&zv); //将zv置为IS_UNDEF
    retval = lex_scan(&zv); //进行词法分析
    if (EG(exception)) {//异常
        return T_ERROR;
    }

    switch (retval) {
        case T_COMMENT:  // 注释,比如//或者#
        case T_DOC_COMMENT: //注释,比如/* */或者  /** */
        case T_OPEN_TAG:// "<? php"
        case T_WHITESPACE: //空格
            goto again; //继续进行词法分析
        //…省略代码…//
    }
    if (Z_TYPE(zv) ! = IS_UNDEF) {
        elem->ast = zend_ast_create_zval(&zv); //如果是非IS_UNDEF的zval,生成zend_ast_zval
    }
    return retval;
}

对于T_OPEN_TAG,会跳转到again继续进行词法分析,分析出“$a”,生成zend_string,赋值给zv的value.str, zv的u1.v.type设置为IS_STRING,并通过zend_ast_create_zval转换为zend_ast,具体示意图如图10-15所示。 这样就生成了“$a”对应的AST节点,其中kind为ZEND_AST_ZVAL,类型为zend_ast_zval,其zval存的就是“a”。“$a”对应的AST会插入到yyvsa[2],同时yyssa[2]置为35,如图10-16所示。

图10-15 $a对应的zend_ast_zval

图10-16 解析$a为zend_ast_zval后的示意图

对于图10-16中的变量$a,根据Bison生成的yydefact和对应的状态yystate,会生成kind为ZEND_AST_VAR的节点,代码如下:

{ (yyval.ast) = zend_ast_create(ZEND_AST_VAR, (yyvsp[0].ast)); }

这个节点的child为“a”,对应的kind为ZEND_AST_ZVAL,然后将这个节点存到yyval中,同时修改yyssa[2]为101,如图10-17所示。

图10-17 解析$a为kind为ZEND_AST_VAR后的示意图

从图10-7可以看出,在-2位置上生成的AST, kind为ZEND_AST_VAR,其child为之前“a”对应的ZEND_AST_ZVAL,然后将-2位置的AST赋值给第2个位置,此时生成的AST如图10-18所示。

图10-18 解析$a为kind为ZEND_AST_VAR后AST的示意图

将ZEND_AST_VAR这棵AST存到yyvsa[2]中,如图10-19所示。

图10-19 解析$a为kind为ZEND_AST_VAR后的示意图

继续解析到“$a”和“=”之间的空格,词法解析会分析到这个空格,返回的Token为T_WHITESPACE。


注意

词法和语法分析会对空格、注释等内容进行分析,会浪费一定的时间,但可以忽略不计,另外因为有opcache等内部扩展,这部分词法和语法分析工作不会每次都进行。


跟T_OPEN_TAG类似,对于T_WHITESPACE,会跳转到again继续进行词法分析。分析到“=”,此时的zendlex中的zv对应的类型是IS_UNDEF,只会修改yyssa中的值;继续分析到常量“1”,同样返回zend_ast_zval, Token为T_LNUMBER,生成的zend_ast_zval中的zval对应的是常量1,通过gdb查看一下:

(gdb) p ((zend_ast_zval*)yyvsa[4].ast).val
$20 = {value = {lval = 1, dval = 4.9406564584124654e-324, counted = 0x1, str = 0x1,
    arr = 0x1,
    obj = 0x1, res = 0x1, ref = 0x1, ast = 0x1, zv = 0x1, ptr = 0x1, ce = 0x1,
        func = 0x1, ww = {
        w1 = 1, w2 = 0}}, u1 = {v = {type = 4 '\004', type_flags = 0 '\000', const_flags =
            0 '\000',
    reserved = 0 '\000'}, type_info = 4}, u2 = {next = 2, cache_slot = 2, lineno
        = 2, num_args = 2,
    fe_pos = 2, fe_iter_idx = 2, access_flags = 2, property_guard = 2}}

可以看出,常量1对应的zend_ast_zval的kind为ZEND_AST_ZVAL,值存于val中,如图10-20所示。

图10-20 常量1对应的zend_ast_zval

该AST会存放在yyvsa[4]中,同时yyssa[4]置为277,如图10-21所示。

图10-21 解析常量1后kind为ZEND_AST_ZVAL的示意图

走到分号后,会创建ZEND_AST_ASSIGN的节点,并将此AST存于yyval中,代码如下:

{ (yyval.ast) = zend_ast_create(ZEND_AST_ASSIGN, (yyvsp[-2].ast), (yyvsp[0].ast)); }

从代码中可以看出,对于kind为ZEND_AST_ASSIGN节点的child为$a对应的ZEND_AST_VAR,右chid为1对应的ZEND_AST_ZVAL,如图10-22所示。此时对应的AST如图10-23所示。

图10-22 生成ZEND_AST_ASSIGN后的示意图

图10-23 解析$a=1为ZEND_AST_ASSIGN后的示意图

  1. 结束状态

词法解析到文件结束,返回RETURN_TOKEN(END),调用代码:

{ (yyval.ast) = zend_ast_list_add((yyvsp[-1].ast), (yyvsp[0].ast)); }

将ZEND_AST_ASSIGN作为child赋值给ZEND_AST_STMT_LIST,如图10-24所示。

图10-24 结束状态时的示意图

到此,我们生成了最终的AST,如图10-25所示。

图10-25 最终AST的示意图

到此,对于简单的PHP代码,经过词法和语法分析,到生成AST的过程,我们从头到尾走了一遍,感兴趣的读者可以动手使用gdb一步步走一下。最后生成的AST会赋值给CG(ast),所以对于任何一段代码,我们都可以在zendparse()后,输出对应的AST。

10.6 AST的优势

在PHP 5中,从PHP代码到opcode的执行过程如下:先进行词法扫描分析,将源文件转换成Token;然后进行语法分析,在此阶段生成Op_array。相比PHP 5, PHP 7的执行过程多了一步,其执行过程如下:先进行词法扫描分析,将源文件转换成Token;然后进行语法分析生成AST,最后AST生成Op_array。PHP 7的执行过程比PHP 5的多了一步,所以按常理来说这会增加程序的执行时间,同时会增加内存的消耗。但事实上内存的消耗确实增加了,但是执行时间上有所降低。具体可以使用PHP 7的测试用例验证:gist.github.com/nikic/289b0…

需要注意的是,以上的结果都是基于没有opcache的情况。在生产环境打开opcache的情况下,内存的消耗增加也不是很大的问题。那么AST有什么好处呢?首先AST解决了很多语法问题,比如括号不影响行为,代码如下:

<?php
($a)['b'] = 1;

对于这段代码,我们通过gdb可以得出其AST,如图10-26所示。

图10-26 带括号的表达式AST的示意图

而我们使用PHP 5执行这段代码会报语法错误:

./php test.php
Parse error: syntax error, unexpected '[' in /root/php5/test.php on line 2

另外,AST还解决了变量语法一致性的问题,PHP 5与PHP 7变量语法对照表如表10-5所示。

表10-5 PHP 5与PHP 7变量语法对照表

PHP 7引入AST后,语法规则是从左到右,同时遵循括号不影响行为的原则,给语法表达带来了很大的便利。

10.7 源码中的其他使用

Re2c在源码的很多位置都有使用,比如在phpdbg中对phpdbg_lexer.l的分析,代码如下:

/*! re2c
re2c:yyfill:check = 0;
T_TRUE      'true'
T_YES       'yes'
T_ON        'on'

另外,json中的json_scanner.re、pdo中的pdo_sql_parser.re、phar中的phar_path_check.re、ext/standard/var_unserializer.re、ext/standard/url_scanner_ex.re也是通过Re2c转换为C语言文件。

Bison在PHP 7源码的很多其他位置也有使用,比如配置文件解析的zend_ini_parser.y,代码如下:

expr:
        var_string_list                  { ? = $1; }
    |   expr '|' expr                    { zend_ini_do_op('|', &?, &$1, &$3); }
    |   expr '&' expr                    { zend_ini_do_op('&', &?, &$1, &$3); }
    |   expr '^' expr                    { zend_ini_do_op('^', &?, &$1, &$3); }
    |   '~' expr                         { zend_ini_do_op('~', &?, &$2, NULL); }
    |   '! ' expr                         { zend_ini_do_op('! ', &?, &$2, NULL); }
    |   '(' expr ')'                     { ? = $2; }

10.8 本章小结

本章详细介绍了词法和语法分析的基本原理,以及Lex、YACC和Re2C、Bison的使用,相信大家对词法和语法分析器有了一定的认识。本章还介绍了PHP 7的Token、词法和语法分析相关的数据结构,以及整个词法和语法分析的过程。AST(抽象语法树)的引入使得语法表达更加清晰,更符合正常的表达方式。经过对本章的学习,相信读者对PHP代码如何通过词法和语法分析生成AST的过程有了比较深入的了解,而AST是转为Op_array的基础。在第11章中,我们会对AST转Op_array的过程,以及Zend虚拟机的执行过程进行详细阐述。