【梳理】编译原理与实践 第一章 引言(docx)

编译原理

知 识 梳 理

(第一版)

建议先修课程:离散数学、C / C++、数据结构、汇编语言、计算机组成原理。
配套教材:
Kenneth C. Louden Compiler Construction: Principles and Practice


链接:https://pan.baidu.com/s/16rZnr6V14BRR5tXx8o8LqQ
提取码:0000


一 引言
编译器(compiler)是将一种语言翻译为另一种语言的计算机程序。编译器将源语言(source language)编写的程序作为输入,产生用目标语言(target language)编写的等价程序。一般地,源程序为高级语言(high-level language),如C或C++;而目标语言则是目标机器的目标代码(object code)或称机器代码(machine code),也就是使用目标计算机的机器指令编写的代码,目标代码将在该计算机上运行。
编译器是相当复杂的,其代码长度可从10 000行到1 000 000行不等。编写甚至读懂这样的一个程序都非易事,大多数的计算机行业人员也从未编写过一个完整的编译器。但尽管如此,几乎所有形式的计算都需要编译器,任何与计算机打交道的人员都应掌握编译器的基本结构和操作。此外,计算机应用中,经常需要开发命令解释器和界面。这样的程序比编译器要小,但却使用相同的技术。因此,掌握编译器涉及的技术具有非常重要的实际意义。
1.1 为何要使用编译器?
1940年代,在John von Neumann的牵头之下,可以存储程序的计算机问世了。这之后,人们开始为计算机编写代码,令计算机执行特定的计算任务。最初,计算机代码都是使用机器语言(machine language)编写的。比如
C7 06 0000 0002
在使用Intel 8x86的IBM PC上,代表将数字2移动到0000h。显然,编写这样的代码工作量极大,且冗长而枯燥。很快,汇编语言(assembly language)替代了机器语言。汇编语言使用符号表示指令和内存位置。例如
mov x,2
代表将数字2移动到内存位置x。汇编器(assembler)将这些符号代码与内存位置翻译成相应的机器指令。
汇编语言极大提升了编程速率与准确度,至今仍在被使用,尤其是在一些性能要求极高的场合,依然需要对编写的代码进行汇编级优化。然而,汇编语言具有许多缺点:它仍旧不易编写,并且理解起来很困难。此外,汇编语言高度依赖特定的平台。针对一类机器编写的汇编语言在另一类机器上往往不能被正确执行,而需要重新编写程序。很明显,编程技术发展的下一步应当是:让代码能够以更接近数学记号和自然语言的方式被简洁地编写出来,而且独立于特定的机器,当然也需要借助专门的程序将它翻译为可执行代码。例如,
x = 2
这样的代码就是平台无关的。最初,人们担心这或许根本不可能实现,又或者说,用这样的方式生成目标代码效率不高,因而没有使用价值。
1954到1957年,John Backus带领IBM的一个团队发明了Fortran语言及其编译器。这充分证明了上述的担心都是多余的。尽管如此,Fortran及其编译器的成功背后,也有着巨大的付出,这是因为涉及到翻译编程语言的许多过程在当时不能被理解。
在第一个编译器被发明出来的同时,Noam Chomsky开始了对自然语言的结构的研究。他的研究成果大大简化了编译器的构建,甚至能够支持一部分自动化。Chomsky的研究将语言按照语法复杂度和理解这些语言的算法要求的高低将语言分为4类:0型(type 0)、1型、2型和3型,这就是Chomsky层次性(Chomsky hierarchy)。2型又称上下文无关语法(context-free grammar),是最为有用的一类编程语言,迄今依然被作为编程语言结构的标准代表。对语法分析(parsing)问题的研究在1960到1970年代蓬勃发展,针对此类问题的完整解决方案就是在这一段时间产生的,并成为了今天的编译器理论的一个标准部分。
与上下文无关语法高度相关内容是:有限自动机(finite automata)和正则表达式(regular expressions),与3型语法有关。对这些领域的研究与Chomsky的工作产生于同一个时代,促成了编程语言的单词(word),或称标记(token)结构表达的符号化方法。
更复杂的内容则是生成高效目标代码的方法,这些方法从第一个编译器被发明直到如今都在不断产生。它们经常被错误地称为最优化技术(optimization techniques):其实更应当被叫做代码改进技术(code improvement techniques),因为它们并不产生最优的目标代码,而只是提升代码效率。
在语法分析问题被非常透彻地研究以后,人们为编译器开发的这一部分的自动化做了大量的工作。这些程序最初被称为编译器的编译器(compiler-compilers),但后来它们有了一个更恰当的名称:语法分析程序生成器(parser generators),因为它们只自动化了语法分析部分。这样的程序中,最著名的一个是Yacc,是由Steve Johnson在1975年为Unix系统编写的。对有限自动机的研究使得另一类工具——扫描程序生成器(scanner generator)也被发明出来,最著名的便是Lex。
在1970年代末1980年代初,大量工程致力于将编译器的其它部分的生成自动化,包括代码生成。这些尝试就没有那么多成功案例了,这也许是因为这样的操作天然的复杂性,并且我们并没有把它们完美理解。
20世纪90年代以后,编译器设计具有了更多的进展,这包括针对推理、简化程序包含的信息的复杂算法的应用。在这样的条件下,更复杂的编程语言也被发明出来了。Hindley-Milner类型检查的统一算法被用于函数语言的编译。编译器也与编辑器、链接器、调试器和项目管理器一起,成为了基于窗口的交互开发环境(interactive development environment,IDE)的一部分。不过,编译器设计的基础知识与原理并没有改变很多,它们也自然而然地成为了计算机科学课程的必备核心。
1.2 与编译器有关的程序
解释器(interpreter)也是类似于编译器的一种语言翻译工具。与编译器不同,解释器在读取源程序代码的过程中将代码立即执行,而不是等待目标代码生成完毕再执行。从原理上说,任何一门编程语言可以是编译的也可以是解释的。在有的情况下,使用解释器更好。BASIC语言通常是解释的。Lisp亦如此。在对速率要求高的场景中,更倾向于使用编译器,因为编译出来的目标代码执行起来远远快于解释执行源代码,有时两者的速率比值甚至可达10倍以上。但是,解释器的许多操作与编译器是一样的。编译器与解释器也可以混合使用。

