mRuby的虚拟机与中间码结构

如果你学过编译原理的话,那你大概知道对于一个编译型语言而言,把代码从文本变成二进制代码所需要经历的步骤大概如下:

词法分析->语法分析->生成抽象语法树->生成中间语言->生成最终的机器语言代码

然而,对于一个解释型的语言,这个过程会停留在哪一步呢?换句话说,对于解释型的语言来说,它会将代码分解到何种程度后再去翻译并执行它呢?

从解释型语言的定义来说,最后一步生成机器语言的过程肯定是没有的,而词法分析,语法分析的过程也是不可省略的,所以,答案好像就落在了抽象语法树与中间语言中间。

这个问题的答案根据不同的语言可能会有不同的答案,但是对于Ruby和mRuby来说,其答案是后者,即中间语言。

##为什么是中间语言?

去问Matz。

##中间语言的结构?

这里,我们只讨论mRuby的中间语言。

mRuby是支持中间语言的持久化的,官方提供了一个mrbc的程序来将.rb文件转化为保存中间码的.mrb文件。

对于mrb的文件结构,你可以参考这里,需要注意的是beoran提供的图里没有提到在compiler name前还应有4字节的二进制文件的大小。

当然,谁会去在意二进制文件的格式呢?让我们还是跳过这些无聊的数字节游戏来到关键的内容吧。那就是,这些格式背后的含义。

###IREP

如果你看了上面我提到的文件结构,你大概会注意一个叫做IREP的东西。唔……有没有想到什么?如果我告诉你中间语言的英文对应的是intermediate representation呢?

没错,IREP就是中间语言。

那么,IREP section的意思就应该是,充满了中间语言的节。实际上,所有的中间语言都是位于一个中间语言节。一个mrb文件中除了IREP节还有一些用于debug等作用的节,当然,这不是我们现在关心的内容。

而IREP record呢?中间语言的记录?这里我们暂且不说它的含义,先让我们看一下另一个东西。

首先,让我们随便写一些ruby代码,比如这样的:

def abc
  x = "test"
  y = :sym
  proc{x}
end
abc.call

然后,用mRuby来执行这段代码,不过,要加上-v选项。

mruby -v test.rb

然后,我们就得到了我们想要的东西,具体的IREP Record格式,还附带AST的结构。

这里我们对AST不感兴趣,所以跳过,然后让我们来看看它输出的IREP Record结果。

irep 0000000002B6CD70 nregs=3 nlocals=1 pools=0 syms=2 reps=1
file: test.rb
    1 000 OP_TCLASS     R1
    1 001 OP_LAMBDA     R2      I(+1)   1
    1 002 OP_METHOD     R1      :abc
    6 003 OP_LOADSELF   R1
    6 004 OP_SEND       R1      :abc    0
    6 005 OP_SEND       R1      :call   0
    6 006 OP_STOP

irep 0000000002B6BCF0 nregs=6 nlocals=4 pools=1 syms=2 reps=1
file: test.rb
    1 000 OP_ENTER      0:0:0:0:0:0:0
    2 001 OP_STRING     R2      L(0)    ; "test"        ; R2:x
    3 002 OP_LOADSYM    R3      :sym    ; R3:y
    4 003 OP_LOADSELF   R4
    4 004 OP_LAMBDA     R5      I(+1)   2
    4 005 OP_SENDB      R4      :proc   0
    4 006 OP_RETURN     R4      return

irep 0000000002B6BD70 nregs=2 nlocals=1 pools=0 syms=0 reps=0
file: test.rb
    4 000 OP_GETUPVAR   R1      2       0
    4 001 OP_RETURN     R1      return

