PHP-Zend引擎剖析之词法分析(一) 博客分类: 源码剖析 PHPZend源码
程序员文章站
2024-03-19 23:58:22
...
前言
闲来研究一下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。
<!--more-->
词法分析
词法分析阶段就是从输入流里边一个字符一个字符的扫描,识别出对应的词素,最后把源文件转换成为一个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_start extern 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的书写出来的规则真心容易懂。