Lua虚拟机指令

Lua虚拟机会将Lua语句进行解析,解析为Lua虚拟机指令。Lua虚拟机指令有如下四十条(在lopcodes.h有定义)。有时我们需要在lua层面分析lua虚拟机的行为,那么解析lua虚拟机指令将是非常有用的。 我们可以通过luac来将lua脚本生成lua虚拟机指令。

Lua虚拟机指令

Lua的指令使用一个32bit的unsigned interger表示。所有指令都在lopcodes.h中。根据指令参数的不同,可以将所有指令分为4类:

Lua虚拟机指令类型

除了sBx之外,所有的指令参数都是unsigned integer类型。sBx可以表示负数,但表示方法非常特殊。ABC一般用力啊存放指令操作数据的地址,而地址可以分成3中:

  • 寄存器id
  • 常量表id
  • upvalue id

Lua使用当前函数的stack作为寄存器使用,寄存器id从0开始。当前函数的stack与寄存器数组是相同的该你那。stack(n)就是register(n)

每一个函数prototype中都有一个upvalue描述表,用于存放在编译过程中确定的本函数所使用的upvalue的描述。在运行期,通过OP_CLOSURE指令创建一个closure时,会根据prototype中的描述,为这个closure初始化upvalue表。upvalue本身不需要使用名称,而是通过id进行访问。

A被大多数指令用来指定计算结果的目标寄存器地址。很多指令使用B或C同时存放寄存器地址和常量地址,并通过最左面的一个bit来区分。在指令生成阶段,如果B或C需要引用的常量地址超出了表示范围,则首先会生成指令将常量装载到临时寄存器,然后再将B或C改为使用该寄存器地址。

再lopcode.h中,对于每个指令都有描述。在分析指令生成过程中,我们使用luac来现实实例代码生成的指令。luac的具体使用方式为

luac -l -l test.lua

Lua虚拟机指令有如下40条,在lopcode.h中的定义摘录如下:

typedef enum {
/*----------------------------------------------------------------------
name		args	description
------------------------------------------------------------------------*/
OP_MOVE,/*	A B	R(A) := R(B)					*/
OP_LOADK,/*	A Bx	R(A) := Kst(Bx)					*/
OP_LOADKX,/*	A 	R(A) := Kst(extra arg)				*/
OP_LOADBOOL,/*	A B C	R(A) := (Bool)B; if (C) pc++			*/
OP_LOADNIL,/*	A B	R(A), R(A+1), ..., R(A+B) := nil		*/
OP_GETUPVAL,/*	A B	R(A) := UpValue[B]				*/

OP_GETTABUP,/*	A B C	R(A) := UpValue[B][RK(C)]			*/
OP_GETTABLE,/*	A B C	R(A) := R(B)[RK(C)]				*/

OP_SETTABUP,/*	A B C	UpValue[A][RK(B)] := RK(C)			*/
OP_SETUPVAL,/*	A B	UpValue[B] := R(A)				*/
OP_SETTABLE,/*	A B C	R(A)[RK(B)] := RK(C)				*/

OP_NEWTABLE,/*	A B C	R(A) := {} (size = B,C)				*/

OP_SELF,/*	A B C	R(A+1) := R(B); R(A) := R(B)[RK(C)]		*/

OP_ADD,/*	A B C	R(A) := RK(B) + RK(C)				*/
OP_SUB,/*	A B C	R(A) := RK(B) - RK(C)				*/
OP_MUL,/*	A B C	R(A) := RK(B) * RK(C)				*/
OP_MOD,/*	A B C	R(A) := RK(B) % RK(C)				*/
OP_POW,/*	A B C	R(A) := RK(B) ^ RK(C)				*/
OP_DIV,/*	A B C	R(A) := RK(B) / RK(C)				*/
OP_IDIV,/*	A B C	R(A) := RK(B) // RK(C)				*/
OP_BAND,/*	A B C	R(A) := RK(B) & RK(C)				*/
OP_BOR,/*	A B C	R(A) := RK(B) | RK(C)				*/
OP_BXOR,/*	A B C	R(A) := RK(B) ~ RK(C)				*/
OP_SHL,/*	A B C	R(A) := RK(B) << RK(C)				*/
OP_SHR,/*	A B C	R(A) := RK(B) >> RK(C)				*/
OP_UNM,/*	A B	R(A) := -R(B)					*/
OP_BNOT,/*	A B	R(A) := ~R(B)					*/
OP_NOT,/*	A B	R(A) := not R(B)				*/
OP_LEN,/*	A B	R(A) := length of R(B)				*/

OP_CONCAT,/*	A B C	R(A) := R(B).. ... ..R(C)			*/

OP_JMP,/*	A sBx	pc+=sBx; if (A) close all upvalues >= R(A - 1)	*/
OP_EQ,/*	A B C	if ((RK(B) == RK(C)) ~= A) then pc++		*/
OP_LT,/*	A B C	if ((RK(B) <  RK(C)) ~= A) then pc++		*/
OP_LE,/*	A B C	if ((RK(B) <= RK(C)) ~= A) then pc++		*/

OP_TEST,/*	A C	if not (R(A) <=> C) then pc++			*/
OP_TESTSET,/*	A B C	if (R(B) <=> C) then R(A) := R(B) else pc++	*/

OP_CALL,/*	A B C	R(A), ... ,R(A+C-2) := R(A)(R(A+1), ... ,R(A+B-1)) */
OP_TAILCALL,/*	A B C	return R(A)(R(A+1), ... ,R(A+B-1))		*/
OP_RETURN,/*	A B	return R(A), ... ,R(A+B-2)	(see note)	*/

OP_FORLOOP,/*	A sBx	R(A)+=R(A+2);
			if R(A) 

UpValue 与 Glabals

在编译期,若果要访问变量a时,会依照以下顺序决定变量a的类型:

  • a是当前函数的local变量
  • a是外层函数的local变量,那么a时当前函数的upvalue
  • a是全局变量

local变量本身就存在于当前的register中,所有的指令都可以直接使用它的id来访问。对于upvalue,lua有专门的指令负责获取和设置。全局变量表放到最外层函数的名字为“_ENV”来访问。如果你访问全局变量a,相当于编译期帮你改成了_ENV.a来进行访问。

nameargsdesc
OP_GETUPVALA B CR(A) := UpValue[B]
OP_SETUPVALA BUpValue[B] := R(A)
OP_GETTABUPA B CR(A) := UpValue[B][RK(C)]
OP_SETTABUPA B CUpValue[A][RK(B)] := RK(C)

GETUPVAL将B作为索引的upvalue的值装在到A寄存器中。SETUPVAL将A寄存器的值保存到B为索引的upvalue中。

GETTABUP将B作为索引的upvalue当作一个table,并将C做为索引的寄存器或者常量当作key获取的值放入寄存器A。SETTABUP将A为索引的upvalue当作一个table,将C寄存器或者常量的值以B寄存器或常量为key,存入table

Table

nameargsdesc
OP_NEWTABLEA B CR(A) := {} (size = B,C)

NEWTABLE在寄存器A处创建一个table对象。B和C分别用来存储这个table数组部分和hash部分的初始大小。初始大小是在编译期计算出来并生成到这个指令中的,目的是使接下来对table的初始化填充不会造成rehash而影响效率。B和C使用“floating point byte”的方法来表示成(eeeeexxx)的二进制形式,其实际值为(1xxx) * 2^(eeeee-1)。

nameargsdesc
OP_SETLISTA B CR(A)[(C-1)*FPF+i] := R(A+i), 1 <= i <= B

SETLIST用来配合NEWTABLE,初始化表的数组部分使用的。A为保存待设置表的寄存器,SETLIST要将A下面紧接着的寄存器列表(1–B)中的值逐个设置给表的数组部分。

当表需要初始化数组元素数量比较小的情况下,例如:

local a = {1,1,1};
	1	[1]	NEWTABLE 	0 3 0
	2	[1]	LOADK    	1 -1	; 1
	3	[1]	LOADK    	2 -1	; 1
	4	[1]	LOADK    	3 -1	; 1
	5	[1]	SETLIST  	0 3 1	; 1
	6	[1]	RETURN   	0 1
constants (1) for 0x80048eb0:
	1	1 

第1行先用NEWTABLE构建一个具有3个数组元素的表,让到寄存器0中;然后使用3个LOADK向下面3个寄存器装入常量1;最后使用SETLIST设置表的1~3为寄存器1~寄存器3。

如果需要创建一个很大的表,其中包含很多的数组元素,使用如上方法就会遇到一个问题。将这些指按顺序放到寄存器时,会超出寄存器的范围。解决的办法就是按照一个固定大小,将这些数组元素分批进行设置。在Lua中,每批的数量由lopcodes.h中的LFIELDS_PER_FLUSH定义,数量为50。所以,大数量的设置会按照50个一批,先将值设置到表下面的寄存器,然后设置给对应的表项。C代表的就是这一次调用SETLIST设置的是第几批。回到上面的例子,因为只有3个表项,所以1批就搞定了,C的值为1。

下面是一个大表的设置:

local a = 
{
	1,2,3,4,5,6,7,8,9,0,
	1,2,3,4,5,6,7,8,9,0,
	1,2,3,4,5,6,7,8,9,0,
	1,2,3,4,5,6,7,8,9,0,
	1,2,3,4,5,6,7,8,9,0,
	1,2,3
};
	1	[1]	NEWTABLE 	0 30 0
	2	[3]	LOADK    	1 -1	; 1
	3	[3]	LOADK    	2 -2	; 2 
...
	50	[7]	LOADK    	49 -9	; 9
	51	[7]	LOADK    	50 -10	; 0
	52	[7]	SETLIST  	0 50 1	; 1
	53	[8]	LOADK    	1 -1	; 1
	54	[8]	LOADK    	2 -2	; 2
	55	[9]	LOADK    	3 -3	; 3
	56	[9]	SETLIST  	0 3 2	; 2
	57	[9]	RETURN   	0 1
constants (10) for 0x80048eb0:
	1	1
	2	2
	3	3
	4	4
	5	5
	6	6
	7	7
	8	8
	9	9
	10	0 

可以看到,这个表的初始化使用了两个SETLIST指令。第一个处理前50个,C为1,设置id从(C-1)*50 + 1开始,也就是1。第二个处理余下的3个,C为2,设置的id从(C-1)*50 + 1开始,也就是51。

如果数据非常大,导致需要的批次超出了C的表示范围,那么C会被设置成0,然后在SETLIST指令后面生成一个EXTRAARG指令,并用其Ax来存储批次。这与前面说到的LOADKX的处理方法一样,都是为处理超大数据服务的。

如果使用核能产生多个返回值的表达式(… 和 函数调用)初始化数组项,如果这个初始化不是表构造的最后一项,那么只有第一个返回值会被设置到数组项;如果是最后一项,那么SETLIST中的B会被设置为0,表示从A+1到当前栈顶都用来设置。

SETLIST只负责初始化表的数组部分,对于hash部分,还是通过SETTABLE来初始化。

nameargsdesc
OP_GETTABLEA B CR(A) := R(B)[RK(C)]
OP_SETTABLEA B CR(A)[RK(B)] := RK(C)

GETTABLE使用C表示的key,将寄存器B中的表项值获取到寄存器A中。SETTABLE设置寄存器A的B项为C代表的值。

Arithmetic

nameargsdesc
OP_ADDA B CR(A) := RK(B) + RK(C)
OP_SUBA B CR(A) := RK(B) – RK(C)
OP_MULA B CR(A) := RK(B) * RK(C)
OP_DIVA B CR(A) := RK(B) / RK(C)
OP_MODA B CR(A) := RK(B) % RK(C)
OP_POWA B CR(A) := RK(B) ^ RK(C)

上表中的指令都是lua本身的二元操作符——对应的标准3地址指令。B和C两个操作数计算的结果存入A中。

nameargsdesc
OP_UNMA BR(A) := -R(B)
OP_NOTA BA B R(A) := not R(B)

商标中指令对应 ‘-’ 和 ‘not’ 操作符,表示将B取反或not后放入A中。

在编译和指令生成阶段,lua还支持所有一元和二元操作符表达式的常量表达式折叠优化。也就是说如果操作数都是数字常量,可以在编译期计算出结果,就直接使用这个结果值,而不生成计算指令。

nameargsdesc
OP_CONCATA B CR(A) := R(B).. … ..R(C)

LEN直接对应 ‘#’ 操作符,返回B对象的长度,并保存到A中。

nameargsdesc
OP_CONCATA B CR(A) := R(B).. … ..R(C)

CONCAT对应字符串连接操作

Function

nameargsdesc
OP_CALLA B CA B C   R(A), … ,R(A+C-2) := R(A)(R(A+1), … ,R(A+B-1))

CALL执行一个函数调用。寄存器A中存放函数对象,所有参数按顺序防止在A后面的寄存器中。B-1表示参数个数。如果参数列表的最后一个表达式是变长的,则B会设置为0,表示使用A+1到当前栈顶作为参数。函数调用返回值会按照顺序存放在从寄存器A开始的C-1个寄存器中。如果C为0,则表示返回值的个数由函数决定。

nameargsdesc
OP_RETURNA Breturn R(A), … ,R(A+B-2)

RETURE将返回结果存放到寄存器A到寄存器A+B-2中。如果返回的为变长表达式,则B会被设置为0,表示将寄存器A到当前栈顶的所有值返回。

nameargsdesc
OP_CLOSUREA BxR(A) := closure(KPROTO[Bx])

CLOSURE为指定的函数prototype创建一个closure,并将这个closure保存到寄存器A中。Bx用来指定函数prototype的id。

nameargsdesc
OP_VARARGA BR(A), R(A+1), …, R(A+B-2) = vararg 

VARARG直接对应’…’运算符。VARARG拷贝B-1个参数到从A开始的寄存器中,如果不足,使用nil补充。如果B为0,表示拷贝实际的参数数量。

nameargsdesc
OP_SELFA B CR(A+1) := R(B); R(A) := R(B)[RK(C)]

SELF是专门为“:”运算符准备的指令。从寄存器B表示的table中,获取出C作为key的closure,存入寄存器A中,然后将table本身存入到寄存器A+1中,为接下来调用这个closure做准备。

a:b();
	1	[1]	GETTABUP 	0 0 -1	; _ENV "a"
	2	[1]	SELF     	0 0 -2	; "b"
	3	[1]	CALL     	0 2 1
	4	[1]	RETURN   	0 1

看一下与上面语法等价的表示方法生成的指令:

a.b(a);
	1	[1]	GETTABUP 	0 0 -1	; _ENV "a"
	2	[1]	GETTABLE 	0 0 -2	; "b"
	3	[1]	GETTABUP 	1 0 -1	; _ENV "a"
	4	[1]	CALL     	0 2 1
	5	[1]	RETURN   	0 1

比使用“:”操作符多使用了一个指令。所以,如果需要使用这种面向对象调用的语义时,应该尽量使用”:”。

关系与逻辑

nameargsdesc
OP_JMPA sBxpc+=sBx; if (A) close all upvalues >= R(A) + 1

JMP执行一个跳转,sBx表示跳转的偏移位置,被加到当前指向下一指令的指令指针上。如果sBx为0,表示没有任何跳转;1表示跳过下一个指令;-1表示重新执行当前指令。如果A>0,表示需要关闭所有从寄存器A+1开始的所有local变量。实际执行的关闭操作只对upvalue有效。

JMP最直接的使用就是对应lua5.2新加入的goto语句。Lua5.2中取出了以前专门处理关闭upvalue的指令CLOSE,而把这个功能加入到JMP中。

nameargsdesc
OP_EQA B Cif ((RK(B) == RK(C)) ~= A) then pc++
OP_LTA B Cif ((RK(B) <  RK(C)) ~= A) then pc++
OP_LEA B Cif ((RK(B) <= RK(C)) ~= A) then pc++

关系指令对RK(B)和RK(C)进行比较,然后将比较结果与A指定的boolean值进行比较,来决定最终的boolean值。A在这里为每个关系指令提供了两种比较目标,满足和不满足。比如OP_LT何以用来实现“<”和“>”。

nameargsdesc
OP_TESTA Cif not (R(A) <=> C) then pc++
OP_TESTSETA B Cif (R(B) <=> C) then R(A) := R(B) else pc++

逻辑指令用于实现and和or逻辑运算符,或者在条件语句中判断一个寄存器。TESTSET将寄存器B转化成一个boolean值,然后与C进行比较。如果不相等,跳过后面的JMP指令。否则将寄存器B的值赋给寄存器A,然后继续执行。TEST是TESTSET的简化版,不需要赋值操作。

Look

Lua5.2种除了for循环之外,其他的各种循环都使用关系和逻辑指令,配合JMP指令来完成。

local a = 0;
while(a < 10) do
    a = a + 1;
end
        1       [1]     LOADK           0 -1    ; 0
        2       [2]     LT              0 0 -2  ; - 10
        3       [2]     JMP             0 2     ; to 6
        4       [3]     ADD             0 0 -3  ; - 1
        5       [3]     JMP             0 -4    ; to 2
        6       [4]     RETURN          0 1

第二行使用LT对寄存器0和敞亮10进行比较,如果小于成立,跳过第三行的JMP,运行第四行的ADD指令,将a加1,然后运行第五行的JMP,跳转回第二行,重新判断条件。如果小于不成立,则直接运行下一个JMP指令,跳转到第六行结束。

对于for循环,Lua5.2使用了两套专门的指令,分别对应numeric for loop和generic for loop。

nameargsdesc
OP_FORLOOPA sBxR(A)+=R(A+2);
if R(A) <?= R(A+1) then { pc+=sBx; R(A+3)=R(A) }
OP_FORPREPA sBxR(A)-=R(A+2); pc+=sBx
 local a;
 for i = 1, 10 do
     a = i;
 end
main  (8 instructions at 0x80048eb0)
0+ params, 5 slots, 1 upvalue, 5 locals, 2 constants, 0 functions
        1       [1]     LOADNIL         0 0
        2       [2]     LOADK           1 -1    ; 1
        3       [2]     LOADK           2 -2    ; 10
        4       [2]     LOADK           3 -1    ; 1
        5       [2]     FORPREP         1 1     ; to 7
        6       [3]     MOVE            0 4
        7       [2]     FORLOOP         1 -2    ; to 6
        8       [4]     RETURN          0 1
constants (2) for 0x80048eb0:
        1       1
        2       10
locals (5) for 0x80048eb0:
        0       a       2       9
        1       (for index)     5       8
        2       (for limit)     5       8
        3       (for step)      5       8
        4       i       6       7
upvalues (1) for 0x80048eb0:
        0       _ENV    1       0

Numeric for loop内部使用了3个局部变量来控制循环,他们分别是"for index",“for limit”和“for step”。“for index”用作存放初始值和循环计数器,“for limit”用作存放循环上限,“for step”用作存放循环步长。对于上面的程序,三个值分别是1,10和1。这三个局部变量对于使用者是不可见得,我们可以在生成代码的locals表中看到这3个局部变量,他们的有效范围为第五行道第八行,也就是整个for循环。还有一个使用到的局部变量,就是使用者自己指定的计数器,上例中为"i"。我们可以看到,这个局部变量的有效范围为6~7行,也就是循环的内部。这个变量在每次循环时都被设置成"for index"变量来使用。

上例中2~4行初始化循环使用的3个内部局部变量。第五行FORPREP用于准备这个循环,将for index减去一个for step,然后跳转到第七行。第七行的FORLOOP将for index加上一个for step,然后与for limit进行比较。如果小于等于for limit,则将i设置成for index,然后跳回第六行。否则就退出循环。我们可以看到,i并不用于真正的循环计数,而只是在每次循环时被赋予真正的计数器for index的值而已,所以在循环中修改i不会影响循环计数。

nameargsdesc
OP_TFORCALLA CR(A+3), ... ,R(A+2+C) := R(A)(R(A+1), R(A+2));
OP_TFORLOOPA sBxif R(A+1) ~= nil then { R(A)=R(A+1); pc += sBx }
for i,v in 1,2,3 do
    a = 1;
end
main  (8 instructions at 0x80048eb0)
0+ params, 6 slots, 1 upvalue, 5 locals, 4 constants, 0 functions
        1       [1]     LOADK           0 -1    ; 1
        2       [1]     LOADK           1 -2    ; 2
        3       [1]     LOADK           2 -3    ; 3
        4       [1]     JMP             0 1     ; to 6
        5       [2]     SETTABUP        0 -4 -1 ; _ENV "a" 1
        6       [1]     TFORCALL        0 2
        7       [1]     TFORLOOP        2 -3    ; to 5
        8       [3]     RETURN          0 1
constants (4) for 0x80048eb0:
        1       1
        2       2
        3       3
        4       "a"
locals (5) for 0x80048eb0:
        0       (for generator) 4       8
        1       (for state)     4       8
        2       (for control)   4       8
        3       i       5       6
        4       v       5       6
upvalues (1) for 0x80048eb0:
        0       _ENV    1       0

Generic for loop内部也使用了3个局部变量来控制循环,分别是"for generator”,“for state”和“for control”。for generator用来存放迭代使用的closure,每次迭代都会调用这个closure。for state和for control用于存放传给for generator的两个参数。Generic for loop还使用自定义的局部变量i,v,用来存储for generator的返回值。

上例中1~3行使用in后面的表达式列表(1,2,3)初始化3个内部使用的局部变量。第四行JMP调转到第六行。TFORCALL教用寄存器0(for generator)中的closure,传入for state和for control,并将结果返回给自定义局部变量列表i和v。第七行调用TFORLOOP进行循环条件判断,判断i是否为空。如果不为空,将i的值赋给for control,然后跳转到第五行,进行循环。

发表评论