汇编器(assembler)将汇编语言翻译成特定机器的机器语言。汇编语言实际上是机器语言的助记符,因此实现这种翻译是非常容易的。有时候,编译器会生成汇编语言,再交由汇编器生成目标代码。

编译器与汇编器都常常依赖于链接器(linker),将单独编译或汇编的多个目标文件收集并合并为一个可以被直接执行的文件。于是目标的代码可以被划分为两类:未被链接的机器代码和可执行的机器代码。链接器也负责将目标程序与标准库函数和计算机操作系统提供的资源(例如内存分配器和I / O设备)连接。链接器现在执行的是编译器最早的一个主要任务(这也是“编译”一词的由来:通过收集不同的源来构造(to construct by collecting from different sources))。链接过程高度依赖操作系统和处理器。

即便是链接器产生的代码,也经常是还不可以被直接执行的,但是其主要的内存引用都是相对的,并没有决定起始位置,这就是说代码能够被装入内在的任何地方,即可重定位的(relocatable)。加载器(loader)根据给定的基地址解析所有可重定位的地址。加载器的使用使得可执行代码更加灵活,但是加载过程通常是在后台(作为操作环境的一部分)或与链接一同进行,加载器极少是单独的程序。

预处理器(preprocessor)是一个单独的程序,在翻译开始之前为编译器所调用。预处理器可以删除注释、引用其它文件并执行宏替换(macro substitution)。预处理器可由语言(如C)要求,也可作为提供额外功能(诸如为FORTRAN的Ratfor预处理器)的附加程序(add-ons)。

