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

PHP 源码“奥德赛”:从 Request 到 Opcode 的死亡之旅

各位老铁,大家好!

今天我们不聊怎么写业务代码,也不聊怎么写微服务架构。今天我们要干一件稍微有点“装逼”的事儿——我们要潜入 PHP 引擎的黑色科幻地下室,去摸一摸那些底层代码的脉搏。

你有没有想过,当你敲下 <?php echo "Hello World"; ?> 并回车后,究竟发生了什么?这行代码就像是一个神秘的魔法咒语,直接从浏览器请求变成了屏幕上的文字。

从源码的角度来看,这个过程就像是一场接力赛

第一棒是 SAPI(服务器抽象层),它是守门人;
第二棒是 Lexer(词法分析器),它把代码切成单词;
第三棒是 Parser(语法分析器),它把单词拼成句子;
第四棒是 Compiler(编译器),它把句子变成机器能懂的 Opcode
最后一棒是 VM(虚拟机),它就是那个挥舞着菜刀的厨师,真正在锅里翻炒。

咱们这就开跑!


第一棒:SAPI —— 懒惰的接待员

当你的 HTTP 请求打到服务器,PHP 进程(比如 PHP-FPM)醒了。这时候,SAPI(Server Application Programming Interface)就上岗了。SAPI 就像是 PHP 的大堂经理,不管你是 Nginx、Apache 还是 CLI 命令行,它都得听它的。

它的核心任务是:接管请求,初始化环境,然后扔给下一个接盘侠。

让我们看看代码长什么样。在 main/php_main.c 里,有个函数叫 php_request_startup。这名字听着就充满了“启动”的野心。

/* 模拟源码片段:php_request_startup 的核心逻辑 */
int php_request_startup(void) {
    /* 1. 收拾桌子:初始化变量表、常量表 */
    zend_initialize_common(TSRMLS_C);

    /* 2. 抹布擦桌:重置状态,防止上一个人的脏数据残留 */
    CG(active_op_array) = NULL; 
    EG(active_symbol_table) = NULL;

    /* 3. 煮开水:加载扩展 */
    if (php_module_startup() == FAILURE) {
        return FAILURE;
    }

    /* 4. 把球踢出去:调用 SAPI 特定的启动逻辑 */
    if (sapi_module.startup(&fake_request) == FAILURE) {
        return FAILURE;
    }

    return SUCCESS;
}

SAPI 的活儿干完了,它把一个结构体(比如 zend_request_info)塞给了解析器。它甚至懒得去读你写的代码,它只负责确保引擎是热乎的。


第二棒:Lexer —— 拆弹专家

SAPI 踢了一脚球,球到了 Lexer 手里。注意,这里用的是单数 Lexer,但在 Zend 引擎里,它通常指代 zend_language_scanner.l 这个文件。

PHP 是脚本语言,程序员写的代码是“字符串流”,计算机不认识字符串。Lexer 的任务就是用正则表达式把这团乱麻解开,变成一个个 Token

什么是 Token?你可以把 Token 理解为代码的“原子”。比如你写:

$a = 1 + 2;

Lexer 看到的不是整行代码,而是一串 Token:T_VARIABLE, T_EQUAL, T_LNUMBER, T_PLUS, T_LNUMBER, T_SEMICOLON

这里有个很神奇的机制叫 FLEX。你在 .l 文件里定义正则,FLEX 就能自动生成 C 代码。这是一种“黑魔法”,但非常高效。

/* 模拟 zend_language_scanner.l 中的正则规则 */
<T_IN_SCRIPTING>"[^a-zA-Z_x7f-xff$][a-zA-Z0-9_x7f-xff]* {
    return T_STRING;
}
<T_IN_SCRIPTING>"[0-9]*.?[0-9]+([eE][+-]?[0-9]+)??" {
    return T_DNUMBER;
}

/* 实际执行时,zendlex() 函数会不断被调用 */
int zendlex(zval *zendlval) {
    /* ... 省略了大量的 FLEX 生成代码 ... */
    yylval->value.lval = yylineno; /* 当前行号 */
    yylval->type = T_VARIABLE;
    return T_VARIABLE;
}

你看,这一行行代码(.l 文件),最终变成了 C 语言里的 return 语句。Lexer 把代码流变成了 Token 流,就像是把一张报纸拆成了一个个铅字。


第三棒:Parser —— 拼图大师

拿到了 Lexer 扔过来的 Token 瓶子,Parser 接手了。

Parser 是基于 Bison/Yacc 构建的。这东西负责检查语法是否正确,比如你写 1 + 2),Parser 就会报错。更重要的是,Parser 负责把 Token 扔进 AST(抽象语法树) 里。

AST 是什么?就是一个树状结构。
比如 $a = 10; 变成了一棵树:

        ASSIGN
       /      
   VARIABLE    LNUMBER
       |
       $a

