编译原理引论3

在阅读本篇文章之前,建议读者优先阅读本专栏内前面的文章。

目录

前言

一、构建一个编译器的相关科学

二、高级程序设计语言的应用

三、针对计算机体系结构的优化

四、新计算机体系结构的设计

五、程序翻译

六、软件生产率工具

总结


前言

本篇文章紧接前面文章,接着详细介绍关于构建一个编译器的相关科学和编译技术的应用这两个主要部分。


一、构建一个编译器的相关科学

编译器的设计中有很多通过数学方法抽象出问题本质从而解决现实世界中复杂问题的完美例子。这些例子可以被用来说明如何使用抽象方法来解决问题:接受一个问题,写出抓住了问题的关键特性的数学抽象表示,并用数学技术来解决它。问题的表达必须根植于对计算机程序特性的深入理解,而解决方法必须使用经验来验证和精化。编译器必须接受所有遵循语言规范的源程序。源程序的集合是无穷的,而程序可能大到包含几百万行代码。在翻译一个源程序的过程中,编译器所做的任何翻译工作都不能改变被编译源程序的含义。因此,编译器设计者的工作不仅会影响到他们创建的编译器,还会影响到他们所创建的编译器所翻译的全部程序。这种杠杆作用使得编译器设计的回报丰厚,但也使得编译器的开发工作具有挑战性。

对编译器的研究主要是有关如何设计正确的数学模型和选择正确算法的研究。设计和选择时,还需要考虑到通用性及功能的要求与简单性及有效性之间的平衡。最基本的数学模型是我们将在之后介绍的有穷状态自动机和正则表达式。这些模型可以用于描述程序的词法单位(关键字、标识符等)以及描述被编译器用来识别这些单位的算法。最基本的模型中还包括上下文无关文法,它用于描述程序设计语言的语法结构,比如嵌套的括号和控制结构。我们也将在之后研究文法。类似地,树形结构是表示程序结构以及程序到目标代码的翻译方法的重要模型。

在编译器设计中,术语"优化"是指编译器为了生成比浅显直观的代码更加高效的代码而做的工作。"优化"这个词并不恰当,因为没有办法保证一个编译器生成的代码比完成相同任务的任何其他代码更快,或至少一样快。现在,编译器所做的代码优化变得更加重要,而且更加复杂。之所以变得更加复杂,是因为处理器体系结构变得更加复杂,也有了更多改进代码执行方式的机会。之所以变得更加重要,是因为巨型并发计算机要求实质性的优化,否则它们的性能将会呈数量级地下降。随着多核计算机日益流行,所有的编译器都将面临充分利用多处理器计算机的优势的问题。

即使有可能通过随意的方法来建造一个健壮的编译器,实现起来也是非常困难的。因此,人们已经围绕代码优化建立了一套广泛且有用的理论。应用严格的数学基础,使得我们可以证明一个优化是正确的,并且它对所有可能的输入都产生预期的效果。在之后的内容中我们将会看到,如果想使得编译器产生经过良好优化的代码,图、矩阵和线性规划之类的模型是必不可少的。从另一方面来说,只有理论是不够的。很多现实世界中的问题都没有完美的答案。实际上,我们在编译器优化中提出的很多问题都是不可判定的。在编译器设计中,最重要的技能之一是明确描述出真正要解决的问题的能力。我们在一开始需要对程序的行为有充分的了解,并且需要通过充分的试验和评价来验证我们的直觉。

编译器优化必须满足下面的设计目标有以下四个:优化必须是正确的,也就是说,不能改变被编译程序的含义;优化必须能够改善很多程序的性能;优化所需的时间必须保持在合理的范围内;所需要的工程方面的工作必须是可管理的。

