相关数据结构

Zend 虚拟机包含词法和语法分析、AST 的编译,以及 opcode 的执行,本章主要详细介绍 ASTopcode 的执行过程,下面先介绍一下用到的基本数据结构,为后面原理性内容的介绍奠定基础。

EG(v)

首先介绍的是全局变量 executor_globals, EG(v) 是对应的取值宏,executor_globals 对应的是结构体 _zend_executor_globals,它是 PHP 生命周期中非常核心的数据结构。这个结构体维护了符号表(symbol_tablefunction_tableclass_table 等)、执行栈(zend_vm_stack)以及包含执行指令的 zend_execute_data,另外还包含了 include 的文件列表、autoload 函数、异常处理 handler 等重要信息,下面给出 _zend_executor_globals 的结构图,然后分别阐述其含义,如图11-5所示。

image 2024 06 10 18 57 38 527
Figure 1. 图11-5 EG(v)结构图

这个结构体比较复杂,下面介绍几个核心的成员。

  1. symbol_table:符号表,主要存放全局变量,以及一些魔术变量,如 $_GET$_POST 等。

  2. function_table:函数表,主要存放函数,包括大量的内部函数以及用户自定义的函数,如 zend_versionfunc_num_argsstr 系列函数等。

  3. class_table:类表,主要存放内置的类以及用户自定义的类,如 stdclassthrowableexception 等类。

  4. zend_constants:常量表,存放 PHP 中的常量,如 E_ERRORE_WARNING 等。

  5. vm_stack:虚拟机的栈,执行时压栈、出栈都在这里操作。

  6. current_execute_data:对应 _zend_execute_data 结构体,存放执行时的数据。

符号表

在 PHP 7 中,符号表分为 symbol_tablefunction_tableclass_table 等。

symbol_table

symbol_table 用于存放变量信息,其类型是 HashTable,其具体定义如下:

//符号表缓存
zend_array *symtable_cache[SYMTABLE_CACHE_SIZE];
zend_array **symtable_cache_limit;
zend_array **symtable_cache_ptr;
//符号表
zend_array symbol_table;
c

symbol_table 里面有什么呢?代码 “$a=1;” 对应的 symnol_table 如图11-6所示。

image 2024 06 10 19 01 04 977
Figure 2. 图11-6 symbol_table示意图

从图11-6可以看出,符号表中有我们常见的超全局变量 $_GET$_POST 等,还有全局变量 $a。在编译过程中会调用 zend_attach_symbol_table 函数将变量加入 symbol_table 中。

function_table

function_table 对应的是函数表,其类型也是 HashTable,见代码:

HashTable *function_table;        /* function symbol table */
c

函数表存储哪些函数呢?同样以上述代码为例,我们利用 gdb 输出一下 function_table 的内容:

(gdb) p *executor_globals.function_table
$1 = {gc = {refcount = 1, u = {v = {type = 7 '\a', flags = 0 '\000', gc_info = 0},
    type_info = 7}}, u = {v = {
      flags  =  25  '\031',  nApplyCount  =  0  '\000',  nIteratorsCount  =  0  '\000',
          consistency = 0 '\000'},
    flags = 25}, nTableMask = 4294966272, arData = 0x12102b0, nNumUsed = 848,
        nNumOfElements = 848,
  nTableSize = 1024, nInternalPointer = 0, nNextFreeElement = 0, pDestructor =
      0x8d0dc3 <zend_function_dtor>}
bash

可以看出,函数表中有大量的函数,上面输出显示函数有 848 个之多,这里面主要是内部函数,如 zend_versionfunc_num_argscli_get_process_title 等。

class_table

class_table 对应的是类表,其也是 HashTable

HashTable *class_table;   /* class table */
c

类表里面也有大量的内置类,如 stdclasstraversablexmlreader 等。

符号表存放了执行时需要的数据,比如在 symbol_table 中,key_GETBucket 对应的也是一个 HashTable 类型的表,里面存放的是 $_GET[xxx],执行时会从中取对应的值。

指令

Zend 虚拟机的指令称为 opline,每条指令对应一个 opcode。PHP 代码在编译后生成 opline, Zend 虚拟机根据不同的 opline 完成 PHP 代码的执行,opline 由操作指令、操作数和返回值组成,与机器指令非常类似,opline 对应的结构体为 zend_op,代码如下:

struct _zend_op {
    const void *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; //返回值的类型
};
c

zend_op 结构图如图11-7所示。

image 2024 06 10 19 05 56 104
Figure 3. 图11-7 zend_op结构图

PHP 代码会被编译成一条一条的 opline,分解为最基本的操作。举个例子,如果把 opcode 当成一个计算器,只接受两个操作数 op1op2,执行一个操作 handler,比如加、减、乘、除,然后它返回一个结果 result,再稍加处理算术溢出的情况,存于 extended_value 中。下面详细介绍下各个字段。

opcode

opcode 有时候被称为所谓的字节码(bytecode),是被软件解释器解释执行的指令集。这些软件指令集通常会提供一些比对应的硬件指令集更高级的数据类型和操作。

opcodebytecode 其实是两个含义不同的词,但经常会把它们当作同一个意思来交互使用。

Zend 虚拟机有很多 opcode,对应可以做非常多事情,并且随着 PHP 的发展,opcode 也越来越多,意味着 PHP 可以做越来越多的事情。所有的 opcode 都在 PHP 的源代码文件 Zend/zend_vm_opcodes.h 中定义。opcode 的名称是自描述的,例如:

  • ZEND_ASSGIN:赋值操作;

  • ZEND_ADD:两个数相加操作;

  • ZEND_JMP:跳转操作。

PHP 7.1.0 中有 186 个 opcode

#define ZEND_NOP                                0
#define ZEND_ADD                                1
#define ZEND_SUB                                2
#define ZEND_MUL                                3
#define ZEND_DIV                                4
#define ZEND_MOD                                5
#define ZEND_SL                                 6#define ZEND_FETCH_THIS                       184
#define ZEND_ISSET_ISEMPTY_THIS               186
#define ZEND_VM_LAST_OPCODE                   186
c

操作数

op1op2 都是操作数,但不一定全部使用,也就是说,每个 opcode 对应的 hanlder 最多可以使用两个操作数(也可以只使用其中一个,或者都不使用)。每个操作数都可以理解为函数的参数,返回值 resulthanlder 函数对操作数 op1op2 计算后的结果。op1op2result 对应的类型都是 znode_op,其定义为一个联合体:

typedef union _znode_op {
    uint32_t      constant;
    uint32_t      var;
    uint32_t      num;
    uint32_t      opline_num; /*  needs to be signed */
#if ZEND_USE_ABS_JMP_ADDR
    zend_op       *jmp_addr;
#else
    uint32_t      jmp_offset;
#endif
#if ZEND_USE_ABS_CONST_ADDR
    zval          *zv;
#endif
} znode_op;
c

这样其实每个操作数都是 uint32 类型的数字,一般表示变量的位置。操作数有 5 种不同的类型,具体在 Zend/zend_compile.h 中定义:

#define IS_CONST  (1<<0)
#define IS_TMP_VAR(1<<1)
#define IS_VAR     (1<<2)
#define IS_UNUSED (1<<3)  /* unused variable */
#define IS_CV      (1<<4)  /* compiled variable */
c

这些类型是按位表示的,具体含义如下。

  1. IS_CONST:值为 1,表示一个常量,都是只读的,值不可改变,如 $a="hello world" 中的 hello world

  2. IS_VAR:值为 4,是 PHP 变量,这个变量并不是 PHP 代码中声明的变量,常见的是返回的临时变量,如 $a=time(),函数 time 返回值的类型就是 IS_VAR,这种类型的变量是可以被其他 Opcode 对应的 handler 重复使用的。

  3. IS_TMP_VAR:值为 2,也是 PHP 变量,跟 IS_VAR 不同之处是,不能与其他 opcode 重复使用。举个例子,$a=“123”.time();这里拼接的临时变量 “123”.time() 的类型就是 IS_TMP_VAR,一般用于操作的中间结果。

  4. IS_UNUSED:值为 8,表示这个操作数没有包含任何有意义的东西。

  5. IS_CV:值为 16,编译变量(compiled variable),这个操作数类型表示一个 PHP 变量,以 $something 形式在 PHP 脚本中出现。

handler

