首页 > Lua栈的理解 > Lua栈的理解,从零开始实现 Lua 虚拟机 ( UniLua 开发过程 )

Lua栈的理解,从零开始实现 Lua 虚拟机 ( UniLua 开发过程 )

互联网 2021-06-24 05:23:53 Tags:Lua栈的理解

lua栈理解及lua和C++的数据交换API介绍 | 学步园关于Lua与C数据通信的栈深入理解 第2页Linux编程Linux公社 [转载]Lua和C++交互详细总结 勇敢的公爵 博客园Lua C库编程的一些心得AlienLee的博客CSDN博客Lua 性能剖析 云+社区 腾讯云Lua模块与require机制的理解 简书 jianshu.com从零开始实现 Lua 虚拟机 ( UniLua 开发过程 ) 知乎Lua的线程和状态 Ring1992 博客园

大约是2013年初,部分由于工作需要,部分由于个人的强烈兴趣,在云风大大的指导下,我使用 C# 编写了一套较为完整的 Lua 实现: UniLua ( GitHub - xebecnan/UniLua: A pure c# implementation of Lua 5.2 focus on compatibility with Unity )可惜后来由于工作调整,用纯 c 实现的 lua 就可以满足新项目需要,不再需要使用这个库,我也就没有继续完善了

UniLua 虽然最终没有上线使用,但是实现的过程还是充满乐趣的我基本是一边参考着原始 Lua,一边也偶尔参考 KopiLua 等其它 Lua 实现来做的但是我并不想像有的项目一样单纯的把 C 翻译到 C#,而是想尽量利用 C# 的特性来实现,很多实现地方也加了自己的一点小想法整个过程还是很有意思的,而且做完之后感觉对 Lua 的理解完全提升了一个层次~ 达成了做这个项目时我的很大一个目的 :)

实现过程中云风大大博客( 云风的 BLOG )上的各种关于 Lua 的文章( 当然还有直接的当面请教:D )和一本叫做《The Implementation of Lua 5.0》的书( https://www.lua.org/doc/jucs05.pdf )给了我很大的帮助

最近偶尔上 github 发现还是不断的有人在关注这个项目,忽然想到可以把当时开发 UniLua 的大致流程分享一下如果有跟我一样兴趣使然想自己撸一个 Lua 实现的朋友可以用于参考 :)

当然实现的过程,从另一方面看也可以作为阅读源码的一种指引。不过跟一些从外围无关痛痒的函数开始看起的建议不一样,我的方法是从最必要最核心的地方开始,先把能省略掉的地方全都省略掉 (=^-ω-^=)

( luajit 作者 mike 大大给出的源码阅读顺序建议就是从外围到内部的,也可以作为参考:mikemike comments on Ask Reddit: Which OSS codebases out there are so well designed that you would consider them 'must reads'? )

虽说是从零开始实现 Lua 虚拟机,不过本文不会尝试手把手的教每个细节( 那需要相当大的篇幅,之后有机会再写文章来讲解更多的细节吧 )而是提供一个大致的流程和思路,以及对一些关键点的讲解,适合有一定经验的朋友参考

由于是几年前写的代码了,回忆起来难免会有一些模糊,而我也是正在学习 Lua 的过程中,如有纰漏之处请指正~

P.S. UniLua 是对应 Lua 5.2.1 版本实现的,建议使用 Lua 5.2.1 源码和 UniLua 源码作为参考

Lua 源码:Lua: download areaUniLua 源码:GitHub - xebecnan/UniLua: A pure c# implementation of Lua 5.2 focus on compatibility with Unity

