php zend vir M - yaokun123/php-wiki GitHub Wiki
PHP是解析型高级语言,事实上从Zend内核的角度来看PHP就是一个普通的C程序,它有main函数,我们写的PHP代码是这个程序的输入,然后经过内核的处理输出结果,内核将PHP代码"翻译"为C程序可识别的过程就是PHP的编译。
那么这个"翻译"过程具体都有哪些操作呢?
C程序在编译时将一行行代码编译为机器码,每一个操作都认为是一条机器指令,这些指令写入到编译后的二进制程序中,执行的时候将二进制程序load进相应的内存区域(常量区、数据区、代码区)、分配运行栈,然后从代码区起始位置开始执行,这是C程序编译、执行的简单过程。
同样,PHP的编译与普通的C程序类似,只是PHP代码没有编译成机器码,而是解析成了若干条opcode数组,每条opcode就是C里面普通的struct,含义对应C程序的机器指令,执行的过程就是引擎依次执行opcode,比如我们在PHP里定义一个变量:$a = 123;,最终到内核里执行就是malloc一块内存,然后把值写进去。
所以PHP的解析过程任务就是将PHP代码转化为opcode数组,代码里的所有信息都保存在opcode中,然后将opcode数组交给zend引擎执行,opcode就是内核具体执行的命令,比如赋值、加减操作、函数调用等,每一条opcode都对应一个处理handle,这些handler是提前定义好的C函数。
从PHP代码到opcode是怎么实现的?最容易想到的方式就是正则匹配,当然过程没有这么简单。PHP编译过程包括词法分析、语法分析,使用re2c、bison完成,旧的PHP版本直接生成了opcode,PHP7新增了抽象语法树(AST),在语法分析阶段生成AST,然后再生成opcode数组。
这一节我们分析下PHP的解析阶段,即 PHP代码->抽象语法树(AST) 的过程。
PHP使用re2c、bison完成这个阶段的工作:
-
re2c: 词法分析器,将输入分割为一个个有意义的词块,称为token
-
bison: 语法分析器,确定词法分析器分割出的token是如何彼此关联的
例如:
$a = 2 + 3;
词法分析器将上面的语句分解为这些token:$a、=、2、+、3,接着语法分析器确定了2+3是一个表达式,而这个表达式被赋值给了a,我们可以这样定义词法解析规则:
/*!re2c
LABEL [a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*
LNUM [0-9]+
//规则
"$"{LABEL} {return T_VAR;}
{LNUM} {return T_NUM;}
*/
然后定义语法解析规则:
//token定义
%token T_VAR
%token T_NUM
//语法规则
statement:
T_VAR '=' T_NUM '+' T_NUM {ret = str2int($3) + str2int($5);printf("%d",ret);}
;
上面的语法规则只能识别两个数值相加,假如我们希望支持更复杂的运算,比如:
$a = 3 + 4 - 6;
则可以配置递归规则:
//语法规则
statement:
T_VAR '=' expr {}
;
expr:
T_NUM {...}
|expr '?' T_NUM {}
;
这样将支持若干表达式,用语法分析树表示:
接下来我们看下PHP具体的解析过程,PHP编译阶段流程:
其中 zendparse() 就是词法、语法解析过程,这个函数实际就是bison中提供的语法解析函数 yyparse() :
#define yyparse zendparse
yyparse() 不断调用 yylex() 得到token,然后根据token匹配语法规则:
#define yylex zendlex
//zend_compile.c
int zendlex(zend_parser_stack_elem *elem)
{
zval zv;
int retval;
...
again:
ZVAL_UNDEF(&zv);
retval = lex_scan(&zv);
if (EG(exception)) {
//语法错误
return T_ERROR;
}
...
if (Z_TYPE(zv) != IS_UNDEF) {
//如果在分割token中有zval生成则将其值复制到zend_ast_zval结构中
elem->ast = zend_ast_create_zval(&zv);
}
return retval;
}
这里两个关键点需要注意:
- 1、token值
词法解析器解析到的token值内容就是token值,这些值统一通过 zval 存储,上面的过程中可以看到调用lex_scan参数是是个zval*,在具体的命中规则总会将解析到的token保存到这个值,从而传递给语法解析器使用,比如PHP中的解析变量的规则:$a;,其词法解析规则为:
<ST_IN_SCRIPTING,ST_DOUBLE_QUOTES,ST_HEREDOC,ST_BACKQUOTE,ST_VAR_OFFSET>"$"{LABEL} {
//将匹配到的token值保存在zval中
zend_copy_value(zendlval, (yytext+1), (yyleng-1)); //只保存{LABEL}内容,不包括$,所以是yytext+1
RETURN_TOKEN(T_VARIABLE);
}
zendlval就是我们传入的zval*,yytext指向命中的token值起始位置,yyleng为token值的长度。
- 2、语义值类型
bison调用re2c分割token有两个含义,第一个是token类型,另一个是token值,token类型一般以yylex的返回值告诉bison,而token值就是语义值,这个值一般定义为固定的类型,这个类型就是语义值类型,默认为int,可以通过 YYSTYPE 定义,而PHP中这个类型是 zend_parser_stack_elem ,这就是为什么zendlex的参数为zend_parser_stack_elem的原因。
#define YYSTYPE zend_parser_stack_elem
typedef union _zend_parser_stack_elem {
zend_ast *ast; //抽象语法树主要结构
zend_string *str;
zend_ulong num;
} zend_parser_stack_elem;
实际这是个union,ast类型用的比较多(其它两种类型暂时没发现有地方在用),这样可以通过%token、%type将对应的值修改为elem.ast,所以在zend_language_parser.y中使用的$$、$1、$2......多数都是 zend_parser_stack_elem.ast :
%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)"
%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
语法解析器从start开始调用,然后层层匹配各个规则,语法解析器根据命中的语法规则创建AST节点,最后将生成的AST根节点赋到 CG(ast) :
%% /* 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); }
;
首先会创建一个根节点list,然后将后面不断命中top_statement生成的ast加到这个list中,zend_ast具体结构:
enum _zend_ast_kind {
ZEND_AST_ZVAL = 1 << ZEND_AST_SPECIAL_SHIFT,
ZEND_AST_ZNODE,
/* list nodes */
ZEND_AST_ARG_LIST = 1 << ZEND_AST_IS_LIST_SHIFT,
...
};
struct _zend_ast {
zend_ast_kind kind; /* Type of the node (ZEND_AST_* enum constant) */
zend_ast_attr attr; /* Additional attribute, use depending on node type */
uint32_t lineno; /* Line number */
zend_ast *child[1]; /* Array of children (using struct hack) */
};
typedef struct _zend_ast_list {
zend_ast_kind kind;
zend_ast_attr attr;
uint32_t lineno;
uint32_t children;
zend_ast *child[1];
} zend_ast_list;
根节点实际为zend_ast_list,每条语句对应的ast保存在child中,使用中zend_ast_list、zend_ast可以相互转化,kind标识的是ast节点类型,后面会根据这个值生成具体的opcode,另外函数、类还会用到另外一种ast节点结构:
typedef struct _zend_ast_decl {
zend_ast_kind kind;
zend_ast_attr attr; /* Unused - for structure compatibility */
uint32_t start_lineno; //开始行号
uint32_t end_lineno; //结束行号
uint32_t flags;
unsigned char *lex_pos;
zend_string *doc_comment;
zend_string *name;
zend_ast *child[4]; //类中会将继承的父类、实现的接口以及类中的语句解析保存在child中
} zend_ast_decl;
这么看比较难理解,接下来我们从一个简单的例子看下最终生成的语法树。
$a = 123;
$b = "hi~";
echo $a,$b;
具体解析过程这里不再解释,有兴趣的可以翻下zend_language_parse.y中,这个过程不太容易理解,需要多领悟几遍,最后生成的ast如下图:
上一小节我们简单介绍了从PHP代码解析为抽象语法树的过程,这一节我们再介绍下从 抽象语法树->Opcodes 的过程。
语法解析过程的产物保存于CG(AST),接着zend引擎会把AST进一步编译为 zend_op_array ,它是编译阶段最终的产物,也是执行阶段的输入,后面我们介绍的东西基本都是围绕zend_op_array展开的,AST解析过程确定了当前脚本定义了哪些变量,并为这些变量 顺序编号 ,这些值在使用时都是按照这个编号获取的,另外也将变量的初始化值、调用的函数/类/常量名称等值(称之为字面量)保存到zend_op_array.literals中,这些字面量也有一个唯一的编号,所以执行的过程实际就是根据各指令调用不同的C函数,然后根据变量、字面量、临时变量的编号对这些值进行处理加工。
我们首先看下zend_op_array的结构,明确几个关键信息,然后再看下ast编译为zend_op_array的过程。
1、zend_op_array数据结构
PHP主脚本会生成一个zend_op_array,每个function也会编译为独立的zend_op_array,所以从二进制程序的角度看zend_op_array包含着当前作用域下的所有堆栈信息,函数调用实际就是不同zend_op_array间的切换。
struct _zend_op_array {
//common是普通函数或类成员方法对应的opcodes快速访问时使用的字段,后面分析PHP函数实现的时候会详细讲
...
uint32_t *refcount;
uint32_t this_var;
uint32_t last;
//opcode指令数组
zend_op *opcodes;
//PHP代码里定义的变量数:op_type为IS_CV的变量,不含IS_TMP_VAR、IS_VAR的
//编译前此值为0,然后发现一个新变量这个值就加1
int last_var;
//临时变量数:op_type为IS_TMP_VAR、IS_VAR的变量
uint32_t T;
//PHP变量名数组
zend_string **vars; //这个数组在ast编译期间配合last_var用来确定各个变量的编号,非常重要的一步操作
...
//静态变量符号表:通过static声明的
HashTable *static_variables;
...
//字面量数量
int last_literal;
//字面量(常量)数组,这些都是在PHP代码定义的一些值
zval *literals;
//运行时缓存数组大小
int cache_size;
//运行时缓存,主要用于缓存一些znode_op以便于快速获取数据,后面单独介绍这个机制
void **run_time_cache;
void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};
zend_op_array.opcodes指向指令列表,具体每条指令的结构如下:
struct _zend_op {
const void *handler; //指令执行handler
znode_op op1; //操作数1
znode_op op2; //操作数2
znode_op result; //返回值
uint32_t extended_value;
uint32_t lineno;
zend_uchar opcode; //opcode指令
zend_uchar op1_type; //操作数1类型
zend_uchar op2_type; //操作数2类型
zend_uchar result_type; //返回值类型
};
//操作数结构
typedef union _znode_op {
uint32_t constant;
uint32_t var;
uint32_t num;
uint32_t opline_num; /* Needs to be signed */
uint32_t jmp_offset;
} znode_op;
opcode各字段含义下面展开说明。
- handler
handler为每条opcode对应的C语言编写的 处理过程 ,所有opcode对应的处理过程定义在zend_vm_def.h中,值得注意的是这个文件并不是编译时用到的,因为opcode的 处理过程 有三种不同的提供形式:CALL、SWITCH、GOTO,默认方式为CALL,这个是什么意思呢?
每个opcode都代表了一些特定的处理操作,这个东西怎么提供呢?一种是把每种opcode负责的工作封装成一个function,然后执行器循环执行即可,这就是CALL模式的工作方式;另外一种是把所有opcode的处理方式通过C语言里面的label标签区分开,然后执行器执行的时候goto到相应的位置处理,这就是GOTO模式的工作方式;最后还有一种方式是把所有的处理方式写到一个switch下,然后通过case不同的opcode执行具体的操作,这就是SWITCH模式的工作方式。
假设opcode数组是这个样子
int op_array[] = {
opcode_1,
opcode_2,
opcode_3,
...
};
各模式下的工作过程类似这样:
//CALL模式
void opcode_1_handler() {...}
void opcode_2_handler() {...}
...
void execute(int []op_array)
{
void *opcode_handler_list[] = {&opcode_1_handler, &opcode_2_handler, ...};
while(1){
void handler = opcode_handler_list[op_array[i]];
handler(); //call handler
i++;
}
}
//GOTO模式
void execute(int []op_array)
{
while(1){
goto opcode_xx_handler_label;
}
opcode_1_handler_label:
...
opcode_2_handler_label:
...
...
}
//SWITCH模式
void execute(int []op_array)
{
while(1){
switch(op_array[i]){
case opcode_1:
...
case opcode_2:
...
...
}
i++;
}
}
三种模式效率是不同的,GOTO最快,怎么选择其它模式呢?下载PHP源码后不要直接编译,Zend目录下有个文件:zend_vm_gen.php,在编译PHP前执行:php zend_vm_gen.php --with-vm-kind=CALL|SWITCH|GOTO,这个脚本将重新生成:zend_vm_opcodes.h、zend_vm_opcodes.c、zend_vm_execute.h三个文件覆盖原来的,然后再编译PHP即可。
后面分析的过程使用的都是默认模式CALL,也就是opcode对应的handler为一个函数指针,编译时opcode对应的handler是如何根据opcode索引到的呢?
opcode的数值各不相同,同时可以根据两个zend_op的类型设置不同的处理handler,因此每个opcode指令最多有20个(25去掉重复的5个)对应的处理handler,所有的handler按照opcode数值的顺序定义在一个大数组中:zend_opcode_handlers,每25个为同一个opcode,如果对应的op_type类型handler则可以设置为空:
//Zend/zend_vm_execute.h
void zend_init_opcodes_handlers(void)
{
static const void *labels[] = {
ZEND_NOP_SPEC_HANDLER,
ZEND_NOP_SPEC_HANDLER,
...
};
zend_opcode_handlers = labels;
}
索引的算法:
static const void *zend_vm_get_opcode_handler(zend_uchar opcode, const zend_op* op)
{
//因为op_type为2的倍数,所以这里做了下转化,转成了0-4
static const int zend_vm_decode[] = {
_UNUSED_CODE, /* 0 */
_CONST_CODE, /* 1 = IS_CONST */
_TMP_CODE, /* 2 = IS_TMP_VAR */
_UNUSED_CODE, /* 3 */
_VAR_CODE, /* 4 = IS_VAR */
_UNUSED_CODE, /* 5 */
_UNUSED_CODE, /* 6 */
_UNUSED_CODE, /* 7 */
_UNUSED_CODE, /* 8 = IS_UNUSED */
_UNUSED_CODE, /* 9 */
_UNUSED_CODE, /* 10 */
_UNUSED_CODE, /* 11 */
_UNUSED_CODE, /* 12 */
_UNUSED_CODE, /* 13 */
_UNUSED_CODE, /* 14 */
_UNUSED_CODE, /* 15 */
_CV_CODE /* 16 = IS_CV */
};
//根据op1_type、op2_type、opcode得到对应的handler
return zend_opcode_handlers[opcode * 25 + zend_vm_decode[op->op1_type] * 5 + zend_vm_decode[op->op2_type]];
}
ZEND_API void zend_vm_set_opcode_handler(zend_op* op)
{
//设置zend_op的handler,这个操作是在编译期间完成的
op->handler = zend_vm_get_opcode_handler(zend_user_opcodes[op->opcode], op);
}
#define _CONST_CODE 0
#define _TMP_CODE 1
#define _VAR_CODE 2
#define _UNUSED_CODE 3
#define _CV_CODE 4
- 操作数(znode_op)
操作数类型实际就是个32位整形,它主要用于存储一些变量的索引位置、数值记录等等。
typedef union _znode_op {
uint32_t constant;
uint32_t var;
uint32_t num;
uint32_t opline_num; /* Needs to be signed */
uint32_t jmp_offset;
} znode_op;
每条opcode都有两个操作数(不一定都用到),操作数记录着当前指令的关键信息,可以用于变量的存储、访问,比如赋值语句:"$a = 45;",两个操作数分别记录"$a"、"45"的存储位置,执行时根据op2取到值"45",然后赋值给"$a",而"$a"的位置通过op1获取到。当然操作数并不是全部这么用的,上面只是赋值时候的情况,其它操作会有不同的用法,如函数调用时的传参,op1记录的就是传递的参数是第几个,op2记录的是参数的存储位置,result记录的是函数接收参数的存储位置。
- 操作数类型(op_type)
每个操作都有5种不同的类型:
#define IS_CONST (1<<0) //1
#define IS_TMP_VAR (1<<1) //2
#define IS_VAR (1<<2) //4
#define IS_UNUSED (1<<3) //8
#define IS_CV (1<<4) //16
IS_CONST:字面量,编译时就可确定且不会改变的值,比如:$a = "hello~",其中字符串"hello~"就是常量
IS_TMP_VAR:临时变量,比如:$a = "hello~" . time(),其中"hello~" . time()的值类型就是IS_TMP_VAR,
再比如:$a = "123" + $b,"123" + $b的结果类型也是IS_TMP_VAR,从这两个例子可以猜测,
临时变量多是执行期间其它类型组合现生成的一个中间值,由于它是现生成的,所以把IS_TMP_VAR赋值给IS_CV变量时不会增加其引用计数
IS_VAR:PHP变量,这个很容易认为是PHP脚本里的变量,其实不是,这里PHP变量的含义可以这样理解:
PHP变量是没有显式的在PHP脚本中定义的,不是直接在代码通过$var_name定义的。
这个类型最常见的例子是PHP函数的返回值,再如$a[0]数组这种,它取出的值也是IS_VAR,再比如$$a这种
IS_UNUSED:表示操作数没有用
IS_CV:PHP脚本变量,即脚本里通过$var_name定义的变量,这些变量是编译阶段确定的,所以是compile variable