PHP 源码推演:描述一次 PHP 请求从 SAPI 接收、Lexer 解析到虚拟机执行的完整路径

好,各位吃瓜群众,搬好小板凳,把你们的笔记本翻到第 388 页(假设有的话)。今天我们要聊的东西,有点硬核,但绝对带劲。

很多人一听到“PHP 源码”,脑子里蹦出来的就是“枯燥”、“C 语言”、“指针”、“内存泄漏”,然后就把书合上了。但我今天要告诉大家:PHP 其实就是披着脚本语言外衣的 C 语言,而且它本质上就是一个极其聪明的工厂流水线。

今天,我们就把 PHP 这台机器拆开,看看当你在浏览器输入 http://localhost/index.php 并敲下回车的那一刻,到底发生了什么。从门口的保安(SAPI),到切菜的师傅(Lexer),到制定食谱的大厨(Parser),再到最后真正干活的主厨(VM),我们来一探究竟。

准备好了吗?Let’s Rock!


第一站:门口的保安与前台接待 —— SAPI

想象一下,PHP 脚本并不是直接就能进入 CPU 运行的。它得有个脸面,有个接口。这个接口在 PHP 内核里就叫做 SAPI(Server Application Programming Interface,服务器应用程序编程接口)。

如果你是写命令行脚本的,你用的是 CLI SAPI。如果你在 Nginx/FPM 里跑,你用的是 FPM SAPI。如果你是写 Apache 模块的,那是 Apache Handler SAPI。别被这些名词吓到了,它们本质上都是一段代码,负责把外部请求“翻译”给 PHP 内核听。

我们要看这段代码,得去 main.c 或者更具体的 sapi/cli/php_cli.c。虽然具体的文件名会随版本变动,但核心逻辑是一致的。

事件流:

  1. 启动: 当你运行 php -f index.php 时,SAPI 的入口函数 main() 被调用。
  2. 解析参数: 它得看看你有没有带奇怪的参数,比如 -d 设置配置项,-r 直接运行代码。
  3. 握手: 它会读取配置(php.ini),初始化全局变量。
  4. 文件分发: 它拿到你的文件路径(比如 /var/www/html/index.php),然后把它扔给 PHP 的核心处理函数。

这里有个特别有意思的点。SAPI 不懂 PHP 代码,它只知道“打开文件”、“读内容”。它把文件内容读成一大坨字符串(char *),然后把这一坨字符串作为“原材料”,扔进工厂里。

源码视角的简单模拟:

/* 这是一个极度简化的伪代码,为了方便理解 */
void sapi_cli_startup(sapi_module_struct *sapi_module) {
    php_module_startup(sapi_module, NULL, 0);
}

int main(int argc, char **argv) {
    sapi_startup(&cli_sapi_module);
    sapi_cli_startup(&cli_sapi_module);

    // 这一步就是你要命的地方:解析参数,打开文件
    if (SG(request_info).path_translated) {
        zend_file_handle file_handle;

        // SAPI 做的事情:把文件系统里的东西拿进来
        php_open_auto_detect_globals = 0;
        if (php_fopen_primary_script(&file_handle) == FAILURE) {
            return FAILURE;
        }

        // 关键时刻:把这一坨字符流扔给 PHP 内核
        zend_execute_scripts(PHP_USER_PHASE, NULL, 1, &file_handle);
    }

    return SUCCESS;
}

你看,SAPI 就像那个前台接待员。它不负责炒菜,它只负责把顾客(请求)领进来,把菜单(PHP 文件)递给后厨。


第二站:切菜师傅 —— Lexer (词法分析)

拿到了那一坨代码字符串(比如 <?php echo "Hello World"; ?>),接下来交给谁?交给 Lexer(词法分析器)。在 PHP 源码里,它主要对应的文件是 zend_language_scanner.l

Lexer 的任务非常枯燥且机械:把一长串字符切成一个个小方块(Token)。

这时候有人要问了:“字符不就字符吗?怎么切?”

Lexer 使用了一种叫 有限状态机 的技术。你可以把它想象成俄罗斯方块。屏幕上有一个光标,光标看当前读到的字符,判断这个字符属于哪一类。

比如,Lexer 看到了 a,它知道这是变量名的一部分;看到了 >,它知道这是标签结束;看到了空格,它知道这是分隔符。

举个例子:
输入代码:

$a = 1;

Lexer 的处理过程大概是这样的:

  1. 读到 T_OPEN_TAG (<?php)。
  2. 读到空格,忽略它(或者存入 T_WHITESPACE,看具体需求)。
  3. 读到 $,这是一个特殊的标识符,生成 T_VARIABLE,变量名是 a
  4. 读到 =,生成 T_EQUAL
  5. 读到数字 1,生成 T_LNUMBER
  6. 读到分号 ;,生成 T_SEMICOLON

