第二节 语法的实现

世上没有无缘无故的爱,也没有无缘无故的恨。

语言从广义上来讲是人们进行沟通交流的各种表达符号。每种语言都有专属于自己的符号,表达方式和规则。就编程语言来说,它也是由特定的符号,特定的表达方式和规则组成。语言的作用是沟通,不管是自然语言,还是编程语言,它们的区别在于自然语言是人与人之间沟通的工具,而编程语言是人与机器之间的沟通渠道。相对于自然语言,编程语言的历史还非常短,虽然编程语言是站在历史巨人的基础上创建的,但是它还很小,还是一个小孩。它只能按编程人员所给的指令翻译成对应的机器可以识别的语言。它就相当于一个转化工具,将人们的知识或者业务逻辑转化成机器码(机器的语言),让其执行对应的的操作。而这些指令是一些规则,一些约定,这些规则约定都是由编程语言来处理。

就PHP语言来说,它也是一组符合一定规则的约定的指令。在编程人员将自己的想法以PHP语言实现后,通过PHP的虚拟机将这些PHP指令转变成C语言(可以理解为更底层的一种指令集)指令,而C语言又会转变成汇编语言,最后汇编语言将根据处理器的规则转变成机器码执行。这是一个更高层次抽象的不断具体化,不断细化的过程。

在这一章,我们讨论PHP虚拟机是如何将PHP语言转化成C语言。从一种语言到另一种语言的转化称之为编译,这两种语言分别可以称之为源语言和目标语言。这种编译过程通常发生在目标语言比源语言更低级(或者说更底层)。语言转化的编译过程是由编译器来完成,编译器通常被分为一系列的过程:词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成等。前面几个阶段(词法分析、语法分析和语义分析)的作用是分析源程序,我们可以称之为编译器的前端。后面的几个阶段(中间代码生成、代码优化和目标代码生成)的作用是构造目标程序,我们可以称之为编译器的后端。一种语言被称为编译类语言,一般是由于在程序执行之前有一个翻译的过程,其中关键点是有一个形式上完全不同的等价程序生成。而PHP之所以被称为解释类语言,就是因为并没有这样的一个程序生成,它生成的是中间代码,这只是PHP的一种内部数据结构。

词法解析

在前面我们提到语言转化的编译过程一般分为词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成等六个阶段。不管是编译型语言还是解释型语言,扫描(词法分析)总是将程序转化成目标语言的第一步。词法分析的作用就是将整个源程序分解成一个一个的单词,这样做可以在一定程度上减少后面分析工作需要处理的个体数量,为语法分析等做准备。除了拆分工作,更多的时候它还承担着清洗源程序的过程,比如清除空格,清除注释等。词法分析作为编译过程的第一步,在业界已经有多种成熟工具,如PHP在开始使用的是Flex,之后改为re2c,MySQL的词法分析使用的Flex,除此之外还有作为UNIX系统标准词法分析器的Lex等。这些工具都会读进一个代表词法分析器规则的输入字符串流,然后输出以C语言实做的词法分析器源代码。这里我们只介绍PHP的现版词法分析器,re2c。

re2c是一个扫描器制作工具,可以创建非常快速灵活的扫描器。它可以产生高效代码,基于C语言,可以支持C/C++代码。与其它类似的扫描器不同,它偏重于为正则表达式产生高效代码(和他的名字一样)。因此,这比传统的词法分析器有更广泛的应用范围。你可以在sourceforge.net获取源码。

在源码目录下的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状态,然后匹配状态后的规则。如果所有的后的规则都无法匹配,输出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;
    }
...
}

在zend_language_scanner.c文件的lex_scan函数中也有类型的跳转过程,只是过程相对这里来说if语句多一些,复杂一些。这就是re2c条件表达式的实现原理。

语法分析

Bison是一种通用目的的分析器生成器。它将LALR(1)上下文无关文法的描述转化成分析该文法的C程序。使用它可以生成解释器,编译器,协议实现等多种程序。Bison向上兼容Yacc,所有书写正确的Yacc语法都应该可以不加修改地在Bison下工作。它不但与Yacc兼容还具有许多Yacc不具备的特性。

