baiyan
全部视频:https://segmentfault.com/a/11…
原视频地址:http://replay.xesv5.com/ll/24…
复习
基本概念
-
首先复习几个基本概念:
opline:在 zend 虚拟机中,每条指令都是一个 opline,每个 opline 由操作数、指令操作、返回值组成
opcode:每个指令操作都对应一个opcode(如 ZEND_ASSIGN/ZEND_ADD 等等),在 PHP7 中,有 100 多种指令操作,所有的指令集被称作 opcodes
handler:每个 opcode 指令操作都对应一个handler 指令处理函数,处理函数中有具体的指令操作执行逻辑 - 我们知道,在经过编译阶段(zend_compile 函数)中,我们生成 AST 并对其遍历,生成一条条指令,每一条指令都是一个 opline。之后通过 pass_two 函数生成了这些指令所对应的 handler,这些信息均存在 op_array 中。既然指令和 handler 已经生成完毕,接下来的任务就是要交给 zend 虚拟机,加载这些指令,并最终执行对应的 handler 逻辑。
- 指令在 PHP7 中,由以下元素构成:
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; // 返回值的类型
};
- 在 PHP7 中,每个操作数有 5 种类型可选,如下:
#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 */
IS_CONST 类型:值为 1,表示常量,如 $a = 1 中的 1 或者 $a = “hello world” 中的 hello world
IS_TMP_VAR 类型:值为 2,表示临时变量,如 $a=”123”.time(); 这里拼接的临时变量”123”.time()的类型就是 IS_TMP_VAR,一般用于操作的中间结果
IS_VAR 类型:值为 4,表示变量,但是这个变量并不是 PHP 中常见的声明变量,而是返回的临时变量,如 $a = time() 中的 time()
IS_UNUSED:值为 8,表示没有使用的操作数
IS_CV:值为 16,表示形如 $a 这样的变量
- 对 AST 进行遍历之后,最终存放所有指令集(oplines)的地方为 op_array:
struct _zend_op_array {
uint32_t last; // 下面 oplines 数组大小
zend_op *opcodes; //oplines 数组,存放所有指令
int last_var;// 操作数类型为 IS_CV 的个数
uint32_t T;// 操作数类型为 IS_VAR 和 IS_TMP_VAR 的个数之和
zend_string **vars;// 存放 IS_CV 类型操作数的数组
...
int last_literal;// 下面常量数组大小
zval *literals;// 存放 IS_CONST 类型操作数的数组
};
op_array 的存储情况
- 为了复习 op_array 的存储情况,我们具体 gdb 一下,使用下面的测试用例:
<?php
$a = 2;
- 根据以上测试用例,在 zend_execute 处打一个断点,这里完成了对 AST 的遍历并生成了最终的 op_array,已经进入到虚拟机执行指令的入口。首先我们先观察传入的参数 op_array,它是经过 AST 遍历之后生成的最终的 op_array:
- last = 2; 表示一共有两个 opcodes:一个是赋值 ASSIGN,另一个是脚本为我们自动生成的返回语句 return 1,opcodes 是一个数组,每个数组单元具体存储了每条指令的信息(操作数、返回值等等),我们打印一下数组的内容:
- last_var = 1; 表示有一个 CV 类型的变量,这里就是 $a
- T = 1; 表示 IS_TMP_VAR 和 IS_VAR 变量类型的数量之和,而我们脚本中并没有这样的变量,它是在存储中间的返回值的时候,这个返回值类型就是一个 IS_VAR 类型,所以 T 的值一开始就为 1
- vars 是一个二级指针,可以理解为外层的一级指针首先指向一个数组,这个数组里每个存储单元都是一个 zend_string* 类型的指针,而每个指针都指向了一个 zend_string 结构体,我们打印数组第一个单元的值,发现其指向的 zend_string 值为 a:
- last_literal = 2; 表示脚本中一共有 2 个常量,一个是我们自己复制的值 2,另一个是脚本为我们自动生成的返回语句 return 1 中的值 1:
- literals 是一个 zend_array,里面每一个单元都是一个 zval,存储这些常量的实际的值,我们可以看到,其值为 2 和 1,与上面的描述相符:
- 我们可以画出最终的 op_array 存储结构图:
- 这样一来,我们就可以清晰地看出指令在 op_array 中是如何存储的。那么接下来,我们需要将其加载到虚拟机的执行栈桢上,来最终执行这些指令。
在虚拟机上执行指令
- 下面让我们真正执行 op_array 中的指令,执行指令的入口为 zend_execute 函数,传入参数为 op_array 以及一个 zval 指针:
ZEND_API void zend_execute(zend_op_array *op_array, zval *return_value)
{
zend_execute_data *execute_data;
if (EG(exception) != NULL) {return;}
execute_data = zend_vm_stack_push_call_frame(ZEND_CALL_TOP_CODE | ZEND_CALL_HAS_SYMBOL_TABLE,
(zend_function*)op_array, 0, zend_get_called_scope(EG(current_execute_data)), zend_get_this_object(EG(current_execute_data)));
if (EG(current_execute_data)) {execute_data->symbol_table = zend_rebuild_symbol_table();
} else {execute_data->symbol_table = &EG(symbol_table);
}
EX(prev_execute_data) = EG(current_execute_data);
i_init_code_execute_data(execute_data, op_array, return_value);
zend_execute_ex(execute_data);
zend_vm_stack_free_call_frame(execute_data);
}
- 观察第一行,声明了一个 zend_execute_data 类型的指针,这个类型非常重要,存储了虚拟机执行指令时的基本信息:
struct _zend_execute_data {
const zend_op *opline; // 当前执行的指令 8B
zend_execute_data *call; // 指向自己的指针 8B
zval *return_value; // 存储返回值 8B
zend_function *func; // 执行的函数 8B
zval This; /* this + call_info + num_args 16B */
zend_execute_data *prev_execute_data; // 链表,指向前一个 zend_execute_data 8B
zend_array *symbol_table; // 符号表 8B
#if ZEND_EX_USE_RUN_TIME_CACHE
void **run_time_cache; /* cache op_array->run_time_cache 8B*/
#endif
#if ZEND_EX_USE_LITERALS
zval *literals; /* cache op_array->literals 8B */
#endif
};
- 可以看到,这个 zend_execute_data 一共是 80 个字节
- 随后执行 zend_vm_stack_push_call_frame(ZEND_CALL_TOP_CODE | ZEND_CALL_HAS_SYMBOL_TABLE,(zend_function*)op_array, 0, zend_get_called_scope(EG(current_execute_data)), zend_get_this_object(EG(current_execute_data))); 这个函数,我们 s 进去看下:
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);
}
- 先不看复杂的函数参数,直接看 zend_vm_calc_used_stack(num_args, func); 这个函数调用,它用来计算虚拟机在执行栈桢上所用的空间,此时应该没有占用任何空间,我们打印一下 used_stack:
- 发现这里的 used_stack 果然是 0,然后进入下一个 if 中,继续执行 used_stack += func->op_array.last_var + func->op_array.T – MIN(func->op_array.num_args, num_args); 这个与函数相关,我们还没有讲,那么我们直接看这个函数外层返回的 used_stack 值,为 112B:
- 那么继续往下执行 zend_vm_stack_push_call_frame_ex(used_stack, call_info,func, num_args, called_scope, object):
static zend_always_inline zend_execute_data *zend_vm_stack_push_call_frame_ex(uint32_t used_stack, uint32_t call_info, zend_function *func, uint32_t num_args, zend_class_entry *called_scope, zend_object *object)
{zend_execute_data *call = (zend_execute_data*)EG(vm_stack_top);
ZEND_ASSERT_VM_STACK_GLOBAL;
if (UNEXPECTED(used_stack > (size_t)(((char*)EG(vm_stack_end)) - (char*)call))) {call = (zend_execute_data*)zend_vm_stack_extend(used_stack);
ZEND_ASSERT_VM_STACK_GLOBAL;
zend_vm_init_call_frame(call, call_info | ZEND_CALL_ALLOCATED, func, num_args, called_scope, object);
return call;
} else {EG(vm_stack_top) = (zval*)((char*)call + used_stack);
zend_vm_init_call_frame(call, call_info, func, num_args, called_scope, object);
return call;
}
}
- 同样忽略复杂的函数参数,只关注传入的 used_stack = 112 即可。我们首先看第一行:把 executor_globals 中的 vm_stack_top 字段赋值给当前的 zend_execute_data 指向自己的指针,说明 zend_execute_data 的起始地址为 EG 这个宏的返回值,查看这个值:
- 可以看到,zend_execute_data 的起始地址为 0x7ffff5e1c030,继续往下执行代码:
- 下面的 if 是用来判断栈上是否有足够的空间,如果已经使用的栈空间太多,那么需要重新分配栈空间,显然我们这里没有进这个 if,说明栈空间还是够的,那么执行下面的 else。重点在于:
EG(vm_stack_top) = (zval*)((char*)call + used_stack);
- 现在这个栈顶的位置变成了 0x7ffff5e1c0a0,也就是 0x7ffff5e1c030 + 112 的结果。至于指针加法步长的运算,本质上就是 地址 a + 步长 * sizeof(地址类型)(地址类型如果是 char *,步长就是 1;如果是 Int *,步长就是 4),举例子:
int *p;
p+3;
- 假如 p 的地址是 0x7ffff5e1c030,那么 p + 3 的结果就应该是 0x7ffff5e1c030 + 3 * sizeof(int) = 0x7ffff5e1c03c
- 我们画出此时栈上的结构图:
- 此时这个返回值 call 就是栈顶的位置,但是 top 指针并不指向栈顶,而是指向栈的中间:
- 接下来回到最外层的 zend_execute 函数,继续往下执行:
- 可以看到,接下来将符号表中的内容赋值给了 execute_data 中的 symbol_table 字段,这个符号表是一个 zend_array,此时还只有几个默认的_GET 这几个预先添加的符号,并没有我们自己的 $a:
- 那么我们继续往下走,关注 i_init_code_execute_data()函数:
static zend_always_inline void i_init_code_execute_data(zend_execute_data *execute_data, zend_op_array *op_array, zval *return_value) /* {{{ */
{ZEND_ASSERT(EX(func) == (zend_function*)op_array);
EX(opline) = op_array->opcodes;
EX(call) = NULL;
EX(return_value) = return_value;
zend_attach_symbol_table(execute_data);
if (!op_array->run_time_cache) {op_array->run_time_cache = emalloc(op_array->cache_size);
memset(op_array->run_time_cache, 0, op_array->cache_size);
}
EX_LOAD_RUN_TIME_CACHE(op_array);
EX_LOAD_LITERALS(op_array);
EG(current_execute_data) = execute_data;
}
- 这里的 EX 宏对应全局变量 execute_data,EG 宏对应全局变量 executor_globals,要区分开
- 重点关注 zend_attach_symbol_table(execute_data)函数:
ZEND_API void zend_attach_symbol_table(zend_execute_data *execute_data) /* {{{ */
{
zend_op_array *op_array = &execute_data->func->op_array;
HashTable *ht = execute_data->symbol_table;
/* copy real values from symbol table into CV slots and create
INDIRECT references to CV in symbol table */
// 从符号表中拷贝真实的值到 CV 槽中,并且创建对符号表中 CV 变量的间接引用
if (EXPECTED(op_array->last_var)) {
zend_string **str = op_array->vars;
zend_string **end = str + op_array->last_var;
zval *var = EX_VAR_NUM(0);
do {zval *zv = zend_hash_find(ht, *str);
if (zv) {if (Z_TYPE_P(zv) == IS_INDIRECT) {zval *val = Z_INDIRECT_P(zv);
ZVAL_COPY_VALUE(var, val);
} else {ZVAL_COPY_VALUE(var, zv);
}
} else {ZVAL_UNDEF(var);
zv = zend_hash_add_new(ht, *str, var);
}
ZVAL_INDIRECT(zv, var);
str++;
var++;
} while (str != end);
}
}
- 我们此时的符号表只包含_GET 这类默认初始化的变量,并不包含我们自己的 $a。首先进入 if,因为 last_var = 1($a),所以将 str 和 end 赋值,他们分别指向 vars 和 vars 后面 1 偏移量的位置,如图:
- 接下来在符号表 ht 中遍历,查找是否有 $a 这个 CV 型变量,现在肯定是没有的,所以进入 else 分支,执行 ZVAL_UNDEF(var)与 zv = zend_hash_add_new(ht, *str, var);
- 上面 EX_VAR_NUM(0)这个宏是一个申请一个 CV 槽大小的空间,但是在这里我们没有使用,所以 ZVAL_UNDEF(var)将这个槽中的 zval 类型置为 IS_UNDEF 类型,然后通过 zend_hash_add_new 将 $a 加入到符号表这个 zend_array 中。那么如果下一次再引用 $a 的时候,就会走上面的 if 分支,这样 CV 槽就有了用武之地。把 $a 拷贝到 CV 槽中,那么在符号表中通过间接引用找到它即可,就不用多次将其加入到符号表中,节省时间与空间。最后将 str 与 var 指针的位置往后挪,说明本次遍历完成
- 回到 i_init_code_execute_data 函数,下面几行是用来操作运行时缓存的代码,我们暂时跳过,回到 zend_execute 主函数,接下来会调用 zend_execute()函数,在这里真正执行指令所对应的 handler 逻辑:
- 赋值操作对应的是 ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER,我们看看这个 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();
// 从 literals 数组中获取 op2 对应的值,也就是值 2
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);
...
}
- 这样,一个赋值指令就被虚拟机执行完毕,那么还有一个 return 1 默认的脚本返回值的指令,也是同理,这里不再展开,那么最终的虚拟机执行栈桢的情况如下:
- 回到 zend_execute 主函数,最后调用了 zend_vm_stack_free_call_frame(execute_data)函数,最终释放虚拟机占用的栈空间,完毕。
参考资料
- 【PHP7 源码分析】PHP7 源码研究之浅谈 Zend 虚拟机
- 【PHP7 源码分析】如何理解 PHP 虚拟机(一)