0%

Lua 脚本如何运行

本文结合了 Lua 源码浅析了 Lua 是如何运行并与 C 语言函数进行交互的,适合不理解 Lua 脚本运行机制、VM 运行机制的读者。

Lua 指令码

Lua 虽然是脚本语言,但是在运行前需要进行代码编译;与 C/C++ 不同,Lua 脚本经过编译生成后的二进制 chunk 文件并不能够直接被物理机识别和运行,只能够在 Lua Virtual Machine ( LVM ) 中运行。Lua 编译后生成的二进制 chunk 文件是由 Lua 指令集构成的,仅能够被同版本的 Lua 虚拟机环境识别。

/images/lua_compile_run.png

在 chunk 文件中,主要负责与 LVM 进行交互的部分为 Lua 指令。在 Lua 的源码中,Lua 指令相关的代码主要集中在lopcodes.h文件中,该文件中也包含了对 Lua 指令的介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*===========================================================================
We assume that instructions are unsigned 32-bit integers.
All instructions have an opcode in the first 7 bits.
Instructions can have the following formats:

3 3 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0
1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0
iABC C(8) | B(8) |k| A(8) | Op(7) |
iABx Bx(17) | A(8) | Op(7) |
iAsBx sBx (signed)(17) | A(8) | Op(7) |
iAx Ax(25) | Op(7) |
isJ sJ(25) | Op(7) |

A signed argument is represented in excess K: the represented value is
the written unsigned value minus K, where K is half the maximum for the
corresponding unsigned argument.
===========================================================================*/

enum OpMode {iABC, iABx, iAsBx, iAx, isJ}; /* basic instruction formats */

一条 Lua 指令一共有 32 bit,即四个字节,可以分为 C、B、k、A、Op 五个部分。其中 A、B、C 都是 Lua 栈的标识位,用于表示当前指令需要操作的 Lua 栈;k 是一个特殊的标识位,作用在源码中已经详细说明。Op 代表了当前 Lua 指令的种类,用于指示 LVM 应当对栈采取何种操作。考虑到一些 Lua 指令并不需要三个操作数、某些情况需要的栈空间大于一个字节,Lua 指令被设计为 5 种不同的编码格式:iABC、iABx、iAsBx、iAx、isJ。后面四种格式编码的 Lua 指令会对 A、B、C、k 四个部分进行合并,以满足不同的需求。OpMode 并不会直接编码进入 chunk 文件中,一条 Lua 指令的编码格式是根据 Op 的种类来判断的。

LVM 中指令的运行

LVM 是 Lua 中最为核心的部分,它负责解释和运行 Lua 指令,并根据指令修改 Lua 栈上的值。Lua 源码中与 LVM 直接相关的代码集中在lvm.h/lvm.c中,其中函数luaV_execute负责接收 Lua 指令并执行。该函数的主要部分是一个巨型 switch-case 结构,用于区分不同的 Lua 指令,我们截取函数中的一小部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void luaV_execute (lua_State *L, CallInfo *ci) {

...

/* main loop of interpreter */
for (;;) {
Instruction i; /* instruction being executed */
vmfetch(); /* fetch an instruction and prepare its execution */

lua_assert(base == ci->func.p + 1);
lua_assert(base <= L->top.p && L->top.p <= L->stack_last.p);
/* invalidate top for instructions not expecting it */
lua_assert(isIT(i) || (cast_void(L->top.p = base), 1));

vmdispatch (GET_OPCODE(i)) { // i 是指令中 Op 的简写
// MOVE 指令
vmcase(OP_MOVE) {
StkId ra = RA(i);
setobjs2s(L, ra, RB(i));
vmbreak;
}

...
}
}
}

截取的代码片段有一条 MOVE 指令的执行,在判断为 OP_MOVE 指令后,luaV_execute直接根据该指令的编码格式 iABC (虽然只有两个操作数,但是该指令仍为 iABC 格式),获取两个操作数 A 和 B。跟随RA()函数的调用链:

1
2
3
4
5
6
// 获取 A 值大小
#define RA(i) (base+GETARG_A(i))
// 获取 base 偏移量大小
#define GETARG_A(i) getarg(i, POS_A, SIZE_A)
// 获取 base 偏移量大小
#define getarg(i,pos,size) (cast_int(((i)>>(pos)) & MASK1(size,0)))

上述代码中的 base 为StkId类型,它是StackValue*类型的 typedef,代表 Lua 栈值的位置:RA()RB()函数实质上是获取两个 Lua 栈的位置 posA 和 posB,后续的setobjs2s函数的逻辑则是将 posB 的值拷贝给 posA。由于 posA 和 posB 都是根据偏移量计算得到的,因此通过修改 base 的值就可以模拟不同的栈环境进行指令运算。

通过分析不难发现,Lua 脚本的执行过程其实是完全处于 C 环境下的,各个指令的实现也完全是 C 实现的,与 C 环境之间没有任何的隔离。既然 LVM 中完全使用 C 函数来完全相关功能,那么自然也可以通过某种方法在 Lua 指令的运行过程中来使用其他的 C 函数。

函数栈切换

函数的运行是依赖于函数栈来进行环境隔离的,这一点在 Lua 中也不例外。在不指定函数的情况下,LVM 会运行在 main 函数中,并且使用该函数的栈。当调用其他函数时,LVM 将会切换函数栈,从而实现 local 值可见域的切换。

经过前面对指令寻址的分析,我们得出只需要修改 base 变量就能够实现栈空间的切换,这正是 Lua 源码中的实现方式。当需要调用或退出调用时,LVM 会收到指令OP_CALLOP_TAILCALL,然后根据指令来进行栈空间的切换。首先分析 LVM 中OP_CALL分支的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
startfunc:
...
base = ci->func.p + 1;
...
vmcase(OP_CALL) {
StkId ra = RA(i);
CallInfo *newci;
int b = GETARG_B(i);
int nresults = GETARG_C(i) - 1;
if (b != 0) /* fixed number of arguments? */
L->top.p = ra + b; /* top signals number of arguments */
/* else previous instruction set top */
savepc(L); /* in case of errors */
if ((newci = luaD_precall(L, ra, nresults)) == NULL)
updatetrap(ci); /* C call; nothing else to be done */
else { /* Lua call: run function in this same C frame */
ci = newci;
goto startfunc;
}
vmbreak;
}

这一分支的实现比较复杂,并且比较分散,这里解释一下主要的逻辑:当一个函数被调用时,LVM 将会更新当前运行的函数,并在 Lua 栈中选取一段合适的大小分配给该函数作为函数的运行栈。完成栈空间的分配后,LVM 会将函数的输入参数按照顺序拷贝到栈空间的起始位置,方便在函数中进行寻址操作。当完成上述这些操作后,LVM 判断需要调用的函数是否为 C 函数,若为 C 函数则运行后退出当前栈空间(C 函数在 LVM 退出逻辑不在这里);否则将继续在当前栈空间下执行其他指令。

当函数完成执行后,将会使用OP_TAILCALL进行退出函数栈,该分支的实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
vmcase(OP_TAILCALL) {
StkId ra = RA(i);
int b = GETARG_B(i); /* number of arguments + 1 (function) */
int n; /* number of results when calling a C function */
int nparams1 = GETARG_C(i);
/* delta is virtual 'func' - real 'func' (vararg functions) */
int delta = (nparams1) ? ci->u.l.nextraargs + nparams1 : 0;
if (b != 0)
L->top.p = ra + b;
else /* previous instruction set top */
b = cast_int(L->top.p - ra);
savepc(ci); /* several calls here can raise errors */
if (TESTARG_k(i)) {
luaF_closeupval(L, base); /* close upvalues from current call */
lua_assert(L->tbclist.p < base); /* no pending tbc variables */
lua_assert(base == ci->func.p + 1);
}
if ((n = luaD_pretailcall(L, ci, ra, b, delta)) < 0) /* Lua function? */
goto startfunc; /* execute the callee */
else { /* C function? */
ci->func.p -= delta; /* restore 'func' (if vararg) */
luaD_poscall(L, ci, n); /* finish caller */
updatetrap(ci); /* 'luaD_poscall' can change hooks */
goto ret; /* caller returns after the tail call */
}
}