Bison分析器文件是定义了名为yyparse并且实现了某个语法的函数的C代码。这个函数并不是一个可以完成所有的语法分析任务的C程序。除此这外我们还必须提供额外的一些函数:如词法分析器、分析器报告错误时调用的错误报告函数等等。我们知道一个完整的C程序必须以名为main的函数开头,如果我们要生成一个可执行文件,并且要运行语法解析器,那么我们就需要有main函数,并且在某个地方直接或间接调用yyparse,否则语法分析器永远都不会运行。

先看下bison的示例:逆波兰记号计算器

%{
#define YYSTYPE double
#include <stdio.h>
#include <math.h>
#include <ctype.h>
int yylex (void);
void yyerror (char const *);
%}
 
%token NUM
 
%%
input:    /* empty */
     | input line
    ;
 
line:     '\n'
    | exp '\n'      { printf ("\t%.10g\n", $1); }
;
 
exp:      NUM           { $$ = $1;           }
   | exp exp '+'   { $$ = $1 + $2;      }
    | exp exp '-'   { $$ = $1 - $2;      }
    | exp exp '*'   { $$ = $1 * $2;      }
    | exp exp '/'   { $$ = $1 / $2;      }
     /* Exponentiation */
    | exp exp '^'   { $$ = pow($1, $2); }
    /* Unary minus    */
    | exp 'n'       { $$ = -$1;          }
;
%%
 
#include <ctype.h>
 
int yylex (void) {
       int c;
 
/* Skip white space.  */
       while ((c = getchar ()) == ' ' || c == '\t') ;
 
/* Process numbers.  */
       if (c == '.' || isdigit (c)) {
       ungetc (c, stdin);
       scanf ("%lf", &yylval);
       return NUM;
     }
 
       /* Return end-of-input.  */
       if (c == EOF) return 0;
 
       /* Return a single char.  */
       return c;
}
 
void yyerror (char const *s) {
    fprintf (stderr, "%s\n", s); 
}
 
int main (void) {
    return yyparse ();
}

我们先看下运行的效果:

bison demo.y
gcc -o test -lm test.tab.c
chmod +x test
./test

gcc命令需要添加-lm参数。因为头文件仅对接口进行描述,但头文件不是负责进行符号解析的实体。此时需要告诉编译器应该使用哪个函数库来完成对符号的解析。  GCC的命令参数中,-l参数就是用来指定程序要链接的库,-l参数紧接着就是库名,这里我们在-l后面接的是m,即数学库,他的库名是m,他的库文件名是libm.so。

这是一个逆波兰记号计算器的示例,在命令行中输入 3 7 + 回车,输出10