handler 对应的是 opcode 的实际处理函数,Zend 虚拟机对每个 opcode 的工作方式是完全相同的,都有一个 handler 函数指针,指向处理函数的地址。处理函数是一个 C 函数,包含了执行 opcode 对应的代码,以 op1、op2 为参数,执行完成后,会返回一个结果 result,有时也会附加一段信息 extended_value。文件 Zend/zend_vm_execute.h 包含所有的 handler 对应的函数,php-7.1.0 中这个文件有 62000 多行。

Zend/zend_vm_execute.h 并非手动编写的,而是由 zend_vm_gen.php 这个 PHP 脚本解析 zend_vm_def.h 和 zend_vm_execute.skl 后生成,这个很有意思,先有鸡还是先有蛋?没有 PHP 哪来的这个 PHP 脚本呢?这个是后期产物,早期 PHP 版本不用这个。这类似于 GO 语言的自举,自己编译自己。

同一个 opcode 对应的 handler 函数会根据操作数的类型而不同,比如 ZEND_ASSIGN 对应的 handler 就有多个:

ZEND_ASSIGN_SPEC_VAR_CONST_RETVAL_UNUSED_HANDLER,
ZEND_ASSIGN_SPEC_VAR_CONST_RETVAL_USED_HANDLER,
ZEND_ASSIGN_SPEC_VAR_TMP_RETVAL_UNUSED_HANDLER,
ZEND_ASSIGN_SPEC_VAR_TMP_RETVAL_USED_HANDLER,
ZEND_ASSIGN_SPEC_VAR_VAR_RETVAL_UNUSED_HANDLER,
ZEND_ASSIGN_SPEC_VAR_VAR_RETVAL_USED_HANDLER,
ZEND_ASSIGN_SPEC_VAR_CV_RETVAL_UNUSED_HANDLER,
ZEND_ASSIGN_SPEC_VAR_CV_RETVAL_USED_HANDLER,
ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER,
ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_USED_HANDLER,
ZEND_ASSIGN_SPEC_CV_TMP_RETVAL_UNUSED_HANDLER,
ZEND_ASSIGN_SPEC_CV_TMP_RETVAL_USED_HANDLER,
ZEND_ASSIGN_SPEC_CV_VAR_RETVAL_UNUSED_HANDLER,
ZEND_ASSIGN_SPEC_CV_VAR_RETVAL_USED_HANDLER,
ZEND_ASSIGN_SPEC_CV_CV_RETVAL_UNUSED_HANDLER,
ZEND_ASSIGN_SPEC_CV_CV_RETVAL_USED_HANDLER,
bash

其函数命名遵循如下规则:

ZEND_[opcode]_SPEC_(操作数1类型)_(操作数2类型)_(返回值类型)_HANDLER
bash

举个例子,对于 PHP 代码:

$a = 1;
php

对应的 handler 为 ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER,其定义如下:

static  ZEND_OPCODE_HANDLER_RET  ZEND_FASTCALL  ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_
    UNUSED_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    USE_OPLINE

    zval *value;
    zval *variable_ptr;

    SAVE_OPLINE();
    //获取op2对应的值,也就是1
    value = EX_CONSTANT(opline->op2);
    //在execute_data中获取op1的位置,也就是$a
    variable_ptr = _get_zval_ptr_cv_undef_BP_VAR_W(execute_data, opline->op1.var);
    /*代码省略*/
    //将1赋值给$a
    value = zend_assign_to_variable(variable_ptr, value, IS_CONST);

    }
    /*代码省略*/
    ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
}
c

从代码中可以非常直观地看出,常量 1 是如何赋值给 CV 类型的 $a 的。

extended_value

extended_value 是存的扩展信息,opcode 和 CPU 的指令类似,有一个标示指令字段 handler,以及这个 opcode 所操作的操作数 op1 和 op2,但 PHP 不像汇编那么底层,在脚本实际执行的时候可能还需要其他更多的信息,extended_value 字段就保存了这类信息。

lineno

lineno 对应源代码文件中的行号。

到这里,相信读者对指令 opline 有了比较深刻的认识,在 Zend 虚拟机执行时,这些指令被组装在一起,成为指令集,下面我们介绍一下指令集。

指令集

在介绍指令集前,需要先介绍一个编译过程用到的基础结构体 znode,其结构如下:

typedef struct _znode { /* used only during compilation */
    zend_uchar op_type; //变量类型
    zend_uchar flag;
    union {
        //表示变量的位置
        znode_op op;
        //常量
        zval constant; /* replaced by literal/zv */
    } u;
} znode;
c

znode 只会在编译过程中使用,其中 op_type 对应的是变量的类型,u 是联合体,u.op 是操作数的类型,zval constant 用来存常量。znode 在后续生成指令集时会用到。

Zend 虚拟机中的指令集对应的结构为 zend_op_array,其结构如下:

struct _zend_op_array {
    /* Common elements */
    /*代码省略common是为了函数能够快速访问Opcodes而设定的*/
    /* END of common elements */
    //这部分是存放opline的数组,last为总个数
    uint32_t last;
    zend_op *opcodes;

    int last_var; //变量类型为IS_CV的个数
    uint32_t T; //变量类型为IS_VAR和IS_TMP_VAR的个数
    zend_string **vars; //存放IS_CV类型变量的数组
    /*代码省略*/
    /* static variables support */
    HashTable *static_variables; //静态变量
    /*代码省略*/
    int last_literal; //常量的个数
    zval *literals; //常量数组

    int  cache_size; //运行时缓存数组大小
    void **run_time_cache; //运行时缓存

    void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};
c

其结构如图11-8所示。

image 2024 06 10 19 17 33 827
Figure 4. 图11-8 zend_op_array结构

这个结构体中有几个关键变量。

  1. last 和 opcodes 是存放 opline 的数组,也就是指令集存放的位置,其中 last 为数组中 opline 的个数。

  2. last_var 代表 IS_CV 类型变量的个数,这种类型变量存放在 vars 数组中;在整个编译过程中,每次遇到一个 IS_CV 类型的变量(类似于 $something),就会去遍历 vars 数组,检查是否已经存在。如果不存在,则插入 vars 中,并将 last_var 的值设置为该变量的操作数;如果存在,则使用之前分配的操作数。代码如下:

    result->op_type = IS_CV;
    result->u.op.var = lookup_cv(CG(active_op_array), name);
    
    //lookup_cv:
    static int lookup_cv(zend_op_array *op_array, zend_string* name) /* {{{ */{
        int i = 0;
        zend_ulong hash_value = zend_string_hash_val(name);
    
        //遍历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);
                    return (int)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, i);
            }
            i++;
        }
        //否则插入到vars中,并将last_var的值设置为该变量的操作数
        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*));
        }
    
        op_array->vars[i] = zend_new_interned_string(name);
        return (int)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, i);
    }
    c
  3. T 为 IS_VAR 和 IS_TMP_VAR 类型变量的总数,编译时遇到这种类型,T 就会加 1,用于后续在执行栈上分配空间。

  4. static_variables 是用于存放静态变量的 HashTable。

  5. literals 是用于存放常量(IS_CONST)类型的数组,last_literal 为常量的个数。

  6. run_time_cache 用于运行时缓存的操作,本书不展开讨论。

执行数据

Zend 在栈上执行的数据为 zend_execute_data,其结构体如下:

struct _zend_execute_data {
    const zend_op       *opline;            /* 要执行的指令 */
    zend_execute_data   *call;              /* current call*/
    zval                 *return_value;     /* 返回值 */
    zend_function       *func;              /* 执行函数 */
    zval                  This;              /* this + call_info + num_args */
    zend_execute_data   *prev_execute_data;
    zend_array          *symbol_table;      /*符号表*/
    void                **run_time_cache;  /* 执行时缓存 */
    zval                 *literals;         /* 缓存常量 */
};
c

下面我们介绍一下各字段。

  1. opline:对应的是 zend_op_array 中 opcodes 数组里面的 zend_op,表示正在执行的 opline。

  2. prev_execute_data: op_array 上下文切换的时候,这个字段用来保存切换前的 op_array,此字段非常重要,能将每个 op_array 的 execute_data 按照调用的先后顺序连接成一个单链表,每当一个 op_array 执行结束要还原到调用前 op_array 的时候,就通过当前的 execute_data 中的 prev_execute_data 字段来得到调用前的执行器数据。

  3. symbol_table:当前使用的符号表,一般会取 EG(symbol_table)。

  4. literals:常量数组,用来缓存常量。

