代码
php
$x = 10;
$y = 20;
$fn = function($a) use ($x, &$y) {
$result = $x + $a + $y;
return $result;
};
源码 → AST
解析器 (zend_language_parser.y:962-966) 匹配闭包语法并生成一个 ZEND_AST_CLOSURE 节点:
php
$fn = function($a) use ($x, &$y) { $result = $x + $a + $y; return $result; };
~~~~~~~~ ~~ ~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| | | |
decl | uses list body stmts
params
lexical_vars 规则 (第 987-999 行) 解析 use(KaTeX parse error: Expected 'EOF', got '&' at position 4: x, &̲y):
- $x → ZEND_AST_VAR('x'),attr = 0
- &$y → ZEND_AST_VAR('y'),attr = 1(由 '&' T_VARIABLE 规则设定)
词法变量列表成为 ZEND_AST_CLOSURE_USES 链表(zend_ast_create_list 的第 3 个参数)。
最终 AST 形状:
c
ZEND_AST_STMT_LIST
├── ZEND_AST_ASSIGN($x, 10)
├── ZEND_AST_ASSIGN($y, 20)
└── ZEND_AST_ASSIGN($fn,
ZEND_AST_CLOSURE
├── child[0]: params = [ZEND_AST_PARAM($a)]
├── child[1]: uses = ZEND_AST_CLOSURE_USES[VAR('x',attr=0), VAR('y',attr=1)]
├── child[2]: stmts = STMT_LIST[
│ ZEND_AST_ASSIGN(
│ $result,
│ ZEND_AST_BINARY_OP(ZEND_ADD,
│ ZEND_AST_BINARY_OP(ZEND_ADD, $x, $a), ← 左→右 → $x + $a 先算
│ $y
│ )
│ ),
│ ZEND_AST_RETURN($result)
│ ]
└── child[3]: return_type = NULL
)
AST → 编译期 op_array
zend_compile_func_decl() --- 创建独立的 op_array
zend_compile.c:4915。关键流程:
c
4915: zend_compile_func_decl(result, ast)
4924: orig_op_array = CG(active_op_array); // 保存父作用域
4925: op_array = zend_arena_alloc(...); // 新的独立内存块
4928: init_op_array(op_array, ...); // 归零所有字段(static_variables=NULL, last_var=0...)
4937: if (decl->kind == ZEND_AST_CLOSURE)
4938: op_array->fn_flags |= ZEND_ACC_CLOSURE; // ← 标记为闭包
4945: zend_begin_func_decl(result, op_array, decl); // ← 发射 ZEND_DECLARE_LAMBDA_FUNCTION 到父函数
4948: CG(active_op_array) = op_array; // 切换作用域到闭包
4965: zend_compile_params(params_ast, ...); // 编译参数 $a → ZEND_RECV
4967: zend_compile_closure_uses(uses_ast); // 编译 use($x, &$y)
4969: zend_compile_stmt(stmt_ast); // 编译闭包体
4980: zend_emit_final_return(NULL); // 隐式 return null
4982: pass_two(op_array); // 最终确定 jumps、字面量、CV 偏移量
4988: CG(active_op_array) = orig_op_array; // 恢复父作用域
zend_begin_func_decl() --- 父函数得到 ZEND_DECLARE_LAMBDA_FUNCTION
zend_compile.c:4867。即使 CG(active_op_array) 仍然指向父函数,也会在父函数中发射一条关键指令:
c
4893: if (op_array->fn_flags & ZEND_ACC_CLOSURE) {
4894: opline = zend_emit_op_tmp(result, ZEND_DECLARE_LAMBDA_FUNCTION, NULL, NULL);
// ^^^^^^ 写入 CG(active_op_array) --- 即父函数的 op_array!
然后生成一个运行时 key 并将闭包注册到 CG(function_table):
4903: zend_string *key = zend_build_runtime_definition_key(lcname, decl->lex_pos);
// key = "\0{closure}\0filename:3" (第 3 行)
4905: opline->op1_type = IS_CONST;
4906: LITERAL_STR(opline->op1, key); // 父函数字面量表 #N 存储这个 key
4908: zend_hash_update_ptr(CG(function_table), key, op_array);
// 运行时 VM 通过此 key 从 EG(function_table) 查找 op_array
zend_compile_closure_uses() --- use 变量得到 FETCH_STATIC 占位符
zend_compile.c:4674。对于 use(KaTeX parse error: Expected 'EOF', got '&' at position 4: x, &̲y) 中的每个变量:
对于 $x (by_ref=false):
c
4689: ZVAL_NULL(&zv);
4690: Z_CONST_FLAGS(zv) = IS_LEXICAL_VAR; // 标志:值拷贝
4692: zend_compile_static_var_common(var_ast, &zv, 0);
对于 &$y (by_ref=true):
c
4689: ZVAL_NULL(&zv);
4690: Z_CONST_FLAGS(zv) = IS_LEXICAL_REF; // 标志:引用绑定
4692: zend_compile_static_var_common(var_ast, &zv, 1);
zend_compile_static_var_common (zend_compile.c:3490) 接着做以下事情:
-
为变量名创建一个 CV 槽位:zend_compile_expr(&var_node, var_ast) → lookup_cv("x") → 在闭包的 op_array->vars\[\] 中分配槽位 0。对于 "y" 也是同样,分配槽位 1。
-
存入 static_variables:zend_hash_update(static_variables, "x", zv) 其中 zv 带有 IS_LEXICAL_VAR 标志。这是一个占位符 --- 不是实际值!
-
发射 FETCH + ASSIGN 指令:
c
ZEND_FETCH_R T0 [ext=ZEND_FETCH_STATIC, "x"] // 运行时从 static_variables 取 "x"
ZEND_ASSIGN CV[$x], T0 // 存到 CV 槽位
ZEND_FETCH_R T1 [ext=ZEND_FETCH_STATIC, "y"] // 运行时从 static_variables 取 "y"
ZEND_ASSIGN CV[$y], T1
注意:$y 在这里不是 ZEND_FETCH_W 因为 by_ref 控制的是静态变量赋值是否按引用(第 3516-3521 行),而不是 use 变量的 FETCH 类型。对于use 变量,by_ref 传入的是 var_ast->attr。
而对应的功能在 zend_compile_closure_uses 中: zend_bool by_ref = var_ast->attr;
然后在 zend_compile_static_var_common 中:
opline = zend_emit_op(&result, by_ref ? ZEND_FETCH_W : ZEND_FETCH_R, &var_node, NULL);
opline->extended_value = ZEND_FETCH_STATIC;
所以对于 $x:是 ZEND_FETCH_R + FETCH_STATIC。对于 &$y:是 ZEND_FETCH_W + FETCH_STATIC。
编译闭包体 --- 表达式 result = x + a + y 的常量折叠
闭包体从左到右编译。表达式 x + a + $y 解析为:
c
ZEND_AST_BINARY_OP(ZEND_ADD,
ZEND_AST_BINARY_OP(ZEND_ADD, VAR($x), VAR($a)), ← $x + $a
VAR($y) ← + $y
)
zend_compile_binary_op (zend_compile.c:5971) 对 x + a:
c
5978: zend_compile_expr(&left_node, VAR($x)) → left_node = CV[$x], op_type=IS_CV
5979: zend_compile_expr(&right_node, VAR($a)) → right_node = CV[$a], op_type=IS_CV
5981: if (left_node.op_type == IS_CONST && right_node.op_type == IS_CONST) ← FALSE!
// $x 和 $a 都是 CV(已编译变量),不是编译时常量
// → 跳过常量折叠
6025: zend_emit_op_tmp(result, ZEND_ADD, &CV[$x], &CV[$a])
// 发射: ZEND_ADD T2, CV[$x], CV[$a]
然后对 T2 + $y(外层二进制操作):
c
5978: zend_compile_expr(&left_node, ...) → left_node = T2, op_type=IS_TMP_VAR
5979: zend_compile_expr(&right_node, ...) → right_node = CV[$y], op_type=IS_CV
5981: T2 是临时变量,不是 IS_CONST → 再次跳过折叠
6025: zend_emit_op_tmp(result, ZEND_ADD, &T2, &CV[$y])
// 发射: ZEND_ADD T3, T2, CV[$y]
就是为什么编译期常量折叠不适用于此例: x 、 x、 x、a、$y 都是运行时变量(CV/临时变量),不是字面量。
只有当两边都是 IS_CONST(字面量,如 2 和 3)时才会触发折叠。
例如,如果是 function() { return 2 + 3; },编译器会计算 5 并完全消除 ZEND_ADD 指令。
到 zend_compile_func_decl 的第 4982 行结束时,闭包的原始字节码如下:
; === 闭包 {closure}:3 字节码(在 pass_two 之前,简化显示)===
#0 ZEND_FETCH_R T0 ext=FETCH_STATIC ; 取 use 变量 "x"
#1 ZEND_ASSIGN CV[$x], T0 ; → CV 槽位 0
#2 ZEND_FETCH_W T1 ext=FETCH_STATIC ; 取 use 变量 "y"(引用)
#3 ZEND_ASSIGN CV[$y], T1 ; → CV 槽位 1
#4 ZEND_RECV CV[$a] ; 接收参数
#5 ZEND_ADD T2, CV[$x], CV[$a] ; $x + $a
#6 ZEND_ADD T3, T2, CV[$y] ; ($x + $a) + $y
#7 ZEND_ASSIGN CV[$result], T3 ; $result = ...
#8 ZEND_RETURN CV[$result] ; return $result
#9 ZEND_RETURN null ; 隐式 final return
; === 父函数字节码 ===
#0 ZEND_ASSIGN CV[$x], 10
#1 ZEND_ASSIGN CV[$y], 20
#2 ZEND_DECLARE_LAMBDA_FUNCTION CONST["\0{closure}\0file:3"] → T0
#3 ZEND_ASSIGN CV[$fn], T0
#4 ZEND_RETURN null
pass_two() --- 最终确定跳转和字面量
zend_opcode.c:577-707。到这里:
- IS_CONST 操作数被转换为字面量表索引
- CV/TMP_VAR 索引被规范化为绝对调用帧偏移量
- 跳转目标被解析为相对偏移量
- 为每条指令设置 VM 处理器:ZEND_VM_SET_OPCODE_HANDLER(opline)
- 设置 ZEND_ACC_DONE_PASS_TWO 标志
Opcache 优化器 --- 两级常量折叠和 CFG 死代码消除
这条流水线也适用于闭包。在 zend_accel_script_optimize (zend_optimizer.c:669) 中:
c
681: zend_accel_optimize(&script->main_op_array, &ctx); // 优化主脚本
683: for each entry in script->function_table: // ← 闭包在这里!
687: zend_accel_optimize(op_array, &ctx); // 独立优化每个闭包
zend_accel_optimize (第 557 行) 首先撤销 pass_two(将跳转和常量转换回绝对索引),运行优化 pass,然后重做 pass_two。
Pass 1:操作码级常量折叠 (pass1_5.c:40-103)
扫描闭包的操作码。对于每条 ZEND_ADD 指令,检查两边是否都是 IS_CONST:
#5 ZEND_ADD T2, CV[$x], CV[$a]
操作数 1 = CV → 不是 IS_CONST
→ 不折叠。继续。
#6 ZEND_ADD T3, T2, CV[$y]
操作数 1 = TMP_VAR → 不是 IS_CONST
→ 不折叠。
同样,没有折叠发生。 x 、 x、 x、a、$y 是运行时变量,优化器无法在编译时求值。
但 pass1 确实 折叠了一个相关的模式:FETCH_R(IS_CONST) + 常量传播。zend_optimizer_replace_by_const(第 362-483
行)通过所有后续使用向前传播折叠后的常量,用字面量替换临时变量引用,并 NOP 掉原来的指令。
Pass 3:复合赋值优化 (pass3.c:56-159)
寻找 ZEND_ADD var, expr 后跟 ZEND_ASSIGN var, result 的模式,并重写为 ZEND_ASSIGN_ADD:
#5 ZEND_ADD T2, CV[$x], CV[$a] 操作数1 = CV[$x]
#6 ZEND_ADD T3, T2, CV[$y] 操作数1 = T2
#7 ZEND_ASSIGN CV[$result], T3 操作数1 = CV[$result]
检查 #5+#6 或 #6+#7:对于 #6→#7,ZEND_OP1(#6) = T2 与 ZEND_OP1(#7) = CV$result --- 不匹配。没有复合赋值机会。
但如果代码是 result = result + x,pass3 会将其优化为 result += $x,节省一条指令。
Pass 5:CFG 优化 (block_pass.c:1975-2044) --- 死代码消除
构建控制流图(CFG)并对每个基本块运行三级优化:
c
1975: optimize_cfg(op_array, ctx)
1989: if (fn_flags & ZEND_ACC_HAS_FINALLY_BLOCK) return; // 跳过有 finally 的函数
1995: find_code_blocks(op_array, &cfg, ctx); // 构建 CFG
2013: for (pass = 0; pass < 3; pass++):
2016: zend_t_usage(blocks, ...); // 计算数据依赖
2023: zend_optimize_block(block, ...); // 优化每个块(折叠 ADD、JMP 等)
2031: zend_jmp_optimization(block, ...); // 优化跨块跳转
2034: zend_rebuild_access_path(&cfg, ...); // 标记可达块
// ^^^^^^^ 不可达块被标记为 access=0 ^^^^^^^
2040: assemble_code_blocks(&cfg, op_array); // 删除不可达块,重写 op_array
对于我们的闭包:字节码是直线型的 --- 没有 JMPZ、JMP、JMPNZ 或其他分支指令。
CFG 只包含从第一条到最后一条指令的单个基本块。所有代码都是可达的,没有不可达块被消除。
但如果有这样的代码:
php
function($a) use ($x) {
if (false) { $x = 999; } // ← JMPZ 以常量 false,跳过此块
return $a + $x;
}
那么 pass2 (pass2.c:114-145) 会将常量条件跳转 (JMPZ with IS_CONST op1 = false) 转换为无条件 JMP,跳过 if-body 块。
然后 assemble_code_blocks 在重写 op_array 时物理移除从未被访问的 if-body 块。
为什么在 PHP 7.0 中 SSA/类型推断不存在
ext\opcache\Optimizer\ 文件列表是决定性的:
block_pass.c --- 每个基本块的折叠、跳转优化、不可达代码消除
compact_literals.c --- 合并重复的字面量
nop_removal.c --- 剥离 NOP
optimize_func_calls.c --- INIT_FCALL_BY_NAME → DO_FCALL
optimize_temp_vars_5.c --- 合并临时变量
pass1_5.c --- 操作码级常量折叠
pass2.c --- 常量数字转换、常量 JMP 折叠
pass3.c --- $i = $i + x → $i += x, JMP 链
zend_optimizer.c --- 编排器
没有 zend_ssa.c,没有 zend_type_inference.c。 SSA 构造和基于类型的推导(" x 是一个整数 " 、 " x 是一个整数"、" x是一个整数"、"a 永远不会是 null")是 PHP 7.1
中添加的全新通道(ext/opcache/Optimizer/zend_ssa.c、ext/opcache/Optimizer/zend_inference.c)。
在 7.1 之前,优化器只作用于操作码结构(哪些是常量、哪些临时变量被复用、哪些块从控制流图可达),而从不作用于操作数的语义类型。
优化后的最终字节码
由于应用到此特定闭包的优化为零(没有常量可折叠,没有复合赋值模式匹配,无法到达死代码),最终字节码仅通过 pass_two 最终确定并进行了 compact-literals 处理:
; === {closure}:3 (pass_two 最终确定) ===
#0 ZEND_FETCH_R CV[$x] ext=FETCH_STATIC
#1 ZEND_FETCH_W CV[$y] ext=FETCH_STATIC
#2 ZEND_RECV CV[$a]
#3 ZEND_ADD T0, CV[$x], CV[$a]
#4 ZEND_ADD T1, T0, CV[$y]
#5 ZEND_ASSIGN CV[$result], T1
#6 ZEND_RETURN CV[$result]
#7 ZEND_RETURN null
总结
这条流水线中有两个架构之美:
- 复用 static_variables 机制。 static i = 0 和 u s e ( i = 0 和 use( i=0和use(x) 使用完全相同的哈希表 (op_array->static_variables)、完全相同 ZEND_FETCH_R + FETCH_STATIC 指令,以及完全相同的运行时路径。
只有 IS_LEXICAL_VAR | IS_LEXICAL_REF 标志位将它们区分开来。没有额外的操作码,没有额外的 VM 分支。
- 编译时分离,运行时缝合。 闭包的 op_array 在编译时是一个完全独立的实体(分配时的 zend_arena_alloc,在 zend_begin_func_decl
处注册)。父函数只持有 ZEND_DECLARE_LAMBDA_FUNCTION 作为指向它的指针。
只有在运行时,zend_create_closure 执行时,两部分才通过 memcpy + zval_copy_static_var → 捕获实际 use 值 + $this 绑定重新连接起来。
这种分离使得:
- 优化器能将闭包视为独立单元(script->function_table 循环,第 687 行)
- 同一闭包定义的不同实例之间共享 run_time_cache(第 575-578 行)
- "延迟绑定" --- use 变量的值在闭包定义时解析,而不是在闭包声明时