PHP 7语言的执行原理

我们常用的高级语言有很多种,比较出名的有 C\C++、Python、PHP、Go、Pascal 等。而这些语言根据运行的方式不同,大体分为两种:编译型语言和解释型语言。

其中,编译型语言包括 C\C++、Pascal、Go 等。这里说的编译是指在应用源程序执行之前,就将程序源代码 “翻译” 成汇编语言,然后进一步根据软硬件环境编译成目标文件。一般称完成编译工作的工具为编译器。而解释型语言,在程序运行时才被 “翻译” 为机器语言。但是执行一次 “翻译” 一次,所以执行效率较低。解释器的工作就是解释型语言中,负责 “翻译” 源代码的程序。

下面会更详细地讨论一下编译型语言和解释型语言的运行方式。

编译型语言与解释型语言

我们知道,对于一段 C 语言代码,需要经过预编译、编译、汇编和链接,才能成为可执行的二进制文件。以 hello.c 为例:

#include<stdio.h>
int main(){
    printf("hello world");
    return 1;
}
image 2024 06 06 18 54 25 077
Figure 1. 图2-1 编译型语言的执行示意

对于这段 C 代码,main 是程序入口函数,实现的功能是打印字符串 “hello world” 到屏幕上。编译和执行过程如图2-1所示。

  • 第1步:C 语言代码预处理(比如依赖处理、宏替换等)。如以上代码示例,#inlcude<stdio.h> 就会在预处理阶段被替换。

  • 第2步:编译。编译器会把 C 语言翻译成汇编语言程序,一条 C 语言通常编译为多条汇编代码。同时编译器会对程序进行优化,生成目标汇编程序。

  • 第3步:编译得到的汇编语言通过汇编器再汇编成目标程序 hello.o

  • 第4步:链接。程序中往往包含一些共享目标文件,如示例程序中的 printf() 函数,位于静态库,需要经过链接器(如 Uinx 连接器 ld)进行链接。

C 语言为代表的编译型语言,代码发生更新都要经过以上步骤。

我们在本章对编译型语言与解释型语言的区别的理解,立足于源代码被编译成目标平台 CPU 指令的时机。对于编译型语言,编译结果已经是针对当前 CPU 体系的指令;而解释型语言,需要先编译成中间代码,再经由该解释型语言的特定虚拟机,翻译成特定 CPU 体系的指令被执行。解释型语言是在运行过程中,翻译为目标平台的指令。常说解释型语言 “慢”,主要也是慢在这里。

在 PHP 7 中,源代码首先进行词法分析,将源代码切割为多个字符串单元,分割后的字符串称为 Token。而一个一个独立的 Token 是无法表达完整语义的,需经过语法分析阶段,将 Token 转换为抽象语法树(简称 AST)。之后,抽象语法树被转换为机器指令执行。在 PHP 中,这些指令称为 opcode(后文会对 opcode 做更详细的解释,此处读者可以将其看待为 CPU 指令)。

AST 的生成这一步,编译型语言与解释型语言所需经历的过程相似。从抽象语法树之后开始产生差异。

image 2024 06 06 18 59 13 924
Figure 2. 图2-2 以 PHP 为例,解释型语言的执行示意

图2-2是执行 PHP(如无特殊说明,本章提到的 PHP 均为 PHP 7 版本)代码的简化步骤,其中最后一步的左侧分支是编译型语言的过程。

  • 第 1 步:源码通过词法分析得到 Token

  • 第 2 步:基于语法分析器生成抽象语法树(AST)。

  • 第 3 步:抽象语法树转换为 opcodes(opcode 指令集合), PHP 解释执行 opcodes

接下来在基本步骤的基础上,细化 PHP 语言的执行原理,以便更清晰地建立认知。

PHP 7的执行原理概述

image 2024 06 06 19 03 51 262
Figure 3. 图2-3 PHP 7 程序的执行过程

首先补充说明前文提到的 PHP 7 程序执行过程,请见图 2-3。

  • 第 1 步:词法分析将 PHP 代码转换为有意义的标识 Token。该步骤的词法分析器使用 Re2c 实现。

  • 第 2 步:语法分析将 Token 和符合文法规则的代码生成抽象语法树。语法分析器基于 Bison 实现。语法分析使用了 BNF(Backus-Naur Form,巴科斯范式)来表达文法规则,Bison 借助状态机、状态转移表和压栈、出栈等一系列操作,生成抽象语法树。

  • 第 3 步:上步的抽象语法树生成对应的 opcode,并被虚拟机执行。opcode 是 PHP 7 定义的一组指令标识,指令对应着相应的 handler(处理函数)。当虚拟机调用 opcode,会找到 opcode 背后的处理函数,执行真正的处理。以常见的 echo 语句为例,其对应的 opcode 便是 ZEND_ECHO

这里为了便于理解词法分析和语法分析过程,将两者分开描述。但实际情况下,出于效率考虑,两个过程并非完全独立。

下面通过一段示例代码,来建立 PHP 7 运转的初步理解。

示例代码如下:

