冲泡 Cappuccino:在没有 LLVM IR 的情况下编写编译器
Source: Dev.to
Introduction
编译器一直让我觉得像魔法。它们既复杂又简单——只是把代码从一种形式转换为另一种形式的程序。我花了无数小时研究 clang 生成的汇编,观察 C 是如何被转化为机器码的。于是我产生了好奇,想知道 如何 编写一个编译器。
So how did I do it?
我做的第一件事是 Google “how to make my own programming language”,这把我引到了这篇文章。它提供了编译流水线的粗略概念,但作者实现的是一个把代码转译成 C++ 的转译器。我对这种做法并不满意。
在翻阅了更多网站、文章和 GitHub 仓库(那时 ChatGPT 还不流行)后,我提炼出了任何编译器的核心组件:
- Tokenizer – 读取源文件并生成 tokens,即最小的有意义词素。
- Abstract Syntax Tree (AST) – 程序结构的树形表示。
- Parser – 将 token 流转换为 AST。
常见的捷径是转译到 LLVM 的 IR,然后让 LLVM 生成二进制。我不喜欢这种方式,因为它把最有趣的部分——自己生成汇编——抽象掉了。虽然 LLVM 的 IR 很强大,但它把语言绑定到特定平台,对于“无外部依赖”的项目来说感觉像是作弊。
既然我已经对汇编比较熟悉,我决定自己动手实现后端。
The part where it got messy
我很快发现 tokenizer、parser 和 AST 都需要一个坚实且一致的结构;否则整个系统会崩溃。
我最初的 AST 设计大致参考了这篇文章。它在小例子上还能工作,但一旦我尝试生成汇编,代码就变得难以维护。我选择实现的语言是 C。我热爱 C,也写过不少 C 项目(例如anishell),但在 C 中处理动态数组和垃圾回收非常繁琐。经过反复重写 AST 并为每种节点类型创建结构体后,我最终改用 C++——这让人如释重负。
有了 C++ 后,我研究了正式的解析技术,最终决定使用 recursive‑descent parser,它提供了超出简单计算器示例的灵活性。
Actually Generating the Assembly
从概念上讲,生成汇编很直接:使用栈机器模型——从栈中弹出操作数,计算结果,再把结果压回栈。真正的挑战在于那些潜伏的细小 bug。变量顺序的一个不匹配就可能导致整个程序灾难性失败。
当我的 C 实现一直报错时,我卡住了,于是暂停了项目。后来改用 C++ 并借助 AI(Gemini)进行调试,我终于把问题逐一解决。随后我加入了一个小型标准库,并最终为项目取名:Cappuccino。
What did I learn?
- 我还有很多东西需要学习,应该在投入多年时间与糟糕设计搏斗之前先做足研究。
- 项目名 Cappuccino 的灵感来源于 Java 的咖啡主题命名,以及我个人对咖啡的热爱。
完整源码已在 GitHub 上公开:https://github.com/AnirudhMathur12/cappuccino。
感谢阅读我的第一篇博客。如果你觉得有趣,请考虑给仓库点个星。