从零开始,用清晰的 C# 代码构建一个完整的 Lua 5.5 运行时、虚拟机与编译器。
Lua 是一门小巧而精巧的动态语言。它的运行时核心只有大约三万行 C 代码,却包含了完整的闭包、协程、元方法、增量/分代 GC 等特性。它被广泛用作嵌入式脚本语言(游戏引擎、Nginx、Redis、Neovim 等),也是学习编程语言实现的绝佳素材。
C# 是一门类型安全、工具链成熟的现代语言。用 C# 来重写 Lua,有几个独特的学习价值:
- 对比理解两种类型系统:Lua 是动态类型,C# 是静态类型。如何在静态语言里高效、安全地表达动态值,是一个经典的工程设计问题。
- 理解虚拟机的工作原理:.NET CLR 本身就是一台虚拟机。用 C# 实现另一台虚拟机(Lua VM),能让你以"创作者"视角重新理解虚拟机的取指-解码-执行循环、调用栈管理、异常处理等机制。
- 实践编译器前端的完整流程:从词法分析到语法分析,再到代码生成,你可以亲手走一遍编译器的完整链路。
- 学习垃圾回收的实现:Lua 5.5 同时支持增量式和分代式两种 GC 策略。用 C# 实现 Lua 侧的 GC 逻辑,能让你深入理解弱表、终结器、三色标记等概念。
这本书面向的读者:
- 熟悉 C#(泛型、模式匹配、
readonly struct、record等现代特性),但对 Lua 语言本身不太了解 - 了解基本的编程概念(栈、堆、函数调用、递归),但没有系统学习过编译原理
- 想要理解脚本语言的内部实现,或者想要亲手构建一门语言运行时
你不需要事先了解 Lua,也不需要读过"龙书"。这本书会在需要的时候解释 Lua 的语言特性和编译原理的基本概念。
本项目是一个完整的 Lua 5.5 实现,包含以下模块:
src/Lua.Runtime 运行时值、栈、调用帧、闭包、表、状态对象
src/Lua.Bytecode 二进制块读取、指令元数据、反汇编
src/Lua.Syntax 词法分析、语法分析、AST
src/Lua.Compiler AST 到字节码的编译
src/Lua.VM 虚拟机(取指-执行循环)
src/Lua.Cli REPL、脚本执行、命令行工具
test/ 单元测试、夹具测试、兼容性测试
实现分为 16 个步骤,按小步推进,每一步都能独立验证。整个过程中遵循"先正确再性能"的原则——代码的首要目标是清晰可读,而非极致优化。
这本书按"自底向上"的顺序组织:先构建运行时的基础类型,然后实现字节码加载,接着搭建虚拟机,再逐步补上元方法、标准库、编译器前端,最后处理 GC 和兼容性。
每一章的末尾会标注:
- 对应的源代码文件——你可以在仓库里直接阅读
- 对应的实现文档——位于
docs/目录,包含更详细的设计决策和实现清单 - 对应的测试——验证本章内容的测试代码
建议的阅读方式:
- 如果你只是想了解 Lua VM 的工作原理,重点阅读"第三部分:字节码"和"第四部分:虚拟机"
- 如果你对编译器前端感兴趣,直接跳到"第六部分:编译器"
- 如果你想从头到尾理解整个系统,按顺序阅读即可
Lua 是一门诞生于 1993 年的轻量级脚本语言,由巴西里约热内卢天主教大学的设计团队持续维护。它有几个鲜明特点,让它在语言实现学习中独树一帜。
Lua 只有九种值类型:
// src/Lua.Runtime/Values/LuaValueKind.cs
public enum LuaValueKind
{
Nil = 0, // nil,表示"不存在"或"无效"
Boolean = 1, // true 或 false
Integer = 2, // 64 位整数
Float = 3, // 64 位双精度浮点数(注意:Lua 没有 32 位浮点)
String = 4, // 不可变字节字符串
Table = 5, // 唯一的复合数据结构——既是数组也是哈希表
Function = 6, // 函数(Lua 函数或 C# 原生函数)
Thread = 7, // 协程(coroutine),不是操作系统线程
UserData = 8 // 宿主语言(C#)注入的 opaque 数据
}对比 C#,你会发现 Lua 没有 class、struct、interface、enum 这些类型——Table 是它唯一的复合数据结构,通过元方法(metamethod)机制可以实现面向对象、运算符重载等行为。
Lua 5.5 里数字要么是 64 位整数,要么是 64 位浮点。整数和浮点是两个独立子类型,但可以自动转换:
local x = 10 -- integer
local y = 10.0 -- float
local z = x + y -- float(混合运算结果是浮点)
local w = 10 / 3 -- float(除法总是返回浮点)
local q = 10 // 3 -- integer(整除)Lua 的表既是数组又是哈希表,还可以通过元方法模拟类和对象:
-- 数组
local arr = {10, 20, 30}
-- 哈希表
local person = {name = "Alice", age = 30}
-- 面向对象(通过元方法)
local MyClass = {}
MyClass.__index = MyClass
function MyClass.new(name)
return setmetatable({name = name}, MyClass)
end
function MyClass:greet()
return "Hello, " .. self.name
endLua 的函数可以赋值给变量、作为参数传递、作为返回值返回。它还支持闭包(closure)——函数可以捕获外层作用域的变量:
function makeCounter()
local count = 0
return function()
count = count + 1
return count
end
end
local counter = makeCounter()
print(counter()) -- 1
print(counter()) -- 2Lua 的协程是非对称式协程(asymmetric coroutine),通过 coroutine.yield 挂起,通过 coroutine.resume 恢复。它在语言层面提供了协作式多任务的能力:
local co = coroutine.create(function()
print("hello")
coroutine.yield()
print("world")
end)
coroutine.resume(co) -- 输出 hello
coroutine.resume(co) -- 输出 world元方法是 Lua 的"运算符重载"和"行为定制"机制。通过在元表(metatable)中定义特定的键(如 __add、__index、__call),你可以改变值在各种操作下的行为:
local vec = {x = 1, y = 2}
local mt = {
__add = function(a, b)
return setmetatable({x = a.x + b.x, y = a.y + b.y}, mt)
end
}
setmetatable(vec, mt)
local v2 = vec + vec
print(v2.x, v2.y) -- 2 4| 概念 | C# | Lua |
|---|---|---|
| 类型系统 | 静态、名义类型 | 动态、鸭子类型 |
| 空值 | null(引用类型) |
nil(唯一的"无"值) |
| 数据结构 | class、struct、List<>、Dictionary<> |
table(包揽一切) |
| 作用域 | 块作用域 | 词法作用域 + 显式 local |
| 面向对象 | 类 + 继承 + 接口 | 元表 + 元方法(原型链) |
| 错误处理 | try/catch |
pcall/xpcall |
| 并发 | Thread/Task |
coroutine(协作式) |
| 数组索引 | 0-based | 1-based |
理解这些差异,是后续实现的核心出发点。
本章源代码:
src/Lua.Runtime/Values/LuaValueKind.cs参考文档:
docs/001-roadmap.md
本项目只对齐一个版本:Lua 5.5.0。所有语义以官方手册和官方源码为准,不做向下兼容,也不做功能裁剪。
官方参考源码保留在 references/lua-5.5.0/ 目录中,方便随时交叉比对。原则是"语义对齐,而非逐行翻译"——C# 实现应该用 C# 的方式组织,但行为必须和 Lua 5.5 一致。
项目拆分为六个核心模块,每个模块有清晰的职责边界:
┌─────────────────────────────────────────────────────┐
│ Lua.Cli │
│ REPL / 脚本执行入口 │
├─────────────────────────────────────────────────────┤
│ Lua.Compiler │ Lua.VM │ Lua.Syntax │ Lua.Bytecode │
│ AST→字节码 │ 虚拟机 │ 词法/语法 │ 二进制块读取 │
├─────────────────────────────────────────────────────┤
│ Lua.Runtime │
│ 值、栈、调用帧、闭包、表、状态 │
└─────────────────────────────────────────────────────┘
关键约束:Lua.Runtime 是最底层的模块,它不依赖 Lua.Bytecode、Lua.VM 或 Lua.Compiler。这意味着:
- 值类型(
LuaValue)的定义不包含任何字节码概念 - 调用帧(
CallFrame)不包含任何 VM 实现细节 - 标准库函数通过委托注册,而不是直接调用 VM API
这种分层让每个模块都可以独立测试。
测试分三层:
- 单元测试(
test/Lua.Runtime.Tests等)——验证单个类型和方法的行为 - 夹具测试(
test/fixtures/lua55/)——用真实 Lua 5.5 编译的字节码验证 VM 行为 - 兼容性测试(
test/Lua.Compatibility.Tests)——跑官方 Lua 5.5 测试套件,量化兼容性
夹具测试是本项目的一个特色:我们不手工构造测试数据,而是用官方 luac 编译真实 Lua 源码得到 .luac 文件,然后用我们的 VM 执行这些 .luac 文件。这保证测试数据和真实 Lua 行为一致。
四个核心原则贯穿整个项目:
- 先正确再性能——不为了快而牺牲清晰
- 小步推进——每步一个纵向切片,不做大重写
- 文档先行——每步先写文档,再写代码和测试
- 语义对齐——以 Lua 5.5 手册和官方源码为准
参考文档:
docs/001-roadmap.md、docs/002-step-01-foundation.md、docs/003-step-01-source-reference.md
在 C# 这样的静态类型语言中实现动态类型系统,第一个要解决的问题就是"如何统一表示不同种类的值"。
我们选择用 readonly struct + 枚举来表示 Lua 值,而不是用继承体系:
// src/Lua.Runtime/Values/LuaValue.cs
public readonly struct LuaValue : IEquatable<LuaValue>
{
private readonly object? _reference; // 字符串、表、函数、线程、userdata
private readonly long _integer; // 整数值
private readonly double _float; // 浮点值
private readonly bool _boolean; // 布尔值
private LuaValue(
LuaValueKind kind,
object? reference = null,
long integer = 0,
double @float = 0,
bool boolean = false)
{
Kind = kind;
_reference = reference;
_integer = integer;
_float = @float;
_boolean = boolean;
}
public LuaValueKind Kind { get; }
}为什么不用继承?因为 Lua 的值语义和 C# 的引用语义有本质区别:
- 在 Lua 中,
nil、true、42、"hello"这些值是"按值传递"的 - 而 table、function 这些是"按引用标识"的
- 如果用继承,
LuaValue只能是引用类型(class),会导致大量的堆分配 - 用 struct 可以让简单值(nil、bool、integer、float)直接在栈上传递
这个设计的代价是每个 LuaValue 占用较多内存(kind + reference + integer + float + boolean),但换来的是值传递零开销和类型检查的简洁性。
每种值类型通过静态工厂方法创建:
LuaValue.Nil // nil
LuaValue.FromBoolean(true) // 布尔
LuaValue.FromInteger(42) // 整数
LuaValue.FromFloat(3.14) // 浮点
LuaValue.FromString("hello") // 字符串
LuaValue.FromTable(new LuaTable()) // 表
LuaValue.FromFunction(closure) // 函数
LuaValue.FromThread(thread) // 线程
LuaValue.FromUserData(userData) // userdata为什么不直接用构造函数?因为工厂方法有名字,能表达意图,而且可以复用静态 Nil 单例。
Lua 的相等性和 C# 默认行为有几个关键差异:
- 整数和浮点比较:
1 == 1.0在 Lua 中为true(值相等,忽略子类型) - 字符串:按内容比较(
Ordinal比较),不是引用比较 - 表、函数、线程、userdata:按引用身份比较——只有完全相同的对象才相等
- nil:只有 nil 和 nil 相等
// LuaValue 的相等性实现逻辑(简化版)
public bool Equals(LuaValue other)
{
if (Kind != other.Kind)
{
// 整数和浮点可以跨类型比较
if (Kind == LuaValueKind.Integer && other.Kind == LuaValueKind.Float)
return (double)_integer == other._float;
if (Kind == LuaValueKind.Float && other.Kind == LuaValueKind.Integer)
return _float == (double)other._integer;
return false;
}
return Kind switch
{
LuaValueKind.Nil => true,
LuaValueKind.Boolean => _boolean == other._boolean,
LuaValueKind.Integer => _integer == other._integer,
LuaValueKind.Float => _float == other._float,
LuaValueKind.String => string.Equals((string)_reference!, (string)other._reference!, StringComparison.Ordinal),
_ => ReferenceEquals(_reference, other._reference) // 表、函数、线程、userdata
};
}在 C# 里只有 bool 类型能做条件判断。但在 Lua 里,nil 和 false 为假,其他所有值都为真——包括 0 和空字符串 ""。这是 Lua 新手最常踩的坑之一:
if 0 then print("true!") end -- 会执行,0 是真
if "" then print("true!") end -- 会执行,空字符串也是真
if nil then print("never") end -- 不会执行,nil 是假在我们的运行时中,这通过一个辅助方法来表达:
// LuaValue 的 IsTruthy 属性
public bool IsTruthy => Kind != LuaValueKind.Nil && !(Kind == LuaValueKind.Boolean && !_boolean);本章源代码:
src/Lua.Runtime/Values/LuaValue.cs、src/Lua.Runtime/Values/LuaValueKind.cs、src/Lua.Runtime/Values/LuaValueHelper.cs参考文档:
docs/004-step-02-runtime-model.md测试:
test/Lua.Runtime.Tests/LuaValueTests.cs
Lua VM 的执行模型围绕三个核心容器展开:值栈(stack)、调用帧(call frame)和全局状态(state)。
Lua 使用寄存器式虚拟机(register-based VM),而不是栈式虚拟机(stack-based VM)。这意味着指令不是在栈顶操作,而是通过寄存器索引直接寻址。
栈的结构(从底到顶):
┌──────────────────────┐
│ 函数 A 的寄存器 │ ← CallFrame.BaseIndex
│ R(0) .. R(n) │
├──────────────────────┤
│ 函数 B 的寄存器 │ ← CallFrame.BaseIndex
│ R(0) .. R(m) │
├──────────────────────┤
│ ... │
└──────────────────────┘
LuaStack 提供基本的栈操作:
public sealed class LuaStack
{
public void Push(LuaValue value);
public LuaValue Pop();
public LuaValue Peek(int offset = 0);
public LuaValue this[int index] { get; set; }
public void SetTop(int top);
}SetTop 是一个关键操作——它用来调整栈顶位置。在函数调用返回后,需要把栈收缩到正确的位置,SetTop 就是做这件事的。
每次 Lua 函数调用都会创建一个调用帧,记录这次调用的上下文:
// src/Lua.Runtime/Execution/CallFrame.cs(关键字段)
public sealed class CallFrame
{
public LuaClosure Closure { get; } // 当前执行的闭包
public int BaseIndex { get; } // 栈上寄存器区的起始位置
public int ExpectedResults { get; } // 调用者期望的返回值数量
public int ProgramCounter { get; } // 当前指令位置(PC)
public int RegisterTop { get; } // 寄存器区上界
public IReadOnlyList<LuaValue> Varargs { get; } // 可变参数
public LuaCallReturnTarget ReturnTarget { get; } // 返回目标
}调用帧还管理两类重要状态:
- open upvalue:当前帧的寄存器中,哪些被上值引用着(闭包捕获的外部变量)
- to-be-closed 寄存器:哪些寄存器在作用域退出时需要执行
__close元方法
Lua 的函数不是孤立的——每个函数都带着它捕获的外部变量(上值),这个组合叫做闭包(closure):
// src/Lua.Runtime/Objects/LuaClosure.cs(关键字段)
public sealed class LuaClosure
{
public string? DebugName { get; }
public int UpvalueCount { get; }
public ILuaClosureBody? Body { get; } // 执行体(字节码或 C# 原生函数)
public LuaUpvalue[] Upvalues { get; } // 捕获的上值
}Body 是一个接口,允许挂接不同类型的执行体:
public interface ILuaClosureBody { }这样字节码闭包、C# 原生函数、标准库包装闭包都可以挂到同一个运行时闭包容器上,而 Lua.Runtime 不需要知道执行体的具体类型。
LuaState 是整个运行时的聚合根,管理所有共享状态:
- 全局环境表(
_ENV) - 值栈和调用帧
- 标准库注册(
print、pcall、require等) - 类型级元表(字符串元表、数字元表等)
- 协程调度
- GC 状态
LuaState 的设计遵循一个重要原则:核心项目不直接写 Console。输出通过可注入的 sink 实现,这样在测试和嵌入场景下都能灵活控制。
上值是 Lua 闭包的核心机制。理解它的关键在于 open/closed 两种状态:
open upvalue: 直接指向栈上的寄存器槽位
closed upvalue: 值已经复制到自己的存储中
┌──────────────┐
│ LuaStack │ ┌──────────────┐
│ R(0) = 42 │◄───│ Upvalue(open)│ ← 闭包 A 引用
│ R(1) = 99 │◄───│ Upvalue(open)│ ← 闭包 A、B 共享引用
└──────────────┘ └──────────────┘
当函数退出、R(1) 离开作用域时:
┌──────────────┐
│ Upvalue(closed)│ 值 99 被复制进来
└──────────────┘
多个闭包可以共享同一个上值——它们看到的是同一个变量。当外层函数退出时,open upvalue 被关闭(值被复制到 upvalue 自身的存储中),但引用关系不变。
本章源代码:
src/Lua.Runtime/Execution/CallFrame.cs、src/Lua.Runtime/Execution/LuaStack.cs、src/Lua.Runtime/Execution/LuaState.cs、src/Lua.Runtime/Objects/LuaClosure.cs、src/Lua.Runtime/Objects/LuaUpvalue.cs参考文档:
docs/004-step-02-runtime-model.md、docs/010-step-05-upvalue-cells.md测试:
test/Lua.Runtime.Tests/LuaStackTests.cs、test/Lua.Runtime.Tests/LuaUpvalueTests.cs
Lua 的编译器把源码编译成字节码(bytecode),保存为二进制块(binary chunk)。我们的 VM 执行的就是这种格式。
类比:.NET 的 IL 字节码存在 PE 文件中;Lua 的字节码存在
.luac文件中。格式不同,但角色类似。
一个 Lua 二进制块由 header 和 prototype 树组成:
┌──────────────────────────────────┐
│ Chunk Header │
│ 签名、版本号、格式标记、 │
│ 整数/浮点/指令的大小和校验值 │
├──────────────────────────────────┤
│ Main Prototype │
│ ├─ 指令列表 │
│ ├─ 常量表 │
│ ├─ 上值描述表 │
│ ├─ 局部变量表 │
│ ├─ 行号信息 │
│ └─ 子 prototype 列表 │
│ └─ (递归结构) │
└──────────────────────────────────┘
Header 包含一系列格式校验值,确保 chunk 是由兼容的 Lua 版本生成的:
// src/Lua.Bytecode/Chunks/LuaChunkHeader.cs
public sealed class LuaChunkHeader
{
public required byte Version { get; init; } // 版本号(0x55 = Lua 5.5)
public required byte Format { get; init; } // 格式版本
public required byte IntSize { get; init; } // int 字节数
public required int IntFormatMarker { get; init; } // 校验:0x5678
public required byte InstructionSize { get; init; } // 指令字节数
public required uint InstructionFormatMarker { get; init; } // 校验指令端序
public required byte LuaIntegerSize { get; init; } // lua_Integer 字节数
public required long LuaIntegerFormatMarker { get; init; } // 校验:0x5678
public required byte LuaNumberSize { get; init; } // lua_Number 字节数
public required double LuaNumberFormatMarker { get; init; } // 校验:370.5
}这些校验值确保了 chunk 不会在错误的平台上加载——比如在大端机器上编译的 chunk 不能在小端机器上直接执行。
Prototype 是编译器输出的核心单元,对应一个函数体:
// src/Lua.Bytecode/Chunks/LuaPrototype.cs(关键字段)
public sealed class LuaPrototype
{
public string SourceName { get; }
public int LineDefined { get; }
public int LastLineDefined { get; }
public byte NumParams { get; }
public byte IsVarArg { get; }
public byte MaxStackSize { get; }
public IReadOnlyList<uint> Instructions { get; } // 指令列表(原始 uint)
public IReadOnlyList<LuaConstant> Constants { get; } // 常量表
public IReadOnlyList<LuaUpvalueDescriptor> Upvalues { get; } // 上值描述
public IReadOnlyList<LuaPrototype> Children { get; } // 子函数
public IReadOnlyList<LuaLocalVariable> LocalVariables { get; }
public IReadOnlyList<LuaAbsoluteLineInfo> AbsoluteLineInfo { get; }
public int FirstLine { get; }
public int LastLine { get; }
}注意 Instructions 是 uint 列表——每条指令就是一个 32 位无符号整数,需要进一步解码才能理解含义。
常量表存储指令中引用的字面量值。一条 LOADK 指令通过索引从常量表中取值:
local x = 42 -- LOADK R(0), K(0) 常量表 K(0) = 42
local y = "hello" -- LOADK R(1), K(1) 常量表 K(1) = "hello"常量的种类:
// src/Lua.Bytecode/Chunks/LuaConstantKind.cs
public enum LuaConstantKind
{
Nil, Boolean, Integer, Float, String
}整个加载流程:
.luac 文件 → BinaryReader → LuaChunkHeader + LuaPrototype 树 → LuaChunk 对象
LuaChunkReader 负责逐字节解析二进制格式,生成内存中的 LuaChunk 对象。
本章源代码:
src/Lua.Bytecode/Chunks/目录参考文档:
docs/005-step-03-bytecode-loader.md测试:
test/Lua.Bytecode.Tests/LuaChunkReaderTests.cs
Lua 的指令是 32 位定长编码,每条指令包含操作码(opcode)和若干操作数。理解指令编码,是理解 VM 执行的基础。
Lua 5.5 定义了六种指令格式,决定了 32 位如何被切分:
// src/Lua.Bytecode/Instructions/LuaInstructionFormat.cs
public enum LuaInstructionFormat
{
IABC = 0, // [op:7][A:8][C:8][B:8] — 通用三操作数
IvABC = 1, // [op:7][A:8][vC:9][vB:9] — 扩展 B/C 位宽
IABx = 2, // [op:7][A:8][Bx:17] — Bx 用于常量索引等
IAsBx = 3, // [op:7][A:8][sBx:17] — 有符号 Bx,用于跳转偏移
IAx = 4, // [op:7][Ax:25] — 超大索引
IsJ = 5 // [op:7][sJ:25] — 有符号跳转
}用位运算解码:
// src/Lua.Bytecode/Instructions/LuaInstruction.cs
public readonly record struct LuaInstruction(uint Raw)
{
public LuaOpcode Opcode => (LuaOpcode)GetArg(Raw, PosOp, SizeOp);
public int A => GetArg(Raw, PosA, SizeA);
public int B => GetArg(Raw, PosB, SizeB);
public int C => GetArg(Raw, PosC, SizeC);
public int Bx => GetArg(Raw, PosBx, SizeBx);
public int SBx => Bx - OffsetSBx; // 有符号偏移
// ...
}Lua 5.5 定义了 89 个操作码(opcode),按功能分组:
| 分组 | 操作码示例 | 说明 |
|---|---|---|
| 加载 | LOADI、LOADK、LOADTRUE、LOADNIL |
把值加载到寄存器 |
| 表访问 | GETTABLE、SETTABLE、GETFIELD、SETFIELD |
读写表的字段 |
| 算术 | ADD、SUB、MUL、DIV、IDIV、POW |
基本运算 |
| 位运算 | BAND、BOR、BXOR、SHL、SHR |
按位操作 |
| 比较 | EQ、LT、LE、EQK、LTI |
条件跳转 |
| 控制 | JMP、CALL、RETURN、FORLOOP |
流程控制 |
| 闭包 | CLOSURE、GETUPVAL、SETUPVAL |
闭包和上值 |
| 其他 | VARARG、CLOSE、TBC、SETLIST |
杂项 |
本章源代码:
src/Lua.Bytecode/Instructions/目录参考文档:
docs/005-step-03-bytecode-loader.md测试:
test/Lua.Bytecode.Tests/LuaInstructionTests.cs、test/Lua.Bytecode.Tests/LuaOpcodeTablesTests.cs
在 VM 实现之前,先写一个反汇编器(disassembler),能让我们检查 chunk 的内容,验证加载是否正确。
反汇编器做的事情很简单:把 uint 指令流翻译成人类可读的文本:
; 反汇编输出示例
1 LOADK R(0), K(0) ; 加载常量 42 到寄存器 0
2 LOADK R(1), K(1) ; 加载常量 "hello" 到寄存器 1
3 ADD R(2), R(0), R(0) ; R(2) = R(0) + R(0)
4 RETURN R(2), 2, 1 ; 返回 R(2)
这个工具在后续的 VM 调试中会反复使用——当你不确定 VM 是否正确执行了某段代码时,先看反汇编输出,再对照源码,通常能快速定位问题。
本章源代码:
src/Lua.Bytecode/Disassembly/LuaDisassembler.cs参考文档:
docs/005-step-03-bytecode-loader.md
现在万事俱备:运行时模型有了,字节码能加载了,指令能解码了。接下来把它们串成一条真实的执行链路。
虚拟机做三件事:
- 从 chunk 创建根闭包(root closure)
- 建立取指-解码-执行循环
- 管理调用帧和栈的生命周期
// src/Lua.VM/LuaVirtualMachine.cs(核心结构)
public sealed partial class LuaVirtualMachine
{
public LuaState State { get; }
public LuaValue[] Execute(LuaChunk chunk)
{
var closure = CreateRootClosure(chunk.MainFunction, ...);
return Call(closure);
}
}VM 的核心循环是一个巨大的 switch 语句,每条指令对应一个 case:
// 简化示意
while (frame.ProgramCounter < instructions.Count)
{
var instruction = LuaInstruction.FromRaw(instructions[frame.ProgramCounter]);
frame.Advance();
switch (instruction.Opcode)
{
case LuaOpcode.LoadI:
var value = LuaValue.FromInteger(instruction.Bx);
SetRegister(instruction.A, value);
break;
case LuaOpcode.Add:
var a = GetRegister(instruction.A);
var b = GetRegister(instruction.B);
var c = GetRegister(instruction.C);
SetRegister(instruction.A, Add(a, b, c));
break;
case LuaOpcode.Call:
// ... 压入新调用帧,继续循环
break;
case LuaOpcode.Return:
// ... 处理返回值,弹出调用帧
break;
// ... 其他 85 个 opcode
}
}LuaVirtualMachine 按职责拆分为 partial class:
LuaVirtualMachine.cs — 核心执行循环和公共 API
LuaVirtualMachine.Arithmetic.cs — 算术、位运算、拼接、长度
LuaVirtualMachine.Comparison.cs — 相等性、有序比较、条件跳转
LuaVirtualMachine.TableAccess.cs — 表读写、SETLIST、NEWTABLE、SELF
LuaVirtualMachine.Metamethods.cs — 元方法解析与分发
LuaVirtualMachine.ControlFlow.cs — 循环、vararg、泛型 for、跳转
LuaVirtualMachine.Helpers.cs — 寄存器、上值、常量、资源清理
LuaVirtualMachine.Coroutines.cs — 协程支持
执行 chunk 的第一步是创建根闭包。一个 chunk 的 MainFunction 是一个 LuaPrototype,需要包装成 LuaClosure 才能执行:
var closure = new LuaClosure(
debugName: "main",
upvalueCount: prototype.Upvalues.Count,
body: new LuaBytecodeClosureBody(prototype),
sourceName: prototype.SourceName);其中第一个上值绑定到全局环境表 _ENV,这样代码里的全局变量访问就能正常工作。
当 VM 真正跑通第一条指令的那一刻,前几章所有的工作就形成了闭环:
源码 → luac → .luac 文件 → LuaChunkReader → LuaChunk → LuaVirtualMachine.Execute → 输出
本章源代码:
src/Lua.VM/目录参考文档:
docs/006-step-04-vm-skeleton.md测试:
test/Lua.VM.Tests/LuaVirtualMachineTests.cs
Lua 的算术运算遵循"快速路径优先,元方法回退"的模式:
- 如果两个操作数都是数值类型,直接计算(快速路径)
- 如果快速路径失败(比如其中一个操作数是表),查找元方法
ADD R(a), R(b), R(c)
├─ b 和 c 都是数字?→ 直接计算
├─ b 有元表且元表有 __add?→ 调用 __add(b, c)
├─ c 有元表且元表有 __add?→ 调用 __add(c, b) [注意参数翻转]
└─ 都没有?→ 抛出类型错误
Lua 的算术有几条容易忽略的规则:
- 除法
/总是返回浮点:10 / 2的结果是5.0(浮点),不是5(整数) - 整除
//对整数返回整数:10 // 3的结果是3 - 幂运算
^总是返回浮点:2 ^ 3的结果是8.0 - 整数溢出回绕:Lua 的整数运算溢出时采用二进制补码回绕,和 C# 的
unchecked行为一致
很多算术指令有"常量"和"立即数"变体,用来减少指令条数和常量表压力:
| 指令 | 说明 |
|---|---|
ADD R(a), R(b), R(c) |
两个寄存器操作数 |
ADDK R(a), R(b), K(c) |
第二个操作数从常量表取 |
ADDI R(a), R(b), sC |
第二个操作数是指令内嵌的立即数 |
这些变体不影响语义——ADD R(0), R(1), R(2) 和 ADDK R(0), R(1), K(2) 最终做的都是加法,只是操作数的来源不同。
本章源代码:
src/Lua.VM/LuaVirtualMachine.Arithmetic.cs参考文档:
docs/006-step-04-vm-skeleton.md测试:
test/fixtures/lua55/chunks/arith_chunk.luac、bit_chunk.luac、floor_div_chunk.luac等
Lua 的 == 比较:
- nil:只有 nil == nil
- 布尔:按值比较
- 数字:先尝试同类型比较,再跨类型(
1 == 1.0为 true) - 字符串:按内容比较
- 表、函数、线程、userdata:按引用比较——除非有
__eq元方法
EQ 指令后面通常跟着一个 JMP 指令:
if x == y then ... end编译成:
EQ R(x), R(y) -- 如果 x == y 则跳过下一条
JMP +1 -- 不相等时跳到 else 分支
... -- then 分支
LT(小于)、LE(小于等于)支持数字比较和字符串比较:
1 < 2 -- 数字比较
"abc" < "def" -- 字符串按字典序比较
1 < "hello" -- 错误!不能跨类型有序比较TEST 和 TESTSET 是 Lua 的"短路"指令:
local x = a and b -- TESTSET: 如果 a 为真则 x = b,否则 x = a
local y = a or b -- TESTSET: 如果 a 为真则 x = a,否则 x = b
if a then ... end -- TEST: 如果 a 为真则继续,否则跳过本章源代码:
src/Lua.VM/LuaVirtualMachine.Comparison.cs参考文档:
docs/006-step-04-vm-skeleton.md
表(table)是 Lua 唯一的复合数据结构。在 C# 中,我们用一个类来实现它,同时支持数组索引和哈希表查找。
// src/Lua.Runtime/Objects/LuaTable.cs(简化版)
public sealed class LuaTable : IMetatableOwner
{
private readonly Dictionary<LuaValue, TableEntry> _entries;
private readonly List<TableEntry> _entriesInOrder;
public LuaTable? Metatable { get; }
public LuaValue? RawGet(LuaValue key);
public void RawSet(LuaValue key, LuaValue value);
public int NextIndex { get; } // 用于 next/pairs 迭代
}Lua 表的键有一个容易忽略的规则:整数浮点数和整数是同一个键:
local t = {}
t[1] = "integer"
t[1.0] = "float"
print(t[1]) -- "float"(1.0 覆盖了 1,因为它们是同一个键)而 NaN 永远不能做键(NaN ~= NaN):
local t = {}
t[0/0] = "never" -- 运行时错误:table index is NaN表构造器 SETLIST 指令用于批量设置数组部分的连续元素:
local t = {10, 20, 30}编译成:
NEWTABLE R(0) -- 创建空表
LOADI R(1), 10 -- 准备元素
LOADI R(2), 20
LOADI R(3), 30
SETLIST R(0), 3 -- 把 R(1)..R(3) 写入表的数组部分
当元素数量超过指令内嵌位宽时,会使用 EXTRAARG 指令提供额外参数。
Lua 支持弱引用表——表可以声明其键或值为弱引用,允许 GC 回收只被弱表引用的对象:
local t = setmetatable({}, {__mode = "v"}) -- 弱值表
t[1] = someObject
-- 如果 someObject 没有其他引用,GC 后 t[1] 会变成 nil弱引用模式有三种:
__mode = "k"— 弱键__mode = "v"— 弱值__mode = "kv"— 弱键 + 弱值
本章源代码:
src/Lua.Runtime/Objects/LuaTable.cs参考文档:
docs/007-step-04-table-access.md、docs/016-step-04-setlist.md、docs/054-step-16-weak-tables.md测试:
test/Lua.Runtime.Tests/LuaTableTests.cs
Lua 的函数调用遵循"固定参数 + 可变参数"的协议:
- 调用者把函数和参数依次压入栈
CALL指令触发调用,创建新的调用帧- 被调用者在新帧的寄存器区中接收参数
- 返回时,结果写回调用者指定的位置
调用前栈布局:
┌──────────────┐
│ R(func) │ ← 调用者放函数
│ R(arg1) │
│ R(arg2) │
│ R(arg3) │
└──────────────┘
调用后(新帧的视角):
┌──────────────┐
│ R(0) = arg1 │ ← 新帧的 BaseIndex 指向这里
│ R(1) = arg2 │
│ R(2) = arg3 │
│ ... │ ← 寄存器区
└──────────────┘
Lua 的函数可以返回任意数量的值。当调用者不知道会返回多少个值时,使用"开放"模式:
local a, b, c = f() -- 期望 3 个返回值
local d = f() -- 期望 1 个返回值(多余的丢弃)
print(f()) -- 开放:f 的所有返回值直接传给 printCALL 和 RETURN 指令的参数 C 表示返回值数量。当 C == 0 时,表示"开放"——接收所有返回值。
Lua 的全局变量不是真正的"全局",而是通过 _ENV 表实现的:
x = 42
-- 等价于:_ENV.x = 42在字节码层面,全局读写使用 GETTABUP 和 SETTABUP 指令——它们访问的是当前闭包的上值 #0(即 _ENV):
GETTABUP R(0), U(0), K("x") -- R(0) = _ENV["x"]
SETTABUP U(0), K("x"), R(1) -- _ENV["x"] = R(1)
Lua 的 obj:method(args) 语法糖编译为 SELF 指令:
obj:method(arg)编译成:
SELF R(0), R(obj), K("method") -- R(0) = method, R(1) = obj
MOVE R(2), R(arg) -- 参数
CALL R(0), 2, 1 -- 调用 method(obj, arg)
SELF 同时把函数和 self(即 obj)放到连续的寄存器中,这样后续的 CALL 可以自然地把 self 作为第一个参数传递。
尾调用是 Lua 的一种优化:当函数的最后一个动作是调用另一个函数时,可以复用当前调用帧,避免调用栈增长:
function f(n)
if n <= 0 then return 0 end
return f(n - 1) -- 尾调用:不会增长调用栈
end在 VM 层面,TAILCALL 把当前帧替换为新函数的帧,而不是压入新帧。
Lua 5.4+ 引入了 to-be-closed 变量——当变量离开作用域时(无论是正常退出还是出错),自动调用其 __close 元方法:
do
local f <close> = io.open("file.txt")
-- 使用 f ...
end -- 这里自动调用 f:close()TBC 指令标记一个寄存器为"待关闭",CLOSE 指令触发关闭。错误传播规则:即使 __close 本身抛出错误,剩余的 to-be-closed 变量仍然会被关闭。
本章源代码:
src/Lua.VM/LuaVirtualMachine.TableAccess.cs、src/Lua.VM/LuaVirtualMachine.ControlFlow.cs参考文档:
docs/008-step-05-self-call.md、docs/009-step-05-global-environment.md、docs/010-step-05-upvalue-cells.md、docs/011-step-05-close.md、docs/012-step-05-tbc.md、docs/013-step-05-close-metamethod.md、docs/014-step-05-close-errors.md
for i = 1, 10, 2 do
print(i) -- 1, 3, 5, 7, 9
end编译成:
LOADI R(0), 1 -- 初始值
LOADI R(1), 10 -- 上限
LOADI R(2), 2 -- 步长
FORPREP R(0) -- 预计算:R(0) -= R(2),然后进入循环
... -- 循环体(R(3) 是当前 i 的副本)
FORLOOP R(0) -- R(0) += R(2),如果未超限则跳回循环体
注意 Lua 的 for 循环在整数路径上使用整数运算,在浮点路径上使用浮点运算,不会混合。
for k, v in pairs(t) do
print(k, v)
end编译成:
-- 迭代器状态存放在连续的寄存器中
TFORPREP R(0) -- 准备迭代
TFORCALL R(0) -- 调用迭代函数,获取下一个 k, v
TFORLOOP R(0) -- 如果 k 不为 nil 则跳回循环体
泛型 for 的状态由三个值组成:迭代函数、状态表、初始键。
本章源代码:
src/Lua.VM/LuaVirtualMachine.ControlFlow.cs参考文档:
docs/018-step-04-loops.md
Lua 的可变参数(vararg)在 Lua 5.5 中有两种形态:经典的可变参数和具名可变参数(vararg table)。
function f(...)
local a, b = ... -- 捕获前两个
print(...) -- 全部传递
local t = {...} -- 打包成表
endVARARGPREP 指令标记函数为可变参数函数。VARARG 指令把可变参数加载到寄存器区。
可变参数可以"开放"传递——把所有参数原封不动地传给下一个调用:
function f(...)
return g(...) -- g 收到 f 收到的所有参数
endLua 5.5 新增了具名可变参数:
function f(...args)
print(args[1]) -- 通过名字访问
args[2] = "modified" -- 可修改
end具名可变参数在内部创建一个表,参数同时存在于寄存器和表中。
本章源代码:
src/Lua.VM/LuaVirtualMachine.ControlFlow.cs参考文档:
docs/017-step-05-vararg-open-results.md、docs/020-step-05-vararg-table.md
元方法是 Lua 最强大的特性之一——它允许你自定义值在各种操作下的行为。对于 C# 开发者来说,可以把它理解为"全局可插拔的运算符重载 + 行为拦截"。
每个表和 userdata 可以有一个元表(metatable),元表是一个普通的 Lua 表,其中的特殊键(以双下划线开头)定义了元方法:
local mt = {
__add = function(a, b) ... end, -- 加法
__sub = function(a, b) ... end, -- 减法
__mul = function(a, b) ... end, -- 乘法
__index = function(t, k) ... end, -- 表读取
__newindex = function(t, k, v) ... end, -- 表写入
__call = function(f, ...) ... end, -- 调用
__tostring = function(v) ... end, -- 转字符串
__len = function(v) ... end, -- 取长度
__eq = function(a, b) ... end, -- 相等比较
__lt = function(a, b) ... end, -- 小于比较
__le = function(a, b) ... end, -- 小于等于
__gc = function(v) ... end, -- 垃圾回收终结器
__close = function(v, err) ... end, -- to-be-closed 清理
}对于二元操作(如 __add),Lua 按特定顺序查找元方法:
- 先查左操作数的元表
- 如果左操作数没有对应的元方法,查右操作数的元表
- 对于常量在左边的情况(如
42 + table),Lua 会翻转操作数再查找
在 C# 实现中,MMBIN/MMBINI/MMBINK 指令负责在快速路径失败后触发元方法查找。
__index 和 __newindex 是最常用的元方法:
-- __index:当表没有某个键时触发
local proto = {greet = function(self) return "hi " .. self.name end}
local mt = {__index = proto}
local obj = setmetatable({name = "Lua"}, mt)
obj:greet() -- "hi Lua"(obj 没有 greet,查元表的 __index)
-- __newindex:当给不存在的键赋值时触发
local readonly = setmetatable({}, {
__newindex = function(t, k, v)
error("this table is read-only")
end
})
readonly.x = 1 -- 错误!关键细节:如果键已经存在,__newindex 不会被触发——它只在"新增"键时触发。这区分了"原始命中"和"原始未命中"。
任何表或 userdata 都可以被"调用",只要它的元表定义了 __call:
local callable = setmetatable({}, {
__call = function(self, x)
return x * 2
end
})
print(callable(21)) -- 42在 VM 中,CALL 和 TAILCALL 遇到非函数值时,会查找 __call 元方法,把原始值作为第一个参数传入。
所有支持元方法的对象(表和 userdata)都实现 IMetatableOwner 接口:
public interface IMetatableOwner
{
LuaTable? Metatable { get; }
}通过 MetatableOwnerExtensions.TryGetMetamethod 统一查找元方法,避免在 LuaState 和 LuaVirtualMachine 里重复实现。
本章源代码:
src/Lua.VM/LuaVirtualMachine.Metamethods.cs、src/Lua.Runtime/Objects/IMetatableOwner.cs参考文档:
docs/021-step-06-binary-metamethods.md至docs/025-step-06-userdata-metamethods.md
Lua 的基础库提供了一组核心函数,它们注册在全局环境 _ENV 中。这些函数本身是用 C# 实现的原生函数,通过 LuaNativeFunction 委托注册。
type(42) -- "number"
type(nil) -- "nil"
type({}) -- "table"
tonumber("42") -- 42
tonumber("0xFF") -- 255
tonumber("1010", 2) -- 10(二进制)
tostring(42) -- "42"(会查 __tostring 元方法)Lua 的错误处理不是 try/catch,而是 pcall(protected call):
local ok, result = pcall(function()
error("something went wrong")
end)
-- ok = false, result = "something went wrong"
-- xpcall 允许设置错误处理函数
local ok, result = xpcall(riskyFunc, function(err)
return "handled: " .. tostring(err)
end)在 C# 实现中,pcall 和 xpcall 使用 try/catch 捕获 LuaRuntimeException,但关键是它们复用同一套 callable 解析路径——不区分 Lua 函数、C# 原生函数、带 __call 的表,统一走 LuaState.InvokeCallable。
rawget(t, k) -- 不触发 __index 的表访问
rawset(t, k, v) -- 不触发 __newindex 的表写入
rawlen(t) -- 不触发 __len 的取长度
rawequal(a, b) -- 不触发 __eq 的比较
setmetatable(t, mt) -- 设置元表
getmetatable(t) -- 获取元表(受 __metatable 保护)
next(t, k) -- 表迭代器的底层原语
pairs(t) -- 支持 __pairs 元方法的迭代
ipairs(t) -- 数组迭代(到第一个 nil 为止)print(1, "hello", {}) -- 1 hello table: 0x...
warn("something bad") -- 输出到 stderrprint 对每个参数调用 tostring,用制表符分隔。warn 支持 @on/@off 控制消息。
load(chunk) -- 加载二进制 chunk 或文本 chunk
loadfile(filename) -- 从文件加载
dofile(filename) -- 加载并立即执行文本 chunk 的加载路径需要编译器前端(Step 11-13)的支持。在实现顺序上,先支持二进制 chunk,后支持文本 chunk。
local mod = require("mylib")require 实现了模块缓存(package.loaded)、搜索器链和模块路径查找。模块返回 nil 时缓存 true,防止重复加载。
本章源代码:
src/Lua.Runtime/Execution/LuaState.cs(内联标准库注册)参考文档:
docs/026-step-07-base-metatable-raw-functions.md至docs/034-step-07-collectgarbage-require-functions.md
table.concat({"a", "b", "c"}, ", ") -- "a, b, c"
table.insert(t, 42) -- 尾部插入
table.insert(t, 1, 42) -- 指定位置插入
table.remove(t) -- 尾部删除
table.sort(t) -- 原地排序
table.pack(1, 2, 3) -- 打包成表 {1, 2, 3, n = 3}
table.unpack({10, 20, 30}) -- 返回 10, 20, 30math 库覆盖了常用的数学函数:三角函数(sin、cos、tan)、取整(floor、ceil)、极值(max、min)、常量(pi、huge、maxinteger)以及位运算辅助(ult——无符号小于比较)。
utf8 库按字节位置操作,不是 .NET 的字符索引——这是一个容易踩的坑:
utf8.len("你好") -- 2(字符数)
utf8.offset("你好", 1) -- 1(第一个字符的字节偏移)
utf8.offset("你好", 2) -- 4("你" 占 3 字节,第二个字符从第 4 字节开始)本章源代码:
src/Lua.Runtime/Execution/LuaState.cs参考文档:
docs/035-step-08-table-math-utf8-libraries.md
string 库是 Lua 最复杂的标准库之一,主要是因为它包含了一个完整的模式匹配引擎。
string.byte("ABC", 1) -- 65
string.char(65, 66) -- "AB"
string.rep("ab", 3) -- "ababab"
string.reverse("hello") -- "olleh"
string.sub("hello", 2, 4) -- "ell"
string.format("%d + %d = %d", 1, 2, 3) -- "1 + 2 = 3"Lua 的模式不是正则表达式。它更简单但也更高效,有自己的语法:
. 任意字符
%a 字母 %d 数字
%w 字母或数字 %s 空白
%p 标点 %l/%u 小写/大写
^ 字符串开头 $ 字符串结尾
* 0 次或多次(贪婪) + 1 次或多次
- 0 次或多次(懒惰) ? 0 次或 1 次
关键差异:Lua 模式不支持交替(|)、不支持反向引用、不支持非捕获组。但支持平衡匹配(%b())。
string.find("hello world", "(%w+)") -- 1, 5, "hello"
string.match("2024-01-15", "(%d+)-(%d+)-(%d+)") -- "2024", "01", "15"
string.gsub("hello", "(%w)", "%1%1") -- "hheelllloo"模式匹配引擎用 C# 实现,直接操作字节,而不是使用 .NET 的 Regex。
string.pack / string.unpack 实现了二进制数据的序列化与反序列化,格式字符串类似于 C 的 printf 格式:
local packed = string.pack("<i4i4", 1, 2) -- 小端,两个 4 字节整数
local a, b = string.unpack("<i4i4", packed) -- 1, 2本章源代码:
src/Lua.Runtime/Execution/LuaState.String.cs、src/Lua.Runtime/Execution/LuaState.String.Format.cs、src/Lua.Runtime/Execution/LuaState.String.Pack.cs参考文档:
docs/036-step-09-string-library-patterns.md
协程是 Lua 最独特的特性之一,也是实现中最具挑战性的部分——它要求对 VM 的调用模型做根本性的改造。
Lua 的协程是非对称式协程:有一个调用者(resume)和一个被调用者(yield),控制权在两者之间显式切换。
yield(values)
调用者 ◄─────────────────────── 协程
resume(args)
调用者 ────────────────────────► 协程
协程有自己的状态:
- suspended:已创建但未开始,或已被 yield 挂起
- running:正在执行
- normal:resume 了其他协程
- dead:函数执行完毕或出错
要支持 yield,VM 的调用模型必须从"C# 递归调用"改为"显式 Lua 调用栈驱动"。
为什么?考虑这个场景:
function f()
coroutine.yield() -- 挂起
return 42
end
function g()
return f() -- 调用 f
end
local co = coroutine.create(g)
coroutine.resume(co) -- 执行到 yield 挂起
coroutine.resume(co) -- 从 yield 之后继续执行,返回 42如果 VM 用 C# 递归实现(g 调用 f 时,C# 方法 ExecuteClosure 递归调用自己),那么 yield 时整个 C# 调用栈都会展开,f 和 g 的状态全部丢失。第二次 resume 无法回到 yield 的位置。
解决方案:不使用 C# 递归。所有 Lua 函数调用都通过压入 CallFrame 实现,由统一的解释器循环驱动。这样 yield 时只需保存当前帧栈,resume 时恢复即可。
改造前(递归调用):
ExecuteClosure(main) → ExecuteClosure(g) → ExecuteClosure(f) → yield
所有 C# 栈帧丢失!
改造后(显式调用栈):
┌──────────────┐
│ Frame: main │
│ Frame: g │
│ Frame: f │ ← yield 时这些帧全部保留
└──────────────┘
统一的 ExecuteLoop 继续驱动最顶层帧
coroutine.yield 通过抛出专用异常来实现控制流转移:
// 简化版实现
void Yield(LuaValue[] values)
{
throw new LuaYieldException(values);
}
LuaValue[] Resume(LuaThread thread, LuaValue[] args)
{
try
{
// 把参数写入挂起点,继续执行
return ContinueExecution(thread);
}
catch (LuaYieldException e)
{
// 协程被挂起,返回 yield 的值
return e.Values;
}
}| 函数 | 说明 |
|---|---|
create(f) |
创建新协程 |
resume(co, ...) |
恢复协程,传入参数 |
yield(...) |
挂起当前协程,传出值 |
wrap(f) |
创建协程并返回一个可直接调用的函数 |
status(co) |
查询协程状态 |
isyieldable() |
当前协程是否可以 yield |
close(co) |
关闭协程,触发 __close |
running() |
返回当前运行的协程 |
本章源代码:
src/Lua.VM/LuaVirtualMachine.Coroutines.cs、src/Lua.Runtime/Execution/LuaState.Coroutine.cs、src/Lua.Runtime/Objects/LuaThread.cs参考文档:
docs/037-step-10-coroutine-library.md测试:
test/Lua.Runtime.Tests/LuaThreadTests.cs
到目前为止,我们的 VM 只能执行预编译的字节码。接下来我们要实现编译器前端,让 VM 能直接执行 Lua 源码。
编译器前端分三个阶段:词法分析(lexer)→ 语法分析(parser)→ 代码生成(compiler)。
源码 → LuaLexer → Token 流 → LuaParser → AST → LuaCompiler → LuaPrototype
词法分析器的任务是:把源码字符串切成一个个词法单元(token)。每个 token 有类型、值和源码位置:
// src/Lua.Syntax/Lexing/LuaTokenKind.cs
public enum LuaTokenKind
{
And, Break, Do, Else, ElseIf, End, False, For, Function,
Global, Goto, If, In, Local, Nil, Not, Or, Repeat, Return,
Then, True, Until, While,
Identifier, Number, String, EndOfFile,
Plus, Minus, Star, Slash, IntegerDivision, Percent, Caret,
Hash, Ampersand, Tilde, Pipe, LessThan, LessEqual, LeftShift,
GreaterThan, GreaterEqual, RightShift, Assign, Equal, NotEqual,
LeftParen, RightParen, LeftBrace, RightBrace, LeftBracket, RightBracket,
Colon, DoubleColon, Semicolon, Comma, Dot, Concat, Vararg
}注意 Global——这是 Lua 5.5 新增的保留字,用于声明全局变量。
保留字与标识符
Lua 有 23 个保留字(加上 global 共 24 个)。标识符以字母或下划线开头,后续可以跟数字。
数字字面量
支持多种格式:
42 -- 十进制整数
0xFF -- 十六进制整数
3.14 -- 浮点数
1.0e10 -- 科学计数法
0xF0.0 -- 十六进制浮点数(Lua 5.5)字符串字面量
短字符串用 " 或 ',支持转义序列:
\a \b \f \n \r \t \v -- 控制字符
\\ \" \' -- 字面量
\xXX -- 十六进制
\ddd -- 十进制
\u{XXX} -- Unicode
\z -- 吞掉后续空白
长字符串用 [[]] 或 [=[]=],不处理转义序列。开括号后紧跟换行时,该换行被跳过。
注释
-- 单行注释
--[[ 多行注释 ]]
--[=[ 长注释 ]=]注释不进入 token 流,但位置信息仍然精确推进——这对后续的错误报告很重要。
每个 token 都带有源码位置(行号、列号、字符偏移),用于错误报告和调试信息:
public readonly struct LuaSourceRange
{
public LuaSourcePosition Start { get; }
public LuaSourcePosition End { get; }
}
public readonly struct LuaSourcePosition
{
public int Line { get; } // 1-based
public int Column { get; } // 1-based
public int Offset { get; } // 0-based
}本章源代码:
src/Lua.Syntax/Lexing/LuaLexer.cs、src/Lua.Syntax/Lexing/LuaToken.cs参考文档:
docs/038-step-11-lexical-analysis.md测试:
test/Lua.Syntax.Tests/LuaLexerTests.cs
Lua 的表达式解析使用优先级攀爬(precedence climbing)算法。Lua 的运算符优先级从低到高:
or
and
< > <= >= ~= ==
..
+ -
* / // %
not # - ~
^
注意 ..(字符串拼接)是右结合的,^(幂运算)也是右结合的,其他二元运算符都是左结合的。
AST 使用一组 record 类型表示:
- 表达式:数字字面量、字符串字面量、nil/true/false、标识符、二元运算、一元运算、函数调用、表构造器、索引访问、字段访问、方法调用、匿名函数、可变参数
... - 语句:局部变量声明、赋值、函数调用、do/end 块、if/elseif/end、while、repeat/until、for(数值和泛型)、return、break、goto/label、函数声明、方法声明
设计上保留了语法糖的区分——函数声明 function foo() end 和方法声明 function obj:method() end 是不同的 AST 节点,方便代码生成时选择不同的指令序列。
解析器除了构建 AST,还负责一些"便宜"的语义检查:
break只能出现在循环体内goto不能跳进局部变量作用域- 函数的最后一个块必须是
return
更复杂的语义分析(类型检查、变量是否定义等)留给后续阶段。
本章源代码:
src/Lua.Syntax/Parsing/LuaParser.cs、src/Lua.Syntax/Ast/LuaSyntaxNodes.cs参考文档:
docs/039-step-12-syntax-analysis-ast.md测试:
test/Lua.Syntax.Tests/LuaParserTests.cs
代码生成是把 AST 翻译成 LuaPrototype 的过程——也就是编译器的后端。
编译器的输出是一个 LuaPrototype 树,结构如下:
LuaPrototype (main chunk)
├─ Instructions: [LOADK, ADD, CALL, ...]
├─ Constants: [42, "hello", ...]
├─ Upvalues: [{name: "_ENV", instack: true, index: 0}]
├─ LocalVariables: [{name: "x", start: 0, end: 10}]
├─ Children:
│ ├─ LuaPrototype (function f)
│ │ ├─ Instructions: [...]
│ │ └─ ...
│ └─ LuaPrototype (function g)
│ └─ ...
全局变量在编译期按词法名字解析:
- 先查找当前函数的局部变量
- 再查找当前函数的上值
- 都找不到时,按
_ENV["name"]处理
这样 _ENV 本身也可以被局部变量遮蔽:
local _ENV = {print = function(...) end}
print("hello") -- 调用的是本地 _ENV.print,不是全局的第一版编译器选择"不复用"策略——局部变量一旦分配了寄存器,在本函数内不再回收。临时寄存器按表达式求值增长。
这样做的代价是生成的 MaxStackSize 可能比官方编译器更大,但好处是:
- 上值捕获不会被寄存器复用污染
- 不需要复杂的
CLOSE和作用域回收逻辑 - 更容易保证正确性
Lua 的多返回值传播是编译器最复杂的部分之一。第一版只覆盖最关键的位置:
return f() -- 尾部开放返回
local a, b, c = f() -- 最后一个调用按剩余变量数补齐
g(a, f()) -- 最后一个参数开放传递其他位置默认按单值收缩。
第一版编译器不支持 for、goto、vararg、<const>/<close> 属性等特性。这些特性在 Step 16 中通过驱动官方测试套件逐个补齐:
function(...)和function(...args)→ Step 16 第 2 轮- 数值
for和泛型for→ Step 16 第 3 轮 <const>和<close>属性 → Step 16 第 4 轮goto/label→ Step 16 第 5 轮
本章源代码:
src/Lua.Compiler/LuaCompiler.cs参考文档:
docs/040-step-13-compiler-first-cut.md、docs/046-step-16-vararg-functions.md、docs/047-step-16-for-loops-bitwise.md、docs/048-step-16-local-variable-attributes.md测试:
test/Lua.Compiler.Tests/LuaCompilerTests.cs
Lua 的 require 机制比看起来要复杂。它不仅仅是一个文件加载器,而是一个完整的模块系统。
require("mylib")
│
├─ 检查 package.loaded["mylib"] → 如果有缓存,直接返回
│
├─ 遍历搜索器链(searchers):
│ ├─ searcher 1: 查找 package.preload["mylib"]
│ ├─ searcher 2: 按 package.path 查找 Lua 文件
│ ├─ searcher 3: 按 package.cpath 查找 C 模块
│ └─ searcher 4: C root 查找
│
├─ 找到后执行模块代码
│
├─ 缓存结果到 package.loaded["mylib"]
│ (模块返回 nil 时缓存 true)
│
└─ 返回模块值
搜索器是可扩展的——每个搜索器是一个函数,接收模块名,返回加载器函数或错误消息。默认搜索器:
- preload 搜索器:查
package.preload表 - Lua 搜索器:按
package.path查找.lua文件,支持文本和二进制 - C 搜索器:按
package.cpath查找动态库 - C root 搜索器:查找根模块名对应的 C 库
在我们的 C# 实现中,C 模块通过宿主注册的原生库模拟,不涉及真正的动态加载。
本章源代码:
src/Lua.Runtime/Execution/LuaState.Package.cs参考文档:
docs/041-step-14-package-system.md
string.dump 把编译好的函数序列化成二进制 chunk——和 Step 3 的读取过程完全对称。
LuaChunkWriter 是 LuaChunkReader 的对称实现:
LuaChunkReader: 二进制字节 → LuaChunk 对象
LuaChunkWriter: LuaChunk 对象 → 二进制字节
支持 strip 模式:去掉调试信息(行号、局部变量名),减小输出体积。
dump → load 的往返(roundtrip)是验证序列化正确性的关键测试:
// 伪代码
var chunk = LoadChunk(file);
var dumped = DumpChunk(chunk);
var reloaded = LoadChunk(dumped);
AssertEqual(chunk, reloaded); // 两次加载的结果必须完全一致本章源代码:
src/Lua.Bytecode/Chunks/LuaChunkWriter.cs参考文档:
docs/042-step-15-bytecode-dump.md
REPL(Read-Eval-Print Loop)是交互式 Lua 环境,支持:
$ lua
> print("hello")
hello
> = 1 + 2 -- = 前缀是求值简写
3
> function f() -- 多行输入自动续行
>> return 42
>> end多行续行通过启发式检测实现——如果当前输入看起来不完整(比如有未闭合的 do/function/括号),继续读取下一行。
lua script.lua arg1 arg2 # 执行脚本
lua -e "print(42)" # 执行一行代码
lua -i # 交互模式
lua -v # 显示版本
lua --help # 帮助命令行参数通过 arg 全局表暴露给脚本。
本章源代码:
src/Lua.Cli/目录参考文档:
docs/043-step-15-cli-repl.md
Lua 5.5 支持两种 GC 模式:增量式(incremental)和分代式(generational)。
C# 已经有 GC 了,为什么还要在 Lua 侧再实现一套?因为 Lua 的弱表(weak table)语义依赖于Lua 可达性(Lua reachability),而不是宿主语言的可达性。
考虑这个场景:
local t = setmetatable({}, {__mode = "v"}) -- 弱值表
local key = "mykey"
t[key] = {} -- 值是一个空表
-- 现在:唯一引用这个空表的是弱表 t
-- Lua 语义:GC 后 t[key] 应该变成 nil
-- 但如果只依赖 C# GC:C# 仍有一个引用指向空表(在 LuaTable 的内部字典中)所以我们需要在 Lua 侧追踪哪些表是弱表,在合适的时机清理那些"只被弱表引用"的条目。
弱表清理按特定顺序进行:
- 弱值表:清理值为 nil 或不可达的条目
- 终结器表:调度有
__gc的对象的终结器 - 弱键表:清理键不可达的条目
弱键表使用 ephemeron(短命表) 语义——如果键只被弱键表自己引用(不通过值间接引用),则键值对被清理。这需要做不动点(fixpoint)传播。
有 __gc 元方法的对象在 GC 回收前会触发终结器调用:
local mt = {
__gc = function(self)
print("cleaning up " .. self.name)
end
}
local obj = setmetatable({name = "test"}, mt)
obj = nil
collectgarbage() -- 触发 "cleaning up test"终结器是一次性的——一个对象的 __gc 只会被调用一次。
VM 的执行循环中定期触发自动 GC,类似 C# 的代际回收思路。collectgarbage 函数提供手动控制:
collectgarbage("collect") -- 完整回收
collectgarbage("stop") -- 停止自动 GC
collectgarbage("restart") -- 恢复自动 GC
collectgarbage("count") -- 返回已用内存(KB)
collectgarbage("incremental", ...) -- 切换到增量模式
collectgarbage("generational", ...) -- 切换到分代模式本章源代码:
src/Lua.Runtime/Execution/LuaState.GarbageCollection.cs参考文档:
docs/053-step-16-gc-api-modes.md、docs/054-step-16-weak-tables.md、docs/055-step-16-gc-finalizers-ephemerons.md、docs/056-step-16-gc-count-and-official-gc.md
Lua 5.5 官方提供了一套完整的测试套件(约 30 个测试文件),覆盖语言的各个方面。Lua.Compatibility.Tests 项目逐个跑这些测试,量化与官方 Lua 的兼容程度。
Step 16 是一个由测试驱动的过程:每跑一个官方测试,失败的地方暴露出实现缺口,修复后再跑下一个。形成了一条修复链:
userdata uservalues → 官方测试接入 → vararg 函数 → for 循环与位运算
→ local 变量属性 → goto/label → __close 修复 → return hooks
→ coroutine close continuations → yieldable protected calls → GC API
→ 弱表 → 终结器与 ephemeron → GC count
每一步修复都让更多的官方测试变绿。
逻辑右移的无符号处理
Lua 的右移 >> 是无符号的——即使操作数是负数,右移时高位也填 0。这和 C# 默认的 >>(对负数高位填 1)不同:
// C# 默认:-1 >> 1 = -1(算术右移)
// Lua 要求:-1 >> 1 = 0x7FFFFFFFFFFFFFFF(逻辑右移)
result = (long)((ulong)a >> (int)b);十六进制浮点数解析
Lua 5.5 支持 0xF0.0 这样的十六进制浮点数字面量。编译器需要正确解析这类格式。
十六进制 .0 字符串输出
string.format("%x", 255) 应该输出 "ff",但某些边界情况下浮点转字符串的行为需要和 Lua 精确对齐。
参考文档:
docs/044-step-16-userdata-uservalues.md至docs/056-step-16-gc-count-and-official-gc.md测试:
test/Lua.Compatibility.Tests/OfficialLuaCompatibilityTests.cs
lua-net/
├── src/
│ ├── Lua.Runtime/ 运行时(值、栈、调用帧、表、闭包、状态)
│ ├── Lua.Bytecode/ 字节码(chunk 读取、指令编码、反汇编)
│ ├── Lua.Syntax/ 语法前端(词法分析、语法分析、AST)
│ ├── Lua.Compiler/ 编译器(AST → 字节码)
│ ├── Lua.VM/ 虚拟机(取指-执行循环)
│ └── Lua.Cli/ 命令行(REPL、脚本执行)
├── test/
│ ├── Lua.Runtime.Tests/
│ ├── Lua.Bytecode.Tests/
│ ├── Lua.Syntax.Tests/
│ ├── Lua.Compiler.Tests/
│ ├── Lua.VM.Tests/
│ ├── Lua.Cli.Tests/
│ ├── Lua.Compatibility.Tests/
│ └── fixtures/lua55/ 测试夹具(93 组 Lua 源码 + 字节码)
├── references/
│ ├── lua-5.5.0/ 官方 Lua 5.5.0 源码(参考)
│ └── luajit/ LuaJIT 源码(参考)
└── docs/ 实现文档(56 份,按步骤编号)
| 操作码 | 格式 | 说明 |
|---|---|---|
MOVE |
ABC | R(A) = R(B) |
LOADI |
AsBx | R(A) = sBx(整数立即数) |
LOADK |
ABx | R(A) = K(Bx)(常量) |
LOADFALSE |
A | R(A) = false |
LOADTRUE |
A | R(A) = true |
LOADNIL |
A | R(A) = nil |
GETUPVAL |
ABC | R(A) = Upvalue[B] |
SETUPVAL |
ABC | Upvalue[B] = R(A) |
GETTABUP |
ABC | R(A) = Upvalue[B][K(C)] |
GETTABLE |
ABC | R(A) = R(B)[R(C)] |
GETI |
ABC | R(A) = R(B)[C] |
GETFIELD |
ABC | R(A) = R(B)[K(C)] |
SETTABUP |
ABC | Upvalue[A][K(B)] = K(C) |
SETTABLE |
ABC | R(A)[R(B)] = R(C) |
SETI |
ABC | R(A)[B] = R(C) |
SETFIELD |
ABC | R(A)[K(B)] = K(C) |
NEWTABLE |
ABC | R(A) = new table |
SELF |
ABC | R(A+1) = R(B); R(A) = R(B)[K(C)] |
ADD..SHR |
ABC | 算术/位运算 |
ADDK..SHRK |
ABC | 常量变体 |
ADDI..SHRI |
ABC | 立即数变体 |
MMBIN |
ABC | 触发元方法 |
UNM/BNOT/NOT |
A | 一元运算 |
LEN |
A | R(A) = #R(A) |
CONCAT |
ABC | R(A) = R(B)..R(C) |
CLOSE |
A | 关闭 upvalue |
TBC |
A | 标记为 to-be-closed |
JMP |
sJ | 无条件跳转 |
EQ/LT/LE |
ABC | 比较 + 条件跳转 |
TEST |
A | if not R(A) then jump |
TESTSET |
ABC | if R(C) then R(A) = R(B) else jump |
CALL |
ABC | 调用函数 |
TAILCALL |
ABC | 尾调用 |
RETURN |
ABC | 返回 |
FORLOOP |
A | 数值 for 循环步进 |
FORPREP |
A | 数值 for 循环准备 |
TFORPREP |
A | 泛型 for 循环准备 |
TFORCALL |
ABC | 泛型 for 循环调用迭代器 |
TFORLOOP |
A | 泛型 for 循环步进 |
SETLIST |
ABC | 批量设置数组元素 |
CLOSURE |
ABx | R(A) = new closure(Child[Bx]) |
VARARG |
ABC | R(A), .. = varargs |
VARARGPREP |
A | 标记为可变参数函数 |
EXTRAARG |
Ax | 提供额外参数 |
| 编号 | 文件 | 主题 |
|---|---|---|
| 001 | 001-roadmap.md |
总路线图 |
| 002 | 002-step-01-foundation.md |
基础基线 |
| 003 | 003-step-01-source-reference.md |
官方源码参考策略 |
| 004 | 004-step-02-runtime-model.md |
运行时值模型 |
| 005 | 005-step-03-bytecode-loader.md |
字节码加载 |
| 006 | 006-step-04-vm-skeleton.md |
VM 骨架 |
| 007 | 007-step-04-table-access.md |
表访问 |
| 008 | 008-step-05-self-call.md |
SELF 与方法调用 |
| 009 | 009-step-05-global-environment.md |
_ENV 全局环境 |
| 010 | 010-step-05-upvalue-cells.md |
上值与闭包捕获 |
| 011 | 011-step-05-close.md |
CLOSE 指令 |
| 012 | 012-step-05-tbc.md |
TBC(to-be-closed) |
| 013 | 013-step-05-close-metamethod.md |
__close 元方法 |
| 014 | 014-step-05-close-errors.md |
__close 错误传播 |
| 015 | 015-step-04-load-opcodes.md |
加载指令补全 |
| 016 | 016-step-04-setlist.md |
SETLIST |
| 017 | 017-step-05-vararg-open-results.md |
可变参数与开放结果 |
| 018 | 018-step-04-loops.md |
循环指令 |
| 019 | 019-step-04-repeat-global-checks.md |
repeat 与 global 检查 |
| 020 | 020-step-05-vararg-table.md |
vararg table |
| 021 | 021-step-06-binary-metamethods.md |
二元元方法 |
| 022 | 022-step-06-length-concat-compare-metamethods.md |
长度/拼接/比较元方法 |
| 023 | 023-step-06-unary-call-metamethods.md |
一元/调用元方法 |
| 024 | 024-step-06-table-metamethods.md |
表访问元方法 |
| 025 | 025-step-06-userdata-metamethods.md |
userdata 元方法 |
| 026 | 026-step-07-base-metatable-raw-functions.md |
元表与 raw 函数 |
| 027 | 027-step-07-base-core-functions.md |
基础库核心函数 |
| 028 | 028-step-07-xpcall.md |
xpcall |
| 029 | 029-step-07-number-string-conversion.md |
tonumber/tostring |
| 030 | 030-step-07-table-iteration-functions.md |
next/pairs/ipairs |
| 031 | 031-step-07-print-warn-functions.md |
print/warn |
| 032 | 032-step-07-string-metamethods.md |
字符串元方法 |
| 033 | 033-step-07-load-dofile-functions.md |
load/dofile |
| 034 | 034-step-07-collectgarbage-require-functions.md |
collectgarbage/require |
| 035 | 035-step-08-table-math-utf8-libraries.md |
table/math/utf8 库 |
| 036 | 036-step-09-string-library-patterns.md |
string 库与模式匹配 |
| 037 | 037-step-10-coroutine-library.md |
coroutine 库 |
| 038 | 038-step-11-lexical-analysis.md |
词法分析 |
| 039 | 039-step-12-syntax-analysis-ast.md |
语法分析与 AST |
| 040 | 040-step-13-compiler-first-cut.md |
编译器(第一版) |
| 041 | 041-step-14-package-system.md |
包系统 |
| 042 | 042-step-15-bytecode-dump.md |
字节码序列化 |
| 043 | 043-step-15-cli-repl.md |
CLI 与 REPL |
| 044 | 044-step-16-userdata-uservalues.md |
userdata 关联值 |
| 045 | 045-step-16-official-test-suite.md |
官方测试套件接入 |
| 046 | 046-step-16-vararg-functions.md |
可变参数函数编译 |
| 047 | 047-step-16-for-loops-bitwise.md |
for 循环与位运算修复 |
| 048 | 048-step-16-local-variable-attributes.md |
local 变量属性 |
| 049 | 049-step-16-locals-close-runtime.md |
goto/label 与运行时修复 |
| 050 | 050-step-16-return-hooks.md |
return hooks |
| 051 | 051-step-16-coroutine-close-continuations.md |
协程 close 延续 |
| 052 | 052-step-16-yieldable-protected-calls.md |
可 yield 的受保护调用 |
| 053 | 053-step-16-gc-api-modes.md |
GC API 与模式 |
| 054 | 054-step-16-weak-tables.md |
弱表 |
| 055 | 055-step-16-gc-finalizers-ephemerons.md |
终结器与 ephemeron |
| 056 | 056-step-16-gc-count-and-official-gc.md |
GC count 与官方 GC 对齐 |