编译器通常处理的是由产生标准文件(比如ASCII文件)的编辑器(editor)编写的源程序。编译器、编辑器和其它部件早已被集成在IDE(例如Visual Studio)中。选定目标语言以后,编辑器将根据目标语言的格式与结构相应地动作。这类编辑器称为基于结构的(structure based),也已经包括了编译器的一些操作。例如,在编译之前就能提示代码中的错误。在编辑器中还可以直接调用编辑器和相关联的程序,程序员便可以不用离开编辑器就能执行程序。

调试器(debugger)是用于在编译好的程序中发现错误的程序。它也常常被集成在IDE里。与直接执行不同,与调试器一同运行程序时,调试器将会追踪源代码的大部分信息,比如行数、变量名和过程。调试器也可以在预先设置的断点(breakpoint)处暂停执行,并提供必要的信息,比如调用了哪些函数,以及当前的一些变量。为了执行这些功能,编译器需要将正确的符号信息提供给调试器,这一点有时候也是很困难的,尤其是编译器尽力优化目标代码的时候。

(性能)分析器(profiler)是一个根据目标程序执行期间的行为收集统计信息的程序。典型的信息包括每个过程被调用的次数和过程执行时间占总时间的百分比。这些统计数据极其有用,它们能帮助程序员改进程序的性能。有时候编译器也可以使用分析器的输出来自动改进目标代码,而无需程序员的介入。

现代的软件项目通常都非常大,只能被一组程序员而不是一个程序员开发。在这种情况下,项目管理器(project manager)就需要正确处理被不同人员使用的文件。举例来说,项目管理器需要为不同的程序员提供同一个文件各自的版本,还需要维护一组文件的历史修改信息,保持版本一致性。当然,在单人开发时,这一点也很有用。项目管理器可以是语言无关的,但针对一个编译器编写项目管理器时,也能维护为生成完整的可执行程序需要的特定编译器与链接器操作的信息。
1.3 翻译过程
编译器编译的整个流程被划分为许多个阶段(phase),如图:

首先,源代码通过扫描器(scanner)被扫描。扫描器执行的任务是词法分析(lexical analysis):将字符序列识别为有意义的基本单元,称为标记(token)。标记的地位相当于单词在英语中的地位。因此,可以认为扫描器的功能与拼写相似。
例如,对于如下的代码:
a[index] = 4 + 2
虽然有12个非空字符,但识别以后只有8个标记:
a 标识符
[ 左中括号
index 标识符
] 右中括号
= 赋值
4 数字

  •   	加记号
    

2 数字
每个标记具有若干个字符。标记作为基本单位,它含有的字符在之后将被作为一个整体处理。
除了识别标记以外,扫描器还可以一同执行其它操作。例如,将标识符(identifier)都放入符号表(symbol table),同时将字面量放入字面量表(literal table)。数学常数,以及被单引号或双引号包含的字符(串)都是字面量。

这之后,标记被语法分析器(parser)接收,并进行语法分析(syntax analysis),这一步将识别出程序的结构。这个步骤与自然语言中的句子语法分析(grammatical analysis)相似。语法分析检测出程序的结构元素以及它们之间的关系。分析结果通常用语法分析树(parse tree)或语法树(syntax tree)表示。
例如,上面的例子中的语句被识别为一种结构元素——表达式(expression)。具体来说,它是一个赋值(assignment)表达式,赋值号左侧具有下标表达式(subscripted expression),而右侧具有整数算术表达式。

语法树的内部节点的名称(标记)为节点代表的结构名称,而叶节点代表输入的标记序列。在上图中,为了与标记区别,节点名称使用斜体。
语法树是将程序或程序元素的语法视觉化的有用工具。但在结构的刻画上,它效率并不高。因此,语法分析器生成的语法树通常是聚合了大量信息的。有时候语法树也称为抽象语法树(abstract syntax tree,AST),因为它们比一般的语法树更加抽象化。

语法树的许多节点是直接可以被丢弃的。例如,假如我们已经知道一个表达式是下标表达式,那么在语法树中就不需要再记录左右中括号了。

