编译器工具链

一个典型的编译器工具链如下所示。 编译器工具链

  • 预处理器处理掉源文件中所有#符号开头的指示语言,比如宏定义、头文件等。
  • 编译器以预处理的输出作为输入,经过扫描、解析,执行类型检查和其他语义操作,进行代码优化,最后输出汇编语言。
  • 汇编器以编译器输出的汇编语言作为输入,输出对象码。对象码虽然包含了CPU能识别的机器语言指令,但是不知道最终的存储地址。
  • 链接器接收对象文件、库文件,并将它们组合成完整的可执行的程序。链接器选择每段代码和数据最终加载的存储位置,并通过写入缺失的地址信息将这些文件链接起来。

编译器内部的阶段

Unix编译器的流程如下所示。 编译器流程

  • 扫描器的输入是程序的普通文本,它将独立的字符组合起来形成一个个完整的词法单元。
  • 解析器的输入是扫描器输出的一个个词法单元,它将这些词法单元组合成完整的声明和表达式,输出抽象语法树。解析器由语法引导,这里的语法声明了给定语言组成的正式规则。抽象语法树描述了程序的语法结构,也记住了源程序中每个结构出现的地方。
  • 语义子程序遍历抽象语法树,根据语言的规则和程序元素间的关系推断程序的语义,输出中间表示。中间表示是汇编代码的简化形式,适用于详细的分析。
  • 一至多个优化器应用在中间表示,使得程序更小、更快、更高效。
  • 代码生成器将优化的中间表示转换为具体的汇编语言程序。一般而言,代码生成器需要执行寄存器分配来高效地管理有限的硬件寄存器,执行指令选择和序列化来得到最高效的汇编指令序列。

编译实例

假设我们要将下面的代码段翻译成汇编形式。

height = (width+56) * factor(foo);

首先,扫描器逐个读取源码文本的字符,识别符号间的边界,发射词法单元序列。每个词法单元都是一个描述每个符号本质和内容的小型数据结构:

id:height = ( id:width + int:56 ) * id:factor ( id:foo ) ;

在这个阶段,每个词法单元的用意并不清楚,比如factor和foo都简单地视作标识符。下一个阶段便是确定这个词法单元序列能否形成有效的程序。解析器通过寻找与编程语言语法匹配的模式来实现这个目的。假设我们的编译器理解下面的语法。

Grammar $G_1$
1. expr $\rightarrow$ expr + expr
2. expr $\rightarrow$ expr * expr
3. expr $\rightarrow$ expr = expr
4. expr $\rightarrow$ id ( expr )
5. expr $\rightarrow$ ( expr )
6. expr $\rightarrow$ id
7. expr $\rightarrow$ int

语法$G_1$的每行称作一个语法规则,解释了语言的不同部分是如何构造的。规则1~3阐述了表达式可以通过将两个表达式和一个运算符连接起来构成;规则4描述了函数调用;规则5描述了括号的使用;规则6和规则7表示标识符和整数是原子表达式。

根据上述语法规则,解析器寻找到一些词法单元序列,将其替换成语法规则左侧的表达式。每次应用一个语法规则,解析器在树中构造一个节点,将子表达式与抽象语法树联系起来。抽象语法树显示了符号间的结构关联——加法操作作用在width和56,函数调用操作作用在factor和foo.

抽象语法树实例

当抽象语法树准备好了,可以进行语义的分析。语义子程序遍历抽象语法树,通过将程序的各部分、程序语言的定义关联起来来推导额外的含义。其中一个重要的部分是类型检查——确定每个表达式的类型,检查和余下程序的一致性。这里做了简化处理,假设所有的变量都是普通整数。

我们对抽象语法树执行后序遍历,并对树中每个节点生成IR指令来生成线性中间表示。一种典型的中间表示看上去和抽象的汇编语言类似,包含加载/存储操作、算术操作和无限的寄存器。一种可能的中间表示如下所示:

中间表示示意
LOAD 56 $\rightarrow$ r1
LOAD width $\rightarrow$ r2
IADD r1, r2 $\rightarrow$ r3
ARG foo
CALL factor $\rightarrow$ r4
IMUL r3, r4 $\rightarrow$ r5
STOR r5 $\rightarrow$ height

大多数优化处理的是中间表示。编译优化包含死码消除、组合公共表达式和代码简化等,减少资源的消耗,提高运行效率。

最后,中间表示必须转化成想要的汇编代码。下面是上述中间表示在X86指令集下面的一个汇编指令序列。

汇编代码示例
MOVQ width, %rax
ADDQ $56, %rax
MOVQ %rax, -8(%rbp)
MOVQ foo, %edi
CALL factor
MOVQ -8(%rbp), %rbx
IMULQ %rbx
MOVQ %rax, height

优秀的编译器是高度模块化的,因此相同的代码可以共享和按需组合。编译器可以提供不同的扫描器和解析器来支持不同的编程语言,输出相同的中间表示。不同的优化技术可以实现为独立的模块(每个模块读写相同的中间表示),以便使能和禁止。通过包含不同的代码生成器,编译器可以实现重定向,使得相同的中间表示可以发射到不同的微处理器上。