Lexer 不关心这些 Token 是干什么的,它只管切。切出来的东西叫 Token。在 C 语言的结构体里,通常是这样的:

typedef struct _zend_token {
    int type;       // T_VARIABLE, T_STRING, etc.
    char *text;     // 指向源码字符串的指针
    size_t len;     // 长度
    uint32_t line;  // 行号,报错的时候很有用
    uint32_t pos;   // 字符在源码中的位置
} zend_token;

所以,PHP 代码在 Lexer 阶段,就变成了一串有序的 Token 列表。这一步其实是通过 lex() 函数反复调用来完成的。


第三站:制定食谱的大厨 —— Parser (语法分析)

切完菜,接下来是 Parser(语法分析器)。源码文件是 zend_language_parser.y。这里我要严肃地提一下 Bison。Bison 是一个通用的语法分析器生成器,Yacc 是它的老祖宗。

Parser 的任务非常高大上:把这一堆散乱的 Token,组织成一个有逻辑的结构。

在 Parser 眼里,Token 只是数据,它不关心 T_EQUAL 是什么意思,它关心的是“T_VARIABLE 紧接着 T_EQUAL 再接着 T_LNUMBER”这个顺序是否符合 PHP 的语法规则。

Parser 使用的是 递归下降 算法。简单说,就是定义一堆规则。比如:

  1. Expression 可以是一个 Assignment。
  2. Assignment 可以是 Variable Equals Expression。
  3. Variable 可以是一个标识符。

当 Lexer 把 Token 列表扔给 Parser 时,Parser 会根据这些规则,在内存里搭起一座 。这就是我们常说的 AST(抽象语法树)

AST 的样子:

Program
├── Statement
│   └── Expression
│       ├── Assignment
│       │   ├── Variable ($a)
│       │   └── LNumber (1)
│       └── Semicolon (;)

如果代码写错了,比如 $a = 1 1;,Parser 会报错,因为它发现 T_LNUMBER 后面跟了 T_LNUMBER,但在它的规则库里,Expression 后面不该跟另一个数字,它不知道该怎么办,只能扔给你一个 Parse error

代码视角:

在源码里,Parser 执行的是 zendparse() 函数(如果是老版本)或者更复杂的逻辑。它会生成 zend_ast 结构体。

typedef struct _zend_ast {
    zend_ast_kind kind; // ZEND_AST_ASSIGN, ZEND_AST_METHOD_CALL, etc.
    uint32_t flags;
    zend_ast *child;    // 孩子,这棵树的枝丫
    uint32_t lineno;
    union {
        zend_string *val; // T_STRING
        zend_ast *child[2]; // 普通二元运算符
        zend_ast **children; // 数组或函数调用
        struct {
            uint32_t lineno;
            uint32_t var_flags;
            uint32_t var_type;
            uint32_t var_data;
        } var; // 变量
    } u;
} zend_ast;

这一步,PHP 代码终于脱离了“字符”的范畴,变成了“结构”。这就是为什么 PHP 可以做 AST 操作,比如在中间件里替换 AST,实现代码优化。


第四站:翻译官 —— Compiler (编译器)

有了 AST,接下来就是 Compiler(编译器)了。文件是 zend_compile.c。编译器的工作非常直接:把 AST 转换成机器能跑的指令。

在 PHP 里,机器能跑的指令不是汇编,也不是机器码,而是 OpCodes(操作码)

你可以把 OpCodes 理解成一种通用的汇编指令。比如:
ZEND_ASSIGN:赋值。
ZEND_ECHO:打印。
ZEND_ADD:加法。
ZEND_RETURN:返回。

Compiler 遍历 AST 这棵树,遇到一个节点,就生成一段对应的 OpCodes。它还会把变量名变成操作数(Operand)。

编译过程模拟:

AST 节点:Assignment(Variable(a), LNumber(1))

Compiler 生成的 OpCodes 数组(Opcode Array)大致长这样:

zend_op_array op_array;

// 1. 读取变量 $a 的槽位,假设是 op1
op_array.opcodes[0].opcode = ZEND_ASSIGN; 
op_array.opcodes[0].op1_type = IS_VAR;
op_array.opcodes[0].op1.var = get_var_index("a");
op_array.opcodes[0].op2_type = IS_CONST;
op_array.opcodes[0].op2.u.val = 1; // 常量池里存了数字 1

// 2. 函数结束
op_array.opcodes[1].opcode = ZEND_RETURN;
op_array.opcodes[1].op1_type = IS_CONST;
op_array.opcodes[1].op1.u.val = NULL; // 顶层脚本默认返回 NULL

这一步结束后,内存里有了一组有序的指令,这就是 字节码。你看到的 opcache,就是干这个事情的:把编译好的 OpCodes 保存成文件(.opcache 文件),下次请求来的时候,直接加载这个文件,跳过 Parser 和 Compiler 的步骤,直接进入执行阶段。


