各位编程界的“肝帝”们、后端界的“搬砖工”们,大家晚上好!
我是你们的老朋友,一个喜欢钻进 PHP 源码堆里找乐子的资深内核极客。
今天咱们不聊框架,不聊 ORM,咱们聊聊 PHP 的“底裤”——也就是它的物理生命周期。很多同学会说:“PHP 嘛,写完就能跑,解释型语言,快不了!”
哎,这就大错特错了。虽然 PHP 在语法层面上是“解释型”的,但在物理层面上,它简直就是一台精密的工业流水线工厂。从你敲下回车的那一刻起,PHP 就在以光速进行着物理变化。
今天,我们就把这层皮扒下来,看看一个 PHP 请求从生到死的全过程:SAPI 接收 -> 词法分析 -> 语法分析 -> Opcode 生成 -> 虚拟机执行。
咱们准备好了吗?那我们就把 IDE 的断点设好,翻开 Zend/ 目录,开始这场源码解剖之旅!
第一阶段:大门口的保安 —— SAPI (Server Application Programming Interface)
想象一下,你是一个外卖小哥,你的目的地是 PHP 这个大楼。但是大楼的安保系统不认识你,它只认识“Apache”或者“Nginx”。这时候,SAPI 就是那个门口的保安。
SAPI 是 PHP 和 Web 服务器(或命令行)之间的接口。它是 PHP 生命周期的入口。
在源码里,当你运行 php index.php 或者访问一个 URL 时,核心流程通常始于 main/sapi/cli/sapi_cli_main.c。
/* 伪代码示意,实则为 sapi/cli/php.c 的逻辑 */
int main(int argc, char **argv) {
// 1. 初始化 SAPI 结构体,告诉 PHP:“我要用 CLI 模式干活”
sapi_module = &cli_sapi_module;
// 2. 启动 SAPI。这不仅仅是“开门”,还要注册信号处理、初始化输出缓冲。
sapi_module->startup(sapi_module);
// 3. 解析命令行参数,比如 --ini, --version
zend_parse_parameters(argc, argv);
// 4. 核心:执行请求
if (SAPI_G(request_info).request_method) {
php_execute_script(ZEND_FILE_SRC_EX);
}
}
物理生命周期点拨:
此时,PHP 进程还在休眠,或者刚被服务器唤醒。SAPI 的 startup 阶段相当于给工厂通电、预热机器。它加载了 php.ini,把所有扩展的初始化函数都挂到了链表上。注意,这时候代码还没被读进去,只是一堆数据结构在内存里待命。
第二阶段:工厂预热 —— 请求启动
当 SAPI 决定要干活了,它会调用 php_request_startup()。这个函数非常长,大概有几百行代码,干的都是重活。
/* main/main.c */
int php_request_startup(void) {
// 1. 重置全局变量 G(),比如当前的错误级别、输出缓冲区
CG(compiler_variables) = (HashTable*) ecalloc(1, sizeof(HashTable));
CG(active_class_entry) = NULL;
// 2. 遍历所有扩展的 request startup 函数
// 比如你的 MySQLi 扩展会在这里初始化连接池
for (i = 0; i < module_count; i++) {
if (zend_modules[i]->request_startup) {
zend_modules[i]->request_startup(TSRMLS_C);
}
}
// 3. 初始化执行环境
zend_execute_data = NULL; // 清空执行栈
EG(opline_ptr) = NULL; // 清空操作码指针
}
物理生命周期点拨:
这时候,你的代码还是文本。所有的全局变量(比如 $_GET、$_POST)都被清空了,等待 SAPI 把新的数据倒进来。CG 和 EG(编译期和执行期的全局上下文)就像两个巨大的托盘,准备接货。
第三阶段:拆快递 —— 词法分析器
好了,SAPI 把文件内容扔进来了(通过 zend_read_script())。现在到了编译阶段的入口。编译的第一步不是理解逻辑,而是拆解字符。
这就是 Lex/Flex 的功劳。PHP 源码里的词法分析器文件是 Zend/zend_language_scanner.l。
当 PHP 读到你的代码:
$a = 1;
Lexer 并不关心这是什么意思,它只关心“字符序列”。它把这段代码切成一块一块的“标记”。
源码推演:
在 zend_language_scanner.l 中,有这么一段规则:
"<" { return T_IS_SMALLER_OR_EQUAL; }
"-" { return T_MINUS; }
"[" { return T_ARRAY; }
当解析器读到 < 时,它返回 T_IS_SMALLER_OR_EQUAL 标记。
代码演示(伪代码):
/* Zend/zend_language_scanner.l 生成的 C 代码逻辑 */
YYSTYPE zendlval;
int zendlex(YYSTYPE *yylval) {
// 1. 读取字符
c = getc(yyin);
// 2. 切分
if (isalpha(c)) {
// 读取标识符,比如 $a
while(isalnum(c)) { ... }
zendlval->token = T_VARIABLE;
return T_VARIABLE;
} else if (c == '$') {
// 读到 $,立马识别为变量
while(isalnum(c)) { ... }
zendlval->token = T_VARIABLE;
return T_VARIABLE;
}
return c; // 返回普通字符
}
物理生命周期点拨:
词法分析器就像一个不耐烦的快递员,他只负责把“包裹”(字符)拆成“箱子”(Token)。它根本不知道 a 是变量名,它只知道 $ 后面跟着的是一个东西,它管这个叫 T_VARIABLE。
第四阶段:拼积木 —— 语法分析器
现在我们拿到了一串 Token 流:T_VARIABLE, T_ASSIGN, T_LNUMBER, SEMICOLON。
语法分析器上场了。它的任务是把这些 Token 拼成一个抽象语法树(AST)。
在 PHP 7 之前,语法分析器是手写的,但 PHP 7 之后,PHP 官方抛弃了 yacc/bison,改成了递归下降解析器(更高效,因为它不需要像 yacc 那样生成笨重的解析表)。源码位置是 Zend/zend_language_parser.y。
源码推演:
看 zend_language_parser.y 里的规则:
expr: expr '=' expr { $$ = zend_ast_create_binary_op(ZEND_AST_ASSIGN, $1, $3); }
这段话的意思是:“如果我看到一个表达式,后面跟着等号,后面跟着另一个表达式,那这就是一个赋值语句,节点类型是 ZEND_AST_ASSIGN。”
语法分析器维护着状态机。它读入 T_VARIABLE,然后期待下一个是 T_ASSIGN。如果下一行是 +,它就会报错。
代码演示(AST 结构):
AST 的核心结构是 zend_ast:
typedef struct _zend_ast {
zend_ast_kind kind; // 节点类型:赋值、函数调用、类定义等
uint32_t lineno; // 行号
uint32_t child_count; // 子节点数量
union {
zend_ast *children;
zend_ast_node *child[1]; // 变长数组
} u;
zend_ast_value value; // 具体的值,比如数字 1,或者字符串 "foo"
} zend_ast;
物理生命周期点拨:
语法分析器是工厂的设计师。它把快递员拆开的箱子(Token),根据工厂的图纸(语法规则),搭成了积木塔(AST)。它负责检查“这个盒子能不能放在那个架子上”。如果不合法,直接抛出 Parse Error。
第五阶段:写生产计划书 —— 编译器与 Opcode
AST 搭好了,现在要把这个树翻译成机器能看懂的指令集,也就是 Opcode(操作码)。
这是编译器的核心工作:compile_file() -> zend_compile()。
Opcode 其实就是一个 zend_op 结构体数组。每个 Opcode 指明了:“下一步该干什么,数据从哪拿,结果往哪放”。
源码推演:
假设我们有这段代码:
$a = 1;
$a = $a + 1;
编译器会把它们翻译成这样的 Opcode 流:
ZEND_ASSIGN:a指向的内存地址 =1ZEND_ADD:a指向的内存地址 =a指向的内存地址 +1
ZEND_OP 结构体详解:
typedef struct _zend_op {
zend_uchar opcode; // 指令码:比如 ZEND_ADD (加法), ZEND_ECHO (输出)
zend_uchar op1_type; // 操作数1类型:IS_UNUSED, IS_CONST, IS_VAR, IS_CV
zend_uchar op2_type;
zend_uchar result_type; // 结果类型
zval op1, op2, result; // 实际的数据(可能是个数字,可能是个字符串引用)
} zend_op;
代码演示(编译过程):
在 Zend/zend_compile.c 中,你会看到类似这样的递归遍历:
void zend_compile_expr(zend_ast *ast) {
switch (ast->kind) {
case ZEND_AST_ASSIGN:
// 这是一个赋值节点
// op1 是变量名,op2 是赋值值
compile ZEND_ASSIGN opcode(ast->op1, ast->op2);
break;
case ZEND_AST_ADD:
// 这是一个加法节点
// op1 和 op2 都是被加数
compile ZEND_ADD opcode(ast->op1, ast->op2);
break;
}
}
物理生命周期点拨:
编译器是车间主任。AST 是设计图纸,而 Opcode 就是生产计划书。它告诉虚拟机:“先去把原料(变量)拿来,别弄错了,算完之后放进这个桶里。”
第六阶段:机器轰鸣 —— 虚拟机执行
好了,现在 Opcode 流准备好了。剩下的就是执行。
执行引擎是 zend_execute.c。这里通常会有一个巨大的 switch(opcode) 语句。这是整个 PHP 生命周期最耗时、最密集的部分。
源码推演:
这是一个简化版的 zend_execute:
void zend_execute(zend_op_array *op_array) {
// 初始化执行上下文
zend_execute_data *execute_data = (zend_execute_data *) ecalloc(1,
sizeof(zend_execute_data) + (op_array->last_var * sizeof(zval)) +
(op_array->last_brk_cont * sizeof(zend_brk_cont_element)));
// 执行循环
while (1) {
// 取出当前指令
zend_op *opline = execute_data->opline;
// 灵魂所在:根据 opcode 跳转到对应的 handler
switch (opline->opcode) {
case ZEND_ASSIGN:
// 执行赋值逻辑
ZVAL_COPY_VALUE(execute_data->result, opline->op2);
ZVAL_COPY_VALUE(ZEND_CALL_VAR_NUM(execute_data, opline->result_type), opline->result);
opline++;
break;
case ZEND_ADD:
// 执行加法逻辑
zval_add_function(ZEND_CALL_VAR_NUM(execute_data, opline->result),
ZEND_CALL_VAR_NUM(execute_data, opline->op1_type),
ZEND_CALL_VAR_NUM(execute_data, opline->op2_type));
opline++;
break;
case ZEND_RETURN:
// 执行返回逻辑,跳出循环
goto exit_loop;
}
}
exit_loop:
// 执行结束,清理上下文
free(execute_data);
}
物理生命周期点拨:
虚拟机是工厂的机械臂。它严格按照 Opcode 的指令,移动内存指针,读写数据。这里没有任何面向对象的高级特性,全是 C 语言级别的内存操作。
第七阶段:数据的容器 —— ZVAL 的生死劫
在整个执行过程中,你会发现一个幽灵在到处乱窜:ZVAL(Zend Variable)。
在 PHP 里,万物皆可赋值。数字、字符串、数组、对象,甚至是资源。PHP 是弱类型的,这就意味着你不能给 ZVAL 分配固定大小的内存。所以,ZVAL 是一个变长结构体。
源码推演:
这是 Zend/zend.h 里的核心定义:
typedef struct _zval {
union {
uint8_t u1; // 1字节:类型和引用计数
uint32_t u2; // 1字节(或2字节):辅助标志位
} v1, v2;
union {
uint64_t lval; // 存储长整型数据
double dval; // 存储浮点数
zend_string *str; // 存储字符串指针
zend_array *arr; // 存储数组指针
zend_object *obj; // 存储对象指针
zend_resource *res; // 存储资源指针
zend_refcounted *ref; // 通用引用计数指针
} value;
} zval;
物理生命周期点拨:
ZVAL 就是 PHP 的“集装箱”。
- 类型标识:
u1.type告诉虚拟机,这个集装箱里装的是牛奶(字符串)还是钢材(数组)。 - 引用计数: PHP 的内存回收机制(GC)就是靠这个。当引用计数为 0 时,集装箱就会被销毁,货物(内存)被回收。
内存分配:
你可能会问,ZVAL 是哪来的?
答案是:emalloc 和 ecalloc。这是 PHP 内部封装的内存分配器(基于系统 malloc),它会自动管理内存池,避免频繁的系统调用。
第八阶段:大扫除 —— 请求关闭与退出
当你的代码执行完毕(遇到 return 或者文件末尾),虚拟机会进入请求关闭阶段。
/* main/main.c */
void php_request_shutdown(void) {
// 1. 执行析构函数
// 比如 $obj->close(),或者 unset 的大扫除
zend_try {
shutdown_destructors();
} zend_catch { }
// 2. 执行 shutdown 函数
// 比如在函数里注册的 register_shutdown_function
execute_shutdown_functions(TSRMLS_C);
// 3. 释放请求上下文
zend_try {
zend_destroy_modules();
zend_list_close();
} zend_catch { }
// 4. 退出 SAPI
sapi_module->shutdown(sapi_module);
}
物理生命周期点拨:
工厂要下班了。垃圾回收车进场了。所有的对象被销毁,内存被释放,变量被清空。SAPI 关闭与 Web 服务器的连接。此时,PHP 进程可能被留在池子里(FPM 模式)等待下一个请求,也可能被杀掉(CLI 模式)。
总结与“灵魂拷问”
好,咱们回顾一下这条物理链路:
- SAPI (门口保安) 接收信号,唤醒工厂。
- Request Startup (预热) 给机器上油,清空托盘。
- Lexer (拆快递) 把字符变成 Token。
- Parser (拼积木) 把 Token 变成 AST。
- Compiler (写计划) 把 AST 变成 Opcode 流。
- VM (机械臂) 读取 Opcode,操作 ZVAL 内存,进行计算。
- Request Shutdown (下班) 垃圾回收,关闭连接。
这就是一次完整的 PHP 请求生命周期。
幽默时刻:
很多初学者觉得 PHP 简单,是因为它封装得太好了,让你感觉是在跟“语言”对话,而不是跟“机器”对话。但实际上,PHP 每次执行一亿次 echo "hello",背后都要重复上面这一整套流程:保安开门 -> 预热 -> 拆字 -> 拼句 -> 写指令 -> 机器喊一声 -> 扫地。
所以,当你觉得 PHP 慢的时候,别光骂 PHP,那是你的代码太啰嗦,Lexer 和 VM 疯狂地拆快递、写计划、搬砖头呢!
如果你看懂了这些源码,你就会明白为什么 OPcache(操作码缓存)这么重要——因为它把“写计划书”这一步省了!下次请求直接拿上写好的计划书让机器干活,速度自然就上去了。
这就是 PHP 的物理世界,充满了 C 语言的硬核逻辑和内存管理的智慧。希望大家在未来的开发中,能偶尔透过代码看一眼这些“底裤”,写出更高效、更优雅的代码!
下课!