<? php
echo "hello world";

从图2-3可知,这段代码首先会被切割为 Token。

Token

Token 是 PHP 代码被切割成的有意义的标识。本书介绍的 PHP 7 版本中有 137 种 Token,在 zend_language_parser.h 文件(该文件是在 make 执行过程中生成的)中做了定义:

/* Tokens.  */
#define END 0
#define T_INCLUDE 258
#define T_INCLUDE_ONCE 259
…
#define T_ERROR 392

更多 Token 的含义,感兴趣的读者可以参考 附录B

PHP 提供了 token_get_all() 函数来获取 PHP 代码被切割后的 Token,可以在深入源码学习前,粗略查看 PHP 代码被切割后的 Token。对于如下代码片段:

/home/vagrant/php7/bin/php -r 'print_r(Token_get_all("<? php echo \"hello world\"; ")); '

输出结果为:

Array
(
    [0] => Array
        (
            [0] => 379
            [1] => <? php
            [2] => 1
        )
    [1] => Array
        (
            [0] => 328
            [1] => echo
            [2] => 1
        )
    [2] => Array
        (
            [0] => 382
            [1] =>
            [2] => 1
        )
    [3] => Array
        (
            [0] => 323
            [1] => "hello world"
            [2] => 1
        )
    [4] => ;
)

其中,二维数组的每个成员数组的第一个值为 Token 对应的枚举值。第二个值为 Token 对应的原始字符串内容。第三个值为代码对应的行号。可以看出,词法解析器将 “<?php echo "hello world"; ” 这段文本内容切分成了 4 部分。

  1. 文本 “<? php”,切割后对应的 Token 值为 379,参考 PHP 7 中的源码:

    #define T_OPEN_TAG 379

    不难理解,它是 PHP 代码的起始 tag,也就是 <? php 标识。

  2. echo 对应的 Token 是 T_ECHO:

    #define T_ECHO 328
  3. 源码中的空格,对应的 Token 为 T_WHITESPACE,值为 382:

    #define T_WHITESPACE 382
  4. 字符串 "hello world",对应的 Token 值为 323:

    #define T_CONSTANT_ENCAPSED_STRING 323

可见,Token 就是一个个的 “词块”,但是单独存在的词块不能表达完整的语义,还需要借助规则进行组织串联。语法分析器就是这个组织者。它会检查语法,匹配 Token,对 Token 进行关联。

PHP 7 中,组织串联的产物就是 AST(Abstract Syntax Tree,抽象语法树)。

AST

AST 是 PHP 7 版本新特性。在这之前的版本中,PHP 代码的执行过程中是没有生成 AST 这一步的。PHP 7 对抽象语法树的支持,实现了 PHP 编译器和解释器解耦,有效提升了可维护性。

顾名思义,抽象语法树具有树状结构。AST 的节点分为多种类型,对应着 PHP 语法。在当前章节,我们可以认为节点类型是对语法规则的抽象,例如赋值语句,生成的抽象语法树节点为 ZEND_AST_ASSIGN。而赋值语句的左右操作数又将作为 ZEND_AST_ASSIGN 类型节点的孩子。通过这样的节点关系,构建出抽象语法树。

如果读者希望一睹为快,可以直接跳到本书 第 13 章,其中图片描绘了一段简单的 PHP 代码生成的抽象语法树。

这里介绍 PHP-Parser 工具,它可以用来查看 PHP 代码生成的 AST

PHP-Parser 是 PHP 7 内核作者之一 Nikic 编写的将 PHP 源码生成 AST 的工具。源码见 https://github.com/nikic/PHP-Parser

更多关于抽象语法树的介绍,将在后续章节展开。

opcodes

AST 扮演了源码到中间代码的临时存储介质的角色,还需要将其转换为 opcode,才能被引擎直接执行。opcode 只是单条指令,opcodesopcode 的集合形式,是 PHP 执行过程中的中间代码,类似 Java 中的字节码。opcode 生成之后由虚拟机执行。

我们知道,PHP 工程优化措施中有一个比较常见的 “开启 opcache”,指的就是这里的 opcodes 的缓存(opcodes cache)。通过省去从源码到 opcode 的阶段,引擎可以直接执行缓存的 opcode,以此提升性能。

借助 vld 插件,可以直观地看到一段 PHP 代码生成的 opcode

php -dvld.active=1 hello.php

经过过滤整理,对应的 opcode 为:

line     op
  1      ECHO
  2      RETURN

其实在源码实现中,上述代码生成的 opcodehandler 为:

ZEND_ECHO          // handler: ZEND_ECHO_SPEC_CONST_HANDLER
ZEND_RETURN        // handler: ZEND_RETURN_SPEC_CONST_HANDLER

可见,ZEND_ECHO 对应的 handlerZEND_ECHO_SPEC_CONST_HANDLER。此 handler 实现的功能便是预期的 “hello world” 语句的输出。

在本书的 PHP 版本中,内核在 zend_vm_opcodes.h 中定义了 186 种 opcodes,也可以参考本书附录 B。