奥门新浦京官方网站深入理解PHP原理之执行周期分析_php技巧_脚本之家

PHP 是一门解释型的语言。诸如 Java、Python、Ruby、Javascript
等解释型语言,我们编写的代码不会被编译成机器码运行,而是会被编译中间码运行在虚拟机(VM)上。运行
PHP 的虚拟机,称之为 Zend 虚拟机,今天我们将深入内核,探究 Zend
虚拟机运行的原理。

本文讲述了PHP原理之执行周期。分享给大家供大家参考,具体如下:

介绍

Github上展示了我们将要做的东西,你可以对比项目中的代码以防你遇到任何错误GitHub
Repository
这是一篇关于使用C语言建造你自己的虚拟机的文章。我喜欢研究底层应用,例如编译器,解释器,编辑器,虚拟机等。

OPCODE

什么是 OPCODE?它是一种虚拟机能够识别并处理的指令。Zend
虚拟机包含了一系列的 OPCODE,通过 OPCODE 虚拟机能够做很多事情,列举几个
OPCODE 的例子:

  • ZEND_ADD 将两个操作数相加。
  • ZEND_NEW 创建一个 PHP 对象。
  • ZEND_ECHO 将内容输出到标准输出中。
  • ZEND_EXIT 退出 PHP。

诸如此类的操作,PHP 定义了186个(随着 PHP 的更新,肯定会支持更多种类的
OPCODE),所有的 OPCODE
的定义和实现都可以在源码的 zend/zend_vm_def.h 文件(这个文件的内容并不是原生的
C 代码,而是一个模板,后面会说明原因)中查阅到。

我们来看下 PHP 是如何设计 OPCODE 数据结构:

struct _zend_op {
    const void *handler;
    znode_op op1;
    znode_op op2;
    znode_op result;
    uint32_t extended_value;
    uint32_t lineno;
    zend_uchar opcode;
    zend_uchar op1_type;
    zend_uchar op2_type;
    zend_uchar result_type;
};

仔细观察 OPCODE 的数据结构,是不是能找到汇编语言的感觉。每一个 OPCODE
都包含两个操作数,op1和 op2handler 指针则指向了执行该 OPCODE
操作的函数,函数处理后的结果,会被保存在 result 中。

我们举一个简单的例子:

<?php
$b = 1;
$a = $b + 2;

我们通过 vld 扩展看到,经过编译的后,上面的代码生成了 ZEND_ADD 指令的
OPCODE。

compiled vars:  !0 = $b, !1 = $a
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   ASSIGN                                                   !0, 1
   3     1        ADD                                              ~3      !0, 2
         2        ASSIGN                                                   !1, ~3
   8     3      > RETURN                                                   1

其中,第二行是 ZEND_ADD 指令的
OPCODE。我们看到,它接收2个操作数,op1 是变量 $bop2 是数字常量1,返回的结果存入了临时变量中。在 zend/zend_vm_def.h 文件中,我们可以找到
ZEND_ADD 指令对应的函数实现:

ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMPVAR|CV, CONST|TMPVAR|CV)
{
    USE_OPLINE
    zend_free_op free_op1, free_op2;
    zval *op1, *op2, *result;

    op1 = GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R);
    op2 = GET_OP2_ZVAL_PTR_UNDEF(BP_VAR_R);
    if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_LONG)) {
        if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_LONG)) {
            result = EX_VAR(opline->result.var);
            fast_long_add_function(result, op1, op2);
            ZEND_VM_NEXT_OPCODE();
        } else if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_DOUBLE)) {
            result = EX_VAR(opline->result.var);
            ZVAL_DOUBLE(result, ((double)Z_LVAL_P(op1)) + Z_DVAL_P(op2));
            ZEND_VM_NEXT_OPCODE();
        }
    } else if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_DOUBLE)) {

    ...
}

上面的代码并不是原生的 C 代码,而是一种模板。

为什么这样做?因为 PHP 是弱类型语言,而其实现的 C
则是强类型语言。弱类型语言支持自动类型匹配,而自动类型匹配的实现方式,就像上述代码一样,通过判断来处理不同类型的参数。试想一下,如果每一个
OPCODE
处理的时候都需要判断传入的参数类型,那么性能势必成为极大的问题(一次请求需要处理的
OPCODE 可能能达到成千上万个)。

