自研编程语言工具链指南:从AST、LLVM到运行时与LSP

发布时间:2026/10/3 20:58:53
自研编程语言工具链指南:从AST、LLVM到运行时与LSP “设计一门编程语言”这事我在上一篇里聊过从零起步时的词汇和语法工具怎么选算是把“前两公里”的装备过了一遍。但每次发完评论区总有同一个声音追上来语法树搭好之后呢后面那些代码生成、运行时、调试器、IDE 适配到底靠什么工具顶上去所以这次我不绕弯子直接沿着一条完整的语言工具链往后捋把从“源码解析完”到“真正能跑、能用、甚至有人愿意用”的环节全部拆开逐个说清每个阶段该上什么开发工具、选什么方案、避什么坑。这篇内容适合两类人一是想趁着课程设计或者业余项目亲手做一门玩具语言、却被工具链劝退的开发者二是对编译技术感兴趣、但还没把“解析”和“后端”“运行时”串成一线的新人。我会尽量把每个工具为什么这么选、有什么替代项、实际用起来是什么手感都讲透保证你看完能照着搭出第一版可执行的语言原型。还是那句话编译器这东西没有玄学多数时候就是工具对路、流程清晰、坑提前知道。1. 语言设计全流程从“能解析”到“能运行”的整体蓝图1.1 先想清楚你的语言要活在哪个生态很多新手拿到一门语言的思路第一反应就是“我要写一个编译器”。但真正动手前更该先回答的是这门语言编译出来之后要活在哪个环境里这个问题的答案直接决定你后面所有工具的选择。我见过最快的入门方式是给自己的语言定一个“生态切片”然后只围绕这个切片选方案。比如你想做一门嵌入式脚本语言那大概率走解释器路线直接把 AST 或者字节码跑起来你想做一门偏向数据处理的话就要去关心数组连续内存布局、SIMD 指令生成、多线程并行库这些目标会逼着你把后面几步的优化格提前想好。这里串进来的一个判断标准很有意思搜“大表计算效率最高的编程语言”的人本质上不是在找语言而是在找“特定计算场景下谁的数据通路最短”。放到你自研语言上同理——你设计的语言如果目标是处理超大表格、跑聚合统计那么内存布局、Cache 友好程度、遍历迭代器的生成质量这些远比语法糖重要。反过来说你要是想做一个偏深度学习的 DSL 语言那么跟 Python 互操作、数组切片运算、GPU Kernel 的生成能力又成了核心。深度学习所需要的编程语言恰恰是这种“语言贴近数学表达式、后端贴近硬件”的典型形态。所以第一件事不是列工具而是拿纸写下一句话“我设计的语言最擅长解决什么问题跑在什么环境里。”这句话写完工具链会自动收窄一大半。1.2 语义定稿之后再画工具链地图语言设计里最难的部分不是语法而是语义。语义没定工具链没法选。比如你定的是动态类型那么类型检查那一段的工具就可以往后放解释器里直接做运行时类型判断你定的是静态类型那从语法树到中间表示之间就得有一张完整的符号表和类型推导工具链。我的经验是先手工把语义写清楚值类型有哪些、作用域规则、表达式求值顺序、错误处理用异常还是返回码、并发模型是协程还是线程还是 actor。这些玩意儿不能靠工具帮你兜底得你自己拍板。定了之后再画一张工具链地图大致分成下面几个阶段阶段主要任务常用工具/方案解析阶段源码转词法流、语法树ANTLR、logos、手写递归下降语义阶段符号表、类型检查、作用域分析手写多路访问器、rustc 风格 lint中间表示AST 直译、自定义字节码、LLVM IR自研 enum Walker、inkwell、llvmlite代码生成目标代码/JIT 生成LLVM、C 代码转译、WebAssembly运行时内存管理、标准库、宿主互操作arena、引用计数、libgc、自研 VM工程化测试、基准、打包、编辑器支持cargo test、criterion、LSP、TextMate这张图不是我凭空画出来的是从一批语言源码里总结出的公共骨架。你会发现绝大多数语言哪怕是那些常年挂在编程语言排行榜前面的名字干的事也都是按这几个阶段切开的只是每一块的深度和工具不同。有了整体蓝图后面每一步选型才不会慌你手上有个玩具语言要跑就能清楚自己卡在“语义阶段”还是“代码生成阶段”而不是对着 IDE 里一堆报错一头雾水。2. 核心开发工具选型安排好你的“编译栈”2.1 词法与语法分析ANTLR 与手写递归下降怎么挑词法和语法分析的工具我在上一篇已经聊过一轮但它在整个工具链里太关键了值得再补充两个选型决策维度和实操手感。第一选择是 ANTLR。它的优点是语法文件本身像一份语言说明书生成出来的 parser 能自动挂在 visitor 和 listener 上非常容易跟后面的语义层解耦。你想要一个表达式语言几十行 grammar 就能搞定输入解析。我见过很多 DSL 项目就是 ANTLR 一套下来从语法到输出一气呵成效率很高。它还能生成多种语言目标比如 Java 和 JavaScript甚至 Rust 也有绑定这对快速试错特别友好。第二个路线是手写递归下降解析器。这个方案更适合你对错误信息质量有执念的场景。ANTLR 默认的错误恢复比较僵硬源码哪一行写错它往往只会给你一句目瞪口呆的 “missing ‘;’” 然后放弃治疗。手写解析器则可以精确控制走到哪个分支时报什么错比如 “第 12 行表达式里缺了个右括号”这种提示对语言使用者来说非常重要。另外手写解析器在性能上也更可控你不用跟 ANTLR 那层运行时纠缠。我个人的建议是如果只是做原型验证直接用 ANTLR省下的时间全投入语义和运行时设计一旦语言开始有真实用户在写、写错了要能被理解就得考虑重写手写解析器因为错误信息就是语言体验的一部分。有个折中路线也能走先用 ANTLR 把整体逻辑跑通再在后续版本里用手写解析器替换词法语法层接口保持不动这样风险可控。写 ANTLR grammar 的第一个大坑是左递归。比如下面这种写法则会直接报错expr : expr expr | INT ;要是沿用这种写法ANTLR 4 会自动把左递归转成等价的非左递归形式但如果你用其他生成器就得手动消除。更稳的做法是分级定义expr : term ( term)* ; term : factor (* factor)* ; factor : INT | ( expr ) ;这样优先级也天然立起来了乘除绑定在 term 层加减绑定在 expr 层。写类似的语法规则时把优先级表直接映射成语法的嵌套结构是让解析阶段少走弯路的铁律。2.2 中间表示与后端LLVM、自研 IR 还是直接翻译语法树解析完成之后摆在面前的是三叉路口一是直接树遍历解释执行二是设计一套自研字节码跑在 VM 上三是接入 LLVM 生成原生机器码。树遍历解释器是最容易起步的。你把 AST 里的每个节点对应到一个“动作”递归执行一遍就行适合做教学语言、原型验证或者嵌入型脚本。缺点是慢重复计算多而且没法做任何有效的优化。自研字节码加轻量 VM 是第二个档位。很多脚本语言都走这条路先把 AST 编译成紧凑的字节码数组然后在循环里取指令分发执行。这层抽象有一个很大好处就是你可以加入常量池、操作数栈、跳转指令这些结构为未来做 JIT 留出余地。但代价是你得自己写 VM 工具链包括反汇编器、字节码 dump、栈状态打印这些辅助调试工具工作量并不小。再往上走就是 LLVM。这是目前几乎所有想认真做编译后端的语言的共同归宿。原因也很直白你不用自己写寄存器分配、指令选择、调度和平台适配LLVM 帮你把 x86、ARM、RISC-V、WebAssembly 这些后端全都打包好了。甚至在不经意间你会发现自己还免费获得了opt和clang带来的优化管道。操作层面如果语言实现用了 Rust推荐inkwell这个 LLVM 绑定库不想碰 C也不想被 C 的构建折磨用它写 IR 生成最顺手。Python 党可以考虑llvmlite适合快速做原型但坦白讲性能和组织度都一般。一旦选定了 LLVM 版本就锁定它别跟着上游横跳。LLVM 的 API 变动幅度极大升级一次足够把整个后端代码重写两遍这是每个接 LLVM 的人都应该提前知道的事。2.3 运行时实现自己写 GC 还是借用现成方案运行时的设计是决定一门语言体感的核心环节。你语法再漂亮只要内存管理搞得乱七八糟或者每次执行都要卡顿停顿用户是不会买账的。这里的第一条经验是第一版运行时别上来就造一个完整 GC。GC 乍看简单实际写起来涉及对象图遍历、写屏障、并发安全等各种细节能把人熬到秃头。更合理的路径是一开始就用 arena 或引用计数。Arena 的思路是把对象统一塞到一个大内存区域里用完了整块释放实现简单性能也好引用计数则适合处理那些生命周期清晰的临时对象比如字符串和数组。拿一门具体的语言举例Monkey 2 是个开源的编程语言加集成开发环境它的运行时设计就选择了轻量路线没有笨重的后台回收线程很多对象生命周期靠手动控制。这个做法对小型语言特别合适实现简单运行时体积小宿主环境也好嵌入。如果你想给语言接上图形库或者游戏引擎这种轻运行时反而更容易与宿主生态打配合。第二个必须想清楚的问题是宿主边界。你设计的语言很可能要嵌入到某个大型应用里作为脚本层出现这时候就要定义清楚语言运行时跟宿主之间的互操作边界。这种场景跟移动端嵌入 JS 引擎有同样的痛点你要解决的是“语言侧对象如何映射到宿主侧对象”“谁持有生命周期”“如何调用宿主提供的 API”。想清楚这层边界比语言本身的语法设计更能决定它的落地能力。2.4 工程化工具测试、基准与打包发布真实的语言开发中工具链的工程量远超编译器本身。装过 Python 的人都知道没有 pip这门语言根本没法用。所以当你设计语言时至少得规划三块工程化支撑测试、基准和发布。测试方面编译器项目跟普通 Web 项目完全不同。你不太依赖“调用接口后看返回值对不对”更依赖“同一段代码经过不做优化的路径和做优化的路径跑出来结果是否一致”。所以除了最常规的单元测试我强烈推荐上差分测试维护一个慢速但正确的参考解释器再写一个快速或优化的实现然后用大量随机生成的程序同时喂给两个实现对比输出是否一致不一致的地方就是 bug 藏身处。这个招数对后端优化特别值钱稍大一点的编译器项目几乎都在用。基准测试用于性能回归。设计语言时最好尽早确定一组典型程序作为性能基准。这里又得说回“大表计算效率”这个场景真正让一门语言在巨型数据表上算得快的往往不是语法层的那一点点糖而是数据容器是否是紧凑数组、编译器能否把循环自动向量化、能否做内存别名分析。你选后端方案时是不是朝着这个方向走的跑一轮基准就清楚了。发布层面也别小看。你要是希望语言被别人下载就得准备安装脚本、包管理器命令、项目模板和示例代码仓库。现在绝大多数新语言死掉都不是死在语法不好而是死在“用户好不容易编译完了还跑不起来第一个 hello world”。所以尽早把打包发布当作一等工程任务跟写编译器放到同一个优先级上。3. 实操过程带你快速搭出一门“玩具语言”3.1 脚手架搭建与环境准备理论说再多不如跑一遍。下面我带你搭一门叫toylang的玩具语言有整数、加减乘除、变量声明和控制流。实现语言选 Rust这是目前写编译器体验最好的选择之一enum和模式匹配天然适合表达 AST内存安全避免了一堆不理解为什么越界崩溃的问题测试工具还直接内置在标准流程里。先建工程cargo new toylang cd toylang然后把关键依赖加进Cargo.toml。我做早期原型用的词法库是logos它声明式分词比手写状态机舒服语法解析直接手写递归下降不依赖 ANTLR保证错误信息可控。[dependencies] logos 0.13.0 inkwell { version 0.4.0, features [llvm16-0] }这里inkwell的 feature 必须跟你的 LLVM 版本匹配版本不对第一步就编译失败这也是我在 2.2 里强调锁版本的原因。3.2 定义 AST 并写一个树遍历解释器AST 定义是整个语言的骨架。我们的玩具语言需要用两个枚举表达节点表达式和语句。表达式负责计算值语句负责改变环境。#[derive(Debug, Clone)] enum Expr { Num(i64), Var(String), Add(BoxExpr, BoxExpr), Mul(BoxExpr, BoxExpr), } #[derive(Debug, Clone)] enum Stmt { Assign(String, Expr), Print(Expr), }有了 AST第一版解释器很简单对表达式递归求值碰到Add就加两边结果碰到Mul就乘两边结果。变量环境用一个哈希表存着就行。fn eval(expr: Expr, env: mut HashMapString, i64) - i64 { match expr { Expr::Num(n) *n, Expr::Var(name) env[name], Expr::Add(l, r) eval(l, env) eval(r, env), Expr::Mul(l, r) eval(l, env) * eval(r, env), } }这个解释器能跑但效率很一般。不过它最大的价值是有一个“绝对正确”的语义标准后面做 LLVM 后端时任何优化导致输出跟解释器不一致都说明你生成代码时出错了。这种“用参考实现做差分测试”的姿势就是语言实现者最常用的排雷手段。3.3 把解释器升级为 LLVM 后端如果只想做一个玩具树遍历解释器就够了。但想让语言有真实性能编译器后端是不可少的。这里我演示一下用inkwell生成 LLVM IR 的方法。首先构造 module 和 builderlet context inkwell::context::Context::create(); let module context.create_module(toylang); let builder context.create_builder();然后在 module 里声明main函数并在其中生成一个加法表达式let i64_type context.i64_type(); let fn_type i64_type.fn_type([], false); let function module.add_function(main, fn_type, None); let entry context.append_basic_block(function, entry); builder.position_at_end(entry); let lhs builder.build_int_val(i64_type, 2, lhs)?; let rhs builder.build_int_val(i64_type, 3, rhs)?; let sum builder.build_int_add(lhs, rhs, sum)?; builder.build_return(Some(sum))?;上面这段代码生成的 IR 大致长这样define i64 main() { entry: %lhs add i64 2, 3 ret i64 %lhs }从这个例子能看出来LLVM 后端的工作本质是把 AST 节点翻译成 IR 指令。你的 AST 越简单翻译器越好写等后面想做优化你不需要自己发明优化算法只需要确保生成的 IR 足够规范让 LLVM 的优化 pass 能识别出来就行。实操时我强烈建议配合llc和lli工具一起用前者把 IR 转成汇编或目标文件后者直接解释执行 IR都是查后端 bug 的好帮手。3.4 完善体验语法高亮、LSP 与 REPL编译器写完语言体验还只完成一半。哪怕功能再完整如果输入代码时没有高亮、没有补全、没有语法报错提示用户体验都谈不上及格。主流的做法是语言开发者不自己写 IDE而是提供标准化的语言服务器协议LSP实现。VSCode、Neovim 这些编辑器都支持 LSP只要你实现一套 language server编辑器就能拿到代码补全、跳转定义、hover 提示这些能力。语言侧要做的是在文件变更时重新解析一遍源码把符号表和诊断信息推给编辑器。语法高亮则相对简单可以直接写一个 TextMate 语法文件定义关键字、数字、字符串的正则和着色作用域。VSCode 和很多编辑器用的都是这套格式写一个.tmLanguage文件丢进插件目录就能生效。如果觉得辗转到编辑器的体验太繁琐还有一个方案可以参考 Monkey 2 的思路语言自带 IDE。把编译器、编辑器和调试器打包成一个独立应用好处是用户拿到手就能用配置成本为零坏处是你得维护一个完整编辑器的 UI、语法高亮、调试适配器工作量比语言本体还大。我的建议是新手不要自建 IDE先用 LSP 把外部编辑器接入立等可取。4. 常见问题与排查技巧实录4.1 语法反复无法通过解析器左递归和运算符优先级写语法的过程中最常见的问题就是左递归导致栈溢出。比如你写expr : expr term | term ;如果没有自动消除机制解析器会无限递归还没输入几个字符程序就崩了。解决办法就是分级改写把优先级合理地分层嵌套前面 2.1 里已经给过示例这里不展开。第二个高频问题是优先级写反。比如2 3 * 4如果被解析成20就是乘法的优先级被放在了加减法下面。解决方式就是先把“乘法项”term定义出来再把“加法表达式”expr建立在 term 之上。这条规则在任何解析器里都成立。排查这类问题最快的方法是把语法树打印出来看一眼。写一个 debug 函数对 AST 做括号化输出比如2 3 * 4应该输出(2 (3 * 4))一眼就知道优先级对不对。4.2 LLVM 接入时的版本诅咒LLVM 是我在所有工具里踩坑最多的一项。API 在版本间变化剧烈比如某个函数这版还在下版就改了签名或挪了命名空间。编译报错还算友好的最怕的是能编过、行为却变了比如某些类型转换规则更新后你的 JIT 运行时突然多段崩溃。破解之道很简单锁环境、锁版本。项目里写死 LLVM 版本比如llvm16-0然后所有成员都用同一套 CI 镜像别让谁手滑升到 17。升级时也不要做“地毯式升级”先用小范围 IR 生成测试跑一遍确认核心行为没变再逐步放开。另外inkwell的版本号和 LLVM 版本号不是一回事经常出现inkwell 0.4配LLVM 15还是LLVM 16的困惑。这种坑看 Cargo 版本说明不如直接去看 GitHub README 里的 feature 列表那才是权威。4.3 运行时内存问题与调试建议自研运行时最常见的是内存泄漏。尤其是用 arena 管理对象时对象生命周期远超预期本该释放的 arena 却一直被某根引用锚住结果内存涨个不停。这种问题排查起来极其痛苦因为程序不报错只是静默地吃内存。我的经验是运行时里一定要内置对象图 dump 能力把当前所有存活对象及其引用关系导出来做成可视化文本。一旦内存异常立刻打印对象图看看有没有不该存在的节点被意外引用。这个调试功夫看起来土但对小型语言项目来说比上 valgrind 和 gdb 都更直接有效。如果走引用计数路线还得防备循环引用。两个对象互相持有对方引用计数就永远无法归零等于白白泄漏。解决方式是标记清楚哪些引用是“弱引用”不参与计数这是很多脚本语言在实现容器、事件绑定场景时的常见套路。4.4 别把工具链当信仰何时该停下来最后想给一个有点反直觉的建议工具链用得越多项目死得越快。很多语言项目刚开始时热情高涨ANTLR、LLVM、LSP、Docker、CI、benchmark 框架全上结果写了三个月解析器都没跑通。做语言设计更像做菜工具只是一个锅具真正的核心是你对语法、语义和场景的理解。所以我强烈建议按“能跑为先”的顺序推进先解释器把语言跑通再补字节码再考虑 LLVM 后端最后才优化工具链细节。每一步都配一个小里程碑比如“支持表达式”“支持变量”“支持函数调用”“支持 if”跑过一个里程碑再进下一站。下面把一阶段最常见的问题和方案收进一张表方便随时翻症状可能原因排解方法程序运行后栈溢出语法规则存在左递归分级语法消除左递归乘法优先级错误表达式层级定义错误打印 AST 括号化检查优先级LLVM 版本编译失败feature 与安装版不匹配锁定 LLVM 版本按 feature 安装JIT 结果不稳定LLVM API 行为变动先跑小范围 IR 测试再推进内存持续增长arena 对象生命周期异常内置对象图 dump打印引用关系引用计数无法归零循环引用未处理区分强引用和弱引用收个尾语言设计真正的门槛不在工具做语言设计的人常遇到一个幻觉以为写得出解析器就是“会设计编程语言”了。实际做过几个玩具语言之后我最大的体会是工具链背后真正难的是取舍——哪些语义简化掉哪些特性为场景保留错误信息写到什么详细程度性能优化优先覆盖哪条路径。这些都要求你既懂用户又懂编译原理是纯粹的工程判断力。再分享一个我自己的小习惯每次启动一门新语言我都会先在纸上用十行话写下这门语言最核心的五个语法示例再拿起编译器工具链一步步跑通它们。这个过程看起来简单但能逼你把模糊的“我想设计一门语言”变成具体的“这门语言能跑出什么”。工具链永远只是手段让这些示例跑通才是一门语言真正的起点。