lua通读之lua源码分析
前言
终于来到我盼望已久的源码阅读了,但我又开始思考一个我经常问的问题,源码该怎么读?是否要全读一遍呢?比如我们的lua是一个类编译器,有必要去读大家都学过的词法分析和句法分析部分吗?读源码要从入口函数main开始吗?读源码更重要的是读出思想,如果不懂得几种程序的设计模式没有摆脱以往的思想,你会发现java源码,怎么也读不懂,甚至会觉得莫名其妙。实际上理解设计模式的好处,就能体会更加高效的编程,这才是我们学习的目的。
读源码,我们首先应该去读程序所定义的数据结构,在C语言里是struct,而java等面向对象的语言里就是class。在如今模块化的编程时代,我们大部分都有一直面向对象的编程思想,在我看来这是一种优秀的数据组织形式,它将大部分的实体与相应操作全都封装了,对于先整体后局部读源码有许多便利之处。多说无益,我们直接开始lua的源码之旅吧。
lua解释器
我们先来看看官方程序是如何利用lua虚拟机实现解释器的。
main函数
lua的入口函数长这样
1 | int main (int argc, char **argv) { |
int status;
声明一个用来存储返回状态的变量,0表示一切正常,都是C的基本内容了。为什么我可以看出来?看最后一句return (status || s.status) ? EXIT_FAILURE : EXIT_SUCCESS;
,0也表示false,这是我们发现了另一个结构体struct Smain s;
,它的定义在上面几行:
1 | struct Smain { |
意思也很明显,用来存储参数和返回状态,不知你有没有一个疑问,为什么会有两个status,实际上,这属于调试范畴的东西,我们使用的lua也是一门程序设计语言,当然也有报错的时候,但程序内部会对于lua的错误会存入debug部分,也就是说两个状态分别对应C的运行状态与lua的运行状态,现在少将点,对于Debug的技术,我想另外写一篇文章,所以以后我们跳过debug了哦。lua_State *L = lua_open();
创建一个虚拟机,我们搞嵌入开发的时候再熟悉不过了。这里我们要注意一点,我们的读的是lua程序的源码(就是指lua.c这个文件),而不是lua这门语言的源码,就比如Smain这个在lua.c里定义的结构体,我们搞嵌入开发实际上是用不了的。我们阅读源码遵循先整体后局部的思想,目的是减少读我们不需要的源码,提高阅读效率,应该理解哦。
1 | if (L == NULL) { |
虚拟机创建失败,则报内存不够的错误,值得注意在C里面并没有try和catch进行专业的错误捕捉,一切基本通过返回值是否为NULL或-1来判断。至于创建虚拟机可能出现的问题是lua源码的事,我们等下再说。l_message()
是封装在这个文件的报错处理函数,不是太重要。
1 | s.argc = argc; |
将参数传递给Smain,lua_close(L);
关闭虚拟机,这些没什么好说的,我们重点关注中间两句话,这应该是核心部分。
1 | status = lua_cpcall(L, &pmain, &s); |
lua_cpcall()
是封装在lua里的函数,参数会作为一个lightuserdata(实际上就是一个void*,可以随意使用,具体转型要自己写,你自己传的东西还不知道吗)放入栈顶,然后调用pmain函数,调用也是使用lua虚拟机来运行,与lua_call一样调用C的函数,不过可以只在pmain依赖栈来运行,所以你做不了什么返回值操作,只有lua系统的返回值。而report(L, status);
则是一个用于打印报错信息的函数,属于Debug部分。
pmain函数
我们来看pmain的实现:
1 | static int pmain (lua_State *L) { |
1 | struct Smain *s = (struct Smain *)lua_touserdata(L, 1); |
从栈顶取出由userdata封装的参数,对于lightuserdata与userdata的区别,其实如果从C语言本身来看都是*void指针,实际上这和垃圾回收有关,我们来看一下lua的数据类型TValue是怎么定义的(lobject.h):
1 | /* |
tt用来标识类别,value存储具体数据。在Value里,我们看到两种指针类型GCObject和void,实际上分别对于了userdata和lightuserdata也就是说,后者不纳入lua的垃圾回收器,需要用户自己进行内存回收,而且lua的API只又lua_pushlightuserdata,也就是说你自己在C里创建的东西自己去管理吧。
这里还要讲的是数据在lu虚拟机里的索引,我们看到这里它不像我们一样使用-1来索引栈顶数据,而使用了1,这时我们可能会好奇0是什么,为了理解我们随便取一个索引函数看一下源码(lua_tonumber() in lapi.c):
1 | static TValue *index2adr (lua_State *L, int idx) { |
index2adr()
是最终提供索引结果的函数,并且返回相应的TValue。如果idx>0则返回base+(idx-1)处的值,如果idx>LUA_REGISTRYINDEX则返回top+indx,剩下的我们基本没用,因为在lua.h里我们可以看到#define LUA_REGISTRYINDEX (-10000)
,也就是说,0返回top,-1返回top-1,1返回base+1,这里我们涉及了lua虚拟机的两个指针top和base,而且top并非指向最上面的元素,而是指向NULL,还记得我们之前用过的lua_gettop()吗?(lapi.c):
1 |
|
这里我们要讲一下base指针了,实际上这存在与函数调用的时候,我们知道lua调用函数的时候都会先将函数入栈,再入参数,实际上调用lua_pcall或lua_call的时候,lua系统会将base指针重新定位为这个函数,所以base+i就会是第i个参数,如果你干过反编译的话,就会发现每一个函数闭包好像都有一个小的虚拟栈,实际上都是lua虚拟机的一部分,base指针以上就是这个小虚拟机,有没有觉得这是一种挺有趣的思想。
1 | int script; |
前两组变量用来存储lua程序的参数解析结果,lua实际还有好几种用法比如lua -v
显示版本信息,lua -i
启动交互解释器,等同于lua
。这不是我们关注的重点。globalL和progname用来存储程序使用的lua虚拟机和程序名,定义在程序前面:
1 | static lua_State *globalL = NULL; |
接下来是,向虚拟机导入库函数,涉及一些gc操作,主要为了导入后,让库函数不要在回收的对象里。collectargs()
用来匹配参数,选项存储到has_i , has_v, has_e
里面,返回脚本文件名(非’-‘开头)在参数组里的索引。
if{}用来判断是否有脚本参数,否则打印使用信息。如果有has_v
则打印版本信息。runargs()
针对参数,执行相应操作如-e -l
等。if (s->status != 0) return 0;
用来检验运行是否正常,属于”常规操作”了。handle_script(L, argv, script);
是我们处理lua脚本的主要文件了,后面几句是用来进入交互环境的,核心是dotty(L);
函数。
handle_script函数
我们先往上看前一个:
1 | static int handle_script (lua_State *L, char **argv, int n) { |
int status;const char *fname;
存储状态和脚本文件名,直接过。getargs(L, argv, n);lua_setglobal(L, "arg");
主要用来将参数放入lua虚拟机的全局变量arg,getargs
返回参数的个数。这里指的是对于lua脚本而言的参数,对于lua程序而言就是脚本名后面的参数。官方也已经给出了说明:
$ lua -la b.lua t1 t2
the table is like this:
arg = { [-2] = “lua”, [-1] = “-la”,
[0] = “b.lua”,
[1] = “t1”, [2] = “t2” }
接下来的句子是用来检测的,一般都会通过,但还是以防万一。luaL_loadfile(L, fname);
载入脚本,在栈顶产生一个函数闭包来封装脚本。lua_insert(L, -(narg+1));
将栈顶函数下移narg+1,这里有点忘说了,在执行getargs(L, argv, n);
后,此时栈已经多了narg个参数和一个包含参数的表,执行lua_setglobal(L, "arg");
后栈上实际上还有narg个参数在上面,此时函数与参数在栈上的位置实际反了,所以要执行lua_insert来纠正,其实本身没有必要因为在lua脚本里我们又不能直接调用虚拟机上的参数。
后面比较简单了,如果status正常则直接docall()
执行代码,否则弹出函数和参数,通过report()报错。
我们来看看docall()
:
1 | static int docall (lua_State *L, int narg, int clear) { |
仔细一看,它写得还挺专业的。先记录status和base的位置,推入traceback
函数并移入底部(base处),这个主要用于在lua脚本里来进行debug报错,跳过,我以后专门来讲。然后监听中断信号,的确考虑挺多的。lua_pcall()执行函数。然后就是各种恢复操作了——移除中断,移除traceback,进行垃圾回收。看得我自己都忏愧了,自己用惯了那些自带垃圾回收的语言,写C的时候都没考虑过这么多,不过操作系统实际上也会进行管理,不用过于担心就是了。
dotty函数
这是用来处理交互环境时的函数,代码如下:
1 | static void dotty (lua_State *L) { |
前后都是比较常规的操作,重点在while循环里面。首先会执行loadline(L)
,这个函数其实挺复杂的,等下我们再说吧,总之最后在栈顶会产生一个无参数的函数,而下面还存储了需要打印信息(后面解释)。然后调用docall()
执行这个函数,然后打印需要打印的信息,这是交互环境特有的一个特性,主要用来监听变量信息。比如你之前有一个全局变量a,在交互环境内就可以直接输入a,就可以打印a的值了,其实等同于代码print(a)
,但更加方便了。
接下来我们看看loadline():
1 | static int loadline (lua_State *L) { |
lua_settop(L, 0);
依据base指针移动top指针位置,都是防止出问题的措施,跳过。pushline()
用于将用户输入的一行数据压入虚拟机栈,有0和1两个参数,因为C没有bool类型,所以以整数代替,1表示非函数内输入。这里需要讲一下,lua交互环境的运行状态了,它有两种情况,在命令行的表现是以>
或>>
开头,>
开头属于可以直接执行的状态,输入一行执行一行,此时参数为1,>>
一般在输入function后进入,此时你基本可以任意输入,直到输入end结尾,当然还有出现内部函数的情况,此时参数为0。感觉把后面的都说了,我们继续看吧。
接下来是一个for循环,直到输入一行,这里的一行是个抽象意,处于>
状态回车后就是一行,而>>
状态需要整个函数闭包输入结束才行。luaL_loadbuffer()
载入栈顶的数据,incomplete()
根据返回状态,判断是否结束。这就是我之前说的。如果输入的一句话合格的话,就会直接break调,否则开始pushline()
处于>>
状态的代码,后面部分就是将新读取的字符串拼接了。这里实际上有几个问题没有解决,就是luaL_loadbuffer()对错误返回的结果问题,这个先留到后面,到时候我们会深入分析lua_load函数,最后两句用处不大,直接跳过。这是我们要注意一下,栈顶存在的字符串最后都会被luaL_loadbuffer()掉,也就是说,此函数最后的结果如我们之前所说,是一个函数。
lua编译器
接下来我们将目光投向lua的编译器,但我么需要注意lua并不能编译为操作系统的可执行文件,而是lua虚拟机的字节码文件。
main函数
1 | int main(int argc, char* argv[]) |
我们发现大部分代码类似于lua解释器。doargs()
用来进行参数匹配,同时对于可执行参数就直接执行了,如-v
,不过有些信息就直接写入了全局变量。
1 |
|
返回值是脚本的位置。fatal()
类似于之前的report()
,用于调试。argc-=i; argv+=i;
用来充新定位参数起始位置为脚本位置。流程与lua解释器差不多,我们转向pmain函数。
pmain函数
1 | static int pmain(lua_State* L) |
前三句好说,下一句我们见到了一个新的数据类型Proto
,这个我们或许要深入了解一下了。不过在此之前我们先读完全部内容吧,其实影响不会太大。lua_checkstack()
见多了,用来保证还能继续往栈里写数据。接下来对所有合格参数都执行一遍luaL_loadfile()
,可见luac还可以批量编译,而且还会打包为一个文件,此时lua虚拟机栈上已经有相应数量的函数了。combine()
有点复杂,还涉及字节码,我们先放一放,总之它将我们栈上的函数打包为了一个Proto。listing
用于打印字节码信息,对应-l
参数,dumping
默认开启,用于输出字节码文件,可以使用-p
取消。里面一个重要的函数是luaU_dump(L,f,writer,D,stripping);
,它将proto文件f打包到输出流writer上去,stripping表示是否有调试信息,默认不包含。
Proto结构体
想必你已经听过许多遍,lua执行过程是先将lua编译为字节码,再在虚拟机上执行。实际上,我们之前学过的用CAPI去操作虚拟机,实际上,这些操作有一个对应的集合,就是字节码。在执行lua_load后,虚拟机会将脚本封装为一个函数,这个函数即包含了脚本编译后的字节码,我们留到以后说,而Proto则是函数的超集,或者说栈上TValue的最后结果是Proto,实际上这里有比较复杂的指向关系,我们先跳过,我们去看一看Proto吧:
1 | /* |
CommonHeader;
和gclist
与垃圾回收相关,我们下次专门讲。*k
存储函数里的常量,*code
存储函数内的指令,**p
是函数内的函数数组,lua函数套函数应该不少见。*lineinfo
存储源码行号,*source
存储源码,主要用于debug。*locvars
存储本地变量,就是有local申明的变量,注意这里存储的只是相关信息,如名称,它的值实际存在lua虚拟机里,等我们熟悉字节码操作后,就能更深理解存储机制了。*upvalues
存储上下文变量,主要存在于多重函数嵌套里面。一个函数的upvalue指的是,它之前级函数的local函数,这里已经有许多涉及字节码的理念了,先放放。int sizeupvalues;int sizek;/* size of k */int sizecode;int sizelineinfo;int sizep;/* size of p */int sizelocvars;
存储各个变量的大小,注意不是个数,linedefined
和lastlinedefined;
是函数的始末行号,用于调试。后面分别是upval个数,参数个数,是否有可变参数,栈最高用到多少。实际上,把Proto当成一个函数也没有问题。
luaU_dump函数
int luaU_dump (lua_State* L, const Proto* f, lua_Writer w, void* data, int strip)
这个和lua_load类似,具体内容等我讲luac反编译再说。data提供输出流,w提供输出的具体函数,f就是需要打包的内容,strip表示是否打包调试信息。
字节码
为了更加深入了解lua的函数和,lua代码究竟如何在虚拟机上运行的,我们就必需深入了解一下字节码了。
字节码大全
虽然没有必要但还是讲一下,lua的所有字节码和它们是如何影响lua虚拟机的运行的。操作码有两部分组成,操作和操作数。对于操作数有三种情况,一是虚拟栈索引(记为v),另一种是函数里里的常数表索引(记为c),最后就是布尔数(只要0和1,记为b),在lua里所有立即数据和名称都会计入函数的常量表,如local a = 3
里的a和3。
关于索引的问题
每一个函数,都会有base指针的移动,在字节码里是从0开始索引,不过还有一点,就是如果有参数的话,就会占用相应参数个位置。
赋值操作
以后我在后面标一个符号,表示索引的种类。OP_MOVE(A v, B v) A=B
,OP_LOADK(A v, B c) A=B
,OP_LOADBOOL(A v, B b, C b) A=B,if(C) pc++
,这个指令看似复杂,但实际就是进行布尔赋值或运算的时候会用到,pc就是程序计数器,pc++即跳过下一条指令。自己用对lua的and or语句编译一下,就能发现其中的奥秘。OP_LOADNIL(A v, B nil) A=nil
,这个比较特别,B基本都是0。
表相关
接下来会有几个需要的表,upval表和global表。OP_GETUPVAL(A v, B c) A=upval[B]
,OP_SETUPVAL(A v, B c) upval[B] = A
,OP_GETGLOBAL(A v, B c) A=global[B]
,OP_SETGLOBAL(A v, B c) global[B]=A
,OP_GETTABLE(A v, B v, C c) A=B[C]
,OP_SETTABLE(A v, B c, C v) A[B]=C
,OP_NEWTABLE(A v, B c, C c)
创建一个表,array大小为B,hash大小为C,后面一般紧跟相应数量的OP_LOAD*和OP_SETTABLE来填数据,最后OP_SETLIST(A v, B c, C c)
,B和C同上,有时opcode是联合使用的,取决于编译器load_*的内部实现。OP_SELF(A v, B v, C c)
,在lua传输self表时特有的一个函数,注意的是这个表在表里是独立存在的,self的变量与表里的变量是不同的,self类似于表私有的变量。
运算
OP_ADD(A v, B v, C v) A=B+C
,OP_SUB(A v, B v, C v) A=B-C
,OP_MUL(A v, B v, C v) A=B*C
,OP_DIV(A v, B v, C v) A=B/C
,OP_MOD(A v, B v, C v) A=B%C
,OP_POW(A v, B v, C v) A=B^C
,OP_UNM(A v, B v) A= -B
,OP_NOT(A v, B v) A= not B
,OP_LEN(A v, B v) A=#B
,OP_CONCAT(A v, B v, C v) A= B..C
,我们发现所有的运算都在栈上运算,就如我之前所说,在lua里所有的变量值要么存储在栈上,要么在全局表里面。而函数闭包,只存储变量的名称,或立即数据,因为它是需要静态存储到文件里的,lua其实还有优化编译,如对于a = 2+5
,lua会直接存储a和7而不是a,2和5。
结构控制
OP_JMP(0, B) pc+=B
,这里还要讲一下pc指向的是将要执行的指令,一般指向下一条,在这里B是具体整数,一般是正数。OP_EQ(A b, B v, C v) if((B==C)~=A)then pc++
,这里实际通过A分开了==和!=,下面同理,OP_LT(A b, B v, C v) if((B<C)~=A)then pc++
,OP_LE(A b, B v, C v) if((B<=C)~=A)then pc++
,有关lua代码如何转化为字节码,有些什么巧妙之处,我还在反编译那里讲。OP_TEST(A v, B b) if not(A==B)then pc++
,OP_TESTSET(A v, B v, C b) if(B==C)then A=B else pc++
,这个主要用在复杂的逻辑表达式里。OP_FORLOOP(A v, B) A+=v(A+2)
用于for循环,一般会在栈上产生三个值,一个是我们常用的索引,最后一个步长,v(A+2)表示相对A位置加2处的值,B用于指示跳转,一般是负数表示回退。OP_FORPREP(A v, B) A-=v(A+2),pc+=B
与OP_TFORLOOP(A v, C) v(A+3),…,v(A+2+C)=A(v(A+1),v(A+2))
一般配合使用来遍历表,C表示返回值个数,一般为3,用过pairs和ipairs吧。对于while和repeat,只要jmp和比较其实就能实现了。
函数
OP_CALL(A v, B, C) v(A),…v(A+C-2)=A(v(A+1),…v(A+B-1))
,B-1和C-1分别是参数和返回值个数,与lua_call使用差不多,但有一个减一操作,即2表示一个参数,有些用处,但现在就当是规定吧。OP_TAILCALL(A v, B, C) return A(v(A+1),…v(A+B-1))
类似上一个,用于函数的快速返回。OP_RETURN(A v, B) return v(A+1),…v(A+B-1)
标准的函数返回方式。OP_CLOSE(A v)
清除栈上>=A的所有值,OP_CLOSURE(A v,B)
取出函数内的闭包到栈上,B是索引,还记得Proto内的Proto,闭包就存在里面。OP_VARARG(A v, B) v(A),…v(A+B-1)=…
用来取出B个函数的可变参数到栈上。
字节码总结
至此,我们浏览了所有的字节码,只是为了有个大概的印象,实际还是得结合具体的编译结果来看,一般成块出现的,我们可以使用指令luac -l [字节码文件]
来查看。对于字节码,我们需要记住,所有要操作的数据都要先入栈,返回值也存在栈上,其实这也就方便了我们通过CAPI来实现C语言与lua的数据交互,当然还可以通过全局变量表。同时我们也看到了,函数Proto并没有存具体的数据,除了那些即时产生的量,而是存储了变量名等变量的特征,我们要清楚,函数是静态存储动态执行的,所以变量的具体值不在函数里也是理所应当的。
函数
前面讲了函数的一个重要组成部分Instruction *code
即字节码,是时候该从新正视一下函数了,函数属于TValue里Value的GCObject。我们来看一下这条定义链。
1 | //lobject.h |
我们看到有Closure和Proto两个,我们使用lua_call一般针对的是Closure,而Proto出现在这里是因为它也是要回收的对象。实际上对于函数有两种,一个是C闭包,还有一个是lua闭包,它们都可以作为函数供lua_call来调用,所以闭包了一个Closure作为统一对象。Proto则是LClosure(lua闭包函数)的主要部分。lua_CFunction f;
的申明在lua.h里,格式如下typedef int (*lua_CFunction) (lua_State *L);
。垃圾回收还是老话,以后再讲。
combine函数
在前面,我们提到了一个combine(L, int)
函数,用来将栈上的函数返回为为一个Proto,我们看看它:
1 | static const Proto* combine(lua_State* L, int n) |
如果只有一个函数,则直接返回toproto(L,-1);
,这个的定义链如下:
1 | //luac.h |
转化一下就是toproto(L,-1)
:= &(L->top-1)->value.gc->cl->l.p
,取出TValue的value(Value)的gc(GCObject)的cl(Closure)的l(LClosure)的p(Proto),的确是一个复杂的过程,但这也是为了统一处理。
对于函数大于1的情况也可以看一看,其实就是将每个函数执行的代码,再封装到一个Proto里面。Proto* f=luaF_newproto(L);
创建一个Proto。setptvalue2s(L,L->top,f); incr_top(L);
虽然复杂,但其实就是将Proto封装为TValue并放入栈顶的操作。f->source=luaS_newliteral(L,"=(" PROGNAME ")");
和f->maxstacksize=1;
用于给Proto补充信息,栈只要1格内存来存Closure就足够了。pc=2*n+1;
标识需要的字节码个数,一个文件两个,最后加一个return,f->code=luaM_newvector(L,pc,Instruction);
创建Instruction的数组,f->sizecode=pc;
最后记录到Proto里面。f->p=luaM_newvector(L,n,Proto*);
创建存储每个文件Proto的数组,f->sizep=n;
记录子Proto的个数(Proto可以包含数个子Proto),pc=0;
计数器归位,准备记录字节码。
1 | for (i=0; i<n; i++) |
记录字节码,并将构造的Proto返回。我们可以看到,所谓编译多个文件就是将每个文件都执行一遍。
lua_call函数
接下来就是要讲讲,我们最重要的执行函数了,也是我们虚拟机运行的核心,对于每一个Proto里面的字节码,执行的主体就是我们的lua_call函数了。
1 | LUA_API void lua_call (lua_State *L, int nargs, int nresults) { |
lua_lock(L);
和lua_unlock(L);
顾名思义就是用来锁住L,防止操作还没结束就被其它线程改变,但其实在lua里并没有实现此方法,而是定义宏来提供接口,只把lua当一次性脚本的话,不用过于关注。其实如果遇到如api_checknelems
,checkresults
和adjustresults
之类的函数,都可以不用在意,与lua_lock()
类似,不改源码的话,基本没什么用。func = L->top - (nargs+1);
把函数取出来,luaD_call(L, func, nresults);
才是真正来执行的函数。
1 | void luaD_call (lua_State *L, StkId func, int nResults) { |
if (++L->nCcalls >= LUAI_MAXCCALLS) {
主要用于检测当前嵌套函数调用个数有没有超标,少用递归的话基本不会出问题。luaD_precall(L, func, nResults)
预调用,如果func是C闭包的话就直接执行并返回PCRC,如果是lua闭包就做好运行准备,比如修改虚拟机的CallInfo,调整base指针之类的,这些是编程细节,不是我们学习的重点,最后会返回PCRLUA,然后接着执行luaV_execute(L, 1)
,这个函数是执行字节码的真正函数,实现的话就是一个个去比对然后执行,没什么看头。luaD_precall
其实还会返回一个与协程相关的PCRYIELD。L->nCcalls--;
函数嵌套减一,luaC_checkGC(L);
垃圾回收操作。
至于lua_pcall
它最终也是通过luaD_call
实现调用,只不过增加了保护措施,防止程序中断,而lua_cpcall
则与lua_pcall
有类似的实现方式,最终仍是回到luaD_call
,就不过多赘述了。
后记
至此lua源码的大致框架和核心部分已经叙述完了,但其实还没有结束,比如Debug,GC机制,协程的实现等都没有讲到。任何源码都是庞大的,其实我们没必要面面俱到,而应先了解其基本框架,再去了解我们想知道的部分就足够了。不要忘了,我们读源码不是要折磨自己,而是想学习优秀的范式。