对正确性的强调是无论如何不会过分的。不管我们设计得到的编译器能够生成运行速度多么快的代码,只要生成的代码不正确,这个设计就是毫无意义的。正确设计优化编译器是如此困难,我们敢说没有一个优化编译器是完全无错的!因此,设计一个编译器时最重要的目标是使它正确。第二个目标是编译器应该有效提高很多输入程序的性能。性能通常意味着程序执行的速度。我们也希望能够尽可能降低生成代码的大小,在嵌入式系统中更是如此。而对于移动设备的情况,尽量降低代码的能耗也是我们期待的。在通常情况下,提高执行效率的优化也能够节约能耗。除了性能,错误报告和调试等可用性方面也很重要。第三,我们需要使编译时间保持在较短的范围内,以支持快速的开发和调试周期。当机器变得越来越快,这个要求会越来越容易达到。开始时,一个程序经常在没有进行优化的情况下开发和调试。这个做法不仅可以降低编译时间,更重要的是未经优化的程序比较容易调试。这是因为编译器引入的优化经常使得源代码和目标代码之间的关系变得模糊。在编译器中开启优化时会暴露出源程序中的新问题,因此需要对经过优化的代码再次进行测试。因为可能需要额外的测试工作,有时会阻止人们在应用中使用优化技术,当应用的性能不很重要的时候更是如此。最后,编译器是一个复杂的系统,我们必须使系统保持简单以保证编译器的设计和维护费用是可管理的。我们可以实现的优化技术有无穷多种,而创建一个正确有效的优化过程需要相当大的工作量。我们必须划分不同优化技术的优先级别,只实现那些可以对实践中遇到的源程序带来最大好处的技术。

二、高级程序设计语言的应用

一个高级程序设计语言定义了一个编程抽象:程序员使用这个语言表达算法,而编译器必须把这个程序翻译成目标语言。总的来说,用高级程序设计语言编程比较容易,但是比较低效,也就是说,目标程序运行较慢。使用低级程序设计语言的程序员能够更多地控制一个计算过程,因此从原则上讲,可以产生更加高效的代码。遗憾的是,低级程序比较难编写,而且更糟糕的是可移植性较差,更容易出错,而且更加难以维护。优化编译器包括了提高所生成代码性能的技术,因此弥补了因高层次抽象而引入的低效率。

我们可以看看龙书上的例1.2,C语言中的关键字register是编译器技术和语言发展互动的一个较早的例子。当C语言在20世纪70年代中期被创立时,人们认为有必要让程序员来控制哪个程序变量应该存放在寄存器中。当有效的寄存器分配技术出现后,这个控制变得没有必要了,大多数现代的程序不再使用这个语言特征。实际上,使用关键字register的程序还可能损失效率,因为寄存器分配是一类很低层次的问题,程序员常常不是最好的判断这类问题的人选。寄存器分配的最优选择很大程度上取决于一个机器的体系结构的特点。把低层次资源管理的决策,比如寄存器分配,写死在程序中反而有可能损害性能。当运行程序的计算机有别于当初所设定的目标机时更是如此。

对于程序设计语言的选择的变化与不断提高抽象层次的方向是一致的。C语言是在20世纪80年代主流的系统程序设计语言;20世纪90年代开始的很多项目则选择C++;在1995年推出的Java很快在20世纪90年代后期流行起来。在每一轮中引入的新的程序设计语言特征都会推动对于编译器优化的新研究。接下来,我们将给出一个关于主要语言特征的概览,这些特征曾经推动了编译器技术的重要发展。在实践中,所有的通用程序设计语言,包括C、Fortran和Cobol,都支持用户定义的聚合类型(如数组和结构)和高级控制流(比如循环和过程调用)。如果我们仅仅把每个高级结构和数据存取运算直接翻译成为机器代码,得到的代码将会非常低效。编译器优化的一个组成部分称为数据流优化,它可以对程序的数据流进行分析,并消除这些构造之间的冗余。它们很有效,生成的代码和一个熟练的低级语言程序员所写的代码类似。

面向对象概念首先在1967年在Simula中引入,并被集成到了Smalltalk、C++、C#和Java这样的语言中。面向对象的主要思想就是数据抽象和特性的继承。人们发现这两者都可以使得程序更加模块化和易于维护。面向对象程序和用很多其他语言编写的程序之间的不同在于它们由多得多的(但是较小)过程(在面向对象术语中称为方法(method))组成。因此,编译器优化技术必须能够很好地跨越源程序中的过程边界进行优化。过程内联技术(即把一个过程调用替换为相应过程体)在这里是非常有用的。人们还开发了可以加速虚拟方法分发的优化技术。

