ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从Lua到C#:手写编译器实现脚本热更新与表达式树代码生成

从Lua到C#:手写编译器实现脚本热更新与表达式树代码生成 简介这份资源是一套用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 语法分析器递归下降构建 ASTAST 节点我分成两类表达式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 ListExpr Args; } public class TableExpr : Expr { public List(Expr Key, Expr Value) Fields; } public abstract class Stmt { } public class LocalStmt : Stmt { public Liststring Names; public ListExpr Inits; } public class AssignStmt : Stmt { public ListExpr Targets; public ListExpr Values; } public class IfStmt : Stmt { public Expr Cond; public ListStmt Then; public ListStmt Else; } public class WhileStmt : Stmt { public Expr Cond; public ListStmt Body; } public class ReturnStmt : Stmt { public ListExpr Values; }Parser 的入口是ParseBlock它不断读取语句直到遇到end、else、elseif、until或EOF。表达式解析用优先级爬升法Lua 的运算符优先级从低到高是orand 比较 .. -* / % 一元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 而非 prec1 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而不是prec1否则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 StackDictionarystring, Symbol _scopes new(); public SymbolTable() _scopes.Push(new Dictionarystring, Symbol()); public void PushScope() _scopes.Push(new Dictionarystring, 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和InfinityLua 的0/0是nan1/0是infC# 的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 Dictionarystring, 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 ((Listobject)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.Breakvar 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的入口点逻辑。解决不要试图把委托保存成程序集。脚本编译结果只放在内存里用ConcurrentDictionarystring, 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.ConcatC# 把1和2当object处理调ToString()得到1和2拼出来是12看似对但1.0 .. 2在 Lua 里是1.02C# 的double.ToString()可能输出1或1.0取决于区域设置。解决LuaRuntime.Concat里对double做格式化用value.ToString(G15, CultureInfo.InvariantCulture)保证小数点后不丢精度也不补零。整数1.0输出11.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 ConcurrentDictionaryint, Token[] _tokenCache new(); private readonly ConcurrentDictionaryint, Stmt[] _astCache new(); private readonly ConcurrentDictionaryint, 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存编译好的委托类型是FuncLuaEnvironment, object调用时传入环境返回脚本执行结果。还有一个技巧是延迟编译脚本加载时只做词法和语法分析生成 AST 就停等第一次调用时才编译成委托。这样启动阶段只花 5ms 左右真正执行时再付编译成本。适合「加载很多脚本但只执行少数」的场景。注意缓存不能无限增长。我一般设一个上限比如 1000 条超过就按 LRU 淘汰。ConcurrentDictionary没有内置 LRU得自己维护一个LinkedList记录访问顺序或者简单点超过阈值就Clear()反正重新编译也就几十毫秒。最后说个血泪教训别在编译器里做过度优化。我一开始想支持完整的 Lua 5.1 标准库io.popen、os.execute这些全实现结果代码量翻了三倍bug 修不完。后来砍到只剩math.floor、string.format、table.insert这几个常用函数反而稳定了。自制编译器的边界要一开始就划清楚能跑通核心语法和常用库就够了剩下的用 C# 扩展方法补别跟自己较劲。希望帮到你。本文还有配套的精品资源点击获取
返回列表