Read OSS

Zend 虚拟机:执行、代码生成与优化

高级

前置知识

  • 第 1–3 篇:完整理解架构、数据结构与编译流程
  • 了解 CPU 分发机制(函数指针、computed goto)
  • 熟悉 SSA 形式与编译器优化 pass

Zend 虚拟机:执行、代码生成与优化

前三篇文章已经完整梳理了 PHP 源码如何被编译成由 zend_op 指令组成的 op_array。现在我们来到了真正运行这些指令的核心组件:Zend 虚拟机。PHP 几乎将全部执行时间花在这里,因此它的设计极度追求分发吞吐量。

Zend VM 区别于其他语言运行时的关键,在于它的基于模板的代码生成机制。它不是手写各种类型特化的处理函数,而是通过一个 PHP 脚本读取处理函数模板并展开为数千个变体——最终生成一个长达 12.3 万行的文件,从热路径中彻底消除运行时类型分发。本文将带你深入了解这套机制、五种分发模式、调用帧布局,以及在执行前对 opcode 进行变换的基于 SSA 的优化器。

VM 代码生成系统

VM 采用三文件架构,在各类解释器中独树一帜:

  1. Zend/zend_vm_def.h — 约 204 个带类型占位符的处理函数模板
  2. Zend/zend_vm_gen.php — 读取模板并生成特化变体的 PHP 脚本
  3. Zend/zend_vm_execute.h — 约 12.3 万行的生成产物,由执行器直接引入
flowchart LR
    DEF["zend_vm_def.h<br/>204 handler templates<br/>with OP1_TYPE, OP2_TYPE placeholders"]
    GEN["zend_vm_gen.php<br/>Template expander<br/>Type specialization engine"]
    EXEC["zend_vm_execute.h<br/>~123,000 lines<br/>Thousands of specialized handlers"]
    OPCODES["zend_vm_opcodes.h<br/>Opcode → handler mapping<br/>Dispatch tables"]
    
    DEF --> GEN
    GEN --> EXEC
    GEN --> OPCODES

这套机制的核心思路是类型特化。以加法运算 $a + $b 为例:运行时 $a$b 各自可能是 IS_CONSTIS_CVIS_TMP_VARIS_VAR,组合起来最多有 4×4 = 16 种情况。zend_vm_def.h 中的 ZEND_ADD 模板使用 OP1_TYPEOP2_TYPE 等占位符,生成器会将其展开为独立的函数:ZEND_ADD_SPEC_CONST_CONSTZEND_ADD_SPEC_CV_CVZEND_ADD_SPEC_CV_CONST 等等。

每个特化后的处理函数在编译时就知道自己的操作数类型。以 CONST 变体为例,它可以直接索引字面量表,而 CV 变体则直接访问编译变量槽——每个操作数都因此减少了一到两个分支判断,大幅提升了热路径性能。

生成脚本同时会在 zend_vm_opcodes.h 中生成 opcode 到处理函数的映射表。编译阶段,编译器在发出 zend_op 时,会根据操作数类型查找对应的特化处理函数,并将其函数指针直接存储在 op 的 handler 字段中。

五种分发模式

生成的 VM 支持五种分发策略,在 C 编译时选定。模式由 Zend/zend_vm_opcodes.h 中的常量决定:

模式 机制 适用场景
CALL handler(execute_data) — 间接函数调用 兜底 / 可移植场景
SWITCH switch(opcode) { case ... } 调试构建
GOTO GCC computed goto(goto *handler 支持 labels-as-values 的 GCC/Clang
HYBRID computed goto 与函数调用混合 GCC/Clang 默认模式
TAILCALL Clang musttail + preserve_none 最新模式,仅限 Clang 19+
flowchart TD
    START["Execute next opcode"] --> MODE{"Dispatch mode?"}
    MODE -->|"CALL"| CALL["opline->handler(execute_data)<br/>Indirect function call<br/>CPU: predict call target"]
    MODE -->|"SWITCH"| SW["switch(opline->opcode)<br/>Jump table<br/>CPU: predict branch"]
    MODE -->|"GOTO"| GOTO["goto *opline->handler<br/>Computed goto<br/>CPU: no prediction needed"]
    MODE -->|"HYBRID"| HY["Hot path: computed goto<br/>Cold path: function call<br/>Best of both worlds"]
    MODE -->|"TAILCALL"| TC["musttail return handler()<br/>preserve_none convention<br/>Near-zero call overhead"]
    
    CALL --> NEXT["Advance opline, repeat"]
    SW --> NEXT
    GOTO --> NEXT
    HY --> NEXT
    TC --> NEXT

HYBRID 模式(默认)是其中最值得关注的。执行频繁的热处理函数使用 computed goto 分发,避免了函数调用/返回的开销;冷处理函数则作为普通函数从分发循环中调用。这样既能让热路径的指令缓存占用保持精简,又不会因冷处理函数体积较大而污染缓存。

TAILCALL 模式是最新加入的,需要 Clang 19 及以上版本。它通过 musttail 属性强制保证尾调用优化,并借助 preserve_none 调用约定将寄存器保存/恢复的开销降到最低。每个处理函数尾调用下一个处理函数,分发循环由此彻底消失。

提示: 可以通过 php -i | grep "Virtual Machine" 查看当前 PHP 构建使用的 VM 模式——不过这个信息并不总是对外暴露。更可靠的方式是在构建输出中查找编译标志(ZEND_VM_KIND)。

处理函数模板解析

让我们通过 ZEND_ADD 处理函数来具体了解这套模板机制。在 Zend/zend_vm_def.h 中,每个处理函数都遵循一套固定结构:

  1. 获取操作数GET_OP1_ZVAL_PTR / GET_OP2_ZVAL_PTR — 这些宏会根据特化类型展开为不同的代码。对于 IS_CV,直接取指向 CV 表的指针;对于 IS_CONST,则索引字面量数组。

  2. 快速路径:检查两个操作数是否都是 IS_LONG。如果是,直接执行整数加法;若结果溢出,则降级到浮点路径。这是最常见的情况,完全规避了函数调用开销。

  3. 中间路径:若其中一个或两个操作数为 IS_DOUBLE,则执行浮点加法。

  4. 慢速路径:调用通用函数,处理类型强制转换、对象运算符重载以及错误情况。

  5. 存储结果:将结果写入 result zval 槽,并将 opline 推进到下一条指令。

flowchart TD
    FETCH["Fetch op1, op2<br/>(type-specialized)"] --> FAST{"Both IS_LONG?"}
    FAST -->|"Yes"| IADD["Integer add<br/>Check overflow"]
    IADD --> OVF{"Overflow?"}
    OVF -->|"No"| STORE["Store IS_LONG result"]
    OVF -->|"Yes"| DFLOAT["Convert to IS_DOUBLE"]
    
    FAST -->|"No"| DBL{"Either IS_DOUBLE?"}
    DBL -->|"Yes"| DADD["Double add"]
    DADD --> DSTORE["Store IS_DOUBLE result"]
    
    DBL -->|"No"| SLOW["Slow path:<br/>type coercion,<br/>operator overloading"]
    
    STORE --> NEXT["ZEND_VM_NEXT_OPCODE()"]
    DSTORE --> NEXT
    DFLOAT --> DSTORE
    SLOW --> NEXT

ZEND_VM_NEXT_OPCODE() 负责将 opline 推进到下一条指令并分发给其处理函数。在 GOTO 模式下,这等同于 goto *(++opline)->handler;在 CALL 模式下,则是从当前处理函数 return(分发循环随后调用下一个处理函数);在 HYBRID 模式下,热处理函数使用标签跳转。

全局寄存器固定

在 x86_64 架构上使用 GCC 或 Clang 编译时,VM 会将两个关键值固定到 CPU 寄存器,如 Zend/zend_execute.c 所定义:

  • execute_data → 固定到 %r14(或等效寄存器)
  • opline → 固定到 %r15

这两个值在每条 opcode 分发时都要访问。将它们固定到寄存器,就消除了热循环中的内存加载——CPU 随时持有当前帧指针和指令指针,无需从内存读取。

EXECUTE_DATA_DOPLINE_D 宏在寄存器固定可用时展开为寄存器变量声明,否则降级为普通局部变量。这带来了可观的性能收益:基准测试显示,仅寄存器固定一项就能带来 5–15% 的性能提升。

这项技术依赖 GCC 和 Clang 的 register ... asm("r14") 扩展。在不支持全局寄存器变量的架构上,或编译器无法保证寄存器在函数调用间保持不变时,这些宏会自动回退到栈变量。

调用帧布局

PHP 在调用函数时并不使用 C 调用栈,而是在自定义 VM 栈上分配一个 zend_execute_data 帧。帧布局在 Zend/zend_compile.h 中定义:

flowchart TB
    subgraph frame["zend_execute_data frame on VM stack"]
        direction TB
        HEADER["zend_execute_data header<br/>opline, func, This, prev_execute_data<br/>return_value, run_time_cache"]
        CV["CV slots (Compiled Variables)<br/>[0]: $this (if method)<br/>[1]: $param1<br/>[2]: $param2<br/>[3]: $localVar<br/>..."]
        TMP["TMP_VAR / VAR slots<br/>(expression temporaries)"]
        EXTRA["Extra args<br/>(variadic overflow)"]
    end
    
    CALLER["Caller's frame<br/>(prev_execute_data)"] --> HEADER
    HEADER --> CV
    CV --> TMP
    TMP --> EXTRA

zend_execute_data 结构体包含以下字段:

  • opline:当前指令指针(快速路径中固定到寄存器)
  • func:指向当前执行的 zend_function 的指针
  • This:方法调用中的 $this 对象(普通函数则为特殊内部值)
  • prev_execute_data:指向调用方帧的链接
  • return_value:指向返回值存储位置的指针(即调用方的结果槽)

紧随头部之后的是 CV 槽——按声明顺序,每个编译变量占一个 zval。编译器为每个 $变量 分配一个数字索引,VM 通过 EX_VAR(offset) 访问它们,本质上就是相对于 execute_data 的简单指针偏移。

CV 槽之后是用于表达式临时值的 TMP_VAR 和 VAR 槽,由编译器在编译阶段分配,大小取决于同时存在的临时变量的最大数量。

函数参数的传递方式是:在切换帧之前,预先初始化被调用方的 CV 槽。调用约定如下:分配被调用方帧,将参数依次复制到其 CV[0]、CV[1]……,然后将 execute_data 切换到新帧。

可挂钩函数指针

php-src 中最重要的可扩展性模式之一,是使用可在运行时替换的全局函数指针。这些指针在 Zend/zend.c 的引擎启动阶段完成初始化:

sequenceDiagram
    participant Engine as Zend Engine
    participant OPcache as OPcache Extension
    participant Profiler as Xdebug/APM

    Note over Engine: zend_startup() sets defaults
    Engine->>Engine: zend_compile_file = compile_file
    Engine->>Engine: zend_execute_ex = execute_ex
    Engine->>Engine: zend_execute_internal = NULL

    Note over Engine,OPcache: During MINIT
    OPcache->>Engine: Save original zend_compile_file
    OPcache->>Engine: zend_compile_file = persistent_compile_file
    
    Profiler->>Engine: Save original zend_execute_ex
    Profiler->>Engine: zend_execute_ex = profiler_execute_ex

    Note over Engine: Runtime compilation
    Engine->>OPcache: zend_compile_file("script.php")
    OPcache->>OPcache: Check shared memory cache
    alt Cache hit
        OPcache-->>Engine: Return cached op_array
    else Cache miss
        OPcache->>Engine: Call original compile_file()
        OPcache->>OPcache: Store in shared memory
        OPcache-->>Engine: Return op_array
    end

三个核心可挂钩指针分别是:

  • zend_compile_file:用于编译 PHP 文件。OPcache 通过替换它来拦截编译过程,直接返回缓存的 op_array。
  • zend_execute_ex:用于执行用户函数的 opcode。调试器(如 Xdebug)和性能分析工具通过替换它来实现函数进入/退出的埋点。
  • zend_execute_internal:用于执行内部(C)函数。APM 工具可以挂钩此指针来监控内置函数的调用。

扩展会保存原始指针,并在自己的替换函数中按需调用它,从而形成类似 middleware 的调用链:OPcache 的编译钩子 → 检查缓存 → 未命中时调用原始编译器 → 存储结果。

提示: 如果你在编写需要拦截执行的 PHP 扩展,建议优先使用 Observer API(见下文),而非直接替换 zend_execute_ex。Observer API 专为多扩展安全共存而设计,而全局函数指针替换容易产生冲突。

Observer API

Observer API 在 Zend/zend_observer.h 中定义、在 Zend/zend_observer.c 中实现,提供了一种结构化的函数调用插桩方式,无需替换全局函数指针。

扩展通过注册 observer 处理函数来监听函数的进入和退出:

  • zend_observer_fcall_register:注册一个回调,在每次函数调用时被触发。该回调可以分别提供 begin 处理函数和 end 处理函数。
  • begin 处理函数在函数入口处接收 execute_data
  • end 处理函数在函数退出处接收 execute_data 和返回值。

多个 observer 可以同时共存——引擎内部维护一个已注册处理函数的数组并逐一调用。这些处理函数存储在每个函数的运行时缓存中,因此每个函数在每次请求中只需付出一次查找开销。

Observer API 还支持 Fiber 切换通知(zend_observer_fiber_switch_register)和错误通知,是 APM 工具、性能分析器和代码覆盖率工具的首选挂钩点。

基于 SSA 的优化器

启用 OPcache 后,编译好的 op_array 在执行前会经过多轮优化。优化器位于 Zend/Optimizer/ 目录,由 Zend/Optimizer/zend_optimizer.c 统一调度:

flowchart TD
    INPUT["zend_op_array<br/>(unoptimized)"] --> P1["Pass 1: Constant Folding<br/>Evaluate constant expressions"]
    P1 --> CFG["CFG Construction<br/>(zend_cfg.c)<br/>Build control flow graph"]
    CFG --> SSA["SSA Construction<br/>(zend_ssa.c)<br/>Insert phi nodes, rename vars"]
    SSA --> TI["Type Inference<br/>(zend_inference.c)<br/>Propagate types through SSA"]
    TI --> SCCP["SCCP Pass<br/>(sccp.c)<br/>Sparse Conditional Constant Propagation"]
    SCCP --> DCE["DCE Pass<br/>(dce.c)<br/>Dead Code Elimination"]
    DCE --> DFA["DFA Pass<br/>(dfa_pass.c)<br/>Data-flow optimizations"]
    DFA --> BLOCK["Block Pass<br/>(block_pass.c)<br/>Peephole, jump threading"]
    BLOCK --> OUTPUT["Optimized zend_op_array"]

SSA 数据结构在 Zend/Optimizer/zend_ssa.h 中定义。每个 SSA 变量都有定义点、使用链和推断出的类型信息,phi 节点在控制流汇合处插入。

类型推断zend_inference.c)尤为重要,因为其结果会直接输入 JIT 编译器。一旦确定某变量在特定位置始终为 IS_LONG,JIT 就可以生成纯整数的机器码,省去类型检查。

Zend/Optimizer/sccp.c 中的 SCCP(稀疏条件常量传播)将常量传播与不可达代码检测结合在一起——如果分支条件是已知常量,假分支会被直接消除。

Zend/Optimizer/dce.c 中的 DCE(死代码消除)会删除结果从未被使用的指令。在 SCCP 完成常量传播和表达式化简之后,DCE 的效果往往出人意料地显著。

优化器的 pass 层级由 INI 配置项 opcache.optimization_level 控制,这是一个位掩码,每一位对应一个特定的 pass。默认配置下所有 pass 均启用。

下一步

至此,我们已经完整走完了从 VM 分发到优化的整个执行流程。第 5 篇——也是本系列的最后一篇——将探索让 PHP 真正落地的扩展生态:扩展 API 及其生命周期钩子、OPcache 的共享内存架构、将热点 opcode 编译为原生机器码的 JIT 编译器、用于协作式并发的 Fiber、流式 I/O 抽象,以及保障线程安全的 TSRM。正是这些系统,共同将 Zend 引擎打造成了驱动整个 Web 的 PHP 运行时。