Lua4虚拟机运行概述
Lua4虚拟机运行概述 概述 Lua是一种比较轻巧而且快捷的脚本语句,语法简单,但应用很广,很容易扩展。这里主要研究下Lua的原理与实现。我选取Lua4版本是因为Lua4简单一些,Lua5加了许多新特征,比如Metatable、Coroutine、尾调用、泛型for等,寄存器等分析起来会复杂一些,所以这里先从简单的入手。还是就是我的工作跟Lua4打的交道会多一些。 首
Lua4虚拟机运行概述
概述
Lua是一种比较轻巧而且快捷的脚本语句,语法简单,但应用很广,很容易扩展。这里主要研究下Lua的原理与实现。我选取Lua4版本是因为Lua4简单一些,Lua5加了许多新特征,比如Metatable、Coroutine、尾调用、泛型for等,寄存器等分析起来会复杂一些,所以这里先从简单的入手。还是就是我的工作跟Lua4打的交道会多一些。
首先可以打开Lua4的代码大概了解一下代码结构,我们可以看到lua的一些头文件.h和源代码文件.c,很多文件都是相对应的,比如lcode.c就有一个lcode.h与它同名。所以我们这里先解释每个h文件的作用,至于它的实现可以参照相应同名的c文件。lapi是Lua API的辅助函数。lcode是Lua的代码生成函数,Lua在用语法分析器分析脚本高级语言的时候就会调用lcode里面的函数来生成lua的字节码(opcode)。ldebug是调试模块的辅助函数。ldo是Lua的堆栈和函数调用等处理函数。lfunc有一些处理脚本函数和闭包的辅助函数。lgc是垃圾收集函数。llex是词法分析器,从脚本源代码的字符流分解出属性字(token)流,llimit是一些类型定义和系统依赖定义,相当于MFC的stdafx文件。lmem是内存管理模块,封装了一些内存的分配、释放等函数。lobject是Lua基本对象定义,包括运行时lua对象和脚本函数、哈希表等都在这里定义。lopcodes是Lua的字节码(指令)定义,脚本的源代码都会汇编成字节码给虚拟机执行。lparser是语法分析器,把属性字(token)流分析成一个个Lua的语句单位并调用代码生成函数生成字节码。lstate是全局状态类型定义;lstring是字符串表定义;ltable是lua表定义;ltm是一些标签函数;而lundump是预编译lua块。lvm是本文最重要的模块——lua虚拟机。lzio处理一些字符串流。主要流程可以看下图:
字节码
Lua4的字节码是一个32位的无符号整数,它用了低6位来保存操作码,高位来放一些简单的操作数,根据操作数的个数和类型,基本上有四种格式,一种是没有操作数的,还有三种是有不同类型操作数的:
我们可以看一下字节码的定义,可以看到它大概有以下几种类型:
1、函数调用返回等,比如:OP_CALL。
2、堆栈处理,比如:OP_PUSHINT。
3、变量和表的获取与设置,比如:OP_GETLOCAL等。
4、算术运算,比如:OP_ADD等。
5、跳转判断和循环,比如:OP_JMP等。
在不同的指令里,操作数有不同的作用,比如在OP_GETLOCAL中,有一个无符号参数U,它表示局部变量在函数局部变量内存空间的索引。这样你就可以通过这个操作数来得到这个局部变量确切的地址了。我们可以通过GETARG_U和SETARG_U等宏来获取和设置这些指令的操作数。
基本结构
Lua_State结构
首先一个很重要的结构是Lua_State,它是Lua的全局状态结构,当你要打开一个Lua脚本本执行之前,你必须调用lua_open函数打开并初始化一个Lua全局状态,此后的所有工作,包括注册给脚本使用的API函数,打开运行Lua脚本,调用Lua函数等,都要使用这个Lua的全局状态指针为参数,当你执行完Lua后还必须调用lua_close关闭Lua_State即可,从这些可以看出这个结构体的重要性。下面我们打开来看一下这个结构的成员。
堆栈相关数据 |
全局缓冲与其它数据 |
全局脚本函数、闭包等 |
全局表、字符串表和全局变量等 |
垃圾收集与调用跟踪相关数据 |
其中堆栈这块数据跟虚拟机关系比较重要,先来看这块,它包括top、stack、stack_last、stacksize等成员变量,它们的关系如下:
如上图,stack指向栈底,即堆栈最底下的那个元素,top指向栈顶,即即将入栈的元素存放的位置,statcksize表示堆栈最大可能的元素个数,stack_last表示最后一个允许入栈的元素。
全局缓冲主要是作为一个临时的缓冲区用,在语法分析等地方会经常用到。全局闭包函数把全局的代码段作为一个主函数,保存,然后脚本里自定义的其它函数作为它的子函数。全局字符串表等保存了脚本里用到的所有字符串,另外还有全局变量等全局数据。
Proto结构
还有个比较重要的结构是脚本函数Proto结构。它保存了一个脚本函数要用到的所有数据,那些全局作用域的脚本其实也是作为一个脚本函数来处理,其它定义的脚本函数都是作为它的子函数。下面我们来看看这个结构的数据:
实数表 knum |
字符串表 kstr |
子函数表 kproto |
指令流 code |
参数信息与其它数据 |
其它调试相关信息 |
实数表保存了脚本用到的一些实数,不过整数的话一般不放这个表而是作为一个字节码的立即操作数用;字符串表存放脚本里用的所有字符串,还包括全局变量名和函数名等。这样在遇到存取全局变量和调用函数的时候都用这个字符串当关键字来进行操作。子函数表里保存了这个函数的所有子函数,即函数里定义的函数。指令流就是这个函数的字节码列表了,运行这个函数就是从指令的第一句一直执行到最后一句,当前中间会有一些跳转和循环。下面我们来看看这个结构:
这样虚拟机执行这个函数的指令就很简单了,找到函数的code变量,然后从第一句开始执行,一直执行到最后一句结束。
执行过程
现在我们已经了解了Lua的一些基本结构和执行流程了,我们来具体看看Lua的虚拟机是怎样执行这些字节码来运行脚本功能的。我写了几个测试脚本,用来测试Lua各个基础语句的运行。
脚本的一些Lua代码如下:
-- 测试一些字符串
FirstName = "Wang";
LastName = "dehao";
FullName = "Name: " .. FirstName .. " " .. LastName;
-- 测试浮点数以及到字符串的转换
Pi = 3.14159;
PiString = "Pi: " .. Pi;
-- 测试一些逻辑
X = 0; -- 这里有点意思,设置nil才能到false那
if X then
Logic = "X is true.";
else
Logic = "X is false.";
end
-- 调用我们自定义的C函数来打印字符串
PrintStringList("Random Strings:", " ");
PrintStringList(FullName, PiString, Logic);
首先我们来看看这个全局脚本函数Proto的一些数据表,其中实数表knum只有一个元素就是:3.14159。字符串表kstr一共有16个数据,让我们来看看前7个:
索引 | 数据 |
0 | FirstName |
1 | Wang |
2 | LastName |
3 | dehao |
4 | FullName |
5 | Name: |
6 | (空格) |
第一段脚本:
-- 测试一些字符串
FirstName = "Wang";
LastName = "dehao";
FullName = "Name: " .. FirstName .. " " .. LastName;
经过lparser的语法解析和lcode的代码生成后,变成以下字节码:
1、OP_PUSHSTRING 1 // kstr[1] = "Wang",即"Wang"入栈
2、OP_SETGLOBAL 0 // kstr[0] = "FirstName",即把栈顶赋给FirstName
3、OP_PUSHSTRING 3 // kstr[3] = "dehao",即"dehao"入栈
4、OP_SETGLOBAL 2 // kstr[2] = "LastName",即把栈顶赋给LastName
5、OP_PUSHSTRING 5 // kstr[5] = "Name: ",即"Name: "入栈
6、OP_GETGLOBAL 0 // kstr[0] = "FirstName", 即FirstName的值"Wang"入栈
7、OP_PUSHSTRING 6 // kstr[6] = " ",即" "入栈
8、OP_GETGLOBAL 2 // kstr[2] = "LastName",即LastName的值"dehao"入栈
9、OP_CONCAT 4 // 连接上面4个字符串
10、OP_SETGLOBAL 4 // kstr[4] = "FullName",即把栈顶赋给FullName
可以看到上面的三行lua脚本被翻译成10行的字节码,而且都是跟堆栈相关的,让我们用图来解释以上字节码的含义。
从上面那段代码和堆栈流程图可以看出,Lua虚拟机的执行和堆栈息息相关,很多操作数都是保存在堆栈里,然后很多操作也是针对堆栈的操作。现在我们对赋值语句和字符串连接语句都有一个形象的认识的,下面让我们来看看它的逻辑语句,比如if,先看如下lua脚本代码:
-- 测试一些逻辑
X = 0; -- 这里有点意思,设置nil才能到false那
if X then
Logic = "X is true.";
else
Logic = "X is false.";
end
以上代码翻译成的字节码如下:
1、OP_PUSHINT 0 // 0为立即数,即0入栈
2、OP_SETGLOBAL 10 // kstr[10] = "X",即X=0
3、OP_GETGLOBAL 10 // kstr[10] = "X",即X的值0入栈
4、OP_JMPF 3 // 如果栈顶元素为nil则pc+=3:跳过三条指令。
5、OP_PUSHSTRING 12 // kstr[12] = "X is true.",即"X is true."入栈
6、OP_SETGLOBAL 11 // kstr[11] = "Logic",即Logic = "X is true."
7、OP_JMP 2 // pc += 2:直接跳过下面两条指令
8、OP_PUSHSTRING 13 // kstr[13] = "X is false."
9、OP_SETGLOBAL 11 // kstr[11] = "Logic"
10、...后续的指令
前面两条指令很好理解了,就是先让立即数“0”入栈,然后赋给全局变量X,第3条指令取出X的值,即为“0”放入栈顶,指令4则取出栈顶元素“0”并把它出栈,然后判断它是不是空值“nil”,如果是的话就把指令计数器增加3,即跳过指令5、6、7、直接开始执行指令8的内容,不过这里“0”不等于“nil”所以没通过判断,继续执行指令5和6,然后在指令7有一个无条件跳转,直接跳过8、9指令,因为8、9指令是脚本的else部分,根据条件不用执行了。这些指令都比较好理解,就不画堆栈图了。
上面的测试代码中还有一个是调用C提供的API函数的,让我们来看看它是如何在虚拟机执行的:
-- 调用我们自定义的C函数来打印字符串
PrintStringList("Random Strings:", " ");
指令如下:
1、OP_GETGLOBAL 14 // kstr[14] = "PrintStringList",取出"PrintStringList"的值入栈
2、OP_PUSHSTRING 15 // kstr[15] = "RandomStrings:",即"RandomStrings:"入栈
3、OP_PUSHSTRING 6 // kstr[6] = " ",即" "入栈
4、OP_CALL 0, 0 // 调用PrintStringList函数,第一个0表示闭包的位置跟堆栈base的偏移。第二个0表示准备接收闭包返回值的个数为0。
最后看看Lua的一些算术运算,看看如下测试代码:
x = 8;
y = 12;
z = x + y;
w = z + x * 2 / y;
前两句无非就是两次PUSHINT整数8或12进去,然后取出来分别赋给全局变量x和y。从第三句开始有算术运算了,让我们来看看它翻译的指令码:
1、OP_GETGLOBAL "x" // x为8,即8入栈 (为省麻烦直接把字符串表值填入)
2、OP_GETGLOBAL "y" // y为12,即12入栈
3、OP_ADD // 8 + 12 = 20,弹出一个元素并设置当前栈顶为20
4、OP_SETGLOBAL "z" // z = 20, 把20出栈并赋给z
5、OP_GETGLOBAL "z" // z为20, 即20入栈
6、OP_GETGLOBAL "x" // x为8, 即8入栈
7、OP_PUSHINT 2 // 压入立即数2
8、OP_MULT // 8 * 2 = 16, 弹出一个元素并设当前栈顶为16
9、OP_GETGLOBAL "y" // y为12, 即12入栈
10、OP_DIV // 16 / 12 = 1.3333,弹出一个元素并设栈顶为1.3333
11、OP_ADD // 20 + 1.3333 = 21.3333,弹出一元素并设栈顶21.3333
12、OP_SETGLOBAL "w" // w = 21.3333,把21.3333出栈并赋给w
为了更好理解,让我们来看看第3步如何执行算术运算:
可以看出算术操作基本上是对栈顶两个元素进行运算,弹出一个元素,并把结果保存在新的栈顶。而复杂的表达式无非就是先PUSH一些操作数,然后根据运算符优先级把级别高的运算先做,级别低的运算后做。
最后对比一下Lua5的寄存器式操作数处理,首先我们看一段子函数,先看看Lua4的字节码与运行过程,然后再了解一下Lua5的一些字节码的结构和函数常量表结构的改变再看看Lua5所翻译成的字节码以及运行的过程。由此来对比Lua4的基于堆栈式运算和Lua5的基于寄存器式运算有何异同,后者的好处和优势在哪。 测试脚本如下:
-- 用这个函数来执行一些计算
function Calc(a, b)
local c;
c = a + b;
c = c / 2;
c = c - a;
return c;
end
-- 外部调用测试部分
x = 8;
y = 12;
v = Calc(x, y);
首先看Calc函数的堆栈结构,然后再看看它的字节码:
1、OP_PUSHNIL // 入栈一个nil,即为局部变量c腾出空间
2、OP_GETLOCAL 0 // 获取堆栈基底0即参数a的值8,并放入栈顶
3、OP_GETLOCAL 1 // 获取堆栈基底1即参数b的值12,放入栈顶
4、OP_ADD // 把栈顶两个元素相加得20,弹出一元素,结果放新栈顶
5、OP_SETLOCAL 2 // 设置堆栈基底2即局部变量c为栈顶值20,并弹出栈顶
6、OP_GETLOCAL 2 // 获取局部变量c的值20放入栈顶
7、OP_PUSHINT 2 // 整数2入栈
8、OP_DIV // 把栈顶两元素相除得10,弹出一元素,结果放新栈顶
9、OP_SETLOCAL 2 // 设置局部变量c的值为栈顶值10,并弹出栈顶
10、OP_GETLOCAL 2 // 获取局部变量c的值10放入栈顶
11、OP_GETLOCAL 0 // 获取局部变量a的值8放入栈顶
12、OP_SUB // 把栈顶两元素相减得2放入栈顶
13、OP_SETLOCAL 2 // 设置局部变量c的为栈顶值2,并弹出栈顶
14、OP_GETLOCAL 2 // 获取局部变量c的值2放入栈顶
15、OP_RETURN // 函数返回
从这段字节码可以看出虽然参数和局部变量都在堆栈中,但是其中的运算还是要把它们的值依次取出来放入栈顶,然后执行相关运算再把栈顶值放相应的局部变量中。因为算术运算等许多操作都限定了是栈顶两个元素,较下面的元素是运算符左操作数,较上面那个是运算符右操作数。所以很多指令都浪费在把操作数取来取去的过程中了。好的,现在我们来看看Lua5的实现,首先Lua5的一些结构稍微有些改变,首先Proto结构中把字符串表kstr和实数表knum等表合并到一个常数表k中。这样程序中的字符串和实数等常数都统一放在常数表k中了。还有Lua5的字节码格式改成如下:
还有两种格式就是B、C两个操作数合并成Bx或sBx操作数的格式,这里因为没怎么用到,所以就不画图了。A一般是目标操作数的索引,所以基本上就是寄存器索引。而B或C是源操作数的索引,可能是寄存器,也可能是常数值,这里就用它们的最高位来判断是取寄存器还是取常数表中的值。
Calc函数的堆栈结构跟Lua4差不多,都是把参数和局部变量都放堆栈中,结构图如下:
先解释下寄存器的意思,其实寄存器并不是80X86的EAX、EBX等,而是关于堆栈的直接访问的一种方法,原来Lua4中访问堆栈只能通过PUSH、POP等方法访问它的栈顶元素,现在把堆栈基底加上一个索引值直接访问并读取或修改它的值,就像使用寄存器那样方便,所以叫寄存器了。Lua5字节码如下:
1、OP_ADD 2, 0, 1 // 把寄存器0(参数a)和寄存器1(b)相加,值放寄存器2中
2、OP_DIV 2, 2, 0* // 把寄存器2(变量c)除以常数表0(2),值放寄存器2(c)中
3、OP_SUB 2, 2, 0 // 把寄存器2(变量c)减去寄存器0(a),值放寄存器2(c)中
4、OP_RETURN // 函数返回
可以看出Lua5的字节码非常简洁,几乎可以跟高级语言一一对应了。第二条语句的0*注意一下,因为这个操作数的最高位为1,表示它的值是一个常数表的索引而不是寄存器的索引,而索引值为0,刚好对应常数表的第一项2,为了表示跟寄存器索引的区别,我加了一个“*”在后面。
小结
以上我们学习了Lua4的虚拟机基本知识,包括源代码结构概述、Lua基本结构和Lua字节码的执行细节。对于Lua的词法解析和语法分析等部分没有具体讲述,不过实现得都不算复杂,花点时间看看词法状态机和递归下降语法分析方法再理解Lua的语法还是能看懂的,对于Lua5的新特征与实现细节可以参照《The Implementation of Lua5.0》一书,在云风的博客上有中文版下载。
更多推荐
所有评论(0)