前言
读了读编译原理,用一句简单的 PHP 代码来理一遍 PHP 的编译执行过程。
简单的测试脚本
<?php
$Twings = 2333;
总览
PHP 的编译执行大概是这么一个流程:
- 读入代码字符串,词法分析得到 token 和 value。
- 语法分析 token,建立 AST 树。
- 遍历分析 AST 树,将数据写入内存,并生成 op_array。
- 遍历 op_array,根据 opcode 调用不同的 handler 执行操作。
入口
直接从 zend.c 的 zend_execute_scripts 函数开始看:
ZEND_API int zend_execute_scripts(int type, zval *retval, int file_count, ...) /* {{{ */
{
...
for (i = 0; i < file_count; i++) {
file_handle = va_arg(files, zend_file_handle *);
if (!file_handle) {
continue;
}
op_array = zend_compile_file(file_handle, type);
...
if (op_array) {
zend_execute(op_array, retval);
zend_exception_restore();
...
destroy_op_array(op_array);
efree_size(op_array, sizeof(zend_op_array));
}
...
}
...
}
一般来说这里会编译执行 3 个文件,分别是 auto_prepend_file、要执行的 PHP 文件 和 auto_append_file。
这里的 zend_compile_file 是个全局函数,指向的是 compile_file 函数:
zend_compile_file = compile_file;
compile_file 先调用 open_file_for_scanning 做好词法分析前的准备(比如设置词法分析的 state 为 INITIAL),再调用 zend_compile 开始编译:
ZEND_API zend_op_array *compile_file(zend_file_handle *file_handle, int type)
{
zend_lex_state original_lex_state;
zend_op_array *op_array = NULL;
zend_save_lexical_state(&original_lex_state);
if (open_file_for_scanning(file_handle)==FAILURE) {
...
} else {
op_array = zend_compile(ZEND_USER_FUNCTION);
}
zend_restore_lexical_state(&original_lex_state);
return op_array;
}
zend_compile 函数里面就是总览里面提到的 1-3 的处理过程了,首先是调用 zendparse 开始词法分析。
词法分析
PHP 的编译采用的是 re2c 词法分析 + Bison 语法分析。在默认情况下,调用 yyparse 开始语法分析,而 yyparse 则不断调用 yylex 从代码字符串中获取 token 和 value 再加以分析,而在 PHP 中这两个函数则分别被替换成了 zendparse 和 zendlex。所以在调用 zendparse 开始语法分析之后,Bison 会调用 zendlex 开始词法分析:
int ZEND_FASTCALL zendlex(zend_parser_stack_elem *elem) /* {{{ */
{
zval zv;
int ret;
if (CG(increment_lineno)) {
CG(zend_lineno)++;
CG(increment_lineno) = 0;
}
ret = lex_scan(&zv, elem);
ZEND_ASSERT(!EG(exception) || ret == T_ERROR);
return ret;
}
这里的 zend_parser_stack_elem 结构体实际上是用来存放 value 的,因为后面语法分析才会用到所以这里暂且不提。
zendlex 调用 lex_scan,后面就是词法分析的语法了,很长这里就不贴了,如果想要自己看最好看 .l 的 re2c 规则文件(匹配规则使用正则表达式,还是挺好看的,简单来说规则就是:\<state> 正则表达式 { 匹配后返回 token 的处理代码 } ),如果看 re2c 编译后的 c 文件,里面的代码逻辑简直不能看的。
结合测试代码和词法规则,我们可以得出分析的过程:
/*!re2c
LNUM [0-9]+(_[0-9]+)*
LABEL [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*
TOKENS [;:,.\[\]()|^&+-/*=%!~$<>?@]
NEWLINE ("\r"|"\n"|"\r\n")
*/
// <?php\n
<INITIAL>"<?php"([ \t]|{NEWLINE}) {
HANDLE_NEWLINE(yytext[yyleng-1]);
BEGIN(ST_IN_SCRIPTING);
RETURN_OR_SKIP_TOKEN(T_OPEN_TAG);
}
// $Twings
<ST_IN_SCRIPTING,ST_DOUBLE_QUOTES,ST_HEREDOC,ST_BACKQUOTE,ST_VAR_OFFSET>"$"{LABEL} {
RETURN_TOKEN_WITH_STR(T_VARIABLE, 1);
}
// =
<ST_IN_SCRIPTING>{TOKENS} {
RETURN_TOKEN(yytext[0]);
}
// 2333
<ST_IN_SCRIPTING>{LNUM} {
...
RETURN_TOKEN_WITH_VAL(T_LNUMBER);
}
// ;
<ST_IN_SCRIPTING>{TOKENS} {
RETURN_TOKEN(yytext[0]);
}
匹配到 = 和 ; 会直接返回字符的,没有什么其他操作。
在匹配到 <?php\n 的 open tag 之后,会将 state 改写为 ST_IN_SCRIPTING 用于下一次的匹配,然后返回一个代表 open tag 的 token(一般是一个整型数)。
然后开始匹配变量 $Twings,这里符合的匹配规则是 “$”{LABEL}(LABEL 其实就是 PHP 的合法变量名构成),这个规则除了返回 token,还会用变量名生成一个 AST 节点存储在前面提到的 zend_parser_stack_elem 里面,在后面的语法分析部分会作为子节点组装成一个完整的操作节点:
#define RETURN_TOKEN_WITH_STR(_token, _offset) do { \
token = _token; \
offset = _offset; \
goto emit_token_with_str; \
} while (0)
...
emit_token_with_str:
zend_copy_value(zendlval, (yytext + offset), (yyleng - offset));
emit_token_with_val:
if (PARSER_MODE()) {
ZEND_ASSERT(Z_TYPE_P(zendlval) != IS_UNDEF);
elem->ast = zend_ast_create_zval_with_lineno(zendlval, start_line);
}
elem -> ast 的生成代码如下:
ZEND_API zend_ast * ZEND_FASTCALL zend_ast_create_zval_with_lineno(zval *zv, uint32_t lineno) {
return zend_ast_create_zval_int(zv, 0, lineno);
}
static zend_always_inline zend_ast * zend_ast_create_zval_int(zval *zv, uint32_t attr, uint32_t lineno) {
zend_ast_zval *ast;
ast = zend_ast_alloc(sizeof(zend_ast_zval));
ast->kind = ZEND_AST_ZVAL;
ast->attr = attr;
ZVAL_COPY_VALUE(&ast->val, zv);
Z_LINENO(ast->val) = lineno;
return (zend_ast *) ast;
}
可以看到本来是个存放着 zval 的 zend_ast_zval 结构体,返回的时候为了适应 AST 树节点结构体而转换为了 zend_ast 类型的指针。
后半 LNUM 的操作里面有八进制和下划线的一些处理,想知道的可以自行阅读源码。
词法分析的过程大致如上,获取到 token 之后,接下来就是语法分析了。
语法分析
同样地,想要阅读源码建议看 zend_language_parser.y 里面 Bison 编译前的代码,更加人类可读一点。
在开头部分可以看到很多用 %token 声明的 token,比如说前面解析出的 T_VARIABLE:
%token <ast> T_VARIABLE "variable (T_VARIABLE)"
\<ast> 指 T_VARIABLE 的类型为 ast,其实就是前面提到的用 union 声明的 _zend_parser_stack_elem 结构体里面的 ast 成员,这个结构体里面声明的就是语法分析里面 token 要用的类型:
typedef union _zend_parser_stack_elem {
zend_ast *ast;
zend_string *str;
zend_ulong num;
unsigned char *ptr;
} zend_parser_stack_elem;
zend_ast 的定义如下,主要的就是操作码和子节点两个成员:
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) */
};
双引号里面的是这个 token 人类可读的字面表示。
语法规则跟词法分析里面的相似,同样是匹配 + 处理,不同的是语法分析里面还有套娃匹配。
阅读规则,我们可以得出分析的过程:
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); }
;
top_statement:
statement { $$ = $1; }
| ...
;
statement:
...
| expr ';' { $$ = $1; }
| ...
;
expr:
...
| variable '=' expr
{ $$ = zend_ast_create(ZEND_AST_ASSIGN, $1, $3); }
| scalar { $$ = $1; }
| ...
;
variable:
callable_variable
{ $$ = $1; }
| ...
;
callable_variable:
simple_variable
{ $$ = zend_ast_create(ZEND_AST_VAR, $1); }
| ...
;
simple_variable:
T_VARIABLE { $$ = $1; }
| ....
;
scalar:
T_LNUMBER { $$ = $1; }
|
;
$$ 代表本次处理完成后返回给上一级的值,$1 代表匹配规则中第一个符号的值。
这样看来就比较直观了,递归套娃,expr 中调用 zend_ast_create_list,用之前词法分析生成的两个 AST 节点组装成一个 ASSIGN 操作的节点,因为 ASSIGN 操作需要 2 个操作数,所以最后调用的其实是 zend_ast_create_2 函数:
ZEND_API zend_ast * ZEND_FASTCALL zend_ast_create_2(zend_ast_kind kind, zend_ast *child1, zend_ast *child2) {
zend_ast *ast;
uint32_t lineno;
ZEND_ASSERT(kind >> ZEND_AST_NUM_CHILDREN_SHIFT == 2);
ast = zend_ast_alloc(zend_ast_size(2));
ast->kind = kind;
ast->attr = 0;
ast->child[0] = child1;
ast->child[1] = child2;
if (child1) {
lineno = zend_ast_get_lineno(child1);
} else if (child2) {
lineno = zend_ast_get_lineno(child2);
} else {
lineno = CG(zend_lineno);
}
ast->lineno = lineno;
return ast;
}
简单来说就是将操作码和两个子节点放进一个新的节点里。完成一整部分代码(top_statement)的分析之后,会将这一部分生成的节点挂到前面(top_statement_list)生成的 AST 树的子节点上,后面通过遍历这棵树来依次处理这些 AST 节点。
遍历 AST 树
zend_compile 在调用 zendparse 完成语法分析之后,会调用 zend_compile_top_stmt 开始处理 AST 树:
void zend_compile_top_stmt(zend_ast *ast) /* {{{ */
{
if (!ast) {
return;
}
if (ast->kind == ZEND_AST_STMT_LIST) {
zend_ast_list *list = zend_ast_get_list(ast);
uint32_t i;
for (i = 0; i < list->children; ++i) {
zend_compile_top_stmt(list->child[i]);
}
return;
}
if (ast->kind == ZEND_AST_FUNC_DECL) {
...
} else if (ast->kind == ZEND_AST_CLASS) {
...
} else {
zend_compile_stmt(ast);
}
if (ast->kind != ZEND_AST_NAMESPACE && ast->kind != ZEND_AST_HALT_COMPILER) {
zend_verify_namespace();
}
}
如果是根节点,则遍历它的子节点重新调用本函数。
如果是 ASSIGN 类型的节点,则经过 zend_compile_stmt 和 zend_compile_expr 函数的 switch 之后会调用 zend_compile_assign:
void zend_compile_assign(znode *result, zend_ast *ast) /* {{{ */
{
zend_ast *var_ast = ast->child[0];
zend_ast *expr_ast = ast->child[1];
znode var_node, expr_node;
zend_op *opline;
uint32_t offset;
if (is_this_fetch(var_ast)) {
zend_error_noreturn(E_COMPILE_ERROR, "Cannot re-assign $this");
}
zend_ensure_writable_variable(var_ast);
switch (var_ast->kind) {
case ZEND_AST_VAR:
offset = zend_delayed_compile_begin();
zend_delayed_compile_var(&var_node, var_ast, BP_VAR_W, 0);
zend_compile_expr(&expr_node, expr_ast);
zend_delayed_compile_end(offset);
zend_emit_op(result, ZEND_ASSIGN, &var_node, &expr_node);
return;
case ZEND_AST_STATIC_PROP:
...
case ZEND_AST_DIM:
...
case ZEND_AST_PROP:
...
case ZEND_AST_ARRAY:
...
EMPTY_SWITCH_DEFAULT_CASE();
}
}
主要看 case ZEND_AST_VAR 里面的处理,这里调用 zend_emit_op 将 AST 节点处理成了 opcode,其结构体为 zend_op,里面存放了该 opcode 的处理函数(用于最后执行相应的操作)、操作码、操作数的值和类型等数据:
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;
};
顺带一提的是这里 var_node 的类型,即 op1_type 表示的是 cv(编译期缓存),还有就是这个时候,opcode 中的处理函数还是空的,赋值操作还在后面。然后将这个 opcode 放入 opcode 数组中:
static zend_op *zend_emit_op(znode *result, zend_uchar opcode, znode *op1, znode *op2) /* {{{ */
{
zend_op *opline = get_next_op();
opline->opcode = opcode;
if (op1 != NULL) {
SET_NODE(opline->op1, op1);
}
if (op2 != NULL) {
SET_NODE(opline->op2, op2);
}
if (result) {
zend_make_var_result(result, opline);
}
return opline;
}
opcode 数组的结构体比较长,这里就不贴了。
zend_compile_top_stmt 遍历完 AST 树生成 opcode 之后,会调用 pass_two 函数为每个 opcode 加上处理函数,因为查找的方式比较复杂,这里就不提了,想要研究的可以自行阅读 zend_vm_execute.h 的 zend_vm_get_opcode_handler_ex 函数。
执行
这时回到 zend_execute_scripts 函数中,生成 op_array,即 opcode 数组之后,会调用 zend_execute 开始执行,因为这个函数代码量过大,这里只简单叙述一下。
opcode 的执行过程是一个死循环,每次调用该 opcode 的处理函数之后会对返回值 ret 进行判断,如果 <= 0 就会终止循环,而 zend_compile 函数在调用 zend_compile_top_stmt 处理完 AST 树之后,还会调用 zend_emit_final_return 在末尾加上一个 opcode 用于终止循环。
在测试脚本中只有一个 opcode,相应的处理函数是 ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER:
static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
USE_OPLINE
zval *value;
zval *variable_ptr;
SAVE_OPLINE();
value = RT_CONSTANT(opline, opline->op2);
variable_ptr = EX_VAR(opline->op1.var);
if (IS_CV == IS_VAR && UNEXPECTED(Z_ISERROR_P(variable_ptr))) {
if (UNEXPECTED(0)) {
ZVAL_NULL(EX_VAR(opline->result.var));
}
} else {
value = zend_assign_to_variable(variable_ptr, value, IS_CONST, EX_USES_STRICT_TYPES());
if (UNEXPECTED(0)) {
ZVAL_COPY(EX_VAR(opline->result.var), value);
}
/* zend_assign_to_variable() always takes care of op2, never free it! */
}
ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
}
在执行结束之后,会调用 ZEND_VM_NEXT_OPCODE_EX 移动 opcode 指针指向下一条 opcode。
后记
简单理了一遍过程,以后有时间再看看其他感兴趣的东西,比如变量哈希表如何存放,函数定义与执行,函数递归等等。
参考:
https://www.php.cn/php-weizijiaocheng-357239.html