Java有很多特征可以使编程变得更容易,其中的很多特征之前已经在别的语言中引入。Java语言是类型安全,也就是说,一个对象不能被当作另一个无关类型的对象来使用。所有的数据访问运算都会被检查以保证它们在数组的界限之内。Java没有指针,也不允许指针运算。它具有一个内建的垃圾收集机制来自动释放那些不再使用的变量所占用的内存。虽然所有这些特征使得编程变得更加容易,但它们也会引起运行时刻的开销。人们开发了相应的编译优化技术来降低这个开销。比如,消除不必要的下标范围检查,以及把那些在过程之外不可访问的对象分配在栈里面而不是堆里。此外,人们还开发了高效的算法来尽量降低垃圾收集的开销。除此之外,Java 用来支持可移植和可移动的代码。程序以Java字节码的方式分发。这些字节码要么被解释执行,要么被动态地(即在运行时刻)编译为本地代码。动态编译也曾经在其他上下文环境中被研究过。在那里,信息在运行时刻被动态地抽取出来,并用来生成更加优化的代码。在动态编译中,尽可能降低编译的时间是很重要的,因为编译时间也是运行开销的一部分。一个常用的技术是只编译和优化那些经常运行的程序片断。

三、针对计算机体系结构的优化

计算机体系结构的快速发展也对新编译器技术提出了越来越多的需求。几乎所有的高性能系统都利用了两种技术,并行(parallelism)和内存层次结构(memory hierarchy)。并行可以出现在多个层次上。在指令层次上,多个运算可以被同时执行;在处理器层次上,同一个应用的多个线程在不同的处理器上运行。内存层次结构是应对下述局限性的方法,我们可以制造非常快的内存,或者非常大的内存,但是无法制造非常大又非常快的内存。

所有的现代微处理器都采用了指令级并行性。但是,这种并行性可以对程序员隐藏起来。程序员写程序的时候就好像所有指令都是顺序执行的。硬件动态地检测顺序指令流之间的依赖关系,并且在可能的时候并行地发出指令。在有些情况下,机器包含一个硬件调度器。该调度器可以改变指令的顺序以提高程序的并行性。不管硬件是否对指令进行重新排序,编译器都可以重新安排指令,以使得指令级并行更加有效。

指令级的并行也显式地出现在指令集中。VLIW(Very Long Instruction Word,非常长指令字)机器拥有可并行执行多个运算的指令。Intel IA64是这种体系结构的一个有名的例子。所有的高性能通用微处理器还包含了可以同时对一个向量中的所有数据进行运算的指令。人们已经开发出了相应的编译器技术,从顺序程序出发为这样的机器自动生成代码。

多处理器也已经日益流行,即使个人计算机也拥有多个处理器。程序员可以为多处理器编写多线程的代码,也可以通过编译器从传统的顺序程序自动生成并行代码。这样的编译器对程序员隐藏了一些细节,包括如何在程序中找到并行性,如何在机器中分发计算任务,以及如何最小化处理器之间的同步和通信。很多科学计算和工程性应用需要进行高强度的计算,因此可以从并行处理中得到很大的好处。人们已经开发了并行技术以便自动地把顺序的科学计算程序翻译成为多处理器代码。

一个内存层次结构由几层具有不同速度和大小的存储器组成。离处理器最近的层速度最快但是容量最小。如果一个程序的大部分内存访问都能够由层次结构中最快的层满足,那么程序的平均内存访问时间就会降低。并行性和内存层次结构的存在都会提高一个机器的潜在性能。但是,它们必须被编译器有效利用才能够真正为一个应用提供高性能计算。

内存层次结构可以在所有的机器中找到。一个处理器通常有少量的几百个字节的寄存器,几层包含了几 K 到几兆字节的高速缓存,包含了几兆到几G字节的物理寄存器,最后还包括多个几G字节的外部存储器。相应地,层次结构中相邻层次间的存取速度会有两到三个数量级上的差异。系统性能经常受到内存子系统的性能(而不是处理器的性能)的限制。虽然一般来说编译器注重优化处理器的执行,现在人们更多地强调如何使得内存层次结构更加高效。

