构建Lua解释器虚拟机的基础
前言
在本篇,我们正式进入到Lua解释器的开发阶段(这是一个遵循Lua5。3标准的项目)。本篇并不直接接入到设计和实现语法分析器和词法分析器的阶段,而是先设计和实现Lua虚拟机的基础数据结构(包括Lua最基本的数据结构,如基本数据类型、表示虚拟机状态的globalState和luaState结构、在函数调用中扮演重要角色的CallInfo结构等)以及设计和实现基于栈的C函数调用流程。这些都是理解后面虚拟机运作的基础。由于这是一个仿制项目,为了和官方版本做区分,就称之为dummylua,后面要称呼本项目时,一律用dummylua来表示。
本篇将分为几个部分:首先介绍工程目录结构的组织,以及为什么要这样组织,每个目录分别包含哪些文件,这些文件分别包含哪些内容;其次是着手进行Lua基本类型的设计与实现,实现表示Lua虚拟机状态的globalState和luaState结构,以及用于函数调用的CallInfo结构;接着是设计我们在使用层,要实现C函数,在栈中调用流程的API,并且构建从虚拟机状态初始化到函数完成调用的逻辑图,阐述这个过程;最后通过编写代码,将所有的流程实现。第一部分的功能已经完成开发和测试(Ubuntu、Mac和Windows平台)。
获取源码可以查看:
GitHubManisteindummyluatutorial:这是一个仿制lua解释器的项目,我希望通过逐步实现lua解释器的各个部分,更加深刻地掌握lua的基本结构和运作原理。
目录结构
在开始介绍项目的目录结构之前,我们不妨先回顾一下Lua运作的两种基本模式。
一种是创建Lua虚拟机,直接加载脚本并且直接运行,其遵循如下流程:创建Lua虚拟机状态实例加载标准库加载脚本,通过词法分析器Lexer和语法分析器Parser将脚本编译成Lua虚拟机能够识别的Opcodes,并存储在虚拟机状态实例中运行虚拟机,对虚拟机状态实例中的Opcodes进行执行
还有一种则是,预先将脚本编译,然后将内存中的指令信息,Dump到文件中,以Bytecode的形式存在,以后要运行的时候,直接加载Dump文件的Bytecode并且直接运行:创建Lua虚拟机状态实例加载标准库加载脚本,通过词法分析器Lexer和语法分析器Parser将脚本编译成Lua虚拟机能够识别的Opcodes,并存储在虚拟机状态实例中将虚拟机指令Dump成二进制文件,以Bytecode的形式保存在将来某个时刻,运行Lua虚拟机,并加载Dump后的文件,直接通过Dump数据,将指令结构还原回来,保存在虚拟机状态实例中运行虚拟机,对虚拟机状态实例中的Opcodes进行执行
这两种方式,前者从虚拟机创建到加载脚本,再到运行一气呵成。后者需要预先将Lua脚本编译成Bytecode,然后要使用的时候再加载运行,运行时省去了编译流程,比前者更快。不过现在Lua直接加载脚本并运行已经足够快了,除非对性能有极其苛刻的要求,否则前者已经能够满足我们的日常需要了。下面引用一张《TheLuaArchitecture》(Reference1)中的一张图,来展示一下后面一种方式的流程。
从上图,我们可以了解到一个流程,就是我们要运行Lua脚本,首先要创建Lua解释器(由于Lua是采用纯C来写的工程,因此函数和数据是分离的,这里其实也只是创建一个Lua虚拟机状态实例,也就是后面我们要介绍的luaState结构和globalState结构),然后通过编译器(Lexer和Parser)将脚本编译成虚拟机能够识别的指令(Opcodes),再交给虚拟机执行。因此,我们可以将编译和运行分割开来,他们共同使用的部分也单独抽离出来,于是我们的目录结构可以按如下所示的方式组织:3rd引用的第三方库均放置在这里bin编译生成的二进制文件放置在这里clib外部要在c层使用Lua的CAPI,那么只能调用clib里提供的接口,而不能调用其他内部接口commonvm和compiler共同使用的结构、接口均放置在这里compiler编译器相关的部分放置在这里test测试用例全部放置在这里vm虚拟机相关的部分放置在这里main。cmakefile
我们有理由相信,目录组织也是架构的一部分,上面附上了目录说明,能够清晰说明他们的分类和作用。我想构建的逻辑层次图如下所示:
3rd和common作为vm和compiler的基础模块而存在,外部在使用c接口的时候,只能通过clib里的辅助库来进行,以隐藏不必要暴露的细节。在定下目录结构以后,接下来将展示不同的文件分别有哪些文件。
目录组织完成以后,接下来确定有哪些文件了,我将文件内容展示到下面部分:3rd引用的第三方库均放置在这里bin编译生成的二进制文件放置在这里clib外部要在c层使用Lua的CAPI,那么只能调用clib里提供的接口,而不能调用其他内部接口luaaux。h供外部使用的辅助库luaaux。ccommonvm和compiler共同使用的结构、接口均放置在这里lua。h提供lua基本类型的定义,错误码定义,全项目都可能用到的宏均会放置在这里luamem。hlua内存分配器luamem。cluaobject。hlua基本类型luaobject。cluastate。h虚拟机状态结构,以及对其相关操作的接口均放置于此luastate。ccompiler编译器相关的部分放置在这里test测试用例全部放置在这里vm虚拟机相关的部分放置在这里luado。h函数调用相关的接口均放置于此luado。cmain。cmakefile
上面展示了我们本部分要实现的部分,后续开发会陆续添加新的文件,后面章节也会陆续引用这个片段。到现在为止,我们的目录结构就介绍完了,后面将介绍基本数据结构。
基本数据结构
基本类型
Lua的基本类型,包括luaInteger、luaNumber、lubyte、luaCFunction等,当然最典型的则是其能够代表任何基本类型的TValue结构。现在我们将逐一实现这些类型。
首先我们要实现两个宏,LUAINTEGER和LUANUMBER在commonlua。h里:commonlua。hifndefluahdefineluahstaticintPOINTERSIZEsizeof(void);ifPOINTERSIZE8defineLUAINTEGERlongdefineLUANUMBERdoubleelsedefineLUAINTEGERintdefineLUANUMBERfloatendifendif
然后在commonluaobject。h中加入下面几行代码:commonluaobject。hifndefluaobjecthdefineluaobjecthincludelua。htypedefLUAINTEGERluaInteger;typedefLUANUMBERluaNumber;endif
此时我们的luaInteger和luaNumber类型就定义完了,这里要先在commonlua。h中定义LUAINTEGER的目的是让LUAINTEGER适配32bit和64bit两种编译环境的逻辑保留在lua。h中。适配的目的也是为了后面的Value结构的各个字段能够完美对齐。官方实现版本,甚至对编译器类型做了适配,这里我们只考虑32bit和64bit的情况。
接下来我们要定义lubyte和luaCFunction两种基本类型,他们的定义如下所示:commonluaobject。hifndefluaobjecthdefineluaobjecthincludelua。htypedefstructluaStateluaState;typedefLUAINTEGERluaInteger;typedefLUANUMBERluaNumber;typedefunsignedcharlubyte;typedefint(luaCFunction)(luaStateL);endif
lubyte是个unsignedchar类型,官方将其取名作lubyte也许是表示luaunsignedbyte的意思。而luaCFunction基本上就是Lua栈中,能被调用的lightcfunction的形式了。
在完成了最基本类型的定义后,现在要来定义Lua的通用数据类型Value和TValue了,我们将这两个数据结构定义在commonluaobject。h中:commonluaobject。hifndefluaobjecthdefineluaobjecthincludelua。h。。。typedefunionluaValue{voidp;lightuserdataintb;boolean:1true,0falseluaIntegeri;luaNumbern;luaCFunctionf;}Value;typedefstructluaTValue{Valuevalue;inttt;}TValue;endif
Value是一个union类型,以上5种类型在32bit环境下,共用4个字节的内存,在64bit环境下共用8个字节的内存,这样做的目的是为了节约内存,p指针是用来存放lightuserdata的,这种值在官方Lua中,需要我们自行管理内存,由于目前我们没有实现GC,因此所有的自定义类型被创建出来后,均放在p中。TValue包含了Value类型的值value,以及使用一个int变量tt表示其类型。我们可以使用TValue来表示任何Lua对象。此外TValue的类型定义在commonlua。h中,如下所示:commonlua。hbasicobjecttypedefineLUATNUMBER1defineLUATLIGHTUSERDATA2defineLUATBOOLEAN3defineLUATSTRING4defineLUATNIL5defineLUATTABLE6defineLUATFUNCTION7defineLUATTHREAD8defineLUATNONE9
由于名称都很直观,这里就不加注释了。上面阐述的还是一般类型,其中LUATNUMBER、LUATSTRING和LUATFUNCTION还可以细分,我将细分的类型在commonluaobject。h里定义,放在这里更加贴近使用它的接口:commonluaobject。hluanumbertypedefineLUANUMINT(LUATNUMBER(04))defineLUANUMFLT(LUATNUMBER(14))luafunctiontypedefineLUATLCL(LUATFUNCTION(04))defineLUATLCF(LUATFUNCTION(14))defineLUATCCL(LUATFUNCTION(24))stringtypedefineLUALNGSTR(LUATSTRING(04))defineLUASHRSTR(LUATSTRING(14))
可以观察到,Lua数值的大类型定义的值在18之间,也就是0001210002之间,那么小类型不能占用低四位,只能往高位作文章,因此他们分别的含义为:LUANUMINTLUATNUMBER(04)0001200002000121LUANUMFLTLUATNUMBER(14)0001210000210001217LUATLCLLUATFUNCTION(04)0111200002011127LUATLCFLUATFUNCTION(14)0111210000210111223LUATCCLLUATFUNCTION(24)011121000002100111239LUALNGSTRLUATSTRING(04)0100200002010024LUASHRSTRLUATSTRING(14)0100210000210100220
这些类型值,将被存储在TValue的tt变量中。到目前为止我们已经介绍完了Lua基本数据类型。接下来我们将定义luaState和globalState。
luaState、CallInfo和globalState
在介绍完TValue数据结构以后,接下来要介绍和虚拟机息息相关的虚拟机状态数据结构luaState和globalState。如果说,虚拟机指令要在栈上运行,那么这个栈就是保存在luaState这个结构体中。同时我们的CallInfo结构,用于标记函数在栈中的位置,标记调用函数时,它的栈顶位于luaState栈中的哪个位置,同时它还保存要返回多少个返回值的标记。而globalState则是包含了luaState和一个内存分配器等,透过globalState来管理内存和luaState是非常方便的事情,在官方的Lua版本中,我们还需要透过它来管理GC。现在我们来定义这些个结构体,将其定义在commonluastate。h中:commonluastate。htypedefTValueStkId;structCallInfo{StkIdfunc;被调用函数在栈中的位置StkIdtop;被调用函数的栈顶位置intnresult;有多少个返回值intcallstatus;调用状态structCallInfonext;下一个调用structCallInfoprevious;上一个调用};typedefstructluaState{StkIdstack;栈StkIdstacklast;从这里开始,栈不能被使用StkIdtop;栈顶,调用函数时动态改变intstacksize;栈的整体大小structlualongjmperrorjmp;保护模式中,要用到的结构,当异常抛出时,跳出逻辑intstatus;luaState的状态structluaStatenext;下一个luaState,通常创建协程时会产生structluaStateprevious;structCallInfobaseci;和luaState生命周期一致的函数调用信息structCallInfoci;当前运作的CallInfostructglobalStatelG;globalState指针ptrdiffterrorfunc;错误函数位于栈的哪个位置intncalls;进行多少次函数调用}luaState;typedefstructglobalState{structluaStatemainthread;我们的luaState其实是luathread,某种程度上来说,它也是协程luaAllocfrealloc;一个可以自定义的内存分配函数voidud;当我们自定义内存分配器时,可能要用到这个结构,但是我们用官方默认的版本因此它始终是NULLluaCFunctionpanic;当调用LUATHROW接口时,如果当前不处于保护模式,那么会直接调用panic函数panic函数通常是输出一些关键日志}globalState;
我将结构的说明,直接写到了注释里,这些注释能够对结构进行很好的解释和说明。现在我们也完成了CallInfo、luaState和globalState的定义,接下来进入下一个阶段。
函数调用流程
到目前为止,我们已经完成了Lua基本数据结构的定义,本篇的目标除了定义这些数据结构,对其中的字段加以说明之外,还将设计和实现C函数在luaState栈中的调用。在开始实现具体细节之前,我们不妨从应用程序调用的视角来观察这个流程。我们以如下的代码为例子:main。cincludeclibluaaux。hstaticintaddop(structluaStateL){intleftluaLtointeger(L,2);intrightluaLtointeger(L,1);luaLpushinteger(L,leftright);return1;}intmain(intargc,charargv){structluaStateLluaLnewstate();创建虚拟机状态实例luaLpushcfunction(L,addop);将要被调用的函数addop入栈luaLpushinteger(L,1);参数入栈luaLpushinteger(L,1);luaLpcall(L,2,1);调用addop函数,并将结果push到栈中intresultluaLtointeger(L,1);完成函数调用,栈顶就是addop放入的结果printf(resultisd,result);luaLpop(L);结果出栈,保证栈的正确性printf(finalstacksized,luaLstacksize(L));luaLclose(L);销毁虚拟机状态实例system(pause);return0;}
正如我们之前设计的那样,要使用dummylua的接口,只能使用clib库里的luaaux。h里定义的接口,这些接口的开头一律以luaL表示,L指代Lib(官方能调用的接口并非均是以luaL开头的,这点需要强调一下)。上面一段代码,我们先创建了一个luaState实例,然后Push了要调用的函数和参数,最终调用了这个函数,这个函数最终将结果入栈,并返回给调用者。在探索代码细节之前,我先绘制几张逻辑图,来描述这个过程,以更好地让大家理解这个逻辑调用过程。
首先我们调用了luaLnewstate接口,用于创建和初始化luaState实例,这个接口不仅仅会创建luaState实例,也会创建一个globalState实例,并且初始化它们,globalState对外部是不可见的,它是Lua虚拟机内部使用的一个结构,在完成函数调用以后,我们可以得到下图的结构:
我们可以看到,globalState实例也被一起创建了,并且将mainthread指针指向了luaState实例,luaState默认就有一个CallInfo类型的变量baseci,这个CallInfo的func和top指针,指明了一个被调用的函数所能使用的栈的范围,每当我们调用一个新的函数时,会从luaState的stack中,取出一段作为该函数的栈空间,开始位置是CallInfo类型变量func指针的下一个位置,而能被使用的栈空间限制一般是20个(官方版本由LUAMINSTACK这个宏指定)。
此外luaState的stack指针,指向了栈起始的位置,而从stacklast开始,包括后面的EXTRASPACE则是不能被使用的空间。luaState中的top指针,则是在函数调用的过程中,动态改变的,它标记着正在被调用函数的栈顶位置。另外还要强调的一点,则是CallInfo的func指针,实质上是指向一个函数实例(baseci除外),而这个被func指针指向的位置,是不能作为栈顶存在的,也就是说,当前函数在被调用时,如果它是空栈,那么luaState的top指针将落在Lcifunc的上一格位置。正如上图所示,现在它是一个空栈。
在完成虚拟机状态实例创建以后,我们需要往luaState的栈中,Push我们希望被调用的函数,这个函数在我们使用的例子中,就是addop,我们通过一张图展示,在luaLpushcfunction执行完以后的状态:
目前看起来平平无奇,接下来将两个参数入栈,于是得到函数调用前的栈状态:
在完成参数入栈以后,我们就需要开始调用addop这个函数了,调用它的方式是执行luaLpcall(L,2,1)这行代码,这两个参数分别表示,我们传入两个参数,并且期待一个返回值。函数调用的过程稍微有点复杂,但是相对于后面涉及到的虚拟机运作,还算是小巫见大巫。要开始函数调用,首先要构建新的CallInfo实例,它会指定addop运行时,该函数栈可以使用的范围同时也包含,这个函数被期待返回多少个函数,于是我们可以得到如下图所示的状态:
从图中我们可以看到,新创建的CallInfo对象归属于addop这个函数,CallInfo的func指针指向了addop这个函数的位置,同时新创建的CallInfo对象的func和top指针限定了addop能够使用的栈空间的范围。同时我们也可以观察到luaState数据实例的ci指针指向了新创建的CallInfo实例,函数调用结束后,它会指向上一个CallInfo数据实例,ci指针本质就是用来标记当前调用的是哪个函数。在完成CallInfo创建以后,就可以开始调用addop这个函数了。在开始调用之前,我们先来看看栈的索引,我们可以观察到如下图所示,当索引为正时,addop函数栈的栈底从1开始,并且朝栈顶递增,如果索引为负数时,addop函数栈的栈顶从1开始,并且朝栈底递减。
在了解了栈索引的操作以后,addop内部的操作也就清晰明了了,就是把两个参数取出相加以后入栈(addop最后的return1,则表示addop实际上有一个返回值),于是得到如下图所示的状态:
现在进入到addop函数的最后调用阶段,就是销毁当前的CallInfo实例,并且将返回值移到addop的位置(大家可以尝试推导一下多个返回值的情况),于是得到下图的状态
现在控制权又回到了main函数了,main函数直接将栈顶变量取出打印,然后是把addop的返回值出栈,于是我们就完成了完整的函数调用。
函数调用实现
创建虚拟机实例
在完成展现逻辑图以后,我们现在开始着手实现具体的逻辑。本节将逐步设计和实现上节引用例子的各个步骤,首先我们要实现的是luaLnewstate接口,这个接口我定义在clibluaaux。hclibluaaux。c中,它的定义和实现如下所示:clibluaaux。hstructluaStateluaLnewstate();clibluaaux。cstaticvoidlalloc(voidud,voidptr,sizetosize,sizetnsize){(void)ud;(void)osize;printf(lallocnsize:ld,nsize);if(nsize0){free(ptr);returnNULL;}returnrealloc(ptr,nsize);}structluaStateluaLnewstate(){structluaStateLluanewstate(lalloc,NULL);returnL;}
如上所示,luaLnewstate实际上是转调了另一个库的接口luanewstate,luaLnewstate为其指定了一个内存分配函数lalloc。而我们的luanewstate函数则定义在commonluastate。hcommonluastate。c中,它们的定义是:commonluastate。hdefineLUAEXTRASPACEsizeof(void)defineG(L)((L)lG)structluaStateluanewstate(luaAllocalloc,voidud);commonluastate。cstructluaStateluanewstate(luaAllocalloc,voidud){structglobalStateg;structluaStateL;structLGlg(structLG)(alloc)(ud,NULL,LUATTHREAD,sizeof(structLG));if(!lg){returnNULL;}glgg;gudud;gfreallocalloc;gpanicNULL;Llgl。l;G(L)g;gmainthreadL;stackinit(L);returnL;}
luanewstate实际上是为globalState和luaState开辟内存,并完成初始化。这里luanewstate使用了从luaLnewstate传入的内存分配函数,这个函数的作用和C语言中的realloc类似,但是它规定,当nsize(意为newsize)为0时,要将内存释放掉。对于realloc(Reference2)这个函数,使用它非常方便,它的作用是先开辟nsize的内存,将旧的内容拷贝到新的内存块后再释放原来的内存,省去了我们手工处理的逻辑。而开辟内存本身,我们可以注意到,它并不是单独为globalState和luaState开辟内存,而是使用了一个叫做LG的数据结构:commonluastate。ctypedefstructLX{lubyteextra〔LUAEXTRASPACE〕;luaStatel;}LX;typedefstructLG{LXl;globalStateg;}LG;
这是参照官方版本做的设计,我们可以看到这里整合了一个globalState变量和一个LX变量,LX结构包含了一个luaState类型的变量和一个LUAEXTRASPACE大小的lubyte数组变量extra,这个变量我在官方版本中没有搜到使用到他的地方,也许是个历史遗留问题。至于为什么要通过一个LG结构,将globalState和luaState绑定在一起?这里我在云风的《lua源码欣赏》找到这样的解释(Reference3):
Lua的实现尽可能的避免内存碎片,同时也减少内存分配和释放的次数。它采用了一个小技巧,利用一个LG结构,把主线程luaState和globalState分配在一起。
余下的逻辑就是globalState和luaState的初始化操作,对于luaState而言,它还需要进行栈相关的初始化stackinit:luastate。cstaticvoidstackinit(structluaStateL){Lstack(StkId)luaMrealloc(L,NULL,0,LUASTACKSIZEsizeof(TValue));LstacksizeLUASTACKSIZE;LstacklastLstackLUASTACKSIZELUAEXTRASTACK;LnextLpreviousNULL;LstatusLUAOK;LerrorjmpNULL;LtopLstack;Lerrorfunc0;inti;for(i0;iLstacksize;i){setnilvalue(Lstacki);}Ltop;LciLbaseci;LcifuncLstack;LcitopLstackLUAMINSTACK;LcipreviousLcinextNULL;}
这里他为luaState开辟LUASTACKSIZE(官方定义40)大小的栈,并且设定了Ltop不能访问的区域,即stacklast以及往后的EXTRASPACE部分。至于EXTRASPACE的作用,我个人的观点是避免爆栈时(Ltop指针超越Lstacklast),访问了其他内存区域破坏其他内存块,这里被作为一个容错的缓冲区。其他部分则是各个变量的初始化,包括为baseci赋初值(限定栈范围等)。直接通过调用globalState的frealloc函数来开辟内存,是非常繁琐的,而且还可能遇到内存不足的情况,遇到这种情况为每一个调用内存的地方做NULL指针判定是相当冗余的,于是我在commonluamem。hcommonluamem。c中定义了一个luaMrealloc接口,M代表memory:luamem。hvoidluaMrealloc(structluaStateL,voidptr,sizetosize,sizetnsize);luamem。cvoidluaMrealloc(structluaStateL,voidptr,sizetosize,sizetnsize){structglobalStategG(L);intoldsizeptr?osize:0;voidret(gfrealloc)(gud,ptr,oldsize,nsize);if(retNULL){luaDthrow(L,LUAERRMEM);}returnret;}
这个接口只是多做了一个NULL指针判定,当内存申请失败时,调用luaDthrow抛出异常,这个luaDthrow后面介绍以保护模式调用函数时会进行说明。走到这一步我们的luaLnewstate流程也完成了,它的形态就是下图所示的那样。
销毁虚拟机实例
在介绍完创建虚拟机状态后,就需要相应地介绍销毁虚拟机状态的接口,这个就是我们要实现的luaLclose函数,和luaLnewstate一样,它定义在clibluaaux。hclibluaaux。c中:clibluaaux。hvoidluaLclose(structluaStateL);clibluaaux。cvoidluaLclose(structluaStateL){luaclose(L);}
它同样是转调commonluastate。h里的接口:commonluastate。hvoidluaclose(structluaStateL);commonluastate。cdefinefromstate(L)(cast(LX,cast(lubyte,(L))offsetof(LX,l)))staticvoidfreestack(structluaStateL){globalStategG(L);(gfrealloc)(gud,Lstack,sizeof(TValue),0);LstackLstacklastLtopNULL;Lstacksize0;}voidluaclose(structluaStateL){structglobalStategG(L);structluaStateL1gmainthread;onlymainthreadcanbeclosebecauseIhavenotimplementgc,soweshouldfreecimanualstructCallInfociL1baseci;while(cinext){structCallInfonextcinextnext;structCallInfofreecicinext;(gfrealloc)(gud,freeci,sizeof(structCallInfo),0);cinext;}freestack(L1);(gfrealloc)(gud,fromstate(L1),sizeof(LG),0);}
整个逻辑很简单,先把CallInfo实例释放掉,然后再把luaState的stack释放掉,最后把整个LG释放掉(上面已经解释过他是包含globalState和luaState类型的结构)。这里需要注意的一点,则是宏fromstate,他实际上是通过luaState指针L,通过offsetof函数,找到LG的起始位置,最终将整个LG释放掉,而offsetof需要我们指定类或结构体中成员的名称,offsetof函数接受两个参数,第一个是类或结构体的名称,第二个是成员变量名,将第一个成员变量名传入,我们会获得0。由于fromstate这个宏中的offsetof调用,是将LX的成员l传入,因此offsetof获得的值就是sizeof(extra),对于luaStateL而言,他正好能找到LG的起始地址,因此可以通过这种方式一并将luaState和globalState释放。
参数入栈
参数入栈其实就是将指定类型的值Push到栈中,为此我实现了luaLpushinteger、luaLpushnumber、luaLpushlightuserdata、luaLpushnil、luaLpushcfunction和luaLpushboolean,这些函数都在clibluaaux。hclibluaaux。c中,他们同样是转调commonluastate。hcommonluastate。c的接口,这里就不一一列举他们的实现了。Push的操作也很简单,其实就是对Ltop指向的位置赋值,然后Ltop,因此我们首先要对每种类型都实现一个set函数:commonluastate。hvoidsetivalue(StkIdtarget,intinteger);voidsetfvalue(StkIdtarget,luaCFunctionf);voidsetfltvalue(StkIdtarget,floatnumber);voidsetbvalue(StkIdtarget,boolb);voidsetnilvalue(StkIdtarget);voidsetpvalue(StkIdtarget,voidp);voidsetobj(StkIdtarget,StkIdvalue);commonluastate。cvoidsetivalue(StkIdtarget,intinteger){targetvalue。iinteger;targetttLUANUMINT;}voidsetfvalue(StkIdtarget,luaCFunctionf){targetvalue。ff;targetttLUATLCF;}voidsetfltvalue(StkIdtarget,floatnumber){targetvalue。nnumber;targetttLUANUMFLT;}voidsetbvalue(StkIdtarget,boolb){targetvalue。bb?1:0;targetttLUATBOOLEAN;}voidsetnilvalue(StkIdtarget){targetttLUATNIL;}voidsetpvalue(StkIdtarget,voidp){targetvalue。pp;targetttLUATLIGHTUSERDATA;}voidsetobj(StkIdtarget,StkIdvalue){targetvaluevaluevalue;targetttvaluett;}
之前我们介绍过TValue,这里其实就是为每一种类型的域赋值,并且为其加上类型,功能简单没有什么要说明的,对于push函数,这里仅举一例luapushinteger,其他的实现方法类似,大家可以直接去dummyluaGitHubManisteindummyluatutorial:这是一个仿制lua解释器的项目,我希望通过逐步实现lua解释器的各个部分,更加深刻地掌握lua的基本结构和运作原理。的工程里找。commonluastate。hvoidincreasetop(structluaStateL);voidluapushinteger(structluaStateL,intinteger);commonluastate。cvoidincreasetop(structluaStateL){Ltop;assert(LtopLcitop);}voidluapushinteger(structluaStateL,intinteger){setivalue(Ltop,integer);increasetop(L);}
出栈
出栈操作非常简单,只需要让Ltop,同时要注意top指针不要Lcifunc:commonluastate。hvoidluasettop(structluaStateL,intidx);intluagettop(structluaStateL);voidluapop(structluaStateL);commonluastate。cintluagettop(structluaStateL){returncast(int,Ltop(Lcifunc1));}voidluasettop(structluaStateL,intidx){StkIdfuncLcifunc;if(idx0){assert(idxLstacklast(func1));while(Ltop(func1)idx){setnilvalue(Ltop);}Ltopfunc1idx;}else{assert(Ltopidxfunc);LtopLtopidx;}}voidluapop(structluaStateL){luasettop(L,1);}
这里引入了设置栈顶指针的函数settop,逻辑也非常直观,这里不作解释。
获取栈上的值
获取栈上的值,实际上就是要传入栈的索引,然后获取他的值,通常我们需要该位置的值是什么类型,因此通常是一个尝试性的操作,如luatointeger(L,1)是尝试将栈顶(Ltop1)的值转成integer类型,期间可能成功,也可能失败,这里仅举luatointeger的例子,其他部分可以到dummylua工程里查阅。clibluaaux。hluaIntegerluaLtointeger(structluaStateL,intidx);clibluaaux。cluaIntegerluaLtointeger(structluaStateL,intidx){intisnum0;luaIntegerretluatointegerx(L,idx,isnum);returnret;}commonluastate。hluaIntegerluatointegerx(structluaStateL,intidx,intisnum);commonluastate。cstaticTValueindex2addr(structluaStateL,intidx){if(idx0){assert(LcifuncidxLcitop);returnLcifuncidx;}else{assert(LtopidxLcifunc);returnLtopidx;}}luaIntegerluatointegerx(structluaStateL,intidx,intisnum){luaIntegerret0;TValueaddrindex2addr(L,idx);if(addrttLUANUMINT){retaddrvalue。i;isnum1;}else{isnum0;LUAERROR(L,cannotconverttointeger!);}returnret;}
luaLpcall实现
在开始介绍pcall之前,我们先来看看不在保护模式下的函数调用逻辑是怎么实现的,这一些列操作包含在luaDcall函数内,它被定义在vmluado。hvmluado。c上:vmluado。hintluaDcall(structluaStateL,StkIdfunc,intnresult);vmluado。cintluaDcall(structluaStateL,StkIdfunc,intnresult){if(LncallsLUAMAXCALLS){luaDthrow(L,0);}if(!luaDprecall(L,func,nresult)){TODOluaVexecute(L);}Lncalls;returnLUAOK;}
其中的func参数,指定了要被调用函数的栈地址,而nresult则指定了这个函数被期望返回多少个返回值。这里并没有直接执行func,而是调用了一个luaDprecall为函数调用做准备,这个函数主要预处理Lua函数调用的情况,如果调用的是lightcfunction,那么就是直接执行,而不用进入到后续章节我们将实现的luaVexecute中去执行虚拟机指令。这个函数同样定义在vmluado。h和vmluado。c中:vmluado。hintluaDprecall(structluaStateL,StkIdfunc,intnresult);vmluado。cprepareforfunctioncall。ifwecallacfunction,justdirectlycallitifwecallaluafunction,justprepareforcallitintluaDprecall(structluaStateL,StkIdfunc,intnresult){switch(functt){caseLUATLCF:{luaCFunctionffuncvalue。f;ptrdifftfuncdiffsavestack(L,func);luaDcheckstack(L,LUAMINSTACK);检查luaState的空间是否充足,如果不充足,则需要扩展funcrestorestack(L,funcdiff);nextci(L,func,nresult);intn(f)(L);对func指向的函数进行调用,并获取实际返回值的数量assert(LcifuncnLcitop);luaDposcall(L,Ltopn,n);处理返回值return1;}break;default:break;}return0;}
目前我只处理了LUATLCF也就是lightcfunction这一种情况。调用func之前,首先会检查一下luaState的栈是否空间充足,如果新创建的CallInfo的top指针,不能在luaState栈的有效范围之内,那么栈就要扩充,通常是扩充为原来的两倍,这一段逻辑写在luaDcheckstack内,大家可以直接到dummylua工程里查看,这里就不列举了。这里需要注意的是,拓展栈需要申请一块新的内存空间,因此Lstack的地址也会改变,在扩充之后,需要修正func所指向的地址。完成这一系列的操作之后,就是为调用func创建一个CallInfo实例,大家可以将下面这段代码和之前展示的逻辑图联系起来,就能很好得理解了。staticstructCallInfonextci(structluaStateL,StkIdfunc,intnresult){structglobalStategG(L);structCallInfoci;ciluaMrealloc(L,NULL,0,sizeof(structCallInfo));cinextNULL;cipreviousLci;Lcinextci;cinresultnresult;cicallstatusLUAOK;cifuncfunc;citopLtopLUAMINSTACK;Lcici;returnci;}
接下来就是对func函数进行调用,调用完成后,他会返回一个值n,告诉我们他实际有多少个返回值,然后需要根据这个n值对返回值进行处理,这些处理逻辑实现在vmluado。hvmluado。c中的luaDposcall函数中:vmluado。hintluaDposcall(structluaStateL,StkIdfirstresult,intnresult);vmluado。cintluaDposcall(structluaStateL,StkIdfirstresult,intnresult){StkIdfuncLcifunc;intnwantLcinresult;switch(nwant){case0:{LtopLcifunc;}break;case1:{if(nresult0){firstresultvalue。pNULL;firstresultttLUATNIL;}setobj(func,firstresult);firstresultvalue。pNULL;firstresultttLUATNIL;Ltopfuncnwant;}break;caseLUAMULRET:{intnrescast(int,Ltopfirstresult);inti;for(i0;inres;i){StkIdcurrentfirstresulti;setobj(funci,current);currentvalue。pNULL;currentttLUATNIL;}Ltopfuncnres;}break;default:{if(nwantnresult){inti;for(i0;inwant;i){if(inresult){StkIdcurrentfirstresulti;setobj(funci,current);currentvalue。pNULL;currentttLUATNIL;}else{StkIdstackfunci;stackttLUATNIL;}}Ltopfuncnwant;}else{inti;for(i0;inresult;i){if(inwant){StkIdcurrentfirstresulti;setobj(funci,current);currentvalue。pNULL;currentttLUATNIL;}else{StkIdstackfunci;stackvalue。pNULL;stackttLUATNIL;}}Ltopfuncnresult;}}break;}structCallInfociLci;Lciciprevious;LcinextNULL;becausewehavenotimplementgc,soweshouldfreecimanuallystructglobalStategG(L);(gfrealloc)(gud,ci,sizeof(structCallInfo),0);returnLUAOK;}
函数第二个参数firstresult记录了第一个返回值在栈中的位置,而第三个参数nresult则指明了实际的返回值数量。很多情况下,我们调用函数时,期待的返回值数量nwant和实际的返回值数量nresult是不一样的,因此在luaDposcall函数里需要对各种情况进行处理。如果我们不期待有返回值,也就是nwant为0,那么实际的返回值不管有多少个都会被丢弃,此时Ltop指针也会指向Lcifunc的位置,函数以及之前Push进来的参数都会被出栈。
当我们期待只有一个返回值时,返回值结果会被移动到Lcifunc的位置,如果实际没有返回值,他就会被设置为NIL。当我们期待的返回值比实际的返回值多时,缺少的部分会被NIL值补足。当我们期待的返回值比实际少时,多出来的会被丢弃。这里还有一个特殊的情况,就是nwant为LUAMULRET的情况,这种情况就是实际有多少个返回值,他就返回多少个,不会丢弃,事实上LUAMULRET的值是1。经过一系列操作,对于被调用者而言现在栈上存在的不再是被调用函数自己的参数,而是被调用函数留下的返回值。
描述C函数在栈上的调用流程,是非常难写的,这也是为什么在讨论具体实现之前,先通过图的方式在概念上展示这个流程,目的是为了让大家有宏观上的感知,如果能理解上节的内容,那么这章节的内容也不难理解,虽然他可能很枯燥。在完成函数调用的实现以后,我们还需要讨论的一个东西,就是在保护模式下进行函数调用。事实上提供给外部使用的函数调用接口是luaLpcall,也就是默认所有的C函数调用都应该在保护模式下进行,他的定义如下所示:clibluaaux。hintluaLpcall(structluaStateL,intnarg,intnresult);clibluaaux。cfunctioncalltypedefstructCallS{StkIdfunc;intnresult;}CallS;staticintfcall(luaStateL,voidud){CallSccast(CallS,ud);luaDcall(L,cfunc,cnresult);returnLUAOK;}intluaLpcall(structluaStateL,intnarg,intnresult){intstatusLUAOK;CallSc;c。funcLtop(narg1);c。nresultnresult;statusluaDpcall(L,fcall,c,savestack(L,Ltop),0);returnstatus;}
luaLpcall接口只是让使用者填入,被调用的函数有多少个参数,以及期望有多少个返回值,luaLpcall是通过narg这个表示参数个数的值,推断出被调用函数在栈中的位置,并且调用另一个函数luaDpcall,我们可以观察到,这里一并将fcall函数传入到luaDpcall中,在保护模式下执行fcall,实际上就是执行我们刚刚讨论的luaDcall。调用luaDpcall,除了需要传入实际执行栈函数调用的fcall以外,我们还需要传入一个CallS类型的变量c,这个变量会在fcall中使用到,其实是保存调用信息临时数据的一个结构。此外我们还需要传入栈顶在函数调用前的位置,用于异常后恢复栈之用。下面我们来看看luaDpcall的定义:vmluado。hintluaDpcall(structluaStateL,Pfuncf,voidud,ptrdifftoldtop,ptrdifftef);vmluado。cintluaDpcall(structluaStateL,Pfuncf,voidud,ptrdifftoldtop,ptrdifftef){intstatus;structCallInfooldciLci;ptrdifftolderrorfuncLerrorfunc;statusluaDrawrunprotected(L,f,ud);if(status!LUAOK){becausewehavenotimplementgc,soweshouldfreecimanuallystructglobalStategG(L);structCallInfofreeciLci;while(freeci){if(freecioldci){freecifreecinext;continue;}structCallInfopreviousfreeciprevious;previousnextNULL;structCallInfonextfreecinext;(gfrealloc)(gud,freeci,sizeof(structCallInfo),0);freecinext;}Lcioldci;Ltoprestorestack(L,oldtop);seterrobj(L,status);}Lerrorfuncolderrorfunc;returnstatus;}
我们的fcall最终会在luaDrawrunprotected中被调用,而在调用之前,我们需要对栈的一些信息进行保存,包括栈当前调用的是哪个函数(保存ci),栈当前的错误处理函数处于哪个位置。而一旦调用失败,我们就需要恢复原来栈的状态。
现在我们来看看luaDrawrunprotected的定义:vmluado。hintluaDrawrunprotected(structluaStateL,Pfuncf,voidud);vmluado。cdefineLUATRY(L,c,a)if(setjmp((c)b)0){a}ifdefWINDOWSPLATFORMdefineLUATHROW(c)longjmp((c)b,1)elsedefineLUATHROW(c)longjmp((c)b,1)endifstructlualongjmp{structlualongjmpprevious;jmpbufb;intstatus;};intluaDrawrunprotected(structluaStateL,Pfuncf,voidud){intoldncallsLncalls;structlualongjmplj;lj。previousLerrorjmp;lj。statusLUAOK;Lerrorjmplj;LUATRY(L,Lerrorjmp,(f)(L,ud);)Lerrorjmplj。previous;Lncallsoldncalls;returnlj。status;}
这里我们定义了一个lualongjmp的结构,这是用来辅助我们处理异常情况用的。在初始化luaState的时候,我们的errorjmp的值为NULL,errorjmp是否为NULL是判断当前函数是否处于保护模式的重要指标,这个我们后面会提到。我们的fcall函数,是在LUATRY这个宏里被调用的,而这个宏的作用是什么呢?由于C语言没有trycatch机制,因此我们需要通过一种方式来模拟trycatch机制来捕捉异常,这里就需要用到我们的setjmp和longjmp函数了。setjmp和longjmp是什么接口?我们来引用Linuxmanpagelongjmplongjmp(3)Linuxmanpage的一段说明:
Description
longjmp()andsetjmp(3)areusefulfordealingwitherrorsandinterruptsencounteredinalowlevelsubroutineofaprogram。longjmp()restorestheenvironmentsavedbythelastcallofsetjmp(3)withthecorrespondingenvargument。Afterlongjmp()iscompleted,programexecutioncontinuesasifthecorrespondingcallofsetjmp(3)hadjustreturnedthevalueval。longjmp()cannotcause0tobereturned。Iflongjmp()isinvokedwithasecondargumentof0,1willbereturnedinstead。siglongjmp()issimilartolongjmp()exceptforthetypeofitsenvargument。If,andonlyif,thesigsetjmp(3)callthatsetthisenvusedanonzerosavesigsflag,siglongjmp()alsorestoresthesignalmaskthatwassavedbysigsetjmp(3)。
是不是看着很晕?那我们就通过一个例子来加以解释和说明:includestdio。hincludeincludesetjmp。hjmpbufb;intmain(void){intretsetjmp(b);printf(setjmpresultd,ret);if(ret0)longjmp(b,1);return0;}
此时的输出是:setjmpresult0setjmpresult1
为什么printf函数会被调用两次?答案是我们第一次调用setjmp的时候,将当前的栈信息保存在了jmpbuf变量b中,并且返回0值,此时程序继续执行,这时候因为返回值ret为0,因此我们会调用longjmp函数。这个longjmp函数会恢复jmpbuf变量b中记录的环境,因此程序会跳回到setjmp调用的地方,并且longjmp还将第二个参数1,带给了setjmp,并作为其第二次调用的返回值。利用这种特性,我们可以模拟C的trycatch机制,下面再举个例子:includestdio。hincludeincludesetjmp。hintexcept0;jmpbufb;voidfoo(){printf(foo);longjmp(b,1);}intmain(void){if((exceptsetjmp(b))0){foo();}else{printf(excepttyped,except);}return0;}输出fooexcepttype1
把setjmp(b)不为0时,执行的部分,作为捕捉异常的逻辑,把longjmp作为throw函数使用,回顾一下我们的LUATRY和LUATHROW宏,是不是有一种异曲同工之妙?当然,我们不会直接在被调用的函数里,发生异常时直接调用LUATHROW宏,而是使用另一个接口luaDthrow:vmluado。hvoidluaDthrow(structluaStateL,interror);vmluado。cvoidluaDthrow(structluaStateL,interror){structglobalStategG(L);if(Lerrorjmp){Lerrorjmpstatuserror;LUATHROW(Lerrorjmp);}else{if(gpanic){(gpanic)(L);}abort();}}
这个函数,根据Lerrorjmp的值,判断当前函数是否处于保护模式中,处于与不处于处理的方式是不相同的,如果Lerrorjmp为非NULL值,那说明函数是在LUATRY的包裹下执行的,因此可以调用LUATHROW跳出当前的调用,如果不是,那么则调用之前设置好的panic函数(如果有),然后退出进程。
到目前为止,函数调用和保护模式的代码我们均已实现。
结束语
这篇文章篇幅较长,但是我们的目标是清晰的,设计和实现Lua基本数据结构、栈和基于栈的C函数调用的设计与实现,并对这些内容进行了不同程度的解析。尽管我们现在并未涉及到任何编译器和虚拟机指令相关的内容,但是当前的内容是理解后续内容的基础,也是关键所在,希望后续内容能够给大家带来更精彩的内容。
ReferenceTheLuaArchitecture图片位于Lua:AnEmbeddedScriptLanguage这个章节中:Figure1:processofinitializingLuaandloadingascriptfilerealloc
a)expandingorcontractingtheexistingareapointedtobyptr,ifpossible。Thecontentsofthearearemainunchangeduptothelesserofthenewandoldsizes。Iftheareaisexpanded,thecontentsofthenewpartofthearrayareundefined。b)allocatinganewmemoryblockofsizenewsizebytes,copyingmemoryareawithsizeequalthelesserofthenewandtheoldsizes,andfreeingtheoldblock。Lua源码欣赏2。2全局状态机
这是侑虎科技第1300篇文章,感谢作者Manistein供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。
人民日报金句选编(五十九)君子不会被物所左右,小人往往被物所左右。君子不会被物所左右,小人往往被物所左右。那些在别人看不见的地方也自律的人,真的连老天都不忍辜负。请相信在暗处执着生长,终有一日馥郁传香。梁实
人民日报用一个整版,聚焦山东这个小村庄黄河边的蓑衣樊村俯瞰。王克军摄(人民视觉)告别穷日子,过上好日子,迈向美日子,从贫困村到全国文明村,山东省淄博市高青县蓑衣樊村家住黄河边,吃上乡村旅游饭泛舟蓑衣樊村湿地。闫立军摄(
石家庄房价四连跌,石家庄楼市的神话结束了,石家庄9月房价对比石家庄房价现在跌的是真有点惨了,石家庄房价从2017年一直跌到现在,房价整整跌了五年了。在2020年的时候,全国房价还迎来了一波反弹,但唯独石家庄房价没有跟着一起反弹,反而还是加快
达赖与班禅究竟是什么关系?二者谁的地位更高?青藏高原之上,古老神秘的藏传佛教竟有两位在世活佛?他们之间的关系如何,到底谁的地位更加尊崇?佛教入藏的关键人物七世纪中叶,西藏还叫吐蕃,当时吐蕃王朝的开国统治者是干布(617650
18岁女学生被特务抓去,用铁镊子土铐子折磨,她痛苦万状却不泄密18岁女学生被特务抓去,用铁镊子土铐子折磨,她痛苦万状却不泄密女英雄姚爱兰(19121930),又名蔼兰,江苏省六合县东王乡人,1912年生,1927年就读南京晓庄师范,1929年
煤炭强势未来预期向好,继续看好板块行情上周国内价格上周坑口售价继续上涨2030元,山西5500坑口价逼近1070元每吨,北方港口动力煤价继续上涨,最新港口报价1400元每吨。支撑煤价主要因素有一当前北方港口库存低至约2
坦克携手徕卡,共赴性能与光影的极致体验之旅山在哪里,便去登风去哪里,便去追心向哪里,便去寻,基于共同的向往和热爱,坦克携手徕卡学院正式发布首部联名TVC,以镜头探索文化肌理,以轰鸣回应旷野之声,开启极致探索之旅。跨山入海,
低部署门槛高影音体验,全景声回音壁科普,非极致影音最佳选择头条创作挑战赛引子爱看电影的好基友在我的影响下已经能够熟练地使用NAS进行PT下载并在电视上进行观影了。周末小聚饭后一起看电影。小伙伴问我在家看电影总觉得缺点什么。我下意识的回答缺
下辈子,请你绕开我吧文明婉儿坐在落叶纷乱的街口,带着些许彷徨,杂着几缕哀伤。时间的钟啊,从来不会因为任何人的挽留,而慢些走。看呐,浅秋已经落下帷幕,暑热已散尽,大雁成阵南去,秋风过处,是不是捎着一些人
原神同样给自己上元素,为什么芭芭拉是内鬼而班尼特却是火神?很大一部分原因,是芭芭拉的泛用性与重要性性不如班尼特班尼特作为奶辅,只要不点六命,几乎可以跟任何一个角色搭配。自身充能效率高的同时还具备高效产球能力,哪怕整个队伍里就他一个火系,他
黑相集灰冥界从地狱重返人间前言由厂商SupermassiveGames所制作的黑相集灰冥界是黑相集游戏系列的第三部作品,而前两作分别为黑相集棉兰号和黑相集希望镇,这个系列的游戏类型一致,都是交互式的恐怖游戏