程序的语义(semantics)即“意思”(meaning),与语法或结构相对。程序的语义决定了程序运行时的行为,但大多数编程语言拥有仅可以在执行前决定的特性,而这些特性又不能被方便地作为语法表达出来并被语法分析器分析。这些特性叫做静态语义(static semantics),对这一部分的分析是交由语义分析器(semantic analyzer)进行的。而程序的动态语义(dynamic semantics)则是指只能在运行期决定的那一部分性质。常见的编程语言典型的静态语义特性包括声明(declaration)和类型检查(type checking)。被语义分析器计算得到的额外的信息(比如数据类型)称为属性(attributes),它们也常常被作为注解(annotation)或“装饰”(decorations)添加到语法树中,得到注解树(annotated tree)或注释树。
对于上例的表达式,将被优先收集的类型信息可能是:a是一个整数数组,具备落在一定范围内的下标,index是一个整数变量。然后,语义分析器会使用子表达式的类型标注语法树,再检查是否能对这些类型进行赋值。若不能,则报告类型不匹配错误。

编译器通常具备许多改进代码的手段或优化的步骤。在语义分析之后,许多优化就可以做了。优化的方向也有许多种,与源代码的具体内容有关。许多编译器不但可以设置执行的优化类型,还可以设置执行这些优化的时机。
还是刚才的例子,两个常量4 + 2相加,可以被编译器直接计算出来,用计算结果6直接替换相应的元素。这种典型的优化方法称为常量合并(constant folding)。
在这个例子中,常量合并已经可以在标注好的语法树上直接进行了:只需要将右子树合并,只保留其根节点即可。

在树上可以做很多优化,但在大量的具体场景中,线性化以后更接近汇编代码的树更容易被优化。这样的代码有很多种,比如三地址代码(three-address code)和P代码(P-code)。P代码应用于许多Pascal编译器。
例如,对代码
t = 4 + 2
a[index] = t
首先,两个常量4 + 2相加被合并为6,由于6也是常量,因此它将被直接代入赋值语句中,最后只剩下一条语句:
a[index] = 6
在本节最初的流程图上,源代码优化器(source code optimizer)的输出称为中间代码(intermediate code)。历史上,中间代码这个词用于指代源代码与目标代码之间的一种代码形式,典型的例子就是三地址代码或者近似的线性表示。然而,它也可以被更一般地指代为编译器使用的对源代码的任何内部表示。这样,语法树也可以被称作中间代码,源代码优化器也可以在输出中继续使用这样的表示。有时候我们也使用一种更广泛的说法:中间表示(intermediate representation,IR)。

代码生成器(code generator)将中间代码作为输入,产生目标机器的代码。在本书中,我们将目标机器的代码都写成汇编语言的形式。但需要知道的是,许多编译器在这一步会直接生成目标代码。在编译的这一阶段,目标机器的属性才成为主要的影响因素。使用目标机器可以识别的指令不仅是必要的,数据的表示方法也起着非常重要的作用(举例:各个整数和浮点类型分别需要用多少字节来表示)。
对于刚才的例子,生成的汇编代码大致是:
t = 4 + 2;
00007FF7A75917A5 mov qword ptr [t],6
a[index] = t;
00007FF7A75917B0 mov rax,qword ptr [index]
00007FF7A75917B7 mov rcx,qword ptr [t]
00007FF7A75917BE mov qword ptr a[rax*8],rcx

生成目标代码以后,编译器还会使用目标代码优化器(target code optimizer)进一步优化代码。常见的手段包括选择合适的选址模式、使用快指令替换慢指令,以及去除冗余的、不必要的运算。常见的例子有:使用位移与加法代替一部分乘法运算、使用位移代替一部分除法运算,等等。如果一些变量在之后从未被使用,那涉及到这些变量的一些运算还有可能直接被忽略。
在上面的例子中,如果在IDE中选择Release模式(一般会开启O2优化),而这两条高级语言的语句后面没有输出这些变量的值之类的函数或被其它语句使用,那么编译器将不会生成有关这两条语句的任何汇编指令。
需要指出,除了上面这些已经普及的、非常典型的优化方式,现代编译器还会对代码进行数量非常可观的各种优化。
1.4 编译器的主要数据结构
理想条件下,编译器编译一份源代码的时间应当与源代码的大小成正比,也就是O(n)的时间复杂度。本节简单介绍一些编译器中常用的数据结构,它们在编译器的主要操作以及不同阶段的交流当中具有举足轻重的作用。