高效使用寄存器可能是优化一个程序时要处理的最重要的问题。和寄存器必须由软件明确管理不同,高速缓存和物理内存是对指令集合隐藏的,并由硬件管理。人们发现,由硬件实现的高速缓存管理策略有时并不高效。当处理具有大型数据结构(通常是数组)的科学计算代码时更是如此。我们可以改变数据的布局或数据访问代码的顺序来提高内存层次结构的效率。我们也可以通过改变代码的布局来提高指令高速缓存的效率。

四、新计算机体系结构的设计

在计算机体系结构设计的早期,编译器是在机器建造好之后再开发的。现在,这种情况已经有所改变。因为使用高级程序设计语言是一种规范,决定一个计算机系统性能的不是它的原始速度,还包括编译器能够以何种程度利用其特征。因此,在现代计算机体系结构的开发中,编译器在处理器设计阶段就进行开发,然后编译得到代码并运行于模拟器上。这些代码被用来评价提议的体系结构特征。

有关编译器如何影响计算机体系结构设计的最有名的例子之一是RISC(Reduced Instruction-Set Computer,精简指令集计算机)的发明。在发明RISC之前,趋势是开发的指令集越来越复杂,以使得汇编编程变得更加容易。这些体系结构称为CISC(Complex Instruction-Set Computer,复杂指令集计算机)。比如,CISC指令集包含了复杂的内存寻址模式来支持对数据结构的访问,还包含了过程调用指令来保存寄存器和向栈中传递参数。编译器优化经常能够消除复杂指令之间的冗余,把这些指令削减为少量较简单的运算。因此,人们期望设计出简单指令集。编译器可以有效地使用它们,而硬件也更容易进行优化。大部分通用处理器体系结构,包括PowerPC、SPARC、MIPS、Alpha和PA-RISC,都是基于RISC概念的。虽然x86体系结构具有CISC指令集,但在这个处理器本身的实现中使用了很多为RISC机器发展得到的思想。不仅如此,使用高性能x86机器的最有效的方法是仅使用它的简单指令。

在过去的30年中,提出了很多的体系结构概念。其中包括:数据流机器、向量机、VLIW(非常长指令字)机器、SIMD(单指令,多数据)处理器阵列、心动阵列(systolic array)、共享内存的多处理器、分布式内存的多处理器。每种体系结构概念的发展都伴随着相应编译器技术的研究和发展。这些思想中的一部分已经应用到嵌入式机器的设计中。因为整个系统都可以放到一个芯片里面,所以处理器不再是预包装的商品。人们可以针对特定应用进行裁剪以获得更好的费效比。由于规模经济效用,通用处理器的体系结构具有趋同性。而专用应用的处理器则与此相反,体现出了计算机体系结构的多样性。人们不仅需要编译器技术来为这些体系结构编程提供支持,也需要用它们来评价拟议中的体系结构设计。

五、程序翻译

我们通常把编译看作是从一个高级语言到机器语言的翻译过程。同样的技术也可以应用到不同种类的语言之间的翻译。下面是程序翻译技术的一些重要应用。

编译器技术可以用于把一个机器的二进制代码翻译成为另一个机器的二进制代码,使得可以在一个机器上运行原本为另一个指令集编译的程序。二进制翻译技术已经被不同的计算机公司用来增加它们的机器上的可用软件。特别地,因为x86在个人计算机市场上的主导地位,很多软件都是以x86二进制代码的形式提供的。人们开发了二进制代码翻译器,把x86代码转换成Alpha和Sparc的代码。Transmeta公司也在他们的x86指令集实现中使用了二进制转换。他们没有直接在硬件上运行复杂的x86指令集,他们的Transmeta Crusoe处理器是一个VLIW处理器,它依赖于二进制翻译器来把x86代码转换成为本地的VLIW代码。二进制翻译也可以被用来提供向后兼容性。1994年,当Apple Macintosh中的处理器从Motorola MC68040变为PowerPC的时候,便使用二进制翻译来支持PowerPC处理器运行遗留下来的MC68040代码。

