PHP7内核剖析 学习笔记 第五章 PHP的编译与执行(1)

PHP的编译与执行是两个相对独立的阶段,编译的流程为词法分析、语法分析、抽象语法树的生成,执行阶段则是根据编译阶段输出的产物(即opline指令)进行执行。

5.1 语言的编译与执行

计算机只认识机器语言,无法理解人类定义的编程语言,因此需要将编程语言翻译为机器语言,这个翻译过程称为编译。

根据编译的时机不同,编程语言可分为编译型、解释型。编译型语言在程序运行前提前编译为计算机可执行的二进制文件,在执行时直接执行机器指令,这种类型语言的典型代表就是C、C++、Golang;解释型语言是程序在运行时由解释器边编译边执行,也称为脚本语言,PHP属于解释型语言。

5.1.1 编译型语言

编译型语言由编译器编译,这个步骤是在线下完成的,在执行前编译器根据不同的机器类型将编程语言编译为对应的低级机器语言,然后将这些机器语言指令按可执行目标程序的格式存储到磁盘文件中,执行时再按照可执行程序的格式将其加载到内存中的对应位置(数据段、代码段),最后逐条执行机器指令。

编译型语言执行的是机器可直接识别的指令,它最大的优势是效率高,执行时不需要经过耗时的编译过程。编译型语言的编译结果是特定于平台的,因为编译时生成的机器指令是针对特定机器的,比如Windows下编译生成的可执行程序在Linux系统中是无法执行的。

以C语言为例,Linux下由GCC编译器读取程序源文件,经过预处理、编译、汇编、链接四个过程,将C语言代码编译为可执行的目标程序,如图5-1所示:

1.预处理

预处理环节对主要对C程序中以#开头的命令进行替换,如替换#include包含的文件,用实际值替换#define定义的字符串,根据#if条件决定要编译的代码。通过gcc -o xxx.i -E xxx.c(-E选项表示对xxx.c文件仅进行预处理,-o选项表示将预处理后的结果保存到xxx.i文件中)命令完成预处理,处理完成后的结果仍然是C语言代码。

2.编译

编译过程是将预处理后的C代码转换为汇编语言,通过命令gcc -S xxx.i完成。转换后的结果是汇编代码,仍然是可理解的文本文件。

3.汇编

汇编器将汇编代码生成机器指令,并生成扩展名为.o的ELF(Executable and Linkable Format)可重定位目标文件。可重定位目标文件包括二进制机器指令及数据,由各个数据节(section)组成,包含数据节、代码节、符号表等,ELF可重定位目标文件大致布局如图5-2所示:

可通过readelf、objdump命令查看.o文件的信息:

4.链接

链接是生成可执行程序的最后一步,链接器将可重定位目标文件中引用其他文件的符号进行替换,同时,根据上一步中生成的符号表,把函数、全局变量的引用位置替换为实际的存储位置。比如生成函数调用的指令,需要知道函数代码段的起始位置,这个信息就从符号表里获取。

可执行的二进制程序在执行时首先需要加载到内存中,这个过程会根据生成的ELF可执行文件的格式将数据段加载到内存中的对应位置。需要注意的是,ELF文件中的节不会全部被load到内存中,ELF包含的在运行期间需要的节(section)被标为"可分配的"(allocable),比如代码节、数据节。但有些节只是提供给链接器或其它工具使用的,在运行时并不需要,那些节就被标为"不可分配的"(non-allocable),在操作系统加载ELF时,只有allocable的节会被加载到内存中,.symtab符号表是提供给链接器使用的,因此不会被加载到内存中。

ELF格式文件提供了两个视角,一个是从链接器的角度,一个是从加载器的角度。链接器把ELF文件看成section的集合,sections中包含了链接和重定位的所有信息;加载器则把ELF文件看成segment的集合,segments中包含了可执行文件需要被加载到内存中的必要信息。每个segment可以由一个或多个section组成,每个segment都有一个长度和一组与之关联的权限(如read、write、execute),一个进程只有在权限允许且在segment中的偏移长度在segment指定的长度内,才能正常引用segment,否则将会出现Segmentation fault(即段错误)异常。

比如下例中,"abc"分配在.roread节,执行时被加载到内存的Text Segment段,它是只读的,没有写权限,因此会触发Segmentation fault:

c 复制代码
int main(void) {
    char *str = "abc";
    str[0] = 'A';
    return 0;
}

32位系统的程序默认内存布局如图5-3所示,这就是ELF可执行文件被加载到内存中的布局:

可执行程序被加载到内存后,操作系统开始从代码段执行机器指令,在程序执行过程中有两个重要的寄存器:ebp(Extended Base Pointer)、esp(Extended Stack Pointer),分别指向栈底、栈顶。函数在调用时首先将ebp入栈保存(push ebp),然后将ebp指向esp,然后函数的参数、局部变量依次入栈,最后是函数返回值。这个过程中esp不断下移,函数调用完成后再依次出栈,最后将保存的ebp出栈,ebp指回原来位置,通过这两个寄存器界定了函数内部局部变量的作用域。

计算机根据代码段具体的指令操作栈、堆以及数据段的数据。这里要把数据、指令两个概念区分开,ebp、esp是用来控制数据栈的,不是用来保存指令的,机器当前执行的指令通过eip寄存器保存,函数调用会把当前执行位置push到栈中保存,然后跳到函数的指令位置执行,执行完后再回到原位置继续执行。

不同架构的CPU,寄存器名称被添以不同前缀以指示寄存器大小,例如x86架构,字母"e"用于名称前缀,指示各寄存器大小为32位;对于x86_64寄存器,字母"r"用于名称前缀,指示各寄存器大小为64位,上面提到的ebp、esp、eip在64位下对应的就是rbp、rsp、rip。

对于参数传递方式,x86和x86_64定义了不同的处理方式,x86会把参数压入调用栈中,而x86_64则是将函数参数传入通用寄存器,因此在x86和x86_64下生成的汇编会有些差别。

默认通过gcc -S生成的是AT&T格式的汇编,可通过-masm指定生成Intel格式的汇编:gcc -S -masm=intel main.c。

例如:

c 复制代码
int sum(int a, int b) {
    return a + b;
}

int main() {
    int a = 100;
    int b = 200;
    int c = sum(a, b);
    return 0;
}

具体的执行过程:

1.如图5-4所示:

main()并不是程序执行的第一个函数,程序入口函数为__start(),假设在执行main前,rbp、rsp分别指向1、2位置,然后开始进入main执行第1条指令push rbp(这是调用main前将__start()的状态压栈),执行时,rsp指向3的位置(即栈顶),同时把rbp的地址入栈保存,即当前rsp指向的内存保存的是rbp的地址。

2.接着执行第2条指令:mov rbp, rsp,将rbp指向rsp(这是将栈底指向main函数的栈底),然后将rsp偏移,为局部变量a、b、c分配栈内存,然后执行call sum调用函数sum。call命令会把当前指令入栈保存,然后将rip指向sum函数的起始位置,开始执行sum函数的指令,此时rsp、rbp的指向变化与main函数开始时相同(将rbp压栈保存;并将rbp更新为rsp,即将main的栈顶rsp作为sum函数的栈底),如图5-5所示:

3.sum函数执行完后依次出栈,栈顶指针rsp也随之递减,之后rsp会指向保存在栈上的rbp的位置(该位置保存了调用sum前的main栈底);然后执行pop rbp,这一步会将栈顶的值取出赋给rbp;rsp、rbp现在都还原到了调用sum前的位置,最后执行ret指令,将rip指向main函数中调用sum函数指令的位置,然后继续执行main后面的指令,如图5-6所示:

5.1.2 解释型语言

解释型语言在执行前不需要编译为机器语言,而是由解释器进行解析执行,解释器是机器可识别的二进制程序。解释型语言实际上是在语言与实际计算机中间加了一层解释器,也称为虚拟机,然后通过解释型语言控制编译好的解释器执行相应的机器指令,如图5-7所示:

解释器不是将解释型语言编译为机器语言再去执行的,而是解释器本身预先定义好了一些操作,解释型语言在执行时,告诉解释器该执行哪段机器指令。比如现在编写一个简单的只能处理加法、减法的解释器,伪代码如下:

c 复制代码
int add(int a, int b) {
    return a + b;
}

int sub(int a, int b) {
    return a - b;
}

int main(int argc, char *argv[]) {
    cmd = GET_CMD();
    switch (cmd->act) {
        case "add":
            add(cmd->param[0], cmd->param[1]);
            break;
        case "sub":
            sub(cmd->param[0], cmd->param[1]);
            break;
    }
}

然后通过如下语法执行:

解释器编译完成后启动,然后根据输入的解释型语言判断具体执行add还是sub,从而完成执行,如图5-8所示:

解释型语言与实际计算机之间多了一层解释器,屏蔽了不同平台之间机器语言的差异,因此解释型语言可以方便地运行在不同平台对应的解释器上,由解释器处理不同平台之间的差异,实现跨平台运行。由此换来的代价是运行效率低,与编译型语言直接执行机器指令相比,解释型语言多了解释器解析的一步。另外,同样的计算,解释型语言往往需要执行更多指令才能完成,比如加法操作,机器指令只需要一条,而在解释其中可能需要调用一个函数才能完成。

5.2 Zend虚拟机

Zend虚拟机(ZendVM)是PHP语言的解释器,它负责PHP代码的解析、执行。ZendVM预先定义好了大量的指令供用户在PHP代码中使用,这些指令对应的处理过程被编译为机器指令,执行PHP代码时,首先根据定义好的规则确定要执行的指令,然后调用对应的机器指令完成执行。

ZendVM对于计算机而言就是普通的二进制可执行程序,它是编译好的机器指令。PHP代码会被编译为ZendVM可识别的指令,而不是机器指令,对于PHP而言,实际计算机是透明的,因此我们可以把ZendVM当作真正的计算机来理解。

ZendVM由两部分组成:编译器、执行器,其中编译器负责将PHP代码解释为ZendVM可识别的指令(即opline),同时生成对应的符号表(函数、类等),执行器负责执行opcode(opcode是opline的处理动作)对应的机器指令,如图5-9所示:

5.2.1 opline指令

opline是ZendVM定义的执行指令(虚拟机指令)。PHP代码在编译阶段被转换为ZendVM可识别的指令,ZendVM根据不同指令完成PHP代码的运行。opline的编译是PHP编译器最核心的操作,也是编译阶段输出的产物。尽管opline指令与机器指令并不一样,但其含义是相同的。

opline指令的结构为zend_op:

c 复制代码
struct _zend_op {
    const void *handler; // 指令执行handler
    znode_op op1; // 操作数1
    znode_op op2; // 操作数2
    znode_op result; // 返回值
    uint32_t extended_value;
    uint32_t lineno;
    zend_uchar opcode; // opcode指令
    zend_uchar op1_type; // 操作数1类型
    zend_uchar op2_type; // 操作数2类型
    zend_uchar result_type; // 返回值类型
};

opline指令的组成概括一下就是:对何数据,作何处理。前者称为操作数,即指令的操作对象,后者称之为opcode,即指令的处理动作。

5.2.1.1 opcode

opcode唯一标识一个指令动作,它是运算符,用于决定做什么事。目前PHP定义了173条opcode,所有PHP语言都是基于opcode实现的,比如赋值、四则运算、循环、条件判断等。ZendVM的指令集(指令集是opcode集合吗?)定义在zend_vm_opcode.h头文件中:

5.2.1.2 操作数

zend_op结构中有三个znode_op类型的成员:op1、op2、result,它们称为操作数。操作数是运算符作用于的实体,是表达式的一个组成部分。即opcode指定机器的运算动作,而操作数是该动作操纵的具体对象,比如汇编指令add eax, 100,是将eax寄存器中的值加上100,这里add就是运算符,对应ZendVM的opcode,而eax、100就是操作数,用来告诉计算机运算操作的对象。

ZendVM为每条指令定义了三个操作数(op1、op2、result),但并不是所有指令都会用到三个,有的指令不需要操作数,有的指令只需要一个,有的需要两个,有的则都会用到,result操作数用于返回值,告诉ZendVM运算结果的存储位置。

操作数的结构为znode_op,实际就是个32位整型:

c 复制代码
typedef union _znode_op {
    uint32_t constant;
    uint32_t var;
    uint32_t num;
    uint32_t opline_num; /* Needs to be signed */
    uint32_t jmp_offset;
} znode_op;

比如赋值操作$a = 123,操作数1用来告诉VM变量a的位置,操作数2用来保存变量值123的位置,执行时,ZendVM从操作数获取到变量a与123的存储位置,从而执行对应的动作,如图5-10所示:

操作数还有类型之分,因为运算操作的对象会有不同类型,比如同样是赋值操作:$b = $a$b = 123,赋的值一个是变量,一个是常量。ZendVM会根据操作数的不同类型,各自处理获取操作数指定的对象的过程。这个类型就好比ELF可执行程序中,有的数据从栈上读取,有的从.data段读取。操作数类型在zend_op中定义,同样有三个:op1_type、op2_type、result_type,分别对应三个操作数。操作数的具体类型有以下几个:

c 复制代码
// file: zend_compile.h
#define IS_CONST   (1<<0) // 1
#define IS_TMP_VAR (1<<1) // 2
#define IS_VAR     (1<<2) // 4
#define IS_UNUSED  (1<<3) // 8
#define IS_CV      (1<<4) // 16

各种类型的具体含义如下:

1.IS_CONST:常量,也称作字面量(literal),即直接写在PHP代码中的值,如$a = 123$a = "hello"$a = array(),其中123、"hello"、array()就是字面量,它们的值都是固定不变的。字面量在编译阶段会被分配在单独的内存区(类似ELF加载到.rodata段的数据),执行时如果发现操作数为IS_CONST类型,就会到字面量存储位置获取数据。

2.IS_CV:CV变量(Compile Variable),即PHP脚本中通过$声明的变量。

3.IS_VAR:PHP变量。注意:这里的变量不是我们在PHP脚本中声明的变量,这个类型变量的常见例子是PHP函数的返回值,比如$a = time()time()的返回值就是IS_VAR类型,注意,time()的返回值指的不是$a,这句代码实际是两个指令,即函数调用、赋值,也就是time()执行完后会把返回值写到一个单独的位置,然后再把它的值赋给CV变量$a,而不是直接以$a作为返回值。除了函数返回值是CV类型,还有$a[0]$$a这种。

4.IS_TMP_VAR:临时变量,或中间变量,比如$a = "hello~" . time(),这里"hello~" . time()字符串拼接指令的执行结果就是临时变量,他们主要用于一些操作的中间结果。

5.IS_UNUSED:表示操作数没有使用。

除了上面的类型,result_type还可能是EXT_TYPE_UNUSED类型的,表示函数有返回值但没有接收,比如这句代码time();

c 复制代码
// file: zend_compile.h
#define EXT_TYPE_UNUSED (1<<5) // 32

5.2.1.3 handler

handler是每条opcode对应的实际处理函数,执行时调用handler进行处理。handler是C语言编写的、编译为机器指令的处理逻辑。默认情况下,handler就是普通C函数。由于操作数有多种类型, 同一opcode对于不同类型操作数的处理方式可能会有差异,因此每条opcode会根据操作数类型定义多个handler。每条指令有2个操作数,操作数有5种类型,因此每条opcode最多可有5×5=25个handler。handler的基本调用过程如图5-11所示:

5.2.2 zend_op_array

opline是编译生成的单条指令,所有指令集合组成了zend_op_array。除了指令集合,zend_op_array保存着很多编译生成的关键数据,比如字面量存储区就在zend_op_array中。zend_op_array是编译器的输出,也是执行器的输入,相当于编译型语言编译出的可执行文件。对于ZendVM而言,zend_op_array就是可执行数据,每个PHP脚本都会被编译为独立的zend_op_array结构。

c 复制代码
struct _zend_op_array {
    // common是普通函数或类方法对应的opcodes快速访问时使用的字段
    ...
    uint32_t *refcount;
    uint32_t this_var;
    uint32_t last;
    // opcode指令数组
    zend_op *opcodes;
    // PHP代码里定义的变量数:op_type为IS_CV变量,不含IS_TMP_VAR、IS_VAR
    // 编译前此值为0,然后发现一个新变量这个值就加1
    int last_var;
    // 临时变量数:op_type为IS_TMP_VAR、IS_VAR的变量
    uint32_t T;
    // PHP变量名数组
    zend_string **vars; // 这个数组在ast编译期间配合last_var来确定各个变量的编号
    ...
    // 静态变量符号表:通过static声明的变量
    HashTable *static_variables;
    ...
    // 字面量数量
    int last_literal;
    // 字面量(常量)数组
    zval *literals;
    // 运行时缓存数组大小
    int cache_size;
    // 运行时缓存,主要用于缓存一些操作数以便快速获取数据
    void **run_time_cache;
    void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};

下面介绍_zend_op_array结构的几个关键的成员:

1.opcodes:指令集合,它是一个数组,执行器执行时从该数组的第一条指令开始,直到最后。

2.literals:字面量存储区,IS_CONST类型的字面量保存在其中。根据编译时字面量出现的先后顺序,依次分配在literals数组中,last_literal用于记录当前字面量的数量,它的值从0开始,依次增大,用于访问字面量的操作数就是用的这个值,执行时根据这个操作数,从literals数组中获取对应的数据。

3.last_var、vars:last_var成员用来统计CV变量数,vars数组用来保存所有CV变量名,即PHP脚本中通过$声明的变量。编译过程中,如果发现一个CV变量,首先会遍历vars数组,检查该变量是否已存在,如果不存在则表示该变量是第一次出现,此时就会把last_var的值分配给它作为这个变量的操作数,然后把它的名称插入vars数组。如果CV在vars数组中已存在,则表示前面已经出现过,此时直接使用之前分配的操作数即可。

4.T:此值记录了TMP、VAR类型操作数的数量,编译时,如果发现需要用到TMP或VAR操作数,就会使用T的值作为操作数,然后把T的值加1。

从zend_op_array结构可见,它囊括了PHP编译过程的所有产物。

ZendVM为每一个PHP变量、临时变量、字面量按顺序编了号,这个编号就是opline指令的操作数,在执行时,ZendVM就是根据这些编号进行相应数据存取的,这一点与C程序的实现是一致的。

看一个例子:

c 复制代码
#include <stdio.h>

int main() {
    char *name = "pangudashu";
    
    printf("%s\n", name);
    return 0;
}

我们知道指针name分配在栈上,而pangudashu分配在常量区,那么变量名name分配在哪呢?实际上C里不会存变量名称,编译时会将局部变量名替换为相对ebp的偏移量来表示。以上代码的汇编:


-8(%rbp)就是name变量,也就是rbp - 8(rbp和ebp都是当前栈帧的基指针,即base pointer,但ebp是32位的rbp是64位的)。PHP中的变量也采用类似方式读取,每个CV变量、临时变量、字面量都有自己唯一的编号,它们分配在不同区域上,然后根据各自的编号实现数据访问。其中字面量保存在literals数组中。CV变量、临时变量是运行时分配的,与字面量不同,每次执行都需要重新分配,执行完后释放。

5.2.3 zend_execute_data

C程序在执行时首先会分配运行栈,局部变量、上下文调用信息都通过栈来保存,eip寄存器指向指令区,函数调用时首先将eip入栈保存,然后移动ebp、esp分配新的栈,执行完后再将保存的eip出栈还原,继续执行。从C程序执行的过程来看,最重要的两部分是执行栈、eip。同样地,ZendVM的执行器在执行流程中,通过zend_execute_data结构实现了类似C程序中执行栈、eip的功能:

c 复制代码
typedef struct _zend_execute_data zend_execute_data;
struct _zend_execute_data {
    const zend_op     *opline;
    zend_execute_data *call;
    zval              *return_value;
    zend_function     *func;
    zval              This;
    zend_class_entry  *called_scope;
    zend_execute_data *prev_execute_data;
    zend_array        *symbol_table;
    void              **run_time_cache;
    zval              *literals;
};

ZendVM执行opcode指令前,首先会根据zend_op_array信息分配一个zend_execute_data结构来保存运行时信息,包括当前执行的指令、局部变量、上下文调用信息等,zend_execute_data结构各成员字段含义:

1.opline:当前执行中的指令,等价于eip的作用,执行之初opline指向zend_op_array->opcodes指令集的第一条指令,执行完一条指令后,该值会更新为下一条指令。

2.return_value:返回值,执行完后,会把返回值设置到这个地址。

3.symbol_table:全局变量符号表。

4.prev_execute_data:调用上下文,当函数调用或include时,会重新分配一个zend_execute_data,并把当前执行的zend_execute_data保存到被调函数的zend_execute_data->prev_execute_data,被调函数执行完后,再根据prev_execute_data还原到原来的执行位置,prev_execute_data等价于C程序调用过程中call、ret指令的作用。

5.literals:就是zend_op_array->literals。

除了以上介绍的成员,zend_execute_data结构的末尾还有一个动态变量区。前一节我们介绍了PHP编译过程中会为所有CV变量、临时变量编号,这些变量就分配在zend_execute_data结构的动态变量区,它们按照编号依次分配在zend_execute_data结构末尾。zend_execute_data占用的内存大小根据具体的CV变量数(即zend_op_array->last_var)、临时变量数(zend_op_array->T)来确定。这些变量的编号就是用于在zend_execute_data上获取数据的,zend_op_array与zend_execute_data的关系如图5-12所示:

5.2.4 zend_executor_globals

zend_execute_globals是全局符号表,它在main执行前分配(非ZTS(Zend Thread Safely)下),生命期直到PHP退出,PHP中常见的EG宏操作的就是这个结构。zend_executor_globals保存着类、函数符号表,类、函数编译过程中会注册到相应的符号表中,执行时如果发生函数调用、实例化类就会去各自的符号表中查找。另外,zend_executor_globals还有一个指针指向当前执行的zend_execute_data。zend_executor_globals结构中比较重要的几个成员如图5-13所示:

5.3 PHP的编译

PHP的编译过程是将PHP脚本代码根据定义的语法规则解析为opcode指令,这个过程中先后经历词法分析、语法分析,生成抽象语法树,然后再将抽象语法树编译为opcode指令,最终输出zend_op_array,处理过程如图5-14所示:

5.3.1 词法、语法解析

词法分析是编译过程的第一个阶段,词法分析器逐行读入源代码,然后按照构词规则,将PHP代码切割为定义好的、可识别的token。例如$a = 123,其中$a会被识别为T_VARIABLE。

语法分析在词法分析的基础上,将单词序列(token)组合成各类语法短语,如语句声明、表达式、函数定义等。

语法分析器需要与词法分析器配合使用,由词法分析器将源代码切割为token返回给语法分析器,然后语法分析器根据token组合,检索匹配的语法规则,生成抽象语法树。

例如,表达式$a = 3 + 4 - 6,词法分析器将其分割为$a=3+4-6,这些token被语法分析器理解、匹配,用语法分析树表示如图5-15所示:

PHP的词法分析、语法分析过程分别使用re2c、yacc完成:

1.re2c:它是一个词法扫描器,它的词法解析规则格式如下:

token格式可以按正则规则编写,大括号里的内容为命中定义的token后的处理,这里可以按照不同需求进行处理,通常最后会返回token类型,以便告诉yacc解析出的是什么token。

2.yacc:它是语法分析器,其配置规则为各种token、字符的组合。token之间通过空格隔开,在匹配命中后的处理逻辑中可以通过$0$1等获取对应的token值,返回值可通过$$设置:

上例中,可通过$1获取token1的值。

想了解更多关于re2c、yacc的内容可以看《flex与bison》这本书。

接下来介绍抽象语法树,在PHP7前的版本中是没有这个概念的,此前在语法分析阶段会直接生成opcode指令,这样导致PHP的编译器与执行器耦合在一起,假如后面改变PHP编译opcode的方式,那么语法规则文件也需要随之修改。因此PHP7在语法解析与编译opcode指令之间加了一层抽象语法树,将语法规则的解析抽离成单独的一层。这样如果语法规则发生改变而编译的opcode不变时,就可以将新的语法规则编译为原来的抽象语法树节点,从抽象语法树编译为opcode的过程不需要修改,比如现在访问对象的成员属性及方法的规则是"->",如果要改成Java中通过"."的方式,则只需要修改生成抽象语法树的规则即可,后面的过程不需要改动。再比如现在要将PHP的执行引擎换成全新的一套VM,但仍使用PHP的语法,此时不需要改动语法解析规则,只需要修改抽象语法树编译为opcode的过程,将抽象语法树编译为新VM的指令即可。

通过抽象语法树将PHP的编译器和执行器很好地隔离开了。

抽象语法树通过不同节点表示具体的语法,节点类型可分为四类:

1.普通节点:它是非叶子节点,通常用于某种语法的根节点,结构为zend_ast:

c 复制代码
typedef struct _zend_ast zend_ast;
struct _zend_ast {
    zend_ast_kind kind; /* 节点类型 */
    zend_ast_attr attr; /* Additional attribute, use depending on node type */
    uint32_t lineno; /* 行号 */
    zend_ast *child[1]; /* 子节点 */
};

child[1]并不意味着它只有一个子节点,不同kind类型的zend_ast的子节点数是不同的。节点类型:

c 复制代码
enum _zend_ast_kind {
    ...
    /* 0个子节点 */
    ZEND_AST_MAGIC_CONST = 0 << ZEND_AST_NUM_CHILDREN_SHIFT, // 0
    END_AST_TYPE, // 1
    /* 1个子节点 */
    ZEND_AST_VAR = 1 << ZEND_AST_NUM_CHILDREN_SHIFT, // 64
    ZEND_AST_CONST,
    ...
    /* 2个子节点 */
    ZEND_AST_DIM = 2 << ZEND_AST_NUM_CHILDREN_SHIFT, // 512
    ZEND_AST_PROP,
    ...
};

即根据kind就可知道该节点类型的子节点数,最多有4个子节点,最少0个子节点。

2.list节点:多个节点的组合,组成list的节点通常具有相同的节点类型,如use aa, bb, cc导入多个命名空间的语法,就会生成列表节点,编译时循环编译各个节点即可。list节点与普通节点的结构相比,多了一个记录子节点数量的成员children:

c 复制代码
typedef struct _zend_ast_list {
    zend_ast_kind kind;
    zend_ast_attr attr;
    uint32_t lineno;
    uint32_t children;
    zend_ast *child[1];
} zend_ast_list;

zend_ast_list与zend_ast节点的前三个成员完全一致,因此使用时会把zend_ast_list的内存地址转为zend_ast类型插入抽象语法树,使用时再根据kind转为zend_ast_list,这样将所有节点统一为zend_ast节点。

在list节点中有一个比较特殊的节点:ZEND_AST_STMT_LIST,值为133。这个节点类型本身不表示任何语法,它代表一个节点数组,里面的节点之间没有任何关系,抽象语法树的根节点CG(ast)就是这个类型。如以下代码:

php 复制代码
$a = 123;
$b = $a;
new stdClass();

生成的抽象语法树如图5-16所示:

3.数据节点:通常作为叶子结点,用于存储词法分析器切割出的token字符,即语法的操作对象。这种节点的结构为zend_ast_zval:

c 复制代码
enum _zend_ast_kind {
    ZEND_AST_ZVAL = 1 << ZEND_AST_SPECIAL_SHIFT,
    ...
};

typedef struct _zend_ast_zval {
    zend_ast_kind kind;
    zend_ast_attr attr;
    zval val;
} zend_ast_zval;

比如$a = 123,其token为a=123,其中=是语法动作,通过非叶子节点ZEND_AST_ASSIGN表示,同时它也是该语句的根节点,子节点表示具体操作的对象,其中a为赋值的操作对象,通过变量节点ZEND_AST_VAR表示,而"a"名称保存在这个节点的子节点中,类型为ZEND_AST_ZVAL。同样,值"123"也是ZEND_AST_ZVAL类型,该赋值语句的语法节点如图5-17所示:

4.声明节点:用于函数、类、成员方法、闭包的表示。

下面看一下PHP中编译的具体实现,PHP的词法规则文件定义在Zend/zend_language_scanner.l中,语法规则文件定义在Zend/zend_language_parser.y中,这两个规则文件需要通过执行re2c、yacc生成对应的C语言代码。编译开始的入口函数为compile_file():

c 复制代码
ZEND_API zend_op_array *compile_file(zend_file_handle *file_handle, int type) {
    ...
    zend_op_array *op_array = NULL;
    // 打开PHP脚本文件
    if (open_file_for_scanning(file_handle) == FAILURE) {
        ...
    } else {
        ...
        CG(ast) = NULL;
        if (!zendparse()) {
            ...
        }
        zend_ast_destroy(CG(ast));
        ...
    }
    return op_array;
}

首先是打开PHP脚本文件,然后调用zendparse()完成语法分析,zendparse()中将不断调用zendlex()切割token,然后匹配语法,生成抽象语法树,zend_language_parser.y语法规则文件中的zend_ast_create()方法就是生成语法树节点的操作。编译过程中会用到zend_compiler_globals结构,它与zend_executor_globals一样作为全局变量分配,记录编译时的一些信息,其中生成的抽象语法树就保存在这个结构中,即CG(ast)。

使用re2c、yacc进行词法、语法分析时,需要注意:

1.语义值(token值):词法解析器解析到的token值内容就是语义值,比如$a = 123,其中a、123就是语义值。这些值统一通过zval存储,zval在zendlex()中分配,然后将其地址作为参数传递给lex_scan()进行token扫描,当匹配到某个token时,把语义值保存到该地址中,从而传递给语法解析器使用。

c 复制代码
#define yylex zendlex

// zend_compile.c
int zendlex(zend_parser_stack_elem *elem) {
    zval zv;
    int retval;
    ...
again:
    ZVAL_UNDEF(&zv);
    // 进行词法扫描,将zval地址传入
    retval = lex_scan(&zv);
    if (EF(exception)) {
        // 语法错误
        return T_ERROR;
    }
    ...
    if (Z_TYPE(zv) != IS_UNDEF) {
        // 如果在分割token中有zval生成,则将其复制到zend_ast_zval结构中
        elem->ast = zend_ast_create_zval(&zv);
    }
    
    return retval;
}

比如PHP中的解析变量的规则$var_name,其词法规则为:

c 复制代码
int lex_scan(zval *zendlval) {
    ...
    // 下面这段不是C语言,而是嵌在C语言中的,用于生成词法分析器
    // 例如Flex会读取这段内容,然后生成对应的C代码形式的词法规则
    <ST_IN_SCRIPTING,ST_DOUBLE_QUOTES,ST_HEREDOC,ST_BACKQUOTE,ST_VAR_OFFSET>
    "$"{LABEL} {
        // 将匹配到的语义值保存在zval中
        // 只保存{LABEL}内容,不包括$,所以是yytext+1
        zend_copy_value(zendlval, (yytext+1), (yyleng-1));
        RETURN_TOKEN(T_VARIABLE);
    }
    ...
}

以上代码中的yytext指向命中的语义值起始位置,类型为char *,yyleng为语义值的长度,zend_copy_value函数将创建zend_string的value,该语义值返回到zendlex()中后,会被保存到ZEND_AST_ZVAL类型的节点中,该节点的val就是解析出的语义值。

2.语义值类型:yacc调用re2c解析出的token有两个含义:一个是token类型、另一个是token值。token类型以yylex函数(即zendlex())的返回值告诉yacc,而token值就是语义值,这个值一般定义为固定的类型,这个类型就是语义值的类型,默认为int,这个类型是通过YYSTYPE宏定义的,而PHP中这个宏被定义为zend_parser_stack_elem。语法分析器传给词法分析器一个zend_parser_stack_elem类型变量的地址,词法分析器将解析出的token值保存到这个地址中,这就是为什么zendlex函数的参数是zend_parser_stack_elem类型的指针。

c 复制代码
#define YYSTYPE zend_parser_stack_elem

typedef union _zend_parser_stack_elem {
    zend_ast *ast; // 抽象语法树
    zend_string *str;
    zend_ulong num;
} zend_parser_stack_elem;

如以上代码,zend_parser_stack_elem结构是一个联合体,ast类型用的比较多。在语法解析规则中,可通过<ast/str/num>指定token或type使用哪种联合体中的类型:

以T_VARIABLE为例,这里指定该token使用ast,因此,在命中的语法解析规则处理中,传入的类型就是zend_parser_stack_elem->ast,下面规则中的$1$2$3$$值的类型就是zend_ast:

PHP的语法解析器从start开始调用,首先会创建一个根节点list,然后层层匹配各个规则,将命中top_statement规则生成的语法树节点依次加到这个list中,最后将语法树根节点保存到CG(ast)完成整个解析操作:

解析的过程可以简单理解为:根据拿到的token去检索定义好的token组合,看是否命中。大致过程可用以下伪代码表示:

c 复制代码
void start() {
    zend_ast *root = top_statement_list();
    CG(ast) = root;
}

zend_ast *top_statement_list() {
    zend_ast *res;
    zend_ast *root = zend_ast_create_list(0, ZEND_AST_STMT_LIST);
    if (res = statement()) {
        zend_ast_list_add(root, res);
    }
    if (res = function_declaration_statement()) {
        zend_ast_list_add(root, res);
    }
    ...
    return root;
}

zend_ast *statement() {
    zend_ast *res;
    switch (token_list) {
        // 命中do{}while()语法
        case T_DO statement T_WHILE '(' expr ')' ';':
            return zend_ast_create(ZEND_AST_DO_WHILE, $2, $5);
            break;
        case 其他规则:
            ...
    }
}

当然,具体的解析过程会有很多递归调用,要复杂很多。语法解析的过程不涉及ZendVM的核心实现,它是相对独立的一部分内容。

5.3.2 抽象语法树编译

抽象语法树的编译就是生成ZendVM可识别指令的过程,语法解析过程的产物保存于CG(ast),接着ZendVM会把抽象语法树进一步编译为zend_op_array。在抽象语法树的编译过程中,会计算出当前脚本使用的CV变量、临时变量、字面量的数量,根据先后顺序为这些变量编号,这些编号作为opline指令的操作数,在执行时按照该编号获取相应数据。

抽象语法的编译过程主要定义在Zend/zend_compile.c文件中,这个过程发生在compile_file()完成zendparse()处理后,编译的过程就是从CG(ast)节点开始遍历抽象语法树,根据不同节点类型编译为对应语法的opline。在编译前首先分配一个zend_op_array结构,然后进行初始化,并把CG(active_op_array)指向该结构,CG(active_op_array)表示当前正在编译的zend_op_array,在抽象语法树的编译过程中,会把编译产生的opline指令插入到CG(active_op_array)。

c 复制代码
// compile_file:
if (!zendparse()) {
    ...
    zend_op_array *original_active_op_array = CG(active_op_array);
    // 分配zend_op_array内存
    op_array = emalloc(sizeof(zend_op_array));
    // 初始化zend_op_array
    init_op_array(op_array, ZEND_USER_FUNCTION, INITIAL_OP_ARRAY_SIZE);
    CG(active_op_array) = op_array;
    ...
}

以上代码中,编译前会把当前CG(active_op_array)保存下来,这是因为compile_file()在PHP生命周期中并非只会调用一次,include、require等语句的执行过程也会触发,因此这里会保存下来,编译完成后再还原回去。zend_op_array初始化完成后从CG(ast)开始编译:

c 复制代码
// 将抽象语法树编译为opline指令
zend_compile_top_stmt(CG(ast));

zend_compile_top_stmt()为编译的入口,如果想查看生成的抽象语法树,就可通过gdb设置该函数的断点进行查看。CG(ast)是一个ZEND_AST_STMT_LIST类型的list节点,list里的每个节点都是独立的语法节点,因此zend_compile_top_stmt()会编译该list,其中会调用zend_compile_stmt()具体编译各个节点:

c 复制代码
// file: zend_compile.c
void zend_compile_top_stmt(zend_ast *ast) {
    if (!ast) {
        return;
    }
    if (ast->kind == ZEND_AST_STMT_LIST) {
        // 将zend_ast节点转为实际的zend_ast_list:
        // list = (zend_ast_list *)ast
        zend_ast_list *list = zend_ast_get_list(ast);
        uint32_t i;
        // 遍历list
        for (i = 0; i < list->children; ++i) {
            // list各child语句相互独立,递归编译
            zend_compile_top_stmt(list->child[i]);
        }
        return;
    }
    // 非ZEND_AST_STMT_LIST节点
    zend_compile_stmt(ast);
    ...
}

zend_compile_stmt()主要根据不同的节点类型进行不同处理,我们根据下例具体看一下其中几个语法的编译处理:

php 复制代码
// 示例5.3.2
$a = 123;
$b = "hi~";
echo $a, $b;

以上代码中,有两条赋值语句,一条输出语句,最终生成的抽象语法树如图5-18所示:

从抽象语法树可见,CG(ast)节点下有3个节点,分别是两个ZEND_AST_ASSIGN节点和一个ZEND_AST_STMT_LIST节点,其中ZEND_AST_STMT_LIST节点包含两个ZEND_AST_ECHO节点。即echo $a, $b;等价于echo $a; echo $b;,下面我们看一下这两类节点的编译过程。

1.赋值语句的编译

ZEND_AST_ASSIGN节点有两个子节点,其中第一个节点保存的是变量名,第二个节点保存的是变量值表达式。ZEND_AST_ASSIGN节点会调用zend_compile_expr()处理,最终由zend_compile_assign()进行编译:

c 复制代码
void zend_compile_stmt(zend_ast *ast) {
    ...
    switch (ast->kind) {
        ...
        default:
        {
            znode result;
            // 编译表达式
            zend_compile_expr(&result, ast);
            zend_do_free(&result);
        }
    }
    ...
}

void zend_compile_expr(znode *result, zend_ast *ast) {
    switch (ast->kind) {
        ...
        case ZEND_AST_ASSIGN:
            zend_compile_assign(result, ast);
            return;
        ...
    }
}

继续进入zend_compile_assign():

c 复制代码
void zend_compile_assign(znode *result, zend_ast *ast) {
    zend_ast *var_ast = ast->child[0]; // 变量名
    zend_ast *expr_ast = ast->child[1]; // 变量值表达式
    
    znode var_node, expr_node;
    zend_op *opline;
    uint32_t offset;
    ...
    switch (var_ast->kind) {
        case ZEND_AST_VAR:
        case ZEND_AST_STATIC_PROP:
            offset = zend_delayed_compile_begin();
            // 生成变量名的znode,这个结构只在此处临时用,所以直接分配在stack上
            zend_delayed_compile_var(&var_node, var_ast, BP_VAR_W);
            // 递归编译变量值表达式,最终需要得到一个ZEND_AST_ZVAL节点
            zend_compile_expr(&expr_node, expr_ast);
            zend_delayed_compile_end(offset);
            // 生成一条op
            zend_emit_op(result, ZEND_ASSIGN, &var_node, &expr_node);
            return;
        ...
    }
}

该过程主要分为三步:编译生成变量名的操作数(即op1)、编译生成变量值的操作数(即op2)、生成赋值指令。

介绍zend_op_array时曾提到,每个PHP变量(CV变量)都会分配一个编号用于变量的存取,这个编号就是这里分配的。CV变量的编号是按顺序分配的,CV变量保存在zend_op_array->vars数组中,生成CV变量的操作数时,首先会检查这个数组,如果变量已经存在则直接使用之前分配的编号,如果不存在则按序分配一个编号,然后将变量名插入vars数组。上例中,$a是第一个CV变量,$b是第二个CV变量,因此$a的编号(操作数)为0,$b为1,具体过程如图5-19所示:

zend_try_compile_cv()中生成$a的操作数,最终的节点为ZEND_AST_ZVAL,即保存变量名"a"的节点。这里生成的操作数结构并不是zend_op,而是znode,zend_op只在编译期间使用,在生成opline时会被替换为znode_op。

c 复制代码
// zend_try_compile_cv:
zend_ast *name_ast = ast->child[0];
if (name_ast->kind == ZEND_AST_ZVAL) {
    zend_string *name = zval_get_string(zend_ast_get_zval(name_ast));
    ...
    result->op_type = IS_CV;
    result->u.op.var = lookup_cv(CG(active_op_array), name);
    ...
}

lookup_cv()就是生成操作数的过程:

c 复制代码
static int lookup_cv(zend_op_array *op_array, zend_string *name) {
    int i = 0;
    zend_ulong hash_value = zend_string_hash_val(name);
    // 遍历op_array.vars检查此变量是否已存在
    while (i < op_array->last_var) {
        if (ZSTR_VAL(op_array->vars[i]) == ZSTR_VAL(name) ||
          (ZSTR_H(op_array->vars[i]) == hash_value &&
          ZSTR_LEN(op_array->vars[i]) == ZSTR_LEN(name) &&
          memcmp(ZSTR_VAL(op_array->vars[i], ZSTR_VAL(name), ZSTR_LEN(name)) == 0)) {
            zend_string_release(name);
            // 这里用了两个类型转换,ZEND_CALL_VAR_NUM返回的可能是指针类型
            // 直接把指针转为int可能会导致编译器警告,因为32位和64位上的int都是4字节
            // 但32位上的指针为4字节,64位上是8字节,转换会丢精度
            return (int)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, i);
        }
        i++;
    }
    // 这是一个新变量
    i = op_array->last_var;
    op_array->last_var++;
    if (op_array->last_var > CG(context).vars_size) {
        CG(context).vars_size += 16; /* FIXME */
        op_array->vars = erealloc(op_array->vars, 
          CG(context).vars_size * sizeof(zend_string*)); // 扩容vars
    }
    
    op_array->vars[i] = zend_new_interned_string(name);
    // 第一个参数传NULL时返回的是96 + i * sizeof(zval)
    return (int)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, i);
}

这里变量的编号从0开始依次递增,但实际使用时并不直接使用这个下标,而是转化成了内存偏移,这是通过ZEND_CALL_VAR_NUM宏处理的,所以变量的操作数实际是96、112、128···递增的,这个96是根据zend_execute_data大小设定的(不同平台下对应的值可能不同)。前面提过:CV变量、临时变量分配在运行时的zend_execute_data结构的末尾,因此offset是相对zend_execute_data的偏移值,执行时,根据offset和zend_execute_data地址来索引要操作的对象。

c 复制代码
// 计算zend_execute_data所占大小是zval大小的几倍,向上取整
// 如zend_execute_data大小为8,zval大小为4,则(8+4-1)/4=2,即zend_execute_data占2个zval大小
// 如zend_execute_data大小为9,zval大小为4,则(9+4-1)/4=3,即zend_execute_data占3个zval大小
#define ZEND_CALL_FRAME_SLOT \
    ((int)((ZEND_MM_ALIGNED_SIZE(sizeof(zend_execute_data)) + \
    ZEND_MM_ALIGNED_SIZE(sizeof(zval)) - 1) / ZEND_MM_ALIGNED_SIZE(sizeof(zval))))

// 将变量编号转为内存偏移,如果call为NULL,n为1,ZEND_CALL_FRAME_SLOT为2
// 则相当于计算(zval *)NULL + (2 + 1),即如果zval大小为12字节,则结果为36
#define ZEND_CALL_VAR_NUM(call, n) \
    (((zval *)(call)) + (ZEND_CALL_FRAME_SLOT + ((int)(n))))

最终生成的a、b的操作数为96、112,如图5-19所示。

编译完赋值语句的变量名后,接着就是变量表达式的编译,这个过程将再次调用zend_compile_expr()。示例中的表达式比较简单,是CONST类型,如果是其他表达式,比如$a = $b + 3,则会在编译过程中生成其他操作的opline,即先执行$b + 3,然后再把值赋给$a

c 复制代码
void zend_compile_expr(znode *result, zend_ast *ast) {
    switch (ast->kind) {
        case ZEND_AST_ZVAL:
            // 将变量值复制到znode.u.constant中
            ZVAL_COPY(&result->u.constant, zend_ast_get_zval(ast));
            // 类型为IS_CONST,这种value后面将会保存在zend_op_array.literals中
            result->op_type = IS_CONST;
            return;
        ...
    }
}

CONST类型的变量值(即字面量)会被复制到操作数的znode结构中,但这里并没有设置操作数的u.op.val,字面量的操作数会在后面分配。

现在,赋值语句的两个操作数都编译完成了,接下来就是最终生成opline指令的操作了:

c 复制代码
zend_emit_op(result, ZEND_ASSIGN, &var_node, &expr_node);

zend_emit_op()传入三个操作数,以及opcode(即ZEND_ASSIGN),这个过程会在CG(active_op_array)中新生成一条opline,具体分为以下3步:

(1)分配opline,设置opcode

c 复制代码
// zend_emit_op:
zend_op *opline = get_next_op(CG(active_op_array));
opline->opcode = opcode;

(2)设置操作数op1、op2

如果用到了操作数1、2,则将临时结构znode中的操作数结构转移到zend_op中,这里有一个特殊处理,如果操作数为CONST类型的字面量,则将原本保存在znode.u.op.constant中的字面量值插入CG(active_op_array)->literals字面量符号表中,然后更新操作数。

c 复制代码
// 设置op1
if (op1 == NULL) {
    SET_UNUSED(opline->op1);
} else {
    SET_NODE(opline->op1, op1);
}
// 同样的方式设置op2
...

SET_NODE()宏展开如下所示:

c 复制代码
#define SET_NODE(target, src) do { \
    // target ## _type会把target与_type拼接
    // 如果这样调用宏:SET_NODE(opline->op1, op1);
    // 则这句代码相当于opline->op1_type = (src)->optype;
    // 调用宏的环境里,opline->op1_type应该是有含义的
    target ## _type = (src)->op_type; \
    if ((src)->op_type == IS_CONST) { \
        target.constant = zend_add_literal(CG(active_op_array), &(src)->u.constant); \
    } else { \
        target = (src)->u.op; \ 
    } \
while (0)

其中,CONST类型将调用zend_add_literal()方法插入字面量,并返回字面量的编号。该编号没有什么规则,示例中123这个字面量的操作数就是0,hi~的就是1。

(3)设置返回值操作数

通过zend_emit_op()方法生成的opline指令的返回值类型都是VAR,这种类型与TMP_VAR临时变量、CV变量都分配在zend_execute_data结构的末尾,VAR与TMP类型的操作数是通过CG(active_op_array)->T分配的:

c 复制代码
// zend_emit_op:
if (result) {
    zend_make_var_result(result, opline);
}
c 复制代码
static inline void zend_make_var_result(znode *result, zend_op *opline) {
    opline->result_type = IS_VAR; // 返回值类型固定为IS_VAR
    // 为返回值编号,这个编号记在临时变量T上
    opline->result.var = get_temporary_variable(CG(active_op_array));
    GET_NODE(result, opline->result);
}

get_temporary_variable()方法用来生成VAR、TMP_VAR类型的操作数的值,目前,操作数为从0开始的递增值,并不是像CV变量那样转为了内存偏移值。

c 复制代码
static uint32_t get_temporary_variable(zend_op_array *op_array) {
    return (uint32_t)op_array->T++;
}

从赋值语句的编译过程可以看出,赋值语句是有返回值的(zend_compile_stmt函数里有个result变量),这个返回值是在zend_compile_stmt()中分配的,但这个返回值不被PHP使用,编译完后就被free了。zend_do_free()将赋值语句返回值的操作数类型打上了EXT_TYPE_UNUSED标记:

c 复制代码
// zend_compile_stmt:
default:
{
    znode result;
    zend_compile_expr(&result, ast);
    zend_do_free(&result);
}

到此,ZEND_AST_ASSIGN节点就编译完成了,其结果就是生成了一条ZEND_ASSIGN指令,该指令操作数op1为offset内存偏移,op2为字面量编号,返回值为VAR类型编号。即除了CV类型操作数用的是offset内存偏移,其他类型用的都是递增编号。

2.echo语句的编译

ZEND_AST_ECHO节点由zend_compile_echo()方法负责编译,这个节点的编译主要分两步:编译生成操作数1、编译生成ZEND_ECHO指令。该指令只用到一个操作数,也没有返回值,用到的这个操作数就是最终要输出的内容,它也通过zend_compile_expr()编译。

c 复制代码
void zend_compile_echo(zend_ast *ast) {
    zend_op *opline;
    zend_ast *expr_ast = ast->child[0];
    
    znode expr_node;
    zend_compile_expr(&expr_node, expr_ast);
    // 生成一条新的opcode
    opline = zend_emit_op(NULL, ZEND_ECHO, &expr_node, NULL);
    opline->extended_value = 0;
}

示例中输出的是CV类型变量,该操作数最终也通过zend_try_compile_cv()生成,此时lookup_cv()将查找到先前分配的CV操作数,整体处理流程如图5-20所示:

最终,示例生成的全部opline指令与zend_op_array结构如图5-21所示:

从抽象语法树编译为opline指令的过程并不复杂,我们只介绍了两种比较简单的语法编译过程,主要目的是通过这两个例子让大家知道编译的基本过程。整个过程中需要特别注意的是CV、VAR/TMP_VAR、CONST几种类型操作数的确定,其中CV类型操作数通过lookup_cv()方法分配,VAR/TMP_VAR类型操作数通过get_temporary_variable()分配,CONST类型操作数通过zend_add_literal()方法分配。另外,CV操作数为内存偏移值,而VAR/TMP_VAR、CONST的操作数都是递增数值,在接下来的处理中,将根据该递增值转化为内存偏移值。

5.3.3 pass_two()

完成抽象语法树编译后,编译阶段并没有结束,最后还会生成一条ZEND_RETURN指令,这一步骤通过zend_emit_final_return()完成。即使我们在脚本中定义了return,这个地方也会再重复编译一条return指令,只不过不会执行,如果我们在脚本中没有定义return,那么就需要依靠ZendVM为我们添加的这条指令结束执行,默认返回整型1,因此在生成ZEND_RETURN指令时整型1会添加到字面量数组中。

除了编译生成ZEND_RETURN指令外,在zend_compile_top_stmt()完成后还有一个重要操作:pass_two(),该过程会对一些特殊opcode进行处理,因为部分指令在编译时有些需要的信息暂时无法获得,只有等抽象语法树编译完成后才能获得,比如goto语句。此外,pass_two()还会把VAR/TMP_VAR、CONST操作数由递增编号转为内存偏移值。

在ZendVM执行时,CV、VAR、TMP_VAR三种类型变量均分配于同一内存区,即zend_execute_data结构末尾,在5.3.2节介绍的CV操作数就是内存偏移。同样地,VAR、TMP_VAR类型操作数也是相对zend_execute_data的内存偏移,其中,会先分配CV变量,再分配VAR与TMP_VAR的内存,因此VAR、TMP_VAR的内存起始位置在最后一个CV变量之后,它们的分配是从zend_op_array->last_var开始的,然后再按照之前分配的递增编号依次确定内存偏移值。

其次是CONST类型变量的操作数,这种变量的值保存在zend_op_array->literals数组中,因此直接将数组下标转为内存偏移即可,即sizeof(zval) * 编号,该过程通过ZEND_PASS_TWO_UPDATE_CONSTANT()宏完成。

c 复制代码
// file: zend_opcode.c
ZEND_API int pass_two(zend_op_array *op_array) {
    zend_op *opline, *end;
    ...
    opline = op_array->opcodes;
    end = opline + op_array->last;
    // 遍历全部opline
    while (opline < end) {
        ...
        // 将0、1、2···转为16、32、48···(即编号*sizeof(zval))
        if (opline->op1_type == IS_CONST) {
            ZEND_PASS_TWO_UPDATE_CONSTANT(op_array, opline->op1);
        } else if (opline->op1_type && (IS_VAR|IS_TMP_VAR)) {
            // 进行与上面的CV变量相同的处理,不同的是这里的起始值是接着IS_CV的
            opline->op1.var = (uint32_t)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, 
              op_array->last_var + opline->op1.var);
        }
        // op2、result操作数的处理与op1相同
        ...
        // 设置此opcode的处理handler
        ZEND_VM_SET_OPCODE_HANDLER(opline);
        opline++;
    }
    // 标识当前op_array已执行过pass_two()
    op_array->fn_flags |= ZEND_ACC_DONE_PASS_TWO;
    return 0;
}

对于CONST类型操作数,在64位系统上,获取方式为(zval*)(((char *)(zend_op_array->literals)) + znode_op.constant),其中zend_op_array->literals是保存字面值常量的数组,而znode_op.constant是内存偏移。而在32位系统上CONST类型的操作数是内存绝对值,即zend_op_array->literals数组中直接保存着zval的地址,以下是32位系统上znode_op结构:

c 复制代码
// 32位系统上
typedef union _znode_op {
    uint32_t constant;
    uint32_t var;
    uint32_t num;
    uint32_t opline_num;
    zend_op *jmp_addr;
    zval *zv;
} znode_op;

znode_op.zv就是字面量的地址。除了CONST操作数在32/64位系统有差异,jmp指令的跳转值也有差异:在32位系统上,跳转指令采用的是绝对值,即根据znode_op.jmp_addr跳转;在64位系统上,采用的是相对值,即znode_op.jmp_offset。

pass_two()中还有一个重要处理,就是根据opcode与操作数设置每条指令的处理handler,等下一节介绍ZendVM执行器时再作说明:

c 复制代码
ZEND_VM_SET_OPCODE_HANDLER(opline);

经过pass_two()的处理后,5.3.2节示例中的opline指令被最终加工完成,如图5-22所示:

相关推荐
BingoGo11 小时前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php
JaguarJack11 小时前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php·服务端
BingoGo1 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php
JaguarJack1 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php·服务端
JaguarJack2 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
后端·php·服务端
BingoGo2 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
php
JaguarJack3 天前
告别 Laravel 缓慢的 Blade!Livewire Blaze 来了,为你的 Laravel 性能提速
后端·php·laravel
郑州光合科技余经理4 天前
代码展示:PHP搭建海外版外卖系统源码解析
java·开发语言·前端·后端·系统架构·uni-app·php
西岸行者4 天前
学习笔记:SKILLS 能帮助更好的vibe coding
笔记·学习
starlaky4 天前
Django入门笔记
笔记·django