退出的逻辑更为复杂,因为在退出时还需要处理 upvalue、闭包的相关内容。这里只描述一下函数栈的变化:LVM 将会切换运行函数为上一个运行函数,并将切换前函数栈顶的元素(返回值)拷贝到切换后的栈顶,这样就复原了之前的调用状态。

C 函数的调用

上一节中,已经分析得到 C 函数与 Lua 函数是以同样的入口调用的,但是调用 C 函数还需要解决一些额外的问题:C 函数不能直接使用 Lua 指令与 Lua 栈交互。这个问题是通过 Lua C API 解决的。在lua.h文件中,Lua 提供了一些 API 来帮助 C 函数完成与 Lua 栈的交互功能。这些函数能够完成 C 环境下的变量与 Lua 环境下的变量之间的相互转换。

由于 C 函数难以支持动态数量的函数参数以及返回值,Lua C API 在接口设计上并没有加入输入输出值的数量,只有一个lua_state类型的函数参数和一个int类型的函数返回值。因此 LVM 无法在调用时确定 C 函数所需要的函数栈,调用 C 函数时必须付出一些额外的代价。

UpValue 和闭包

UpValue 是 Lua 脚本中一个比较特殊的概念,它代表函数中引用的以非函数形式传递的值。正如其命名,它代表调用函数之前就已经存在的值。

1
2
3
4
5
function foo()
print(bar) -- bar 是一个 UpValue
return
end
foo()

在上述代码中,bar 就是一个 UpValue,同样,bar 也是一个全局变量。在 Lua 中,全局变量是一种比较特殊的 UpValue,它与普通的 UpValue 存储位置不同。我们使用 luac 工具输出上述 Lua 脚本的指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
luac -l hello.lua

main <hello.lua:0,0> (6 instructions at 0x600000928080)
0+ params, 2 slots, 1 upvalue, 0 locals, 1 constant, 1 function
1 [1] VARARGPREP 0
2 [4] CLOSURE 0 0 ; 0x600000928100
3 [1] SETTABUP 0 0 0 ; _ENV "foo"
4 [5] GETTABUP 0 0 0 ; _ENV "foo"
5 [5] CALL 0 1 1 ; 0 in 0 out
6 [5] RETURN 0 1 1 ; 0 out

function <hello.lua:1,4> (5 instructions at 0x600000928100)
0 params, 2 slots, 1 upvalue, 0 locals, 2 constants, 0 functions
1 [2] GETTABUP 0 0 0 ; _ENV "print"
2 [2] GETTABUP 1 0 1 ; _ENV "bar"
3 [2] CALL 0 2 1 ; 1 in 0 out
4 [3] RETURN0
5 [4] RETURN0

根据输出的指令集,脚本中的 foo 函数会被 SETTABUP 指令注册到表 _ENV 中,键名为 “foo”;bar 变量则会使用 GETTABUP 指令在 _ENV 表中查找键 “bar”。表 _ENV 是用来存储全局变量的,可以称之为上值表,表中的每一个全局变量都是一个上值,因为在 Lua 环境中的任何位置都可以通过查找上值表来代替参数方式对其引用。同样地,Lua 中的每一个函数都是一个闭包,因为它们至少可以引用全局变量这一特殊的上值。

