从源码到操作码:PHP 的词法分析器、语法分析器、AST 与编译器
前置知识
- ›第 1-2 篇:架构概览与 zval/数据结构基础知识
- ›编译器基础理论(词法分析器、语法分析器、AST 概念)
- ›位域标志与枚举的基本理解
从源码到操作码:PHP 的词法分析器、语法分析器、AST 与编译器
前两篇文章梳理了 php-src 的整体架构,并深入剖析了核心数据结构。本篇将跟随一个 PHP 源文件,走完将其转换为可执行操作码的完整流水线。每当 PHP 遇到 require、include 或主脚本时,这条流水线就会启动——除非 OPcache 介入并返回缓存结果。
这套编译流水线的设计出人意料地"传统":词法分析器将 token 流送入语法分析器,语法分析器构建 AST,编译器再遍历 AST 生成一组扁平的操作码数组。真正有趣的地方在于其规模(语法文件包含数百条产生式,AST 拥有约 130 种节点类型,编译器则是整个代码库中最大的文件之一)以及那些让它能在每次请求中都保持足够性能的工程决策。
流水线概览
整个编译流水线分为四个阶段,由 zend_compile_file() 统一调度:
flowchart LR
SRC["PHP Source<br/>(.php file)"] --> LEX["Lexer<br/>(re2c)<br/>zend_language_scanner.l"]
LEX --> |"Token stream"| PARSE["Parser<br/>(Bison)<br/>zend_language_parser.y"]
PARSE --> |"AST"| COMP["Compiler<br/>zend_compile.c"]
COMP --> |"zend_op_array"| VM["VM Execution<br/>or OPcache storage"]
OPCACHE["OPcache Hook"] -.-> |"Replaces<br/>zend_compile_file"| SRC
OPCACHE -.-> |"Returns cached"| VM
关键的扩展点在于:zend_compile_file 是一个全局函数指针。OPcache 在 MINIT 阶段将其替换,从而拦截编译调用,直接从共享内存返回已缓存的 op_array。这正是我们将在第 4 篇深入探讨的"可挂钩函数指针"模式。
编译器的核心实现位于 Zend/zend_compile.c,这是整个代码库中最大的源文件之一,超过 10,000 行。接下来我们逐阶段追踪数据的流向。
re2c 词法分析器
词法分析器定义在 Zend/zend_language_scanner.l,这是一个 re2c 输入文件。与 flex/lex 不同,re2c 直接生成编码的 DFA——没有状态表,只有 goto 跳转和字符比较。这使得扫描器的性能远超基于状态表的方案。
扫描器有多种状态(在 re2c 中称为"条件"):
| 状态 | 触发条件 | 扫描内容 |
|---|---|---|
ST_INITIAL / ST_IN_SCRIPTING |
默认 | PHP 关键字、运算符、标识符 |
ST_LOOKING_FOR_PROPERTY |
-> 或 ?-> 之后 |
属性/方法名 |
ST_DOUBLE_QUOTES |
" |
双引号字符串插值内容 |
ST_HEREDOC |
<<<IDENTIFIER |
Heredoc 主体(含插值) |
ST_NOWDOC |
<<<'IDENTIFIER' |
Nowdoc 主体(无插值) |
ST_BACKQUOTE |
` |
Shell 执行插值 |
ST_VAR_OFFSET |
字符串中的 $var[ |
字符串插值中的数组偏移量 |
文件顶部的扫描器宏负责设置 re2c 接口。SCNG() 用于访问扫描器全局变量(当前缓冲区位置、行号、文件名)。.l 文件中的每条规则返回一个 token 常量(如 T_FUNCTION、T_CLASS、T_VARIABLE 等),并将匹配到的文本以 zval 形式保存。
字符串处理是词法分析器中最复杂的部分。Heredoc 和 Nowdoc 需要跟踪界定符,双引号字符串中的插值需要嵌套的扫描状态,而 PHP 灵活的字符串插值语法("$obj->prop"、"{$arr['key']}")更带来了错综复杂的状态转换。
提示: 想查看 PHP 对某个源文件生成的 token 序列,可以使用
php -w(去除空白)或token_get_all()函数——后者会直接将词法分析器的结果暴露给用户空间。
Bison 语法分析器与文法
语法分析器定义在 Zend/zend_language_parser.y,这是一个 Bison 文法文件。生成的解析器是 LALR(1) 解析器——从左到右读取 token,使用一个 token 的前瞻,自底向上归约产生式。
文法中的优先级声明确定了运算符的优先顺序——从最低(,、yield)到最高(一元运算符、成员访问)。%left、%right 和 %nonassoc 声明用于消除 $a + $b * $c 或 $a ?? $b ?? $c 等表达式中的歧义。
flowchart TD
TOP["top_statement_list"] --> STMT["statement"]
TOP --> FUNC["function_declaration_statement"]
TOP --> CLASS["class_declaration_statement"]
STMT --> EXPR["expr ';'"]
STMT --> IF["if_statement"]
STMT --> WHILE["while_statement"]
STMT --> FOR["for_statement"]
STMT --> RETURN["T_RETURN expr ';'"]
EXPR --> ASSIGN["variable '=' expr"]
EXPR --> BINARY["expr '+' expr"]
EXPR --> CALL["function_call"]
EXPR --> NEW["T_NEW class_name_reference"]
CLASS --> MEMBERS["class_statement_list"]
MEMBERS --> METHOD["method_declaration"]
MEMBERS --> PROP["property_declaration"]
每个 Bison 动作(产生式规则后 { } 中的 C 代码)都会调用 zend_ast_create* 系列函数来构造 AST 节点。例如,二元加法 expr '+' expr 会创建一个带有两个子节点的 ZEND_AST_BINARY_OP 节点;函数声明则会创建一个 ZEND_AST_FUNC_DECL 节点,其子节点分别对应函数名、参数、返回类型和函数体。
语法分析器不直接生成操作码——那是 PHP 5 的做法。PHP 7 及以后的版本将解析与编译通过 AST 隔离开来,从而支持多遍分析和更好的优化空间。
AST 节点类型与结构
AST 节点系统定义在 Zend/zend_ast.h,共有约 130 种 ZEND_AST_* 节点类型,按结构分类如下:
| 分类 | 子节点数量 | 示例 |
|---|---|---|
| 特殊节点 | 可变 | ZEND_AST_ZVAL(字面量)、ZEND_AST_ZNODE(编译器临时值) |
| 声明节点 | 固定(4+) | ZEND_AST_FUNC_DECL、ZEND_AST_CLASS、ZEND_AST_METHOD |
| 列表节点 | 可变长度 | ZEND_AST_STMT_LIST、ZEND_AST_ARG_LIST、ZEND_AST_EXPR_LIST |
| 0 子节点 | 0 | (目前无——字面量由 ZEND_AST_ZVAL 覆盖) |
| 1 子节点 | 1 | ZEND_AST_VAR、ZEND_AST_RETURN、ZEND_AST_UNARY_PLUS |
| 2 子节点 | 2 | ZEND_AST_ASSIGN、ZEND_AST_BINARY_OP、ZEND_AST_WHILE |
| 3 子节点 | 3 | ZEND_AST_CONDITIONAL(三元运算符)、ZEND_AST_FOR |
| 4 子节点 | 4 | ZEND_AST_IF_ELEM、ZEND_AST_FOR(init、cond、loop、body) |
zend_ast 基础结构体包含 kind(节点类型)和 attr(节点特定标志——例如 ZEND_AST_BINARY_OP 对应的具体运算符,或方法声明的访问修饰符)。具体实现有三种结构体变体:
zend_ast_zval:封装一个zval(用于字面量值)zend_ast_decl:用于函数/类/方法声明,包含名称、标志、文档注释和子 AST 等额外字段zend_ast_list:用于可变长度的子节点(语句列表、参数列表等)
包含 1 至 4 个子节点指针的普通 zend_ast 结构体则覆盖其余所有情况。这种层次设计使 AST 的内存分配非常紧凑——大多数节点只需一个头部加几个指针。
编译器:从 AST 到操作码
Zend/zend_compile.c 中的编译器是一个单遍递归树遍历器。核心分发函数——语句对应 zend_compile_stmt(),表达式对应 zend_compile_expr()——根据 AST 节点类型进行分支,并调用对应的专用编译函数。
flowchart TD
ENTRY["zend_compile_top_stmt()"] --> SWITCH{"node->kind?"}
SWITCH -->|"ZEND_AST_FUNC_DECL"| CF["zend_compile_func_decl()"]
SWITCH -->|"ZEND_AST_CLASS"| CC["zend_compile_class_decl()"]
SWITCH -->|"ZEND_AST_STMT_LIST"| CL["iterate children,<br/>call zend_compile_stmt()"]
SWITCH -->|"ZEND_AST_RETURN"| CR["zend_compile_return()"]
SWITCH -->|"other"| CE["zend_compile_stmt()<br/>→ zend_compile_expr()"]
CE --> EMIT["zend_emit_op()<br/>zend_emit_op_tmp()"]
EMIT --> OP["zend_op appended<br/>to current op_array"]
CF --> NEW_OP["New zend_op_array<br/>for function body"]
CC --> NEW_CE["New zend_class_entry<br/>with method op_arrays"]
编译器维护两个上下文结构体,定义在 Zend/zend_compile.h:
zend_file_context:文件级状态(当前命名空间、use语句的导入表)zend_oparray_context:函数级状态(当前 op_array、循环/try-catch 的跳转目标、变量分配)
表达式编译过程中的中间结果使用 znode(定义在 Zend/zend_compile.h),用于记录结果是常量、临时变量、编译变量还是未使用。znode 是 AST 编译与操作码生成之间的桥梁——每个 zend_compile_expr_* 函数都会填充一个结果 znode,父级表达式再将其作为操作数使用。
zend_op 指令格式
每条编译后的指令都是一个 zend_op,定义在 Zend/zend_compile.h:
flowchart LR
subgraph zend_op["zend_op struct"]
direction TB
HANDLER["handler: void*<br/>Function pointer to VM handler"]
OP1["op1: znode_op<br/>First operand (32-bit)"]
OP2["op2: znode_op<br/>Second operand (32-bit)"]
RESULT["result: znode_op<br/>Result location (32-bit)"]
EXT["extended_value: uint32_t<br/>Extra data (operator type, flags)"]
LINE["lineno: uint32_t"]
OPCODE["opcode: uint8_t"]
TYPES["op1_type, op2_type, result_type: uint8_t"]
end
每个操作数都有一个类型标签,用于决定如何解释 32 位的 znode_op 值:
| 操作数类型 | 值 | 含义 |
|---|---|---|
IS_UNUSED |
0 | 操作数未使用(或承载跳转目标) |
IS_CONST |
1 (1<<0) |
字面量表(literal table)中的索引(编译期常量) |
IS_TMP_VAR |
2 (1<<1) |
临时变量槽(表达式中间结果) |
IS_VAR |
4 (1<<2) |
变量槽(可持有引用) |
IS_CV |
8 (1<<3) |
编译变量(Compiled Variable),即具名局部变量($foo) |
extended_value 字段因操作码不同而有不同用途。对于 ZEND_ASSIGN_OP,它存储具体运算符(ZEND_ADD、ZEND_SUB 等);对于 ZEND_FETCH_OBJ,它存储缓存槽偏移量;对于 ZEND_CAST,它存储目标类型。
操作码常量定义在 Zend/zend_vm_opcodes.h,共约 200 个,涵盖算术运算、控制流、函数调用、对象操作、数组操作等。每个操作码都有一个 handler 字段——一个在编译期设置的函数指针,指向 VM 中对应的类型特化处理器。我们将在第 4 篇深入探讨 handler 系统。
zend_op_array 与 zend_function
编译的输出结果是 zend_op_array——函数、方法或顶层脚本编译后的表示形式。它定义在 Zend/zend_compile.h,主要包含:
opcodes:zend_op指令的扁平数组literals:常量表(源码中字面量值对应的 zval)vars:编译变量名($this、$param1、$localVar)arg_info:参数类型与默认值信息try_catch_array:异常处理范围static_variables:static $var初始化器filename、line_start、line_end:源码位置信息num_args、required_num_args:参数数量信息
zend_function 联合体(定义在 Zend/zend_compile.h)统一了用户函数与内置(C)函数:
classDiagram
class zend_function {
<<union>>
+uint8_t type
+common: zend_function_common
+op_array: zend_op_array
+internal_function: zend_internal_function
}
class zend_function_common {
+uint8_t type
+uint32_t fn_flags
+zend_string *function_name
+zend_class_entry *scope
+zend_function *prototype
+zend_arg_info *arg_info
+uint32_t num_args
+uint32_t required_num_args
}
class zend_op_array {
+common fields...
+zend_op *opcodes
+zval *literals
+...compiled PHP function
}
class zend_internal_function {
+common fields...
+handler: zif_handler
+...C function
}
zend_function --> zend_function_common
zend_function --> zend_op_array
zend_function --> zend_internal_function
common 字段在 zend_op_array 和 zend_internal_function 的开头以相同的布局排列。这意味着只需访问函数名、标志或参数信息的代码,可以直接通过 func->common.* 获取,无需关心它是用户函数还是内置函数。
类、方法与属性的标志位
编译系统使用大量位域来表示类和成员修饰符,定义在 Zend/zend_compile.h。ZEND_ACC_* 常量身兼多职:
| 标志 | 位 | 十六进制值 | 适用范围 |
|---|---|---|---|
ZEND_ACC_PUBLIC |
1 << 0 |
0x00000001 |
方法、属性、常量 |
ZEND_ACC_PROTECTED |
1 << 1 |
0x00000002 |
方法、属性、常量 |
ZEND_ACC_PRIVATE |
1 << 2 |
0x00000004 |
方法、属性、常量 |
ZEND_ACC_STATIC |
1 << 4 |
0x00000010 |
方法、属性 |
ZEND_ACC_FINAL |
1 << 5 |
0x00000020 |
类、方法、属性、常量 |
ZEND_ACC_ABSTRACT |
1 << 6 |
0x00000040 |
类、方法、属性 |
ZEND_ACC_READONLY |
1 << 7 |
0x00000080 |
属性 |
ZEND_ACC_INTERFACE |
1 << 0 |
0x00000001 |
类(与 PUBLIC 共用同一位——取决于上下文!) |
ZEND_ACC_TRAIT |
1 << 1 |
0x00000002 |
类(与 PROTECTED 共用同一位——取决于上下文!) |
ZEND_ACC_ENUM |
1 << 28 |
0x10000000 |
类 |
注意,某些位在不同上下文中是共用的。ZEND_ACC_INTERFACE 和 ZEND_ACC_PUBLIC 使用同一个位(1 << 0),ZEND_ACC_TRAIT 与 ZEND_ACC_PROTECTED(1 << 1)共用位。这是可行的,因为类声明不可能同时既是接口又是方法——标志的解释取决于它应用于类条目还是函数/属性。编译器会在类/方法声明的编译阶段验证标志组合是否合法(例如,abstract final 是非法的),并据此设置相应标志。
提示: 在 GDB 中调试标志值时,将
fn_flags或ce->ce_flags转换为十六进制,再与ZEND_ACC_*表进行对照。例如0x00000051表示PUBLIC | STATIC | ABSTRACT(0x01 + 0x10 + 0x40)。位位置可能因版本而异,务必查阅对应的头文件。
下一步
我们已经完整追踪了从源文本到编译操作码的全过程。第 4 篇将深入虚拟机内部——那个真正执行这些操作码的组件。我们将探索独特的基于模板的代码生成系统(可生成 123,000 行类型特化处理器)、五种分发模式(包括全新的 TAILCALL 模式)、用于性能优化的全局寄存器锁定,以及在操作码执行前对其进行变换的基于 SSA 的优化器。本篇介绍的 zend_op 格式与 zend_op_array 结构,正是 VM 的直接输入。