当扫描器将字符划分为记号时,它通常将记号用符号表示,即用一个枚举数据类型的值代表源语言的记号集。有时候,保留字符串和其它源于字符串的信息也是必要的,例如与标识符记号关联的名称或数字记号的值。在绝大多数语言中,扫描器一次只生成一个记号(单符号先行,single symbol lookahead)。在这种情况下,单个全局变量可以用于保存记号信息。在其它情况(例如Fortran)中,则需要记号序列。

如果语法分析器生成语法树,它通常会被构造为标准的基于指针的结构,随着语法分析的进程推进而动态分配。整棵树可以使用指向根节点的单个指针变量来定位。每个节点是一条记录,记录的字段包含了语法分析器和语义分析器收集的信息(举例:表达式的数据类型)。有时,为了节省空间,这些字段也是动态分配的,或存储于其它数据结构,比如符号表,后者允许选择性地分配与释放内存。实际上,每个语法树节点可能要求存储不同的属性,这与节点代表的语言结构有关。一个表达式节点与一个声明节点需要存储的信息可以是不同的。此种情况中,可由不同的记录表示语法树中的每个节点,每个节点类型只包含相应的结构需要的信息。

符号表(symbol table)存储与标识符有关的信息:函数、变量、常量和数据类型。符号表几乎与编译器的每个阶段都有交互:扫描器、语法分析器和语义分析器会向表中添加标识符;语义分析器还会写入数据类型和其它信息;优化器与代码生成器会使用符号表中的信息选择正确的目标代码进行输出。符号表会被频繁访问,因此其增删查的效率要求很高,最好是常数时间的。因此哈希表较为适合作为符号表的数据结构。不过,有时也会使用不同的树结构存储符号表,或者用列表或栈来维护几张符号表。

快速的插入与查找对字面量表(literal table)也是必不可少的。字面量表存储程序使用的常量。但字面量表不需要允许删除,因为常量作用于程序的全局,而且每个常量只会在表中出现一次。字面量表对减小程序在内存中占用的大小是很重要的,因为它允许常量的重用。代码生成器也需要字面量表,为字面量和目标代码文件中的数据定义构造符号地址。

取决于中间代码的类型(比如,三地址代码和P代码)和执行的优化类型,中间代码可能被保存为字符串数组、临时的文本文件,或结构的链表。在执行复杂优化的编译器中,必须尤其注意:应当选择易于重组织的表示方法。

历史上,计算机在编译期间并没有足够的内存去放下整个程序。当时解决这个问题的办法是:使用临时文件保存编译步骤产生的中间结果,或者仅从源程序的早期部分保留足够的信息,使得编译过程能继续推进。当然在现在,内存限制基本已经不是问题了,于是整个编译单元都可以放在内存中,尤其是语言支持分别编译的时候。不过,有时候生成中间文件依然是很有用的。典型的例子就是,在代码生成的过程中,有时候诸如标号之类的地址不能立刻被得知,而是需要在之后才能把地址写上。例如,当编译类似这样的条件语句时:
if x = 0 then…else…
在else部分的代码的地址被得知之前,从条件测试到else部分的跳转必须生成:
cmp x,0
jne NEXT

NEXT:

典型地,需要为标号NEXT的地址预留位置。当NEXT的地址确定时,就把预留的空白填上。使用临时文件很容易做到这一点。
1.5 编译器结构中的其它问题
编译器分析源程序并计算其属性的操作被划分为分析(analysis)部分,而产生翻译后的代码的操作则被划入合成(synthesis)部分。自然地,词法分析、语法分析和语义分析属于分析部分,代码生成属于合成部分。执行优化阶段则同时涉及到分析与合成。分析更加偏向数学、更好理解,相比之下合成则要求更多的技巧。因此,将分析与合成的步骤分开来是很有用的,因为对每一部分都可以分别处理,而不影响另一部分。

