mRuby的虚拟机与中间码结构

Written by zumikua Updated at 2017-04-11 11:45:09 UTC

如果你学过编译原理的话,那你大概知道对于一个编译型语言而言,把代码从文本变成二进制代码所需要经历的步骤大概如下: 词法分析->语法分析->生成抽象语法树->生成中间语言->生成最终的机器语言代码 然而,对于一个解释型的语言,这个过程会停留在哪一步呢?换句话说,对于解释型的语言来说,它会将代码分解到何种程度后再去翻译并执行它呢? 从解释型语言的定义来说,最后一步生成机器语言的过程肯定是没有的,而词法分析,语法分析的过程也是不可省略的,所以,答案好像就落在了抽象语法树与中间语言中间。 这个问题的答案根据不同的语言可能会有不同的答案,但是对于Ruby和mRuby来说,其答案是后者,即中间语言。 ##为什么是中间语言? 去问Matz。 ##中间语言的结构? 这里,我们只讨论mRuby的中间语言。 mRuby是支持中间语言的持久化的,官方提供了一个mrbc的程序来将.rb文件转化为保存中间码的.mrb文件。 对于mrb的文件结构,你可以参考[这里](https://github.com/mruby/mruby/issues/944),需要注意的是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的这种。优点是快,读起来简单,代码短,当然缺点自然就是实现起来稍微复杂一些,需要的资源要多那么一点,不过现在就算是嵌入式环境大概也不缺那一点内存了吧,如果你真的要在嵌入式环境跑虚拟机的话。 对于这两者之间的比较,[这里](http://rednaxelafx.iteye.com/blog/492667)有一篇不错的文章可以参阅。 让我们来结束这些无聊的比较来看代码吧。 ##mRuby VM的实现 mRuby VM的主要代码都位于src/vm.c下。 不过在看VM的实现之前先让我们看一些其他的东西。 首先,虽然mruby的VM是基于寄存器的,但是我们仍然需要一个栈来记录函数的调用信息等。而这个栈是保存在mrb_state结构下的mrb_context结构中。 同时,虽然被称为寄存器,但是mruby的VM实际上是把寄存器保存到栈上的,R0就代表着当前栈帧的第0个元素。 mrb_state没什么好说的,让我们来看看mrb_context。 ```` 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的结构。 ```` 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时,即使栈上已经远没有当初创建该闭包的内容了,但是因为栈已经被复制了一份,所以我们仍然可以得到创建闭包时的栈上的数据。
Main