lua虚拟机

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
2
#define luaL_dofile(L, fn) \
(luaL_loadfile(L, fn) || lua_pcall(L, 0, LUA_MULTRET, 0))

其中lual_loadfile函数用于进行词法和语法分析,lua_pcall用于将第一步中分析的结果(也就是字节码)放到虚拟机中执行
lual_loadfile函数最终会调用于f_parser函数,这是对代码进行分析的人口函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void f_parser (lua_State *L, void *ud) {
int i;
Proto *tf;
Closure *cl;
struct SParser *p = cast(struct SParser *, ud);
int c = luaZ_lookahead(p->z);
luaC_checkGC(L);
tf = ((c == LUA_SIGNATURE[0]) ? luaU_undump : luaY_parser)(L, p->z,
&p->buff, p->name);
cl = luaF_newLclosure(L, tf->nups, hvalue(gt(L)));
cl->l.p = tf;
for (i = 0; i < tf->nups; i++) /* initialize eventual upvalues */
cl->l.upvals[i] = luaF_newupval(L);
setclvalue(L, L->top, cl);
incr_top(L);
}

完成词法分析之后,返回了Proto类型的指针tf,然后将其绑定在新创建的Closure指针上,初始化UpValue,最后压入战中。不难想象,词法分析之后产生的字节码等相关数据都在这个Proto类型的结构体中,而这个数据又作为Closure保存了下来,留待下一步使用。
接着看看lua_pcall函数是如何将产生的字节码放入虚拟机中执行的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
LUA_API int lua_pcall (lua_State *L, int nargs, int nresults, int errfunc) {
struct CallS c;
int status;
ptrdiff_t func;
lua_lock(L);
api_checknelems(L, nargs+1);
checkresults(L, nargs, nresults);
if (errfunc == 0)
func = 0;
else {
StkId o = index2adr(L, errfunc);
api_checkvalidindex(L, o);
func = savestack(L, o);
}
c.func = L->top - (nargs+1); //获取需要调用的函数指针
c.nresults = nresults;
status = luaD_pcall(L, f_call, &c, savestack(L, c.func), func);
adjustresults(L, nresults);
lua_unlock(L);
return status;
}

这里的nargs是由函数参数传入的,在luaL_dofile中调用lua_pcall时,这里传入的参数是0,换句话说,这里得到的函数对象指针就是前面f_parser函数中最后两句代码放入Lua栈的Closure指针:

1
2
setclvalue(L, L->top, cl);
incr_top(L);

继续往下执行,在调用函数luaD_pcall时, 最终会执行到luaD_call函数,这其中有这么一段代码:

1
2
if (luaD_precall(L, func, nResults) == PCRLUA)  /* is a Lua function? */
luaV_execute(L, 1); /* call it */

首先,调用luaD_precall函数进行执行前的准备工作:

  • lua_StateCallInfo数组中得到一个新的CallInfo结构体,设置它的funcbasetop指针
  • 从前面分析阶段生成的Closure指针中,取出保存下来的Proto结构体 。 前面提到过,这个结构体中保存的是分析过程完结之后生成的字节码等信息
  • 将这里创建的CallInfo指针的top/base指针赋值给lua_State结构体的topbase指针。 将Proto结构体的code成员赋值给 lua_State指针的savedpc字段,code成员保留的就是字节码
  • 把多余的函数参数赋值为nil;比如一个函数定义中需要的是两个参数,实际传入的只有一个,那么多出来的那个参数会被赋值为nil。

调用完luaD_precall函数之后,接着会进入luaV_execute函数,这里是虚拟机执行代码的主函数:
这里的pc指针存放的是虚拟机OpCode代码,它最开始从L->savepc初始化而来,而L->savepcluaD_precall中赋值:

1
L->savedpc = p->code;  /* starting point */

可以看到,luaV_execute函数最主要的作用就是一个大循环,将当前传入的指令依次执行。
最后,执行完毕后,还会调用luaD_poscall函数恢复到上一次函数调用的环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int luaD_poscall (lua_State *L, StkId firstResult) {
StkId res;
int wanted, i;
CallInfo *ci;
if (L->hookmask & LUA_MASKRET)
firstResult = callrethooks(L, firstResult);
ci = L->ci--;
res = ci->func; /* res == final position of 1st result */
wanted = ci->nresults;
L->base = (ci - 1)->base; /* restore base */
L->savedpc = (ci - 1)->savedpc; /* restore savedpc */
/* move results to correct place */
for (i = wanted; i != 0 && firstResult < L->top; i--)
setobjs2s(L, res++, firstResult++);
while (i-- > 0)
setnilvalue(res++);
L->top = res;
return (wanted - LUA_MULTRET); /* 0 iff wanted == LUA_MULTRET */
}

总结下,大致的流程如下:

  • 1)在f_parser函数中,对代码文件的分析返回了Proto指针。 这个指针会保存在Closure指针中,留待后续继续使用
  • 2)在luaD_precall函数中,将lua_statesaved 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
2
3
4
5
6
7
8
9
10
11
12
13
/*
** Function Prototypes
*/
typedef struct Proto {
CommonHeader;
TValue *k; //函数的常量数组
Instruction *code;//编译生成的字节码信息,也就是前面提到的 code成员
struct Proto **p; /* functions defined inside the function */
int *lineinfo; /* map from opcodes to source lines */
struct LocVar *locvars; //函数的局部变量信息
TString **upvalues; //保存upvalue的数组
...
} Proto;

Lua虚拟机相关的数据结构与栈

请看lua_state里的Lua虚拟机相关的数据结构与栈。

指令相关

lua指令相关

从lua-5.1.1中分离出来的vm实现代码

lvm

reference

《Lua设计与实现》


Tonmar el destino en sus propias manos.