编译器的一些操作只依赖于原语言,这部分属于前端(front end);只依赖目标语言的部分自然就属于后端(back end)了。扫描器、语法分析器和语义分析器属于前端,代码生成器则为后端的一部分。一些优化分析依赖于目标语言,中间代码分析经常是依赖于源语言的。理想情况下,编译器可以被分为前端和后端这两部分,它们之间由中间代码作为沟通媒介。
这样的结构对于编译器的可移植性(portability)十分重要。可移植性在实践中是很难达到的,所谓的可移植编译器多少都会依然具有依赖源语言或目标语言的特性。可以将难以实现编译器可移植的一部分责任推给编程语言与计算机架构的迅速变化,但是,高效地保持转换到新的目标语言需要的全部信息,或者令数据结构更加通用以允许转换到新的源语言也是很困难的。尽管如此,不断尝试分离前后端仍然会降低移植的难度。

生成代码之前,编译器但可以很方便的将整个源程序处理若干次。每处理一次称为一个pass。最初的一次pass以后,语法树或中间代码就被构建出来了。一次pass包含了处理中间表示、添加信息、改变结构,以及产生不同表示,等等。Pass与阶段不一定对应,一次pass经常可以包括几个阶段。视源语言的不同,编译器可以是1-pass的。1-pass的编译速度很快,但生成的目标代码效率没有那么高。Pascal和C都允许1-pass编译,而Modula-2语言的结构要求编译器至少为2-pass。许多支持优化的编译器都在2-pass或以上,扫描和词法分析为一pass,语义分析和源代码优化为一pass,再有一pass包括代码生成和目标代码优化。执行大量优化的编译器可以做到5-pass、6-pass、甚至8-pass。

1.1节已经讲过,编程语言的词法与语法结构通常使用形式化的术语、正则表达式和上下文无关语法。然而,编程语言的语义仍然常常是用英语(或其它自然语言)描述的。这些描述与形式词法和语法结构被一起编入语言参考手册(language reference manual)或语言定义(language definition)。开发一种新的语言时,语言定义和编译器通常是同步开发的,因为编译器作者有能力运用的技术对语言定义具有主要影响。同样地,语言的定义也会反过来影响编译器需要使用的技术。
编译器的作者更经常遇到的一种情况是:正在实现的语言是熟知的,已经具有语言定义。有时这样的语言定义已经上升到语言标准(language standard)层面,并被一些官方的标准化组织,例如美国国家标准协会(ANSI)和国际标准化组织(ISO)所接受。Fortran、Pascal和C都具有ANSI标准,Ada则具有为美国政府所接受的标准。在这样的条件下,编译器作者必须解释语言定义,并依照语言标准实现编译器。这并不是一项容易的任务,但测试套件(标准测试程序)可以测试编译器,就降低了这个任务的难度。
有时候,一门编程语言会通过形式化的数学定义来给出其语义。要达到这个目的有几种方法,不过并没有一种成为标准,即使所谓的指称语义(denotational semantics)已经成为一种很常用的方法(尤其是在函数编程领域)。当一门语言具有形式定义时,就可以从数学上证明一个编译器符合这样的语言定义。然而这太难了,几乎没有人完成过这样的事情。
的尤其受语言定义所影响的构建编译器的一个方面是:运行时环境的结构和行为。编程语言允许的数据结构,尤其是允许的函数调用类型和原始的返回值,对运行时系统的复杂度有着决定性的影响。特别地,运行环境的三个基本类型,从复杂度低到高列举如下:
首先是Fortran 77,没有指针、动态分配和递归函数调用,允许一个完整的静态运行时环境,所有内存分配在程序执行之前都完成了。这使得编译器作者特别容易完成内存分配的工作。然后是Pascal、C及其它所谓的类似ALGOL的语言,允许受限制的动态内存分配与递归函数调用,以及要求一个“半动态”或基于栈的、带有一个额外的动态结构(称为堆)的运行时环境。程序员可以在堆上进行动态内存分配。最后是函数式语言与许多面向对象语言,比如Lisp和Smalltalk,要求一个“完全动态的”环境,在此之中全部内存分配都由编译器生成的代码自动进行。这一点是复杂的,因为它同时也要求内存能够被自动释放,这就导致了复杂的垃圾收集(garbage collection)算法。

