虚拟机

 

    Lua首先将程序编译成指令【操作码】,然后执行这些指令。每个函数,Lua都要为创建一个原型【prototype】,一个包含该函数操作码的数组以及一个包含该函数所使用的所有常量(字符串和数值)Lua值的数组。

     十年来(从1993年,Lua首次发布),Lua在不同的实现中,使用基于栈的虚拟机。从2003年开始,随着Lua5.0发布,Lua使用基于寄存器的虚拟机。基于寄存器的虚拟机也使用栈来分配活动记录,寄存器就在其中。当Lua进入一个函数,他首先从栈中预分配一个足够大的活动记录,来存储该函数所有的寄存器。所有的局部变量都在寄存器中分配。因此,所有的局部变量的访问都特别高效。

    基于寄存器的代码,避免了多个"push"和"pop"指令,基于栈的代码需要围绕栈来移动值。那些指令在Lua中会显得特别昂贵,因为他们包含值的拷贝,正如第三章所讨论的。因此,寄存器架构既避免昂贵的值拷贝,也压缩每个函数的总指令数。Daviset al 【参看6】为基于寄存器的虚拟机辩护,并提供了Java字节码性能提升的过硬数据。一些作者基于他们即时【on the fly】编译的实用性上,也为基于寄存器的虚拟机进行辩护【参看24】。

    通常,有2个问题和基于寄存器的虚拟机相关:代码尺寸和解码开销。寄存器虚拟机中指令需要明确指定他们的操作数,所以他明显比栈虚拟机中相应的指令要大。(例如,Lua虚拟机中,一个指令大小是4个字节,而几个栈虚拟机,包括Lua以前版本用的只有1到2个字节)从另外一个方面来看,寄存器虚拟机比栈虚拟机产生更少的操作码,所以,总代码尺寸并没有更大。

    栈虚拟机中的大多数指令有隐式操作数。寄存器虚拟机中的相应指令必须从指令中解码出操作数。这个解码给解释器增加了额外的开销。有几个因素可以改善这些额外开销。首先,栈虚拟机也需要花时间操作隐式操作数(特别是增加和减少栈顶部)。第二,因为寄存器虚拟机所有操作数都是在指令中并且是一个机器字,操作数解码只和简单操作有关,比如逻辑操作。此外,栈虚拟机需要常常需要多字节多字节操作数。例如,在Java虚拟机中,goto和分支指令需要2个字节位移。由于对齐,解释器无法一次性提取这样的操作数(至少在可移植代码中不行,这里,他必须最坏情况下的对齐约束)。在寄存器虚拟机中,因为操作数在指令中,所以解释器不需要独立地提取他们。

    Lua虚拟机中有35个指令。大多数指令被选做直接对应于语言构造:计算、表创建和索引、函数和方法调用,设置和获取值。也有一套常规跳转指令集合来实现控制结构。第五节展示这个完整的集合,同时,使用下面记号来简要介绍每个指令的功能:

    R(X)表示第(X)个寄存器;

    K(X)表示第(X)个常量;

    RK(X表示可能是R(X)或者是K(X-k),依赖于X的值--如果X的值比k的小那么就是R(X)【k是一个内建参数,比如250】。

    G(X)表示全局表中的X域。

    U(X)表示第(X)upvalue【好像没法翻译,也不用翻译吧。陈铨 20090324】

    关于Lua虚拟机指令的详细讨论,参看【14,22】

 

 


MOVE A B R(A) := R(B)
LOADK A Bx R(A) := K(Bx)
LOADBOOL A B C R(A) := (Bool)B; if (C) PC++
LOADNIL A B R(A) := ... := R(B) := nil
GETUPVAL A B R(A) := U[B]
GETGLOBAL A Bx R(A) := G[K(Bx)]
GETTABLE A B C R(A) := R(B)[RK(C)]
SETGLOBAL A Bx G[K(Bx)] := R(A)
SETUPVAL A B U[B] := R(A)
SETTABLE A B C R(A)[RK(B)] := RK(C)
NEWTABLE A B C R(A) := {} (size = B,C)
SELF A B C R(A+1) := R(B); R(A) := R(B)[RK(C)]
ADD A B C R(A) := RK(B) + RK(C)
SUB A B C R(A) := RK(B) - RK(C)
MUL A B C R(A) := RK(B) * RK(C)
DIV A B C R(A) := RK(B) / RK(C)
POW A B C R(A) := RK(B) ^ RK(C)
UNM A B R(A) := -R(B)
NOT A B R(A) := not R(B)
CONCAT A B C R(A) := R(B) .. ... .. R(C)
JMP sBx PC += sBx
EQ A B C if ((RK(B) == RK(C)) ~= A) then PC++
LT A B C if ((RK(B) < RK(C)) ~= A) then PC++
LE A B C if ((RK(B) <= RK(C)) ~= A) then PC++
TEST A B C if (R(B) <=> C) then R(A) := R(B) else PC++
CALL A B C R(A), ... ,R(A+C-2) := R(A)(R(A+1), ... ,R(A+B-1))
TAILCALL A B C return R(A)(R(A+1), ... ,R(A+B-1))
RETURN A B return R(A), ... ,R(A+B-2) (see note)
FORLOOP A sBx R(A)+=R(A+2); if R(A) <?= R(A+1) then PC+= sBx
TFORLOOP A C R(A+2), ... ,R(A+2+C) := R(A)(R(A+1), R(A+2));
TFORPREP A sBx if type(R(A)) == table then R(A+1):=R(A), R(A):=next;
SETLIST A Bx R(A)[Bx-Bx%FPF+i] := R(A+i), 1 <= i <= Bx%FPF+1
SETLISTO A Bx
CLOSE A close stack variables up to R(A)
CLOSURE A Bx R(A) := closure(KPROTO[Bx], R(A), ... ,R(A+n))

 


Figure 5: The instructions in Lua's virtual machine


 

 

    寄存器被保存在运行期的栈,这和数据十分相似。这样,对寄存器的访问是快速的。常量和upvalue被存储在数组中,并且访问他们也是快速的。全局表是一个普通的Lua表。他是通过性能良好的哈希来访问,因为他只对字符串进行索引(对应于变量名),并且字符串被预先计算了他们的哈希值,这在第二节提过了。

    Lua虚拟机中的指令,将32个比特分成3到4个域,如表6所示那样。OP域标识指令,用掉6个比特。其他域标识操作数。A域总是再用并且使用8个比特。B域和C域每个使用9个比特。他们可以合成18比特域:Bx(无符号)和sBx(有符号)。

 

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31

 


 

|OP           | A                            |B                                          |C


|OP           |A                             |Bx


|OP           |A                             |sBx


Figure 6: Instruction layout

 

 

    大多数指令使用线程地址格式,A指向保存结果的寄存器,B和C指向操作数,既可以是寄存器或者常量(使用RK(X)表示,如上面解释那样)。使用这种格式,几个Lua中典型的操作可以被编码成单个指令。例如,局部变量的加数,如a=a+1,可以被编码成ADD x x y,这里x表示存储局部变量的寄存器,而y代表常量。赋值操作,象a = b.f,当a和b都是局部变量,也可以编码成简单指令 GETTABLE x y z ,这里x是a所在的寄存器,y是b所在的寄存器,z是字符串常量“f”的索引。(在Lua中,语法b.f是b["f"]的语法糖,就是说,b被字符串“f”所索引)

    分支指令貌似有点难度,因为他们需要指定2个操作数加上跳转偏移量来比较。封装所有的这些数据到一个单独的指令,将限制跳转偏移为256(假定使用9个比特的域)。这种解决方案被Lua采用是因为,理论上,一个检测指令在检测失败时,只是简单地跳过下个指令;下个指令是个使用18比特的常规跳转指令。事实上,一个检测指令总是跟着跳转指令,解释器将2个指令合并执行。那就是说,当执行一个检测指令成功后,解释器将迅速提取下个指令,并不再跳转,而是在下个分发周期中执行他。图7展示Lua代码和对应字节码的例子。注意条件结构和刚才所描述的跳转指令。

 

function max (a,b)
    local m = a                           1MOVE 2 0 0 ; R(2) = R(0)
    if b > a then                         2LT 0 0 1 ; R(0) < R(1) ?  
        m = b                               3JMP 1 ; to 5 (4+1)
    end                                      4MOVE 2 1 0 ; R(2) = R(1)
    return m                               5RETURN 2 2 0 ; return R(2)
end                                           6 RETURN 0 1 0 ; return


Figure 7: Bytecode for a Lua function

 

 

local a,t,i                                  1: LOADNIL 0 2 0
a=a+i                                       2: ADD 0 0 2
a=a+1                                      3: ADD 0 0 250 ; 1
a=t[i]                                        4: GETTABLE 0 1 2


Figure 8: Register-based opcode (Lua 5.0)

 

local a,t,i                                   1: PUSHNIL 3
a=a+i                                        2: GETLOCAL 0 ; a
                                                 3: GETLOCAL 2 ; i
                                                 4: ADD
                                                 5: SETLOCAL 0 ; a
a=a+1                                       6: GETLOCAL 0 ; a
                                                 7: ADDI 1
                                                 8: SETLOCAL 0 ; a
a=t[i]                                        9: GETLOCAL 1 ; t
                                               10: GETINDEXED 2 ; i
                                               11: SETLOCAL 0 ; a


Figure 9: Stack-based opcode (Lua 4.0)

 

    图8演示Lua编译器对执行效率优化后的小例子。图9演示被Lua4.0编译后的相同代码,Lua4.0使用的基于栈的虚拟机,使用49个指令。注意,基于寄存器的虚拟机是如何让选择分支【switch】产生短得多得代码。这个例子中得每个可执行语句在Lua5.0中被编译成一条单独得指令,但他在Lua4.0需要3到4条指令。

    对于函数调用,Lua使用一种叫寄存器窗口的计算。他从第一个未使用的寄存器开始,在一片连续的寄存器中填充调用参数。当他执行这个调用时,这些寄存器变成这个被调用函数活动记录的一部分,因此,被调用函数可以象访问普通局部变量一样访问他的参数。当这个函数返回时,这些寄存器被回放到调用者的活动记录中。

    Lua为函数调用使用2个并行的栈。(实际上,每个协程也有他自己的一对栈,我们在第六节讨论过)一个栈包含每个活动函数的一个入口,这个入口存储被调用的函数,该函数完成调用的返回地址,和指向该函数活动记录的基址索引。另外一个栈仅仅是一个的保存Lua值的大数组,用来存储那些活动记录。每个活动记录保存该函数所有的临时变量(参数、局部变量,以及其他)。实际 上,我们能够看到第二个栈的每个入口,作为对应入口的变量大小部分,保存在第一个栈中。

 

 

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