什么是真正的编译器

230 阅读8分钟

前言

编译器是一种神秘、有趣、又无聊的程序。说它神秘, 是因为只有非常少的人知道如何写出优秀的编译器。这些写编译器的人, 就像身怀绝技的武林高手一样神出鬼没, 说它有趣, 是因为编译器的技术之中含有大量的"哲学问题"和深刻的的理论。但为什么说它无聊呢? 因为你一旦掌握了编译器技术里面最精华的原理, 就会发现说来说去就那么点东西。编译器代码里面的"创造性含量", 里面有一些固定的"模式", 几十年都不变。这是因为编译器只是一种"工具", 而不是最终的"目的"。

编译的流程

一、生成代码:

在这个阶段就是根据AST直接翻译成汇编代码,并编译成为可执行的文件, 汇编和高级语言不同, 其中一点是要关心CPU和内存这样具体的硬件, 你需要了解不同的CPU指令集的差别

由于LLVM的出现, 我们可以将代码转换成为IR,然后使用LLVM的编译器将IR转换成为汇编代码。

二、代码分析和优化


生成正确的, 能够执行的代码比较简单, 可这样的代码执行效率非常的低效,因为直接翻译生成的代码往往不够简洁, 比如会生成大量的临时变量, 指令变量, 因为翻译首先照顾到的是正确性质, 很难同时兼顾是否足够优化。 所以为了生成高效的代码, 我们需要做好优化。

优化工作又分为独立于机器和依赖于机器的优化两种。

独立于机器的优化, 是基于IR进行的,它可以通过对代码的分析, 用更加高效的代码代替原来的代码:

int foo() {
	int a = 10*10;
	int b = 20;
	if (a>0) return a+1;
	else return a-1;
}
int a = foo();

依赖机器的优化则是依赖于硬件的特征, 现在的计算机硬件设计了很多的特性, 以便提供更高的处理能力, 比如并行计算的能力, 多层级内存结构,编译器要充分利用硬件提供的性能, 比如:

  • 寄存器优化: 对于频繁访问的变量, 最好放在寄存器中, 并且尽量最大限度地利用寄存器, 不让其中一些空着
  • 充分的利用高速缓存, 高速缓存的访问速度可以比内存快上几十倍或者上百倍, 所以我们需要尽量利用高速缓存
  • 并行性质, 现代计算机都有多个内核, 可以并行计算, 我们的编译器需要尽可能充分利用多个内核的计算能力
  • 流水线: CPU在针对不同的指令的时候, 需要等待的周期是不同的
  • 指令选择, 有的时候CPU需要完成一个功能, 有多个指令可以选择, 但是针对某个特定的需要, 采用A指令可能比B指令的效率更高

补充: 什么是真正的优化


很多编译器都强调"优化", 但是大部分编译器的"优化"都是针对糟糕的程序员做的优化, 比如提取公共表达式, 训练有素的程序员是应该避免写出重复而且耗时的表达式, 应该使用中间变量来避免重复的计算, 现在编译器需要把这件事情放在自己的头上,编译器速度才是最重要的事情, 很多编译器做了很多这种愚蠢的优化, 试图将糟糕的代码变成优秀的代码, 但是编译器的速度却被这些糟糕的代码给拖慢了, 每次build一个project用很长的时间, 这样修改代码很长时间才看到结果, 开发效率就变慢了

所以ChezScheme不强调这些普通优化, 它假设程序员具有基本的素质, 能够自己避免重复的事情, ChezScheme的优化大部分都是针对编译器自已生成的代码而做的, 比如closure, optimization, 会尽量产生比较小的内存大小, 这些都是程序员无法控制的, 所以编译器应该尽量达到最优,但是这样的优化也需要一个限度, 要是为了优化而让编译器变得非常的慢, 目标程序没有快到很多, 也是不值得的, ChezScheme力争编译速度和目标程序达到平衡。

编译的本质

编译的本质的理解, 以及编译器与解释器的根本区别。解释器之所以大部分比编译器慢, 是因为解释器"问太多问题", 正因为这些问题产生解释开销。编译的本质, 其实就是在程序运行之前进行静态的分析, 试图一劳永逸的去回答这些问题, 于是编译后的代码根本不会询问这些问题, 我们是直接可以知道那个位置肯定会出现什么样子的构造, 应该做什么事情, 于是它就去做了。

比如:

//这是一个函数吗?
fn(){}

//这是一个字符串吗
str

编译器的架构设计

我们可以通过限制我们的环境(工具), 来强迫我们想出一种解决方法,来简化我们的编译器。

首先第一页就是整个编译器的入口, 在这一页中就是关于所有的编译器信息。可以让你了解API, AST结构, 以及我们需要使用到的工具函数。

在设计的过程中同时我们得遵循自已的开发原则。我认为对于我来说主要有两点

  1. 代码理解起来非常的简单
  2. 代码非常的简短

我发现阅读代码变得非常自然。即使有一段时间没有考虑特定的编译器处理,我只需从头开始,几秒钟后就能轻松看到代码的结构和整体思路,然后深入了解实际操作。这真的很容易上手。其中一部分原因是因为我不必来回跳转才能理解代码的功能。如果我忘记了某个辅助功能的作用,我只需瞥一眼上面半页的定义,就能明白发生了什么。我不需要随时打开文档页来查看API或意图等,因为所有调用点都是可见的,代码的定义也都清晰可见。这很大程度上归功于使用惯例而不是黑盒抽象,采用模式而不是信息隐藏。

这就是良好的架构为我们带来的好处

无法在整个代码中坚持相同的核心抽象的原因之一是可能存在不够充分的核心抽象我在视频中试图强调的编译器设计目标之一是确保整个设计始终保持相同的“上下文”或抽象水平,这样进行任何更改都会与当前的组合情境相契合。我通过尽可能简化来实现这一点。此外,我也尽量让代码保持尽可能简单

我们需要意识到核心的抽象是怎么样子的, 基于核心的抽象去分解成相同的子抽象

这个编译器的复杂性与您编写Scheme核心编译器时通常遇到的复杂性大致相同(不包括语法扩展部分)。它非常适用于过程式编程语言或其他编程语言,但显然您可能需要进行一些调整。编译器的一项常规策略是尽早消除尽可能多的不规则性。很大一部分取决于您的底层 IR/ASM 语言与您正在编译的语言的语义需求之间的匹配程度。举例来说,我在这里没有演示如何进行尾调用优化,但如果您熟悉像Nanopass编译器示例中所找到的Kent Dybvig风格的尾调用优化过程,您可以将这些思想应用到这种数据并行风格中。

我已经构建了点自由样式,因此它基本上就像使用任何表达式一样,我只是使用构建函数的表达式而不是构建值的表达式。该编译器在风格上非常实用,如果您深入了解的话,编译器的整个核心只是一个数据流图。我将在现场会议中对此进行更多讨论,但它们以 Nanopass 风格在 AST 的大部分单调增长(沿场轴)矩阵表示上运行,其核心“列”对应于 Quad-XML 格式的核心列。我将属性列展平为多个主列,而不是单个“属性”列,但其他方面都是相同的,并且编译器中的“Xml”函数有助于转换为 Quad-XML 格式并为那些想要的人序列化 AST存储中间 AST 结果。