本文将完整拆解 Lua 从源码到执行的全生命周期,覆盖编译期(源码→闭包) 、运行时(虚拟机执行) 、值类型系统 、全局状态与线程模型 、垃圾回收 、元方法 、调试接口 、C API 栈操作及核心结构体内存占用,完整还原 Lua 底层执行逻辑。
一、编译期:从源码到闭包
Lua 没有独立的预编译阶段,编译器逻辑完全嵌入在运行时中。只有当 C API(如 lua_load、luaL_loadfilex、luaL_loadstring)被调用时,才会触发词法分析和语法分析,最终生成字节码闭包供虚拟机执行。
1.1 编译入口
lbaselib.c 中的 base_funcs 表里,load 函数对应 luaB_load,其职责是把一段 Lua 代码(字符串或 reader 函数)编译成一个可执行的函数闭包并返回给调用者。
整个编译链路如下:
plaintext
luaL_loadstring / luaL_loadfilex
→ lua_load
→ luaD_protectedparser // 保护性编译入口
→ f_parser // 编译器真正入口
→ 若为二进制:反序列化字节码 → 生成 Proto + LClosure
→ 若为文本:luaY_parser(词法 + 语法分析)→ 生成 Proto + LClosure
luaD_protectedparser 的作用是在一个安全环境里调用 f_parser,并在出错时能优雅地返回错误,而不是直接崩溃。
1.2 Proto 与 LClosure
所有 Lua 函数(包括顶层 chunk)都会被编译成一个 Proto(函数原型)。Proto 中保存了字节码指令、常量表、局部变量信息和调试信息等内容。
每个 Proto 都会被封装进一个 LClosure(Lua 闭包)。闭包除了引用 Proto,还包含 upvalue(如 _ENV)。执行时,Lua VM 运行的是 LClosure,闭包内部引用了对应的 Proto。
每个函数都是一个 chunk:顶层 chunk 是整个文件或字符串,内部函数是嵌套 chunk。函数内可以继续定义函数,因为 Proto 内部可以嵌套存放子 Proto。
1.3 词法分析(llex.c)
luaY_parser 是源码编译器的主入口。它首先通过 luaX_setinput 把源码流交给词法分析器,然后调用 luaX_next 推进到第一个 token。
词法分析的核心函数是 llex,它负责从源码字符流中逐字符扫描,识别关键字、标识符、数字、字符串、运算符和注释,每次返回一个 token 供语法分析器消费。llex 是按需驱动的,不会一次性生成所有 token,而是由语法分析器(lparser.c)主导调用。
1.4 语法分析与字节码生成(lparser.c)
语法分析器的核心流程是 statlist,它以递归下降方式遍历并消费 token,同时调用 luaK_* 系列函数直接生成字节码指令,填充到 Proto 中。
编译过程使用 Vardesc、FuncState 等结构记录局部变量、upvalue 和作用域信息。mainfunc 作为处理顶层 chunk 的入口,负责把顶层源码编译成一个闭包,并设置默认环境 _ENV。
完整编译链路如下:
plaintext
源码
→ llex(词法分析,按需生成 token)
→ statlist(语法分析,递归下降)
→ exprstat / suffixedexp / primaryexp
→ luaX_next(读取标识符/运算符)
→ luaK_codeABC(发射字节码指令)
→ luaK_exp2nextreg(将表达式放入寄存器)
→ close_func(发射 RETURN 指令,收缩数组)
→ 返回 Proto + LClosure
编译完成后,闭包通过 load_aux 被压到 Lua 栈上,等待 VM 执行。
1.5 预编译字节码
Lua 提供了 string.dump(function),可以把函数的字节码(即 Proto)序列化成二进制字符串,写入文件(通常扩展名为 .luac)。运行时再用 lua_load 或 luaL_loadfilex 加载该二进制文件,Lua 会直接反序列化成 Proto + LClosure,从而跳过词法分析和语法分析,直接进入执行阶段。
运行时之所以仍然保留词法和语法分析能力,根本原因是 Lua 设计上追求轻量与灵活,不依赖外部编译器。热更新(在程序运行中加载新脚本、重新定义函数逻辑)正是这种机制的自然产物。
二、运行时:虚拟机启动与执行
2.1 Lua 入口与虚拟机初始化
整个 Lua 程序的入口是 lua.c 中的 main 函数,其初始化流程如下:
2.1.1 创建 Lua 状态机
lua_newstate(l_alloc, NULL) 创建一个新的 Lua 虚拟机状态(lua_State)。参数 l_alloc 是内存分配函数,NULL 是传给它的用户数据。这一步会初始化全局状态(global_State)、主线程、GC、字符串表等核心组件。
lua_State 是 Lua 虚拟机中最核心的结构之一,代表着一个 Lua 线程(coroutine)或执行上下文。每个运行中的 Lua 协程都有一个对应的 lua_State,里面保存了该协程的所有运行状态。
2.1.2 设置 panic 与警告函数
lua_atpanic(L, &panic) 注册 panic 回调,当 Lua 遇到无法捕获的错误时(如内存分配失败)调用,默认行为是打印错误信息并调用 abort()。lua_setwarnf(L, warnfoff, L) 设置警告函数,默认关闭警告输出,用户可通过 API 修改。
2.1.3 加载标准库
pmain 执行后会调用 luaL_openlibs,遍历所有标准库(base、table、string、math、io、os、debug、package),调用各自的初始化函数,把库表注册到全局环境 _G。相关库的加载列表在 linit.c 的 loadedlibs 中维护,通过 luaL_setfuncs 遍历 base_funcs 数组,把每个函数注册到栈顶的 _G 全局表。
2.1.4 其他初始化
createargtable 创建 Lua 表 arg,保存命令行参数(脚本里可用 arg[1] 访问)。lua_gc(L, LUA_GCGEN, 0, 0) 把垃圾回收器设置为分代模式,影响后续内存管理和性能表现。
2.2 执行脚本
pmain 调用 dofile(L, NULL) 执行脚本文件,其内部流程是:luaL_loadfile(编译脚本)+ lua_pcall(执行闭包)。
整体链路如下:
plaintext
源码 → 编译成闭包(lua_load)→ 压栈
→ dochunk(取出栈顶闭包)
→ docall(安全调用封装,负责错误处理和信号处理)
→ lua_pcall → lua_pcallk → luaD_call → ccall
→ luaV_execute(VM 核心执行循环)
2.3 VM 执行核心:luaV_execute
luaV_execute 是 VM 最关键的部分,它解析 luaD_precall 生成的 CallInfo,按照寄存器模式的栈结构依次执行字节码。
执行流程是一个主循环:
c
运行
for(;;) {
vmfetch(); // 取指令(vmfetch 宏)
vmdispatch(); // 译码并执行(vmdispatch 宏)
}
Lua 字节码共有 63 个操作模式,32 位指令决定操作类型,根据不同精度执行运算。每次循环执行完一条指令后更新栈状态。Protect 宏在可能触发错误的操作前后做状态保护,出错时可恢复。
关键设计:savedpc 是 VM 的程序计数器快照,保存当前字节码执行位置,确保在函数调用、错误处理、协程切换等场景下能正确恢复执行位置。
2.4 寄存器模式 vs 栈模式
Lua 的栈在 C 端使用后进先出的栈结构(StackValue 数组),而每个进入 VM 的操作则以寄存器模式表示。
这样设计的目的是:避免像 Python 那样每次操作都要执行多条压栈 / 弹栈指令,减少操作指令数量,从而提升执行速度。
三、值类型系统
3.1 类型编码
Lua 用 8 位表示一个值的类型信息:低 4 位是大类型,高 2 位是变体(子类型),最高位标记是否为可回收对象(GC 对象)。
大类型共 9 种:
表格
| 大类型 | 编号 | 说明 |
|---|---|---|
| NIL | 0 | 空值 |
| BOOLEAN | 1 | 布尔值 |
| LIGHTUSERDATA | 2 | 轻量用户指针 |
| NUMBER | 3 | 数值 |
| STRING | 4 | 字符串 |
| TABLE | 5 | 表 |
| FUNCTION | 6 | 函数 |
| USERDATA | 7 | 完整用户数据 |
| THREAD | 8 | 协程 / 线程 |
完整变体类型表(含可回收标记):
表格
| 变体宏名 | tt_ 值 | 可回收 | 说明 |
|---|---|---|---|
| LUA_VNIL | 0x00 | 否 | 标准 nil |
| LUA_VEMPTY | 0x10 | 否 | 空槽位(表内部使用) |
| LUA_VABSTKEY | 0x20 | 否 | 不存在的键 |
| LUA_VFALSE | 0x01 | 否 | false |
| LUA_VTRUE | 0x11 | 否 | true |
| LUA_VLIGHTUSERDATA | 0x02 | 否 | 轻量指针 |
| LUA_VNUMINT | 0x03 | 否 | 64 位整数 |
| LUA_VNUMFLT | 0x13 | 否 | 64 位浮点数 |
| LUA_VSHRSTR | 0x44 | 是 | 短字符串(≤40 字节) |
| LUA_VLNGSTR | 0x54 | 是 | 长字符串(>40 字节) |
| LUA_VTABLE | 0x45 | 是 | 表 |
| LUA_VLCL | 0x46 | 是 | Lua 闭包 |
| LUA_VLCF | 0x16 | 否 | 轻量 C 函数(无 GC) |
| LUA_VCCL | 0x66 | 是 | C 闭包 |
| LUA_VUSERDATA | 0x47 | 是 | 完整用户数据 |
| LUA_VTHREAD | 0x48 | 是 | 协程 / 线程 |
| LUA_VUPVAL | 0x49 | 是 | 上值(内部类型) |
| LUA_VPROTO | 0x4A | 是 | 函数原型(内部类型) |
3.2 GCObject 与 TValue
可回收对象都拥有一个 GCObject 头,其中包含 CommonHeader(next 指针 8 字节 + tt 1 字节 + marked 1 字节 + 对齐),在 64 位系统上共占 16 字节。GCObject 内的 next 指针将所有可回收对象串成链表,供 GC 遍历。
Value 是一个 union 联合体,包含可回收对象指针 GCObject、轻量用户数据、C 函数指针、整数和浮点数等,取最大成员的占用大小,统一为 8 字节。
TValue 在 Value 外面再包一层类型标记(tt_),是栈上所有值的统一表示,所有值类型均以 TValue 存放并统一处理。TValue 共占 16 字节(Value 8 字节 + tt_ 1 字节 + 对齐 7 字节)。
3.3 字符串
TString 至少占用 24 字节头部,加上字符串内容本身。Lua 以 40 字节为界区分短字符串和长字符串:
- 短字符串(≤40 字节):在
luaS_newlstr中根据哈希值查找全局字符串表,用hnext指针将同一哈希桶里的字符串串成链表解决冲突,相同内容的短字符串在整个 VM 中只有一份(字符串内化 /interning)。 - 长字符串(>40 字节):每次都新建一个字符串对象,不做内化。
3.4 Table
Table 是 Lua 中同时充当数组、字典、对象和模块的核心数据结构。它把 GC 头、数组部分、哈希部分、元表等全部平铺到结构体上,因此一个 Table 实例就占用 56 字节。
如果项目中大量使用 Table 来表达简单数据,内存占用会显著增大,也容易因 GC 频繁触发而造成卡顿。
3.5 函数与闭包
Lua 的函数有三种形式,通过类型标记区分:
LUA_VLCL(Lua 闭包):运行时根据Proto和作用域链建立 upvalue,创建时先清空 upvalue 等待后续填充。LUA_VLCF(轻量 C 函数):直接存函数指针,无 GC 开销。LUA_VCCL(C 闭包):C 函数 + upvalue,upvalue 为外部传入值。
函数被编译保存到 Proto,Proto 存放在 FuncState 中。闭包创建时会找到所有 upvalue 填入 upvals 数组,同一作用域内的 upvalue 被共享(同一实例共用同一份),不同实例则各有独立的 upvalue。
局部变量若走 to-be-close 逻辑(如 LUA_TNUMBER 等类型),编译时会生成 OP_TBC 指令,VM 在运行时作用域结束时自动清理。
尾调用会关闭当前上值,然后创建新的 CallInfo,把参数传入并直接复用当前栈帧执行新函数,因此不会导致栈溢出。Lua 限制最大递归深度为 200(LUAI_MAXCCALLS = 200)。
四、全局状态与线程模型
4.1 global_State
global_State 是全局数据,每个虚拟机实例只有一份。它集中管理内存分配器、垃圾回收器、字符串表、注册表、元表、主线程等所有核心组件。每次调用 lua_newstate 都会创建一个独立的 Lua 虚拟机,每个虚拟机独立运行,互不干扰。
主线程由一个 LG 结构体包含(LG = lua_State + global_State),创建虚拟机时同步创建。
4.2 协程(伪线程)
Lua 的 "线程" 实际上是协程,并非操作系统级别的多线程。同一时刻只有一个执行流,不存在真正的并发。
协程拥有独立的 lua_State,包含自己的调用栈、指令指针和局部变量环境。执行 coroutine.yield 时,当前协程的执行状态被完整保存;执行 coroutine.resume 时,Lua 切换到目标协程的 lua_State,恢复其栈和指令指针,从断点继续执行,实现 "挂起与恢复"。
4.3 栈管理
栈通过 stack_init 初始化。向栈中压值前,会通过 luaL_checkstack 检查空间,不足时调用 luaD_growstack 扩容 ------ 默认上限为 100 万个值,未超过则在当前大小基础上翻倍扩展。
当线程未处于栈溢出错误处理状态,且栈大小已超过当前使用量 3 倍时,会将栈缩小到当前使用量的 2 倍,以节省内存。
五、垃圾回收
5.1 三色标记清除
Lua GC 采用三色标记清除算法,三种颜色的含义如下:
- 白色(两种):白色 0 和白色 1 交替使用。每轮 GC 只清除 "上一轮的白色" 对象,保留 "本轮新建的白色" 对象,避免刚创建的对象被误回收。轮次结束后切换白色集合。
- 灰色:对象自身已被标记为存活,但其子对象尚未扫描完毕。GC 维护一个灰色工作队列,逐个扫描灰色对象的子对象:子对象若为白色则变为灰色;当前灰色对象的所有子对象都处理完后变为黑色。
- 黑色:对象自身及所有子对象均已扫描完毕,不会被回收。若黑色对象引用了白色对象,则通过
luaC_barrier将该白色对象提升为灰色(写屏障正向);若白色对象引用了黑色对象,则通过luaC_barrierback将白色对象变为灰色(写屏障反向)。
每次创建闭包、upvalue、Proto、字符串、userdata 或 table 时,都会调用 luaC_newobj,将新对象标记为白色并挂入 GCObject 链表,以供后续 GC 识别处理。
5.2 分代回收
Lua 支持分代 GC,对象按存活时长分为不同年龄档:
表格
| 年龄值 | 宏名 | 含义 |
|---|---|---|
| 0 | G_NEW | 当前周期新创建 |
| 1 | G_SURVIVAL | 存活了一个小周期 |
| 2 | G_OLD0 | 被写屏障标记为老化(本轮) |
| 3 | G_OLD1 | 第一个完整周期作为老对象 |
| 4 | G_OLD | 真正的老对象(不再被扫描) |
| 5 | G_TOUCHED1 | 老对象在本轮被修改 |
| 6 | G_TOUCHED2 | 老对象在上轮被修改 |
分代 GC 分为两种周期:小周期(youngcollection)只扫描年轻对象(G_NEW、G_SURVIVAL),大周期等价于全量 GC(fullgen),通过增量标记清除与分代 GC 的切换来实现新老生代标记。
5.3 GC 执行路径
通过 API 手动触发 GC 时,最终调用 luaB_collectgarbage → lua_gc。VM 在执行字节码期间,luaV_execute 通过 checkGC 宏自动判断是否需要触发 GC,并调用 luaC_step。
增量标记清除策略通过 incstep → singlestep 按阶段推进,各阶段如下:
plaintext
GCSpause → GC 暂停阶段
GCSpropagate → 递归标记可达对象
GCSenteratomic → 原子阶段,确保所有对象都被标记
GCSswpallgc → 清除不可达对象
GCSswpfinobj → 处理带 __gc 元方法的对象
GCSswptobefnz → 将带 __gc 的对象移至待终结队列
GCSswpend → 完成所有释放,准备回到 pause
GCScallfin → 执行用户定义的清理逻辑(__gc)
所有 GC 管理对象的堆分配最终都走到 luaM_realloc_,这是 GC 托管对象的堆内存申请的最终入口。
六、元方法
Lua 内置了 25 个元方法,定义在 ltm.c 中。luaD_tryfuncTM 是元方法的调用入口:对于 table 或 userdata,会查找是否有 __call 元方法,有则作为函数调用。
七、调试接口
7.1 Hook 机制
luaD_poscall 在函数结束后被调用,负责把返回值写到调用者的栈上;若设置了 hook,则在此处触发 return hook。
lua_sethook 是 Lua 提供的调试接口,允许在 C 层为某个 Lua 状态机设置钩子函数,在以下事件触发时调用:
- 函数调用(call hook):用于调试器跟踪函数进入。
- 函数返回(return hook):用于统计函数执行结果。
- 每行执行(line hook):用于单步调试和代码追踪。
- 指令计数(count hook):每隔 N 条指令触发一次,用于性能分析或超时控制。
7.2 debug 库
Lua 内置 debug 库提供了访问栈、变量和函数信息的能力:
debug.getinfo():获取函数信息(来源、行号、类型等)。debug.getlocal()/debug.setlocal():访问和修改局部变量。debug.traceback():打印当前调用栈。
八、C API 栈操作
8.1 索引规则
- 正数索引:相对栈底的偏移,1 表示栈底第一个元素。
- 负数索引:相对栈顶的偏移,-1 表示栈顶,-2 表示次栈顶,以此类推。
8.2 常用 API
表格
| API | 说明 |
|---|---|
| lua_gettop | 返回栈顶指针到函数基地址之差,即当前元素数量 |
| lua_absindex | 将负数相对索引转为正数绝对索引 |
| lua_checkstack | 确保栈空间足够容纳指定数量的新元素 |
| lua_settop | 正值增大栈(不足补 nil),负值缩小栈 |
| lua_pop | 从栈顶弹出指定数量的元素 |
| lua_pushvalue | 将指定索引处的值复制一份压到栈顶 |
| lua_rotate | 旋转栈元素,正数右移,负数左移 |
| lua_copy | 将 fromidx 处的值覆盖到 toidx 处 |
| lua_remove | 移除指定位置的元素(rotate 后弹出实现) |
| lua_insert | 将栈顶元素插入到指定索引位置 |
| lua_replace | 用栈顶值覆盖指定索引处的值 |
| lua_xmove | 在两个 lua_State 之间移动值(用于协程间通信) |
| lua_push* | 各种 push 操作,将对应类型的值压栈,栈顶指针上移 |
| lua_is* | 根据索引检查栈上 TValue 的类型标记 |
| lua_to* | 将栈上的值转换为 C 原生类型 |
| lua_callk | 直接调用函数(不捕获错误),适用于确定不会出错的场景 |
| lua_pcallk | 受保护调用,捕获错误并返回状态码,适合宿主程序安全执行 Lua |
8.3 协程相关 API
lua_resume:恢复一个挂起的 Lua 协程,让它继续执行。L 是目标协程(由lua_newthread创建),from 是调用方(通常是主线程),nargs 是传给协程的参数数量。返回LUA_YIELD(再次挂起)、LUA_OK(正常结束)或错误码。lua_yieldk:在协程内挂起执行,把栈顶 nresults 个值返回给调用者。保存当前CallInfo及 continuation 函数(k)和上下文(ctx),设置状态为LUA_YIELD,将控制权归还给lua_resume的调用方。协程被再次 resume 时,会调用保存的 continuation k。lua_yield:lua_yieldk的简化版本,无 continuation,只挂起协程并返回结果给调用者。
九、主要结构体内存占用
以下是 Lua 主要结构体在 64 位系统上的内存占用,供性能分析和内存评估参考:
表格
| 结构体 | 大小(字节) | 说明 |
|---|---|---|
| TValue / StackValue | 16 | Value (8) + tt_(1) + 对齐 (7) |
| GCObject | 16 | next (8) + tt (1) + marked (1) + 对齐 (6) |
| TString(短) | 32 + len | header (24) + 内容 (len+1) |
| TString(长) | 32 + len | header (24) + 内容 (len+1) |
| Table | 56 | 含 7 个指针和若干标志字节 |
| Node(哈希节点) | 24 | 展开的 key + value |
| LClosure | 24 + 8×n | header + n 个 UpVal 指针 |
| CClosure | 24 + 16×n | header + n 个 TValue 上值 |
| Proto | ~144 | 大量指针和计数器 |
| UpVal | 48 | header + v + union(open/value) |
| CallInfo | 64 | 双向链表 + 联合体 |
| lua_State | ~160 | GC 头 + 栈指针 + hook 等 |
| global_State | ~400 | 所有全局运行时数据 |
| LG(主虚拟机) | ~560 | lua_State + global_State 合体 |
附录:完整编译执行调用链
以下是从 luaL_dostring 到 VM 执行的完整调用链:
【编译阶段】luaL_dostring(宏展开)
plaintext
= luaL_loadstring || lua_pcall
编译阶段:luaL_loadstring(lauxlib.c)
plaintext
→ lua_load → f_parser(ldo.c)
→ luaZ_init:初始化 ZIO(字节流)
→ luaY_parser(lparser.c):
├→ 创建 LexState, FuncState
├→ open_func:创建 Proto,初始化编译状态
├→ luaX_next:读第一个 token
├→ statlist:递归下降解析所有语句
│ ├→ exprstat → suffixedexp → primaryexp
│ │ ├→ luaX_next(读标识符/运算符)
│ │ ├→ luaK_codeABC(发射字节码)
│ │ └→ luaK_exp2nextreg(表达式入寄存器)
├→ close_func:发射 RETURN,收缩数组
└→ 返回 LClosure
执行阶段:lua_pcall(lapi.c)
plaintext
→ luaD_pcall(ldo.c)
→ luaD_rawrunprotected(f_call)
→ luaD_callnoyield → luaD_call
→ luaD_precall:
检测函数类型
Lua 函数 → 创建新 CallInfo,设置 base/savedpc/top
C 函数 → 直接调用,返回
→ luaV_execute(lvm.c):
主循环:for(;;) { vmfetch(); vmdispatch(...) }