大家好,欢迎来到“PHP 内部宇宙”的深度探索现场。
我知道,作为一名 PHP 开发者,你可能每天都在写代码:$a = 1; echo $a;。这行代码轻描淡写,快如闪电。但在你的屏幕背后,发生了一场惊心动魄的“世界大战”。
今天,我们不谈业务逻辑,不谈怎么防 SQL 注入,我们要像剥洋葱一样,把 PHP 的内核剥开,看看它是怎么把你的代码从“文本”变成“肉肠”(字节码),最后塞进“机器”里吃下去的。
准备好了吗?让我们从 SAPI 这个大门开始。
第一幕:守门人——SAPI
首先,你的 Web 服务器(Nginx 或 Apache)收到了一个请求。这时候,它不认识 PHP,它只认识“文本”。它把请求扔给了 PHP 的 SAPI(Server Application Programming Interface)。
SAPI 就像是 PHP 的前台接待员。在 PHP 的源码里,这个接待员的名字叫 php_main(或者在 PHP 8 中更复杂的流程)。但这只是个入口,真正的戏肉在 main/php.c 里。
1. 启动流程:从零到一
当你运行 php-fpm 或者通过 Apache 加载模块时,PHP 进程启动了。它要干的第一件事是什么?初始化环境!
/* main.c 伪代码风格 */
int php_module_startup(sapi_module_struct *sapi_module)
{
// 1. 初始化全局变量
zend_startup_parameters();
// 2. 启动 Zend 引擎(这是核心中的核心)
if (zend_startup() == FAILURE) {
return FAILURE;
}
// 3. 初始化 SAPI 具体的数据结构,比如获取 POST 数据
sapi_initialize_request_globals();
// 4. 注册常量,比如 __FILE__, __LINE__
register_internal_constants();
}
这时候,PHP 就像是一个刚被唤醒的巨人,它有了大脑(Zend Engine),有了感官(SAPI),但还不知道要干嘛。
2. 接收请求:咀嚼数据
当真正的 HTTP 请求到来,SAPI 开始干活了。假设是 CLI 模式,它会去读文件;如果是 CGI/FPM,它会去读 FastCGI 协议的数据包。
代码流程大概是这样的:
/* sapi/cli/cli_main.c */
static int php_cli_request_startup(void)
{
// 准备堆栈,这是 VM 运行的地方,就像演员的舞台
zend_vm_stack_init();
// 初始化执行上下文
EG(current_execute_data) = NULL;
// 分配一个 op_array(指令数组)的指针,准备接收代码
// 这里我们先不分配,等解析完再分配
return SUCCESS;
}
这时候,你的 PHP 代码还在硬盘上安安静静地躺着,或者还在请求缓冲区里排队。SAPI 拿到了这段文本,把它塞给了 Zend 引擎。
第二幕:解构——Lexer 与 Parser
文本拿到了,现在 Zend 引擎要把它拆解成有意义的块。这个过程分为两步:Lexical Analysis(词法分析)和 Syntax Analysis(语法分析)。
1. 词法分析器:把代码切成单词
想象一下你在吃牛排,你不能直接吞,得先切小块。Lexer 就是那把刀。
PHP 代码是字符串,Lexer 要把它们转换成 Token。在 Zend 里,这叫 zend_language_scanner.l 文件。这是一个 Flex 扫描器文件。
比如你的代码:
$a = 10;
Lexer 看到这个字符串,它的状态机开始疯狂运转:
- 看到
$,它是变量,产生T_VARIABLE。 - 看到
a,它是标识符的一部分,吞进去,产生T_STRING。 - 看到
=,它是赋值,产生T_ASSIGN。 - 看到
10,它是数字,产生T_LNUMBER。 - 看到
;,它是结束,产生T_SEMICOLON。
代码层面大概是这样的(为了通俗易懂,手动模拟 Flex 逻辑):
/* 模拟 Zend 中的扫描逻辑 */
zval token;
while (!feof(yyin)) {
yylex(&token); // 调用 Flex 生成的 C 函数
switch (token.type) {
case T_VARIABLE:
// 识别出 $a
break;
case T_ASSIGN:
// 识别出 =
break;
}
}
2. 语法分析器:把单词变成句子
词法分析器吐出了一堆 Token,语法分析器(Parser)要接住这些 Token,并检查它们是否符合语法规则。
PHP 使用的是 递归下降解析(Recursive Descent Parser),而不是常见的 LR/LL 语法分析器。这意味着 Parser 看起来更像是一套 if/else 嵌套的逻辑。
当 Parser 看到 $a = 10; 这串 Token,它的内部逻辑是这样的:
/* zend_language_parser.y (Bison 文件,生成的 Parser) */
expression_list:
expression_list expr ';'
{
zend_do_end_batch($1 TSRMLS_CC);
}
;
expression:
expr ';'
{
/* 这里就是 AST 生成的节点 */
$1 = $1;
}
;
如果 Parser 说:“嘿,这个 Token 顺序不对,$a 10 =?这是火星语吗?”,它就会抛出一个解析错误。
如果 Parser 顺滑地通过了,它会构建一个 抽象语法树(AST)。这是一棵树,根节点是整个文件,叶子节点是具体的表达式。比如 $a = 10 + $b 会变成一个树状结构,左边是赋值操作,右边是加法操作,加法操作两边又是变量。
3. 编译器:从 AST 到 Opcodes
Parser 完成后,AST 就像是一个蓝图。现在,编译器登场了。它的任务是把这张蓝图变成真正的执行指令——Opcodes。
在 Zend 引擎里,这叫 zend_compile。它遍历 AST,为每个节点生成对应的指令。
代码:
<?php
$a = 1;
$b = 2;
$c = $a + $b;
编译后,PHP 不会直接存储这段 PHP 代码,而是存储一个 op_array(操作码数组)。这个数组里存的就是一排排指令:
| 执行顺序 | Opcode (指令名) | Operand 1 | Operand 2 | Comment |
|---|---|---|---|---|
| 1 | ZEND_ASSIGN |
$a |
1 |
把 1 赋值给变量 a |
| 2 | ZEND_ASSIGN |
$b |
2 |
把 2 赋值给变量 b |
| 3 | ZEND_ADD |
$a |
$b |
把 a 和 b 相加 |
| 4 | ZEND_ASSIGN |
$c |
result |
把结果赋值给 c |
注意,这里没有 echo,因为代码里没有输出语句。
4. 字节码缓存(OPcache):别重复造轮子
这可是性能优化的大头。如果每次请求都要经过词法分析、语法分析、AST 构建、Opcodes 生成,那 PHP 会慢得像蜗牛。
所以,当你开启了 OPcache,PHP 会把编译好的 Opcodes 保存到 .opcache 文件里。下次请求来的时候,SAPI 直接把这个文件加载到内存,跳过前面的“拆解和组装”过程,直接进入执行阶段。
第三幕:表演者——虚拟机(VM)的执行
现在,我们已经拿到了 Opcodes 数组,并且把它挂载到了执行上下文(EG(active_op_array))上。接下来,就是激动人心的时刻:执行。
这就是 Zend 虚拟机(ZVM)的工作。
1. 虚拟机的主循环:吞噬指令
zend_execute 函数是 VM 的心脏。它有一个巨大的 switch 语句(在 PHP 5 时代是 switch,在 PHP 7/8 时代优化成了跳转表或直接调用,但原理一样),负责调度每一个 Opcode。
流程是这样的:
/* zend_vm_def.h */
ZEND_VM_HANDLER(0, ZEND_ASSIGN, CV, CONST|CV|REF|UNUSED)
{
// 1. 获取操作数
zval *value_ptr = _get_zval_ptr_cv_op1(execute_data, opline->op1_type, EX(TSMLS_C)) TSRMLS_CC;
zval *target_ptr = _get_zval_ptr_cv_op2(execute_data, opline->op2_type, EX(TSMLS_C)) TSRMLS_CC;
// 2. 执行赋值操作
zval_ptr_dtor(target_ptr); // 先把旧的值清理掉
value_ptr = _get_zval_ptr_cv_op1(execute_data, opline->op1_type, EX(TSMLS_C)) TSRMLS_CC;
ZVAL_COPY_VALUE(target_ptr, value_ptr); // 把新值拷贝过去
// 3. 移动指针到下一条指令
ZEND_VM_NEXT_OPCODE();
}
看懂了吗?这就是物理路径。
- 指针移动:VM 有一个指针(
opline),指向当前要执行的指令。执行完一条,++。 - 栈操作:所有的变量、参数都存储在一个动态数组(堆栈)里。
- 引用计数:注意代码里的
zval_ptr_dtor,这是 PHP 内存管理的核心。赋值不仅仅是拷贝内存,还要处理引用计数。如果没人引用旧值,旧值就死掉了(De-ref)。
2. 执行流程的微观视角
让我们手把手模拟一下执行 $c = $a + $b; 的过程。
- Fetch:VM 从
EX(TSMLS_C)(执行上下文)的堆栈中,找到$a和$b的地址。 - Calculate:读取这两个地址的值(比如 $a=1, $b=2),执行加法。结果放入一个临时变量(Stack top)。
- Store:VM 找到
$c的地址,把结果存进去。 - Jump:
ZEND_VM_NEXT_OPCODE。指针跳到下一条指令(如果是最后一条,则跳转到RETURN指令)。
3. 函数调用:层层嵌套
如果代码里有函数调用,流程会更复杂。
当遇到 my_function($arg) 时:
- VM 执行
ZEND_DO_FCALL_BY_NAME。 - 它会检查
my_function是否存在。 - 它会创建一个新的执行上下文栈帧。
- 它会把参数压栈。
- 它会跳转到函数内部的 Opcode 开始处。
- 函数返回时,VM 会弹出栈帧,恢复到调用者的上下文,并继续执行下一条指令。
4. 特殊指令的魔法
并不是所有指令都像加减乘除那么简单。
- ZEND_ECHO:它会查找 SAPI,调用
sapi_module.write,把数据通过 HTTP 协议发回给浏览器。 - ZEND_INCLUDE_OR_EVAL:这个家伙最危险。如果是
include,它会重新走一遍“词法+语法+编译”流程,把新代码变成 Opcodes 执行。如果是eval,它直接把字符串当做代码执行。 - ZEND_NEW:实例化对象。这涉及到对象模型(HashTable),分配内存,调用构造函数的 Opcodes。
第四幕:数据的容器——zval 结构体
在整个物理路径中,唯一不变的主角就是 zval。它是 PHP 变量的物理容器。
在 PHP 7 之前,zval 很臃肿,用了联合体 u1 和 u2。在 PHP 7/8 中,它变成了两个结构体:zval 和 zend_value。
/* zend_types.h */
typedef union _zend_value {
zend_long lval; /* Long value */
double dval; /* Double value */
zend_refcounted *counted;
zend_string *str;
zend_array *arr;
zend_object *obj;
zend_resource *res;
zend_reference *ref;
zend_ast_ref *ast;
zval *zv;
} zend_value;
struct _zval {
zend_value value;
union {
struct {
uint32_t type_info;
} v;
uint32_t type_info;
} u1;
};
这个结构体里藏着两个秘密:
-
类型信息 (
type_info):
这是var_dump的秘密来源。它用位掩码来存储类型:IS_NULL,IS_LONG,IS_STRING… 甚至还有IS_INDIRECT这种为了性能优化的黑魔法。
为什么这样做?因为存一个int(类型)和一个指针(值),比存一个巨大的联合体更省内存。 -
引用计数 (
counted):
这是 PHP 内存回收的基础。
当你$a = $b时,并不是拷贝了$b的内容,而是让$a和$b指向同一个zval,并且引用计数加 1。
当$a死亡时,引用计数减 1。如果变成 0,这个zval就被销毁了。
生命周期推演:
- Creation:
ZEND_NEW指令。分配内存,初始化为 0,类型设为IS_NULL,引用计数设为 1。 - Assignment:
ZEND_ASSIGN指令。如果是= &x,修改指针指向;如果是= x,创建新zval,拷贝数据,引用计数 +1。 - Unset:
ZEND_UNSET_VAR指令。修改类型为IS_UNDEF,引用计数 -1。
第五幕:终结——清理与退出
所有的 Opcode 都执行完了,代码跑到了最后。此时,执行上下文里还残留着变量 $a, $b, $c。
1. 恢复与清理
- Symbol Table:全局变量表(
$GLOBALS)和超全局变量表需要被清理(引用计数 -1)。 - Locals:当前函数的局部变量表(其实也是 HashTable)需要被清理。
- Resources:打开的文件句柄、数据库连接、内存分配(
emalloc)必须被显式释放。
PHP 使用一种叫做 Cycle Collection(循环引用收集) 的机制。有时候,变量 A 指向变量 B,变量 B 又指向 A,形成了一个圈。垃圾回收器(GC)会遍历这个圈,只要没人引用它们,就回收内存。
2. 返回给 SAPI
一切收拾停当,PHP 告诉 SAPI:“老板,活干完了,给你 Result。”
如果是 HTTP 请求,SAPI 会把输出缓冲区里的内容打包,通过 TCP/IP 协议扔给 Nginx 或 Apache。Nginx 再扔给浏览器。浏览器渲染出 1。
如果是 CLI 模式,SAPI 直接把 echo 的内容打印在终端。
深度杂谈:从 Opcodes 看性能瓶颈
既然我们已经知道 Opcodes 是物理路径的终点,那么优化 PHP 就变成了“优化指令”。
1. 减少指令数
// 写法 A
if ($a) {
$b = $a;
} else {
$b = 0;
}
// 写法 B (更高效)
$b = $a ?: 0;
写法 A 生成了很多指令:ZEND_JMP, ZEND_ASSIGN, ZEND_JMP…
写法 B 生成:ZEND_QM_ASSIGN(三元运算符优化)。
PHP 7/8 会自动把 ?: 优化成更底层的指令。
2. 对象 vs 数组
// 数组
$arr = [];
$arr['key'] = 'value';
// 对象
$obj = new stdClass();
$obj->key = 'value';
在 PHP 7/8 中,数组在底层就是一个 HashTable,而对象是一个结构体指针。
对于简单的数据存储,数组 往往比 对象 更快,因为对象还要经过对象模型的检查(属性表查找等)。
3. 函数调用的开销
function_exists() 检查非常慢,因为它需要遍历全局函数表。把常量放在顶部定义,或者使用 const(编译时常量),会让它在编译阶段就变成直接引用,省去运行时的查找。
结语:这只是一场排练
从 SAPI 接收 HTTP 请求,到 Lexer 把代码切碎,Parser 把碎片拼成树,Compiler 把树变成 Opcodes,最后 VM 踩着这些指令跳完踢踏舞,最后输出给浏览器。
这就是一次 PHP 请求的完整物理路径。
听起来很复杂?确实复杂。但是,你作为开发者,只需要写 $a = 1 就能触发这一连串的魔法。这就是 PHP 的魅力:给你提供极其强大的底层工具,同时允许你像写诗一样写代码。
下次当你看到 Fatal error: Maximum execution time exceeded,或者 CPU 占用 100% 的时候,你可以想想,是不是那个 VM 大哥跑得太累了,或者是你的代码被 Lexer 切得太碎了。
好了,今天的源码之旅就到这里。我是你们的讲师,我们下期再见!记得给 OPcache 开个光!