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

大家好,欢迎来到“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; 的过程。

  1. Fetch:VM 从 EX(TSMLS_C)(执行上下文)的堆栈中,找到 $a$b 的地址。
  2. Calculate:读取这两个地址的值(比如 $a=1, $b=2),执行加法。结果放入一个临时变量(Stack top)。
  3. Store:VM 找到 $c 的地址,把结果存进去。
  4. JumpZEND_VM_NEXT_OPCODE。指针跳到下一条指令(如果是最后一条,则跳转到 RETURN 指令)。

3. 函数调用:层层嵌套

如果代码里有函数调用,流程会更复杂。

当遇到 my_function($arg) 时:

  1. VM 执行 ZEND_DO_FCALL_BY_NAME
  2. 它会检查 my_function 是否存在。
  3. 它会创建一个新的执行上下文栈帧。
  4. 它会把参数压栈。
  5. 它会跳转到函数内部的 Opcode 开始处。
  6. 函数返回时,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 很臃肿,用了联合体 u1u2。在 PHP 7/8 中,它变成了两个结构体:zvalzend_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;
};

这个结构体里藏着两个秘密:

  1. 类型信息 (type_info)
    这是 var_dump 的秘密来源。它用位掩码来存储类型:IS_NULL, IS_LONG, IS_STRING… 甚至还有 IS_INDIRECT 这种为了性能优化的黑魔法。
    为什么这样做?因为存一个 int(类型)和一个指针(值),比存一个巨大的联合体更省内存。

  2. 引用计数 (counted)
    这是 PHP 内存回收的基础。
    当你 $a = $b 时,并不是拷贝了 $b 的内容,而是让 $a$b 指向同一个 zval,并且引用计数加 1。
    $a 死亡时,引用计数减 1。如果变成 0,这个 zval 就被销毁了。

生命周期推演:

  1. CreationZEND_NEW 指令。分配内存,初始化为 0,类型设为 IS_NULL,引用计数设为 1。
  2. AssignmentZEND_ASSIGN 指令。如果是 = &x,修改指针指向;如果是 = x,创建新 zval,拷贝数据,引用计数 +1。
  3. UnsetZEND_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 开个光!

发表回复

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