如果你写复杂的嵌套代码,AST 会变得非常深。这也是为什么 switchif/else 快的原因——在生成 AST 时,switch 会被优化成一种特殊的节点结构,而 if 是一串嵌套的 if 节点。

让我们看看 Parser 的核心逻辑(简化版):

/* 模拟 parser.y 的核心规则 */
statement:
    expr SEMI
    {
        zend_compile_stmt($1 TSRMLS_CC);
        FREE_NODE($1);
    }
    ;

expr:
    T_VARIABLE
    {
        $$ = zend_get_variable(TSRMLS_C);
    }
    | T_LNUMBER
    {
        $$ = zend_get_constant(TSRMLS_C);
    }
    | expr PLUS expr
    {
        $$ = zend_make_binary_op(ZEND_ADD, $1, $3 TSRMLS_CC);
    }
    ;

这里看到了吗?Parser 不仅仅是检查语法,它还在指挥编译器(下一棒)该怎么干活。当它遇到 expr PLUS expr 时,它知道,下一步要把这两部分加起来,生成一个 ZEND_ADD 的指令。


第四棒:Compiler —— 翻译官

AST 是给人类(和解释器)看的结构,但虚拟机看不懂树。Compiler 的任务是:把 AST 压扁,变成一个线性列表,这就是 Opcode!

这个列表在 C 语言里被定义为 zend_op_array 结构体。你可以把它理解为“编译好的 PHP 程序”。

举个栗子,如果你写:

function add($a, $b) {
    return $a + $b;
}

Compiler 会把它转换成下面这段 C 结构:

/* 编译后的 Opcode 列表 */
zend_op_array op_array = {
    .type = ZEND_USER_FUNCTION,
    .arg_info = { /* 参数信息 */ },
    .last = 5, /* 5条指令 */
    .opcodes = (zend_op[]) {
        {
            .handler = ZEND_RECV_INIT, /* 接收参数 */
            .op1_type = IS_CONST,
            .op1.u.constant = { .value = { .lval = 0 } } /* 参数默认值 */
        },
        {
            .handler = ZEND_RECV_INIT, /* 接收参数 */
            .op1_type = IS_CONST,
            .op1.u.constant = { .value = { .lval = 0 } } /* 参数默认值 */
        },
        {
            .handler = ZEND_ADD, /* 加法指令 */
            .op1_type = IS_CV, /* 指向符号表的引用 */
            .op2_type = IS_CV,
            .result_type = IS_TMP_VAR
        },
        {
            .handler = ZEND_RETURN, /* 返回结果 */
            .op1_type = IS_TMP_VAR
        },
        /* ... */
    }
};

注意这里的 handler。这是整个引擎最核心的优化点。PHP 没有像 C++ 那样真正把代码编译成机器码(除了 JIT),而是编译成一种“伪机器码”。

每一条指令都有一个对应的函数指针。比如 ZEND_ADD 指向了加法函数。

所以,Compiler 的任务完成了。现在你有了 op_array,这就好比厨师拿到了写好的菜谱,下一步就是开火!


第五棒:VM(虚拟机)—— 机器人厨师

现在,op_array 交到了 Virtual Machine (VM) 手里。VM 是 Zend Engine 的心脏,它的心跳就是 Execute Loop(执行循环)

在 PHP 7/8 中,这个循环在 zend_execute.c 中,主要通过宏定义 ZEND_VM_HANDLER 来实现。

核心代码长这样:

void zend_execute(zend_op_array *op_array) {
    /* 1. 准备执行环境 */
    zend_execute_data *execute_data = zend_vm_stack_push_call_frame(...);

    /* 2. 拿到第一条指令 */
    zend_op *opline = op_array->opcodes;

    while (1) {
        /* 3. 执行当前指令 */
        opline->handler(execute_data, opline);

        /* 4. 切换到下一条指令 */
        opline++;
    }
}

看起来很简单对吧?其实这里面藏着巨大的工程学智慧。

栈操作

PHP 是动态语言,变量没有固定的类型。VM 是怎么知道你刚才那个变量是数字还是字符串,该加还是该拼呢?它靠的是 Operand Stack(操作数栈)

每当 VM 执行一条指令,它都会去栈上拿东西,算完之后把结果压回去。

比如 ZEND_ADD 指令的执行过程(简化版):

/* 定义 ADD 指令的处理器 */
ZEND_VM_HANDLER(23, ZEND_ADD, CONST|CV, CONST|CV) {
    ZEND_VM_DISPATCH_BY_CALLABLE(ZEND_ADD, execute_data);
}

