C 编译器制作入门

前言

前言

这本在线书仍在撰写中,尚未完成。反馈表单

这本书的目标相当明确:带你从零实现一个 C 编译器。它接收 C 源代码,输出汇编代码;而编译器本身也用 C 编写。近期目标是完成自举,也就是让我们亲手写出的编译器,最终能编译它自己的源代码。

为了避免一上来就陷入过深的理论,本书不按传统教材那样把“词法分析、语法分析、代码生成”分章讲完再动手,而是采用增量式路线:先做一个很小但能运行的编译器,再不断扩展它。

从概念上说,编译器可以拆成语法分析、中间表示、代码生成等多个阶段。传统教材常常逐个阶段深入解释,但这种写法容易让读者在某个局部被细节淹没,还没有看到整体程序跑起来,就已经失去方向。

还有一个实际问题:如果先把每个阶段都“完整”写完,再尝试把它们接起来,那么在很长一段时间里,你手里都没有一个能工作的编译器。错误会被拖到很晚才暴露,反馈周期极长。更糟糕的是,在没有亲手实现下一个阶段之前,你很难准确知道当前阶段究竟应该输出什么。

为了避开这个陷阱,本书采用另一种方法。

在本书前半部分,读者会实现一门语言规格极其简单的“自制语言”。这门语言简单到在实现它时,并不需要先全面了解编译器的制作方法。之后,读者会在全书的推进过程中不断给这门“自制语言”增加功能,最终把它培养成与 C 相一致的语言。

在这种增量式开发方法中,我们会一边留下细小的提交,一边一步一步制作编译器。

采用这种方法时,无论在哪一个提交点,编译器在某种意义上始终都是“完成品”。在某个阶段,它也许只能做到计算器级别;在某个阶段,它也许只是一个相当受限的 C 子集;再到某个阶段,它也许已经可以说几乎就是 C。

重点在于:无论处于哪个阶段,我们追求的都是与当时完成度相称的、规格合理的语言。我们不会在开发过程中只让某一小部分功能突兀地变得像 C。

数据结构、算法以及计算机科学方面的知识,也会随着开发阶段逐步讲解。

在增量开发中,读者无论读到本书的哪个位置,都应当已经均衡地掌握了“在当前层级上如何制作一门规格合理的语言”。这种状态比只对编译器制作中的某个局部主题异常熟悉要好得多。读完全书时,读者应该已经对所有主题都获得了比较均衡的理解。

此外,这本书也是一本说明如何从零开始编写大型程序的书。

编写大型程序的能力,是一种不同于数据结构和算法的独特技能,但专门讲解这种能力的书似乎并不多。即使有人讲解,如果没有实际体验,也很难真正理解开发方法的好坏。

本书的设计,是让读者在把自制语言培养成 C 语言的过程中,亲身体验一种良好的开发方法。

如果作者的设想顺利实现,那么读者通过阅读本书,学到的将不仅是编译器制作技巧和 CPU 指令集知识,还会包括如何把大型程序拆成小步骤一点点实现、软件测试的方法、版本管理的方法,甚至包括面对编译器制作这类有野心的项目时应有的心态。

本书假定的读者是普通的 C 程序员。不需要是熟知 C 语言规格的超级 C 程序员。只要理解指针和数组,并且至少在花一些时间后能读懂别人写的小型 C 程序,就足够了。

在写作本书时,对于语言规格和 CPU 规格,我尽量不仅说明规格本身,也解释为什么会选择那样的设计。同时,我也穿插了一些关于编译器、CPU、计算机行业及其历史的专栏,希望读者能有兴趣地读下去。

制作编译器是一项非常有趣的工作。刚开始,自制语言只能做一些近乎可笑的简单事情;但随着开发继续,它会很快成长得越来越像 C,甚至让作者自己都感到惊讶,并且像魔法一样顺利运行。实际开发时,经常会遇到这样的时刻:原本觉得在当前阶段不可能编译通过的一大段测试代码,竟然没有错误地完成编译,并且完全正确地运行。这样的代码,即使去看编译结果的汇编,自己也不一定能马上理解。有时甚至会觉得,自己写的编译器仿佛拥有了超越作者本人的智慧。编译器是一种即使明白其机制,仍然会让人隐约觉得不可思议的程序:它为什么能运行得这么好?你应该也会被它的魅力吸引。

前言就到这里。现在,让我们立刻和作者一起进入编译器开发的世界。

为什么选择 C 语言

在众多编程语言中,本书为什么选择 C?或者说,为什么不选择自制语言?这个问题并不是因为“非 C 不可”,但如果要为学习“输出原生代码的编译器制作方法”选择一种语言,那么 C 是为数不多的合理选择之一。

解释器式语言很难让人学到底层知识。另一方面,C 通常会被编译成汇编语言;因此,通过制作 C 编译器,既可以学习 C 本身,也可以学习 CPU 指令集和程序运行机制。

C 使用广泛,所以当编译器能正常工作后,可以从网上下载第三方源代码来编译着玩。例如,可以尝试构建迷你 Unix xv6。如果编译器的完成度足够高,甚至应该可以编译 Linux 内核。这种玩法在小众语言或自制语言中很难做到。

