理解 LuaJIT:一个简单程序的执行过程
LuaJIT 是 Lua 语言的一个高性能实现,它包含一个即时编译器(JIT,Just-In-Time Compiler),能够将 Lua 代码高效地转换为本地机器代码以提高执行速度。
本文通过一个简单的例子,介绍 LuaJIT 的非 JIT 模式执行过程。
假设你已经有了 LuaJIT2 的源代码,我们需要准备一个 tests/hello.lua 文件。包含以下代码:
1print("hello, world!")
本文将通过讲述它的完整一生,让读者对 LuaJIT 的机制和基础数据结构以及函数有所概念。
环境准备
你需要先获取 LuaJIT2 的源代码,然后编译它。并且使用调试器运行它。VSCode 的调试器是一个不错的选择。
下面是我的 VSCode 配置文件:
1{
2 "version": "0.2.0",
3 "configurations": [
4 {
5 "type": "lldb",
6 "request": "launch",
7 "name": "Debug",
8 "program": "${workspaceFolder}/src/luajit",
9 "args": [
10 "${workspaceFolder}/tests/hello.lua"
11 ],
12 "cwd": "${workspaceFolder}"
13 }
14 ]
15}
命令行解析和程序初始化
本阶段的调用栈概览
1pmain
2 handle_script
3 luaL_loadfile/luaL_loadfilex
4 lua_loadx
5 lj_vm_cpcall/cpparser
6 lj_lex_setup init lexer state
7 lj_parse
8 parse_chunk
9 parse_stmt
10 ...
解析命令行参数
当你在命令行中输入 luajit hello.lua
时,操作系统会解析这个命令并让对应程序执行,并将 “hello.lua” 作为参数传递给它。因此 luajit 的 main
函数(位于 src/luajit.c
中)会被调用。
在 main
中,程序会创建 lua_State
,表示一个 Lua 状态机。
然后,程序会通过 lua_cpcall
间接调用 pmain
函数(位于 src/lua.c
中)。这是为了保证在 pmain
函数中发生的错误能够被捕获。
lua_cpcall 用于带有保护地调用 C 函数。另一个类似的函数是 lua_pcall,它用于调用 Lua 函数。
Lua 状态机
Lua 状态机(Lua State)是一个重要概念,下面简要介绍。
一个 Lua 状态机表示一个 Lua 环境 (environment)。每个状态机都有自己的全局环境 (global environment) 和栈 (stack)。 当我们引入 Lua 库并创建 Lua 环境后,就会得到一个 Lua 状态机。我们可以在该状态机上执行 Lua 代码,访问全局变量和函数,操作栈等。
当我们要执行多段完全独立的 Lua 代码时,需要创建多个独立的 Lua 状态机,每个状态机有自己的环境和栈。
将命令行参数交给 Lua
在 pmain 中,我们通过 collectargs
对命令行参数进行基本的解析,例如用户输入 -v
,则加上 flag FLAGS_VERSION
。然后根据 flag 的设置不同,会有不同的行为,例如 FLAGS_VERSION
存在,则打印版本信息。
由于我们的参数是一个文件名,pcall 将调用 handle_script
,在 handle_script
中,我们调用 luaL_loadfile
,从而将文件内容加载到 Lua 状态机中。
luaL_loadfile
内部会调用 luaL_loadfilex
,我们进入到文件的装载流程。
装载和解析文件
本节中,我们将介绍 Lua 文件的装载过程。
文件的装载
相关代码在 src/lj_load.c
中。
luaL_loadfilex
直接通过 fopen
打开文件,还会把文件名记录到状态栈,作为 chunkname
。文件打开后,文件描述符保存在 ctx 中。ctx 指的是 FileReaderCtx
,它存放文件读取的状态,它可以作为读取文件的缓冲,并记录读到了哪里。
然后 luaL_loadfilex 调用 lua_loadx。这里面,利用 lj_vm_cpcall 调用 cpparser。
在 cpparser 中,主要目标是:
-
进行词法分析(利用 LexState)
-
GCproto 生成(通过解析字节码或者源代码) 前者使用
lj_bcread
后者使用lj_parse
-
生成 GCfunc
实际上,整个 chunk 最后会被转换为一个 GCfunc,然后放到状态机的栈顶。由于我们的源码是文本格式,所以会调用 lj_parse
。
代码的解析
解析相关代码在 src/lj_parse.c
中。
LuaJIT 的解析过程中,词法分析和语法分析是一起进行的,这一点从函数原型也可以看出来:
1GCproto *lj_parse(LexState *ls)
解析的目的是生成 GCproto,一个函数原型。与 GCfunc 不同,GCproto 只包含函数的原型信息,而不包含函数的运行时状态。比如说,upvalue 的值就不包含在 GCproto 中。
一些解析时比较重要的信息保存在 ls(LexState)中,例如 fs(FuncState)、tok(当前 Token)、行号和文件名等。
FuncState 通过 fs_init 初始化,它包含了当前函数的许多信息,为编译和执行该函数提供支持。例如 bl 当前作用域、prev 外围函数状态、bcbase 字节码栈的基地址、pc 指针等。其中 bcbase 能告诉我们下一条字节码插入到哪里。
当预先读取一个 token 之后,就进入到 parse_chunk 函数中。
1 lj_lex_next(ls); /* Read-ahead first token. */
2 parse_chunk(ls);
lua 中的 chunk 是一个语句块,它可以包含多个语句。函数定义、变量定义、表达式等都是语句。因此函数体可以看做是一个语句块。甚至整个文件也可以看做是一个语句块。
parse_chunk 函数会不断尝试调用 parse_stmt,直到所有的语句都被解析完毕。parse_stmt 会根据下一个 Token,调用其构成元素的解析器,例如 parse_func、parse_if、parse_while 等。
这是一种经典的递归下降解析流程。
由于我们的代码是一个函数调用,所以 parse_chunk 会调用 parse_call_assign。然后 print("hello world")
会被解析为一个表达式。
解析表达式
本例的表达式在 expr_primary 函数中被解析。
在解析技术中,表达式可以分为前缀(prefix)、中缀(infix)、后缀(postfix)和基础(primary)四个部分。
例如:-10! + (9! + 8!)
这个数学表达式中,-
是前缀,!
是后缀,+
是中缀,10
、9
、8
是基础。我们也可以把 (9! + 8!)
看做一个基础。
一个更复杂的例子:a.b.c[1].d(1, 2, 3)
。它可以看做基础 a.b.c[1].d
和后缀 (1, 2, 3)
两部分,整体上构成一个 CallExpr。前缀是一个 MemberExpr,后者是一个 ArgList。a.b.c[1].d
又可以分为两部分:
-
a.b.c[1]
是一个 MemberExpr,它的前缀是a.b.c
,后缀是[1]
。 -
d
是一个 PrimaryExpr,它同时也是.
Op 的 RHS Opr。
expr_primary 它能够处理诸如变量、函数调用、表索引和方法调用等表达式。它只支持前缀表达式和后缀表达式的解析。
原始代码有些复杂,这里不写出来了。只给一个简化的伪代码:
1function parse_primary_expression(ls, v)
2 -- Parse prefix expression
3 parse_prefix_expression(ls, v)
4
5 -- Parse multiple expression suffixes in a loop
6 while true do
7 if ls.tok is field_access, table_index, method_call, or function_call then
8 parse_expression_suffix(ls, v)
9 else
10 break
11 end
12 end
13end
对于中缀表达式,它是利用文法规则的层次化来实现对优先级的处理的。
字节码的生成
LuaJIT 解析器非常紧凑,解析的同时也生成字节码。
对于我们的程序,首先调用 var_lookup
在作用域中搜索被调用的函数,存放到 ExpDesc 的状态中。然后,调用 parse_args
解析参数列表。
在这过程中,参数相关的字节码被生成。例如我们的参数是字符串,则会经过 parse_args -> expr_list -> expr_binop -> expr_unop -> expr_simple
的流程。在 expr_init
中,会调用 emit_kstr
生成字节码。
最后调用 BCINS_ABC
生成字节码,通过 bcemit_INS
将字节码推入字节码栈。
VVOID 表示一个没有值的表达式,通常用于语句的末尾或空语句中。 VCALL 表示一个函数调用表达式,包括一个函数对象和它的参数列表,可以产生一个或多个返回值。
执行 Lua 代码
解析完成之后,字节码也生成了。我们回到 cpparser,一个新的函数已经创建好,并被放置在 lua_State 的栈顶。
然后我们回到 handle_script,Lua 通过调用 docall 执行这些字节码。do_call 中,程序会设置跟踪函数。base 错误处理函数的位置,然后调用 lua_pcall 开始执行。
当我们调用 lua_pcall
时,base(即 traceback 指针)以最后一个参数的形式传给了 lua_pcall。
lua_pcall 中,通过 lj_vm_pcall 开始在虚拟机执行 Lua 函数。被执行的函数的地址通过 api_call_base(L, nargs)
推算出。(因为 Lua 中,每个参数的长度都是固定的,因此可以通过参数的数量推算出函数的地址) lua_pcall 会调用函数并捕获错误。
lj_vm_pcall 是用来执行一个 Lua 函数并返回其结果或错误信息的虚拟机入口函数。
它的参数含义如下:
-
L: 指向 lua_State 结构体的指针,表示当前线程的状态。
-
base: 指向 TValue 类型的指针,表示函数的起始参数的地址。
-
nres1: 一个整数值,表示要求返回的结果数量(不包括错误码)。
-
ef: 一个 ptrdiff_t 类型的值,表示错误处理函数在栈中的位置。如果该值为 0,则不设置错误处理函数。
lj_vm_pcall 的源码并不是 C 语言写的。它是通过 DynASM 生成的汇编代码。你可以在 dasc 文件中搜索 ->vm_pcall
找到它的定义。
在 vm_pcall 的执行过程中,会通过 mov DISPATCH, L:RB->glref
和 add DISPATCH, GG_G2DISP
两步,将 DISPATCH
寄存器的值设置为当前 GG_State的分发表地址。
Path src/vm_x64.dasc
1 |1: // Entry point for vm_pcall above (PC = ftype).
2 | mov SAVE_NRES, CARG3d
3 | mov L:RB, CARG1 // Caveat: CARG1 may be RA.
4 | mov SAVE_L, CARG1
5 | mov RA, CARG2
6 |
7 | mov DISPATCH, L:RB->glref // Setup pointer to dispatch table.
8 | mov KBASE, L:RB->cframe // Add our C frame to cframe chain.
9 | mov SAVE_CFRAME, KBASE
10 | mov SAVE_PC, L:RB // Any value outside of bytecode is ok.
11 | add DISPATCH, GG_G2DISP
12 | mov L:RB->cframe, rsp
它会设置被保护的 C 栈帧,顺序执行后进入 vm_call_dispatch
然后视情况决定是否跳转到 lj_vmeta_call
(比如函数对象存在元方法)
我们的情况下,会继续顺序执行,进入 lj_vm_call_dispatch_f
,然后通过 ins_call
跳转到 lj_BC_IFUNCV
。
ins_call 是一个重要的 dasc 宏,定义如下:
Path src/vm_x64.dasc
1|// Call decode and dispatch.
2|.macro ins_call
3| // BASE = new base, RB = LFUNC, RD = nargs+1, [BASE-8] = PC
4| mov PC, LFUNC:RB->pc
5| mov RAd, [PC]
6| movzx OP, RAL
7| movzx RAd, RAH
8| add PC, 4
9| jmp aword [DISPATCH+OP*8]
10|.endmacro
-
第一行代码 mov PC, LFUNC:RB->pc 将 LFUNC 中的 PC 寄存器的值存储到当前执行指令的 PC 寄存器中,以准备执行下一条指令。
-
接下来的三行代码
mov RAd, [PC]
,movzx OP, RAL
,movzx RAd, RAH
从指令中提取操作码(OP)和操作数(RAd),以便进行下一步的操作。 -
add PC, 4 将 PC 寄存器的值增加 4,以便跳转到下一条指令的地址。
-
最后一行代码
jmp aword [DISPATCH+OP*8]
将控制权跳转到 DISPATCH 中相应操作码的指令地址,以执行下一条指令。
经过如上步骤,由于字节码是这样的:
10000 FUNCV 3
20001 GGET 0 0 ; "print"
30002 KSTR 2 1 ; "hello, world!"
40003 CALL 0 1 2
50004 RET0 0 1
这段字节码的执行流程如下:
FUNCV 3 指令将创建一个新的函数,并将其存储在寄存器3中。 GGET 0 0 指令将从全局变量表中获取 print 函数,并将其存储在寄存器 0 中。 KSTR 2 1 指令将一个字符串 “hello, world!” 作为常量,存储在常量表中,并将其存储在寄存器2中。 CALL 0 1 2 调用 0 号寄存器中的函数,返回值数量为 0 个,参数数量为 1 个。 RET0 0 1 从函数中返回,返回值数量为 0 个。
实际运行之后,我们会发现执行的是 lj_BC_IFUNCV
指令,用于初始化闭包函数。具体而言,包括获取指令操作数、计算新函数的栈帧大小并分配,必要时扩展栈空间等等。
然后,进入 lj_BC_GGET
、lj_BC_GGET 内部会调用 lj_BC_TGETS
通过 “print” 这个 key 寻找对应的函数。
然后进入 lj_BC_CALL
,运行时在它内部会进入 lj_BC_FUNCC
,调用内部函数 lj_cf_print
,完成打印。
之后,进入 lj_vm_returnc
,执行 lj_BC_RET
,lj_BC_RET0
,返回。依次经过 lj_vm_return
(主要负责将返回值复制到调用者的栈帧中) lj_vm_leave_unw
(主要负责恢复寄存器),lj_vm_leave_cp
(恢复 C 栈帧,判断 pcall 是否成功),最后完成 pcall.
分发表和计算跳转
那么 LuaJIT 如何知道不同指令如何执行呢?它使用了 dispatch table 和 computed gotos 技术。这种技术非常容易理解
解释器中通常会使用两种方法来处理控制流:计算跳转(computed gotos)和传统写法(如switch语句)。下面是一个非常简单的C语言例子,演示了两种方法在解释器中的应用以及它们的优缺点。
首先,我们假设有一个虚拟机,执行四个操作:加、减、乘、除。指令集如下:
1enum Opcode {
2 OP_ADD = 0,
3 OP_SUB,
4 OP_MUL,
5 OP_DIV,
6 OP_LENGTH
7};
传统的写法就是循环套 Switch
1void interpret_switch(int *stack, int *program, int program_size) {
2 for (int ip = 0; ip < program_size; ++ip) {
3 int op = program[ip];
4 int a = stack[0];
5 int b = stack[1];
6
7 switch (op) {
8 case OP_ADD:
9 stack[0] = a + b;
10 break;
11 case OP_SUB:
12 stack[0] = a - b;
13 break;
14 case OP_MUL:
15 stack[0] = a * b;
16 break;
17 case OP_DIV:
18 stack[0] = a / b;
19 break;
20 }
21 }
22}
而计算跳转的写法如下:
1#define DISPATCH() goto *labels[program[ip++]]
2
3void interpret_computed_goto(int *stack, int *program, int program_size) {
4 static void* labels[] = {&&op_add, &&op_sub, &&op_mul, &&op_div};
5
6 int ip = 0;
7
8 DISPATCH();
9
10 op_add:
11 stack[0] = stack[0] + stack[1];
12 DISPATCH();
13
14 op_sub:
15 stack[0] = stack[0] - stack[1];
16 DISPATCH();
17
18 op_mul:
19 stack[0] = stack[0] * stack[1];
20 DISPATCH();
21
22 op_div:
23 stack[0] = stack[0] / stack[1];
24 DISPATCH();
25}
26#undef DISPATCH
除了让代码更加紧凑外,计算跳转的最大优势是性能上的提升。计算跳转使用goto语句直接跳转到目标代码位置,跳转目标固定的,使得分支预测器容易预测下一条指令。switch语句通常会被编译成条件跳转指令,预测成功率相对较低。除此之外,计算跳转还可以手动进行一些特殊内联,例如直接指定一个指令的下一条指令处理逻辑的位置。
上面的例子中的 labels 就是一个分发表。
在 LuaJIT 中,分发表定义在 GG_State
的 dispatch 字段。在 lua_newstate 时,会调用 lj_dispatch_init
初始化分发表以及热计数器。我们结合源码分析。
1/* Initialize instruction dispatch table and hot counters. */
2void lj_dispatch_init(GG_State *GG)
3{
4 uint32_t i;
5 ASMFunction *disp = GG->dispatch;
6 for (i = 0; i < GG_LEN_SDISP; i++)
7 disp[GG_LEN_DDISP+i] = disp[i] = makeasmfunc(lj_bc_ofs[i]);
8 for (i = GG_LEN_SDISP; i < GG_LEN_DDISP; i++)
9 disp[i] = makeasmfunc(lj_bc_ofs[i]);
10 /* The JIT engine is off by default. luaopen_jit() turns it on. */
11 disp[BC_FORL] = disp[BC_IFORL];
12 disp[BC_ITERL] = disp[BC_IITERL];
13 /* Workaround for stable v2.1 bytecode. TODO: Replace with BC_IITERN. */
14 disp[BC_ITERN] = &lj_vm_IITERN;
15 disp[BC_LOOP] = disp[BC_ILOOP];
16 disp[BC_FUNCF] = disp[BC_IFUNCF];
17 disp[BC_FUNCV] = disp[BC_IFUNCV];
18 GG->g.bc_cfunc_ext = GG->g.bc_cfunc_int = BCINS_AD(BC_FUNCC, LUA_MINSTACK, 0);
19 for (i = 0; i < GG_NUM_ASMFF; i++)
20 GG->bcff[i] = BCINS_AD(BC__MAX+i, 0, 0);
21}
disp 即是分发表,可以看做一个映射,从指令字节码指向汇编函数(ASMFunction)函数。这里的循环就是初始化分发表的主要逻辑,makeasmfunc 将LuaJIT内部的字节码指令偏移转换为实际的汇编函数指针。
我们还会看到 disp[BC_FORL] = disp[BC_IFORL];
这样的代码,这是设置某些特定字节码的分发指针。将BC_FORL字节码的分发指针设置为与BC_IFORL相同,后者是BC_FORL的内联版本。
1GG->g.bc_cfunc_ext = GG->g.bc_cfunc_int = BCINS_AD(BC_FUNCC, LUA_MINSTACK, 0);
这行初始化了全局状态的内部和外部C函数调用的默认字节码。它们将BC_FUNCC字节码与给定的参数A(LUA_MINSTACK)和参数D(0)一起编码成一个32位整数,用于在分发表中查找分发指针。GG->g.bc_cfunc_ext 和 GG->g.bc_cfunc_int 这两个字段表示内部和外部C函数调用的默认字节码。这意味着当LuaJIT需要调用C函数时,将使用这个字节码作为默认行为。
1 for (i = 0; i < GG_NUM_ASMFF; i++)
2 GG->bcff[i] = BCINS_AD(BC__MAX+i, 0, 0);
初始化了LuaJIT的所有内联汇编快速函数(Asm Fast Functions,简称ASMFF)的字节码。
作的一组高效汇编函数。ASMFF 可以直接内嵌到字节码指令流中,从而提高执行性能。
言归正传,对于不同指令之间的交接,我们可以找到一个这样的宏:
|// Instruction decode+dispatch. Carefully tuned (nope, lodsd is not faster).
|.macro ins_NEXT
| mov RCd, [PC]
| movzx RAd, RCH
| movzx OP, RCL
| add PC, 4
| shr RCd, 16
| jmp aword [DISPATCH+OP*8]
|.endmacro
这里将PC寄存器指向的内存中的指令读取出来,然后将指令解码出操作码 OP,根据 OP 计算下一条字节码指令的解释语句指令的地址。
基本上,每个指令结尾都会执行 ins_next,从而实现了字节码指令之间的跳转,不需要一个低效率的 switch + 主循环。
语句直接的跳转都是通过这样的形式实现,而第一条指令的地址是通过 vm_call 设置,通过 ins_call 开始第一个字节码的解释执行。具体过程上文有提到。
JIT 编译
在执行过程中,LuaJIT 的即时编译器会观察代码的运行情况。如果发现某些代码片段频繁执行,他们就会被编译成 IR,然后可以基于 IR 优化并生成机器码。
然而,在这个简单的例子中,我们的代码只有一行,且只执行一次,尽管 JIT 可能会初始化,但不会触发 JIT 编译。
作为 LuaJIT 的核心功能,我们将在后面的章节讲述。
GC
执行完毕之后,lua_close 会释放 lua_State,这个过程会进行 GC。由于代码只有一行,内存占用率很低,所以执行过程中不太可能触发 GC。我们也将后面详细讲述。
参考
https://eli.thegreenplace.net/2012/07/12/computed-goto-for-efficient-dispatch-tables
LuaJIT 2.x (not out yet) uses a different VM. The bytecode has an 8 bit opcode field and 2 or 3 operands with 8/8/8 or 8/16 bits. The interpreter is written in hand-coded assembler. Dispatch is decentralized at the end of every operation using an indirect jump through a central table, indexed by the opcode. The dispatch table can be patched on-the-fly to support debugging and trace recording.