专栏首页Web技术布道师(2)PHP内核 - 玩转php的编译与执行

(2)PHP内核 - 玩转php的编译与执行

0X03 抽象语法树AST

通用的普通节点为:

struct _zend_ast {
    zend_ast_kind kind; /* 节点类型*/
    zend_ast_attr attr; /* 附加属性 */
    uint32_t lineno;    /* 行号 */
    zend_ast *child[1]; /* 子节点 */
};

注意这个的child[1],并不是表示是一个节点,类似于zval_string里面的val[1],节点地址连续分配在zend_ast结构末尾。根据 kind 类型转换为其他类型节点,具体的类型和对应的结构在/Zend/zend_ast.h里面定义。常用的下面两个节点类型

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;
 
/* Lineno is stored in val.u2.lineno */
typedef struct _zend_ast_zval {
    zend_ast_kind kind;
    zend_ast_attr attr;
    zval val; /*节点zval值*/
} zend_ast_zval;

抽象语法的节点类型,也没什么特别的。前面也说提到过整个抽象语法树根节点zend_ast_stmt_list定义在CG(ast),中,CG是个访问编译全局变量的宏。有的同学可能会想看看既然是抽象语法树,肯定想看一看它在视图上是怎么呈现的,有办法。这里分享一个将php-parser处理过得到的抽象语法树可视化的东西。 https://github.com/ircmaxell/php-ast-visualizer 原本想自己写个扩展来动态显示抽象语法树,意外看到这个工具其实也没什么必要了。抽象语法数的建立是php静态分析里面重要的一环。

0x04 抽象语法树2Oplines

接下来就是如何将抽象语法数如何编译成我们期待已久的opline。这也是解释型语言和静态编译型语言不同的一点,编译出来的不是汇编语言,而是ZendVM可以识别的中间指令。前面也简单解释了一遍opline,一条opline和汇编语言类似,指令标识符opcode,操作数1和操作2。编译抽象语法树发生在yacc的 zendparse()结束之后,同样在zend_compile里面:

if (!zendparse()) {
        int last_lineno = CG(zend_lineno);
        zend_file_context original_file_context;
        zend_oparray_context original_oparray_context;
        zend_op_array *original_active_op_array = CG(active_op_array);
 
        op_array = emalloc(sizeof(zend_op_array));  //内存分配
        init_op_array(op_array, type, INITIAL_OP_ARRAY_SIZE);
        CG(active_op_array) = op_array;
 
        /* Use heap to not waste arena memory */
        op_array->fn_flags |= ZEND_ACC_HEAP_RT_CACHE;
        if (zend_ast_process) {
            zend_ast_process(CG(ast));
        }
        zend_file_context_begin(&original_file_context);
        zend_oparray_context_begin(&original_oparray_context);
        zend_compile_top_stmt(CG(ast));
        CG(zend_lineno) = last_lineno;
        zend_emit_final_return(type == ZEND_USER_FUNCTION);
        op_array->line_start = 1;
        op_array->line_end = last_lineno;
        pass_two(op_array);
        zend_oparray_context_end(&original_oparray_context);
        zend_file_context_end(&original_file_context);
 
        CG(active_op_array) = original_active_op_array;
    }

开始正常的流程的,给op_array 分配内存,初始化,让CG(active_op_array)指向当前的op_array,zend_ast_process是个扩展的hook点,如果你想要对抽象语法树做一些自定义的东西,比如我先前把ast输出,就可以在此处做文章。

最主要的还是来看看是如何遍历抽象语法节点一步一步来编译成opcode,进入zend_compile_top_stmt

