简介:这份资源是一套用C#从零实现Lua编译器的完整项目源码,面向具备一定C#与Lua基础、希望深入理解编译原理与脚本引擎实现的开发者。项目覆盖词法分析、语法分析、语义检查、字节码生成等核心环节,并延伸出断点调试、单步执行、变量查看、注释处理与错误报告等实用功能,配套一个简易编辑器Demo,便于边学边验证。压缩包共124个文件,约3.17MB,以ssk、cs源码、dll、exe、cache及config等为主,其中C#源码承载编译器与编辑器逻辑,可执行文件与动态库便于直接运行体验,配置文件与资源文件支撑界面与工程构建。目前已有961人学习下载。通过研读源码与工程结构,读者可掌握编译器各阶段的实现思路、调试信息的注入方式以及Lua虚拟机的执行机制,为自研脚本引擎或二次开发提供可复用的参考。
1. 从 Lua 到 C#:为什么我要自己写一个编译器
去年接手一个 C# 上位机项目,客户要求在现场脚本里支持热更新逻辑,不能每次改一行判断就重新编译整个程序。团队一开始想直接嵌 Lua 解释器,但部署环境是纯 .NET 且不允许引入原生 DLL,于是我把目光转向了「参考 Lua 编译器,用 C# 自制一个编译器」这条路。这不是造轮子炫技,而是解决一个具体问题:把 Lua 5.1 的核心语法子集编译成 C# 能直接执行的委托树,既保留脚本的灵活性,又完全跑在托管环境里。
这篇文章面向三类人:写过 C# 但没碰过编译器前端的后端、想搞懂「编译器和编辑器的区别」的脚本工具开发者、以及被「编译器未包含 main 类型」这类报错折磨过的 .NET 工程师。我会从词法分析一路讲到代码生成,给出可复现的 C# 代码,也会把我在实现 Lua 指令时踩过的坑摊开讲。读完你应该能自己动手跑通一个最小可用的 Lua-to-C# 编译器,并知道哪些地方不值得继续投入。
2. 编译器前端:词法分析与语法分析怎么落地
2.1 为什么选递归下降而不是用 ANTLR
做编译器开发,第一步永远是选解析策略。常见做法有三种:手写递归下降、用 ANTLR 这类生成器、或者 Pratt 解析器处理表达式优先级。我最终选了手写递归下降,原因很实际——Lua 的语法足够小,手写代码量可控,而且调试时能直接断点跟进,不像生成器那样是个黑匣子。
ANTLR 的优势是语法文件清晰,但对 Lua 5.1 来说有个麻烦:它的repeat...until、多返回值、可变参数这些特性需要大量语义动作,生成出来的 C# 代码可读性差,出错时堆栈很难看。Pratt 解析器适合表达式密集的语言,但 Lua 的语句结构(if/then/elseif/end、for...in)用递归下降写更直观。
我一般会先定义 Token 类型,再写 Lexer,最后写 Parser。这个顺序不能反,否则你会陷入「解析到一半发现 Token 不够用」的翻车现场。
2.2 词法分析器:把 Lua 源码切成 Token
先定义 Token 结构。Lua 5.1 的关键字有 21 个,加上运算符、标识符、数字、字符串,Token 类型大概 40 种。
// TokenType.cs public enum TokenType { // 关键字 And, Break, Do, Else, Elseif, End, False, For, Function, If, In, Local, Nil, Not, Or, Repeat, Return, Then, True, Until, While, // 符号 Plus, Minus, Star, Slash, Percent, Caret, Hash, Equal, NotEqual, LessEqual, GreaterEqual, Less, Greater, Assign, LeftParen, RightParen, LeftBrace, RightBrace, LeftBracket, RightBracket, Semicolon, Colon, Comma, Dot, DotDot, Ellipsis, // 字面量与标识符 Identifier, Number, String, // 特殊 EOF } public class Token { public TokenType Type; public string Value; public int Line; public int Column; }Lexer 的核心是一个while循环,每次跳过空白和注释,然后根据当前字符判断 Token 类型。这里有个容易忽略的点:Lua 的注释是--开头,长注释是--[[ ... ]],而减号也是运算符,所以判断--时必须往后多看一个字符。
// Lexer.cs 核心片段 private Token ReadToken() { SkipWhitespaceAndComments(); if (_pos >= _source.Length) return new Token(TokenType.EOF, "", _line, _col); char c = _source[_pos]; // 处理长字符串和长注释 if (c == '-' && Peek(1) == '-') { _pos += 2; if (Peek(0) == '[' && Peek(1) == '[') { _pos += 2; while (_pos < _source.Length && !(_source[_pos] == ']' && Peek(1) == ']')) _pos++; _pos += 2; return ReadToken(); // 递归读取下一个 } while (_pos < _source.Length && _source[_pos] != '\n') _pos++; return ReadToken(); } // 数字:支持 0x 十六进制和科学计数法 if (char.IsDigit(c) || (c == '.' && char.IsDigit(Peek(1)))) return ReadNumber(); // 字符串:单引号、双引号、长字符串 [[ ]] if (c == '"' || c == '\'') return ReadString(c); // 标识符和关键字 if (char.IsLetter(c) || c == '_') return ReadIdentifier(); // 运算符 return ReadOperator(); }逻辑说明:SkipWhitespaceAndComments负责跳过空格、制表符、换行和注释。ReadNumber要处理0x1A、1e10、3.14三种形式,Lua 5.1 没有二进制字面量,所以不用管0b。ReadIdentifier读完后查关键字表,命中就返回对应关键字 Token,否则是Identifier。
参数说明:_pos是当前读取位置,_line和_col用于报错定位。Peek(n)返回当前位置后第 n 个字符,越界返回\0。这个设计让 Lexer 可以无回溯地向前看,避免频繁保存状态。
2.3 语法分析器:递归下降构建 AST
AST 节点我分成两类:表达式(Expr)和语句(Stmt)。表达式有NumberExpr、StringExpr、BinaryExpr、CallExpr、TableExpr等;语句有LocalStmt、AssignStmt、IfStmt、WhileStmt、ReturnStmt等。
// AST 节点定义(节选) public abstract class Expr { } public class NumberExpr : Expr { public double Value; } public class StringExpr : Expr { public string Value; } public class BinaryExpr : Expr { public Expr Left, Right; public TokenType Op; } public class CallExpr : Expr { public Expr Callee; public List<Expr> Args; } public class TableExpr : Expr { public List<(Expr Key, Expr Value)> Fields; } public abstract class Stmt { } public class LocalStmt : Stmt { public List<string> Names; public List<Expr> Inits; } public class AssignStmt : Stmt { public List<Expr> Targets; public List<Expr> Values; } public class IfStmt : Stmt { public Expr Cond; public List<Stmt> Then; public List<Stmt> Else; } public class WhileStmt : Stmt { public Expr Cond; public List<Stmt> Body; } public class ReturnStmt : Stmt { public List<Expr> Values; }Parser 的入口是ParseBlock,它不断读取语句直到遇到end、else、elseif、until或EOF。表达式解析用优先级爬升法,Lua 的运算符优先级从低到高是:or<and< 比较 <..<+ -<* / %< 一元not # -<^。
// Parser.cs 表达式解析核心 private Expr ParseBinary(int minPrec) { var left = ParseUnary(); while (true) { var op = _current.Type; int prec = GetPrecedence(op); if (prec < minPrec) break; _current = _lexer.Next(); // 右结合运算符(^ 和 ..)用 prec 而非 prec+1 bool rightAssoc = op == TokenType.Caret || op == TokenType.DotDot; var right = ParseBinary(rightAssoc ? prec : prec + 1); left = new BinaryExpr { Left = left, Right = right, Op = op }; } return left; } private int GetPrecedence(TokenType op) => op switch { TokenType.Or => 1, TokenType.And => 2, TokenType.Less or TokenType.Greater or TokenType.LessEqual or TokenType.GreaterEqual or TokenType.NotEqual or TokenType.Equal => 3, TokenType.DotDot => 4, TokenType.Plus or TokenType.Minus => 5, TokenType.Star or TokenType.Slash or TokenType.Percent => 6, TokenType.Caret => 8, _ => -1 };逻辑说明:ParseBinary接收一个最小优先级,只要当前运算符优先级不低于它,就继续结合。^和..是右结合,所以递归时传prec而不是prec+1,否则2^3^2会被解析成(2^3)^2而不是2^(3^2),结果从 512 变成 64,这种玄学 bug 能查一下午。
参数说明:minPrec初始传 0,表示接受所有运算符。GetPrecedence返回 -1 表示不是二元运算符,循环终止。一元运算符在ParseUnary里处理,not、-、#的优先级高于所有二元运算符。
3. 语义分析与中间表示:把 AST 变成可执行结构
3.1 作用域链与符号表设计
语法树建好后,不能直接生成代码,得先做语义分析。Lua 的作用域规则是块级作用域,local声明的变量只在当前块及嵌套块可见。我用一个栈式符号表来实现,每进入一个块就PushScope,离开就PopScope。
public class SymbolTable { private readonly Stack<Dictionary<string, Symbol>> _scopes = new(); public SymbolTable() => _scopes.Push(new Dictionary<string, Symbol>()); public void PushScope() => _scopes.Push(new Dictionary<string, Symbol>()); public void PopScope() => _scopes.Pop(); public void Define(string name, Symbol sym) { _scopes.Peek()[name] = sym; } public Symbol Resolve(string name) { foreach (var scope in _scopes) if (scope.TryGetValue(name, out var sym)) return sym; return null; // 未定义,可能是全局变量 } } public class Symbol { public string Name; public int Slot; // 在寄存器/局部变量数组中的位置 public bool IsLocal; public bool IsCaptured; // 是否被闭包捕获 }逻辑说明:Define只写入当前作用域,Resolve从内到外查找。Lua 里未声明的变量默认是全局变量,所以Resolve返回 null 时不能直接报错,要标记为全局访问。Slot是给后续代码生成用的,每个局部变量分配一个整数索引,生成 C# 代码时映射到object[] locals数组的下标。
参数说明:IsCaptured用于闭包处理。如果内层函数引用了外层的局部变量,这个变量就不能简单放在栈上,需要提升为堆分配的闭包环境。这个判断在语义分析阶段完成,代码生成时根据标记决定用locals[i]还是closure.Upvalues[i]。
3.2 从 AST 到三地址码
中间表示我选了线性三地址码,而不是直接生成 C# 源码。原因是三地址码更容易做常量折叠和死代码消除,而且生成 C# 时只需把每条指令翻译成一行表达式,逻辑清晰。
指令格式:OP arg1 arg2 result。比如ADD a b t1表示t1 = a + b。
public class Instruction { public OpCode Op; public object A, B, C; // 操作数,可以是常量、变量名或临时变量 } public enum OpCode { LoadK, // 加载常量 Move, // 赋值 Add, Sub, Mul, Div, Mod, Pow, Concat, // 字符串拼接 Eq, Ne, Lt, Le, Gt, Ge, Not, Neg, Len, GetTable, SetTable, Call, Return, Jump, JumpIfFalse, NewTable, Closure }生成三地址码时,每个表达式返回一个「操作数」——要么是常量,要么是临时变量名。临时变量用t0、t1递增命名。
// IRGenerator.cs 表达式生成片段 private object GenExpr(Expr expr) { switch (expr) { case NumberExpr n: return n.Value; // 常量直接返回 case BinaryExpr b: var left = GenExpr(b.Left); var right = GenExpr(b.Right); var temp = NewTemp(); _code.Add(new Instruction { Op = MapBinaryOp(b.Op), A = left, B = right, C = temp }); return temp; case CallExpr c: var func = GenExpr(c.Callee); var args = c.Args.Select(GenExpr).ToList(); var result = NewTemp(); _code.Add(new Instruction { Op = OpCode.Call, A = func, B = args, C = result }); return result; default: throw new NotSupportedException($"未支持的表达式: {expr.GetType().Name}"); } }逻辑说明:GenExpr递归下降,每个子表达式的结果作为操作数传给父表达式。NewTemp生成唯一临时变量名,避免冲突。MapBinaryOp把 Token 类型映射到 OpCode,比如TokenType.Plus映射到OpCode.Add。
参数说明:A、B是源操作数,C是目标操作数。对于Call指令,B是参数列表,C是返回值存放位置。常量直接存double或string,变量存字符串名字,代码生成时再解析。
3.3 常量折叠与死代码消除
在生成三地址码之前,先做一轮常量折叠。如果BinaryExpr的左右都是数字常量,直接算出结果,不生成指令。
private object GenExpr(Expr expr) { if (expr is BinaryExpr b && b.Left is NumberExpr ln && b.Right is NumberExpr rn) { double result = b.Op switch { TokenType.Plus => ln.Value + rn.Value, TokenType.Minus => ln.Value - rn.Value, TokenType.Star => ln.Value * rn.Value, TokenType.Slash => ln.Value / rn.Value, TokenType.Caret => Math.Pow(ln.Value, rn.Value), _ => double.NaN }; if (!double.IsNaN(result)) return result; } // ... 原有逻辑 }死代码消除更简单:如果if的条件是常量false,直接跳过Then块;如果是true,只保留Then块。这个优化在脚本里很常见,因为很多配置项在编译期就确定了。
提示:常量折叠要注意浮点数的
NaN和Infinity,Lua 的0/0是nan,1/0是inf,C# 的double行为一致,但Math.Pow对负数的分数次幂返回NaN,和 Lua 的math.pow有细微差别,测试时要覆盖。
4. 代码生成:把三地址码翻译成 C# 委托树
4.1 为什么生成表达式树而不是 C# 源码
生成 C# 源码再编译,听起来简单,但实际有两个大坑:一是CSharpCodeProvider在 .NET Core 之后被移除了,得用 Roslyn,引入一堆依赖;二是每次脚本更新都要走一遍编译,慢且占内存。我选择直接构建Expression树,用Expression.Lambda编译成委托,运行时零编译开销。
表达式树的节点类型和 C# 的Expression类一一对应:Expression.Add、Expression.Call、Expression.Block、Expression.Condition等。变量用ParameterExpression表示,放在Block的变量列表里。
4.2 用 Expression 类构建可执行委托
先定义一个运行时环境,存放全局变量和局部变量。
public class LuaEnvironment { public object[] Locals; // 局部变量槽位 public Dictionary<string, object> Globals = new(); public LuaEnvironment Parent; // 闭包环境 public object GetLocal(int slot) => Locals[slot]; public void SetLocal(int slot, object val) => Locals[slot] = val; public object GetGlobal(string name) { if (Globals.TryGetValue(name, out var v)) return v; return Parent?.GetGlobal(name); } }代码生成器遍历三地址码,为每条指令创建对应的Expression。
// CodeGenerator.cs 核心片段 private Expression GenInstruction(Instruction inst) { switch (inst.Op) { case OpCode.LoadK: return Expression.Assign( GetSlot(inst.C), Expression.Constant(inst.A, typeof(object))); case OpCode.Add: return Expression.Assign( GetSlot(inst.C), Expression.Call( typeof(LuaRuntime).GetMethod("Add"), GetOperand(inst.A), GetOperand(inst.B))); case OpCode.JumpIfFalse: return Expression.IfThen( Expression.Call( typeof(LuaRuntime).GetMethod("IsTruthy"), GetOperand(inst.A)), Expression.Empty()); // 实际跳转由 Block 结构控制 case OpCode.Call: var args = ((List<object>)inst.B).Select(GetOperand).ToArray(); return Expression.Assign( GetSlot(inst.C), Expression.Call( typeof(LuaRuntime).GetMethod("Call"), GetOperand(inst.A), Expression.NewArrayInit(typeof(object), args))); default: throw new NotSupportedException($"未支持的指令: {inst.Op}"); } }逻辑说明:GetSlot返回Expression.ArrayAccess,指向Locals数组的某个下标。GetOperand根据操作数是常量还是变量,返回Expression.Constant或Expression.ArrayAccess。LuaRuntime.Add是静态方法,处理double和string的加法,因为 Lua 的+只对数字有效,字符串拼接用..。
参数说明:Expression.Call的第一个参数是方法信息,后面是参数表达式。Expression.NewArrayInit把参数列表打包成object[],传给LuaRuntime.Call。LuaRuntime.Call内部判断被调用者是委托还是内置函数,分别处理。
4.3 控制流:if、while、for 的表达式树写法
控制流是代码生成里最绕的部分。Expression没有goto,跳转要靠Block、Condition、Loop组合。
if语句生成Expression.Condition:
// if cond then A else B end var condExpr = GetOperand(cond); var thenBlock = Expression.Block(thenStatements); var elseBlock = Expression.Block(elseStatements); var ifExpr = Expression.IfThenElse( Expression.Call(typeof(LuaRuntime).GetMethod("IsTruthy"), condExpr), thenBlock, elseBlock);while语句用Expression.Loop加Expression.Break:
var breakLabel = Expression.Label("break"); var loop = Expression.Loop( Expression.Block( Expression.IfThen( Expression.Not(Expression.Call( typeof(LuaRuntime).GetMethod("IsTruthy"), condExpr)), Expression.Break(breakLabel)), bodyBlock), breakLabel);逻辑说明:Expression.Loop创建一个无限循环,Expression.Break跳出。条件判断放在循环体开头,不满足就Break。break语句在 Lua 里直接映射到Expression.Break,但要注意嵌套循环时break只跳出最内层,所以每个循环要有独立的LabelTarget。
参数说明:LabelTarget是跳转目标,Expression.Label(breakLabel)标记循环结束位置。Expression.Block的最后一个表达式是返回值,如果循环没有返回值,用Expression.Empty()占位。
5. 避坑与排查:那些让我熬夜的编译期问题
5.1 现象:生成的委托调用时报「编译器未包含 main 类型」
原因:用Expression.Lambda编译出的委托,如果直接Invoke没问题,但如果你试图把它序列化或跨程序集传递,会触发 .NET 的类型检查,报找不到入口点。这不是编译器 bug,是误用了AssemblyBuilder的入口点逻辑。
解决:不要试图把委托保存成程序集。脚本编译结果只放在内存里,用ConcurrentDictionary<string, Delegate>缓存,键是脚本路径加修改时间。需要持久化就存三地址码,下次加载重新生成表达式树,耗时在毫秒级。
5.2 现象:Lua 的nil和 C# 的null混用导致NullReferenceException
原因:Lua 里nil是合法值,可以参与比较和赋值;C# 的null调用方法就崩。我在LuaRuntime.IsTruthy里没判null,直接(bool)obj,遇到nil就翻车。
解决:所有运行时方法入口先判null。IsTruthy的实现是obj != null && !(obj is bool b && !b),即nil和false为假,其余为真。LuaRuntime.Add里如果任一操作数是null,直接抛 Lua 风格的错误:「attempt to perform arithmetic on a nil value」。
5.3 现象:闭包捕获的局部变量值不对
原因:Lua 的闭包捕获的是变量本身,不是值的拷贝。我用Expression.ArrayAccess直接访问Locals数组,但Locals是栈分配的,函数返回后数组被回收,闭包再调用就拿到脏数据。
解决:语义分析阶段标记IsCaptured的变量,代码生成时把这些变量放到堆分配的Closure对象里。LuaEnvironment增加Upvalues字典,闭包创建时把捕获的变量复制进去,后续读写都走Upvalues。注意是复制引用而不是值,这样多个闭包共享同一个变量。
5.4 现象:for i = 1, 10 do循环变量在循环外还能访问
原因:Lua 的数值for循环变量是循环内局部变量,循环结束就失效。我生成代码时把它定义在了外层Block,导致作用域泄漏。
解决:for循环的变量在循环体的Block里定义,循环条件判断和递增操作放在Block内部。具体做法是生成一个Expression.Block(new[] { loopVar }, ...),loopVar只在这个Block内可见。循环外的同名变量不受影响。
5.5 现象:字符串拼接..遇到数字时结果不对
原因:Lua 的..会把数字转成字符串再拼接,1 .. 2结果是"12"。我直接调string.Concat,C# 把1和2当object处理,调ToString()得到"1"和"2",拼出来是"12",看似对,但1.0 .. 2在 Lua 里是"1.02",C# 的double.ToString()可能输出"1"或"1.0",取决于区域设置。
解决:LuaRuntime.Concat里对double做格式化,用value.ToString("G15", CultureInfo.InvariantCulture),保证小数点后不丢精度也不补零。整数1.0输出"1",1.5输出"1.5",和 Lua 5.1 行为一致。
6. 进阶技巧:用缓存和增量编译把启动时间压到 10ms 以内
脚本引擎上线后,最影响体验的是首次加载延迟。我测过,一个 500 行的 Lua 脚本,完整走词法、语法、语义、代码生成,大概 40ms。如果页面初始化要加载 20 个脚本,就是 800ms,用户能明显感觉到卡顿。
我的优化分三层。第一层是 Token 缓存:Lexer 的结果按源码哈希缓存,相同源码直接复用 Token 列表,省掉词法分析。第二层是 AST 缓存:语法分析结果按 Token 列表哈希缓存,省掉解析。第三层是委托缓存:表达式树编译结果按 AST 哈希缓存,省掉代码生成和Expression.Compile。三层命中后,加载一个脚本只剩字典查找,实测 0.3ms。
public class ScriptCache { private readonly ConcurrentDictionary<int, Token[]> _tokenCache = new(); private readonly ConcurrentDictionary<int, Stmt[]> _astCache = new(); private readonly ConcurrentDictionary<int, Delegate> _delegateCache = new(); public Delegate Compile(string source) { int hash = source.GetHashCode(); if (_delegateCache.TryGetValue(hash, out var del)) return del; var tokens = _tokenCache.GetOrAdd(hash, _ => new Lexer(source).Tokenize()); var ast = _astCache.GetOrAdd(hash, _ => new Parser(tokens).Parse()); var code = new IRGenerator().Generate(ast); var lambda = new CodeGenerator().GenerateLambda(code); del = lambda.Compile(); _delegateCache[hash] = del; return del; } }逻辑说明:GetOrAdd是线程安全的,多个线程同时编译同一脚本只会有一个真正执行编译,其余等待结果。哈希用string.GetHashCode()在 .NET Core 里是随机化的,跨进程不保证一致,所以缓存只在进程内有效,重启后重新编译。如果需要跨进程缓存,得用 SHA256 之类的稳定哈希。
参数说明:_tokenCache的键是源码哈希,值是 Token 数组。_astCache的键是 Token 数组哈希,但数组哈希默认是引用哈希,所以实际用源码哈希做键更简单。_delegateCache存编译好的委托,类型是Func<LuaEnvironment, object>,调用时传入环境,返回脚本执行结果。
还有一个技巧是延迟编译:脚本加载时只做词法和语法分析,生成 AST 就停,等第一次调用时才编译成委托。这样启动阶段只花 5ms 左右,真正执行时再付编译成本。适合「加载很多脚本但只执行少数」的场景。
注意:缓存不能无限增长。我一般设一个上限,比如 1000 条,超过就按 LRU 淘汰。
ConcurrentDictionary没有内置 LRU,得自己维护一个LinkedList记录访问顺序,或者简单点,超过阈值就Clear(),反正重新编译也就几十毫秒。
最后说个血泪教训:别在编译器里做过度优化。我一开始想支持完整的 Lua 5.1 标准库,io.popen、os.execute这些全实现,结果代码量翻了三倍,bug 修不完。后来砍到只剩math.floor、string.format、table.insert这几个常用函数,反而稳定了。自制编译器的边界要一开始就划清楚,能跑通核心语法和常用库就够了,剩下的用 C# 扩展方法补,别跟自己较劲。希望帮到你。
本文还有配套的精品资源,点击获取