一. 前言
Lua是一种脚本语言,在游戏、嵌入式等领域有着众多应用。Lua的源码体量较小,但是有着诸多精彩之处,值得认真品鉴。在阅读了众多源码分析的文章和博客后,笔者发现不少博客、书籍有着诸多亮点,但是不能做到由浅入深且让人提纲挈领,亦存在种种缺陷之处,因此才有了本系列的源码分析文章。本系列文章不能代替读者自己去阅读源码,但是可以引导读者从高维度俯瞰源码,做到边思考边学习,从而不至于望而无所裨益,而是以Lua源码为借鉴,去激发自己的灵感,产生更多的思路。本系列文章大致覆盖内容如下。
首先开篇,即本文,会介绍什么是脚本语言,其设计意图和权衡取舍。
其次,就Lua的重要结构体、基本数据结构、Lua虚拟机、Lua环境、gc回收等Lua特色等方面来分析Lua的实现,从而做到系统性的充分了解。
最后,会对Lua的一些库进行介绍,并对Lua的性能监控、性能优化进行分析探讨,做到入源码而超越源码,从高维度洞悉掌握,而非沉溺于源码之中迷失了方向。
二. 何为脚本语言
设想有一个以C++开发的大型游戏项目,作为一个开发者,难免会遇到一系列的不良体验,可能包括
- 上手难度大,容易出现错误导致宕机
- 根据策划的方案设计玩法,需要反复修改逻辑,每次微调都需要重新编译,编译成本随着代码体量的增大可能会到几十分钟,可能只是一个简单的伤害计算公式,调来调去一天就过去了。
为此,如果有一个相对安全的沙盒开发环境,并且满足修改完代码无需编译无需关闭服务器重启,那一定可以极大的提高开发效率。而这就是脚本语言,即动态语言的设计精髓所在。
下面让我们抱着解决以上两个问题为主旨需求,来看看如果要自己设计一个脚本语言,需要做些什么:我们需要用一种规定好的语法,去写一个脚本,然后让其在程序中可以加载、解析并成功执行我们想要的命令,对于输入错误、逻辑有误的不予执行。因此,大致需要以下两方面功能。
- 代替编译器工作:如同静态语言需要经历语法解析等一系列过程最后生成可执行文件,脚本语言需要对输入的脚本进行解析生成对应的执行指令。
- 检测并执行指令:静态语言编译后即为机器指令直接可执行,而脚本语言经过上一步,翻译成了我们自己特定的执行指令,因此需要对其进行判断和实际的逻辑处理。这一步的判断实现了沙盒的安全性,而逻辑处理则实现了类似于CPU的运算功能。
为了实现这两方面功能,我们需要一套指令系统,以及一个能够不断执行各条指令的逻辑处理系统。这两个,也就是脚本语言中核心设计之指令集和虚拟机。
三. 指令集
假设我们需要做一个游戏的逻辑开发,那我们设计的这一套脚本语言,至少需要以下三方面的指令
- 内外部交互:程序主体和该脚本语言执行的交互,即发出指令、加载执行脚本及获取执行结果并执行后续脚本或静态语言逻辑的能力
- 内部基本操作:如字面值的读取,基本的算术运算,比较操作,栈操作等
- 控制流:条件、循环等控制流。
这里仅做一个简单的例子,例如我们要对某角色设置状态,则大致需要
1 | void SetPlayerHP(int nPlayerID, int nValue); |
对应的指令集
1 | enum ScriptInstructions |
当指令读入后,只需要调用对应的函数即可
1 | switch (intstruction) |
细心看到人可能发现问题了,只是一个指令的话,对应的传参怎么办呢?所以这里就需要考虑如何判断输入的是数值,因此内部基本操作指令必不可少一种字面值指令:读取的下一个字节为数值,进行存储以待使用。
四. 虚拟机
第三节中的例子,可以完成一个简单指令的操作,但是实际的游戏中,我们往往需要多条指令去实现一个目标,这里就需要多条指令保持一个相对关系并存储对应的值,这儿就和操作系统的栈功能很类似了,我们将这个栈及对应的栈操作称之为虚拟机。
1 |
|
对应的,指令的执行需要修改为
1 | case esiSetHP: |
最后再完善下循环函数
1 | void VM::Run() |
至此,整个指令执行的雏形就完成了,实现了基础指令集后,就可以做出各种复杂多样的需求。
五. 取舍
上文已经大致说明了脚本语言的基本设计思路和实现方法,但是这只能简单的做出一个有趣的雏形,距离真正的商业实践还有着很大的差距。对比lua/python
等成熟的脚本语言,其实还有着诸多实现上的取舍,这些也是将雏形变为成熟项目的精妙之处。
5.1 值
上文例中可以忽视了传值的类型问题,全部使用了整形,而在真实的程序之中,远不止如此,因此需要考虑如何设定传递不同种类的值的问题。大体上有几种做法:
- 只传递单一类型的值:好处就是简单方便,坏处就是过于不通用,牺牲了灵活性,因此几乎不被采纳
- 标签+联合传值:传递的值开头几位设计为类型的标签,后续才是长度、数值,这种做法好处当然是通用性强,问题是在于会占用更多的内存,需要对内存进行更精细的管理。如Lua等脚本语言就采取此方法,因此gc也就成了重中之重。
- 不带标签直接传值:将值的正确性交给脚本的编写者承担,这就意味着提高了运行速度的同时也带来了极大的风险。
5.2 指令和栈
如果指令均只能访问栈顶元素,那么一个简单的加法,就需要读指令,取值,读指令,取值,然后再相加。如果模拟CPU中的寄存器,则可以大幅提高速度,甚至可以通过寄存器存储栈帧位置实现栈内任意位置的访问。这也是指令和栈设计的另一种模式。两种模式如何选择,需要设计者多多思考。
- 仅基于栈,则指令会非常简单,代码生成也更容易,但是同样的一段逻辑处理,无疑需要更多的指令,越复杂的逻辑,需要的数量就会更繁琐。Java虚拟机、.NET CLR、Python虚拟机均基于此实现。
- 栈+寄存器,会导致指令变得复杂,但是相对的指令数就会减少。Lua正式基于这种设计思想而实(5.0之前基于栈,5.0之后改为此形式)。
5.3 内存处理
如果仅生成不释放,那么执行脚本的沙盒的空间会随着使用的增加而增长,因此如何释放、何时释放、释放权交给脚本编写者还是由沙盒内核心逻辑进行,都是很重要的问题,后续也会专门写文章分析Lua是如何进行内存管理和回收的。
5.4 使用和扩展
一个成熟的脚本语言,肯定需要考虑如何提供给大家使用,以及如何去不断地更新、并且支持大家一起去扩展其功能。常见的使用方法无外乎脚本编辑或者UI编辑,二者的取舍主要是面向群体的不同。如何不断更新,其实主要涉及到模块和模块之间耦合关系的问题,在设计之初就应该考虑到如何解耦各模块并留出扩展槽接口。
六. 实现的问题
在实际工程实现的初始阶段,还需要考虑诸如源文件的划分以及代码风格等问题,保持良好的统一才是成功的项目。下面简单介绍一下Lua的选用,仅作参考。
Lua wiki上有介绍各个源文件的具体作用(参考文献5),大致包括
- 功能函数实现(调试、I/O、内存分配、垃圾回收)
- 基本数据结构(栈、数据对象、字符串、函数、表)
- 脚本解析和字节码生成
- 字节码执行
- 独立库
- C API
- 可执行的解析器,字节码编译器
Lua的代码风格比较统一,不一定符合大家平日开发的习惯,但是没有好坏对错之言。值得一提的点是Lua的内部模块暴露出来的API以luaX_xxx
风格命名,即 lua
后跟一个大写字母表识内部模块名,而后由下划线加若干小写字母描述方法名。供外部程序使用的API则使用lua_xxx的
命名风格。此外,除了供库开发用的luaL
系列 API外,其它都属于内部 API,禁止被外部程序使用(参考文献5)。
具体命名参考规则及各源文件主要功能如下
1 | luaA_ - lapi.c - Lua API. Implements the bulk of the Lua C API (lua_* functions). |
七. 总结
看完本文,不会告诉你Lua或者Python是如何具体实现的,但是一定会给你如何设计并实现一门脚本语言的思路和入手着手点。由此再去看源码,你就不会局限于源码中一行一段的逻辑,而是高屋建瓴的站在脚本语言设计者的角度去思考,这就是本文希望能带给你的全部收获,可能也是最重要的东西。
参考文献
《A No Frills Introduction to Lua 5.1 VM Instructions》
《A Look at the Design of Lua》
《游戏编程模式》
《Lua 源码欣赏》
《The Implementation of Lua 5.0》