void zend_compile_top_stmt(zend_ast *ast) /* {{{ */
{
    if (!ast) {
        return;
    }
 
    if (ast->kind == ZEND_AST_STMT_LIST) {
        zend_ast_list *list = zend_ast_get_list(ast);
        uint32_t i;
        for (i = 0; i < list->children; ++i) {
            zend_compile_top_stmt(list->child[i]);
        }
        return;
    }
    ...

判断节点如果为ZEND_AST_STMT_LIST,则再递归编译子节点,前面说过ZEND_AST_STMT_LIST是一种什么也不做的列表节点,主要就是起到连接的作用,整个抽象语法树的根节点也是这个类型。

if (ast->kind == ZEND_AST_FUNC_DECL) {  //函数
        CG(zend_lineno) = ast->lineno;
        zend_compile_func_decl(NULL, ast, 1);
        CG(zend_lineno) = ((zend_ast_decl *) ast)->end_lineno;
    } else if (ast->kind == ZEND_AST_CLASS) { //类
        CG(zend_lineno) = ast->lineno;
        zend_compile_class_decl(ast, 1);
        CG(zend_lineno) = ((zend_ast_decl *) ast)->end_lineno;
    } else {
        zend_compile_stmt(ast);
    }
    if (ast->kind != ZEND_AST_NAMESPACE && ast->kind != ZEND_AST_HALT_COMPILER) {
        zend_verify_namespace();
    }
    ...

三种处理方式,函数定义节点,类的定义节点,其他节点。这里我们先不深究函数和类的定义节点编译,先来看其他节点的编译。

void zend_compile_stmt(zend_ast *ast) /* {{{ */
{
    if (!ast) {
        return;
    }
 
    CG(zend_lineno) = ast->lineno;
 
    if ((CG(compiler_options) & ZEND_COMPILE_EXTENDED_INFO) && !zend_is_unticked_stmt(ast)) {
        zend_do_extended_info();
    }
    switch (ast->kind) {//类型选择
        case ZEND_AST_STMT_LIST:
            zend_compile_stmt_list(ast);
            break;
        case ZEND_AST_GLOBAL:
            zend_compile_global_var(ast);
            break;f
        case ZEND_AST_STATIC:
            zend_compile_static_var(ast);
            break;
        case ZEND_AST_UNSET:
            zend_compile_unset(ast);
            break;
        case ZEND_AST_RETURN:
            zend_compile_return(ast);
            break;
        case ZEND_AST_ECHO:
            zend_compile_echo(ast);
...

再根据节点类型,再进行不同的编译方法,关于switch语句里面的选择项,可以看看去语法分析中top_statement结构里面包含的类型,在这里其实一一对应的。这里有很多编译分支,不能一一讲到,这里分析一下ZEND_AST_ECHO节点的编译。

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);
 
    opline = zend_emit_op(NULL, ZEND_ECHO, &expr_node, NULL);
    opline->extended_value = 0;
}

再分析之前,先要熟悉echo的语法结构,心里要有个大概的echo结构的分支走向。

T_ECHO echo_expr_list ';' { $$ = $2}
echo_expr_list:
        echo_expr_list ',' echo_expr { $$ = zend_ast_list_add($1, $3); }
    |    echo_expr { $$ = zend_ast_create_list(1, ZEND_AST_STMT_LIST, $1); }
echo_expr:
    expr { $$ = zend_ast_create(ZEND_AST_ECHO, $1); }
;

比如echo 1 , 2 会在语法分析就会给它分开,分成T_ECHO 1和T_ECHO 2都在同一个ZEND_AST_STMT_LIST同一个节点下,所以在编译处理echo语法的时候,echo后面都只有一个表达式。即需要去编译这个表达式成为ZEND_ECHO 的第一个操作数。这里需要说一下,znode 这个类型并不是opline里面定义操作数会用到的类型,只是在编译阶段会用到,最后被会转换到定义opline的zend_op结构中相对应操作数的字段。

再看一看编译表达式expr的过程

void zend_compile_expr(znode *result, zend_ast *ast) /* {{{ */
{
    /* CG(zend_lineno) = ast->lineno; */
    CG(zend_lineno) = zend_ast_get_lineno(ast);
    switch (ast->kind) {
        case ZEND_AST_ZVAL:
            ZVAL_COPY(&result->u.constant, zend_ast_get_zval(ast));
            result->op_type = IS_CONST;
            return;
        case ZEND_AST_ZNODE:
            *result = *zend_ast_get_znode(ast);
            return;
        case ZEND_AST_VAR:
        case ZEND_AST_DIM:
        case ZEND_AST_PROP:
        case ZEND_AST_STATIC_PROP:
        case ZEND_AST_CALL:
        case ZEND_AST_METHOD_CALL:
        case ZEND_AST_STATIC_CALL:
            zend_compile_var(result, ast, BP_VAR_R);
            return;
        case ZEND_AST_ASSIGN:
            zend_compile_assign(result, ast);
            return;
        case ZEND_AST_ASSIGN_REF:
            zend_compile_assign_ref(result, ast);

在通过遍历expr下的子节点最后会返回一个最终的expr,这个expr可能最终是个常量,也可能是经过复杂运算之后的临时变量。比如switch 第一个case 这里取的就是比如包含单引号包裹的字符串,整形,浮点型这些简单常量的zval_ast_zval节点,然后把常量对应的zval赋值给znode.u.constant,如何定义该操作数为常量类型。再来看一个比如expr是 $a //ZEND_AST_VAR这样php变量的编译过程。

void zend_compile_var(znode *result, zend_ast *ast, uint32_t type) /* {{{ */
{
    CG(zend_lineno) = zend_ast_get_lineno(ast);
    switch (ast->kind) {
        case ZEND_AST_VAR:
            zend_compile_simple_var(result, ast, type, 0);
            return;
        case ZEND_AST_DIM:
    ...
}
 
static void zend_compile_simple_var(znode *result, zend_ast *ast, uint32_t type, int delayed) /* {{{ */
{
    if (is_this_fetch(ast)) {
        zend_op *opline = zend_emit_op(result, ZEND_FETCH_THIS, NULL, NULL);
        if ((type == BP_VAR_R) || (type == BP_VAR_IS)) {
            opline->result_type = IS_TMP_VAR;
            result->op_type = IS_TMP_VAR;
        }
    } else if (zend_try_compile_cv(result, ast) == FAILURE) {
        zend_compile_simple_var_no_cv(result, ast, type, delayed);
    }
}

is_this_fetch是用来判断是不是特殊变量this,这不是我们要走的分支,php的变量应该为CV变量。看第一个函数zend_try_compile_cv

static int zend_try_compile_cv(znode *result, zend_ast *ast) /* {{{ */
{
    zend_ast *name_ast = ast->child[0];
    if (name_ast->kind == ZEND_AST_ZVAL) {
        zval *zv = zend_ast_get_zval(name_ast);
        zend_string *name;
 
        if (EXPECTED(Z_TYPE_P(zv) == IS_STRING)) {
            name = zval_make_interned_string(zv);
        } else {
            name = zend_new_interned_string(zval_get_string_func(zv));
        }
 
        if (zend_is_auto_global(name)) {
            return FAILURE;
        }
 
        result->op_type = IS_CV;
        result->u.op.var = lookup_cv(CG(active_op_array), name);

判断是不是ZEND_AST_ZVAL节点,然后取节点中的CV变量名,判断是不是auto_global变量,如果是直接返回。接着进入CV变量的逻辑,操作类型指定为IS_CV。前面已经介绍过了操作数的值是按偏移量来存储的。CV变量名依次储存在zend_op_array中的vars数组中,lookup_cv的作用就是遍历vars数组,并根据该CV变量名出现在vars数组中的位置,计算返回偏移量。如果改CV变量名并不在vars中,就会添加到其中。vars数组中是不存在重复的CV变量名的。列如改CV变量名出现在var[0],则其偏移值地址为(sizeof(zend_execute_data)+15)/16*16+0*16,在这里为80,前面说了本文zend_execute_data大小为72。并通过zend_execute_>关于操作数类型的编译。上面讲了CV类型操作数的编译过程,同时还有CONST字面量类型,这里需要注意的是,这里CONST常量的存储并不是指像C语言那样在编译过程把源代码中的显式常量都存储在同一个常量段里。举个例子:

<?php
echo  "hello"."maple";

在这里有的同学会认为这里op_array->last_literal == 3, echo语句里面"hello","maple",还包括在编译过程中会自动添加的opline RETURN 1中的这个1,其实我刚开始的时候也有这样的困惑。在这里你需要先想一想CONST类型的操作数个数是在哪增长的?

#define SET_NODE(target, src) do { \
        target ## _type = (src)->op_type; \
        if ((src)->op_type == IS_CONST) { \
            target.constant = zend_add_literal(CG(active_op_array), &(src)->u.constant); \
        }
        ...

在SET_NODE这个宏里判断操作数类型是不是CONST类型,与此同时决定是否将其添加到op_array->literals常量数组里面,其实这里就是将编译过程的中间量 znode内容转换到zend_op里面,然后将这条zend_op 添加到 op_array->opcodes数组里面。所以在这里你可以认为在最终确定形成一条opline的时候,才会去判断操作数是不是CONST类型,并将其添加到字面量数组。在这里其实只有2条opline,并没有一条用来连接字符串的opline。

ECHO    'hellomaple'
RETURN    1

在这里2个简单字符串的连接并没有再去编译一条opline,而是在编译过程直接调用相应的二进制处理函数,直接把连接好的字符串返回,和连接的字符串一样,+-*/|&^%<<>>**通过这些运算符的简单运算也是有相应的二进制处理函数。所以在这里其实是把连接之后"hellomaple"添加到了字面量数组。

还有TMP_VAR 和VAR类型操作数的编译,TMP_VAR操作数出现在比如,字符串连接,当然简单的字符串连接是没有中间变量的,比如'maple'.$a这样的情况下结果的返回值类型会被编译成TMP_VAR。TMP_VAR和VAR类型其实很容易弄混,这里其实好理解,TMP_VAR是在计算过程出现的临时变量。通常情况下带返回值的每一条opline的返回值类型都是VAR类型,返回值你可以决定用还是不用。比如函数调用的返回值类型,判断语句的返回值类型,简单的赋值语句的返回值类型都是VAR类型,VAR就是相当于隐式的php变量。在这里不用纠结所有情况下的操作数类型的判断,在具体的过程中你能判断即可。

还有关于VAR和TMP_VAR类型操作数的值和CV类型的操作数值一样都是偏移量,但是在这里前者两个类型的操作数的偏移不是地址偏移量,而是以此次出现的顺序递增作为偏移量,即0,1,2,3,4....这样的形式。下一个处理过程会把递增数值再转换成具体的内存偏移地址。聪明的你有想过为什么会这样做吗?是因为当CV变量,TMP_VAR,VAR都分配在zend_execute_data结果的末尾,有一个顺序所有CV变量在前依次分配,而后才是TMP_VAR,VAR这些变量,如果你在这一步就以具体地址偏移量作为除CV变量以外的值,这里会造成交叉。编译器不知道究竟有多少个CV变量,难道当出现一个CV变量就把已经存在的TMP_VAR,VAR这些变量依次往后移吗?这样做的效率太差,所以这一步只保存递增的数值,当初步完成编译整个抽象语法树之后,知道了到底有多少个CV变量,然后在最后一个CV变量的末尾依次分配。

在编译抽象语法树的过程中最主要的就是确定操作数和具体的处理函数。下面接着讲关于每一条opcode对应的处理函数。根据前面的目标,我们对整个指令集其实已经了解的差不多了,现在需要探究每一条指令集的解释过程即对应handler处理函数。这一过程在pass_two()中

ZEND_API int pass_two(zend_op_array *op_array)
{
    ...
    if (CG(context).vars_size != op_array->last_var) {
        op_array->vars = (zend_string**) erealloc(op_array->vars, sizeof(zend_string*)*op_array->last_var);
        CG(context).vars_size = op_array->last_var;
    } //这一步主要是用来在分配CV变量的变量名数组。
 
    ...
    if (op_array->literals) {
        memcpy(((char*)op_array->opcodes) + ZEND_MM_ALIGNED_SIZE_EX(sizeof(zend_op) * op_array->last, 16),
            op_array->literals, sizeof(zval) * op_array->last_literal);
        efree(op_array->literals);
        op_array->literals = (zval*)(((char*)op_array->opcodes) + ZEND_MM_ALIGNED_SIZE_EX(sizeof(zend_op) * op_array->last, 16));
    }//这一步用来分配存储字面量的数组
 
    ...
    op_array->fn_flags |= ZEND_ACC_DONE_PASS_TWO; //标志此op_array已经经过pass_two处理了
 
    ...
    opline = op_array->opcodes;
    end = opline + op_array->last;
    while (opline < end) {   //遍历每一条opline,为其添加handler。
        switch (opline->opcode) {
            case ZEND_RECV_INIT:
                {
                    zval *val = CT_CONSTANT(opline->op2);
                    if (Z_TYPE_P(val) == IS_CONSTANT_AST) {
                        uint32_t slot = ZEND_MM_ALIGNED_SIZE_EX(op_array->cache_size, 8);
                        Z_CACHE_SLOT_P(val) = slot;
                        op_array->cache_size += sizeof(zval);
                    }
                }
                break;
            case ZEND_FAST_CALL:
                opline->op1.opline_num = op_array->try_catch_array[opline->op1.num].finally_op;
                ZEND_PASS_TWO_UPDATE_JMP_TARGET(op_array, opline, opline->op1);
                break;
            case ZEND_BRK:
            case ZEND_CONT:
        ...
        if (opline->op1_type == IS_CONST) {
            ZEND_PASS_TWO_UPDATE_CONSTANT(op_array, opline, opline->op1);
        } else if (opline->op1_type & (IS_VAR|IS_TMP_VAR)) {
            opline->op1.var = (uint32_t)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, op_array->last_var + opline->op1.var);
        }//将操作数按照不同类型转换成内存的偏移地址。

前面我忘记说到CONST类型的操作数的值应该怎么确定,CONST类型的字面量会被储存到op_array->literals中,所以CONST类型的操作数的值为字面量数组中的下标。因为字面量的值不同于其他类型变量的值,并不是储存在zend_execute_data的结尾,在ZEND_PASS_TWO_UPDATE_CONSTANT这里两只转化方式,第一种是相对于当前opline的偏移地址:((char *)((op_array)->literals + (num)))-((char*)opline)),第二种是直接用 (opline->op).zv直接存储字面量zval变量地址。不同之处是前一种是64位系统的处理方式,而后一种是32为系统的处理方式。为什么可以用在64位系统上用相对寻址,这就需要去看看php内核里面内存的管理了。有兴趣的同学可以由此继续跟下去。

同样前面说到过的,这里用ZEND_CALL_VAR_NUM将TMP_VAR和VAR操作数的值也转换成内存地址的偏移量。接着具体看ZEND_VM_SET_OPCODE_HANDLER为opline添加handler的具体过程:

ZEND_API void ZEND_FASTCALL zend_vm_set_opcode_handler(zend_op* op)
{
    zend_uchar opcode = zend_user_opcodes[op->opcode]; //opcode不变
    ...
    op->handler = zend_vm_get_opcode_handler_ex(zend_spec_handlers[opcode], op);
}

zend_spec_handlers是一个用来保存单个opcode对应的起始handler在zend_opcode_handler的位置和该opcode可以接受的操作数的个数如下:

static const uint32_t specs[] = {
        0,
        1 | SPEC_RULE_OP1 | SPEC_RULE_OP2,
        26 | SPEC_RULE_OP1 | SPEC_RULE_OP2,
        51 | SPEC_RULE_OP1 | SPEC_RULE_OP2 | SPEC_RULE_COMMUTATIVE,
        76 | SPEC_RULE_OP1 | SPEC_RULE_OP2,
        101 | SPEC_RULE_OP1 | SPEC_RULE_OP2,
        ...

拿到可以接受操作数的个数和opcode对应的其实handler位置,计算出实际处理handler。

static const void* ZEND_FASTCALL zend_vm_get_opcode_handler_ex(uint32_t spec, const zend_op* op)
{
    static const int zend_vm_decode[] = {
        _UNUSED_CODE, /* 0 = IS_UNUSED  */
        _CONST_CODE,  /* 1 = IS_CONST   */
        _TMP_CODE,    /* 2 = IS_TMP_VAR */
        _UNUSED_CODE, /* 3              */
        _VAR_CODE,    /* 4 = IS_VAR     */
        _UNUSED_CODE, /* 5              */
        _UNUSED_CODE, /* 6              */
        _UNUSED_CODE, /* 7              */
        _CV_CODE      /* 8 = IS_CV      */
    };
    uint32_t offset = 0;
    if (spec & SPEC_RULE_OP1) offset = offset * 5 + zend_vm_decode[op->op1_type];
    if (spec & SPEC_RULE_OP2) offset = offset * 5 + zend_vm_decode[op->op2_type];

一个opcode对应的handler种类和它可以接受的操作数有关。操作数类型一共5种如上,最多一个opcode可能有两个操作数,每个操作数最多有5种类型,就出现25种不一样的形式的op1和op2 的对应关系。上述就是根据对应关系计算到handler偏移的方法,首先得根据操作数类型做一个映射把0->3, 1->0, 2->1, 4->2, 8->4。然后再根据操作数的个数,类型计算出实际处理函数的偏移量。

...
return zend_opcode_handlers[(spec & SPEC_START_MASK) + offset];

zend_opcode_handlers这个数组保存的并不是处理函数,而是标签。由此引出对应的handler的生成和调度问题。

0x05 Handler 的生成和调度

仔细想一想大概存在200种 不同类型的opcode,如果两个操作数的对应关系也按25算。那么一共应该有5000个handler。实际上没那多,但也是极其庞大的handler处理结构。ZendVM里面对于handler的处理全部定义在zend_vm_execute.h 中,这个文件其实是自动生成的,通过同级目录下的zend_vm_gen.php生成。庞大的handler分支,从生成到调度,这两个过程是分不开的。一种生成方法对应一种调度方法。生成handler的过程基本都一样,生成handler可以为内联,也可以以函数的形式来调用。为什么需要根据操作数类型把一个处理函数分成一个个只能接受指定类型的操作数的handler呢?为什么不直接写一个handler然后在里面判断操作数的类型不就行了?如果只通过一个opcode对应一个handler,那么必然要在这个handler里面对操作数类型进行判断。必然存在大量的if else这样的判断语句,判断语句本质上对应着地址的跳转,根据操作数类型就需要做大量的判断,可能就需要24次,这里就提到一个概念叫分支预测,虽然我们可以在写ifesle判断语句的时候,可以把经常出现的对应关系往前写,提高命中率,但是还是无法准确的预知操作数类型的对应关系。所以把一个处理函数分成多个处理函数,把这些处理函数的标志放在一张表里面,通过映射直接获取单个处理函数,相对于一次跳转到对应的处理函数上。在php_vm_gen.php生成使用调度方法一共有4种:

  • CALL
  • SWITCH
  • GOTO
  • HYBRID

CALL类型的调度方法是把单个handler封装成函数,进行调用:

ZEND_API void execute_ex(zend_execute_data *ex)
{
    LOAD_OPLINE();
    ZEND_VM_LOOP_INTERRUPT_CHECK();
 
    while (1) {
        int ret ;
        ret = ((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
 
    }
    //zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen");
}

这种情况下handler指向的是处理函数,这个处理的函数作用包括具体的处理过程和处理完成之后让当前的opline指向下一条。在这里说一下当前的execute_data 中opline的指向,在编译的时候进行了优化,将指定一个全局的寄存器变量去保存当前opline的地址,同样当前的execute_data也会用一个寄存器变量来保存。在不同的架构上可能使用的寄存器不同。

# elif defined(__GNUC__) && ZEND_GCC_VERSION >= 4008 && defined(__x86_64__)
#  define ZEND_VM_FP_GLOBAL_REG "%r14"
#  define ZEND_VM_IP_GLOBAL_REG "%r15"
 
register zend_execute_data* volatile execute_data __asm__(ZEND_VM_FP_GLOBAL_REG);
register const zend_op* volatile opline __asm__(ZEND_VM_IP_GLOBAL_REG);

本文上用r14来保存execute_data,用r15来保存当前的opline。所以在进行gdb调试的时候你并不能直接打印这两个值,你需要去引用一下这个两个寄存器上相对应的变量的地址。当使用全局的寄存器变量来保存execute_data的时候,在调用相应处理函数的时候,就不需要再传递。具体看ZEND_OPCODE_HANDLER_ARGS_PASSTHRU这个宏定义。在Call调用下可能存在调用handler处理函数可能不会立即返回,而是继续在该handler里面调用下一条opline的处理函数。

SWITCH 是最容易生成的一种调度方法:

ZEND_API void execute_ex(zend_execute_data *ex)
{
    LOAD_OPLINE();
    ZEND_VM_LOOP_INTERRUPT_CHECK();
 
    while (1) {
zend_vm_continue:
        dispatch_handler = OPLINE->handler;
zend_vm_dispatch:
        switch((int)(uintptr_t)dispatch_handler){
            case 0:
                //处理过程
                ZEND_VM_NEXT_OPCODE(); //opline ++ && goto zend_vm_contiune
            case 1:
                ...
        }
 
    }
    //zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen");
}

处理过程内嵌在每一个case语句里面,opline中handler保存是case的节点信息,生成这种调用方式非常简单,只需要一个顺序的映射表就行。但是这里又用写了一次switch,switch语句的效率和多个分支的if语句效率基本是相当的,不利于分支预测,每次的switch都可能跳转到任意一个case节点上,而且至少都有上千的case的分支。

GOTO相当于把Call里面的handler都写成了内联的形式,且handler之间的切换用goto来完成。

ZEND_API void execute_ex(zend_execute_data *ex)
{
    LOAD_OPLINE();
    ZEND_VM_LOOP_INTERRUPT_CHECK();
 
    while (1) {
        goto *(void**)(OPLINE->handler);
{$spec_name}_LABEL: ZEND_VM_GUARD($spec_name);
        {
 
        }
{$spec_name}_LABEL: ZEND_VM_GUARD($spec_name);
        {
 
        }
                ...
    }
    //zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen");
}

标签的地址是可以这样void *ptr = &&label; goto *ptr;用变量来表示。这样可以定义一个标签地址的数组作为映射表,opline->handler保存相应标签地址。在这里也不存在if这样的判断语句,从第一个goto开始到handler处理完成再进行goto,执行每一个goto位置都是不一样的,所以这里可以根据每一个goto进行单独的分支预测,可以把每次跳转范围减少到一个比较小的范围,提高了预测的精度。

HYBRID是7.2版本才出来的一种优化后的混合调用方式,是CALL和GOTO的结合。

ZEND_API void execute_ex(zend_execute_data *ex)
{
    LOAD_OPLINE();
    ZEND_VM_LOOP_INTERRUPT_CHECK();
 
    while (1) {
        HYBRID_SWITCH() { //goto *(void**)(OPLINE->handler)
            HYBRID_CASE(ZEND_JMP_SPEC):/*op ## _LABEL*/
                VM_TRACE(ZEND_JMP_SPEC)
                ZEND_JMP_SPEC_HANDLER(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
                HYBRID_BREAK();//goto *(void**)(OPLINE->handler)
            HYBRID_CASE(ZEND_DO_ICALL_SPEC_RETVAL_UNUSED):/*op ## _LABEL*/
                VM_TRACE(ZEND_DO_ICALL_SPEC_RETVAL_UNUSED)
                ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_HANDLER(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
                HYBRID_BREAK();//goto *(void**)(OPLINE->handler)
                ...
            HYBRID_CASE(ZEND_RETURN_SPEC_CONST):
                VM_TRACE(ZEND_RETURN_SPEC_CONST)
{
    USE_OPLINE
    zval *retval_ptr;
    zval *return_value;
    zend_free_op free_op1;
 
    retval_ptr = RT_CONSTANT(opline, opline->op1);
    return_value = EX(return_value);
    if (IS_CONST == IS_CV && UNEXPECTED(Z_TYPE_INFO_P(retval_ptr) == IS_UNDEF)) {
    ....
    goto zend_leave_helper_SPEC_LABEL;
}
    }
    //zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen");
}

你看到的是在分支的选择上用的goto,handler的表现形式是有函数调用也有内联,如果把所有的函数调用都换成内联的形式,其实就是goto的调用方法。在HYBRID这个模式里面如果你看到handler定义为ZEND_VM_HOT,其实就是内联函数体。

以上四种生成不同VM模式,既然是用zend_vm_gen.php生成的VM,如果我们想要添加新的handler就需要去zend_vm_def.h 定义新handler,现在来看一看定义新handler的格式,如下为echo的handler定义

ZEND_VM_HANDLER(40, ZEND_ECHO, CONST|TMPVAR|CV, ANY)
{
    USE_OPLINE
    zend_free_op free_op1;
    zval *z;
    S*E_OPLINE();
    z = GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R);
    if (Z_TYPE_P(z) == IS_STRING) {
        zend_string *str = Z_STR_P(z);
        if (ZSTR_LEN(str) != 0) {
            zend_write(ZSTR_VAL(str), ZSTR_LEN(str));
        }
    } else {
        zend_string *str = zval_get_string_func(z);
        if (ZSTR_LEN(str) != 0) {
            zend_write(ZSTR_VAL(str), ZSTR_LEN(str));
        } else if (OP1_TYPE == IS_CV && UNEXPECTED(Z_TYPE_P(z) == IS_UNDEF)) {
            GET_OP1_UNDEF_CV(z, BP_VAR_R);
        }
        zend_string_release_ex(str, 0);
    }
    FREE_OP1();
    ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
}

标志的handler定义需要使用ZEND_VM_HANDLER作为起始,括号里面的参数分别为,opcode整数值,opcode常量,操作数1类型,操作数2类型,可能还存在一个参数为分割的flag参数。有时候会在操作数类型里面看到其他不一样的操作数类型,比如NEXT,ANY,THIS等等,其实这些并不是操作数类型,相当于flag额外的属性,并不参加操作数1和操作数2的笛卡尔集的对应关系。

handler定义里面还有类似GET_OP1_ZVAL_PTR_UNDEF这样的取值标记,在这里我们不用考虑不同操作数的取值方法,zend_vm_gen.php在内部做了映射,会根据不同的操作数类型替换这样的标记,如下:

$op1_get_zval_ptr_undef = array(
    "ANY"      => "get_zval_ptr_undef(opline->op1_type, opline->op1, &free_op1, \\1)",
    "TMP"      => "_get_zval_ptr_tmp(opline->op1.var, &free_op1 EXECUTE_DATA_CC)",
    "VAR"      => "_get_zval_ptr_var(opline->op1.var, &free_op1 EXECUTE_DATA_CC)",
    "CONST"    => "RT_CONSTANT(opline, opline->op1)",
    "UNUSED"   => "NULL",
    "CV"       => "EX_VAR(opline->op1.var)",
    "TMPVAR"   => "_get_zval_ptr_var(opline->op1.var, &free_op1 EXECUTE_DATA_CC)",
    "TMPVARCV" => "EX_VAR(opline->op1.var)",
);

如果想看更多定义的替换规则,可以去看zend_vm_gen.php文件里面靠前的位置。可能有时候会看见类型下面的判断语句

if (IS_CV == IS_VAR && UNEXPECTED(Z_ISERROR_P(variable_ptr))) {
        if (UNEXPECTED(0)) {
            ZVAL_NULL(EX_VAR(opline->result.var));
        }
    } else {
        value = zend_assign_to_variable(variable_ptr, value, IS_CONST);
        if (UNEXPECTED(0)) {
            ZVAL_COPY(EX_VAR(opline->result.var), value);
        }
        /* zend_assign_to_variable() always takes care of op2, never free it! */
    }

IS_CV==IS_VAR这种奇怪的条件,这是因为zend_vm_gen.php在生成handler的时候是直接替换的操作数类型。if (OP1_TYPE == IS_VAR && UNEXPECTED(Z_ISERROR_P(variable_ptr))) {,就造成了这种情况,是无用的判断条件,在编译的时候编译器会自行优化掉这些判断条件,所以并不造成影响。

VM的生成到调用,需要掌握的是怎样是去定义或者修改正确的handler,让zend_vm_gen.php能正常的处理,指定相应的调度方式,最终生成zend_vm_execute.h。这过程需要自己去实践才能明白一条可用的handler是怎样生成的。

终于handler的分配到这里也结束了,在pass_two结束遍历所有的oplines,前面整个编译过程就结束了,接下来就是进入执行过程。整个VM的执行过程都是zend_vm_execute.h生成的,通过填充zend_vm_execute.skl里面相关函数,生成完整的zend_execute(),execute_ex()。

0x06 执行过程

进入zend_execute

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)));//初始化execute_data,在vm栈上分配execute_data的大小
    if (EG(current_execute_data)) {
        execute_>//设置符号表
    } else {
        execute_data->symbol_table = &EG(symbol_table);
    }
    EX(prev_execute_data) = EG(current_execute_data);//保存的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);
}

execute_data相当于处理当前op_array的context上下文,当前context里面的CV变量,临时变量均分配在execute_data结尾。

zend_execute_ex = execute_ex;

ZEND_API void execute_ex(zend_execute_data *ex)
{
    DCL_OPLINE
 
#ifdef ZEND_VM_IP_GLOBAL_REG
    const zend_op *orig_opline = opline;
#endif
#ifdef ZEND_VM_FP_GLOBAL_REG
    zend_execute_data *orig_execute_data = execute_data;
    execute_data = ex;
#else
    zend_execute_data *execute_data = ex;
#endif
    LOAD_OPLINE(); //  opline = EX(opline)
    ZEND_VM_LOOP_INTERRUPT_CHECK();
    while(1){
        //遍历oplines,顺序处理
    }

这里具体的调用handler的过程上面已经将的差不多了,这里看看返回的过程,返回的标志是RETURN,相应的handler会根据操作数1的不同类型将返回值zval赋值给EX(return_value),最后会跳转到下面的位置。

zend_leave_helper_SPEC_LABEL:
    zend_execute_data *old_execute_data;
    uint32_t call_info = EX_CALL_INFO();
    if (EXPECTED((call_info & (ZEND_CALL_CODE|ZEND_CALL_TOP|ZEND_CALL_HAS_SYMBOL_TABLE|ZEND_CALL_FREE_EXTRA_ARGS|ZEND_CALL_ALLOCATED)) == 0)) {
        ...
    }else if(EXPECTED((call_info & (ZEND_CALL_CODE|ZEND_CALL_TOP)) == 0)) {
        ...
    }else if (EXPECTED((call_info & ZEND_CALL_TOP) == 0)) {
        ..
    }else {
        if (EXPECTED((call_info & ZEND_CALL_CODE) == 0)) {
            ...
        }else{
            zend_array *symbol_table = EX(symbol_table);
            zend_detach_symbol_table(execute_data);
            old_execute_data = EX(prev_execute_data);
            while (old_execute_data) {
                if (old_execute_>func && (ZEND_CALL_INFO(old_execute_data) & ZEND_CALL_HAS_SYMBOL_TABLE)) {
                    if (old_execute_data->symbol_table == symbol_table) {
                        zend_attach_symbol_table(old_execute_data);
                    }
                    break;
                }
                old_execute_data = old_execute_data->prev_execute_data;
            }
            EG(current_execute_data) = EX(prev_execute_data);
            ZEND_VM_RETURN();
        }

这里通过判断调用者的信息决定如何返回。调用者信息有下面几种,除了开始"main" op_array的execute_data调用,其他几种都是涉及到切换execute_data,切换的时候会创建新的execute_data。最后分支是main execute_data的返回,其中zend_detach_symbol_table是清理execute_data末尾的CV和临时变量。

typedef enum _zend_call_kind {
    ZEND_CALL_NESTED_FUNCTION,    /* stackless VM call to function 自定义php函数 即用户代码*/
    ZEND_CALL_NESTED_CODE,        /* stackless VM call to include/require/eval 文件包含 */
    ZEND_CALL_TOP_FUNCTION,        /* direct VM call to function from external C code  内置函数*/
    ZEND_CALL_TOP_CODE            /* direct VM call to "main" code from external C code mian函数*/
} zend_call_kind;

最后execute_ex返回,再调用zend_vm_stack_free_call_frame()释放掉execute_data。这里不是真正的释放,而是把相应的内存归还给Zend 的内存池,避免频繁的申请和释放。有兴趣的同学可以去看看Zend的内存管理。

到这里ZendVM编译和执行过程也就差不多介绍个大概,其实还有很多细节值得推敲。比如opcode缓存,opcode 的优化等等,关于opcode缓存和php7.4 alpha1的新特性FFI应该是我下一篇文章,在写本文的时候,恰巧也是php7.4 alpha1 release的时候,只感觉php变得很快,越来越不局限于Web的专属语言了。

0x7 牛刀小试

说了这么多,你们可能也想试一试如何去增加一个新的php语法,这里我将通过一个简单的例子描述这一过程。其实通过前面基础介绍从 词法扫描->语法分析->抽象语法树->oplines->zend_execute 这已基本过程也应该了解了。现在我们添加一个 关于in的语法 ,在JavaScript里面 in 作为运算符用来判断指定的属性是否在指定的对象或其原型链中,返回值为bool类型,同样在python里面也有in运算符,使用于字符串和字典运算。字典类似于php里面的数组,js 和 python 的in运算符应用于string in ['b','a','c']这样运算的时候,js判断是数组的key值 ,而python关注的value值,类似于php的in_array。这里我们添加一个比较简单的语法用in来代替strpos。

最终的效果应该是

var_dump('maple' in 'hello , maple'); //int(8)
var_dump(1 in '11111'); //bool(false)
var_dump('' in 'maple'); //bool(false)

这里in两边表达式不进行弱类型转化,如strpos一样,应该都为字符串类型。一步一步来。

  • 00001. 首先需要在词法扫描的时候碰到"in" 返回 'T_IN';
  • 00002. T_IN 作为运算符和+-*/%这些运算符意义相同,应该出现在表达式里面。

先完成第一步re2c扫描的时候,遇到"in",返回token,需要在zend_language_scanner.l中lex_scan()中添加相应的正则匹配规则。

<ST_IN_SCRIPTING>"in" {
    RETURN_TOKEN(T_IN);
}

这里有同学可能会问应该放在什么位置,在这里其实放在任意位置都行,只要在/*!re2c内就行,因为这里不存在冲突,存在一个include规则,但是re2c在处理匹配的相同字符串的规则的时候,是优先取长的。所以include和in并不冲突。

然后去zend_language_parser.y去定义一下T_IN相关语法。

%token T_IN     "in (T_IN)"//首先定义T_IN,放在定义token的末尾就行。
expr:
...
|expr T_IN expr  { $$ = zend_ast_create_binary_op(ZEND_IN, $1, $3); }
...//添加一下具体的语法规则,左右两边为表达式。后面的ast节点建立后面再说。
;

引入token和定义相关语法,其实还需要做一些事情,否则bison还是无法处理。比如

'stra' in 'strb'  && 1

这种情况下究竟是 ('stra' in 'strb' ) && 1 还是'stra' in ('strb' && 1),会导致bison无法处理。所以这里我们还需要定义in的优先级。再比如下面

'stra' in 'strb' in 'strc'

究竟是('stra' in 'strb') in 'strc'还是'stra' in ('strb' in 'strc')呢?这里需要定义结合性。结核性好考虑%left 即可。

再考虑优先级应该放在什么位置

'stra' in 'strb'  && 1 // 应该为下面的情况
('stra' in 'strb' ) && 1 // 即应该放在 %left T_BOOLEAN_AND 后面
'stra' in 'strb'.'strc' //
'stra' in ('strb'.'strc') //应该 %left '+' '-' '.' 之前

&& 和+, -, .之间的token如下

%left T_BOOLEAN_AND
%left '|'
%left '^'
%left '&'
%nonassoc T_IS_EQUAL T_IS_NOT_EQUAL T_IS_IDENTICAL T_IS_NOT_IDENTICAL T_SPACESHIP
%nonassoc '<' T_IS_SMALLER_OR_EQUAL '>' T_IS_GREATER_OR_EQUAL
%left T_SL T_SR
%left '+' '-' '.'

应该在大于号小于号 之后,而又应该在位运算符之前之后都行。我放在了位运算后面,这里in两边的表达式应该为字符串类型,不适用于位运算。所以这里插入位置如下

%left T_SL T_SR
%left T_IN
%left '+' '-' '.'

便完成了语法分析的修改。接着关于in语法节点的建立。我们可以看一下其他简单运算符的建立的过程。

|    expr '|' expr    { $$ = zend_ast_create_binary_op(ZEND_BW_OR, $1, $3); }
    |    expr '&' expr    { $$ = zend_ast_create_binary_op(ZEND_BW_AND, $1, $3); }
    |    expr '^' expr    { $$ = zend_ast_create_binary_op(ZEND_BW_XOR, $1, $3); }
    |    expr '.' expr     { $$ = zend_ast_create_binary_op(ZEND_CONCAT, $1, $3); }
    |    expr '+' expr     { $$ = zend_ast_create_binary_op(ZEND_ADD, $1, $3); }
    |    expr '-' expr     { $$ = zend_ast_create_binary_op(ZEND_SUB, $1, $3); }
    |    expr '*' expr    { $$ = zend_ast_create_binary_op(ZEND_MUL, $1, $3); }
    |    expr T_POW expr    { $$ = zend_ast_create_binary_op(ZEND_POW, $1, $3); }

都通过zend_ast_create_binary_op来建立节点,其实建立是一个ZEND_AST_BINARY_OP类型的节点,然后将该节点attr设置为相应的opcode,我们再去看一下关于ZEND_AST_BINARY_OP节点编译成opcode的过程。

void zend_compile_expr(znode *result, zend_ast *ast) /* {{{ */
{
    ...
    switch (ast->kind) {
        case ZEND_AST_BINARY_OP:
            zend_compile_binary_op(result, ast);
        ...
    }
}
 
//接着zend_compile_binary_op
void zend_compile_binary_op(znode *result, zend_ast *ast) /* {{{ */
{
    zend_ast *left_ast = ast->child[0];//取出左右expr节点
    zend_ast *right_ast = ast->child[1];
    uint32_t opcode = ast->attr;//相应的opcode zend_add zend_sub ....
 
    znode left_node, right_node;
    zend_compile_expr(&left_node, left_ast); //递归处理可能存在的嵌套表达式
    zend_compile_expr(&right_node, right_ast);
 
    if (left_node.op_type == IS_CONST && right_node.op_type == IS_CONST) { //一步优化,上面也提到过
        if (zend_try_ct_eval_binary_op(&result->u.constant, opcode,//如果是两边表达式节点都是字面量,直接调用内置的二进制处理函数,返回结果,并不会再根据opcode生成opline。
                &left_node.u.constant, &right_node.u.constant)
        ) {
            result->op_type = IS_CONST;
            zval_ptr_dtor(&left_node.u.constant);
            zval_ptr_dtor(&right_node.u.constant);
            return;
        }
    }

这里我们先把如果 in 两边是字面量的处理过程写出来,例如'aaaaaaa' in 'bbbbbbbb',所以这里我们需要去添加相应的内置函数来处理。

static inline zend_bool zend_try_ct_eval_binary_op(zval *result, uint32_t opcode, zval *op1, zval *op2) /* {{{ */
{
    binary_op_type fn = get_binary_op(opcode);
    ...
}
 
 
ZEND_API binary_op_type get_binary_op(int opcode)
{
    switch (opcode) {
        case ZEND_ADD:
        case ZEND_ASSIGN_ADD:
            return (binary_op_type) add_function;
        case ZEND_SUB:
        case ZEND_ASSIGN_SUB:
            return (binary_op_type) sub_function;
        case ZEND_MUL:
        case ZEND_ASSIGN_MUL:
        ...
}

这里我们需要添加 ZEND_IN的case分支如下

...
case ZEND_IN:
            return (binary_op_type) in_function;
        default:
            return (binary_op_type) NULL;
...

接着去定义in_function,在zend_operators.c中,

ZEND_API int ZEND_FASTCALL in_function(zval *result, zval *op1, zval *op2) /* {{{ */
{
    const char *found = NULL;
    if (Z_TYPE_P(op2) == IS_STRING){
        if (!Z_STRLEN_P(op2)) {
            ZVAL_FALSE(result);
        }else{
            if(Z_TYPE_P(op1) == IS_STRING ){
                if(!Z_STRLEN_P(op1)){
                    ZVAL_FALSE(result);
                }else{
                    found = (char*)zend_memnstr(Z_STRVAL_P(op2),
                                Z_STRVAL_P(op1),
                                Z_STRLEN_P(op1),
                                   Z_STRVAL_P(op2) + Z_STRLEN_P(op2));
                    //ZVAL_LONG(result,found-Z_STRVAL_P(op2));
                }
            }else{
                ZVAL_FALSE(result);
            }
        }
    }else{
        ZVAL_FALSE(result);
    }
    if(found){
        ZVAL_LONG(result,found-Z_STRVAL_P(op2));
    }else{
        ZVAL_FALSE(result);
    }
    retuSrn SUCCES;
}

改函数实现了strpos不带offset的功能。记得还要去zend_vm_opcodes.h去定义一下新添加的ZEND_IN.使用bison重新预处理一下zend_language_parser.y,同样也需要使用re2c重新处理一下zend_language_scanner.l。重新编译整个php。你就会看到预期in左右两边字面量的新语法。接着还有'a' in $a,'a' in foo(),就需要使用zend_vm_gen.php 去生成相对应的handler。有兴趣的同学可以去接着深入,这里的东西再怎么陈述,你终究会有一些不懂的地方。

0x08 写在最后

终于php的编译和执行到此就结束了,从前到后其实就是在不断的重新编译php,然后配合gdb。很多人觉得庞大的代码很难入手,其实把大致逻辑梳理一遍,再针对性的看,也不是很难下手,原希望这篇文章作为一篇基础的入门级文章送给那些渴求一探php内部奥秘的朋友,不在某一个细节上过于深究,留下可探究的点,供大家参考。如果大家能从此篇学到一些东西,那我这一段时间就没用白费 :)。同时送给大家一段我看见挺正确的话:

我觉得韩天峰有句话说的很对,技术栈上,PHP 只是 C 的一个开发效率提升的补充,资深的高级 PHP 程序员,很多时候都是很好的 C 程序员(参考鸟哥),C 对于 PHP 不是后门,是基石。PHP 极早期很多函数就是对 C 的一些简单封装,你可以看下 PHP4 时代遗留下来的东西,很多有很重的 C 痕迹,PHP5 拥抱 oop 不是和 Java 学,而是跟着语言发展潮流走,拥抱开发方式的发展和变化,但是发展到现在,有人觉得弄出 laravel 那种花式封装的就是高级 PHP 程序员了,其实离真的高级资深 PHP 程序员还远着十万八千里。

本文分享自微信公众号 - PHP技术大全(phpgod)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-07-02

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • php - tcp 粘包/拆包实例

    tcp 长链接模式下,使用固定消息头长度的方式进行消息 拆包 ,解决 粘包 问题。

    猿哥
  • PHP的垃圾回收机制以及大概实现

    垃圾回收,简称gc。顾名思义,就是废物重利用的意思。再说这个之前先接触一下内存泄露,大概意思就是申请了一块地儿拉了会儿屎,拉完后不收拾,那么那块儿地就算是糟...

    猿哥
  • 【干货】PHP7强悍性能背后,zval的变化!

    PHP7已经发布, 如承诺, 我也要开始这个系列的文章的编写, 主要想通过文章让大家理解到PHP7的巨大性能提升背后到底我们做了什么, 今天我想先和大家聊聊zv...

    猿哥
  • Python基础(一)

    以#开头的语句是注释,解释器会忽略掉注释。其他每一行都是一个语句,当语句以冒号:结尾时,缩进的语句视为代码块。

    haifeiWu
  • bodymovin 的使用场景初步调研

    bodymovin 不仅可以播放动画,可以完全控制动画的播放、暂停、速率、播放对应帧等等。更可以做到更改帧对象的位置。可以说是不可多得的好工具。

    腾讯IVWEB团队
  • 人工智能让智能锁具有“思维”,切中用户痛点

    偏门行业,偏偏发展潜力巨大,特别是门锁业正处在从传统门锁到智能门锁转型的关键风口。据有关数据,智能门锁这个行业正在以爆发式速度增长。2016年,市场容量为200...

    企鹅号小编
  • 2018全球(南京)人工智能应用大赛赛题发布,共20个赛题覆盖5大领域 | 活动

    镁客网
  • HCM SaaS的中国模样

    ? 来源:人力资源市场观察 ---- 有人说:未来没有传统企业,只有数字和智慧企业。Workday的高市值让投资者们把国内HCM SaaS厂商吹向风口的信心再...

    腾讯SaaS加速器
  • 软件测试对用户的分析

    大部分程序员都由于不能使自己进入必要的精神状态,因而不能有效地测试自己的程序。 除了这个心理学问题之外,还有一个重要的问题:程序中可能包含由于程序员对问题的叙述...

    新梦想IT职业教育
  • 通俗的理解阻塞/非阻塞和同步/异步的概念

    joymufeng

扫码关注云+社区

领取腾讯云代金券