编译原理及实践


下载 第1章 概 论 本章要点 • 为什么要用编译器 • 编译器结构中的其他问题 • 与编译器相关的程序 • 自举与移植 • 翻译步骤 • TINY样本语言与编译器 • 编译器中的主要数据结构 • C-Minus:编译器项目的一种语言 编译器是将一种语言翻译为另一种语言的计算机程序。编译器将源程序( source language) 编写的程序作为输入,而产生用目标语言( t a rget language)编写的等价程序。通常地,源程 序为高级语言( high-level language),如C或C + +,而目标语言则是目标机器的目标代码 (object code,有时也称作机器代码(machine code)),也就是写在计算机机器指令中的用于运 行的代码。这一过程可以用下图表示: 编译器是一种相当复杂的程序,其代码的长度可从 10 000行到1 000 000行不等。编写甚至 读懂这样的一个程序都非易事,大多数的计算机科学家和专业人员也从来没有编写过一个完整 的编译器。但是,几乎所有形式的计算均要用到编译器,而且任何一个与计算机打交道的专业 人员都应掌握编译器的基本结构和操作。除此之外,计算机应用程序中经常遇到的一个任务就 是命令解释程序和界面程序的开发,这比编译器要小,但使用的却是相同的技术。因此,掌握 这一技术具有非常大的实际意义。 也正因为这一点,本书不仅仅要讲解基础知识,还为读者提供了所有必要的工具和设计编 写真正的编译器的实践。要做到这些,就必须学习各项理论知识,而这主要应从自动机原理 (它使编译器结构合理)着手。在讲述时我们假设读者并不了解自动机原理。当然,此处的观 点与标准的自动机原理论著有所不同,这些论著特别强调编译过程;但是,学过自动机原理的 读者就会发现对这些理论材料很熟悉,这部分阅读起来也十分迅速。特别是对于那些十分了解 自动机原理背景的读者来说,对2 . 2节、2 . 3节、2 . 4节和3 . 2节就不必细读了。无论怎样,读者都 应知道基本的数据结构和离散数学。机器结构和汇编语言的相关知识也很重要,在第 8章“代 码生成”中尤为如此。 实际编码技术的研究本身就要求认真规划,这是因为即使有很好的理论基础,编码的细节 也可能会复杂得令人不知如何操作。本书包括了有关程序设计语言结构的一系列简单示例,并 利用它们针对该项技术进行详细描述,讨论中使用到的语言被称作 T I N Y。此外,附录A还提供 了一个更广泛的示例,它包括了一个小小的但却非常复杂的适用于分类项目的 C子集(称作C - M i n u s)。本书还有大量的练习,这其中包括简单的笔头训练、文本中的代码扩充,以及更多的 相关编码练习。 总之,在编译器结构和被编译的程序设计语言的设计之间存在着一个很重要的交互。在本 书中,只是附带着讲解了一下语言设计问题,而是着重于程序设计语言的概念和设计问题(参 源程序 → 编译器 → 目标程序 见本章最后的“注意与参考”部分)。 首先将简要地介绍编译器的历史及其存在目的与理由,以及与编译器相关的程序描述。接 着讲解编译器的结构、各种翻译过程和相关的数据结构,并联系一个简单的具体示例来示范这 个结构。最后,再概括地讲述一下编译器结构的其他问题,这包括自举和移植,以及本书后面 用到的主要语言的描述。 1.1 为什么要用编译器 在本世纪4 0年代,由于冯·诺伊曼在存储 -程序计算机方面的先锋作用,编写一串代码或 程序已成必要,这样计算机就可以执行所需的计算。开始时,这些程序都是用机器语言 (machine language)编写的。机器语言就是表示机器实际操作的数字代码,例如: C7 06 0000 0002 表示在IBM PC上使用的Intel 8x86处理器将数字2移至地址0 0 0 0(1 6进制)的指令。当然,编写 这样的代码是十分费时和乏味的,这种代码形式很快就被汇编语言( assembly language)代替 了。在汇编语言中,都是以符号形式给出指令和存储地址的。例如,汇编语言指令 MOV X, 2 就与前面的机器指令等价(假设符号存储地址 X是0 0 0 0)。汇编程序(a s s e m b l e r)将汇编语言 的符号代码和存储地址翻译成与机器语言相对应的数字代码。 汇编语言大大提高了编程的速度和准确度,人们至今仍在使用着它,在编码需要极快的速 度和极高的简洁程度时尤为如此。但是,汇编语言也有许多缺点:编写起来也不容易,阅读和 理解很难;而且汇编语言的编写严格依赖于特定的机器,所以为一台计算机编写的代码在应用 于另一台计算机时必须完全重写。很明显,发展编程技术的下一个重要步骤就是以一个更类似 于数学定义或自然语言的简洁形式来编写程序的操作,它应与任何机器都无关,而且也可由一 个程序翻译为可执行的代码。例如,前面的汇编语言代码可以写成一个简洁的与机器无关的形式 x = 2 起初人们担心这是不可能的,或者即使可能,目标代码也会因效率不高而没有多大用处。 在1 9 5 4年至1 9 5 7年期间,I B M的John Backus带领的一个研究小组对F O RT R A N语言及其编 译器的开发,使得上面的担忧不必要了。但是,由于当时处理中所涉及到的大多数程序设计语 言的翻译并不为人所掌握,所以这个项目的成功也伴随着巨大的辛劳。 几乎与此同时,人们也在开发着第一个编译器, Noam Chomsky开始了他的自然语言结 构的研究。他的发现最终使得编译器结构异常简单,甚至还带有了一些自动化。 C h o m s k y的 研究导致了根据语言文法( g r a m m a r,指定其结构的规则)的难易程度以及识别它们所需的 算法来为语言分类。正如现在所称的 — 与乔姆斯基分类结构( Chomsky hierarchy)一样 — 包括了文法的4个层次:0型、1型、2型和3型文法,且其中的每一个都是其前者的专门化。 2 型(或上下文无关文法( context-free grammar))被证明是程序设计语言中最有用的,而且 今天它已代表着程序设计语言结构的标准方式。分析问题( parsing problem,用于限定上下 文无关语言的识别的有效算法)的研究是在 6 0年代和7 0年代,它相当完善地解决了这一问题, 现在它已是编译理论的一个标准部分。本书的第 3、4和5章将研究上下文无关的语言和分析 算法。 有穷自动机(finite automata)和正则表达式(regular expression)同上下文无关文法紧密 相关,它们与乔姆斯基的 3型文法相对应。对它们的研究与乔姆斯基的研究几乎同时开始,并 且引出了表示程序设计语言的单词(或称为记号)的符号方式。第 2章将讲述有穷自动机和正 2 编译原理及实践 下载 则表达式。 人们接着又深化了生成有效的目标代码的方法,这就是最初的编译器,它们被一直使用至 今。人们通常将其误称为优化技术( optimization technique),但因其从未真正地得到过被优化 了的目标代码而仅仅改进了它的有效性,因此实际上应称作代码改进技术( code improvement t e c h n i q u e)。第8章将讲述该技术的基础知识。 当分析问题变得好懂起来时,人们就在开发程序上花费了很大的功夫来研究这一部分的编 译器的自动构造。这些程序最初被称为编译程序 -编译器,但更确切地应称为分析程序生成器 (parser generator),这是因为它们仅仅能够自动处理编译的一部分。这些程序中最著名的是 Ya c c(yet another compiler- c o m p i l e r),它是由Steve Johnson在1 9 7 5年为U n i x系统编写的,我们将 在第 5章中再次谈到它。类似地,有穷自动机的研究也发展了另一种称为扫描程序生成器 (scanner generator)的工具,L e x(与Ya c c同时,由Mike Lesk为U n i x系统开发的)是这其中的 佼佼者。读者将在第2章中学到L e x。 在7 0年代后期和8 0年代早期,大量的项目都关注于编译器其他部分的生成自动化,这其中 就包括了代码生成。这些尝试并未取得多少成功,这大概是因为操作太复杂而人们又对其不甚 了解,本书也就不详细谈它了。 编译器设计最近的发展包括:首先,编译器包括了更为复杂的算法的应用程序,它用于推 断和/或简化程序中的信息;这又与更为复杂的程序设计语言(可允许此类分析)的发展结合 在一起。其中典型的有用于函数语言编译的 H i n d l e y - M i l n e r类型检查的统一算法。其次,编译 器已越来越成为基于窗口的交互开发环境( interactive development environment,I D E)的一部 分,它包括了编辑器、链接程序、调试程序以及项目管理程序。这样的 I D E的标准并没有多少, 但是已沿着这一方向对标准的窗口环境进行开发了。这一专题的研究超出了本书的范围(但是 读者可以参阅下一节中有关 I D E部件的内容)。读者可以参阅本章末尾的“注意与参考”中的 文献内容。尽管近年来对此进行了大量的研究,但是基本的编译器设计在近 2 0年中都没有多大 的改变,而且它们正迅速地成为计算机科学课程中的中心一环。 1.2 与编译器相关的程序 本节主要描述与编译器有关或专编译器一同使用的其他程序,以及那些在一个完整的语言 开发环境中与编译器一同使用的程序(有一些已在前面提到过)。 (1) 解释程序(i n t e r p re t e r) 解释程序是如同编译器的一种语言翻译程序。它与编译器的不同之处在于:它立即执行源 程序而不是生成在翻译完成之后才执行的目标代码。从原理上讲,任何程序设计语言都可被解 释或被编译,但是根据所使用的语言和翻译情况,很可能会选用解释程序而不用编译器。例如, 我们经常解释B A S I C语言而不是去编译它。类似地,诸如 L I S P的函数语言也常常是被解释的。 解释程序也经常用于教育和软件的开发,此处的程序很有可能被翻译若干次。而另一方面,当 执行的速度是最为重要的因素时就使用编译器,这是因为被编译的目标代码比被解释的源代码 要快得多,有时要快1 0倍或更多。但是,解释程序具有许多与编译器共享的操作,而两者之间 也有一些混合之处。本书后面也将会提到解释程序,但重点仍是编译。 (2) 汇编程序(a s s e m b l e r) 汇编程序是用于特定计算机上的汇编语言的翻译程序。正如前面所提到的,汇编语言是计 算机的机器语言的符号形式,它极易翻译。有时,编译器会生成汇编语言以作为其目标语言, 然后再由一个汇编程序将它翻译成目标代码。 第 1章 概 论 3下载 (3) 连接程序(l i n k e r) 编译器和汇编程序都经常依赖于连接程序,它将分别在不同的目标文件中编译或汇编的代 码收集到一个可直接执行的文件中。在这种情况下,目标代码,即还未被连接的机器代码,与 可执行的机器代码之间就有了区别。连接程序还连接目标程序和用于标准库函数的代码,以及 连接目标程序和由计算机的操作系统提供的资源(例如,存储分配程序及输入与输出设备)。 有趣的是,连接程序现在正在完成编译器最早的一个主要活动(这也是“编译”一词的用法, 即通过收集不同的来源来构造)。因为连接过程对操作系统和处理器有极大的依赖性,本书也 就不研究它了。我们也对不细分连接的目标代码和可执行的代码,这是因为对于编译技术而言, 这个区别并不重要。 (4) 装入程序(l o a d e r) 编译器、汇编程序或连接程序生成的代码经常还不完全适用或不能执行,但是它们的主要 存储器访问却可以在存储器的任何位置中且与一个不确定的起始位置相关。这样的代码被称为 是可重定位的(r e l o c a t a b l e),而装入程序可处理所有的与指定的基地址或起始地址有关的可重 定位的地址。装入程序使得可执行代码更加灵活,但是装入处理通常是在后台(作为操作环境 的一部分)或与连接相联合时才发生,装入程序极少会是实际的独立程序。 (5) 预处理器(p re p ro c e s s o r) 预处理器是在真正的翻译开始之前由编译器调用的独立程序。预处理器可以删除注释、包 含其他文件以及执行宏(宏 m a c r o是一段重复文字的简短描写)替代。预处理器可由语言(如 C)要求或以后作为提供额外功能(诸如为F O RT R A N提供R a t f o r预处理器)的附加软件。 (6) 编辑器(e d i t o r) 编译器通常接受由任何生成标准文件(例如 A S C I I文件)的编辑器编写的源程序。最近, 编译器已与另一个编辑器和其他程序捆绑进一个交互的开发环境—— I D E中。此时,尽管编辑 器仍然生成标准文件,但会转向正被讨论的程序设计语言的格式或结构。这样的编辑器称为基 于结构的(structure based),且它早已包括了编译器的某些操作;因此,程序员就会在程序的 编写时而不是在编译时就得知错误了。从编辑器中也可调用编译器以及与它共用的程序,这样 程序员无需离开编辑器就可执行程序。 (7) 调试程序(d e b u g g e r) 调试程序是可在被编译了的程序中判定执行错误的程序,它也经常与编译器一起放在 I D E 中。运行一个带有调试程序的程序与直接执行不同,这是因为调试程序保存着所有的或大多数 源代码信息(诸如行数、变量名和过程)。它还可以在预先指定的位置(称为断点(b r e a k p o i n t)) 暂停执行,并提供有关已调用的函数以及变量的当前值的信息。为了执行这些函数,编译器必 须为调试程序提供恰当的符号信息,而这有时却相当困难,尤其是在一个要优化目标代码的编 译器中。因此,调试又变成了一个编译问题,本书的内容就不涉及它了。 (8) 描述器(p ro f i l e r) 描述器是在执行中搜集目标程序行为统计的程序。程序员特别感兴趣的统计是每一个过程 的调用次数和每一个过程执行时间所占的百分比。这样的统计对于帮助程序员提高程序的执 行速度极为有用。有时编译器也甚至无需程序员的干涉就可利用描述器的输出来自动改进目 标代码。 (9) 项目管理程序(p roject manager) 现在的软件项目通常大到需要由一组程序员来完成,这时对那些由不同人员操作的文件进 行整理就非常重要了,而这正是项目管理程序的任务。例如,项目管理程序应将由不同的程序 4 编译原理及实践 下载 员制作的文件的各个独立版本整理在一起,它还应保存一组文件的更改历史,这样就能维持一 个正在开发的程序的连贯版本了(这对那些由单个程序员管理的项目也很有用)。项目管理程 序的编写可与语言无关,但当其与编译器捆绑在一起时,它就可以保持有关特定的编译器和建 立一个完整的可执行程序的链接程序操作的信息。在 U n i x系统中有两个流行的项目管理程序: s c c s(source code control system)和r c s(revision control system)。 1.3 翻译步骤 编译器内部包括 了许多步骤或称 为阶段 (p h a s e),它们执行不同的逻辑操作。将这些阶段 设想为编译器中一个个单独的片断是很有用的, 尽管在应用中它们是经常组合在一起的,但它们 确实是作为单独的代码操作来编写的。图 1 - 1是编 译器中的阶段和与以下阶段(文字表、符号表和 错误处理器)或其中的一部分交互的3个辅助部件。 这里只是简要地描述一下每个阶段,今后大家还 会更详细地学到它们(文字表和符号表在 1 . 4节中, 错误处理器在1 . 5节)。 (1) 扫描程序(s c a n n e r) 在这个阶段编译器实际阅读源程序(通常以 字符流的形式表示)。扫描程序执行词法分析 (Lexical analysis):它将字符序列收集到称作记号 (t o k e n)的有意义单元中,记号同自然语言,如英 语中的字词相似。因此可以认为扫描程序执行与 拼写相似的任务。 例如在下面的代码行(它可以是 C程序的一部 分)中: a [index] = 4 + 2 这个代码包括了 1 2个非空字符,但只有 8个 记号: a 标识符 [ 左括号 i n d e x 标识符 ] 右括号 = 赋值 4 数字 + 加号 2 数字 每一个记号均由一个或多个字符组成,在进一步处理之前它已被收集在一个单元中。 扫描程序还可完成与识别记号一起执行的其他操作。例如,它可将标识符输入到符号表中, 将文字(l i t e r a l)输入到文字表中(文字包括诸如 3 . 1 4 1 5 9 2 6 5 3 5的数字常量,以及诸如“H e l l o , w o r l d !”的引用字符串)。 (2) 语法分析程序(p a r s e r) 第 1章 概 论 5下载 扫描程序 语法分 析程序 语义分 析程序 源代码 优化程序 代码 生成器 目标代码 优化程序 记号 源代码 语法树 注释树 中间 代码 目标 代码 目标代码 符号表 错误处 理器 文字表 图1-1 编译器的阶段 语法分析程序从扫描程序中获取记号形式的源代码,并完成定义程序结构的语法分析 (syntax analysis),这与自然语言中句子的语法分析类似。语法分析定义了程序的结构元素及 其关系。通常将语法分析的结果表示为分析树( parse tree)或语法树(syntax tree)。 例如,还是那行 C代码,它表示一个称为表达式的结构元素,该表达式是一个由左边为 下标表达式、右边为整型表达式的赋值表达式组成。这个结构可按下面的形式表示为一个分 析树: 请注意,分析树的内部节点均由其表示的结构名标示出,而分析树的叶子则表示输入中的 记号序列(结构名以不同字体表示以示与记号的区别)。 分析树对于显示程序的语法或程序元素很有帮助,但是对于表示该结构却显得力不从心了。 分析程序更趋向于生成语法树,语法树是分析树中所含信息的浓缩(有时因为语法树表示从分 析树中的进一步抽取,所以也被称为抽象的语法树( abstract syntax tree))。下面是一个C赋值 语句的抽象语法树的例子: 请注意,在语法树中,许多节点(包括记号节点在内)已经消失。例如,如果知道表达式 是一个下标运算,则不再需要用括号“[”和“]”来表示该操作是在原始输入中。 (3) 语义分析程序(semantic analyzer) 程序的语义就是它的“意思”,它与语法或结构不同。程序的语义确定程序的运行,但是 大多数的程序设计语言都具有在执行之前被确定而不易由语法表示和由分析程序分析的特征。 这些特征被称作静态语义( static semantic),而语义分析程序的任务就是分析这样的语义(程 序的“动态”语义具有只有在程序执行时才能确定的特性,由于编译器不能执行程序,所以它 不能由编译器来确定)。一般的程序设计语言的典型静态语义包括声明和类型检查。由语义分 析程序计算的额外信息(诸如数据类型)被称为属性( a t t r i b u t e),它们通常是作为注释或“装 饰”增加到树中(还可将属性添加到符号表中)。 在正运行的C表达式 6 编译原理及实践 下载 a [index] = 4 + 2 中,该行分析之前收集的典型类型信息可能是: a是一个整型值的数组,它带有来自整型子范 围的下标;i n d e x则是一个整型变量。接着,语义分析程序将用所有的子表达式类型来标注语 法树,并检查赋值是否使这些类型有意义了,如若没有,则声明一个类型匹配错误。在上例中, 所有的类型均有意义,有关语法树的语义分析结果可用以下注释了的树来表示: (4) 源代码优化程序(s o u rce code optimizer) 编译器通常包括许多代码改进或优化步骤。绝大多数最早的优化步骤是在语义分析之后完 成的,而此时代码改进可能只依赖于源代码。这种可能性是通过将这一操作提供为编译过程中 的单独阶段指出的。每个编译器不论在已完成的优化种类方面还是在优化阶段的定位中都有很 大的差异。 在上例中,我们包括了一个源代码层次的优化机会,也就是:表达式 4 + 2可由编译器计算 先得到结果6(这种优化称为常量合并( constant folding))。当然,还会有更复杂的情况(有 些将在第8章中提到)。还是在上例中,通过将根节点右面的子树合并为它的常量值,这个优化 就可以直接在(注释)语法树上完成: 尽管许多优化可以直接在树上完成,但是在很多情况下,优化接近于汇编代码线性化形式 的树更为简便。这样节点的变形有许多,但是三元式代码( three-address code)(之所以这样称 呼是因为它在存储器中包含了3个(或3个以上)位置的地址)却是标准选择。另一个常见的选 择是P -代码(P - c o d e),它常用于P a s c a l编译器中。 在前面的例子中,原先的C表达式的三元式代码应是: t = 4 + 2 a [ index] = t (请注意,这里利用了一个额外的临时变量 t 存放加法的中间值)。这样,优化程序就将这个代 码改进为两步。首先计算加法的结果: t = 6 a [index] = t 第 1章 概 论 7下载 接着,将t替换为该值以得到三元语句 a [index] = 6 图1 - 1已经指出源代码优化程序可能通过将其输出称为中间代码( intermediate code)来使 用三元式代码。中间代码一直是指一种位于源代码和目标代码(例如三元式代码或类似的线性 表示)之间的代码表示形式。但是,我们可以更概括地认为它是编译器使用的源代码的任何一 个内部表示。此时,也可将语法树称作中间代码,源代码优化程序则确实能继续在其输出中使 用这个表示。有时,这个中间代码也称作中间表示( intermediate representation, I R)。 (5) 代码生成器(code generator) 代码生成器得到中间代码( I R),并生成目标机器的代码。尽管大多数编译器直接生成目 标代码,但是为了便于理解,本书用汇编语言来编写目标代码。正是在编译的这个阶段中,目 标机器的特性成为了主要因素。当它存在于目标机器时,使用指令不仅是必须的而且数据的形 式表示也起着重要的作用。例如,整型数据类型的变量和浮点数据类型的变量在存储器中所占 的字节数或字数也很重要。 在上面的示例中,现在必须决定怎样存储整型数来为数组索引生成代码。例如,下面是所 给表达式的一个可能的样本代码序列(在假设的汇编语言中): M O V R0, index ;; value of index -> R0 M U L R0, 2 ;; double value in R0 M O V R1, &a ;; address of a -> R1 A D D R1, R0 ;; add R0 to R1 M O V *R1, 6 ;; constant 6 -> address in R1 在以上代码中,为编址模式使用了一个类似 C的协定,因此& a是a的地址(也就是数组的 基地址),* R 1则意味着间接寄存器地址(因此最后一条指令将值 6存放在R 1包含的地址中)。 这个代码还假设机器执行字节编址,并且整型数占据存储器的两个字节(所以在第 2条指令中 用2作为乘数)。 (6) 目标代码优化程序(target code optimizer) 在这个阶段中,编译器尝试着改进由代码生成器生成的目标代码。这种改进包括选择编址 模式以提高性能、将速度慢的指令更换成速度快的,以及删除多余的操作。 在上面给出的样本目标代码中,还可以做许多更改:在第 2条指令中,利用移位指令替代 乘法(通常地,乘法很费时间),还可以使用更有效的编址模式(例如用索引地址来执行数组 存储)。使用了这两种优化后,目标代码就变成: MOV R0, index ;; value of index -> R0 SHL R0 ;; double value in R0 MOV &a[R0], 6 ;; constant 6 -> address a + R0 到这里,对编译器阶段的简要描述就结束了,但我们还应特别强调这些讲述仅仅是示意性 的,也无需表示出正在工作中的编译器的实际结构。编译器在其结构细节上确实差别很大,然 而,上面讲到的阶段总会出现在几乎所有的编译器的某个形式上。 我们还谈到了用于维持每一个阶段所需信息的数据结构,例如语法树、中间代码(假设它 们并不相同)、文字表和符号表。下一节是编译器中的主要数据结构的概览。 1.4 编译器中的主要数据结构 当然,由编译器的阶段使用的算法与支持这些阶段的数据结构之间的交互是非常强大的。 编译器的编写者尽可能有效实施这些方法且不引起复杂性。理想的情况是:与程序大小成线性 8 编译原理及实践 下载 比例的时间内编译器,换言之就是,在 0 ( n)时间内,n是程序大小的度量(通常是字符数)。 本节将讲述一些主要的数据结构,它们是其操作部分阶段所需要的,并用来在阶段中交流信 息。 (1) 记号(t o k e n) 当扫描程序将字符收集到一个记号中时,它通常是以符号表示这个记号;这也就是说,作 为一个枚举数据类型的值来表示源程序的记号集。有时还必须保留字符串本身或由此派生出的 其他信息(例如:与标识符记号相关的名字或数字记号值)。在大多数语言中,扫描程序一次 只需要生成一个记号(这称为单符号先行( single symbol lookahead))。在这种情况下,可以用 全程变量放置记号信息;而在别的情况(最为明显的是 F O RT R A N)下,则可能会需要一个记 号数组。 (2) 语法树(syntax tre e) 如果分析程序确实生成了语法树,它的构造通常为基于指针的标准结构,在进行分析时动 态分配该结构,则整棵树可作为一个指向根节点的单个变量保存。结构中的每一个节点都是一 个记录,它的域表示由分析程序和之后的语义分析程序收集的信息。例如,一个表达式的数据 类型可作为表达式的语法树节点中的域。有时为了节省空间,这些域也是动态分配或存放在诸 如符号表的其他数据结构中,这样就可以有选择地进行分配和释放。实际上,根据它所表示的 语言结构的类型(例如:表达式节点对于语句节点或声明节点都有不同的要求),每一个语法 树节点本身都可能要求存储不同的属性。在这种情况下,可由不同的记录表示语法树中的每个 节点,每个节点类型只包含与本身相关的信息。 (3) 符号表(symbol table) 这个数据结构中的信息与标识符有关:函数、变量、常量以及数据类型。符号表几乎与编 译器的所有阶段交互:扫描程序、分析程序或将标识符输入到表格中的语义分析程序;语义分 析程序将增加数据类型和其他信息;优化阶段和代码生成阶段也将利用由符号表提供的信息选 出恰当的代码。因为对符号表的访问如此频繁,所以插入、删除和访问操作都必须比常规操作 更有效。尽管可以使用各种树的结构,但杂凑表却是达到这一要求的标准数据结构。有时在一 个列表或栈中可使用若干个表格。 (4) 常数表(literal table) 常数表的功能是存放在程序中用到的常量和字符串,因此快速插入和查找在常数表中也十 分重要。但是,在其中却无需删除,这是因为它的数据全程应用于程序而且常量或字符串在该 表中只出现一次。通过允许重复使用常量和字符串,常数表对于缩小程序在存储器中的大小显 得非常重要。在代码生成器中也需要常数表来构造用于常数和在目标代码文件中输入数据定义 的符号地址。 (5) 中间代码(intermediate code) 根据中间代码的类型(例如三元式代码和P -代码)和优化的类型,该代码可以是文本串 的数组、临时文本文件或是结构的连接列表。对于进行复杂优化的编译器,应特别注意选择允 许简单重组的表示。 (6) 临时文件(t e m p o r a ry file) 计算机过去一直未能在编译器时将整个程序保留在存储器中。这一问题已经通过使用临时 文件来保存翻译时中间步骤的结果或通过“匆忙地”编译(也就是只保留源程序早期部分的足 够信息用以处理翻译)解决了。存储器的限制现在也只是一个小问题了,现在可以将整个编译 单元放在存储器之中,特别是在可以分别编译的语言中时。但是偶尔还是会发现需要在某些运 第 1章 概 论 9下载 行步骤中生成中间文件。其中典型的是代码生成时需要反填( b a c k p a t c h)地址。例如,当翻译 如下的条件语句时 if x = 0 then ... else ... 在知道e l s e部分代码的位置之前必须由文本跳到e l s e部分: CMP X, 0 JNE NEXT ;; location of NEXT not yet known < code for then-part > N E X T : < code for else-part > 通常,必须为N E X T的值留出一个空格,一旦知道该值后就会将该空格填上,利用临时文 件可以很容易地做到这一点。 1.5 编译器结构中的其他问题 可从许多不同的角度来观察编译器的结构。 1 . 3节已讲述了它的阶段——用来表示编译器的 逻辑结构。此外,还有其他一些可能的观点:编译器的物理结构、操作的顺序等等。由于编译 器的结构对其可靠性、有效性、可用性以及可维护性都有很大的影响,所以编译器的编写者应 熟悉尽可能多的有关编译器结构的观点。本节将考虑编译器结构的其他方面以及每一个观点是 如何应用的。 (1) 分析和综合 在这个观点中,已将分析源程序以计算其特性的编译器操作归为编译器的分析( a n a l y s i s) 部分,而将生成翻译代码时所涉及到的操作称作编译器的综合( s y n t h e s i s)部分。当然,词法 分析、语法分析和语义分析均属于分析部分,而代码生成却是综合部分。在优化步骤中,分析 和综合都有。分析正趋向于易懂和更具有数学性,而综合则要求更深的专业技术。因此,将分 析步骤和综合步骤两者区分开来以便发生变化时互不影响是很有用的。 (2) 前端和后端 本观点认为,将编译器分成了只依赖于源语言(前端( front end))的操作和只依赖于目 标语言(后端(back end))的操作两部分。这与将其分成分析和综合两部分是类似的:扫描 程序、分析程序和语义分析程序是前端,代码生成器是后端。但是一些优化分析可以依赖于目 标语言,这样就是属于后端了,然而中间代码的综合却经常与目标语言无关,因此也就属于前 端了。在理想情况下,编译器被严格地分成这两部分,而中间表示则作为其间的交流媒介。 这一结构对于编译器的可移植性( p o r t a b i l i t y)十分重要,此时设计的编译器既能改变源 代码(它涉及到重写前端),又能改变目标代码(它还涉及到重写后端)。在实际中,这是很难 做到的,而且称作可移植的编译器仍旧依赖于源语言和目标语言。其部分原因是程序设计语言 和机器构造的快速发展以及根本性的变化,但是有效地保持移植一个新的目标语言所需的信息 或使数据结构普遍地适合改变为一个新的源语言所需的信息却十分困难。然而人们不断分离前 端和后端的努力会带来更方便的可移植性。 (3) 遍 编译器发现,在生成代码之前多次处理整个源程序很方便。这些重复就是遍( p a s s)。首 遍是从源中构造一个语法树或中间代码,在它之后的遍是由处理中间表示、向它增加信息、更 1 0 编译原理及实践 下载 前端 后端源 代码 中间 代码 目标 代码 换结构或生成不同的表示组成。遍可以和阶段相应,也可无关——遍中通常含有若干个阶段。 实际上,根据语言的不同,编译器可以是一遍( one pass)——所有的阶段由一遍完成,其结 果是编译得很好,但(通常)代码却不太有效。 P a s c a l语言和 C语言均允许单遍编译。 (M o d u l a - 2语言的结构则要求编译器至少有两遍)。大多数带有优化的编译器都需要超过一遍: 典型的安排是将一遍用于扫描和分析,将另一遍用于语义分析和源代码层优化,第 3遍用于代 码生成和目标层的优化。更深层的优化则可能需要更多的遍: 5遍、6遍、甚至8遍都是可能的。 (4) 语言定义和编译器 我们注意到在1 . 1节中,程序设计语言的词法和语法结构通常用形式的术语指定,并使用 正则表达式和上下文无关文法。但是,程序设计语言的语义通常仍然是由英语(或其他的自 然语言)描述的。这些描述(与形式的词法及语法结构一起)一般是集中在一个语言参考手 册(language reference manual)或语言定义(language definition)之中。因为编译器的编写 者掌握的技术对于语言的定义有很大的影响,所以在使用了一种新的语言之后,语言的定义 和编译器同时也能够得到开发。类似地,一种语言的定义对于构造编译器所需的技术也有很 大的关系。 编译器的编写者更经常遇到的情况是:正在实现的语言是众所周知的并已有了语言定义。 有时这个语言定义已达到了某个语言标准( language standard)的层次,语言标准是指得到诸 如美国国家标准协会( American National Standards Institute,A N S I)或国际标准化组织 (International Organization for Standardization,I S O)的官方标准组织批准的标准。F O RT R A N、 P a s c a l和C语言就具有A N S I标准,A d a有一个通过了美国政府批准的标准。在这种情况下,编 译器的编写者必须解释语言的定义并执行符合语言定义的编译器。通常做到这一点并不容易, 但是有时由于有了标准测试程序集(测试组( test suite)),就能够测试编译器( A d a有这样一 个测试组),这又变得简单起来了。文本中使用的 T I N Y示范语言有其词法、语法和语义结构, 在2 . 5节、3 . 7节和6 . 5节中将分别谈到这些。附录 A包括了用于C - M i n u s编译器项目语言的一个 最小的语言参考手册。 有时候,一种语言可从数学术语的形式定义( formal definition)中得到它的语义。现在人 们已经使用了许多方法,尽管一个称作表示语义( denotational semantics)的方法已经成为较 为常用的方法,在函数编程共同体中尤为如此,但现在仍然没有一种可成为标准的方法。当语 言有一个形式定义时,那么在理论上就有可能给出编译器与该定义一致的数学证明,但是由于 这太难了而几乎从未有人做过。无论怎样,该技术已超出了本书的范围,本书也不会涉及到形 式语义方面的知识。 运行时环境的结构和行为是尤其受到语言定义影响的编译器构造的一个方面。运行时环境 将在第7章中学习。尽管此时它没有多大用处,但程序设计语言所允许的数据结构(尤其是被 许可的函数调用和返回值的类型)对于运行时系统的复杂程度具有决定性意义。以下是运行时 环境的3个基本类型(按难易程度排列): 首先是F O RT R A N 7 7,它没有指针或动态分配,也没有递归函数调用,但它允许有一个完 整的静态运行时环境。在这个环境中,所有存储器的分配都在执行之前进行。因为无需生成代 码来维护环境,编译器的编写者的分配工作也就容易许多了。其次是 P a s c a l、C和其他类似 A l g o l的语言,它们允许有限动态分配以及递归函数的调用,并且要求“半动态”或带有额外 的动态结构(称为堆,由此程序员可安排动态分配)的基于栈的运行时环境。最后是面向对象 的函数语言,如L I S P和S m a l l t a l k,它们要求“完全动态”的环境,在其中所有的分配都是由编 译器的生成代码自动完成的。因为它要求也能够自动释放存储器,而这又相应地要求复杂的 第 1章 概 论 11下载 “垃圾回收”算法,所以它很复杂。在学习运行时环境时将会讨论到它,但是更为复杂的内容 就超出本书的范围了。 (5) 编译器的选项和界面 编译器结构的一个重要方面是包含了一种机制与操作系统相连接,并为了满足用户的各种 目的而提供选择。界面机制的例子是提供对目标机器的文件系统的访问以及输入和输出功能。 用户的选项可包括列表特征(长度、出错信息和相互对照表)的说明和代码优化选项(只是某 个优化的执行)。选项和界面共称为编译器的语用学( p r a g m a t i c s)。有时一种语言的定义指出 必须提供的语用学。例如, P a s c a l和C语言均指出一定的输入 /输出过程(在P a s c a l中,它们是 语言特性中的一部分;而在 C语言中,它们是标准库说明的一个部分)。在A d a中,许多编译器 的指示(称为(p r a g m a s))则是语言定义的一部分。例如:A d a语句 pragma LIST(ON); . . . pragma LIST(OFF); 为包含在p r a g m a s中的程序部分生成了一个编译器列表。在这个文本中,我们发现编译器指 示仅存在于用作编译器调试的生成列表信息的上下文中;另外,也不会在输入 /输出和操作系统 界面中处理问题,这是因为它们涉及到了大量的细节,而且随操作系统的不同而有很大差异。 (6) 出错处理 编译器的一个最为重要的功能是其对源程序中错误的反应。几乎在编译的每一个阶段中都 可以诊察出错误来。这些静态(或称为编译时( c o m p i l e - t i m e))的错误(static error)必须由 编译器来报告,而编译器能够生成有意义的出错信息并在每一个错误之后恢复编译是非常重要 的。编译器的每一个阶段都需要一个类型略为不同的出错处理,因此错误处理器( e r r o r h a n d l e r)必须包括不同的操作,每个操作都给出指定的阶段和结构。因此,读者将在相应的章 节中学到每一个阶段的出错处理技术。 语言定义经常要求编译器不仅能够找到静态错误,而且还能找到执行错误。这就需要编译 器生成额外的代码,该代码将执行恰当的运行时测试,以保证所有这样的错误都将在执行时引 起一个合适的事件。在此类事件中,最简单的就是中止程序的执行。但这经常是不合适的,而 且语言的定义可能要求存在异常处理( exception handling)机制。这将使运行时系统的管理变 得非常复杂,当程序可能由错误发生处继续执行时尤其如此。本书并不涉及到这样一个机制的 执行情况,但会提到编译器如何生成测试代码,以保证指定的运行时错误引起执行中止。 1.6 自举与移植 前面已经讨论过源语言和目标语言在编译器结构中的决定因素,以及将源语言和目标语言 分为前端和后端的作用,但是却未提到过编译器构造过程中涉及到的另一个语言:编写编译器 本身使用的语言。为了使编译器能立即执行,这个执行(或宿主 ( h o s t ))语言只能是机器语言。 当时并没有编译器,因此这确实是最初的编译器编写所用的语言。现在更为合理的方法是用另 一种语言来编写编译器,而使用该种语言的编译器早已存在了。如果现存的编译器已经在目标 机器上运行了,则只需利用现存的编译器编译出新的编译器以得到可运行的程序: 当语言B的现存编译器没有运行在目标机器上时,情况会更复杂一些。编译将产生一个交叉编 1 2 编译原理及实践 下载 用语言B编写 语言A的编译器 语言A正运 行的编译器 语言B已存 在的编译器 译器(cross compiler),也就是一个为不同于它在运行之上的机器生成目标代码的编译器。这 种以及其他更为复杂的情况最好通过将编译器画成一个 T型图(T- d i a g r a m)(以其形状来命名) 来描述。用语言H(代表宿主语言)编写的编译器将语言 S(代表源语言)翻译为语言T(代表 目标语言)可画成以下的T型图: 请注意,这与表示编译器是在“机器” H上运行是等价的(如果 H不是机器代码,则可认 为其是一个假定机器的可执行代码)。我们一般都希望H与T相同(也就是编译器为与之运行之 上一样的机器生成代码),但是也并不是必须这样做。 可以用两种方法组合 T型图。一种是,如果在一台机器 H上运行有两个编译器,其中一个 编译器将语言A翻译为语言B,另一个将语言B翻译为语言C,就可按照将第1个的输出作为第2 个的输入来组合。其所得结果就是一个在机器 H上的由A到C的编译。将该过程表示为: 另一种是,利用由“机器” H到“机器”K的编译器来翻译由H到K的其他编译器的执行语 言。表示如下: 在上面的描述中,第 1个假定是,在机器 H上利用语言B现存的编译器将语言 A翻译为用B 编写的语言H。它是前面所讲的特例,如下所示: 第2个假定是,当语言 B的编译器运行在另一台机器上时,就会引出语言 A的交叉编译器。 如下图所示: 以将要编译的相同语言编写一个编译器是很普通的: 第 1章 概 论 13下载 但这将表现为一个循环错误:因为如果源语言的编译器不存在,那么编译器本身也就不可 能被编译了。从这个方法中可以得到很重要的启示。 让我们设想一个问题:如何解决循环。我们可以在汇编语言中编写一个“虽快但不佳”的 编译器,并翻译那些在编译器中真正使用得到的语言特征(当然,在编写“较好的”编译器时, 会对使用那些特征有所限制)。这些“虽快但不佳”的编译器也可能产生极为无效的代码(它 仅需要正确而已!)。一旦运行这个“虽快但不佳”的编译器,就可用它来编译那个“较好的” 编译器。接着,对“较好的”编译器进行重编译以得到最终的版本。人们将这个过程称为自举 (b o o t s t r a p p i n g)。图1-2a 和图1-2b 描述了这一过程。 自举之后,在源代码和执行代码中就有了一个编译器。这样做的好处在于:通过应用与 前面相同的两步过程,编译器的源代码的任何改进都会立即被自举到一个正在工作着的编译 器中。 除此之外,还有一个好处。现在将编译器移植到一个新的主机,只要求重写源代码的后端 来生成新机器的代码。接着用旧的编译器来编译它以生成一个交叉编译器,该编译器又再次被 交叉编译器重新编译,以得到新机器的工作版本。图 1 - 3 a和图1 - 3 b描述了这一过程。 1.7 TINY样本语言与编译器 任何一本关于编译结构的书如果不包括编译过程步骤的示例就不能算完整。本书将会多次 用从现有的语言(如C、C + +、P a s c a l和A d a)中抽取的实例来讲解。但是仅用这些实例来描述 编译器的各个部分是如何协调一致的却不够。因此,写出一个完整的编译器并对其操作进行注 释仍是很必要的。 描述真实的编译器非常困难。“真正的”编译器——也就是希望在每天编程中用到的—— 内容太复杂而且不易在本教材中掌握。另一方面,一种很小的语言(其列表包括 1 0页左右的文 本)的编译也不可能准确地描述出“真正的”编译器所需的所有特征。 为了解决上述问题,人们在(A N S I)C中为小型语言提供了完整的源代码,一旦能明白这 1 4 编译原理及实践 下载 用语言A自身 编写的编译器 用机器语言写的“虽快但不佳”的编译器 编译器的最终版本 用语言A自身 编写的编译器 正运行但效率低的编译器(从第一步得来作) 编译器的最终版本 a) b) 图1-2 自举进程 目标重定位为K 的编译器源代码 原始编译器 交叉编译器 目标重定位为K 的编译器源代码 交叉编译器 目标重定位后的编译器 a) b) 图1-3 移植一个在其自身源代码中编写的编译器 a) 自举进程中的第1个步骤 b) 自举进程中的第2个步骤 a) 步骤1 b) 步骤2 种技术,就能够很容易地理解这种小型语言的编译器了。这种语言称作 T I N Y,在每一章的示 例中都会用到它,它的编译代码也很快会被提到。完整的编译代码汇集在附录 B中。 还有一个问题是:选择哪一种机器语言作为 T I N Y编译器的目标语言?为现有的处理器使 用真正的机器代码的复杂性使得这个选择很困难。但是选择特定的处理器也将影响到执行这些 机器生成的目标代码。相反地,可将目标代码简化为一个假定的简单处理器的汇编语言,这个 处理器称为T M机(tiny machine)。在这里只是简单地谈谈,详细内容将放在第8章(代码生成) 中。附录C有C的T M模拟程序列表。 1.7.1 TINY 语言 T I N Y的程序结构很简单,它在语法上与 A d a或P a s c a l的语法相似:仅是一个由分号分隔开 的语句序列。另外,它既无过程也无声明。所有的变量都是整型变量,通过对其赋值可较轻易 地声明变量(类似 F O RT R A N或B A S I C)。它只有两个控制语句: i f语句和r e p e a t语句,这两个 控制语句本身也可包含语句序列。 I f语句有一个可选的e l s e部分且必须由关键字e n d结束。除此 之外,r e a d语句和w r i t e语句完成输入/输出。在花括号中可以有注释,但注释不能嵌套。 T I N Y的表达式也局限于布尔表达式和整型算术表达式。布尔表达式由对两个算术表达式 的比较组成,该比较使用 <与=比较算符。算术表达式可以包括整型常数、变量、参数以及 4个 整型算符+、-、*、/,此外还有一般的数学属性。布尔表达式可能只作为测试出现在控制语 句中——而没有布尔型变量、赋值或 I / O。 程序清单1 - 1是该语言中的一个阶乘函数的简单编程示例。这个例子在整本书中都会用到。 程序清单1-1 一个输出其输入阶乘的T I N Y语言程序 虽然T I N Y缺少真正程序设计语言所需要的许多特征——过程、数组且浮点值是一些较大 的省略——但它足可以用来例证编译器的主要特征了。 1.7.2 TINY编译器 T I N Y编译器包括以下的C文件,(为了包含而)把它的头文件放在左边,它的代码文件放 在右边: g l o b a l s . h m a i n . c u t i l . h u t i l . c s c a n . h s c a n . c p a r s e . h p a r s e . c 第 1章 概 论 15下载 s y m t a b . h s y m t a b . c a n a l y z e . h a n a l y z e . c c o d e . h c o d e . c c g e n . h c g e n . c 除了将m a i n . c 放在g l o b a l s . h 的前面之外,这些文件的源代码及其行号都按顺序列在附录 B中了。任何代码文件都包含了 g l o b a l s . h 头文件,它包括了数据类型的定义和整个编译器 均使用的全程变量。m a i n . c 文件包括运行编译器的主程序,它还分配和初始化全程变量。其 他的文件则包含了头 /代码文件对、在头文件中给出了外部可用的函数原型以及在相关代码文 件中的实现(包括静态局部函数)。s c a n、p a r s e、a n a l y z e 和c g e n 文件与图1 - 1中的扫描 程序、分析程序、语义分析程序和代码生成器各阶段完全相符。 u t i l 文件包括了实用程序函 数,生成源代码(语法树)的内部表示和显示列表与出错信息均需要这些函数。 s y m t a b 文件 包括执行与T I N Y应用相符的符号表的杂凑表。 c o d e文件包括用于依赖目标机器(将在1 . 7 . 3节 描述的T M机)的代码生成的实用程序。图 1 - 1还缺少一些其他部分:没有单独的错误处理器或 文字表且没有优化阶段;没有从语法树上分隔出来的中间代码;另外,符号表只与语义分析程 序和代码生成器交互(这将在第6章中再次讨论到)。 虽然这些文件中的交互少了,但是编译器仍有 4遍:第1遍由构造语法树的扫描程序和分析 程序组成;第2遍和第3遍执行语义分析,其中第2遍构造符号表而第3遍完成类型检查;最后一 遍是代码生成器。在m a i n . c中驱动这些遍的代码十分简单。当忽略了标记和编辑时,它的中 心代码如下(请参看附录B中的第6 9、7 7、7 9和9 4行): syntaxTree = parse( ); buildSymtab (syntaxTree); typeCheck (syntaxTree); codeGen (syntaxTree, codefile); 为了灵活起见,我们还编写了条件编译标志,以使得有可能创建出一部分的编译器。如下是该 标志及其效果: 标 志 设置效果 编译所需文件(附加) N O _ P A R S E 创创建只扫描的编译器 globals.h, main.c, util.h, util.c, scan.h, scan.c N O _ A N A L Y Z E 创创建只分析和扫描的 parse.h, parse.c 编译器 N O _ C O D E 创创建执行语义分析, symtab.h, symtab.c, 但不生成代码的编译器 a n a l y z e . h , a n a l y z e . c 尽管这个T I N Y编译器设计得有些不太实际,但却有单个文件与阶段基本一致的好处,在 以后的章节中将会一个一个地学到这些文件。 任何一个ANSI C编译器都可编译T I N Y编译器。假定可执行文件是 t i n y,通过使用以下 命令: tiny sample. tny 就可用它编译文本文件s a m p l e . t n y 中的T I N Y源程序。(如果省略了. t n y,则编译器会 自己添加. t n y后缀)。屏幕上将会出现一个程序列表(它可被重定向到一个文件之上)并且 (如当代码生成是被激活的)生成目标代码文件 s a m p l e . t m(在下面将谈到的T M机中使用)。 1 6 编译原理及实践 下载 在编辑列表的信息中有若干选项,以下的标志均可用: 标 志 设 置 效 果 E c h o S o u r c e 将T I N Y源程序回显到带有行号的列表 T r a c e S c a n 当扫描程序识别出记号时,就显示每个记号的信息 T r a c e P a r s e 将语法树以线性化格式显示 T r a c e A n a l y z e 显示符号表和类型检查的小结信息 T r a c e C o d e 打印有关代码文件的代码生成跟踪注释 1.7.3 TM 机 我们用该机器的汇编语言作为 T I N Y编译器的目标语言。 T M机的指令仅够作为诸如 T I N Y 这样的小型语言的目标。实际上,T M具有精减指令集计算机(R I S C)的一些特性。在R I S C中, 所有的算法和测试均须在寄存器中进行,而且地址模式极为有限。为了使读者了解到该机的简 便之处,我们将下面C表达式的代码 a [index] = 6 翻译成T M汇编语言(请读者将它与1 . 3节中相同语句假定的汇编语言比较一下): LDC 1, 0 ( 0 ) load o into reg 1 * 下面指令 * 假设index 在存储器地址1 0中 LD 0, 10 ( 1 ) load val at (10+R1 into R0 LDC 1, 2 ( 0 ) load 2 into reg 1 MUL 0, 1, 0 put R1 * R0 into R0 LDC 1, 0 ( 0 ) load 0 into reg 1 * 下面指令 * 假设a在存储器地址 2 0中 LDA 1, 20 ( 1 ) load 20 + R1 into R0 ADD 0, 1, 0 put R1 + R0 into R0 LDC 1, 6 ( 0 ) load 6 into reg 1 ST 1, 0 ( 0 ) store R1 at 0 + R0 我们注意到装入操作中有 3个地址模式并且是由不同的指令给出的: L D C是“装入常量”, L D是“由存储器装入”,而L D A是“装入地址”。另外,该地址通常必须给成“寄存器 +偏差” 值。例如“1 0 ( 1 )”(上面代码的第2条指令),它代表在将偏差1 0加到寄存器1的内容中计算该 地址。(因为在前面的指令中,0已被装入到寄存器1中,这实际是指绝对位置1 0) 。我们还看 到算术指令M U L和A D D可以是“三元”指令且只有寄存器操作数,其中可以单独确定结果的目 标寄存器(1 . 3节中的代码与此相反,其操作是“二元的”)。 T M机的模拟程序直接从一个文件中读取汇编代码并执行它,因此应避免将由汇编语言翻 译为机器代码的过程复杂化。但是,这个模拟程序并非是一个真正的汇编程序,它没有符号地 址或标号。因此,T I N Y编译器必须仍然计算跳转的绝对地址。此外为了避免与外部的输入 /输 出例程连接的复杂性,T M机有内部整型的I / O设备;在模拟时,它们都对标准设备读写。 第 1章 概 论 17下载 L D C命令也要求一个“寄存器+偏差”的格式;但由于T M汇编程序格式简单统一,也就忽略了寄存器,偏差 本身也作为常量装入。 通过使用任何一个ANSI C编译器,都可将t m . c 源代码编译成T M模拟程序。假定它的可 执行文件叫作t m,通过发出命令 tm sample.tm 就可使用它了。其中,sample. tm是T I M Y编译器由sample. tny源文件生成的代码文件。该命令 引起代码文件的汇编和装入,接着就可交互地运行 T M模拟程序了。例如:如果 sample. tny是 程序清单1 - 1中的范例程序,则按以下计算就可得到7的阶乘: tm sample. tm TM simulation (enter h for help) ... Enter command: go Enter value for IN instruction: 7 OUT instruction prints: 5040 HALT: 0, 0, 0 H a l t e d Enter command: quit Simulation done. 1.8 C-Minus:编译器项目的一种语言 附录A将描述一个比T I N Y更大的适用于编译器项目的语言。由于它受到C子集的严格限制, 因此就称作C - M i n u s。它包括整型变量、整型数组以及函数(包含过程或空函数);它还有局 部、全局(静态)说明和(简单)递归函数,此外还有 i f语句和w h i l e语句;除此之外几乎什么 也没有了。程序由函数序列和变量说明序列组成。最后必须是一个 m a i n函数,执行则由一个 对m a i n的调用开始 。 程序清单1 - 2是在C - M i n u s中的一个程序示例,在其中通过递归函数编写了程序清单 1 - 1中 的阶乘程序。该程序的输入 /输出由一个 r e a d函数提供,此外还有一个根据标准的 C函数 s c a n f和p r i n t f定义的w r i t e函数。 程序清单1-2 一个以其输入的阶乘为输出的 C - M i n u s程序 C - M i n u s是一个比T I N Y复杂的语言,在它的代码生成的要求中尤其如此;但是, T M机仍 是其编译器合理的目标机器。附录A中有关于如何修改和扩充T I N Y编译器到C - M i n u s的内容。 1 8 编译原理及实践 下载 为了与C - M i n u s的其他函数一致, m a i n被说明为一个带有 v o i d参数列表的 v o i d函数。尽管这与ANSI C不同, 但编译器还是接受了这种表示。 练习 1.1 从开发环境中找一个熟悉的编译器,并列出所有的相关程序,这些相关程序适用于与 编译器一同进行操作,该编译器带有对其函数的简要描述。 1.2 下面是C中的赋值 a [ i + 1 ] = a [ i ] + 2 利用1 . 3节中的类似例子为参考,画出这个表达式的分析树和语法树。 1.3 编译错误大致可分为两类:语法错误和语义错误。语法错误包括丢失记号和记号放置 错误,例如算术表达式( 2 + 3,就缺少了右括号。语义错误包括表达式中错误的类型 及未说明的变量(在大多数语言中),例如赋值x = 2,其中x 是一个数组变量。 a. 在你所选语言的每一个类型的错误中,再举出两个例子。 b. 找出一个你所熟悉的编译器,并判断它是在语义错误之前列出了所有的语法错误还 是混合了语法和语义错误。这和编译的遍数有什么关系? 1.4 这个问题假设你有一个编译器,它有一个产生汇编语言输出的选项。 a. 判断你的编译器是否完成常量的合并优化。 b. 一个相关的但更先进的优化是常量传送的优化:当变量在表达式中有一个常量值时, 它就由该值替代。例如,代码(在C语法中): x = 4; y = x + 2; 通过常量传送(和常量合并)就变成代码: x = 4; y = 6; 判断你的编译器是否进行了常量的传送。 c. 为什么常量传送比常量合并更难,请说出尽可能多的理由。 d. 与常量传送和常量合并相关的情况是程序中被命名的常量的用法。利用命名了的常 量x代替一个变量,就可将上例翻译为以下的代码: const int x = 4; . . . y = x + 2; . . . 判断你的编译器是否在这些情况下执行传送/合并,这与b有何不同? 1.5 若你的编译器由键盘直接接受输入,则判断编译器是在生成出错信息之前读取整个程 序还是在遇到它们时生成出错信息。这和编译的遍数有什么关系? 1.6 描述由以下程序完成的任务,并解释它们怎样与编译器相似或相关: a. 语言预处理器 b. 格式打印程序 c. 文本格式化程序 1.7 假设有一个用C编写的由P a s c a l到C的翻译程序以及一个可运行的 C编译器。请利用 T 型图来描述创建可运行的P a s c a l编译器的步骤。 1.8 我们已用箭头符 表示出将一个有两个T型图的格式变成只有一个T型图的格式。可 将这个箭头符认为是一个“归约相关”,并构成它的传递闭包 *,在其中允许有归 约序列发生。下面给出了一个 T型图,其中字母代表任意语言。请判断哪些语言必须 第 1章 概 论 19下载 相等才能使归约有效,并且显示使它有效的单个归约步骤: 请给出一个该图所描述归约的实例。 1.9 在1 . 6节的图1 - 3中移植编译器的另一种方法是:对编译器生成的中间代码使用解释程 序,并完全去掉后端。 Pascal P-system使用的就是这种方法,Pascal P-system包括了 生成P -代码的P a s c a l编译器、“一般的”栈式机器的汇编代码,以及模拟执行 P -代码 的P -代码解释程序。P a s c a l编译器和P -代码解释程序均用P -代码编写。 a. 假设有一个Pascal P-system,请描述在任一台机器上得到可运行的 P a s c a l编译器的 步骤。 b. 描述在( a )中从你的系统得到一个可运行的本机代码编译器必需的步骤(也就是, 为主机生成可执行代码的编译器,但不使用 P -代码解释程序)。 1.10 可以认为移植编译器的过程有两个不同的操作:重置目标( r e t a rg e t i n g)(为新机器 修改编译器以生成目标代码)和重置主机( r e h o s t i n g)(修改编译程序以在新机器上 运行)。请根据T型图讨论两个操作的不同之处。 注意与参考 本章所讲到的大部分问题都将在以后各章中更加详细地再次提到,并且在以后的“注意与 参考”中也会给出恰当的参考。例如,第 2章会谈到L e x,第5章中则是Ya c c,第6章中是类型检 查、符号表和属性分析,第 8章中则为代码生成、三元式代码和 P -代码,第4章和第5章会研究 错误处理。 Aho [1986]是编译器的一个标准的综合参考,尤其是在理论和算法方面。 Fischer and LeBlanc [1991]中有许多有用的实行提示。在Fraser and Hanson [1995]和Holub [1990]中可找到 对C编译器的完整描述。G n u编译器是一个源代码在I n t e r n e t上广泛应用的流行的C / C + +编译器, Stallman [1994]详细描述了它。 如要了解程序设计语言的概念以及其与翻译程序相互影响的资料,可参考 Louden [1993]或 Sethi [1996]。 Hopcroft and Ullman [1979]对来自数学观点(不同于此处的实用观点)的自动机理论很有 用。在这里还可以找到更多的有关乔姆斯基分类的内容(第 3章中也会提到)。 Backus [1957]和Backus [1981]中有对早期F O RT R A N编译器的描述。在Randell and Russell [ 1 9 6 4 ]中有对早期的A l g o 1 6 0编译器的描述。B a r r o n [ 1 9 8 1 ]描述了P a s c a l编译器,在这里还提到 了Pascal P-system(Nori [1981])。 Kernighan [1975]中有1 . 2节中提到的R a t f o r预处理器,Bratman [1961]介绍了1 . 6节的T型图。 本书着重于对大多数语言的翻译中有用的标准翻译技术。要对在基于 A l g o l命令语言的主 要传统语言之外的语言进行有效的翻译可能还需要其他技术。特别地,用于诸如 M L和H a s k e l l 的函数语言翻译已经发展了许多新技术,其中一些可能会成为将来重要的通用技术。 A p p e l [ 1 9 9 2 ]、Peyton Jones [1992]和Peyton Jones [1987]均提到了这些技术。Peyton Jones [1987]还描 述了H i n d l e y - M i l n e r类型检查(1 . 1节中已讲到过)。 2 0 编译原理及实践 下载
还剩19页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 5 金币 [ 分享pdf获得金币 ] 0 人已下载

下载pdf

pdf贡献者

shy8587722

贡献于2014-01-27

下载需要 5 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf