本文结合了 Lua 源码浅析了 Lua 是如何运行并与 C 语言函数进行交互的,适合不理解 Lua 脚本运行机制、VM 运行机制的读者。
Lua 指令码
Lua 虽然是脚本语言,但是在运行前需要进行代码编译;与 C/C++ 不同,Lua 脚本经过编译生成后的二进制 chunk 文件并不能够直接被物理机识别和运行,只能够在 Lua Virtual Machine ( LVM ) 中运行。Lua 编译后生成的二进制 chunk 文件是由 Lua 指令集构成的,仅能够被同版本的 Lua 虚拟机环境识别。
在 chunk 文件中,主要负责与 LVM 进行交互的部分为 Lua 指令。在 Lua 的源码中,Lua 指令相关的代码主要集中在lopcodes.h
文件中,该文件中也包含了对 Lua 指令的介绍:
1 | /*=========================================================================== |
一条 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 | void luaV_execute (lua_State *L, CallInfo *ci) { |
截取的代码片段有一条 MOVE 指令的执行,在判断为 OP_MOVE 指令后,luaV_execute
直接根据该指令的编码格式 iABC (虽然只有两个操作数,但是该指令仍为 iABC 格式),获取两个操作数 A 和 B。跟随RA()
函数的调用链:
1 | // 获取 A 值大小 |
上述代码中的 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_CALL
和OP_TAILCALL
,然后根据指令来进行栈空间的切换。首先分析 LVM 中OP_CALL
分支的实现:
1 | startfunc: |
这一分支的实现比较复杂,并且比较分散,这里解释一下主要的逻辑:当一个函数被调用时,LVM 将会更新当前运行的函数,并在 Lua 栈中选取一段合适的大小分配给该函数作为函数的运行栈。完成栈空间的分配后,LVM 会将函数的输入参数按照顺序拷贝到栈空间的起始位置,方便在函数中进行寻址操作。当完成上述这些操作后,LVM 判断需要调用的函数是否为 C 函数,若为 C 函数则运行后退出当前栈空间(C 函数在 LVM 退出逻辑不在这里);否则将继续在当前栈空间下执行其他指令。
当函数完成执行后,将会使用OP_TAILCALL
进行退出函数栈,该分支的实现为:
1 | vmcase(OP_TAILCALL) { |
退出的逻辑更为复杂,因为在退出时还需要处理 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 | function foo() |
在上述代码中,bar 就是一个 UpValue,同样,bar 也是一个全局变量。在 Lua 中,全局变量是一种比较特殊的 UpValue,它与普通的 UpValue 存储位置不同。我们使用 luac 工具输出上述 Lua 脚本的指令:
1 | luac -l hello.lua |
根据输出的指令集,脚本中的 foo 函数会被 SETTABUP 指令注册到表 _ENV 中,键名为 “foo”;bar 变量则会使用 GETTABUP 指令在 _ENV 表中查找键 “bar”。表 _ENV 是用来存储全局变量的,可以称之为上值表,表中的每一个全局变量都是一个上值,因为在 Lua 环境中的任何位置都可以通过查找上值表来代替参数方式对其引用。同样地,Lua 中的每一个函数都是一个闭包,因为它们至少可以引用全局变量这一特殊的上值。
在上述指令集中,函数的编译是以 CLOSURE 指令集进行的。该指令会根据语义分析阶段生成的Proto
类型的函数定义来生成函数的调用入口,如果函数作用域是全局的,会将其放入 _ENV 表中;若函数作用域是局部的,则将其放入栈中。函数经过 CLOSURE 指令编译后,将会在内存中生成一段 chunk 文件,当需要调用该函数时,会通过 CALL 指令在内存中寻址,找到该 chunk 片段。除内存寻址外,还需要进行一些其他操作,在 LVM 的 OP_CALL 分支中,主要功能是通过luaD_precall
来完成的,该函数有比较详细的注释:
1 | /* |
根据注释,该函数的主要功能是将函数的参数拷贝到当前栈顶,然后在 Lua 栈与上值表中查询需要的上值并绑定。不是全局变量的 UpValue 被称为 OpenUpValue,这些值是存储在 Lua 栈中的,如果函数中所需要的上值并不是全局变量,那么该函数需要在 Lua 栈中查找所需的 OpenUpValue。
局部与全局变量
Lua 中局部全量与全局变量的存储位置不同,全局变量会存储在_ENV 表中,而局部变量则是直接存储在栈上的;由于存储位置不同,对二者的寻址方式也是不同的。以下述的脚本为例:
1 | local foo = 1 |
使用 luac 工具输出上述 Lua 脚本的指令:
1 | luac -l hello.lua |
可以看到,foo 变量是直接使用 LOADI 指令存储在栈位置 0 上的;而 bar 则是存储在了 _ENV 表中。在取值阶段,局部变量会直接在栈中寻址,而全局变量则需要使用 GETTABUP 指令先将值拷贝到栈上才能够使用。
关系图
最后以一张关系图作为结束,该图简单描述了 C 与 Lua 之间的关系。图中的虚线代表着逻辑调用,实现代表着实际调用的逻辑链,属于相同调用的关系用同一种颜色表达。
以下为图中各个部分的解释:
- 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中的值