Lua 源码执行流程全解析:词法分析、语法分析、字节码生成、虚拟机执行与垃圾回收

本文将完整拆解 Lua 从源码到执行的全生命周期,覆盖编译期(源码→闭包)运行时(虚拟机执行)值类型系统全局状态与线程模型垃圾回收元方法调试接口C API 栈操作及核心结构体内存占用,完整还原 Lua 底层执行逻辑。

一、编译期:从源码到闭包

Lua 没有独立的预编译阶段,编译器逻辑完全嵌入在运行时中。只有当 C API(如 lua_loadluaL_loadfilexluaL_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 中。

编译过程使用 VardescFuncState 等结构记录局部变量、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_loadluaL_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.cloadedlibs 中维护,通过 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 字节。

TValueValue 外面再包一层类型标记(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 为外部传入值。

函数被编译保存到 ProtoProto 存放在 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_NEWG_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_yieldlua_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(...) }
相关推荐
凤山老林4 小时前
04-Java JDK, JRE和JVM
java·开发语言·jvm
小成2023032026510 小时前
Linux高级02
linux·开发语言
camellias_10 小时前
【无标题】
java·tomcat
知行合一。。。10 小时前
Python--04--数据容器(总结)
开发语言·python
咸鱼2.010 小时前
【java入门到放弃】需要背诵
java·开发语言
ZK_H10 小时前
嵌入式c语言——关键字其6
c语言·开发语言·计算机网络·面试·职场和发展
A.A呐10 小时前
【C++第二十九章】IO流
开发语言·c++
椰猫子10 小时前
Java:异常(exception)
java·开发语言
lifewange10 小时前
pytest-类中测试方法、多文件批量执行
开发语言·python·pytest