P.P.S. 找资料时发现了 Lua 作者们写的这篇 《The Evolution of Lua》 ( http://www.lua.org/doc/hopl.pdf ) 这么好的文章以前竟然没注意到 (((゚д゚)))

从 VM 开始----------lvm

为了能快速把架子搭起来,可以运行和看到反馈,可以先把文法解析、语法解析这些放到一旁,从核心的虚拟机开始做起比起闷头写一大堆代码但是跑不起来不知道对错,能快速的把程序跑起来可以看到一步一步的进展和反馈是非常重要的建立起快速的小步迭代的开发节奏,除了的帮助你更快的发现问题以外,还是对信心的极大鼓舞,更快的获得达成感可以帮助你更有热情的继续做下去因此我设定了一个 lazy 原则:尽量只关心最核心最必要的部分,能不做的就不做,能晚做的就晚做 _(:3 ⌒゙)_

VM 部分主要的工作是实现虚拟机的指令分派执行循环( luaV_execute )一条一条的实现各种类型的 OPCODE当所有 OPCODE 都实现的时候,虚拟机也就基本完成了~

所谓的指令分派执行循环,其实基本结构非常简单:

while 取下一条指令 {switch 指令类型 {case 指令类型A: 执行A处理逻辑; break;case 指令类型B: 执行B处理逻辑; break;......}}这里的指令就是 Instruction,指令类型就是 Instruction 中的 OPCODE,具体细节可以看下一节“关于 Lua 的字节码 Instruction ”

我们可以优先实现一些简单的 OPCODE:

OP_LOADK 加载一个常量OP_ADD 进行加法运算OP_RETURN 这个可以先做一个空实现然后,duang!就可以跑一些简单的类似

local a = 1local b = a + 5这样的代码了~

P.S. 这段代码生成的字节码如下:

LOADK 0 -1; 1ADD 1 0 -1; - 1RETURN0 1当然想要跑起来,还需要一些准备工作:实现 undump 来加载字节码 ( 附带 Proto 等相关结构 )然后可以用现有的 luac 来编译源代码生成字节码,再交给 undump 加载运行~

大致可以按以下顺序实现:

基础的变量操作: OP_MOVE OP_LOADK OP_LOADKX OP_LOADBOOL OP_LOADNIL算术运算: OP_ADD OP_SUB OP_MUL OP_DIV OP_MOD OP_POW OP_UNM关系运算 逻辑运算和跳转: OP_EQ OP_LT OP_LE OP_NOT OP_TEST OP_TESTSET OP_JMP

然后是复杂一些的:

函数: OP_CLOSURE OP_CALL OP_TAILCALL OP_RETURN循环: OP_FORLOOP OP_FORPREP OP_TFORCALL OP_TFORLOOPtable: OP_GETUPVAL OP_GETTABUP OP_GETTABLE OP_SETTABUP OP_SETUPVAL OP_SETTABLE OP_NEWTABLE OP_SELF OP_LEN OP_CONCAT进一步完善补完: OP_SETLIST OP_VARARG OP_EXTRAARG

好,大功告成!恭喜你有一个 Lua 虚拟机了 ( 此处应有掌声 )

当然事实上并没有这么简单,简单的 OPCODE 还好说,几行代码就可以搞定。但是一些复杂的 OPCODE 就牵涉到很多其它基本设施了例如你没有实现 table 相关的底层机制的话, 是没法实现那些相关的 OPCODE 的即便是基础的变量操作,也是需要 stack 结构来支持函数调用也是需要处理调用链,upvalue 等结构

关于 Lua 的字节码 Instruction-----------------------------lopcodes

虚拟机主循环的功能是一条接一条的执行 Lua 字节码,而字节码对应的数据结构就是 InstructionInstruction 中包含了代表功能类型的 OPCODE 部分,以及调用改功能的所有参数作为一种基础元素,在虚拟机运行时可能会有海量的 Instruction,因此采用比较紧凑的方式来表示Instruction 按参数数量和占用位数可以分为 B-C-A-OP, Bx-A-OP, Ax-OP 三种类型,默认内存布局如下

| B 9bits | C 9bits | A 8bits | OP 6bits || Bx 18bits | A 8bits | OP 6bits || Ax 26bits | OP 6bits |^-POS_B ^-POS_C ^-POS_A^-POS_OP首先需要关心的是 OP 部分。因为有 6 位,所以最多支持 64 种 OP ( Lua 5.2 中使用了 40 种 )OP 决定了这条指令的具体操作内容是什么,比如进行一次加法运算啊,或者从表中读取一个域等不同 OP 对应的参数数量和长短是不同的,也就对应了上面三种不同的 Instruction 内存布局具体某个 OP 是用哪一种呢?lopcodes.c 里有定义,例如/* TAB C mode opcode*/opmode(0, 1, OpArgR, OpArgN, iABC)/* OP_MOVE */其中T 表示 OPCODE 是一个 testA 表示 OPCODE 会修改的 A 参数指向的栈位置B 和 C 标记可以为以下值:OpArgN,/* argument is not used */OpArgU,/* argument is used */OpArgR,/* argument is a register or a jump offset */OpArgK /* argument is a constant or register/constant */mode 标记指定了 Instruction 的基本格式, 也就对应了上面提到的不同搞的内存布局enum OpMode {iABC, iABx, iAsBx, iAx};/* basic instruction format */其中 iABC, iABx, iAx 正好对应了上面的三种布局iAsBx 的意思是 i + A + sBx, 其中 sBx 的 s 是 signed 的意思, 具体可以看 lopcodes.h 中的注释

lcode 模块负责生成各种 Instructionlvm 模块负责解析各种 Instruction 并执行对应操作ldump 模块用于将内存中的函数 (包含其中的 Instruction 列表) 转换成对应的二进制数据lundump 与 ldump 相对,用于从二进制数据中加载 Instruction 列表和其它相关数据,生成 Lua 函数

Lua 对象模型------------lobject

要实现基础的变量操作,还有一点绕不开的就是需要实现 Lua 的各种类型对象幸运的是 Lua 中的类型特别少,少到只有仅仅 9 种!具体可以看 lua.h 中的定义:

#define LUA_TNIL0#define LUA_TBOOLEAN1#define LUA_TLIGHTUSERDATA2#define LUA_TNUMBER 3#define LUA_TSTRING 4#define LUA_TTABLE5#define LUA_TFUNCTION 6#define LUA_TUSERDATA 7#define LUA_TTHREAD 8NIL 类型你一定不会陌生,这个类型只有一个值:nil 代表一个空值BOOLEAN 类型也复杂不到哪去,两个值:true 和 falseLIGHTUSERDATA 类型代表了一个 C 指针NUMBER 类型代表一个数字。在 Lua 5.3 之前 Lua 中没有整数,都是浮点数,一般默认是一个 double (你可以在 luaconf.h 中修改)STRING 类型表示一个字符串,大家的老朋友了TABLE 类型是 Lua 中唯一的可以用于组织数据结构的类型,可以同时是一个数组也是一个哈希表,这样的设计让 Lua 语言整体非常简洁易用,但当然也挖了不少黑洞洞的坑,这里就不展开聊了 :PFUNCTION 类型就是函数,也是大家的老朋友。不过值得多提一下的是 Lua 的 FUNCTION 类型又分为 Lua closure, light C function, C closure 等三个子类型USERDATA 代表了一块内存,其中的数据对 Lua 不透明,但是由 Lua 管理生命周期,一般用于实现 C 库THREAD 就是线程了,不过并不是操作系统级别的线程,而是用于实现 Lua 中的协程

这里面其实大多数类型都可以映射到 C# 的基本类型,甚至连字符串都可以!这点与用 C 实现相比可以省不少事 :)稍微要花一些功夫的是 TABLE, FUNCTION, THREAD 等几个类型

Lua 所有类型的对象都用 TValue 结构表示:

struct lua_TValue {Value value_;int tt_;};其中 tt_ 是一个类型标签,对应上面提到的类型宏定义,用来区分对象类型value_ 用来存放具体的值内容,是一个联合:

union Value {GCObject *gc;/* collectable objects */void *p; /* light userdata */int b; /* booleans */lua_CFunction f; /* light C functions */numfield /* numbers */};typedef union Value Value;所有引用类型都是由 GCObject 指针间接引用,接受 GC 管理而值类型都是直接存放在 Value 中

Lua 的栈--------lstate

在实现变量相关的 OPCODE 时,你可能需要先对 Lua 的栈有所了解

虽然从 Lua 5.0 开始,Lua 就从基于栈的虚拟机( stack-based VM )改为了基于寄存器的虚拟机( register-based VM ),但是这并不影响 Lua 有一个栈( stack )~ 事实上 Lua 每个 thread 都有自己独立的栈~好吧,其实这两者之间并没有什么关系 ヽ(●´∀`●)ノ

stack-based VM 和 register-based VM 更多的是指的虚拟机存取数据指令的实现方式而这里我们要说的栈其实是指的调用栈( call stack ),是一块连续的内存区域,作为函数执行的环境,通常用来存放函数调用时的返回地址,传递给子函数的参数,函数中的局部变量和一些临时变量不过 Lua 的调用返回地址并不存放在栈上,而是用一个 CallInfo 链来存放

Lua 的栈可以被看作一系列的槽位,每个槽位上可以存放一个 TValue

struct lua_State {......StkId stack;/* stack base */int stacksize;......}typedef TValue *StkId;/* index to stack elements */lua_State 结构中的 stack 是一个指针,指向了一个可以动态增长的数组各种参数、局部变量、临时变量就临时的存活在这个动态数组表示的栈上,在函数执行结束时结束他们的生命

当然如果局部变量被 upvalue 引用,则可以在函数执行结束时得到 UpVal 的 +1s 加持,继续存活下去直到 GC 判定它不再被需要为止现在想想 UniLua 会不会在这个地方有内存泄漏,有时间检查一下 (|||゚д゚)后面“基础设施:函数 ”一节会再继续聊 upvalue

基础设施:错误处理------------------ldo ldebug

虚拟机执行过程中如果发现错误,就会尝试抛出错误。错误会被上层截获处理,或者一直抛出到最外层被默认处理器处理。在 C 实现的 Lua 中,这个过程是通过 setjmp 和 longjmp 来实现的对应在 C# 中,只能用 try {} catch {} 来实现了 ( UniLua/Do.cs at master · xebecnan/UniLua · GitHub )效率应该会低一些,不过好处是直接利用了 C# 的现成机制,简单直接粗暴。嗯,感觉不错 :D

基础设施:函数--------------ldo lfunc

虽然做到这里已经可以跑很多逻辑了,但是函数仍然是无法绕开的一个坑,填上函数相关的几条 OPCODE 的坑就又可以做很多事啦~

* OP_CLOSURE

这条 OPCODE 的作用是:根据一个函数原型( Proto )创建对应的闭包对象( Closure )

正如前面 Lua 对象模型一节提到过的一样在 Lua 的实现里,对 C 函数的引用分为不含 upvalue 的 light C function 和 含 upvalue 的 C closure而 Lua 函数统一都是 Lua closure

UniLua 里并没有必要实现 C 函数,而是实现了对应于 C# 函数的 LuaCsClosureValue 类

不过在这个阶段还没必要关心 C 函数,因为 OP_CLOSURE 只负责创建 Lua closure

Lua 中对应的结构如下:

typedef struct LClosure {ClosureHeader;struct Proto *p;UpVal *upvals[1];/* list of upvalues */} LClosure;比较重要的是其中指向 Proto 的指针,和 upvalue 列表

Proto 就是函数的原型,在 Lua 中对应的结构如下:

typedef struct Proto {CommonHeader;TValue *k;/* constants used by the function */Instruction *code;struct Proto **p;/* functions defined inside the function */int *lineinfo;/* map from opcodes to source lines (debug information) */LocVar *locvars;/* information about local variables (debug information) */Upvaldesc *upvalues;/* upvalue information */union Closure *cache;/* last created closure with this prototype */TString*source;/* used for debug information */int sizeupvalues;/* size of 'upvalues' */int sizek;/* size of `k' */int sizecode;int sizelineinfo;int sizep;/* size of `p' */int sizelocvars;int linedefined;int lastlinedefined;GCObject *gclist;lu_byte numparams;/* number of fixed parameters */lu_byte is_vararg;lu_byte maxstacksize;/* maximum stack used by this function */} Proto;里面存放了所有函数原型的相关信息:函数使用的常量、函数的字节码指令列表、函数中定义的子函数的原型列表、局部变量列表、upvalue 列表、参数和栈的信息、以及用于调试的名字和行号等

按现在的实现步骤的话,Proto 结构是由 undump 模块在加载字节码时创建的另外现在先被我们无视的 parser 模块也会在进行语法分析时创建 Proto 结构

创建 closure 最重要的部分就是初始化对应的 upvalue根据 Proto 中 Upvaldesc *upvalues 记录的相关信息来查找或创建对应的 UpVal 对象在我的另一篇文章中有较为具体的描述,有兴趣可以参考:《Lua upvalue 的一些实现细节》 https://zhuanlan.zhihu.com/p/22468297

* OP_CALL, OP_TAILCALL

这两条 OPCODE 的作用是:执行函数调用其中的区别可以看下一节“小细节:尾调用优化 ”按照 lazy 原则,我们可以先只实现 OP_CALL,并且只实现对 Lua 函数的 call

函数调用时主要是处理好两件事:参数 和 CallInfo 链

Lua 会在 CALL 指令之前生成好一系列的变量操作指令,将要调用的 closure 对象和调用传递的参数在栈上准备好但是因为 Lua 允许调用方传递的参数数量和函数声明接受的参数数量不一致,并且支持变长参数列表,所以执行 CALL 的时候还需要对参数做一些处理首先,如果调用方传递的参数比声明接受的参数数量少,则需要用 nil 补齐然后,函数中的指令只可能直接访问声明接受的参数,其它多传的参数只可能通过变长参数列表 "..." 来访问( 有一个专门处理它的 OPCODE: OP_VARARG,不过我们可以先不管 )调用函数时,我们在栈上划出一片新的区域作为被调用函数的栈帧( stack frame )使用,函数声明接受的参数被移动到栈帧的起点( base )之后,方便后续指令索引,多余的参数留在 base 以前,供 OP_VARARG 访问

CallInfo 是一个记录函数调用相关信息的一个结构

typedef struct CallInfo {StkId func;/* function index in the stack */StkId top;/* top for this function */struct CallInfo *previous, *next;/* dynamic call link */short nresults;/* expected number of results from this function */lu_byte callstatus;ptrdiff_t extra;union {struct {/* only for Lua functions */StkId base;/* base for this function */const Instruction *savedpc;} l;struct {/* only for C functions */int ctx;/* context info. in case of yields */lua_CFunction k;/* continuation in case of yields */ptrdiff_t old_errfunc;lu_byte old_allowhook;lu_byte status;} c;} u;} CallInfo;所有 CallInfo 都通过指针 *prev 和 *next 挂接在双向链表 L->ci 上一个 CallInfo 结构对应一个当前的 Lua 调用层次CallInfo 中存放了这一层调用的函数对象,栈的相关位置,pc 指针等关键信息

在函数调用和返回时,在链表中切换当前的 CallInfo 节点,然后填写或者读取相关信息其实挺简单的,不是么?

当把参数和 CallInfo 都准备好之后,只需要将指向当前执行指令的指针跳转到要调用的函数的指令就可以了

* OP_RETURN

OP_RETURN 用来处理返回调用当前函数的父函数

需要处理的事包括:luaF_close 将被 upvalue 引用的变量移到 UpVal 结构中,保证在函数销毁后依然存活到不再被任何地方引用为止luaD_poscall 处理 Hook (先忽略) 和将返回值放到栈上合适的位置处理 CallInfo 节点然后就可以快乐的返回到父函数了~

小细节:尾调用优化------------------上一节提到,函数调用除了有一个叫做 OP_CALL 的字节码,还有一个叫做 OP_TAILCALL 的字节码那么这个 OP_TAILCALL 跟 OP_CALL 有什么不同呢?

这里涉及到一个叫做“尾调用”的概念。当一个函数调用是当前函数的最后一条语句时,这个调用就可以被称为“尾调用”。例如:

local function foo()......return print("some string")end或

local function foo()if condition thenreturn print("some string")end......end这里的 return print("some string") 就是尾调用

那么尾调用有什么特别的地方呢?

上一节提到,对函数调用的每一层,都有一个 CallInfo 结构来对应存放其环境信息很容易就可以想到,当函数调用层数太深的时候,会需要大量的 CallInfo 结构在某些极端情况下,例如一个无限循环的递归调用,函数调用层数会有无限多层,因此会需要无限多个 CallInfo 结构当然事实上我们并没有足够的内存来创建无限多个 CallInfo 结构即使不是无限多个,足够深的函数调用层数也会导致创建大量的 CallInfo 结构而大量消耗内存

这个现象并不是 Lua 独有的,例如 C 也会遇到一样的问题C 在函数调用时没有一个单独的结构来存放相关信息,而是将所有需要的信息都存放在栈上调用层数太多导致存放在栈上的内容太多最后就 Boom!爆掉了,这就是所谓的 stack overflow

而在尾调用的情况下,调用子函数返回的时候其实不用返回到当前调用的地方了因为反正是最后一条语句了,就算是返回到调用的地方,接下来也会马上再返回到父函数那不如直接返回到父函数,省掉中间的这一步在调用子函数时,当前这一层调用的环境信息其实就已经没有作用了,不如提前释放掉这样在总是尾递归的情况下,不管调用多少层,都只需要保留一层环境信息就够了!

是的,这就是 OP_TAILCALL 的故事

小细节:Lua 5.2 引入的 lua_callk 和 lua_pcallk----------------------------------------------在 Lua 5.1 中只有 lua_call 和 lua_pcall那么为什么在 Lua 5.2 中要引入 lua_callk 和 lua_pcallk 呢?

如果有使用 Lua coroutine 的话,你很可能遇到过一个叫做“yield 跨越 C 调用边界”的问题

当你使用 coroutine.yield() 的时候,执行会从当前位置跳出去到外层调用 resume 的地方而当下一次再对这个 coroutine 调用 resume 的时候,执行会再恢复到上次调用 yield 的地方继续执行

不管是从当前 yield 位置跳出,还是 resume 到之前 yield 的位置,都设计到对整个调用栈环境的保存和恢复如果整个调用栈上都是 Lua 函数,那好说,一切都在我们掌握之中~但是如果其中某一层调用是 C 函数呢?那可就大大的不妙了Lua 虚拟机本身也是运行在 C 栈上的,我们没法简单的保存和恢复 C 函数调用的栈Lua 的 yield 是用 longjmp 实现的,longjmp 时会清理掉中间 C 函数的栈,然后我们就恢复不了这个 C 函数的环境了因此也就没办法正确的处理跨越 C 函数的 yield 和 resume

然后 Lua 的开发者想了个很“巧妙”的办法:我们没办法帮你处理 C 函数的环境恢复,那你自己做不就好了嘛~所以就有了 callk:允许你自己传递一个额外的 C 函数作为 continuation function当需要恢复 callk 直接调用的 C 函数时,相对的 Lua 会改为调用对应的 continuation function好啦,剩下的你自己在 continuation function 里自己搞定就好了

这是这个故事的简单版本,要完全说清楚可能需要更多的篇幅如果有兴趣可以自行看 Lua manual 的 Handling Yields in C 相关章节或者等我哪天试着 专门写一篇来更详细的讲解一下 :D

基础设施:table---------------ltable

table 作为 Lua 中唯一的容器类型,在 Lua 代码中扮演着非常重要的作用Lua manual 曰: table 本质上是一个关系数组,也就是说它是一个数组,用来存放一系列的 Lua 值,并且它的 key 不只可以是数字,也可以是其它任意除了 nil 和 Nan 之外的 Lua 值Lua table 还有一个特点就是所谓的异质性( heterogeneous ),其中存放的值可以同时是多种类型的。不像 C 说这是一个 int 数组,里面就只能存放 int 类型的值。Lua table 可能第一个值是数字,第二个值是个字符串,而第三个值是一个子 table!

好吧,感觉上面这段话有点过于科普了。对这篇文章感兴趣的人应该这些东西都很清楚了,我还是直接讲我的想法吧

我一开始的想法是一切从简,我只需要一个 C# 的 Dictionary,理论上就可以模拟出 Lua table 的各种特性了于是在 1 月份的时候我这样实现了一个简化的版本是的,这很符合我的 Lazy 原则!这样你可以很快的得到一个可用的实现

不过这样有个坏处,很多时候执行效率太低了。比如你当做传统意义的数组用时,基于 Dictionary 的实现效率就非常低于是 4 月份在整体性能优化的时候,我还是老老实实的按照 Lua 的方式改用了一个 ArrayPart + 一个 HashPart 的方式来重写了一遍

后来 雷先生( 雷雨 ) 还帮忙做了一个复用 Node 节点的优化,大大的提高了 Table 的效率

关于 Lua table 具体的实现细节倒是没什么特别想说的,不是特别复杂用一个数组和一个哈希表同时工作的方式还是挺有意思的,不过同时也给使用者挖了一些坑,看看源码就能明白

还有一个有意思的地方是 Hash表 对于冲突的处理方式,冲突的一系列值通过一个链表串起来,不过值本身还是放在 Hash 表中一系列冲突的值中其中一个保留在原始计算出所在的位置,也被称为主位置。其它值找一个空闲位置放起来,然后这一系列值串到一个链表上所有不在主位置的值,如果之后发现占了别的值的主位置,都给别的值让位,再重新找空闲位置放当没有空闲位置了,就会触发 rehash 过程,根据需要扩张 hash 表大小,再把老的值插入到新扩张后的 hash 表中

有一点实现细节值得注意:空闲位置是通过一个单向移动的 lastfree 指针来管理的每次找空闲位置的时候都把当前 lastfree 指向的位置返回出去,然后移动 lastfree 到下一个空闲位置而通过赋值为 nil 的方式释放掉的 slot,如果在 lastfree 指针已经划过去的地方,是不会被当成 free 位置再分配的也就是说每次冲突时会消耗一个 free 位置,冲突的次数足够多就一定会触发一次 rehash

基础设施:string----------------lstring

Lua 对象模型一节已经提到,UniLua 直接使用了 C# 的字符串类型来实现 Lua 的字符串,这可省了太多事了~

在 Lua 本身的实现中字符串也不算太复杂,看了一下 lua-5.2.1 中 lstring.c 只有一百多行,还包含了针对长短字符串区别做的优化~Lua 字符串的最大特点是有一个全局的字符串 hash 表,相同的短字符串在 Lua 中只存在一份:比较起来非常高效,直接比较指针即可

另外字符串虽然是 GC 对象,但是是不可变的,并不是引用类型,而是当作值类型来处理

标准库------lbaselib lcorolib ldblib liolib lmathlib loadlib loslib lstrlib ltablib

实现到这一步其实已经算是实现了完整的虚拟机逻辑了,可以跑大部分不调用库函数的 Lua 代码了

接下来可以继续实现和完善各种标准库。也没有太多值得说的,也就是一个一个的实现各种库函数

lcorolib(coroutine) 和 loadlib(package) 跟核心关系比较紧密lbaselib 非常必要,一定要实现lmathlib(math) 和 ltablib(table) 也很常用,实现起来也比较简单,优先实现liolib(io) loslib(os) lstrlib(string) 由于在游戏纯逻辑开发中作用较小,因此仅挑选了一些必要的函数实现ldblib(debug) 实现起来较为复杂,因此暂时只实现了最必要的 debug.traceback()

Lua 的 C API------------lapi lauxlib

Lua 是用 C 来实现标准库的,UniLua 里是用 C# 来实现这时就需要使用到 Lua 的 C API 了 ( UniLua 是对应的 C# API :P )

包含比较核心的 lapi 和在核心之上进行封装提供更多易用性的 lauxlibUniLua 中使用了 C# 的 interface 来定义

核心API: public interface ILuaAPI { ... }辅助API: public interface ILuaAuxLib { ... }值得留意的一点是 Lua C API 非常巧妙的基于栈交互的设计,虽然略嫌繁琐,但是非常灵活在自己实现库函数的时候,也要注意保持栈的平衡

P.S.从另外一个角度看,Lua 的 C API 虽然灵活,但也确实繁琐。自己为各种 C 库编写接口函数也是一件挺费力的事。所以有人做各种模版库、代码生成之类的东西来降低编写接口函数的负担LuaJIT 里实现了一个 FFI 库更是特别好用在 UniLua 里我也尝试着利用 C# 的反射功能实现了一个 ffi 库,不过由于反射的巨大开销,我并不建议在关心性能的场合直接使用

词法解析,语法解析和指令生成----------------------------llex lparser lcode

要实现基础库的 load 系列函数和 lua_load 系列 C API,就需要实现词法解析、语法解析、指令生成这一套系统了印象中以前心血来潮时翻了下 Lua 1.0 的代码,里好像是用了 lex/yacc不过后来成熟的 Lua 实现是用的手写的代码实现的配合 lua 的语法设计,这一套实现得相当高效,一遍编译,parse 的同时生成指令

词法解析部分相对比较简单( llex.c )除了字符串解析部分略微复杂一点以外都很容易懂

语法解析结合指令生成就要复杂一些了( lparser.c 和 lcode.c )有很多细节,要讲的话也需要相当的篇幅,而且以我现在的能力也没把握能完全讲清除暂时留作以后的课题吧,等有机会再单独写写这部分

关于指针--------实现 UniLua 的过程中我发现,C 的指针实在是太强大太灵活了,要在 C# 这种类型安全至上的语言里实现一些简单的对应功能,也要绕不少弯才行最后我用泛型实现了一个 Pointer 来用一个 List 和一个 Index 来在特定的情景下部分的模拟指针功能,详情可以看代码 https://github.com/xebecnan/UniLua/blob/master/Assets/UniLua/LuaState.cs#L13-L63

关于 GC ( 垃圾收集 )--------------------lgc

这里我偷了个懒,UniLua并没有实现任何 GC 相关代码~

不要着急,虽然我没有实现 GC,但是不用担心对象就会泄漏因为 UniLua 本身是由 C# 实现的,UniLua 中所有的对象和引用,都会对应到 C# 的对象和引用而 C# 自身有完善的的 GC 机制,所以干脆就完全交给 C# 的 GC 代劳了~

这样做的好处是实现简单 ( 没有实现当然简单 :D )坏处是不好实现 __gc 源方法和用 __mode 定义虚表 ( 因此也暂时没有实现 )

性能优化 ( 针对 C# 实现 )-------------------------尽量干掉堆上的分配,改为Value类型再栈上分配堆上分配大量小对象的问题是会压迫 GC

然后避免使用一些虽然略微方便,但是会分配临时对象的写法,例如 foreach

设计合理的缓存机制来避免重复工作

Next Step---------可以考虑如何更简单轻松的导出 Unity 的接口完善 ffi 库:目前的 ffi 库通过反射来实现,完全没有考虑效率,应该有一些方法可以优化完善这一块__gc 和 __mode 在很多时候还是很有用的,可以考虑下如何实现debug 库很多时候也很有用,可以考虑实现有听到反馈说在手机实机测试的时候遇到内存压力方面的问题,可以尝试找一下原因跟进最新的 lua-5.3.3 版结语

----从中秋心血来潮开始想写这篇文章,到现在终于写完,感觉将脑中的知识又梳理了一遍,还是挺愉快的之后有机会希望能把里面没有讲清除的的地方再好好完善一下

写完之后回头一看,好像有一点虎头蛇尾的感觉啊?不不不……绝对不是这样,凡事都要讲一个节奏,有轻重缓急,有详有略这样才好嘛是不是?是的,一定是这样(๑´ `๑)

免责声明:非注明原创的信息,皆为程序自动获取自互联网,目的在于传递更多信息,不代表本网赞同其观点和对其真实性负责;如此页面有侵犯到您的权益,请给网站管理员发送电子邮件,并提供相关证明(版权证明、身份证正反面、侵权链接),网站管理员将在收到邮件24小时内删除。