与 C 一样会编译成原生机器码、采用静态类型,并且至少与 C 同样广泛使用的语言,还有 C++。但是 C++ 的语言规格过于庞大,想轻松自制一个 C++ 编译器是不现实的,因此实际并不适合作为选择。

设计并实现原创语言,对于磨炼语言设计感觉来说是有好处的,但也有陷阱:实现起来麻烦的地方,可以通过语言规格绕开,从而不必实现。像 C 这样已经有标准规格的语言则不能这样做。从学习角度看,这种约束反而相当有益。

本书的记法

函数、表达式、命令等,会在正文中以等宽字体显示,例如 mainfoo=3make

跨多行的代码,会像下面这样用等宽字体显示在边框内。

int main() {
    printf("Hello world!\n");
    return 0;
}

如果边框中的代码是希望读者原样输入的 shell 命令,那么以 $ 开头的行表示提示符。请把该行中 $ 之后的内容输入 shell(不要输入 $ 本身)。不以 $ 开头的行,表示输入命令后产生的输出。例如,下面的代码块表示读者输入 make 并按下回车后的执行例。make 命令的输出是 make: Nothing to be done for `all'.

$ make
make: Nothing to be done for `all'.

本书假定的开发环境

本书假定使用运行在 Intel 或 AMD 等普通 PC 上的 64 位 Linux 环境。请根据自己使用的发行版,预先安装 gccmake 等开发工具。如果使用 Ubuntu,可以执行下面的命令来安装本书所需的命令。

$ sudo apt update
$ sudo apt install -y gcc make git binutils libc6-dev

macOS 在汇编源代码层面与 Linux 有相当程度的兼容性,但并不是完全兼容(具体来说,它不支持“静态链接”这一功能)。按照本书内容制作支持 macOS 的 C 编译器并非不可能,但实际尝试时,很可能会在各种细节上遇到不兼容问题。不建议同时学习 C 编译器制作技巧和 macOS 与 Linux 之间的差异。因为一旦某处不能正常运行,你会很难判断到底是对哪一边的理解出了问题。

因此,本书不以 macOS 为目标环境。请在 macOS 上使用某种虚拟环境准备 Linux 环境。如果读者是第一次准备 Linux 虚拟环境,可以参考附录 3中使用 Docker 创建开发环境的方法。

Windows 在汇编源代码层面与 Linux 不兼容。不过,Windows 10 可以像运行一个应用程序那样在 Windows 上运行 Linux,并借此在 Windows 上继续开发。这个 Linux 兼容环境就是 Windows Subsystem for Linux(WSL)。在 Windows 上实践本书内容时,请安装 WSL,并在其中进行开发。

交叉编译器

编译器运行所在的机器称为“宿主机”(host),编译器输出的代码将要运行所在的机器称为“目标机”(target)。在本书中,两者都是 64 位 Linux 环境,但宿主机和目标机并不一定必须相同。

宿主机和目标机不同的编译器称为交叉编译器。例如,一个运行在 Windows 上、用于生成 Raspberry Pi 可执行文件的编译器,就是交叉编译器。交叉编译器常用于目标机器性能较弱或环境特殊、不适合在其上运行编译器的场景。

关于作者

植山类(@rui314)。高速链接器 lld 的原作者及现任维护者。lld 已在 Android(Q 版本以后)、FreeBSD(12 以后)、Nintendo Switch、Chrome、Firefox 等许多操作系统和项目中,被用作生成可执行文件的标准链接器(也就是说,读者手边的计算机里,很可能就有由作者编写的工具生成的二进制文件)。作者也是小型 C 编译器 8cc 的作者。关于软件的随笔主要写在 note 上。

编译编译器的编译器

“C 编译器由 C 编写”这种自我指涉式的情况并不少见。即使在 C 以外,也有许多语言实现是用该语言自身写成的。

如果已经存在语言 X 的实现,那么用语言 X 自身去编写新的 X 编译器,在逻辑上并没有矛盾。如果想要实现自举,只需要先用既有编译器推进开发,等自己写的编译器完成后再切换过去即可。本书中我们要做的正是这种事情。

但是,如果一开始并不存在既有编译器,又该怎么办?这时就只能用另一种语言来编写。假设要以自举为目标编写 X 语言的第一个编译器,那么最初需要用不同于 X 的既有语言 Y 来写;等编译器的完成度提高后,再把编译器自身从 Y 语言改写成 X 语言。

现代复杂编程语言的编译器,如果沿着“用于编译该语言实现的另一个编译器”这条谱系不断追溯,最终应该会抵达计算机黎明期某个人直接用机器语言写成的简单汇编器。这个汇编器可以说是现存所有语言实现的终极祖先。它到底是唯一的,还是有多个这样的祖先,我们并不知道;但可以肯定的是,今天的编译器都是从极少数祖先出发演化而来的。编译器以外的可执行文件通常也是由编译器生成的,因此,现存几乎所有可执行文件,都是那台原始汇编器的间接后代。这像是在谈生命起源一样,很有意思。