其中,每一段都代表了一个IREP Record。nregs是用到的寄存器数量,如果你好奇为什么始终比用到的寄存器多1,请把R0也一同考虑进去,R0在mRuby的虚拟机中有着特殊的含义。nlocals代表着该段代码中的本地变量数量,当然这很明显是对不上的,这是因为存在着一些没有名字的本地变量,它们没法被代码所访问到。syms代表着该段代码中的符号数量,如果你了解ruby的话应该会知道那些:开头的东西就是所谓的符号了。而pools代表着字符串池内字符串的数量,很明显只有有字符串的Record才会有字符串池。而最后的,就是子IREP Record的数量。而最重要的部分,也就是每一段中那些OP_XXX,就是具体的中间码。

看到这里,你也许也猜到了IREP Record到底代表着什么,那就是代码段。

那么到底什么能够算作代码段呢?不严格的定义来说,proc,方法,以及类定义的内部都可以算作是一个代码段。而具体的规则,就靠读者你们自行翻阅mRuby生成中间语言相关的代码了。

##虚拟机?VM?

现在,我们有了中间码,为了让程序能够正常执行,下一步自然就是要执行这个中间码,而为此,我们需要用到虚拟机。

不要搞混了,这和VirtualBox以及QEMU那些虚拟机不一样,唔……或者说在某些地方上不一样。VirtualBox这类虚拟机主要的目的,是为了虚拟实际设备的运行,而且现在许多都不再通过软件方式模拟执行机器码而是直接交给CPU执行。而我们这里的虚拟机,是为了执行中间代码的,在复杂度上以及目的上都有所不同。

不过,也没有那么大的不同。这里的虚拟机同样有自己的内存模型(寄存器),都是在执行机器码。

我刚刚有没有提到寄存器?正如我在上文所说,mRuby的虚拟机是基于寄存器的。

什么是基于寄存器的虚拟机?要说这个问题就要扯到另一种基于栈的虚拟机了。基于栈的虚拟机其指令是没有操作数的,一切操作都在栈上执行,比如两数相加就是把两数push到求值栈中,然后调用加法指令,有点像你在计算后缀表达式的时候使用的方法。基于栈的虚拟机的优点是实现简单,单个指令短,适用于资源受限的场景,而缺点也很明显,因为各种没必要入栈出栈操作所以效率会低,生成的代码冗长等。

而基于寄存器的虚拟机,自然就是现在这种指令都有操作数R1、R2、R3的这种。优点是快,读起来简单,代码短,当然缺点自然就是实现起来稍微复杂一些,需要的资源要多那么一点,不过现在就算是嵌入式环境大概也不缺那一点内存了吧,如果你真的要在嵌入式环境跑虚拟机的话。

对于这两者之间的比较,这里有一篇不错的文章可以参阅。

让我们来结束这些无聊的比较来看代码吧。

##mRuby VM的实现

mRuby VM的主要代码都位于src/vm.c下。

不过在看VM的实现之前先让我们看一些其他的东西。

首先,虽然mruby的VM是基于寄存器的,但是我们仍然需要一个栈来记录函数的调用信息等。而这个栈是保存在mrb_state结构下的mrb_context结构中。

同时,虽然被称为寄存器,但是mruby的VM实际上是把寄存器保存到栈上的,R0就代表着当前栈帧的第0个元素。

mrb_state没什么好说的,让我们来看看mrb_context。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct mrb_context {
struct mrb_context *prev;

mrb_value *stack; /* stack of virtual machine */
mrb_value *stbase, *stend;

mrb_callinfo *ci;
mrb_callinfo *cibase, *ciend;

mrb_code **rescue; /* exception handler stack */
int rsize;
struct RProc **ensure; /* ensure handler stack */
int esize;

enum mrb_fiber_state status;
struct RFiber *fib;
};

其中,prev是用于Fiber的实现,这里暂时不管。
stack正是VM所用到的栈空间,其代表着当前栈帧的起始位置,而stbase以及stend代表着栈的头部以及栈的尾部(并不是栈顶而是栈空间的结尾,用于记录当前已申请的栈空间大小)。

ci是个很有意思的成员变量,它保存了方法的调用信息栈,当从方法中返回时也需要用到这个成员变量,cibase和ciend的含义和栈的含义一样。

后面的rescue和ensure用于异常处理,本文将不涉猎。status以及fib用于Fiber的实现,亦不涉猎。

让我们来看看ci的结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct {
mrb_sym mid;
struct RProc *proc;
mrb_value *stackent;
int nregs;
int ridx;
int eidx;
struct REnv *env;
mrb_code *pc; /* return address */
mrb_code *err; /* error position */
int argc;
int acc;
struct RClass *target_class;
} mrb_callinfo;

其中,mid是当前正在执行的方法名称。proc是当前正在执行的proc。在这里要指出的是,在mruby里所有的代码段都是proc。stackent,返回时栈的位置。nregs,当前正在执行代码段所用到的寄存器数量。ridx,当前代码段的rescue节数量。eidx,ensure节数量。env,这个成员我们会以后再说。pc与err请参阅注释。argc代表了调用当前代码段时的参数数量。acc代表了在上一级栈帧中这一级栈帧开头所处的位置,用于在返回时正确地将返回值写入正确的位置。target_class记录了定义当前调用方法的类(对于调用父类的方法的情况,该成员记录的是父类)。

###OP_SEND

有了上述的结构,接下来我们就可以看一下OP_SEND的具体实现过程了。

OP_SEND有三个操作数,分别是接收者、方法名、参数个数。

当执行该指令时,VM首先会去接收者的类及其父类中寻找对应的方法名,如果找不到该方法,则转而去调用method_missing方法,如果还是没有找到的情况,则报错。

接下来,会向ci栈里push一个新的callinfo,然后为callinfo填充内容。

再之后,会向前推栈帧的位置到接收者的位置。

最后,修改下PC为跳转到的位置,跳转到方法的内部进行执行。

对于foo.bar(1,2)调用时栈的结构

… 调用前栈帧的位置

foo 调用后栈帧的位置
1
2

如同上面所示,调用后的栈帧指向了接收者,而接收者在方法内部代表着self,所以,R0寄存器中保存的值就是self。

###OP_CALL

一般,你是不会在中间语言中看到OP_CALL的,OP_CALL是Proc#call中唯一的一条指令,当你调用Proc#call的时候,就相当于执行了这个指令。OP_CALL的主要作用是替换callinfo。

为什么要替换callinfo呢?我的猜测是为了节约生成的指令数量。如果我们为跳转到proc再建立一个callinfo,一方面会有不必要的ci生成,以及我们在Proc#call中的指令数量也会变多。

当然,为了跳转到一个proc,除了替换callinfo,我们还需要进行其他的操作,比如将proc所绑定的self替换到栈帧的R0处。

###OP_GETUPVAR

现在,我们面对另一个问题,那就是闭包的问题。

众所周知,ruby里的闭包是可以访问到其定义时所能访问到的变量的。而这个功能的实现,就是通过这个叫OP_GETUPVAR指令。

OP_GETUPVAR的实现主要是通过查找Proc的成员变量env,然后在env所保存的栈上寻找对应的数据。

那么这个ENV是什么时候创建的呢?

答案在proc.c中。

当你创建一个闭包的时候,mruby会调用closure_setup,这个函数会生成一个新的env,新的env会保存stack的地址,当前callinfo在ci栈上的位置,上级env的地址(作为class的指针保存),以及创建该闭包时的方法名。同时,这个函数还会把当前的env保存到当前callinfo的env成员里,没错,就是上文中没有介绍作用的ci中的env。

而在VM的cipop函数中,在将当前callinfo弹出ci栈的时候若当前ci的env不为空,则会将其stack中的内容原样复制一份,进而,当执行到OP_GETUPVAR时,即使栈上已经远没有当初创建该闭包的内容了,但是因为栈已经被复制了一份,所以我们仍然可以得到创建闭包时的栈上的数据。