从零写一门小语言
如果你刚学完 C++ 基础,已经知道怎样写函数、定义类和使用指针,也许仍然有一个疑问:写下的代码究竟是怎样运行起来的?这套课程就是为这个问题准备的。
我们不依赖第三方库,用 C++ 实现一门很小的 Lisp 风格语言。它既有解释器,也有编译器:解释器直接计算程序的结果,编译器则把同一个程序一步一步转换成 x86-64 汇编。
第一章的语言只支持整数和加法。面对下面这个程序:
|
|
我们会完整走通两条路径:
|
|
你以后也许并不会亲手开发编译器,这完全没有关系。编译器是这套课程的训练载体,而不是学习的终点。它的输入清楚、结果可见,中间每一步都可以观察,恰好能把许多重要的编程能力集中在一个完整项目里。
真正值得带走的,是下面这些可以迁移到其他项目中的能力:
- 把问题建模成数据。 源代码要变成 token 和 AST,配置文件、网络协议、查询语句和业务规则同样要先变成程序能够处理的结构。你会练习怎样选择数据结构,并把规则和约束明确地写进程序。
- 把复杂过程拆成阶段。 词法分析、语法解析、解释器、IR 和汇编生成各自只解决一部分问题。这种分层方法同样适用于数据处理流水线、后端业务流程和开发工具:每一层都有清楚的输入、输出与责任。
- 理解状态、身份和所有权。 变量绑定、闭包捕获、堆对象和可变状态会让我们分清名字、位置、值与生命周期。这些问题也存在于回调、缓存、界面状态和游戏对象中,是写好 C++ 绕不开的基本功。
- 建立逐层验证的调试习惯。 当结果不对时,不再只盯着最终输出猜原因,而是依次检查 token、AST、IR 和汇编,找到最早出现偏差的地方。这种利用中间结果缩小范围的方法,也适用于其他复杂程序。
- 看懂抽象下面发生了什么。 理解栈、寄存器、调用约定、堆和垃圾回收之后,链接错误、生命周期问题、运行时错误和性能问题就不再完全是黑盒。即使日常使用高级框架,你也更容易判断问题发生在哪一层。
课程采用增量式的组织方式。每一章集中解决一个核心难点,同时保留一份可以构建、运行和修改的完整代码。你会亲手实现词法分析、语法解析、AST、解释器、IR 和汇编生成,再随着语言加入变量、条件、函数、递归、记录、闭包和可变状态,继续进入堆对象、垃圾回收与寄存器分配。完成十章主线以后,还可以通过第十一章的无栈协程选修篇,把闭包、状态、控制流和 GC 串成一个 generator。你不必等到课程结束才看到所有模块拼起来;从第一章开始,每一步都可以亲手验证。
课程结束时,你得到的不只是一些零散示例,而是一门由自己逐步实现的语言,以及一套可以带到其他项目中的建模、拆解和调试方法。
你不需要学过编译原理,也不需要提前掌握汇编。学过 C++ 的基础语法,就可以开始。课程代码以 C++17 为基线,默认运行环境为 Linux、macOS 或 WSL;具体工具和运行方式会在第 0 章说明。
代码仓库:build-mini-lang
现在,让我们从一个最小的程序开始,看看它究竟是怎样运行起来的。
章节目录
- 第 0 章 工具与运行方式
- 0.0 路线图
- 0.1 运行代码
- 0.2 命令是怎么进入代码的
- 0.3 make:只在需要时重新构建
- 第 1 章 数字与加法
- 1.0 合抱之木,生于毫末
- 1.1 从两个表达式开始:整数和加法
- 1.2 AST:从字符串到 C++ 可操作的结构
- 1.3 Lexer:把源码切成 token
- 1.4 Parser:把 token 组成 AST
- 1.5 解释器:沿着 AST 直接算结果
- 1.6 IR:编译器的草稿步骤
- 1.7 汇编基础:寄存器、栈槽和返回值
- 1.8 compile:把 IR 翻译成汇编
- 1.9 本章总结
- 1.10 练习
- 第 2 章 变量与 let
- 2.0 无名,天地之始;有名,万物之母
- 2.1 从名字开始:变量和 `let`
- 2.2 AST:保存变量和绑定结构
- 2.3 Parser:读出 identifier 和 let
- 2.4 解释器:环境与变量查找
- 2.5 IR:把用户名字变成内部名字
- 2.6 汇编:为 copy 增加翻译规则
- 2.7 本章总结
- 2.8 练习
- 第 3 章 条件表达式
- 3.0 有无相生,前后相随
- 3.1 从顺序计算到条件选择
- 3.2 AST:保存条件与两个分支
- 3.3 Parser:读出谓词和 if
- 3.4 解释器:先判断,再选择
- 3.5 IR:把一条直线变成几条可选择的路
- 3.6 IR lowering:把 AST 变成分支和汇合
- 3.7 汇编:让 CPU 真的选择道路
- 3.8 本章总结
- 3.9 练习
- 第 4 章 函数值与调用
- 4.0 函数成为值
- 4.1 语言:创建函数,再调用函数
- 4.2 AST:保存函数和调用的结构
- 4.3 Parser:特殊形式之外就是调用
- 4.4 解释器:函数值和一次新的求值环境
- 4.5 IR:把函数体和函数值分开
- 4.6 栈帧和调用约定:让两个函数完成交接
- 4.7 汇编:代码地址和间接调用
- 4.8 本章总结
- 4.9 练习
- 4.10 可选收尾:从 mini 调用 C++ runtime
- 第 5 章 递归函数
- 5.0 函数怎样调用自己
- 5.1 语言:给函数一个能看见自己的名字
- 5.2 AST:结构仍是一棵树,递归来自名字
- 5.3 Parser:在括号 head 位置识别 `sub1` 和 `letrec`
- 5.4 解释器:每次调用都把函数自己带进去
- 5.5 IR:两个函数值临时指向同一个入口
- 5.6 汇编:调用向下展开,结果逐层返回
- 5.7 边界:会调用自己,还不是闭包
- 5.8 本章总结与验收清单
- 5.9 练习
- 第 6 章 record
- 6.0 record 与堆上的数据
- 6.1 语言:一组值,也可以成为一个值
- 6.2 值表示:一个机器字怎样引用不同的堆对象
- 6.3 AST 与 parser:拥有数量不定的字段子树
- 6.4 解释器:用共享对象保存一组 Value
- 6.5 结构化 IR:保留一组有序字段
- 6.6 汇编:分配可变大小对象,并在读取前检查
- 6.7 边界:这套 record 提供什么,不提供什么
- 6.8 小结:从一个 Value 到一张对象图
- 6.9 练习:把对象布局真正画出来
- 第 7 章 闭包
- 7.0 让函数带着环境离开
- 7.1 语言:名字由定义位置决定
- 7.2 自由变量:只带走以后真正需要的名字
- 7.3 解释器:把自由变量放进函数值
- 7.4 结构化 IR:把代码标签和捕获值配成一对
- 7.5 汇编:在通用堆对象里放入代码和环境
- 7.6 闭包:从一行源码走到一次间接调用
- 7.7 边界与验收:第七章真正承诺什么
- 7.8 常见错误与练习
- 第 8 章 变量
- 8.0 让名字指向可以改变的位置
- 8.1 语言:修改绑定,但不改变词法作用域
- 8.2 AST、parser 与自由变量:写入目标也是一次变量使用
- 8.3 解释器:Environment 找位置,Store 保存值
- 8.4 结构化 IR:把值和位置分开
- 8.5 汇编:把位置落成 cell 对象
- 8.6 边界与验收:第八章真正承诺什么
- 8.7 常见错误与练习
- 第 9 章 垃圾回收
- 9.0 垃圾回收
- 9.1 对象图:从“还拿得到”定义活对象
- 9.2 分配器:在两个半区之间轮换
- 9.3 roots:用独立栈保存 GC 的起点
- 9.4 forwarding 与 Cheney:搬一次,更新每一条边
- 9.5 编译器变化:让活值拥有可更新的 root home
- 9.6 C++ runtime:让汇编只负责交接
- 9.7 本章完成后的能力与验收
- 9.8 常见错误与练习
- 第 10 章 寄存器分配
- 10.0 寄存器分配
- 10.1 本章边界:不改语言,只改变值的家
- 10.2 统一 pseudo 指令:分配器真正看到什么
- 10.3 活跃性:这个值以后还会不会用
- 10.4 冲突图与染色:谁不能共用寄存器
- 10.5 三种 home:寄存器、普通 spill 与 GC root
- 10.6 用 `alloc` 看见整个分配过程
- 10.7 本章实现要求
- 10.8 常见错误与练习
- 第 11 章 协程
- 11.0 把“下一步”做成一个值
- 11.1 语言:一次 `resume` 只走到下一个边界
- 11.2 边界:哪些位置可以 yield
- 11.3 AST 与 parser:先保留表面语法,再统一去糖
- 11.4 Lowering:把暂停后的计算装进 closure
- 11.5 解释器:执行 core,而不是认识协程
- 11.6 IR 与后端:没有 coroutine 操作
- 11.7 语义边界:这不是通用协程系统
- 11.8 验收:120 个程序走同一条流水线
- 11.9 常见错误、练习与全书收束