一般来说,使用Bison设计语言的流程,从语法描述到编写一个编译器或者解释器,有三个步骤:

  • 以Bison可识别的格式正式地描述语法。对每一个语法规则,描述当这个规则被识别时相应的执行动作,动作由C语句序列。即我们在示例中看到的%%和%%这间的内容。
  • 描述编写一个词法分析器处理输入并将记号传递给语法分析器(即yylex函数一定要存在)。词法分析器既可是手工编写的C代码, 也可以由lex产生,后面我们会讨论如何将re2c与bison结合使用。上面的示例中是直接手工编写C代码实现一个命令行读取内容的词法分析器。
  • 编写一个调用Bison产生的分析器的控制函数,在示例中是main函数直接调用。编写错误报告函数(即yyerror函数)。 将这些源代码转换成可执行程序,需要按以下步骤进行:
  • 按语法运行Bison产生分析器。对应示例中的命令,bison demo.y
  • 同其它源代码一样编译Bison输出的代码,链接目标文件以产生最终的产品。即对应示例中的命令 gcc -o test -lm test.tab.c 我们可以将整个Bison语法文件划分为四个部分。这三个部分的划分通过%%',%{' 和`%}'符号实现。一般来说,Bison语法文件结构如下:
%{
这里可以用来定义在动作中使用类型和变量,或者使用预处理器命令在那里来定义宏, 或者使用#include包含需要的文件。
如在示例中我们声明了YYSTYPE,包含了头文件math.h等,还声明了词法分析器yylex和错误打印程序yyerror。
%}
 
Bison 的一些声明
在这里声明终结符和非终结符以及操作符的优先级和各种符号语义值的各种类型
如示例中的%token NUM。我们在PHP的源码中可以看到更多的类型和符号声明,如%left,%right的使用
 
%%
在这里定义如何从每一个非终结符的部分构建其整体的语法规则。
%%
 
这里存放附加的内容
这里就比较自由了,你可以放任何你想放的代码。
在开始声明的函数,如yylex等,经常是在这里实现的,我们的示例就是这么搞的。

我们在前面介绍了PHP是使用re2c作为词法分析器,那么PHP是如何将re2c与bison集成在一起的呢?我们以一个从PHP源码中剥离出来的示例来说明整个过程。这个示例的功能与上一小节的示例类似,作用都是识别输入参数中的字符串类型。本示例是在其基础上添加了语法解析过程。首先我们看这个示例的语法文件:demo.y

%{
#include <stdio.h>
#include "demo_scanner.h"
extern int yylex(znode *zendlval);
void yyerror(char const *);
 
#define YYSTYPE znode   //关键点一,znode定义在demo_scanner.h   
%}
 
%pure_parser    //  关键点二
 
%token T_BEGIN
%token T_NUMBER
%token T_LOWER_CHAR
%token T_UPPER_CHAR 
%token T_EXIT
%token T_UNKNOWN
%token T_INPUT_ERROR
%token T_END
%token T_WHITESPACE
 
%%
 
begin: T_BEGIN {printf("begin:\ntoken=%d\n", $1.op_type);}
     | begin variable {
        printf("token=%d ", $2.op_type);
        if ($2.constant.value.str.len > 0) {
            printf("text=%s", $2.constant.value.str.val);
        }
        printf("\n");
}
 
variable: T_NUMBER {$$ = $1;}
|T_LOWER_CHAR {$$ = $1;}
|T_UPPER_CHAR {$$ = $1;}
|T_EXIT {$$ = $1;}
|T_UNKNOWN {$$ = $1;}
|T_INPUT_ERROR {$$ = $1;}
|T_END {$$ = $1;}
|T_WHITESPACE {$$ = $1;}
 
%%
 
void yyerror(char const *s) {
    printf("%s\n", s);  
}

这个语法文件有两个关键点:

1、znode是复制PHP源码中的znode,只是这里我们只保留了两个字段,其结构如下:

typedef union _zvalue_value {
    long lval;                  /* long value */
    double dval;                /* double value */
    struct {
        char *val;
        int len;
    } str;
} zvalue_value;
 
typedef struct _zval_struct {
    /* Variable information */
    zvalue_value value;     /* value */
    int type;    /* active type */
}zval;
 
typedef struct _znode {
    int op_type;
    zval constant;
}znode;

这里我们同样也复制了PHP的zval结构,但是我们也只取了关于整型,浮点型和字符串型的结构。op_type用于记录操作的类型,constant记录分析过程获取的数据。一般来说,在一个简单的程序中,对所有的语言结构的语义值使用同一个数据类型就足够用了。比如在前一小节的逆波兰记号计算器示例就只有double类型。而且Bison默认是对于所有语义值使用int类型。如果要指明其它的类型,可以像我们示例一样将YYSTYPE定义成一个宏:

#define YYSTYPE znode

2、%pure_parser在Bison中声明%pure_parse表明你要产生一个可重入(reentrant)的分析器。默认情况下Bison调用的词法分析函数名为yylex,并且其参数为void,如果定义了YYLEX_PARAM,则使用YYLEX_PARAM为参数,这种情况我们可以在Bison生成的.c文件中发现其是使用#ifdef实现。

如果声明了%pure_parser,通信变量yylval和yylloc则变为yyparse函数中的局部变量,变量yynerrs也变为在yyparse中的局部变量,而yyparse自己的调用方式并没有改变。比如在我们的示例中我们声明了可重入,并且使用zval类型的变更作为yylex函数的第一个参数,则在生成的.c文件中,我们可以看到yylval的类型变成

一个可重入(reentrant)程序是在执行过程中不变更的程序;换句话说,它全部由纯(pure)(只读)代码构成。 当可异步执行的时候,可重入特性非常重要。例如,从一个句柄调用不可重入程序可能是不安全的。 在带有多线程控制的系统中,一个非可重入程序必须只能被互锁(interlocks)调用。

通过声明可重入函数和使用znode参数,我们可以记录分析过程中获取的值和词法分析过程产生的token。在yyparse调用过程中会调用yylex函数,在本示例中的yylex函数是借助re2c生成的。在demo_scanner.l文件中定义了词法的规则。大部分规则是借用了上一小节的示例,在此基础上我们增加了新的yylex函数,并且将zendlval作为通信变量,把词法分析过程中的字符串和token传递回来。而与此相关的增加的操作为:

SCNG(yy_text) = YYCURSOR;   //  记录当前字符串所在位置
/*!re2c
  <!*> {yyleng = YYCURSOR - SCNG(yy_text);} //  记录字符串长度 

main函数发生了一些改变:

int main(int argc, char* argv[])
{
    BEGIN(INITIAL); //  全局初始化,需要放在scan调用之前
    scanner_globals.yy_cursor = argv[1];    //将输入的第一个参数作为要解析的字符串
 
    yyparse();
    return 0;
}

在新的main函数中,我们新增加了yyparse函数的调用,此函数在执行过程中会自动调用yylex函数。

如果需要运行这个程序,则需要执行下面的命令:

re2c -o demo_scanner.c -c -t demo_scanner_def.h demo_scanner.l
bison -d demo.y
gcc -o t demo.tab.c demo_scanner.c
chmod +x t
./t "<?php tipi2011"

在前面我们以一个小的示例和从PHP源码中剥离出来的示例简单说明了bison的入门和bison与re2c的结合。当我们用gdb工具Debug PHP的执行流程中编译PHP代码过程如下:

#0  lex_scan (zendlval=0xbfffccbc) at Zend/zend_language_scanner.c:841
#1  0x082bab51 in zendlex (zendlval=0xbfffccb8)
    at /home/martin/project/c/phpsrc/Zend/zend_compile.c:4930
#2  0x082a43be in zendparse ()
    at /home/martin/project/c/phpsrc/Zend/zend_language_parser.c:3280
#3  0x082b040f in compile_file (file_handle=0xbffff2b0, type=8)
    at Zend/zend_language_scanner.l:343
#4  0x08186d15 in phar_compile_file (file_handle=0xbffff2b0, type=8)
    at /home/martin/project/c/phpsrc/ext/phar/phar.c:3390
#5  0x082d234f in zend_execute_scripts (type=8, retval=0x0, file_count=3)
    at /home/martin/project/c/phpsrc/Zend/zend.c:1186
#6  0x08281b70 in php_execute_script (primary_file=0xbffff2b0)
    at /home/martin/project/c/phpsrc/main/main.c:2225
#7  0x08351b97 in main (argc=4, argv=0xbffff424)
    at /home/martin/project/c/phpsrc/sapi/cli/php_cli.c:1190

PHP源码中,词法分析器的最终是调用re2c规则定义的lex_scan函数,而提供给Bison的函数则为zendlex。而yyparse被zendparse代替。

实现自己的语法

经过前面对r2ec以及Bison的介绍,熟悉了PHP语法的实现,我们来动手自己实现一个语法吧。也就是对Zend引擎语法层面的实现。以此来对Zend引擎有更多的了解。

编程语言和社会语言一样都是会慢慢演进的,不同的语种就像我们的不同国家的语言一样,他们各有各的特点,语言通常也能反映出一个群体的特质,不同语言的社区氛围和文化也都会有很大的差异,和现实生活一样,我们也需要尽可能的去接触不同的文化,来开阔自己的视野和思维方式,所以我们也建议多学习不同的编程语言。

在这里简单提一下PHP语言的演进,PHP的语法继承自Perl的语法,这一点和自然语言也很类似,语言之间会互相影响,比如PHP5开始完善的面向对象机制,已经PHP5.4中增加的命名空间以及闭包等等功能。

PHP是个开源项目,它的发展是由社区来决定的,它也是开放的,如果你有想要改进它的愿望都可以加入到这个社区当中,当然也不是谁都可以改变PHP,重大改进都需要由社区确定,只有有限的人具有对代码库的修改权限,如果你发现了PHP的Bug可以去http://bugs.php.net提交Bug,如果同时你也找到了Bug的原因那么你也可以同时附上对Bug的修复补丁,然后在PHP邮件组中进行一些讨论,如果没有问题那么有权限的成员就可以将你的补丁合并进入相应的版本内,更多内容可以参考附录D怎样为PHP共享自己的力量。

在本小节中将要实现一个对PHP本身语言的一个“需求”:返回变量的名称。用一小段代码简单描述一下这个需求:

 [php]
 <?php 
 $demo = 'tipi';
 echo var_name($demo);   //执行结果,输出: demo
 ?>

经过前面的章节,我们了解到,一种PHP语法的内部实现,主要经历了以下步骤:

图7.2 Zend Opcodes执行

即:词法分析 => 语法分析 => opcode编译 => 执行

由此,我们还是要从词法和语法分析着手。

词法分析与语法分析

熟悉编译原理的朋友应该比较熟悉这两个概念,简而言之,就是在要运行的程序中,根据原来设定好的“关键字”(Tokens),将每条程序指令解释成为可以由语言解释器理解的操作。

在PHP中,可以使用token_get_all()函数来查看一段PHP代码生成的Tokens。

PHP的词法分析和语法分析的实现分别位于Zend目录下的zend_language_scanner.l和zend_language_parser.y 文件,使用r2ec&flex来编译。我们要做的,就是在PHP原有的词法和语法分析中,加入新的Token,在zend_language_scanner.l中加入以下内容:

"var_name" {
    return T_VARIABLE_NAME;
}

也就是在此法分析阶段遇到var_name这个字符串的时候会被标记为我们定义的T_VARIABLE_NAME token。

同样,在 zend_language_parser.y 也需要加入对这个token的处理,通常是进行响应的逻辑处理。我们要实现的语法和PHP内置的echo print结构类似,所以我们把这个处理放到 internal_functions_in_yacc规则里面:

| T_VARIABLE_NAME '(' T_VARIABLE ')' { zend_do_variable_name(&$$, &$3 TSRMLS_CC); }
| T_VARIABLE_NAME T_VARIABLE { zend_do_variable_name(&$$, &$2 TSRMLS_CC); }

上面的两条规则分别对于类似:

<?php
echo var_name($varname);
echo var_name $varname;

的两种调用方式,和include() require() 类似。

大家可以很容易理解第一行的定义,如果发现 T_VARIABLE_NAME + ( + 变量 + ), 则使用zend_do_variable_name来处理, &$$ 是当前表达式的返回值, &$3 表示第三个表达式的值,也就是T_VARIABLE,也就是一个通常的变量定义。这样就是把变量相关的信息传递进 zend_do_variable_name() 函数中进行处理。在这里是获取变量的名称,然后进行opcode编译。

opcode编译

在开始之前需要向大家介绍一下PHP opcode的定义及执行。opcode在PHP中通常是一个数字唯一标识,在PHP中目前对每个opcode对应的执行方法的分发提供了3种方式:

首先,我们在Zend/zend_vm_opcodes.h 为我们的新opcode 加入一个宏定义:

#define ZEND_VARIABLE_NAME 154

这个数字要求在0-255之间,并且不能与现有opcode重复。

第二步,在Zend/zend_compile.c中加入我们对OPCODE的处理,也就是将代码操作转化为op_array放入到opline中:

void zend_do_variable_name(znode *result, znode *variable TSRMLS_DC)
{
    // 生成一条zend_op
    zend_op *opline = get_next_op(CG(active_op_array) TSRMLS_CC);
 
    // 因为我们需要有返回值, 并且返回值只作为中间值.所以就是一个临时变量
    opline->result.op_type = IS_TMP_VAR;
    opline->result.u.var = get_temporary_variable(CG(active_op_array));
 
    opline->opcode = ZEND_VARIABLE_NAME;
    opline->op1 = *variable;
 
    // 我们只需要一个操作数就好了
    SET_UNUSED(opline->op2);
    *result = opline->result;
}

这样,我们就完成了对opcode的编译。

内部处理逻辑的编写

经过在上面两个步骤中,我们已经完成了自定义PHP语法的语法规则定义,opcode编译。最后的工作,就是定义如何处理自定义的opcode,以及编写具体的代码逻辑。在前面关于如何找到opcode具体实现的小节,我们提到 Zend/zend_vm_execute.h中的zend_vm_get_opcode_handler()函数。这个函数就是用来获取opcode的执行函数。

这个对应的关系,是根据一个公式来进行了,目的是将不同的参数类型分开,对应到多个处理函数,公式是这样的:

return zend_opcode_handlers[opcode * 25 + zend_vm_decode[op->op1.op_type] * 5 
    + zend_vm_decode[op->op2.op_type]];

从这个公式我们可以看出,最终的处理函数是与参数类型有关,根据计算,我们要满足所有类型的映射,尽管我们可以使用同一函数进行处理。于是,我们在zend_opcode_handlers这个数组的结尾,加上25个相同的函数定义:

void zend_init_opcodes_handlers(void)
{
    static const opcode_handler_t labels[] = {
    ....
    ZEND_VARIABLE_NAME_HANDLER,
    ....
    ZEND_VARIABLE_NAME_HANDLER
}

如果我们不想支持某类型的数据,只需要将类型代入公式计算出的数字做为索引,使opcode_handler_t中相应的项为:ZEND_NULL_HANDLER

最后,我们在Zend/zend_vm_def.h 中增加相应的处理函数。

和对语法的修改一样,opcode处理函数也不是直接修改Zend/zend_vm_execute.h文件的, 这是因为PHP提供了3种opcode分发的机制: 1. CALL 函数调用的方式分发 1. SWITCH 使用SWITCH case 进行分发 1. GOTO 使用goto语句进行分发 之所以提供3中方式主要是从性能出发的,可能在不同的CPU上这几种调用方式的效率并不一样。 默认采用的是CALL

回到编写返回变量名的具体实现,在Zend/zend_vm_def.h中增加如下:

static int ZEND_FASTCALL ZEND_VARIABLE_NAME_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{   
    zend_op *opline = EX(opline);
 
    // PHP中所有的变量在内部都是存储在zval结构中的. 
    zval *result = &EX_T(opline->result.u.var).tmp_var;
 
    // 把变量的名字赋给临时返回值
    Z_STRVAL(*result) = estrndup(opline->op1.u.constant.value.str.val, opline->op1.u.constant.value.str.len);
    Z_STRLEN(*result) = opline->op1.u.constant.value.str.len;
    Z_TYPE(EX_T(opline->result.u.var).tmp_var) = IS_STRING;
 
    ZEND_VM_NEXT_OPCODE();
}

进行完上面的修改之后,我们要删除r2ec&flex已经编译好的原文件,即删除Zend/zend_language*.c文件以使新的语法规则生效。这样我们再次对PHP源码进行make时,会自动生成新的编译好的语法规则处理程序,不过,编译环境要安装有lex&yacc和re2c。

从上面的步骤可以看出,PHP语法的扩展并不困难,而真正的难点在于如何在当前zend内核框架基础上进行的具体功能的实现,以及到底应该实现什么语法。关于语法的改进通常也是一个漫长的过程,要修改语言的语法通常需要:

  • 提出需求,并说明该语法的作用,以及具体的应用场景,这个语法带来的好处
  • 大家讨论这个需求是否合理,实现起来是否有困难,对现有的语法是否造成影响
  • 如果大部分人都认可这个需求最好,那么提出该需求的人可以自己来实现,并让大家review,如果没有问题则就可以进入版本库了。
  • 如果比较有争议,那可能需要进行投票了。 更多内容请参考附录:怎么样为PHP做贡献小节。