如果你学过编译原理的话,那你大概知道对于一个编译型语言而言,把代码从文本变成二进制代码所需要经历的步骤大概如下:
词法分析->语法分析->生成抽象语法树->生成中间语言->生成最终的机器语言代码
然而,对于一个解释型的语言,这个过程会停留在哪一步呢?换句话说,对于解释型的语言来说,它会将代码分解到何种程度后再去翻译并执行它呢?
从解释型语言的定义来说,最后一步生成机器语言的过程肯定是没有的,而词法分析,语法分析的过程也是不可省略的,所以,答案好像就落在了抽象语法树与中间语言中间。
这个问题的答案根据不同的语言可能会有不同的答案,但是对于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 | struct mrb_context { |
其中,prev是用于Fiber的实现,这里暂时不管。
stack正是VM所用到的栈空间,其代表着当前栈帧的起始位置,而stbase以及stend代表着栈的头部以及栈的尾部(并不是栈顶而是栈空间的结尾,用于记录当前已申请的栈空间大小)。
ci是个很有意思的成员变量,它保存了方法的调用信息栈,当从方法中返回时也需要用到这个成员变量,cibase和ciend的含义和栈的含义一样。
后面的rescue和ensure用于异常处理,本文将不涉猎。status以及fib用于Fiber的实现,亦不涉猎。
让我们来看看ci的结构。
1 | typedef struct { |
其中,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时,即使栈上已经远没有当初创建该闭包的内容了,但是因为栈已经被复制了一份,所以我们仍然可以得到创建闭包时的栈上的数据。