第五站:主厨与中央厨房 —— Virtual Machine (VM)

终于到了最核心的部分了:虚拟机。在源码里,你找不到一个叫 virtual_machine() 的函数。VM 的灵魂在于 zend_execute() 函数。

虽然名字叫“虚拟机”,但其实它就是一个巨大的 switch-case 循环

执行循环:

想象一下,zend_execute() 函数就是厨师站在炉子前。它的手里拿着一个指针 execute_data,这个指针指向当前执行的指令。

ZEND_API void zend_execute(zend_op_array *op_array) {
    zend_execute_data *execute_data = (zend_execute_data *) (
        op_array->run_time_cache + 
        (sizeof(zend_execute_data) * op_array->last_var) +
        (sizeof(zend_op) * op_array->last_try_catch)
    );

    // 进入循环,直到遇到 RETURN 或者 END
    while (1) {
        // 取出当前指令
        zend_op *opline = execute_data->opline;

        // 伟大的 switch 语句,这就是虚拟机的核心!
        switch (opline->opcode) {
            case ZEND_ASSIGN:
                // 执行赋值逻辑
                // 从 execute_data 里取操作数
                zval *target = EX_VAR(opline->op1.var);
                zval *value = EX_VAR(opline->op2.var);

                // Zval 的魔法操作,这里涉及引用计数和类型转换
                add_function(target, value, &EG(type_info));
                break;

            case ZEND_ECHO:
                // 执行打印逻辑
                zval *arg = EX_VAR(opline->op1.var);
                zend_print_zval(arg, 0);
                break;

            // ... 几百种其他的 opcode 处理 ...
        }

        // 指针下移,执行下一条指令
        execute_data->opline++;
    }
}

Zval:神奇的变量容器

在 VM 执行过程中,有一个贯穿始终的角色,那就是 Zval(Zend Value)。它是 PHP 变量的载体。

typedef struct _zval_struct {
    union {
        uint64_t lval; // 长整型
        double dval;   // 浮点型
        zend_string *str; // 字符串
        zend_array *arr;  // 数组
        zend_object *obj; // 对象
        // ...
    } value;

    union {
        uint32_t type;
        uint32_t type_flags;
        uint32_t reserved; // 调度器
    } u1;

    union {
        uint32_t v;
        uint32_t t;
    } u2;
} zval;

Zval 有两个核心属性:type(类型)和 refcount(引用计数)。

这涉及到 PHP 的写时复制机制。
当你执行 $a = 1; 时,VM 会创建一个 Zval,里面的 value1refcount 是 1。
当你执行 $b = $a; 时,VM 不会把 1 复制一份,而是把 $a 的 Zval 指针扔给 $b,并把 $arefcount 加 1。
当你执行 $a = 2; 时,PHP 发现 $arefcount 是 1(它是引用),于是直接修改 value 为 2。

如果 $a = 1; $b = &$a;(引用赋值),那么 $ais_ref 标志会被置为 1。这时候 $b = 2,PHP 知道 $a$b 指向同一个地方,必须把这两个地方的值都改成 2。

VM 就是在这一堆 Zval 的海洋里游泳,指挥它们去取值、计算、赋值、销毁。


第六站:善后与垃圾回收 (GC)

当 VM 跑完了所有的 OpCodes,比如遇到了 ZEND_RETURN 或者脚本结束了,执行循环也就退出了。

这时候,就是 Zend 引擎的收尾时间

  1. 变量回收: 所有的局部变量 Zval,它们的 refcount 都会减 1。如果减到 0,说明没人用这个变量了,内存释放。
  2. GC(垃圾回收): 虽然引用计数(RC)能处理大部分情况,但如果出现了循环引用(比如对象 A 引用 B,对象 B 引用 A),单靠 RC 就不够了。这时候 PHP 会有一个周期性的 GC 算法,去扫描内存中的 Zval,清理那些没人引用的孤岛。

结束语

好了,各位观众,今天的 PHP 请求之旅就讲到这儿。

你看,从 SAPI 接收请求,Lexer 把代码切碎,Parser 把碎片拼成树,Compiler 把树变成指令,最后 VM 驱着 Zval 在指令流里跳舞。这就是一次普通的 PHP 请求的完整生命周期。

有人说 PHP “慢”,其实不然。它的慢在于“解释”,也就是 VM 的开销。但它的“快”在于“开发效率”和“生态”。当我们理解了这些底层逻辑,我们写的代码(比如手动管理 Zval,或者优化 OpCache)就会更接近机器的直觉。

下次当你写 echo 的时候,别忘了,在你的机器深处,有成千上万个 switch-case 语句正在欢呼雀跃,为你这一行代码买单。

下课!

发表回复

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