在上述指令集中,函数的编译是以 CLOSURE 指令集进行的。该指令会根据语义分析阶段生成的Proto类型的函数定义来生成函数的调用入口,如果函数作用域是全局的,会将其放入 _ENV 表中;若函数作用域是局部的,则将其放入栈中。函数经过 CLOSURE 指令编译后,将会在内存中生成一段 chunk 文件,当需要调用该函数时,会通过 CALL 指令在内存中寻址,找到该 chunk 片段。除内存寻址外,还需要进行一些其他操作,在 LVM 的 OP_CALL 分支中,主要功能是通过luaD_precall来完成的,该函数有比较详细的注释:

1
2
3
4
5
6
7
8
9
10
11
/*
** Prepares the call to a function (C or Lua). For C functions, also do
** the call. The function to be called is at '*func'. The arguments
** are on the stack, right after the function. Returns the CallInfo
** to be executed, if it was a Lua function. Otherwise (a C function)
** returns NULL, with all the results on the stack, starting at the
** original function position.
*/
CallInfo *luaD_precall (lua_State *L, StkId func, int nresults) {
...
}

根据注释,该函数的主要功能是将函数的参数拷贝到当前栈顶,然后在 Lua 栈与上值表中查询需要的上值并绑定。不是全局变量的 UpValue 被称为 OpenUpValue,这些值是存储在 Lua 栈中的,如果函数中所需要的上值并不是全局变量,那么该函数需要在 Lua 栈中查找所需的 OpenUpValue。

局部与全局变量

Lua 中局部全量与全局变量的存储位置不同,全局变量会存储在_ENV 表中,而局部变量则是直接存储在栈上的;由于存储位置不同,对二者的寻址方式也是不同的。以下述的脚本为例:

1
2
3
local foo = 1
bar = 2
print(foo + bar)

使用 luac 工具输出上述 Lua 脚本的指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
luac -l hello.lua

main <hello.lua:0,0> (9 instructions at 0x600003c30080)
0+ params, 3 slots, 1 upvalue, 1 local, 3 constants, 0 functions
1 [1] VARARGPREP 0
2 [1] LOADI 0 1
3 [2] SETTABUP 0 0 1k ; _ENV "bar" 2
4 [3] GETTABUP 1 0 2 ; _ENV "print"
5 [3] GETTABUP 2 0 0 ; _ENV "bar"
6 [3] ADD 2 0 2
7 [3] MMBIN 0 2 6 ; __add
8 [3] CALL 1 2 1 ; 1 in 0 out
9 [3] RETURN 1 1 1 ; 0 out

可以看到,foo 变量是直接使用 LOADI 指令存储在栈位置 0 上的;而 bar 则是存储在了 _ENV 表中。在取值阶段,局部变量会直接在栈中寻址,而全局变量则需要使用 GETTABUP 指令先将值拷贝到栈上才能够使用。

关系图

最后以一张关系图作为结束,该图简单描述了 C 与 Lua 之间的关系。图中的虚线代表着逻辑调用,实现代表着实际调用的逻辑链,属于相同调用的关系用同一种颜色表达。

/images/lua_c.png

以下为图中各个部分的解释:

  • LVM:Lua Virtual Machine,以指令方式与 Lua 脚本交互;
  • cfuncs:注册到 LVM 中的 C 函数,可以被 Lua 脚本直接调用;
  • C Main:除 LVM 和 cfuncs 外的 C 代码,不能被 Lua 脚本直接调用;
  • Lua Stack:LVM 中的栈,以 LValue 类型的形式的各种值;
  • Lua Script:Lua 脚本,经过编译后的脚本以指令形式存储在 Lua 栈中;

图中的各种颜色箭头分别代表:

  • 橙色:在 C 代码中调用 Lua 脚本中的方法;
  • 蓝色:Lua 脚本访问 Lua Stack;
  • 红色:在注册的 C 代码中调用 Lua 脚本中的方法;
  • 黑色:代表编译后的脚本存储在栈中;
  • 绿色:C 函数访问 Lua Stack中的值