
如果你正在问“如何设计一门编程语言需要哪些开发工具”我猜你多半刚看完某本编译原理教材或者在某个社区被一门小语言的创作过程点燃想自己动手试试。这时候你最容易犯的错误就是把“工具”理解成 Flex、Bison、LLVM 这一整套东西——觉得只要把它们装上语言就能自己长出来。实际情况恰恰相反我见过不少项目工具链齐全最后却因为语言本身没想清楚或者前端和后端的节奏没搭好永远停在“能打开 IDE”的阶段。这篇文章我不打算给你列一张大而全的工具清单而是按从零设计一门语言的真实顺序拆开讲讲每个阶段到底需要什么工具、为什么需要以及哪些工具其实可以暂时不碰。适合的人群也很明确想从 0 写一门脚本语言、DSL或者只是想把编译原理落地的人。我的建议会比较偏向实用主义——能用最小工具跑通验证的项目就没必要一开始就上重型后端。1. 先别急着装工具语言规范比编译器生成器更重要1.1 第一份文档是你自己的语言手册如果你脑子里的第一步是写词法规则先停一下。真正地设计一门编程语言第一步是在 Markdown 或纯文本里写一份“语言手册草案”这本手册不用追求 ISO 标准那么完整但必须能回答一个问题一段代码在我这门语言里按什么顺序、用什么规则、最终得到什么结果。我当时给一个内部报表系统设计 DSL 时第一版规范大概只有二十多行。我先写了几个目标用法示例再反过来总结语法规则而不是先定义词法。比如说我希望用户能这样写rule 毛利计算 { if category 营业收入 and type 结转 then amount amount * -1 } }接下来我才会在规范里写rule关键字、字符串字面量、花括号块、if 表达式、赋值语句分别是什么含义。这个顺序很重要先有例子再有语法先有语法再有实现。因为等你写词法分析器、语法分析器的时候任何“这段代码到底该不该合法”的疑问答案都应该回到规范里查而不是临时拍脑袋。这份规范文件本身就是最重要的“开发工具”之一。要用版本控制工具管起来每一次语法改动都先改文档再改代码。别小看这一步它能帮你省下大量“为什么解析器突然不认昨天写的代码”的排查时间。规范还可以配一些用于做回归测试的.dsl示例文件这些示例文件会成为后续所有测试的基础。1.2 语法和语义要分开推进很多人设计语言时只顾着语法长什么样等写代码生成器才发现语义一塌糊涂。语法解决的是“文本怎么被接受”语义解决的是“接受之后它是什么意思”。语义设计至少包括这几个方面作用域规则变量在哪一段代码里可见、求值顺序从左到右还是从右到左是否有短路操作、类型系统要不要类型标注如果没有类型标注运行时如何判断类型、错误处理机制异常回传还是错误码返回。这些建议用自然语言先写下来尤其是作用域规则它往往决定你要不要为解析器和分析器增加额外的逻辑。这部分用到的工具特别简单一个顺手文本编辑器再加一份.md文档。你可以用 Markdown 的列表和表格来组织但我个人不建议用太重的东西。花哨的文档工具会让人把精力放在排版上忘了真正要打磨的是语言本身。等语言设计到一定阶段你可以用工具生成一版相对稳定的语法参考页但那是后话。1.3 在语法稳定之前不要把自己焊死在某个解析器生成器上这里有个非常容易踩的坑你选了一个 Yacc/Bison 之类的 LALR 解析器生成器然后为了迁就工具的冲突检测机制去修改语言的语法结构让语法变得别扭。反过来选 ANTLR4 有时候又会因为它的 LL(*) 能力让人产生“一切复杂语法都没问题”的错觉。所以我对新手的建议是规范草案先于解析工具甚至可以先用手写递归下降解析器验证语法是不是好用。手写解析器改起来快你可以随时调整优先级和结合性不需要重新生成代码。等你确认了语言的实际感觉再决定要不要换成生成器来提升开发效率。2. 词法与语法分析解析前端选型决定你能走多远2.1 词法分析手写扫描器通常就是第一个选择词法分析器lexer负责把源码字符串切成 token看起来好像必须用工具其实手写通常比用生成器更合适。原因很简单大部分语言的 token 结构并不复杂一个带peek和advance的字符循环加上几组判断函数就够了。手写的好处是你可以完全控制状态识别字符串字面量时遇到换行要怎么处理嵌套注释要不要支持数字后面跟着字母算不算合法 token这些都是在手写扫描器里最容易表达清楚的“人文关怀”。如果你是在 C 或 C 这类偏底层的宿主语言里做项目性能又敏感那可以看看 re2c 或 Ragel。这类工具能把词法状态机直接生成到 C 代码里性能非常好。Flex 到现在也还在但它的 C 输出风格比较老状态管理也僵硬。现代很多新语言项目都选择手写扫描器连一些靠性能吃饭的解释器也是这样做的说明手写并没有想象中那么费劲。词法阶段最重要的设计决定是 token 是否携带位置信息。我强烈建议从一开始就让每个 token 记录行号、列号和字节偏移token { type: IDENT text: amount line: 3 col: 12 }很多初学者图省事只存type和text结果写解释器时遇到错误想报出“第 3 行有问题”都没有数据可用只能靠猜。位置信息在后续的 LSP语言服务器协议里也是必需品这个昂贵的信息越早带着越好。2.2 语法分析器递归下降、Bison、ANTLR 怎么选到了语法分析parser选型就分成两派了手写解析器和工具生成解析器。这里没有绝对答案我按实际场景帮你拆开看。手写递归下降解析器是最值得优先考虑的方式。它不依赖外部代码生成器代码完全由你控制出错信息和语法错误恢复也能做得非常自然。缺点是需要写的代码量比较大尤其是表达式优先级处理容易把人绕晕。解决办法是用“优先级爬升”precedence climbing或者 Pratt 解析来处理二元表达式这部分代码写一次就能一直复用并不复杂def parse_expr(min_prec0): left self.parse_prefix() # 处理数字、变量、括号等 while self.current().is_binary_op(): op self.current() prec op.precedence() if prec min_prec: break self.advance() right self.parse_expr(prec if op.is_left_assoc() else prec 1) left BinaryExpr(op, left, right) return leftYacc 和 Bison 则比较适合这种场景语法已经非常稳定团队里也有懂 LALR 的老手同时你希望用一个.y文件来集中表达语法规则。但我得提醒一句LALR 的冲突提示对新人极不友好reduce/reduce 冲突会让你进入“盯着终端输出怀疑人生”的状态。如果你不是在做 SQL 引擎或者老牌编译器我不建议从这个开始。ANTLR4 的优势在于能生成多语言目标解析器无论你的主语言是 Java、Go 还是 Python都能拿到一套可用的 parser。它处理常见语言的语法非常轻松还自带图形化语法分析树工具。但它生成的 AST 不一定完全贴合你的语义设计很多时候你还得再做一次 AST 转换。对于想要精确控制语义分析的新语言项目它的抽象会挡住一些路。2.3 解析器之外AST 设计才真正影响开发效率解析器把源码变成一棵语法树但这棵树长成什么样直接决定你接下来写解释器和编译器时舒服不舒服。我见过有人用非常细的解析节点比如把a b c拆成一层右嵌套的 chain之后在解释器里为了“提取两个操作数”就得先解包三层。这种痛苦是纯自找的。我的建议是 AST 节点应该更简化和语义化。下一步要执行的是 if、while、赋值、函数调用就不必保留所有标点和括号的位置细节虽然还是要保留它们的源位置。同时要设计一个统一的node.dump()方法或者 debug 打印函数在遇到运行结果不对时第一步就是把 AST 打出来看看到底是语法理解错还是执行错。这个 dump 工具听起来不起眼但在整个开发期它就是你最重要的调试工具没有之一。3. 中间表示与后端执行从 AST 到真正能跑的程序3.1 语义分析和中间表示是后端的前提很多人把“后端”直接理解成生成机器码其实在 AST 和机器码之间还有关键的一层语义分析。你需要遍历 AST建立符号表把变量声明和引用对应起来确定作用域检查类型是否匹配。这些工作不借助特别复杂的工具但你一定要想清楚是分成多遍遍历还是一次遍历完成。我建议至少分成两遍第一遍收集全局的声明和类型信息第二遍做表达式和语句的检查。这样你在处理“先使用后定义”的代码时不会头疼也不必为了顺序问题来回折腾符号表。符号表的实现可以简单到用哈希表加作用域栈但作用域进入和退出时 snapshot 和 restore 的接口要提前设计否则每天都可能在作用域 bug 上浪费时间。在这之后才是中间表示IR。IR 决定了你如何执行语言。这是一个非常关键的架构取舍我下一节展开。3.2 解释器路线从 AST 直接解释到字节码虚拟机如果你只是想快速跑通一门脚本语言先写一个最直接的 AST 解释器最划算。AST 解释器的本质就是一个大的递归求值函数遇到IfExpr就递归求值条件根据真值选择某个分支继续求值。它的优点是开发速度快、定位问题容易缺点也非常明显递归调用深、每个节点都带类型分发开销、内存布局不连续。如果你的语言定位是简单 DSL、教学语言这个阶段完全可以用很长时间。当你希望性能再上一个台阶时再做字节码虚拟机VM。字节码 VM 把 AST 编译成一组紧凑指令比如LOAD_CONST、ADD、STORE_VAR、JUMP_IF_FALSE然后用一个循环逐个执行指令。CPython 和 Lua 都是这种路线。优点是执行逻辑统一、内存访问模式稳定、后续还能加 JIT缺点是需要额外实现一个编译器把 AST 变平还要管理操作数栈的边界和执行帧。从 AST 解释器升级到字节码 VM是一个比较大的重构。所以要不要走这条路取决于你的语言是否真的追求性能。我的经验是别一开始就冲击字节码先拿 AST 解释器验证语言设计等语言稳定了再决定要不要优化也不迟。3.3 编译为机器码LLVM、QBE 与生成 C 代码的取舍如果从一开始你就确定要编译成原生机器码那工具选型基本就是这几条路。LLVM 是目前最成熟最完整的选择。它提供经过充分优化的中间表示和大量后端指令生成能力社区资料也多著名的 Kaleidoscope 教程几乎就是编程语言入门 LLVM 的必读。代价是 LLVM 的 API 面非常广、构建依赖较重、Debug 信息生成也有点复杂。对这门语言的总体把握不足时把心力全部耗在 LLVM binding 上容易让语言本身停滞不前。QBE 是一个更轻量级的编译器后端它没有 LLVM 那么大但支持一套简单的中层 IR可以直接生成针对 X86_64 和 AArch64 的汇编指令。如果你不想背 LLVM 的重包袱又想体验“编译到原生代码”的快感QBE 是个被低估的选择。它文档不算丰富这需要你能自己翻代码和实验。还有一个极其实用的办法先让你的语言生成 C 代码再用系统里的 GCC 或 Clang 去编译。这条路调试起来很直观因为 C 本身就是一门高度可读的“汇编之上的语言”。你只需要负责语法转换和语义正确就行了。很多初创语言在早期都靠这个验证设计。缺点是你得处理 C 语言当中一些不太好映射的部分比如闭包、垃圾回收。我建议把“生成 C 代码”作为快速验证编译器设计的方式跑通之后再看要不要换 LLVM。# 一个可能的三地址码中间表示示例 t0 load_var x t1 load_var y t2 add t0 t1 store_var z t23.4 不要过早追求“生成哪一种机器码”我特别想强调一个观点性能优化是最后阶段的事。编程语言设计最先要做的是让你的语义跑得通让程序能运行让用户能基于你的语言写代码。等语言确实有人用了、性能问题真实出现了再回头补优化也不迟。过早引入 JIT、太激进地设计寄存器分配模型通常只会让你在拼图上花掉大量精力最后却没有一门能落地的语言。如果你还是要看 JIT有几个选择是靠谱的基于 LLVM 的 ORC JIT、基于 Cranelift 的 JIT、或者你自己维护一套很少量的汇编生成方案。所有这些都是在 IR 稳定之后才该考虑的事阶段顺序千万不能反。4. 语言不是写完解析器就结束REPL、LSP、调试和测试才决定开发效率4.1 REPL 越早做越好一个早期就存在的 REPL交互式命令行会让你的开发效率翻倍。你不需要每次都用一个完整的.程序文件去测试而是在终端里敲两行表达式立刻看到输出或报错。实现一个最小的 REPL 其实只比主程序循环多几行代码读一行输入交给词法分析器和语法分析器走解释器求值输出结果。我建议 REPL 要做进“错误不导致整个进程崩溃”的机制。所有的解析错误和运行时错误都应该用某种异常或错误返回值捕获然后打印可读信息。否则 REPL 一跑就退出你根本没法连续调试。判断 REPL 做得够不够好有个标准当你看到某个行为不符合预期时能不能在三秒钟内在 REPL 里复现同一个式子并得到原因。做不到的话说明错误信息的详细程度还不够。4.2 LSP 是让新语言从“可运行”走向“可用”的关键一门语言如果只能在命令行里跑普通人不会接受它。它需要有代码高亮、错误提示、跳转定义这些现代编辑器功能而这些几乎都可以通过 LSPLanguage Server Protocol来统一实现。好消息是LSP 的实现成本没有想象中那么高。当你的解析器和分析器已经把 AST 和符号表建好之后LSP 服务其实就是在响应编辑器的请求。最基础的两个请求可以优先做textDocument/diagnostic返回语法和语义诊断textDocument/definition返回符号定义位置。这里最关键的开发工具其实是语言服务的宿主语言库。比如你用 Rust 写语言本体可以用tower-lsp用 Python 也可以用 LSP 相关的框架用 Go 也有现成库。做 LSP 时一定要把“源码位置”数据管理好。没有准确的行列偏移跳转和诊断都写不出来。这也是我在前面反复强调 token 必须带位置信息的原因。4.3 调试先学会 dump AST 和 IR再谈 gdb 和 lldb调试语言解释器的第一个工具不是符号化调试器而是打印。我强烈建议在最开始就给你的 AST 节点和指令序列写漂亮的 dump 函数输出结构化的树形文本和带缩进的指令表。这样遇到解释器错误时你先通过--dump-ast和--dump-ir检查解析结果再决定要看执行逻辑。这两个开关式参数比我用任何调试器都解决更多问题。当你进入原生编译阶段后要想用 gdb 或 lldb 调试生成出来的本机机器码就需要生成对应的调试信息了。用 LLVM 的话需要把 Debug Info 附在 IR 上如果走 QBE 或 C 代码生成路线QBE 的调试支持不太完善生成 C 代码后的调试体验反而好因为你可以带着-g去编译 C 文件。到这一步我的建议还是先追求“可读的调试路径”再追求性能匹配。4.4 自动化测试是语言项目的氧气写一门语言的测试方式和普通业务项目不太一样。最有效的不是每个函数写一堆断言而是准备一批样例文件每个样例是一段源码配上对应的期望输出、期望 AST、或者期望的错误信息。然后用一套测试 runner 批量执行对比实际输出与期望文件。这套东西实际就是 golden test 的变体。我见过一些初学者把测试做成“硬编码在 Python/Rust 测试函数里”改语法时维护成本极高。反过来用纯文本样例文件和稳定的测试 harness你可以在几分钟内把几十个正例和反例全部跑完。每当你修了一个解析器 bug就把当时的出错代码存成回归测试样例防止后面再炸。如果没有这套东西语言项目的重构会很可怕因为任何一个语法调整都可能绷断一堆地方。5. 从零到能跑一门最小脚本语言的三天原型路线5.1 宿主语言与项目骨架怎么选用自己的语言实现另一个语言听起来像是脑筋急转弯但实际开发时你总得有一个“宿主语言”。我的建议是分场景来选如果你只是想快速验证想法用 Python 最舒服。Python 本身的正则、字典、元组能把词法和 AST 实现的样板代码压到很低。但 Python 的性能上限低后续做字节码 VM 或 JIT 时会想换语言。如果你更看重工程化能力和长期演进Rust 和 Go 都比 Python 合适。Rust 的 enum 和模式匹配在表达 AST 节点时优雅到让人感动缺点是借用检查会给语言本身的循环引用和符号表设计制造一点小摩擦。Go 的语法简单、垃圾回收开箱即用写解析器和解释器非常快但 AST 表达的灵活度不如 Rust 的 enum。我个人的路线是先用 Python 快速验证语法语言稳定后有用 Rust 重写前端的打算。这个“先开发后重写”的顺序不一定每个项目都要走但对你验证“设计一门语言”的兴奋感会友好得多。5.2 设计一门最小语言的输入输出为了把这条路线讲具体我们设想一个极简语言 MiniLang。它只包含整数、布尔值、变量、分支、循环、函数和打印功能。它的基础语法可以用下面这个简化的 EBNF 描述program : stmt* stmt : let ident expr | print expr | if expr block | while expr block | return expr? block : { stmt* } expr : number | ident | expr ( | - | * | / | | ) expr三天里你只需要把这几条规则变成真实运行的程序。如果你想让语言更个性一点可以把语法替换成“人类很容易读”的 DSL 风格但本质都是一样的。5.3 三天的推进节奏第一天把目标定成规范草案 词法分析器 递归下降解析器 AST dump。你最终应该能在命令行里输入一段 MiniLang 源码打印出完整的 AST 树。这时候还不急着执行。测试方式就是多个.mini文件对照 AST dump 结果。如果你自己都看不懂 AST dump那解析器的结构设计就有问题。第二天补上树遍历解释器。每遇到一个 AST 节点就按照语义求值。同时维护一个简单的变量符号表。此时 REPL 应该已经能用了输入let x 3 4输入print x 1输出 8。这个阶段最值得投入的精力是让错误信息可读比如报告“第 2 行第 5 列未定义变量 x”而不是一长串内部栈。第三天做两件有意义的事第一补上函数和最简单的作用域规则第二把测试样例正式归档成回归测试集。此时语言虽然很简陋但它已经具备“一门语言”的整个环节从源码到词法、到语法、到语义、到执行。之后你爱上哪个模块就可以在哪里慢慢加深比如把解释器换成字节码 VM或者接上 LSP。5.4 原型期最容易翻车的几个坑先说解析器的递归深度。手写递归下降在解析非常深嵌套的表达式时可能会把一个线程的调用栈打爆。简单语言可能不需要考虑这点但如果你设计语言时允许用户写出大型括号嵌套建议在解析函数里维护一个显示深度计数超过阈值后直接给出“表达式嵌套过深”的错误。这个坑只在写极端样例时出现但会出现得极让人痛苦。然后是错误恢复。初学者写的解析器往往“遇到第一个错误就崩溃”。可一个可用的语言解析器最好能跳过一小段输入继续找下一个错误把多个问题一次性报告出来。虽然错误恢复会让解析器代码变复杂但这个投资很值得会让你的语言工具链体验大大提升。还有字符串和转义。不要小看字符串解析很多人实现到\n和\时才意识到需要同时处理转义解析和原样字符串的支持。如果你是设计带嵌套双引号的字符串字面量这一步更要小心。建议在测试集里专门准备一组“边界字符”样例。5.5 把最小原型当设计跳板三天之后的 MiniLang 虽然是玩具但它对你后续的意义非常实在。你想加闭包就在这里加你想做类型推断就在这里扩展你想体验字节码 VM就把它作为重构的起点。比“直接读一份大型语言代码库”好得多因为每一行代码都是你自己写的你知道这颗石头的每一条纹理为什么存在。6. 最后聊几句工具之外的东西6.1 语言设计是需求倒逼出来的不是纯自嗨我一开始以为设计语言最需要的是工具到后面才发现最需要的是清晰的问题你到底想解决什么什么场景下现有语言让你不舒服。一套从需求里长出来的语言工具链自然有边界反之一门“想到什么加什么”的语言工具链也会变成无底洞因为你给自己铺了一条永远改不完的语法高速路。我自己最有感触的一次是给一个连续的批处理流程做配置 DSL。一开始我总想设计成类似 Python 的通用语言后来发现真正使用它的人只关心三步定义输入、声明规则、输出结果。当我放弃不必要的通用性把语法收敛到那三件事之后解析器和运行时一下子瘦了一大圈。能砍掉功能其实就是足够的工具。6.2 给真正想动手的人一个妥协方案如果你看完这些还是觉得无从下手那我建议你绕开设计一门独立语言的起点先挑一个足够小、足够常用的场景来做。比如做一个计算单位换算脚本做一个报表过滤表达式引擎或者做一个有限状态机描述语言。为了能跑通它你需要经历的每一个环节都和设计大型编程语言一致但范围小到你在一个周末内完成。工具上只用一个文本编辑器、一门宿主语言、一个测试文件夹就足够了。真正设计和实现过第一次完整闭环之后你再回头看 LLVM、LSP、Debug Info 这些东西就不会再被它们吓住因为你已经知道它们只是这条路线上某一段的补给站而已。先动手把最无关紧要的那一小段路径走通比什么都重要。