Lua
的虚拟机核心部分,没有任何的系统调用,是一个纯粹的黑盒子,正确的使用Lua
,不会对系统造成任何干扰。这其中最关键的一点是,Lua
让用户自行定义内存管理器,在创建Lua
虚拟机时传入,这保证了Lua
的整个运行状态是用户可控的。
基于寄存器的虚拟机
lua
从5.0开始,就把虚拟机改为基于寄存器的。
基于栈的虚拟机执行操作,要事先pop
出数据,再将数据push
入栈,字节码条数较多,但指令中不需要关心操作数的地址,在执行操作之前已经将操作数准备在栈顶上了。与基于栈的虚拟机不同,在基于寄存器的指令中,操作数是放在“CPU的寄存器”中(因为并不是物理意义上的寄存器,所以这里打了双引号)。因此,同样的操作不再需要PUSH、POP
指令,取而代之的是在字节码中带上其体操作数所在的寄存器地址。 需要指令较少,但缺点是此时程序需要关注操作数所在的位置。
Lua
使用的是基于寄存器的虚拟机实现方式,其中很大的原因是它的设计目标之一就是尽可能高效。
lua虚拟机工作流程
lua
代码是通过翻译成Lua
虚拟机能识别的字节码运行的,以此它主要分为两大部分。
翻译代码以及编译为字节码
这部分代码负责将lua
代码进行词法分析(llex.c)
、语法分析等(lparser.c)
,最终生成字节码(lcode.c)
。lopcodes.x
则定义了lua
虚拟机相关的字节码指令的格式以及相关的API
。
lua虚拟机相关(指令的执行)
在第一步中,经过分析阶段后,生成了对应的字节码,第二步就是将这些字节码装载到虚拟机中执行。Lua
虚拟机相关的代码在 lvm.c
中,虚拟机执行的主函数是luaV_execute
,不难想象这个函数是一个大的循环,依次从字节码中取出指令并执行。Lua
虚拟机对外看到的数据结构是lua_State
这个结构体将一直贯穿整个分析以及执行阶段 。 除了虚拟机的执行之外,Lua的核心部分还包括了进行函数调用和返回处理的相关代码,主要处理函数调用前后环境的准备和还原,这部分代码在ldo.c
中,垃圾回收部分的代码在lgc.c
中。Lua
是一门嵌入式的脚本语言,这意味着它的设计目标之一必须满足能够与宿主系统进行交互,这部分代码在lapi.c
中。
总结一下,实现一个脚本语言的解释器,其核心问题有如下几个
- 设计一套字节码,分析源代码文件生成字节码
- 在虚拟机中执行字节码
- 如何在整个执行过程中保存整个执行环境
执行Lua
文件调用的是luaL_dofile
函数,它实际上是个宏,内部首先调用luaL_loadfile
函数,再调用lua_pcall
函数:
1 |
|
其中lual_loadfile
函数用于进行词法和语法分析,lua_pcall
用于将第一步中分析的结果(也就是字节码)放到虚拟机中执行lual_loadfile
函数最终会调用于f_parser
函数,这是对代码进行分析的人口函数:
1 | static void f_parser (lua_State *L, void *ud) { |
完成词法分析之后,返回了Proto
类型的指针tf
,然后将其绑定在新创建的Closure
指针上,初始化UpValue
,最后压入战中。不难想象,词法分析之后产生的字节码等相关数据都在这个Proto
类型的结构体中,而这个数据又作为Closure
保存了下来,留待下一步使用。
接着看看lua_pcall
函数是如何将产生的字节码放入虚拟机中执行的:
1 | LUA_API int lua_pcall (lua_State *L, int nargs, int nresults, int errfunc) { |
这里的nargs
是由函数参数传入的,在luaL_dofile
中调用lua_pcall
时,这里传入的参数是0,换句话说,这里得到的函数对象指针就是前面f_parser
函数中最后两句代码放入Lua
栈的Closure
指针:
1 | setclvalue(L, L->top, cl); |
继续往下执行,在调用函数luaD_pcall
时, 最终会执行到luaD_call
函数,这其中有这么一段代码:
1 | if (luaD_precall(L, func, nResults) == PCRLUA) /* is a Lua function? */ |
首先,调用luaD_precall
函数进行执行前的准备工作:
- 从
lua_State
的CallInfo
数组中得到一个新的CallInfo
结构体,设置它的func
、base
、top
指针 - 从前面分析阶段生成的
Closure
指针中,取出保存下来的Proto
结构体 。 前面提到过,这个结构体中保存的是分析过程完结之后生成的字节码等信息 - 将这里创建的
CallInfo
指针的top/base
指针赋值给lua_State
结构体的top
、base
指针。 将Proto
结构体的code
成员赋值给lua_State
指针的savedpc
字段,code
成员保留的就是字节码 - 把多余的函数参数赋值为
nil
;比如一个函数定义中需要的是两个参数,实际传入的只有一个,那么多出来的那个参数会被赋值为nil。
调用完luaD_precall
函数之后,接着会进入luaV_execute
函数,这里是虚拟机执行代码的主函数:
这里的pc
指针存放的是虚拟机OpCode
代码,它最开始从L->savepc
初始化而来,而L->savepc
在luaD_precall
中赋值:
1 | L->savedpc = p->code; /* starting point */ |
可以看到,luaV_execute
函数最主要的作用就是一个大循环,将当前传入的指令依次执行。
最后,执行完毕后,还会调用luaD_poscall
函数恢复到上一次函数调用的环境:
1 | int luaD_poscall (lua_State *L, StkId firstResult) { |
总结下,大致的流程如下:
- 1)在
f_parser
函数中,对代码文件的分析返回了Proto
指针。 这个指针会保存在Closure
指针中,留待后续继续使用 - 2)在
luaD_precall
函数中,将lua_state
的saved pc
指针指向第1步中Proto
结构体的code
指针,同时准备好函数调用时的栈信息 - 3)在
luaV_execute
函数中,pc
指针指向第2步中的saved pc
指针,紧眼着就是一个大的循环体,依次取出其中的OpCode
执行 - 4)执行完毕后,调用
luaD_poscall
函数恢复到上一个函数的环境
因此,Lua
虚拟机指令执行的两大入口函数如下:
- 词法、语法分析阶段的
luaY_parser
。为了提高效率,Lua
一次遍历脚本文件不仅完成了词法分析,还完成了语法分析,生成的OpCode
存放在Proto
结构体的code
数组中 luaV_execute
。它是虚拟机执行指令阶段的入口函数,取出第一步生成的Proto
结构体中的指令执行
Proto
是分析阶段的产物,执行阶段将使用分析阶段生成的Proto
来执行虚拟机指令,在分析阶段会有许多数据结构参与其中,可它们都是临时用于分析阶段的,或者说最终是用来辅助生成Proto
结构体的。
可以看到,Proto结构体是分析阶段和执行阶段的纽带。只要抓住了Proto结构体这一个数据的流向,就能对从分析到执行的整个流程有大体的了解了luaY_parser->Proto->luaV_execute。
到了这里,可以大致看看Proto
结构体中都有哪些数据
1 | /* |
Lua虚拟机相关的数据结构与栈
请看lua_state里的Lua虚拟机相关的数据结构与栈。
指令相关
从lua-5.1.1中分离出来的vm实现代码
reference
《Lua设计与实现》