原文
在编译期,如果要访问变量a时,会依照以下的顺序决定变量a的类型:
local变量本身就存在于当前的register中,所有的指令都可以直接使用它的id来访问。而对于upvalue,lua则有专门的指令负责获取和设置。
全局变量在lua5.1中也是使用专门的指令,而5.2对这一点做了改变。Lua5.2种没有专门针对全局变量的指令,而是把全局表放到最外层函数的名字为"_ENV"的upvalue中。对于全局变量a,相当于编译期帮你改成了_ENV.a来进行访问。
GETUPVAL将B为索引的upvalue的值装载到A寄存器中。SETUPVAL将A寄存器的值保存到B为索引的upvalue中。
GETTABUP将B为索引的upvalue当作一个table,并将C做为索引的寄存器或者常量当作key获取的值放入寄存器A。SETTABUP将A为索引的upvalue当作一个table,将C寄存器或者常量的值以B寄存器或常量为key,存入table。
local u = 0; function f() local l; u = 1; l = u; g = 1; l = g; end
main <test.lua:0,0> (4 instructions at 0x80048eb0) 0+ params, 2 slots, 1 upvalue, 1 local, 2 constants, 1 function 1 [1] LOADK 0 -1 ; 0 2 [8] CLOSURE 1 0 ; 0x80049140 3 [2] SETTABUP 0 -2 1 ; _ENV "f" 4 [8] RETURN 0 1 constants (2) for 0x80048eb0: 1 0 2 "f" locals (1) for 0x80048eb0: 0 u 2 5 upvalues (1) for 0x80048eb0: 0 _ENV 1 0 function <test.lua:2,8> (7 instructions at 0x80049140) 0 params, 2 slots, 2 upvalues, 1 local, 2 constants, 0 functions 1 [3] LOADNIL 0 0 2 [4] LOADK 1 -1 ; 1 3 [4] SETUPVAL 1 0 ; u 4 [5] GETUPVAL 0 0 ; u 5 [6] SETTABUP 1 -2 -1 ; _ENV "g" 1 6 [7] GETTABUP 0 1 -2 ; _ENV "g" 7 [8] RETURN 0 1 constants (2) for 0x80049140: 1 1 2 "g" locals (1) for 0x80049140: 0 l 2 8 upvalues (2) for 0x80049140: 0 u 1 0 1 _ENV 0 0
上面的代码片段生成一个主函数和一个内嵌函数。根据前面说到的变量规则,在内嵌函数中,l是local变量,u是upvalue,g由于既不是local变量,也不是upvalue,当作全局变量处理。我们先来看内嵌函数,生成的指令从17行开始。
第17行的LOADNIL前面已经讲过,为local变量赋值。下面的LOADK和SETUPVAL组合,完成了u = 1。因为1是一个常量,存在于常量表中,而lua没有常量与upvalue的直接操作指令,所以需要先把常量1装在到临时寄存器1种,然后将寄存器1的值赋给upvalue 0,也就是u。第20行的GETUPVAL将upvalue u赋给local变量l。第21行开始的SETTABUP和GETTABUP就是前面提到的对全局变量的处理了。g=1被转化为_ENV.g=1。_ENV是系统预先设置在主函数中的upvalue,所以对于全局变量g的访问被转化成对upvalue[_ENV][g]的访问。SETTABUP将upvalue 1(_ENV代表的upvalue)作为一个table,将常量表2(常量"g")作为key的值设置为常量表1(常量1);GETTABUP则是将upvalue 1作为table,将常量表2为key的值赋给寄存器0(local l)。
原文
name args desc OP_MOVE A B R(A) := R(B)
OP_MOVE用来将寄存器B中的值拷贝到寄存器A中。由于Lua是register based vm,大部分的指令都是直接对寄存器进行操作,而不需要对数据进行压栈和弹栈,所以需要OP_MOVE指令的地方并不多。最直接的使用之处就是将一个local变量复制给另一个local变量时:
local a; local b = a;
1 [1] LOADNIL 0 0 2 [2] MOVE 1 0 3 [2] RETURN 0 1
在编译过程中,Lua会将每个local变量都分配到一个指定的寄存器中。在运行期,lua使用local变量所对应的寄存器id来操作local变量,而local变量的名字除了提供debug信息外,没有其他作用。
在这里a被分配给register 0,b被分配给register 1。第二行的MOVE表示将a(register 0)的值赋给b(register 1)。其他使用的地方基本都是对寄存器的位置有特殊要求的地方,比如函数参数的传递等等。
name args desc OP_LOADK A Bx R(A) := Kst(Bx)
LOADK将Bx表示的常量表中的常量值装载到寄存器A中。很多其他指令,比如数学操作指令,其本身可以直接从常量表中索引操作数,所以可以不依赖于LOADK指令。
local a=1; local b="foo";
1 [1] LOADK 0 -1 ; 1 2 [2] LOADK 1 -2 ; "foo" 3 [2] RETURN 0 1 constants (2) for 0x80048eb0: 1 1 2 "foo"
name args desc OP_LOADKX A R(A) := Kst(extra arg)
LOADKX是lua5.2新加入的指令。当需要生成LOADK指令时,如果需要索引的常量id超出了Bx所能表示的有效范围,那么就生成一个LOADKX指令,取代LOADK指令,并且接下来立即生成一个EXTRAARG指令,并用其Ax来存放这个id。5.2的这个改动使得一个函数可以处理超过262143个常量。
name args desc OP_LOADBOOL A B C R(A) := (Bool)B; if (C) pc++
LOADBOOL将B所表示的boolean值装载到寄存器A中。B使用0和1分别代表false和true。C也表示一个boolean值,如果C为1,就跳过下一个指令。
local a = true;
1 [1] LOADBOOL 0 1 0 2 [1] RETURN 0 1C在这里的作用比较特殊。要了解C的具体用处,首先要知道lua中对于逻辑和关系表达式是如何处理的,比如:
local a = 1 < 2
对于上面的代码,一般我们会认为lua应该先对1<2求出一个boolean值,然后放入到a中。然而实际上产生出来的代码为:
1 [1] LT 1 -1 -2 ; 1 2 2 [1] JMP 0 1 ; to 4 3 [1] LOADBOOL 0 0 1 4 [1] LOADBOOL 0 1 0 5 [1] RETURN 0 1 constants (2) for 0x80048eb0: 1 1 2 2
可以看到,lua生成了LT和JMP指令,另外再加上两个LOADBOOL对于a赋予不同的boolean值。LT(后面会详细讲解)指令本身并不产生一个boolean结果值,而是配合后面紧跟的JMP实现true和false的不同跳转。如果LT评估为true,就继续执行,也就是执行到JMP,然后调转到4,对a赋予true;否则就跳过下一条指令到达第三行,对a赋予false,并且跳过下一个指令。所以上面的代码实际的意思被转化为:
local a; if 1 < 2 then a = true; else a = false; end
逻辑或者关系表达式之所以被设计成这个样子,主要是为if语句和循环语句所做的优化。不用将整个表达式估值成一个boolean值后再决定跳转路径,而是评估过程中就可以直接跳转,节省了很多指令。
C的作用就是配合这种使用逻辑或关系表达式进行赋值的操作,他节省了后面必须跟的一个JMP指令。
name args desc OP_LOADNIL A B R(A), R(A+1), ..., R(A+B) := nil
LOADNIL将使用A到B所表示范围的寄存器赋值成nil。用范围表示寄存器主要为了对以下情况进行优化:
local a,b,c;
1 [1] LOADNIL 0 2 2 [1] RETURN 0 1
对于连续的local变量声明,使用一条LOADNIL指令就可以完成,而不需要分别进行赋值。
对于一下情况
local a; local b = 0; local c;
1 [1] LOADNIL 0 0 2 [2] LOADK 1 -1 ; 0 3 [3] LOADNIL 2 0在Lua5.2中,a和c不能被合并成一个LOADNIL指令。所以以上写法理论上会生成更多的指令,应该予以避免,而改写成
local a,c; local b = 0;
原文
Lua一直把虚拟机执行代码的效率作为一个非常重要的设计目标。而采用什么样的指令系统的对于虚拟机的执行效率来说至关重要。
Stack based vs Register based VM根据指令获取操作数方式的不同,我们可以把虚拟机的实现分为stack based和register based。
Stack based vmStack based vm的指令一般都是在当前stack中获取和保存操作数的。比如一个简单的加法赋值运算:a=b+c,对于stack based vm,一般会被转化成如下的指令:
push b; // 将变量b的值压入stack push c; // 将变量c的值压入stack add; // 将stack顶部的两个值弹出后相加,将结果压入stack mov a; // 将stack顶部结果放到a中
由于Stack based vm的指令都是基于当前stack来查找操作数的,这就相当于所有操作数的存储位置都是运行期决定的,在编译器的代码生成阶段不需要额外为在哪里存储操作数费心,所以stack based的编译器实现起来相对比较简单直接。也正因为这个原因,每条指令占用的存储空间也比较小。
但是,对于一个简单的运算,stack based vm会使用过多的指令组合来完成,这样就增加了整体指令集合的长度。vm会使用同样多的迭代次数来执行这些指令,这对于效率来说会有很大的影响。并且,由于操作数都要放到stack上面,使得移动这些操作数的内存复制大大增加,这也会影响到效率。
Register based vmLua 采用的是register based vm。
Register based vm的指令都是在已经分配好的寄存器中存取操作数。对于上面的运算,register based vm一般会使用如下的指令:
add a b c; // 将b与c对应的寄存器的值相加,将结果保存在a对应的寄存器中
Register based vm的指令可以直接对应标准的3地址指令,用一条指令完成了上面多条指令的计算工作,并且有效地减少了内存复制操作。这样的指令系统对于效率有很大的帮助。
不过,在编译器设计上,就要在代码生成阶段对寄存器进行分配,增加了实现的复杂度。并且每条指令所占用的存储空间也相应的增加了。
Lua虚拟机指令简介Lua的指令使用一个32bit的unsigned integer表示。所有指令的定义都在lopcodes.h文件中,使用一个enum OpCode代表指令类型。在lua5.2中,总共有40种指令(id从0到39)。根据指令参数的不同,可以将所有指令分为4类:
除了sBx之外,所有的指令参数都是unsigned integer类型。sBx可以表示负数,但表示方法比较特殊。sBx的18bit可表示的最大整数为262143,这个数的一半131071用来表示0,所以-1可以表示为-1+131071,也就是131070,而+1可以表示为+1+131071,也就是131072。
ABC一般用来存放指令操作数据的地址,而地址可以分成3种:
- 寄存器id
- 常量表id
- upvalue id
每一个函数prototype都有一个属于本函数的常量表,用于存放编译过程中函数所用到的常量。常量表可以存放nil,boolean,number和string类型的数据,id从1开始。
每一个函数prototype中都有一个upvalue描述表,用于存放在编译过程中确定的本函数所使用的upvalue的描述。在运行期,通过OP_CLOSURE指令创建一个closure时,会根据prototype中的描述,为这个closure初始化upvalue表。upvalue本身不需要使用名称,而是通过id进行访问。
A被大多数指令用来指定计算结果的目标寄存器地址。很多指令使用B或C同时存放寄存器地址和常量地址,并通过最左面的一个bit来区分。在指令生成阶段,如果B或C需要引用的常量地址超出了表示范围,则首先会生成指令将常量装载到临时寄存器,然后再将B或C改为使用该寄存器地址。
在lopcodes.h中,对于每个指令,在源码注释中都有简单的操作描述。本文接下来将针对每一个指令做更详细的描述,并给出关于这个指令的示例代码。示例代码可以帮助我们构建出一个指令使用的具体上下文,有助于进一步理解指令的作用。对指令上下文的理解还可以作为进一步研究lua的编译和代码生成系统的基础。
在分析过程中,我们使用luac来显示示例代码所生成的指令。luac的具体使用方式为:
luac -l -l test.lua