Read OSS

从源码到操作码:PHP 的词法分析器、语法分析器、AST 与编译器

高级

前置知识

  • 第 1-2 篇:架构概览与 zval/数据结构基础知识
  • 编译器基础理论(词法分析器、语法分析器、AST 概念)
  • 位域标志与枚举的基本理解

从源码到操作码:PHP 的词法分析器、语法分析器、AST 与编译器

前两篇文章梳理了 php-src 的整体架构,并深入剖析了核心数据结构。本篇将跟随一个 PHP 源文件,走完将其转换为可执行操作码的完整流水线。每当 PHP 遇到 requireinclude 或主脚本时,这条流水线就会启动——除非 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_FUNCTIONT_CLASST_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_DECLZEND_AST_CLASSZEND_AST_METHOD
列表节点 可变长度 ZEND_AST_STMT_LISTZEND_AST_ARG_LISTZEND_AST_EXPR_LIST
0 子节点 0 (目前无——字面量由 ZEND_AST_ZVAL 覆盖)
1 子节点 1 ZEND_AST_VARZEND_AST_RETURNZEND_AST_UNARY_PLUS
2 子节点 2 ZEND_AST_ASSIGNZEND_AST_BINARY_OPZEND_AST_WHILE
3 子节点 3 ZEND_AST_CONDITIONAL(三元运算符)、ZEND_AST_FOR
4 子节点 4 ZEND_AST_IF_ELEMZEND_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_ADDZEND_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,主要包含:

  • opcodeszend_op 指令的扁平数组
  • literals:常量表(源码中字面量值对应的 zval)
  • vars:编译变量名($this$param1$localVar
  • arg_info:参数类型与默认值信息
  • try_catch_array:异常处理范围
  • static_variablesstatic $var 初始化器
  • filenameline_startline_end:源码位置信息
  • num_argsrequired_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_arrayzend_internal_function 的开头以相同的布局排列。这意味着只需访问函数名、标志或参数信息的代码,可以直接通过 func->common.* 获取,无需关心它是用户函数还是内置函数。

类、方法与属性的标志位

编译系统使用大量位域来表示类和成员修饰符,定义在 Zend/zend_compile.hZEND_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_INTERFACEZEND_ACC_PUBLIC 使用同一个位(1 << 0),ZEND_ACC_TRAITZEND_ACC_PROTECTED1 << 1)共用位。这是可行的,因为类声明不可能同时既是接口又是方法——标志的解释取决于它应用于类条目还是函数/属性。编译器会在类/方法声明的编译阶段验证标志组合是否合法(例如,abstract final 是非法的),并据此设置相应标志。

提示: 在 GDB 中调试标志值时,将 fn_flagsce->ce_flags 转换为十六进制,再与 ZEND_ACC_* 表进行对照。例如 0x00000051 表示 PUBLIC | STATIC | ABSTRACT(0x01 + 0x10 + 0x40)。位位置可能因版本而异,务必查阅对应的头文件。

下一步

我们已经完整追踪了从源文本到编译操作码的全过程。第 4 篇将深入虚拟机内部——那个真正执行这些操作码的组件。我们将探索独特的基于模板的代码生成系统(可生成 123,000 行类型特化处理器)、五种分发模式(包括全新的 TAILCALL 模式)、用于性能优化的全局寄存器锁定,以及在操作码执行前对其进行变换的基于 SSA 的优化器。本篇介绍的 zend_op 格式与 zend_op_array 结构,正是 VM 的直接输入。