zend_execute 是 opcode 执行总入口,本质是调用预编译的 c handler 函数;执行由 execute_data->opline 驱动循环调用 handler,handler 在编译期生成并静态链接;opcache 仅跳过编译不跳过执行;php 运行时状态依赖使 opcode 无法直接编译为机器码。

zend_execute 是 Opcode 执行的总入口,但它本身不干活——它只是个函数指针,最终调用的是 execute 函数(位于 Zend/zend_vm_execute.h),而每条 opcode 的实际行为,由其 handler 字段指向的 C 函数决定。
这个流程不是“编译成机器码再执行”,而是“逐条调用预编译好的 C 处理函数”。CPU 执行的始终是 Zend 引擎自身那套 C 代码,不是 PHP 脚本生成的新机器码。
Opcode 怎么被一条条取出来执行?
执行靠一个叫 execute_data 的结构体驱动,其中 opline 字段是指向当前 zend_op 指令的指针。整个执行就是一个循环:
-
opline从op_array->opcodes[0]开始 - 读取
opline->handler,调用它(比如ZEND_ADD_HANDLER) - handler 执行完后,通常更新
opline指针(多数情况 +1,跳转类指令则改写偏移量) - 循环继续,直到
opline超出op_array->last
这个循环在 execute 函数里用 while (1) 或 GCC 的 computed goto 实现,性能关键路径上几乎无额外开销。
每个 opcode 的 handler 是怎么来的?
handler 不是运行时动态生成的,而是在 PHP 编译期(configure/make 阶段)通过脚本 Zend/zend_vm_def.h 和 Zend/zend_vm_gen.php 自动生成的函数指针表。例如:
ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMPVARCV, CONST|TMPVARCV)
{
USE_OPLINE
// ... 实际加法逻辑,读 op1/op2,写 result
ZEND_VM_NEXT_OPCODE();
}
这类宏展开后生成形如 ZEND_ADD_HANDLER 的函数,全部静态编译进 PHP 二进制。所以执行时只是普通函数调用——没有 JIT,没有解释器字节码解码开销。
- 所有标准 opcode 的 handler 都在
Zend/zend_vm_execute.h里定义 - handler 之间不共享栈帧,但共用同一个
execute_data和符号表 - 像
ZEND_JMP、ZEND_JMPZNZ这类控制流指令,会直接修改opline地址,而非顺序递进
OPcache 为什么能跳过编译却不能跳过执行?
OPcache 缓存的是完整的 zend_op_array 结构(含 opcode 数组、变量表、常量表等),加载后直接塞给 zend_execute。它省掉了词法分析、语法分析、AST 构建、opcode 生成这四步,但执行阶段完全不变。
- 缓存的 opcode 仍需经由同一套
handler逐条执行 - OPcache 不改变执行模型,只避免重复编译;真正提速来自避免 malloc/parse/AST walk 等 CPU 和内存密集操作
- 若启用了
opcache.optimization_level,优化器会在缓存前重写 opcode(如常量折叠),但执行时仍是调用 handler
为什么不能直接把 opcode 编译成机器码?
因为 PHP 的执行模型依赖大量运行时状态:符号表、引用计数、zval 类型动态判断、GC 标记、异常跳转上下文……这些无法在编译期固化为纯机器码。
- 比如
ZEND_ASSIGN要检查左侧是否为引用、右侧是否需要 copy-on-write,这些决策全在 handler 里用 C 条件分支完成 - 所有 opcode handler 都接收
zend_execute_data *参数,从中读取当前作用域、this、return_value 等上下文 - 所谓“虚拟机”,本质是用 C 函数模拟指令语义,而非抽象出独立指令集架构
真正容易被忽略的是:handler 函数内部对 zval 的读写、类型转换、内存管理操作,才是性能热点所在,而不是 opcode 调度本身。











