欢迎大家前往腾讯云 + 社区,获取更多腾讯海量技术实践干货哦~
本文由鹅厂优文发表于云 + 社区专栏
作者:郑小辉 | 腾讯 游戏客户端开发高级工程师
写在前面:本文所有的文字都是我手工一个一个敲的,以及本文后面分享的 Demo 代码都是我一行一行码的,在我之前已经有非常多的前辈研究过 Lua 虚拟机了,所以本文很多思想必然是踏在这些巨人的肩膀上的。
本文标题是”深入浅出 Lua 虚拟机”,其实重点在浅出这两字上。毕竟作者的技术水平有限。但是听说名字要起的屌一点文章才有人看,故而得名。
谨以此文奉献给那些对 Lua 虚拟机有兴趣的人。希望本文能达到一个抛砖引玉的效果。
Lua 的执行流程:
Lua 代码的整个流程:
如下图所示:程序员编码 lua 文件 -> 语法词法分析生成 Lua 的字节码文件(对应 Lua 工具链的 Luac.exe)->Lua 虚拟机解析字节码,并执行其中的指令集 -> 输出结果。
蓝色和绿色的部分是本文所试图去讲的内容。
词法语法分析:
我不准备讲 Lua 的所有词法分析过程,毕竟如果浪费太多时间来写这个的话一会策划同学要提刀来问我需求的开发进度如何了,所以长话短说,我就根据自己对 Lua 的理解,以某一个具体的例子来做分析:
Lua 代码块:
If a < b then a = c end
这句话咱们程序员能看懂,可是计算机就跟某些男程序员家里负责貌美如花的老婆一样,只知道这是一串用英文字符拼出来的一行没有任何意义的字符串而已。
为了让计算机能够读懂这句话,那么我们要做的第一件事情就是分词:既然你看不懂。我就先把一句话拆成一个一个单词,而且我告诉你每个单词的含义是什么。
分词的结果大概长下面这样:
分词结果 类型(意义)
if Type_If (if 关键字)
a Type_Var (这是一个变量)
< Type_OpLess(这是一个小于号)
b Type_Var(这是一个变量)
then Type_Then(Then 关键字)
a Type_Var (这是一个变量)
= Type_OpEqual(这是一个等号)
c Type_Var(这是一个变量)
end Type_End(End 关键字)
好了。现在计算机终于明白了。原来你写的这行代码里面有 9 个字,而且每个字的意思我都懂了。所以现在问题是,计算机理解了这句话了吗?
计算机依然不理解。就好像“吃饭”这句话,计算机理解了“吃”是动词,张开嘴巴的意思。“饭”是名词,指的米饭的意思。但是你把吃饭放在一起,计算机并不知道这是“张开嘴巴,把饭放进嘴里,并且咽到胃里”的意思。因为计算机只知道“张开嘴巴”和“米饭”两件事,这两件事有什么联系,计算机并不能理解。有人会说了:简单:吃 + 其他字 这种结构就让计算机笼统的理解为把后一个词代表的东西放进嘴巴里的意思就好了啊?这种情况适合”吃饭”这个词,但是如果这样你让计算机怎么理解“吃惊”这个词呢?所以这里引出下一个话题:语义解析。
关于语义解析这块,如果大家想要了解的更深入,可以去了解一下 AST(抽象语法树)。然而对于我们这个例子,我们用简单的方式模拟着去理解就好了。
对于 Lua 而言,每一个关键字都有自己特别的结构。所以 Lua 的关键字将成为语义解析的重点。我们现在涉及到的 if 这个例子:我们可以简单的用伪代码表述这个解析过程:
对于 if 语句我们可以抽象成这种结构:
If condition(条件表达式) then dosth(语句块) end
所以对 if 语句块进行解析的伪代码如下:
ReadTokenWord();
If(tokenWord.type == Type_If) then
ReadCondition() // 读取条件表达式
ReadThen() // 读取关键字 then
ReadCodeBlock() // 读取逻辑代码块
ReadEnd() // 读取关键字 End
End
所以为了让计算机理解,我们还是得把这个东西变成数据结构。
因为我只是做一个 Demo 而已,所以我用了先验知识。也就是我假定我们的 If 语句块逻辑结构是这样的:
If 小于条件表达式 then 赋值表达式 End
所以在我的 Demo 里转成 C ++ 数据结构就是 IfStateMent 大概是这样:
OK, 所以现在,我们整个词法语法分析都做完了。但是真正的 Lua 虚拟机并不能执行我们的 ifStateMent 这种东西。Lua 源码里的实现也是类似这种 TokenType 和 结构化的 if Statement whileStatement 等等,并且 Lua 没有生成完整的语法树。Lua 源码的实现里面,它是解析一些语句,生成临时的语法树,然后翻译成指令集的。并不会等所有的语句都解析完了再翻译的。语义解析和翻译成指令集是并行的一个过程。贴一个源码里面关于语义解析的部分实现:
OK,现在咱们已经把我们程序员输入的 Lua 代码变成了一个数据结构(计算机能读懂)。下一步我们要把这个数据结构再变成 Lua 虚拟机能认识的东西,这个东西就是 Lua 指令集!
至于转换的过程,对于我们这个例子,大概是这样的:
If a < b then a = c end
先理解条件 a<b:一种基于寄存器的指令设计大概是这样的:
a,b 均为变量。假定我们的可用的寄存器索引值从 10(0- 9 号寄存器都已经被占用了)开始:又假定我们有一个常量索引表:0 号常量:字符’a’,1 号常量:字符串’b’。那么 a <b 可以被翻译为这样:
LoadK 10,0:将_G[ConstVar[0]]载入 10 号寄存器:R[10] = _G[“a”]
LoadK 11,1:将_G[ConstVar[1]]载入 11 号寄存器:R[11] = _G[“b”]
LT 10,11 : 比较 R[10]<R[11]是否成立,如果成立,则跳过下一条指令 (++PC), 否则执行下一条指令。LT 后面跟着的一条指令必然是 JMP 指令。就是如果 R[10]<R[11] 成立,则不执行 JMP,直接执行 JMP 后面的一条指令(a= c 的语句块对应的指令集),否则直接跳过下面的一个语句块(跳过 a = c 的赋值过程)。
同理,继续进行 a = c 的翻译等等。
所以 If a < b then a = c end 在我写的 demo 里面最后被翻译成了:
OK,我们现在大概明白了从 Lua 代码怎么变成指令集的这件事了。
现在我们来具体看一下 Lua5.1 的指令集:
Lua 的指令集是定长的,每一条指令都是 32 位,其中大概长这样:
每一条指令的低六位 都是指令的指令码,比如 0 代表 MOVE,12 代表 Add。Lua 总共有 37 条指令,分别是 MOVE,LOADK,LOADBOOL,LOADNIL,GETUPVAL,GETGLOBAL,GETTABLE,
SETGLOBAL,SETUPVAL,SETTABLE,NEWTABLE,SELF,ADD,SUB,MUL,DIV,MOD,POW,
UNM,NOT,LEN,CONCAT,JMP,EQ,LT,LE,TEST,TESTSET,CALL,TAILCALL,RETURN,FORLOOP,
TFORLOOP,SETLIST,CLOSE,CLOSURE,VARARG.
我们发现图上还有 iABC,iABx,iAsBx。这个意思是有的指令格式是 OPCODE,A,B,C 的格式,有的指令是 OPCODE A,BX 格式,有的是 OPCODE A,sBX 格式。sBx 和 bx 的区别是 bx 是一个无符号整数,而 sbx 表示的是一个有符号的数,也就是 sbx 可以是负数。
我不打算详细的讲每一条指令,我还是举个例子:
指令编码 0x 00004041 这条指令怎么解析:
0x4041 = 0000 0000 0000 0000 0100 0000 0100 0001
低六位(0~5)是 opcode:000001 = 1 = LoadK 指令(0~37 分别对应了我上面列的 38 条指令,按顺序来的,0 是 Move,1 是 loadk,2 是 loadbool…..37 是 vararg)。LoadK 指令格式是 iABC(C 没用上,仅 ab 有用)格式。所以我们再继续读 ab。
a = 低 6~13 位 为 00000001 = 1 所以 a =1
b = 低 14~22 位 为 000000001 = 1 所以 b =1
所以 0x4041 = LOADK 1, 1
指令码如何解析我也在 demo 里面写了,代码大概是这样:
那么 Lua 文件经过 Luac 的编译后生成的 Lua 字节码,Lua 字节码文件里面除了包含指令集之外又有哪些东西呢?当然不会像我上面的那个词法语法解析那个 demo 那么弱智拉。所以下面我们就讲一下 Lua 字节码文件的结构:
Lua 字节码文件 (*.lua.bytes) 包含了:文件头 + 顶层函数:
文件头结构:
顶层函数和其他普通函数都拥有同样的结构:
所以我们是可以轻松自己写代码去解析的。后文提供的 Demo 源码里面我也已经实现了字节码文件的解析。
Demo 中的例子是涉及到的 Lua 源代码以及最终解析字节码得到的信息分别是:
OK,本文现在就剩最后一点点东西了:Lua 虚拟机是怎么执行这些指令的呢?
大概是这样的:
While(指令不为空)
执行指令
取下一条要执行的指令
End
每一条指令应该怎么执行呢???如果大家还有印象的话,咱们前文语义解析完之后转指令集是这样的:
a < b
LoadK 10,0:将_G[ConstVar[0]]载入 10 号寄存器:R[10] = _G[“a”]
LoadK 11,1:将_G[ConstVar[1]]载入 11 号寄存器:R[11] = _G[“b”]
LT 10,11 : 比较 R[10]<R[11]是否成立,如果成立,则跳过下一条指令 (++PC), 否则执行下一条指令。LT 后面跟着的一条指令必然是 JMP 指令。就是如果 R[10]<R[11] 成立,则不执行 JMP,直接执行 JMP 后面的一条指令(a= c 的语句块),否则直接跳过下面的一个语句块(跳过 a = c 的赋值过程)。那当然是指令后面的文字就已经详细的描述了指令的执行逻辑拉,嘿嘿。
为了真正的执行起来,所以我们在数据结构上设计需要 1,寄存器:2,常量表:3,全局变量表:
为了能执行我们 demo 里面的例子:
我实现了这段代码涉及到的所有指令
insExecute[(int)OP_LOADK] = &LuaVM::LoadK;
insExecute[(int)OP_SETGLOBAL] = &LuaVM::SetGlobal;
insExecute[(int)OP_GETGLOBAL] = &LuaVM::GetGlobal;
insExecute[(int)OP_ADD] = &LuaVM::_Add;
insExecute[(int)OP_SUB] = &LuaVM::_Sub;
insExecute[(int)OP_MUL] = &LuaVM::_Mul;
insExecute[(int)OP_DIV] = &LuaVM::_Div;
insExecute[(int)OP_CALL] = &LuaVM::_Call;
insExecute[(int)OP_MOD] = &LuaVM::_Mod;
insExecute[(int)OP_LT] = &LuaVM::_LT;
insExecute[(int)OP_JMP] = &LuaVM::_JMP;
insExecute[(int)OP_RETURN] = &LuaVM::_Return;
以 Add 为例:
bool LuaVM::_Add(LuaInstrunction ins)
{
//R(A):=RK(B)+RK(C) :::
//Todo:必要的参数合法性检查:如果有问题则抛异常
// 将 ins.bValue 代表的数据和 ins.cValue 代表的数据相加的结果赋值给索引值为 ins.aValue 的寄存器
luaRegisters[ins.aValue].SetValue(0, GetBK(ins.bValue) + GetBK(ins.cValue));
return true;
}
下面是程序的运行效果截图:
看完整个过程,其实可以思考这个问题:为什么 Lua 执行效率会远远低于 C 程序?
个人愚见:
1,真假寄存器:Lua 指令集涉及到的寄存器是模拟的寄存器,其实质还是内存上的一个数据。访问速度取决于 CPU 对内存的访问速度。而 C 程序最后可以用 win32 指令集 or Arm 指令集来执行。这里面涉及到的寄存器 EBX,ESP 等都是 CPU 上面的与非门,其访问速度 =CPU 的频率(和 cpu 访问内存的速度对比简直一个天上一个地上)。
2,指令集运行的平台:Lua 指令集运行的平台是 Lua 虚拟机。而 C 程序指令集运行的直接是硬件支持的。
3,C 里面的数据直接对应的就是内存地址。而 Lua 里面的数据对应的是一个描述这个数据的数据结构。所以隔了这么一层,效率也大打折扣了。
4,比如 Lua 的 Gc 操作等等这些东西都是 C 程序不需要去做的。。。。
OK,最后献上我写的这个 demo 的源代码:这份源代码是我在清明节在家的时候瞎写的。也就是说代码并没有经过耐心的整理,而且清明节有人找我出去喝酒,导致我有很长一段时间都处于“我艹快点码完我要出去喝了”这种心不在焉的状态,所以有些编码格式和结构设计都处处能看到随性的例子~ 毕竟只是一个 demo 嘛。人生在世,要有佛性,随缘就好!如果各位真的想进一步理解关于 Lua 虚拟机的东西,那么我推荐诸位有空耐着性子去读一读 Lua 虚拟机的源代码~
最后,诚挚感谢所有看到了最后这句话的同学。谢谢你们耐着性子看完了一个技术菜鸡的长篇废话。
Demo.zip
问答 Lua 支持 Unicode 吗?相关阅读 Lua 性能剖析使用 lua 小技巧 Lua 游戏开发学习【每日课程推荐】机器学习实战!快速入门在线广告业务及 CTR 相应知识