构建编译器的一个重要方面是:连接操作系统的界面机制的引入,以及为用户提供许多选项用于众多目的。界面机制的例子是I / O设备与访问目标计算机的文件系统的相关规定。用户选项的例子包括列表特征(长度、错误信息、相互引用表)和代码优化选项(针对代码性能或代码大小,等等)。界面与选项合称编译器的语用学(pragmatics)。有时候,一种语言定义会指明必须提供的特定语用学。举例:Pascal和C都指明了特定的I / O过程(在Pascal中,它们是语言特性的一部分;在C中,它们是标准库说明的一部分)。在Ada中,大量的编译器指令,即pragma,是语言定义的一部分。例如:
pragma LIST(ON);
pragma LIST(OFF);
为包含该段指令的程序片段生成一张编译器列表。我们不讲解I / O和操作系统界面的问题,因为它们包含的细节实在太多了,而且随着操作系统的不同而差异非常大。

编译器的一个最重要的功能就是对源程序的错误进行响应。错误能够在编译的每个阶段被检测出来。静态错误(static error)也称编译期错误(compile-time error),是编译器必须报告的。编译器也应当生成有意义的错误信息。在编译的不同阶段,编译器的错误处理手段需要有一点点不同,因此错误处理程序(error handler)需要包括不同的操作,与编译的不同阶段或情况相适应。
有的语言定义还要求编译器捕获执行期间的错误。这要求编译器生成额外代码进行运行时测试,保证这些运行时错误引发适当的事件。最简单的这种事件就是暂停程序执行。不过,这样的应对方法常常是不足的,语言定义也会要求具有异常处理(exception handling)机制。这些都会导致运行时管理变得异常复杂,尤其是当一个程序可能在错误发生以后继续执行时。
1.6 自举与移植
【例1】
已知:
【1】假设:目前没有能将C语言编译为x86汇编语言的编译器。
【2】假设:目前没有能将其它高级语言编译为x86汇编语言的编译器。
目标:
利用C语言实现:将C语言编译为x86汇编语言的编译器。
【解】
首先,因为还没有能将C语言编译为x86汇编语言的编译器,也无法借助其它高级语言作为中介,所以我们只能直接使用x86汇编语言。但是直接用汇编语言编写整个高级语言的编译器是极其困难的。因此我们:
【1】先使用x86汇编语言直接实现一个能将C语言的一个子集(我们称为C–)编译为x86汇编语言的编译器。
编译是将高级语言转换成低级语言。显然,如果能使用高级语言来开发编译器,那么我们不可能用到一门高级语言的全部特性。因此只需要将涉及的部分(包含在C–内)先手工编译成汇编语言,然后再用高级语言的这个子集去继续完成编译器剩余的部分,编译器开发的过程就简单了许多,因为剩下的部分不一定必须要繁琐地使用汇编语言来继续完成了:
【2】再使用C–继续实现将C语言编译为x86汇编语言的编译器。
这样,我们就得到了一个能将C语言编译为x86汇编语言的编译器。它的其中一部分是直接使用x86汇编语言编写的,剩下的部分则是用C语言本身来编写的。而使用C语言编写的这部分也被第【1】步开发的编译器编译成了x86汇编语言。再使用x86汇编器将其汇编,便可得到最终的编译器成品。
如果觉得第【2】步依然过于复杂,那么可以选择先实现一个比已经能够编译的C语言子集更大,但仍为C语言子集的子集。如此类推下去,最终实现一个完整的C语言编译器。
有了这第一个C语言到x86汇编语言编译器,以后我们就可以直接使用C语言来开发C语言到x86汇编语言的编译器了。

