Lua如何执行代码
这一篇稍微深入一点,大概说说Lua虚拟机的指令集。试想一下原生语言是如何跑起来的?
- 编译器将程序编译成平台相关的机器码。
- 然后CPU一条条的执行里面的指令。
- 指令需要的操作数放在内存中:可能在线程相关的栈里,也可能在进程相关的堆里,不管在哪里它都是一个内存地址,用间接或直接的方式从该地址取数据就是。
脚本语言本质上也差不多是这样的逻辑,只不过执行这些指令的不是CPU,而是一个程序,它模拟CPU一条条的执行指令,所以也被称为虚拟机。
Lua的代码可以以文本的形式保存(也就是普通的脚本文件),在加载的时候才预编译为字节码;也可以预先编译为字节码文件(用luac),这样加载进来之后节省了预编译的过程,可以提高加载速度。有了字节码,虚拟机就可以执行指令。
那么指令到底有什么内容呢?说起来似乎也很简单:就是指明要执行什么操作,以及这些操作需要的操作数从哪里获取。
Register-based和Stack-based虚拟机
Lua 4.0之前的虚拟机是Stack-based
的执行方式,到5.0之后才改为Register-based
。
基于栈的虚拟机在执行时,指令只包含操作码,操作数需要从栈中取出,运算完毕后再将结果压回栈,它的指令格式相对更简单,每条指令占用的空间也更小,但是完成相同的运算往往需要更多的指令。
基于寄存器的虚拟机一条指令可以同时包含操作码和数据,因此可以完成的事情更多。操作码指定了要执行的操作,数据则因操作码而异,有可能是一个寄存器地址,也可能是纯粹的运算数值。这里所说的“寄存器”实际上是存在于栈上的元素,而地址是函数栈基址的一个偏移。
下面同样的代码分别翻译成Lua4和Lua5的字节码,从中可以看出两者的差别:
-- Lua 4.0
local a,t,i 1: PUSHNIL 3 -- 向栈压入3个nil,分别代码a, t, i
a=a+i 2: GETLOCAL 0 -- 从栈中取a
3: GETLOCAL 2 -- 从栈中取i
4: ADD -- a + i
5: SETLOCAL 0 -- 将结果存入a的栈位置
a=a+1 6: GETLOCAL 0 -- 从栈中取a
7: ADDI 1 -- a + 1
8: SETLOCAL 0 -- 将结果存入a的栈位置
a=t[i] 9: GETLOCAL 1 -- 从栈中取t
10: GETINDEXED 2 -- t[i]
11: SETLOCAL 0 -- 将结果存入a的栈位置
-- Lua 5.0
local a,t,i 1: LOADNIL 0 2 0 -- R(0)..R(2) := nil,R为寄存器
a=a+i 2: ADD 0 0 2 -- R(0) := RK(0) + RK(2) RK为寄存器或常量,这里是寄存器
a=a+1 3: ADD 0 0 250 -- R(0) := RK(0) + RK(250) RK(250)表示第0个常量,后面描述
a=t[i] 4: GETTABLE 0 1 2 -- R(0) := R(1)[RK(2)]
Lua5.3指令格式
在Lua中一条指令的大小为4个字节,其中最低6位为操作码(OpCode),由此可知Lua最多支持64条指令,不过现在(Lua5.3)只用到47条。操作码后面的内容因指令而异,可能有如下划分:
OP(6) A(8) C(9) B(9)
|-----|-------|--------|--------|
0 31
OP(6) A(8) Bx(18)
|-----|-------|-----------------|
0 31
OP(6) A(8) sBx(18)
|-----|-------|-----------------|
0 31
OP(6) Ax(26)
|-----|-------------------------|
0 31
总结起来有A, B, C, Bx, sBx, Ax这些部分,其他都是无符号数;sBx表示有符号数,它需要从原始的数值减去一个偏移才能得到真实值,这个偏移就是有符号的最大值,比如sBx的0表示-max,2*max表示max。
那么这些A,B。。。的数值含义到底是什么呢?它们是和OP相关的,不同的OP代表不同的含义,假设A, B。。抽象为X,可以归纳为下面几种:
- R(X) 表示第X个寄存器,寄存器存在于和函数关联的栈里面:当Lua进入一个函数时,它会从线程的栈中(每个Lua线程有一个栈)预分配足够的空间用于容纳函数参数,本地变量和临时变量,这些我们统称为“寄存器”。第X个寄存器就是以栈帧基址偏移的元素值。关于函数栈帧的内容则是另外的主题了。
- Kst(X) 表示第X个常量,常量就是函数中的那些字面量,比如
function local s = "aa" end
,aa
这个就是一个常量。Lua在编译函数时会生成一个Proto结构,Proto当中就有一个常量数组,而Kst(X)就是该数组中的第X的值。 - RK(X) 可能是寄存器索引也可能是常量索引,通过测试X的最高位决定。
- UpValue(X) 表示第X个upvalue,当Lua执行一个函数时(function...end),会创建一个新的闭包对象,对象里面有一个upvalues数组,用于引用其外部的本地变量。UpValue(X)即是从这个数组去取值。
- KPROTO(X) 表示第几个函数原型,上述闭包对象还有一个指向Proto对象的引用,而Proto还有一个Propto数组,表示函数里面的内嵌函数列表,KPROTO(X)即表示第几个函数原型。
现在来看指令列表应该就好理解多了,不过每条指令还是有很多细节在里面,在文末会给出Lua5.3的字节码参考手册,有兴趣直接跳过去查阅
typedef enum {
/*----------------------------------------------------------------------
name args description
------------------------------------------------------------------------*/
// 在寄存器间拷贝值
OP_MOVE,/* A B R(A) := R(B) */
// 加载常量给寄存器
OP_LOADK,/* A Bx R(A) := Kst(Bx) */
// 加载常量到寄存器,常量索引从下一条指令OP_EXTRAARG获取,
// 也就是下一行指令必定是OP_EXTRAARG
OP_LOADKX,/* A R(A) := Kst(extra arg) */
// 设置布尔值B给R(A),如果C为true则跳过下一条指令
OP_LOADBOOL,/* A B C R(A) := (Bool)B; if (C) pc++ */
// 加载nil给寄存器
OP_LOADNIL,/* A B R(A), R(A+1), ..., R(A+B) := nil */
// 取第B个Upvalue给寄存器
OP_GETUPVAL,/* A B R(A) := UpValue[B] */
// 从第B个Upvalue取出表,然后以RK(C)为Key取表的值给寄存器
OP_GETTABUP,/* A B C R(A) := UpValue[B][RK(C)] */
// 从第B个寄存器取出表,然后以RK(C)为Key取表的值给寄存器
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) */
// 新建一个表,表的数组和哈希初始大小分别是B,C
OP_NEWTABLE,/* A B C R(A) := {} (size = B,C) */
// 这是为了支持面向对象的成员函数调用的写法,如:self:func()
// 首先将R(B)表设置给R(A+1),相当于函数的第1个参数
// 接着从R(B)表取出函数给R(A),这样函数也准备好了。
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) */
// 连接操作,从R(B)一直连到R(C)
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_JMP配合
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++ */
// 条件测试 R(A)的布尔值如果不等于C,则跳过下一条指令
// 否则执行下一条指令是OP_JMP
OP_TEST,/* A C if not (R(A) <=> C) then pc++ */
// 条件测试和赋值 R(B)的布尔值如果不等于C,则跳过下一条指令
// 否则R(B)赋值给R(A),然后执行下一条指令是OP_JMP
OP_TESTSET,/* A B C if (R(B) <=> C) then R(A) := R(B) else pc++ */
// 函数调用语句,如果B>0,则参数是从R(A+1)往后B-1个。
// 如果B==0,则参数个数是从R(A+1)到栈顶
// 关于该指令的详细信息,请见参考资料
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) */
// 这两个指令实现数值的for循环
// 关于该指令的详细信息,请见参考资料
OP_FORLOOP,/* A sBx R(A)+=R(A+2);
if R(A) <?= R(A+1) then { pc+=sBx; R(A+3)=R(A) }*/
OP_FORPREP,/* A sBx R(A)-=R(A+2); pc+=sBx */
// 这两个指令实现泛型的for循环
// 关于该指令的详细信息,请见参考资料
OP_TFORCALL,/* A C R(A+3), ... ,R(A+2+C) := R(A)(R(A+1), R(A+2)); */
OP_TFORLOOP,/* A sBx if R(A+1) ~= nil then { R(A)=R(A+1); pc += sBx }*/
// 批量设置数组元素
// 关于该指令的详细信息,请见参考资料
OP_SETLIST,/* A B C R(A)[(C-1)*FPF+i] := R(A+i), 1 <= i <= B */
// 用函数原型创建一个闭包对象
OP_CLOSURE,/* A Bx R(A) := closure(KPROTO[Bx]) */
// 可变参数赋值
OP_VARARG,/* A B R(A), R(A+1), ..., R(A+B-2) = vararg */
// 前一条指令的附加参数
OP_EXTRAARG/* Ax extra (larger) argument for previous opcode */
} OpCode;
通过luac可以查看一个脚本文件的字节码,比如下面函数:
local function max(a, b)
local m = a
if b > a then
m = b
end
return m
end
反编译字节码得到:
$luac -l test.lua
function <test.lua:2,8> (6 instructions at 0x2138cb0)
2 params, 3 slots, 0 upvalues, 3 locals, 0 constants, 0 functions
1 [3] MOVE 2 0
2 [4] LT 0 0 1
3 [4] JMP 0 1 ; to 5
4 [5] MOVE 2 1
5 [7] RETURN 2 2
6 [8] RETURN 0 1
如果有兴趣的可以对着参考手册慢慢研究。但我觉得相对于OpCodes来说,Lua的对象模型,函数执行框架,垃圾回收这些似乎更有价值。这些留待后面分解。
参考资料
- The Implementation of Lua 5.0
- Lua 5.3 Bytecode Reference
- Lua源代码:
lopcodes.c|h,lvm.c|h
等等。