不仅仅大部分软件是用高级语言描述的,连大部分硬件设计也是使用高级硬件描述语言描述的,这些语言有Verilog和VHDL(Very high-speed integrated circuit Hardware Description Language,甚高速集成电路硬件描述语言)。硬件设计通常是在寄存器传输层(Register Transfer Level,RTL)上描述的。在这个层中,变量代表寄存器,而表达式代表组合逻辑。硬件合成工具把RTL描述自动翻译成为门电路,而门电路再被翻译成为晶体管,最后生成一个物理布局。和程序设计语言的编译器不同,这些工具经常会花费几个小时来优化门电路。还存在一些用来翻译更高层次(比如行为和函数层次)的设计描述的技术。

除了描述软件和硬件,语言在很多应用中都是有用的。比如,查询语言(特别是SQL语言(Structured Query Language,结构化查询语言))被用来搜索数据库。数据库查询由包含了关系和布尔运算符的断言组成。它们可以被解释,也可以编译为代码,以便在一个数据库中搜索满足这个断言的记录。

模拟是在很多科学和工程领域内使用的通用技术。它用来理解一个现象或者验证一个设计。模拟器的输入通常包括设计描述和某次特定模拟运行的具体输入参数。模拟可能会非常昂贵。我们通常需要在不同的输入集合中模拟很多可能的设计选择。而每个实验可能需要在高性能计算机上花费几天时间才能完成。另一个方法不需要写一个模拟器来解释这些设计。它对设计进行编译并生成能够在机器上直接模拟特定设计的机器代码。后者的运行更加快。经过编译的模拟运行可以比基于解释器的方法快几个数量级。在那些可以模拟用Verilog或VHDL描述的设计的最新工具中,人们都使用了编译后模拟的技术。

六、软件生产率工具

程序可以说是人类迄今为止生产出的最复杂的工程制品,它们包含了很多很多的细节。要使得程序能够完全正确运行,每个细节都必须是正确的。结果是程序中的错误很是猖獗。错误可以使一个系统崩溃,产生错误的输出,使得系统容易受到安全性攻击,在关键系统中甚至会引起灾难性的运行错误。测试是对系统中的错误进行定位的主要技术。一个很有意思且很有前景的辅助性方法是通过数据流分析技术静态地(即在程序运行之前)定位错误。数据流分析可以在所有可能的执行路径上找到错误,而不是像程序测试的时候所做的那样,仅仅是在那些由输入数据组合执行的路径上找错误。很多原本为编译器优化所开发的数据流分析技术可以用来创建相应的工具,帮助程序员完成他们的软件工程任务。

找到程序的所有错误是不可判定问题。可以设计一个数据流分析方法来找出所有可能带有某种错误的语句,对程序员发出警告。但是如果这些警告中的大部分都是误报,用户将不会使用这个工具。因此,实用的错误检测器经常既不是健全的也不是完全的。也就是说,它们不可能找出程序中的所有错误,也不能保证报告的所有错误都真正是错误。虽然如此,人们仍然开发了很多种静态分析工具,这些工具能够在实际程序中有效地找到错误,比如释放空指针或已释放过的指针。错误探测器可以是不健全的。这个事实使得它们和编译器的优化有着显著不同。优化器必须是保守的,在任何情况下都不能改变程序的语义。在本节中,我们将提到使用程序分析技术来提高软件生产效率的几个已有途径。这些分析是在原本为编译器代码优化而开发的技术的基础上建立的。其中静态探测一个程序是否具有安全漏洞的技术是极为重要的。

类型检查是一种有效的,且被充分研究的技术,它可以被用于捕捉程序中的不一致性。它可以用来检测一些错误,比如,运算被作用于错误类型的对象上,或者传递给一个过程的参数和该过程的范型(signature)不匹配。通过分析程序中的数据流,程序分析还可以做出比检查类型错误更多的工作。比如,一个指针被赋予了 NULL 值,然后又立刻被释放了,这个程序显然是错误的。这个技术也可以用来捕捉某种安全漏洞。其中,攻击者可以向程序提供一个字符串或者其他数据,而这些数据没有被程序谨慎使用。一个用户提供的字符串可以被加上一个"危险"的标号。如果没有检查这个字符串是否满足特定的格式,那么它仍然是"危险"的。如果这种类型的字符串能够在某个程序点上影响代码的控制流,那么就存在一个潜在的安全漏洞。