zend_execute_data 是在执行栈上运行的关键数据,可以用 EX 宏来获取其中的值,代码如下:

#define EX(element) ((execute_data)->element)
c

执行栈

Zend 虚拟机中有一个类似函数调用栈的结构体——_zend_vm_stack。EG 里面的 vm_stack 也是这种类型的。其定义如下:

struct _zend_vm_stack {
    zval *top; //栈顶位置
    zval *end; //栈底位置
    zend_vm_stack prev;
};
typedef struct _zend_vm_stack *zend_vm_stack;
c

可以看出,栈的结构比较简单,有 3 个变量,其中 top 指向栈顶,end 指向栈底,pre 是指向上一个栈的指针,也就意味着所有栈在一个单向链表上。

在执行 PHP 代码时,参数的压栈操作以及出栈调用执行函数都是在栈上进行的,下面介绍栈操作的核心函数。

初始化

初始化调用的函数为 zend_vm_stack_init,主要用于内存申请,以及对 zend_vm_stack 成员变量进行初始化,代码如下:

ZEND_API void zend_vm_stack_init(void)
{
    EG(vm_stack) = zend_vm_stack_new_page(ZEND_VM_STACK_PAGE_SIZE(0 /* main stack */),
        NULL);
    EG(vm_stack)->top++;
    EG(vm_stack_top) = EG(vm_stack)->top;
    EG(vm_stack_end) = EG(vm_stack)->end;
}
c

该函数调首先调用 zend_vm_stack_new_page 为 EG(vm_stack) 申请内存,申请的大小为 16× 1024× zeof(zval),代码如下:

static zend_always_inline zend_vm_stack zend_vm_stack_new_page(size_t size, zend_
    vm_stack prev) {
    zend_vm_stack page = (zend_vm_stack)emalloc(size);
    page->top = ZEND_VM_STACK_ELEMENTS(page);
    page->end = (zval*)((char*)page + size);
    page->prev = prev;
    return page;
}
c

然后将 zend_vm_stack 的 top 指向 zend_vm_stack 的结束位置,其中 zend_vm_stack 占用 24 字节,end 指向申请内存的最尾部,prev 指向 null,如图11-9所示。

image 2024 06 10 19 25 16 369
Figure 5. 图11-9 zend_vm_stack初始化后示意图

可以看出,多个 vm_stack 构成单链表,将多个栈连接起来,栈初始为 16× 1024 个 zval 的大小,栈顶部占用了一个 *zval 指针的大小,以及一个结构体 _zend_vm_stack 的大小。

入栈操作

调用的函数为 zend_vm_stack_push_call_frame,代码如下:

static zend_always_inline zend_execute_data *zend_vm_stack_push_call_frame(uint32_
    t call_info, zend_function *func, uint32_t num_args, zend_class_entry *called_
    scope, zend_object *object)
{
    uint32_t used_stack = zend_vm_calc_used_stack(num_args, func);

    return zend_vm_stack_push_call_frame_ex(used_stack, call_info,
        func, num_args, called_scope, object);
}
c

该函数会分配一块用于当前作用域的内存空间,并返回 zend_execute_data 的起始位置。首先调用 zend_vm_calc_used_stack 计算栈需要的空间,代码如下:

static  zend_always_inline  uint32_t  zend_vm_calc_used_stack(uint32_t  num_args,
    zend_function *func)
{
    uint32_t used_stack = ZEND_CALL_FRAME_SLOT + num_args;

    if (EXPECTED(ZEND_USER_CODE(func->type))) {
        used_stack += func->op_array.last_var + func->op_array.T - MIN(func->op_
            array.num_args, num_args);
    }
    return used_stack * sizeof(zval);
}
c

这段代码按照 zval 的大小对齐,我们知道 zval 为 16 字节,而 zend_execute_data 的大小为 80 字节,那么其对应 5 个 zval;同时对应 IS_CV 类型变量的个数(last_var)以及变量类型为 IS_VAR 和 IS_TMP_VAR 的个数(T),如图11-10所示。

image 2024 06 10 19 28 06 665
Figure 6. 图11-10 压栈过程

至此,我们了解了 Zend 虚拟机中符号表、指令集、执行数据以及执行栈相关的数据结构,下面我们基于这些基本知识来介绍一下指令集生成的过程。