相关数据结构
Zend
虚拟机包含词法和语法分析、AST
的编译,以及 opcode
的执行,本章主要详细介绍 AST
和 opcode
的执行过程,下面先介绍一下用到的基本数据结构,为后面原理性内容的介绍奠定基础。
EG(v)
首先介绍的是全局变量 executor_globals
, EG(v)
是对应的取值宏,executor_globals
对应的是结构体 _zend_executor_globals
,它是 PHP 生命周期中非常核心的数据结构。这个结构体维护了符号表(symbol_table
、function_table
、class_table
等)、执行栈(zend_vm_stack
)以及包含执行指令的 zend_execute_data
,另外还包含了 include
的文件列表、autoload
函数、异常处理 handler
等重要信息,下面给出 _zend_executor_globals
的结构图,然后分别阐述其含义,如图11-5所示。

这个结构体比较复杂,下面介绍几个核心的成员。
-
symbol_table
:符号表,主要存放全局变量,以及一些魔术变量,如$_GET
、$_POST
等。 -
function_table
:函数表,主要存放函数,包括大量的内部函数以及用户自定义的函数,如zend_version
、func_num_args
、str
系列函数等。 -
class_table
:类表,主要存放内置的类以及用户自定义的类,如stdclass
、throwable
、exception
等类。 -
zend_constants
:常量表,存放 PHP 中的常量,如E_ERROR
、E_WARNING
等。 -
vm_stack
:虚拟机的栈,执行时压栈、出栈都在这里操作。 -
current_execute_data
:对应_zend_execute_data
结构体,存放执行时的数据。
符号表
在 PHP 7 中,符号表分为 symbol_table
、function_table
和 class_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所示。

从图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_version
、func_num_args
、cli_get_process_title
等。
指令
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所示。

PHP 代码会被编译成一条一条的 opline
,分解为最基本的操作。举个例子,如果把 opcode
当成一个计算器,只接受两个操作数 op1
和 op2
,执行一个操作 handler
,比如加、减、乘、除,然后它返回一个结果 result
,再稍加处理算术溢出的情况,存于 extended_value
中。下面详细介绍下各个字段。
opcode
opcode
有时候被称为所谓的字节码(bytecode),是被软件解释器解释执行的指令集。这些软件指令集通常会提供一些比对应的硬件指令集更高级的数据类型和操作。
|
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
操作数
op1
和 op2
都是操作数,但不一定全部使用,也就是说,每个 opcode
对应的 hanlder
最多可以使用两个操作数(也可以只使用其中一个,或者都不使用)。每个操作数都可以理解为函数的参数,返回值 result
是 hanlder
函数对操作数 op1
和 op2
计算后的结果。op1
、op2
和 result
对应的类型都是 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
这些类型是按位表示的,具体含义如下。
-
IS_CONST
:值为 1,表示一个常量,都是只读的,值不可改变,如$a="hello world"
中的hello world
。 -
IS_VAR
:值为 4,是 PHP 变量,这个变量并不是 PHP 代码中声明的变量,常见的是返回的临时变量,如$a=time()
,函数time
返回值的类型就是IS_VAR
,这种类型的变量是可以被其他Opcode
对应的handler
重复使用的。 -
IS_TMP_VAR
:值为 2,也是 PHP 变量,跟IS_VAR
不同之处是,不能与其他opcode
重复使用。举个例子,$a=“123”.time()
;这里拼接的临时变量“123”.time()
的类型就是IS_TMP_VAR
,一般用于操作的中间结果。 -
IS_UNUSED
:值为 8,表示这个操作数没有包含任何有意义的东西。 -
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 的。
指令集
在介绍指令集前,需要先介绍一个编译过程用到的基础结构体 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所示。

这个结构体中有几个关键变量。
-
last 和 opcodes 是存放 opline 的数组,也就是指令集存放的位置,其中 last 为数组中 opline 的个数。
-
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 -
T 为 IS_VAR 和 IS_TMP_VAR 类型变量的总数,编译时遇到这种类型,T 就会加 1,用于后续在执行栈上分配空间。
-
static_variables 是用于存放静态变量的 HashTable。
-
literals 是用于存放常量(IS_CONST)类型的数组,last_literal 为常量的个数。
-
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
下面我们介绍一下各字段。
-
opline:对应的是 zend_op_array 中 opcodes 数组里面的 zend_op,表示正在执行的 opline。
-
prev_execute_data: op_array 上下文切换的时候,这个字段用来保存切换前的 op_array,此字段非常重要,能将每个 op_array 的 execute_data 按照调用的先后顺序连接成一个单链表,每当一个 op_array 执行结束要还原到调用前 op_array 的时候,就通过当前的 execute_data 中的 prev_execute_data 字段来得到调用前的执行器数据。
-
symbol_table:当前使用的符号表,一般会取 EG(symbol_table)。
-
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所示。

可以看出,多个 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所示。

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