相对于较高级的程序设计语言而言,用较低级语言编程更加容易犯错。比如,很多系统中的安全漏洞都是因为用 C 语言编写的程序中的缓冲区溢出造成的。因为 C 语言没有数组边界检查,所以必须由用户来保证对数组的访问没有超出边界。因为不能检验用户提供的数据是否可能溢出一个缓冲区,程序可能被欺骗,把一个数据存放到缓冲区之外。攻击者可以巧妙处理这些数据,使得程序做出错误的行为,从而危及系统的安全。人们已经开发了一些技术来寻找程序中的缓冲区溢出,但收效并不显著。如果程序是用一种包含了自动区间检查的安全的语言编写的,这个问题就不会发生。用来消除程序中的冗余区间检查的数据流分析技术也可以用来定位缓冲区溢出错误。而最大区别在于,没能消除某个区间检查仅仅会导致很小的额外运行时刻开销,而没有指出一个潜在的缓冲区溢出错误却可能危及系统的安全性。因此,虽然使用简单的技术去进行区间检查优化就已经足够了,但在错误探测工具中获得高质量的结果则需要复杂的分析技术,比如在过程之间跟踪指针值的技术。

垃圾收集机制是在效率和易编程及软件可靠性之间进行折衷处理的另一个极好的例子。自动的内存管理消除了所有的内存管理错误(比如内存泄漏)。这些错误是 C 或 C++ 程序中问题的主要来源之一。人们开发了很多工具来帮助程序员寻找内存管理错误。比如,Purify 是一个能够动态地捕捉内存管理错误的被广泛使用的工具。还有一些能够帮助静态识别部分此类错误的工具也已经被开发出来。


总结

本文围绕编译器技术展开,分为六个部分:编译器构建的科学,强调数学模型(如自动机、正则表达式)和算法设计的重要性,指出优化需平衡正确性、性能与工程复杂度;高级语言应用,讨论语言抽象与效率的权衡,优化技术如何弥补高层次编程的代价,并提及面向对象、Java等语言特性对编译技术的推动;体系结构优化,分析并行计算与内存层次结构对编译器优化的需求,如指令级并行、多线程及缓存效率提升;新体系结构设计,以RISC为例说明编译器对硬件设计的影响,指出专用处理器需定制化编译支持;程序翻译扩展,列举二进制翻译、硬件合成等跨领域应用,展示编译技术的广泛适用性;软件生产力工具,探讨静态分析技术(如类型检查、数据流分析)在错误检测与安全漏洞防范中的作用,对比低级语言的风险与高级语言的安全性。全文贯穿编译器在性能优化、硬件适配及软件可靠性中的核心作用,体现其理论与实践结合的复杂性。

相关推荐
是苏浙6 天前
编译原理引论2
编译原理
H Journey15 天前
静态编译与动态编译:链接方式与执行时机(AOT vs JIT)
aot·编译原理·jit
用户75389755281751 个月前
《手写解释器》第10章 函数
编译原理·编译器
原则猫1 个月前
AST 与AI 应用
编译原理
米丘2 个月前
Vue 3.x 单文件组件(SFC)模板编译过程解析
前端·vue.js·编译原理
米丘2 个月前
Vue 3.x 模板编译优化:静态提升、预字符串化与 Block Tree
前端·vue.js·编译原理
无巧不成书02182 个月前
编辑器、编译器与解释器全解析
编辑器·编译原理·编译器·编程入门·解释器·开发工具链
Hello.Reader2 个月前
编译器的六大阶段详解以一行赋值语句为例
编译原理·逆向工程
九成宫3 个月前
编译技术/编译原理期末复习
笔记·软件工程·编译原理·编译技术