【例2】
在【例1】的条件下,开发第一个将C语言编译为MIPS汇编语言的编译器。
【解】
【1】使用C语言编写从C语言到MIPS汇编语言的编译器。
【2】使用【例1】中已经开发完毕的C语言到x86汇编语言的编译器,将【1】中的编译器源码编译为能在使用x86 CPU的计算机上运行的MIPS编译器。
【3】使用【2】中的在x86计算机上运行的C语言到MIPS汇编语言的编译器,将【1】中的MIPS编译器源码编译。
将第【3】步得到的汇编代码使用MIPS汇编器汇编,就可以得到能够在使用MIPS CPU的计算机上运行的、将C语言编译为MIPS汇编语言的编译器。

从上面的例子我们可以看出,第一个编译器是使用机器语言(汇编语言)直接实现(作为宿主(host)语言)的,因为那时候还没有其它任何的编译器。现在编写编译器的更合理的方法是:使用另一种语言编写编译器,而这个语言的编译器已经存在了。如果已有的编译器在目标机器上可以运行,我们只需要做的就是使用该编译器支持的语言编写编译器即可。在【例1】中,目标机器是x86架构的,其支持的源语言为C语言。日后为目标计算机再开发新的编译器时,就可以直接使用C语言编写。

如上图,如果已有的语言B编译器针对的目标机器与我们要开发的语言A编译器针对的目标机器不同,情况就复杂一点。编译过程会产生一个交叉编译器(cross compiler),也就是一个为与它正在运行于的机器不同的机器生成目标代码的编译器。在【例2】中,已有的C语言编译器针对x86 CPU,我们却需要开发针对MIPS CPU的编译器。使用T型图(T-diagram)可以描述这样的情形:

上述T型图的意义是:编译器运行在使用宿主语言H的计算机上。典型地,我们希望H与T相同(即:编译器运行在某台机器上,正好为这台机器产生代码),但也未必总是需要这样做。在【例1】中,编译器运行在x86架构上,产生x86汇编语言。在【例2】中,我们得到了一个交叉编译器作为中间产物:它运行在x86架构的计算机上,但产生MIPS汇编语言。
T型图可以以两种方式组合。第一,若有两个编译器都能运行在使用宿主语言H的计算机上,分别将语言A编译为语言B、将语言B编译为语言C,则我们可以将前者的输出作为后者的输入,得到一个将语言A编译为语言C的编译器:

第二,如果有一个将语言H编译为语言K的编译器,那么我们就有能力将实现语言从H转换为K:

第一种情况——使用宿主语言H的B语言编译器,将一个从语言A转到H的编译器用语言B编写——与下面的T型图是等效的。这个图只是一种特殊情况:

我们讨论的第二种情况——语言B的编译器为不同的机器生成代码,也运行在这台机器上,产生了A的交叉编译器。

如果编译器编译语言S,则通常将编译器也用S语言编写:

但这会导致一个愚蠢的循环:如果源语言S的编译器还不存在,编译器自己也就不能被编译了。
在【例1】中,我们先用汇编语言编写一个相当简单的编译器,仅将编译器实际用到的语言特征进行编译(当然,一个良好的编译器应当具备的特征还没能实现)。这个编译器也可能只产生效率极其低的代码。当然,应当竭力令目前只要求产生的代码是正确的。这之后,我们就用这个非常简易的编译器去生成更复杂的编译器,如此下去,直到构造出最终的编译器来。这个过程称为自举(bootstrapping)。

自举的优势是:对编译器源代码的任何改进都能被立即自举到新的编译器中。
另一个优点是:若要移植(port)这个编译器到使用不同宿主语言的计算机,现在只需要重写后端的源代码,使得能够为新的机器生成代码。然后,再使用旧的编译器生成一个交叉编译器。这个编译器也会被交叉编译器重新编译,产生一个新机器可以使用的编译器。【例2】就体现了这样的思想。

猜你喜欢

转载自blog.csdn.net/COFACTOR/article/details/114503983
今日推荐