/* 实际实现(在 zend_vm_def.h 中) */
ZEND_API void ZEND_FASTCALL ZEND_ADD_HANDLER_SPEC_CONST_CONST(zend_execute_data *execute_data) {
    zval *result, *op1, *op2;
    op1 = EX_VAR(0);
    op2 = EX_VAR(1);
    result = EX_VAR(2);

    /* 核心逻辑:根据类型决定怎么加 */
    if (Z_TYPE_P(op1) == IS_LONG && Z_TYPE_P(op2) == IS_LONG) {
        ZVAL_LONG(result, Z_LVAL_P(op1) + Z_LVAL_P(op2));
    } else {
        /* 如果类型不对,触发类型转换,再执行 */
        convert_to_long(op1);
        convert_to_long(op2);
        ZVAL_LONG(result, Z_LVAL_P(op1) + Z_LVAL_P(op2));
    }
}

你看,这里非常直观:

  1. EX_VAR(0) 是栈顶的第一个操作数。
  2. EX_VAR(1) 是第二个。
  3. VM 检查类型,如果是整数,直接加;如果不是,先转成整数。
  4. 结果放回 EX_VAR(2)

这就是为什么 PHP 里的类型转换(比如字符串加数字)会有副作用,因为 VM 被迫在运行时进行“手术”。

为什么这么快?

你可能会问:“哥,这看起来每次都要 if (type == IS_LONG),多慢啊?”

这就要提到 PHP 7 的一个巨大优化:分支预测Direct Thunk

zend_vm_def.h 中,你会发现有 ZEND_VM_HANDLER_SPEC_CONST_CONST,还有 ZEND_VM_HANDLER_SPEC_CV_CV 等等。针对不同的操作数类型(CONST, CV, TMP, VAR),编译器会生成不同的代码入口。

比如,如果你的变量是常量,它直接从寄存器里取;如果是 CV(Compile Value,编译时的引用),它直接查符号表。

这就好比:

  • 老版本(PHP 5): 所有的加法指令都去同一个大门,进门后先问“你是谁?什么类型?”,然后转身去检查类型。
  • 新版本(PHP 7): 直接有三个不同的大门(入口)。如果是整数,走“整数门”,进门就是加法,一步到位。

这就是为什么 PHP 7 比 PHP 5 快好几倍的原因——它消除了大量的类型检查分支。


深入一下:Zval 和 内存管理

在执行过程中,你肯定会用到变量。PHP 的变量在底层是 zval 结构体。

typedef struct _zval_struct {
    union {
        long lval;          /* long value */
        double dval;        /* double value */
        char *str;          /* string */
        struct {
            void *val;
            int type;
        } arr;
        // ... 省略其他类型
    } value;

    uint32_t type;         /* actual type of the variable */
    uint32_t refcount;     /* reference counter (for GC) */
} zval;
  • type:告诉你里面装的是字符串、数字还是对象。
  • refcount:这是引用计数,PHP 的垃圾回收机制(GC)全靠它。

比如你写 $a = 1; $b = $a;

  • $a 创建了一个 zval,type 是 IS_LONG, refcount 是 1。
  • $b 赋值时,并没有复制 zval 的内容,而是把 $b 指向 $a 的那个 zval。这时候,refcount 变成了 2。

这就解释了为什么 PHP 变量默认是“按值赋值”(实际上是指针赋值,引用计数+1),这在很多场景下比 Java 的引用传递更直观,也更容易造成内存泄漏(如果忘记 unset)。


收官:Opcache 的魔法

我们一路推演到了 Opcode 执行。这看起来很完美,对吧?

但每次请求都要经过 Lexer -> Parser -> Compiler 这三棒,就像每次做饭都要现磨面粉、现发酵面团一样累。

Opcache 闪亮登场了!

Opcache 是 Zend 生态里的一块金牌插件。它的核心思想是:把编译好的 Opcode 保存到内存(或者文件)里,下次请求来了,直接把编译好的 op_array 拿出来用。

它的执行流程变成了:

  1. 请求进来。
  2. 检查 opcache 缓存。
  3. 如果有,直接跳过 Lexing 和 Parsing,把编译好的 Opcode 加载到 VM。
  4. 开始执行。

这就解释了为什么 PHP 文件里 <?php ?> 标签外面不能有任何代码。因为那些代码被 Opcache 当作垃圾扔掉了,根本不会执行。


总结

好了,各位老铁,这场“死亡之旅”讲完了。

让我们回顾一下这条路径:

  1. SAPI:接待员,清理桌子。
  2. Lexer:拆弹专家,把代码切 Token。
  3. Parser:拼图大师,搭 AST。
  4. Compiler:翻译官,把 AST 压扁成 Opcode。
  5. VM:机器人,拿着 Opcode,操作栈,进行加减乘除。

PHP 虽然经常被调侃“语法随意”、“运行慢”,但在源码层面,它是一个极度精巧、充满工业美学的软件工程。它用正则表达式解决了词法分析,用 Bison 解决了语法分析,用极其聪明的函数指针分发和栈操作解决了执行效率问题。

下次当你再敲下 echo 时,别忘了,在屏幕的背后,有一个 C 语言编写的虚拟机,正在通过千万次 switchadd 指令,为你服务。

好了,下课!

发表回复

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