哪有什么办法吗?我们发现在编译的时候,已经能够确定每个操作数的类型(可能是常量还是变量)。所以,PHP
真正执行时的 C
代码,不同类型操作数将分成不同的函数,供虚拟机直接调用。这部分代码放在了 zend/zend_vm_execute.h 中,展开后的文件相当大,而且我们注意到还有这样的代码:

if (IS_CONST == IS_CV) {

完全没有什么意义是吧?不过没有关系,C
的编译器会自动优化这样判断。大多数情况,我们希望了解某个 OPCODE
处理的逻辑,还是通过阅读模板文件 zend/zend_vm_def.h 比较容易。顺便说一下,根据模板生成
C 代码的程序就是用 PHP 实现的。

PHP的执行周期,从最初我们编写的PHP脚本->到最后脚本被执行->得到执行结果,这个过程,其实可以分为如下几个阶段:

预备知识和提醒

在我们继续之前,有一些东西是你必须知道的:

  • 一个编译器 — 我在使用clang3.4,但是你可以使用支持c99/c11的任何编译器
  • 编辑器 — 我会建议你使用文本编辑器而不是IDE,我会使用Emacs
  • 基本的编程知识 — 只是基本,例如变量,控制符,函数,结构体等
  • Make — 一个编译体系,让我们不必在控制台写重复的命令去编译我们的代码

执行过程

准确的来说,PHP
的执行分成了两大部分:编译和执行。这里我将不会详细展开编译的部分,而是把焦点放在执行的过程。

通过语法、词法分析等一系列的编译过程后,我们得到了一个名为 OPArray
的数据,其结构如下:

struct _zend_op_array {
    /* Common elements */
    zend_uchar type;
    zend_uchar arg_flags[3]; /* bitset of arg_info.pass_by_reference */
    uint32_t fn_flags;
    zend_string *function_name;
    zend_class_entry *scope;
    zend_function *prototype;
    uint32_t num_args;
    uint32_t required_num_args;
    zend_arg_info *arg_info;
    /* END of common elements */

    uint32_t *refcount;

    uint32_t last;
    zend_op *opcodes;

    int last_var;
    uint32_t T;
    zend_string **vars;

    int last_live_range;
    int last_try_catch;
    zend_live_range *live_range;
    zend_try_catch_element *try_catch_array;

    /* static variables support */
    HashTable *static_variables;

    zend_string *filename;
    uint32_t line_start;
    uint32_t line_end;
    zend_string *doc_comment;
    uint32_t early_binding; /* the linked list of delayed declarations */

    int last_literal;
    zval *literals;

    int  cache_size;
    void **run_time_cache;

    void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};

内容超多对吧?简单的理解,其本质就是一个 OPCODE
数组外加执行过程中所需要的环境数据的集合。介绍几个相对来说比较重要的字段:

  • opcodes 存放 OPCODE 的数组。
  • filename 当前执行的脚本的文件名。
  • function_name 当前执行的方法名称。
  • static_variables 静态变量列表。
  • last_try_catch try_catch_array 当前上下文中,如果出现异常
    try-catch-finally 跳转所需的信息。
  • literals 所有诸如字符串 foo 或者数字23,这样的常量字面量集合。

为什么需要生成这样庞大的数据?因为编译时期生成的信息越多,执行时期所需要的时间就越少。

接下来,我们看下 PHP 是如何执行 OPCODE。OPCODE
的执行被放在一个大循环中,这个循环位于 zend/zend_vm_execute.h 中的 execute_ex 函数:

ZEND_API void execute_ex(zend_execute_data *ex)
{
    DCL_OPLINE

    zend_execute_data *execute_data = ex;

    LOAD_OPLINE();
    ZEND_VM_LOOP_INTERRUPT_CHECK();

    while (1) {
        if (UNEXPECTED((ret = ((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU)) != 0)) {
            if (EXPECTED(ret > 0)) {
                execute_data = EG(current_execute_data);
                ZEND_VM_LOOP_INTERRUPT_CHECK();
            } else {
                return;
            }
        }
    }

    zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen");
}

这里,我去掉了一些环境变量判断分支,保留了运行的主流程。可以看到,在一个无限循环中,虚拟机会不断调用
OPCODE
指定的 handler 函数处理指令集,直到某次指令处理的结果 ret 小于0。注意到,在主流程中并没有移动
OPCODE
数组的当前指针,而是把这个过程放到指令执行的具体函数的结尾。所以,我们在大多数
OPCODE 的实现函数的末尾,都能看到调用这个宏:

ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();

在之前那个简单例子中,我们看到 vld 打印出的执行 OPCODE
数组中,最后有一项指令为 ZEND_RETURN 的 OPCODE。但我们编写的 PHP
代码中并没有这样的语句。在编译时期,虚拟机会自动将这个指令加到 OPCODE
数组的结尾。ZEND_RETURN 指令对应的函数会返回
-1,判断执行的结果小于0时,就会退出循环,从而结束程序的运行。

首先,Zend Engine,调用词法分析 器(Lex生成的,源文件在
Zend/zend_language_sanner.l), 将我们要执行的PHP源文件,去掉空格
,注释,分割成一个一个的token。

为什么你应该写一个虚拟机

1
这里有一些你应该写一个虚拟机的原因:

  • 你希望对计算机的工作有更加深入的理解。这篇文章会帮助你去了解你的电脑是怎么在底层工作的,一个虚拟机提供了一个简单而优美的抽象。并且写一个虚拟机是你学习它的最好方式,不是吗?
  • 你因为觉得它有趣而去学习一个虚拟机
  • 你希望了解一些编程语言的工作方式。当今不同的语言有自己的虚拟机。例如JVM,Lua’s
    VM, Facebook’s Hip-Hop VM
    (PHP/Hack)等等。这些都是很大的概念,假设从C++程序转换到汇编给机器执行,当你思考它们的时候,你会理所当然地使用OOP编程方式,自动垃圾回收等所有的特性。

方法调用

如果我们调用一个自定义的函数,虚拟机会如何处理呢?

<?php
function foo() {
    echo 'test';
}

foo();

我们通过 vld 查看生成的 OPCODE。出现了两个 OPCODE
指令执行栈,是因为我们自定义了一个 PHP
函数。在第一个执行栈上,调用自定义函数会执行两个 OPCODE
指令:INIT_FCALL 和 DO_FCALL

compiled vars:  none
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   NOP
   6     1        INIT_FCALL                                               'foo'
         2        DO_FCALL                                      0
         3      > RETURN                                                   1

compiled vars:  none
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   3     0  E >   ECHO                                                     'test'
   4     1      > RETURN                                                   null

其中,INIT_FCALL 准备了执行函数时所需要的上下文数据。DO_FCALL 负责执行函数。DO_FCALL 的处理函数根据不同的调用情况处理了大量逻辑,我摘取了其中执行用户定义的函数的逻辑部分:

ZEND_VM_HANDLER(60, ZEND_DO_FCALL, ANY, ANY, SPEC(RETVAL))
{
    USE_OPLINE
    zend_execute_data *call = EX(call);
    zend_function *fbc = call->func;
    zend_object *object;
    zval *ret;

    ...

    if (EXPECTED(fbc->type == ZEND_USER_FUNCTION)) {
        ret = NULL;
        if (RETURN_VALUE_USED(opline)) {
            ret = EX_VAR(opline->result.var);
            ZVAL_NULL(ret);
        }

        call->prev_execute_data = execute_data;
        i_init_func_execute_data(call, &fbc->op_array, ret);

        if (EXPECTED(zend_execute_ex == execute_ex)) {
            ZEND_VM_ENTER();
        } else {
            ZEND_ADD_CALL_FLAG(call, ZEND_CALL_TOP);
            zend_execute_ex(call);
        }
    }

    ...

    ZEND_VM_SET_OPCODE(opline + 1);
    ZEND_VM_CONTINUE();
}

可以看到,DO_FCALL 首先将调用函数前的上下文数据保存到 call->prev_execute_data,然后调用 i_init_func_execute_data 函数,将自定义函数对象中的 op_array(每个自定义函数会在编译的时候生成对应的数据,其数据结构中包含了函数的
OPCODE 数组) 赋值给新的执行上下文对象。

然后,调用 zend_execute_ex 函数,开始执行自定义的函数。zend_execute_ex 实际上就是前面提到的 execute_ex 函数(默认是这样,但扩展可能重写 zend_execute_ex 指针,这个
API 让 PHP
扩展开发者可以通过覆写函数达到扩展功能的目的,不是本篇的主题,不准备深入探讨),只是上下文数据被替换成当前函数所在的上下文数据。

我们可以这样理解,最外层的代码就是一个默认存在的函数(类似 C
语言中的 main()函数),和用户自定义的函数本质上是没有区别的。

然后,ZE会将得到的token forward给语法分析 器(yacc生成, 源文件在
Zend/zend_language_parser.y),生成一个一个的opcode,opcode一般会以op
array的形式存在,它是PHP执行的中间语言。

指令集

我们会实现自己的指令集,它非常的简单。
我将简单提及一些指令,例如从寄存器中移动值,或者跳转到其他指令,但是希望你在度过这篇文章以后弄清楚它们。

我们的虚拟机会有一系列的寄存器A,B, C, D, E, 和
F。这些都是目的寄存器,你可以使用来存储任何东西。一个程序是一个只读的指令序列。这是一个基于栈的虚拟机,也就是说我们拥有一个栈来进行进栈和出栈的操作,另外还有少量的寄存器供我们使用。基于栈的虚拟机比基于寄存器的虚拟机更加容易实现。

不再多说,这里是我们将会用到的指令集。分号后是对每一行作用的说明。

PSH 5 ; 将5进栈
PSH 10 ; 将10进栈
ADD ; 将栈顶的两个值相加,然后将结果进栈
POP ; 将栈顶元素出栈,将会用于调试
SET A 0 ; 将寄存器A置零
HLT ; 停止程序

这就是我们的指令集,注意POP指令会打印出栈的值,这样更加便于调试(ADD指令会将结果入栈,所以我们使用POP来判断是否操作正确)
你也可以自己尝试实现类似MOV A,B。HLT指令表示我们的程序结束。

逻辑跳转

我们知道指令都是顺序执行的,而我们的程序,一般都包含不少的逻辑判断和循环,这部分又是如何通过
OPCODE 实现的呢?

<?php
$a = 10;
if ($a == 10) {
    echo 'success';
} else {
    echo 'failure';
}

我们还是通过 vld 查看 OPCODE(不得不说 vld 扩展是分析 PHP 的神器)。

compiled vars:  !0 = $a
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   ASSIGN                                                   !0, 10
   3     1        IS_EQUAL                                         ~2      !0, 10
         2      > JMPZ                                                     ~2, ->5
   4     3    >   ECHO                                                     'success'
         4      > JMP                                                      ->6
   6     5    >   ECHO                                                     'failure'
   7     6    > > RETURN                                                   1

我们看到,JMPZ 和 JMP 控制了执行流程。JMP 的逻辑非常简单,将当前的
OPCODE 指针指向需要跳转的 OPCODE。

ZEND_VM_HANDLER(42, ZEND_JMP, JMP_ADDR, ANY)
{
    USE_OPLINE

    ZEND_VM_SET_OPCODE(OP_JMP_ADDR(opline, opline->op1));
    ZEND_VM_CONTINUE();
}

JMPZ 仅仅是多了一次判断,根据结果选择是否跳转,这里就不再重复列举了。而处理循环的方式与判断基本上是类似的。

<?php
$a = [1, 2, 3];
foreach ($a as $n) {
    echo $n;
}

compiled vars:  !0 = $a, !1 = $n
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   ASSIGN                                                   !0, <array>
   3     1      > FE_RESET_R                                       $3      !0, ->5
         2    > > FE_FETCH_R                                               $3, !1, ->5
   4     3    >   ECHO                                                     !1
         4      > JMP                                                      ->2
         5    >   FE_FREE                                                  $3
   5     6      > RETURN                                                   1

循环只需要 JMP 指令即可完成,通过 FE_FETCH_R 指令判断是否已经到达数组的结尾,如果到达则退出循环。

最后,ZE调用zend_executor来执行op array
,输出结果。(也就是将源文件转换成机器语言,然后在虚拟机上运行它。)

怎么使虚拟机工作?

虚拟机比你想的要简单,它们遵循一个简单的模式:取指令,解析,执行。一些先进的虚拟机可能会有另外的步骤,但是核心就是这些。我们从代码或指令集做取得下一条指令,然后去解析指令和执行解析后的指令。为了简单起见,我们不会去解析指令,典型的虚拟机会打包一个指令到一个值,然后去解析它。

结语

通过了解 Zend 虚拟机,相信你对 PHP
是如何运行的,会有更深刻的理解。想到我们写的一行行代码,最后机器执行的时候会变成数不胜数的指令,每个指令又建立在复杂的处理逻辑之上。那些从前随意写下的代码,现在会不会在脑海里不自觉的转换成
OPCODE 再品味一番呢?

ZE是一个虚拟机,正是由于它的存在,所以才能使得我们写PHP脚本,完全不需要考虑所在的操作系统类型是什么,这才是PHP的可移植性的原因。ZE是一个CISC,它支持150条指令(具体指令在
Zend/zend_vm_opcodes.h),包括从最简单的ZEND_ECHO到复杂的
ZEND_INCLUDE_OR_EVAL,所有我们编写的PHP都会最终被处理为这150条指令的序列,从而最终被执行

项目结构

在我们开始编程之前,我们虚拟创建工程。首先,你需要一个C编辑器。我们需要一个文件夹来放置你的工程,我喜欢将工程放置在/Dev下。我们在目标文件夹中创建工程。这里假设你已经有了/Dev/目录,但是你也可以在任何地方创建你工程。

$cd ~/Dev/
mkdir mac  
cd mac  
mkdir src 

在目标文件夹中,我们创建一个文件夹(称为VM”mac”)。然后我们cd进入这个文件夹,创建src文件夹,这里是我们放置代码的地方。

PHP是一个脚本语言,也就是说,用户编写的PHP代码最终都是会被PHP解释器解释执行,所有编写的PHP代码,都会被翻译成PHP的虚拟机ZE的虚拟指令来执行。

Makefile

我们没有任何的子文件,并且不会包含任何东西,所以我们只是需要makefile来进行编译

SRC_FILES = main.c  
CC_FLAGS = -Wall -Wextra -g -std=c11  
CC = clang

all:  
    ${CC} ${SRC_FILES} ${CC_FLAGS} -o mac

这些目前已经足够了,我们以后改进它,但是只要它能完成工作就好。

那我们的PHP脚本,最终被“翻译”成什么样的呢? 也就是说,op
code长的什么样子呢? Opcode是一种PHP脚本编译后的中间语言。

程序指令(代码)

现在是虚拟机代码部分。首先我们需要为程序定义指令集。为此,我们使用enum,因为我们的指令集只是简单的从数字0-X。实际上,当你汇编文件的时候,汇编会将类似mov等指令转换成.。例如,我们可以用0,5来代替PSH,5,但是这样的可读性非常差,所以我们使用枚举。

typedef enum {  
    PSH,
    ADD,
    POP,
    SET,
    HLT
} InstructionSet

现在我们将程序存储为数据。用于测试,我们会写一个简单的程序,将5和6相加,然后打印出来(使用POP指令)。如果你想的话,你可以创造一条打印栈顶元素的指令。

指令会存储成数组形式,我会在文档的开头定义它,但是你可以将它放入头文件。这是我们的测试程序:

const int program[] = {  
    PSH, 5,
    PSH, 6,
    ADD,
    POP,
    HLT
};

上面的程序会将5,6入栈,调用ADD指令,将两者出栈,然后将相加的结果入栈。我们会将结果出栈然后打印它,但是你不需要自己去做这个事情。我们只是用它来测试。最后,HLT指令被调用表示程序结束。

现在我们有程序了。所以现在我们实现取值,解析,求值的虚拟机操作。但是记住,我们不解析任何东西,因为这里只是原始指令。这意味着我们只需要关心取值,和求值。我们简化出两个函数fetch和evaluate

在PECL中已经有这样的模块,利用由 Derick Rethans开发的VLD (Vulcan Logic
Dissassembler)模块。你只要下载这个模块,并把他载入PHP中,就可以通过简单的设置,来得到脚本翻译的结果了。

取出当前指令

因为我们将程序存储到数组中了,取出指令就变得很简单。虚拟机有一个计数器,通常称为程序计算器,指令指针…这些名字都是一个意思,取决于你个人喜好。我将这些缩写成IP或PC,因为他们在VM代码中非常常见。

如果你还记得,我说过我们会将程序计数器当成一个寄存器..我们会这样做,但是还要等一下。现在,我们只是在程序的开头,创建一个变量,叫ip,将其值设置为0。

int ip = 0;

ip表示指令指针。因为我们将程序存储成数组,我们使用ip变量去指向当前的index。例如,如果我们创建了一个变量x,值为为ip指向的程序,它会存储我们程序的第一条指令(加上ip值为0)

int ip = 0;

int main() {  
    int instr = program[ip];
    return 0;
}

我们打印变量instr,它会给我们PSH,也就是0,因为这是我们enum的第一个值。我们可以将这个过程写出一个函数

int fetch() {  
    return program[ip];
}

这个函数会方法当前执行的指令。那么下一条指令呢?我们只要递增指令指针即可。

int main() {  
    int x = fetch(); // PSH
    ip++; // increment instruction pointer
    int y = fetch(); // 5
}

那么我们怎么使之自动化呢?我们知道程序会一直运行,知道HLT指令被调用。所以我们使用一个死循环,用于保证程序的运行。

// INCLUDE <stdbool.h>!
bool running = true;

int main() {  
    while (running) {
       int x = fetch();
       if (x == HLT) running = false;
       ip++;
    }
}

这里完美运行,但是有一点混乱。我们遍历每个指令,检查其是否是HLT,如果是停止循环,否则执行指令,并继续。

VLD模块的安装以及应用:

执行指令

这里我们的目的是真正地执行指令,并且让它看起来更加清晰。由于这个虚拟机很简单,我们可以利用enum写一个巨大的switch结构。eval有一个参数,表示要执行的指令。我们不会增加指令指针,除非我们消耗掉操作数。

void eval(int instr) {  
    switch (instr) {
        case HLT:
            running = false;
            break;
    }
}

回到main函数,我们可以将eval结合进去

bool running = true;  
int ip = 0;

// instruction enum here

// eval function here

// fetch function here

int main() {  
    while (running) {
        eval(fetch());
        ip++; // increment the ip every iteration
    }
}
[root@localhost software]# tar zxvf vld-0.9.1.tgz.gz[root@localhost vld-0.9.1]# /usr/local/php/bin/phpize[root@localhost vld-0.9.1]# ./configure --with-php-config=/usr/local/php/bin/php-config[root@localhost vld-0.9.1]# make install //不需要make

栈!

在我们增加其他指令之前,我们需要一个栈。幸运的是,这很简单,我们只需要一个数组。数组有一个合适的大小,在这里是256。我们也需要一个栈指针,通常缩写为sp。它指向我们的栈数组的当前index。

为了更加形象,这是我们的栈(数组)

[] // empty

PSH 5 // put 5 on top of the stack
[5]

PSH 6
[5, 6]

POP
[5]

POP
[] // empty

PSH 6
[6]

PSH 5
[6, 5]

所以继续我们的程序。

PSH, 5,
PSH, 6,
ADD,
POP,
HLT

首先将5入栈

[5]

然后6入栈

[5,6]

然后ADD指令会将这两个数出栈,相加以后将结果入栈

[5, 6]

// pop the top value, store it in a variable >called a
a = pop; // a contains 6
[5] // stack contents

// pop the top value, store it in a variable >called b
b = pop; // b contains 5
[] // stack contents

// now we add b and a. Note we do it >backwards, in addition
// this doesn’t matter, but in other >potential instructions
// for instance divide 5 / 6 is not the same >as 6 / 5
result = b + a;
push result // push the result to the stack
[11] // stack contents

所以我们栈指针在哪里呢?栈指针,或者说sp一般默认为-1,表示栈为空。数组从0开始,所以如果sp表示是0,那么C编译器会造成混乱。

现在如果我们将3个数入栈,sp会是2。

sp points here (sp = 2)
|
V
[1, 5, 9]
0 1 2 <- these are the array indices

当我们想看栈顶元素,我们只是看到sp指向的值。所以现在你应该明白栈是怎么工作的了。C语言实现也很简单。除了ip变量,我们还定义了一个sp变量,其默认值是-1!现在stack只是一个数组,我们有如下定义:

int ip = 0;  
int sp = -1;  
int stack[256]; // use a define or something here preferably

// other c code here...

现在如果我们想将元素入栈,我们自增栈指针,然后将值设置到sp指向的位置。注意,顺序非常重要!

// pushing 5

// sp = -1
sp++; // sp = 0  
stack[sp] = 5; // top of stack is now [5] 

所以我们可以在eval函数中增加push操作:

void eval(int instr) {  
    switch (instr) {
        case HLT: {
            running = false;
            break;
        }
        case PSH: {
            sp++;
            stack[sp] = program[++ip];
            break;
        }
    }
}

现在,你可能会注意到一些以前的eval函数之间的区别。首先,每个case中有括号。如果你不熟悉这个技巧,它给了这样一个范围,这样你就可以在case中定义变量。我们现在不需要它,但我们稍后会用到,这样方便所有的case保持一致。

另外,program[++ip]表示自增操作。我们的程序是存储在一个数组中。我们PSH指令消耗一个操作数。一个操作数基本上是一个参数,就像当你调用一个函数你可以传递一个参数。在这种情况下,我们说要把5入栈。所以我们必须得到这个操作数,要做到这一点,我们增加指令指针。所以我们的IP为零,这意味着它指向PSH,但是现在我们在PSH的指令,我们想获得下一个指示。要做到这一点就要增加指令。注意增量的位置是很重要的,我们得到的指令之前,我们要增加指令指针否则我们只会得到PSH,然后就跳过下一个指令造成一些奇怪的错误。我可以将sp++简写为stack[++sp]。

现在说POP指令,它很简单。我们只需要自减栈指针,但是我也想打印出我们出栈的那个值。我省略了其他的代码指令和switch语句,这里只包括POP指令的case部分:

// REMEMBER TO INCLUDE <stdio.h>!

case POP: {  
    int val_popped = stack[sp--];
    printf("popped %dn", val_popped);
    break;
}

所以我们所做的就是我们栈顶值保存到val_popped中,然后我们将栈指针自减。如果我们先将栈指针自减,你会得到一些垃圾值,因为sp可以是0,那么我们将栈指针自减并设置val_popped到stack[-1]基本上,这并不妙。

最后是ADD指令。这里用到了上面提到的case作用范围。

case ADD: {  
    // first we pop the stack and store it as a
    int a = stack[sp--];

    // then we pop the top of the stack and store it as b
    int b = stack[sp--];

    // we then add the result and push it to the stack
    int result = b + a;
    sp++; // increment stack pointer **before**
    stack[sp] = result; // set the value to the top of the stack

    // all done!
    break;
}

编辑php.ini文件并激活vld扩展。

寄存器

对于虚拟机,寄存器是可选的。寄存器很容易实现,我们提到我们有寄存器A, B,
C, D, E, 和 F。我们可以这样定义枚举:

typedef enum {  
   A, B, C, D, E, F,
   NUM_OF_REGISTERS
} Registers;

最后一个值NUM_OF_REGISTERS只是一个简单的技巧,这样我们得到寄存器的数量。现在我们需要一个数组来存储寄存器。

int registers[NUM_OF_REGISTERS]; 

我们可以这样来使用寄存器A

printf("%dn", registers[A]); // prints the value at the register A

创建一个文件,如:hello.php

指令指针

什么是分支?我把它留给你。记住一个指令指针指向当前的指令。现在,因为这是在虚拟机源代码,你最好的选择是有指令指针寄存器,这样从虚拟机可以读取和操作程序。

typedef enum {  
    A, B, C, D, E, F, PC, SP,
    NUM_OF_REGISTERS
} Registers;

现在我们可以实际使用这些指令和堆栈指针。一个快捷的方法,是进行宏定义

#define sp (registers[SP])
#define ip (registers[IP])

应该是一个不错的解决方法,这样你不需要重写代码,并且保证功能正常。然而,这可能不便于扩展,可能会导致代码混淆,所以我建议不使用这种方法,但对于一个简单的玩具虚拟机它足够了。

当涉及到我们的代码中的分支,我给你一个提示。利用我们新的IP寄存器,我们可以给这个IP写入不同的值。试试下面这个示例,看它做什么:

PSH 10
SET IP 0

和人们熟悉的BASIC程序相似

10 PRINT “Hello, World”
20 GOTO 10

然而,因为我总是要将值入栈,所以最终会造成栈溢出。在你的VM中,这也是一个需要处理的边界情况。

; these are the instructions
PSH 10 ; 0 1
PSH 20 ; 2 3
SET IP 0 ; 4 5 6

如果你想调到第二条SET指令,我们会将IP寄存器设置为2而不是0。

[root@localhost html]# /usr/local/php/bin/php -dvld.active=1 hello.phpBranch analysis from position: 0Return foundfilename: /var/www/html/hello.phpfunction name: number of ops: 3compiled vars: noneline # op fetch ext return operands------------------------------------------------------------------------------- 2 0 ECHO 'hello%2C+world.' 4 1 RETURN 1 2* ZEND_HANDLE_EXCEPTIONhello, world.

[root@localhost html]# vi vld.php

[root@localhost html]# /usr/local/php/bin/php -dvld.active=1 vld.phpBranch analysis from position: 0Return foundfilename: /var/www/html/vld.phpfunction name: number of ops: 5compiled vars: !0 = $iline # op fetch ext return operands------------------------------------------------------------------------------- 3 0 ASSIGN !0, 'This+is+a+string' 7 1 CONCAT ~1 !0, '+that+has+been+echoed+on+screen' 2 ECHO ~1 10 3 RETURN 1 4* ZEND_HANDLE_EXCEPTIONThis is a string that has been echoed on screen

最后

你可以在这里获得代码。如果你想要看到包含MOV,SET指令的版本,你可以下载bettervm.c。如果你遇到问题,你也可以将你的实现和上面的文件对比。如果你想要教程代码,你可以下载main.c。

你可以在项目文件夹下执行make命令,如果正确编译,你可以执行./mac文件。

如果你对这个话题感兴趣并且向扩展,网上有很多的资源。Notch写了DCPU-16,一个16位的虚拟机。Github上也有很多的实现,你可以模仿它们。如果你写了一个类似的模拟器,检查它的语法规则,看看你是否能够执行指令和设置寄存器。

谢谢阅读。

原文地址

注:ZEND_HANDLE_EXCEPTION 就是 Zend/zend_vm_opcodes.h 中第149条指令

compiled vars: !0 = $i 此处是获取变量名”i”的变量于!0。#0
将字符串”this+is+a+string”赋值给!0#1 字符串连接#2 显示

这些中间代码会被Zend VM直接执行。真正负责执行的函数是:zend_execute。

更多关于PHP相关内容感兴趣的读者可查看本站专题:《PHP数学运算技巧总结》、《php操作office文档技巧总结(包括word,excel,access,ppt)》、《PHP数组操作技巧大全》、《php排序算法总结》、《PHP常用遍历算法与技巧总结》、《PHP数据结构与算法教程》、《php程序设计算法总结》、《php正则表达式用法总结》、《PHP运算与运算符用法总结》、《php字符串用法总结》及《php常见数据库操作技巧汇总》

希望本文所述对大家PHP程序设计有所帮助。

发表评论

电子邮件地址不会被公开。 必填项已用*标注