“设计一门编程语言”这事,我在上一篇里聊过从零起步时的词汇和语法工具怎么选,算是把“前两公里”的装备过了一遍。但每次发完,评论区总有同一个声音追上来:语法树搭好之后呢?后面那些代码生成、运行时、调试器、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(Box<Expr>, Box<Expr>), Mul(Box<Expr>, Box<Expr>), } #[derive(Debug, Clone)] enum Stmt { Assign(String, Expr), Print(Expr), }有了 AST,第一版解释器很简单:对表达式递归求值,碰到Add就加两边结果,碰到Mul就乘两边结果。变量环境用一个哈希表存着就行。
fn eval(expr: &Expr, env: &mut HashMap<String, 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 和 builder:
let 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,打印引用关系 |
| 引用计数无法归零 | 循环引用未处理 | 区分强引用和弱引用 |
收个尾:语言设计真正的门槛不在工具
做语言设计的人常遇到一个幻觉,以为写得出解析器就是“会设计编程语言”了。实际做过几个玩具语言之后,我最大的体会是:工具链背后真正难的是取舍——哪些语义简化掉,哪些特性为场景保留,错误信息写到什么详细程度,性能优化优先覆盖哪条路径。这些都要求你既懂用户又懂编译原理,是纯粹的工程判断力。
再分享一个我自己的小习惯:每次启动一门新语言,我都会先在纸上用十行话写下这门语言最核心的五个语法示例,再拿起编译器工具链一步步跑通它们。这个过程看起来简单,但能逼你把模糊的“我想设计一门语言”变成具体的“这门语言能跑出什么”。工具链永远只是手段,让这些示例跑通,才是一门语言真正的起点。