PHP 源码推演:描述一次完整的 PHP 请求从 SAPI 接收、Lexer 解析到 Opcode 执行的物理生命周期

各位编程界的“肝帝”们、后端界的“搬砖工”们,大家晚上好!

我是你们的老朋友,一个喜欢钻进 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 把新的数据倒进来。CGEG(编译期和执行期的全局上下文)就像两个巨大的托盘,准备接货。


第三阶段:拆快递 —— 词法分析器

好了,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 流:

  1. ZEND_ASSIGN: a 指向的内存地址 = 1
  2. ZEND_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 是哪来的?
答案是:emallocecalloc。这是 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 模式)。


总结与“灵魂拷问”

好,咱们回顾一下这条物理链路:

  1. SAPI (门口保安) 接收信号,唤醒工厂。
  2. Request Startup (预热) 给机器上油,清空托盘。
  3. Lexer (拆快递) 把字符变成 Token。
  4. Parser (拼积木) 把 Token 变成 AST。
  5. Compiler (写计划) 把 AST 变成 Opcode 流。
  6. VM (机械臂) 读取 Opcode,操作 ZVAL 内存,进行计算。
  7. Request Shutdown (下班) 垃圾回收,关闭连接。

这就是一次完整的 PHP 请求生命周期。

幽默时刻:
很多初学者觉得 PHP 简单,是因为它封装得太好了,让你感觉是在跟“语言”对话,而不是跟“机器”对话。但实际上,PHP 每次执行一亿次 echo "hello",背后都要重复上面这一整套流程:保安开门 -> 预热 -> 拆字 -> 拼句 -> 写指令 -> 机器喊一声 -> 扫地。

所以,当你觉得 PHP 慢的时候,别光骂 PHP,那是你的代码太啰嗦,Lexer 和 VM 疯狂地拆快递、写计划、搬砖头呢!

如果你看懂了这些源码,你就会明白为什么 OPcache(操作码缓存)这么重要——因为它把“写计划书”这一步省了!下次请求直接拿上写好的计划书让机器干活,速度自然就上去了。

这就是 PHP 的物理世界,充满了 C 语言的硬核逻辑和内存管理的智慧。希望大家在未来的开发中,能偶尔透过代码看一眼这些“底裤”,写出更高效、更优雅的代码!

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注