动态编译器是连接两种体系结构的编译器,让其中一个体系结构上的
二进制代码经过翻译以后运行在另一个体系结构上。其中被翻译的那个体系结构成为源体系结构,在这个体系结构上编译的程序是动态编译器的输入;而在底层实际运行,使得二进制代码能够正确运行的体系结构,称之为目标体系结构。
简介
传统的
编译器,即静态编译器,把一个程序的源代码编译为机器可识别的目标代码(即可执行文件)。尽管静态编译器十分普遍,由于它往往只能在一种体系结构上运行,所以限制很大。从理论上来看,我们可以把针对某个体系结构的
二进制代码通过反编译转换成源代码,再通过另一个编译器转换成我们想要的那个体系结构上的可执行文件。然而,由于编译器之间的差异很大,加上
反编译的正确性和性能并不让人满意,所以最后导致了动态编译器的诞生。
最初的动态编译器来自于
解释器,通过解释器对源代码的解释,来执行和解释器同一平台上的程序。可是,这样的执行效率低下,用户难以忍受。为了解决这一问题,人们发明了编译-解释器。编译-解释器先把源代码编译成字节码(bytecode),也就是编译前端的输出文件,然后等到执行的时候,通过解释字节码执行程序。这种方式存在了很多年,但是近年来随着人们对程序性能要求的不断提高,编译-解释器的性能也不能满足目前的需要了
20 世纪80 年代初,Smalltalk 的实现首次探索了真正的动态编译,随后Java
虚拟机的出现推进了JIT(Just-In-Time)的技术进步。起初,动态编译器的性能并不理想,后来,随着字节码长度的缩短和编译方式的简化,动态编译器的性能越来越高,针对体系结构的优化也逐步引入,这大大提高了动态编译器的竞争力。
1997 年前后,Sun 公司推出的HotSpot 编译器是第一款高性能的Java 动态编译器。随着Java 技术的普及和计算机体系结构的改良,动态编译器的应用领域也越来越广。目前动态编译技术已经应用于所有常用的计算机体系结构和操作系统,其中包括微软公司.NET 环境下的基于JIT 的系统以及硬件设计上的Crusoe 处理器。
优缺点
优点
动态编译从科学和商业角度来看有如下的优点:
(1)性能提高:由于编译过程包含在运行过程中,因此可以根据运行时具体程序的特点对每个程序分别进行优化,这比静态的编译器针对所有程序作统一优化更有效。我们可以在程序员未知的情况下进行动态优化和简档引导的(profile-guided)优化。
(2)软件形态:目标机在没有动态编译器的条件下是不能运行非目标机软件的,动态编译器在这里充当了软件层的作用,也就是在操作系统之上建立一层软件平台,通过该平台对源程序的翻译,从而得到能够在目标机上运行的代码。由于动态编译通过软件实现,因此减少了硬件设计的难度,增加了软件的灵活性。
(3)硬件复杂度降低:普通的超标量机往往把计算结果一遍遍地重复放到内存中,不但浪费了计算资源,还占用了相当多的计算时间。用动态编译器可以轻松地解决上述问题。动态编译器只对相同代码翻译和优化一遍,然后将目标代码存储在内存中,每次执行时不用像硬件那样重复进行代码优化工作。
(4)遗留代码的新生:许多年前的遗留代码由于“年久失修”,其性能已经大大下降。从源程序角度进行维护已经毫无意义。动态编译技术的出现使得原来的遗留代码可以重新进行编译,并在新的体系结构上运行。对它们来说,不啻为一种“新生”。
(5)加快软件发布的速度:在新的体系结构上开发软件总是一件麻烦的事情,往往需要结合其新的优点重新设计开发。动态编译技术的出现彻底解决了这个问题,它不仅消除了软件重新开发的问题,还加快了软件发布的速度,提高了软件在新的体系结构上的性能。
(6)扩展了软件优化的空间:传统的软件优化技术总是着眼于局部的、静态的优化。动态编译技术的出现带动了软件优化技术的发展,针对间接跳转、函数返回、共享库、系统调用等等都逐步形成了新的优化技术。
(7)改善了存储系统的使用:一般的程序都有相当多的跳转,经常造成颠簸现象,导致存储系统性能下降,从而影响了软件的整体性能。动态编译技术能够消除不必要的跳转,改善目标代码的分布情况,更好地利用目标机存储系统的特点,节省访存时间,提高指令高速缓存的性能。
(8)改正硬件的错误:某些处理器问世以来,会发现许多新的错误。对用户而言,不断更换新的硬件是一笔昂贵的开销。有些硬件错误可以通过动态编译器来解决。例如,某些硬件不支持某条指令,或是错误地实现了某条指令。通过动态编译器的重新实现可以完全解决上述问题。
(9)推进处理器的发展:动态编译器从某种程度上可以代替硬件。因此芯片上可以腾出不少空间实现复杂的电路、增加处理器核、扩大高速缓存的容量。这些手段都推进了处理器的发展。
缺点
动态编译不可避免地有如下缺点,在设计动态编译器时必须尽可能地减少这些因素对动态编译器正确性和性能的影响。
(1)占用额外的运行时间:动态编译,顾名思义就是在运行时进行编译,很显然动态编译会占用额外的运行时间。
(2)占用额外的内存资源:动态编译的结果必须存储在内存中才能被执行(除非完全采用解释执行),这就占用了机器额外的内存资源。
(3)初次执行的代码效率很低:动态编译器在第一次遇到代码时,必须先翻译后执行,因此会造成性能的低下。通常一个程序刚被启动是动态编译器效率最低的时候。
(4)动态编译程序的调试很困难:由于动态编译器编译后代码的地址同源程序中的地址是不同的。因此调试起来必须注意译前译后地址的转换,稍有不慎就容易引起调试失败。
动态编译器的设计
动态编译器的设计思想和静态编译器有较大的不同。它没有静态编译器那样复杂的编译过程,但是它在编译方式、模拟方式、编译范围、体系结构的选择方面比静态编译器灵活、复杂。
出于性能的考虑,动态编译器的设计架构不仅要考虑编译代码的正确性,还要同时关注代码的执行性能。因此在设计中必须为将来可能的性能优化留出接口,数据结构的组织和通用算法的安排都需要把性能因素考虑在内。
设计难点
尽管动态编译器有许多优点,设计上也有相当大的弹性。然而成功设计一个有实际价值的动态编译器还面临着相当大的难题。纵观现有的动态编译器,主要难点如下:
(1)自修改代码:
Henry Massalin 是自修改代码之父。他最早发现并指出了自修改代码对Java 虚拟器的影响。同样,自修改代码对动态编译器也是一个棘手的问题。所谓自修改代码就是在程序运行过程中出于某种目的能够修改自身代码的代码。这种目的可能是运行时生成代码,也可能是子函数调用的回补,甚至可能是优化某个与状态无关的循环。以Windows 系统为例,有一个段寄存器同时包含了数据和代码,它往往就是自修改代码产生的根源。由于数据段、代码段和堆栈段的属性各不相同,数据段可读可写,代码段可读可执行,堆栈段可读可写可执行,因此自修改代码利用不同段的特性修改自身代码从而达到需要的效果。
动态编译器必须能够对操作系统加载和卸载的不同程序进行翻译。当源程序中含有自修改代码时,它不可避免地在满足某些条件时(往往是执行了某条指令后)修改自身的代码。对动态编译器而言,这是十分危险的动作。动态编译器不仅应该知道代码已经被修改,还必须知道是哪些代码被修改了。无论是哪种情况,都必须对翻译后的相应目标代码作无效处理。由于动态编译器没有操作系统的功能,因此自修改代码的执行并不会通知动态编译器,所以任何一个能处理自修改代码的动态编译器都得主动检查段属性,从而发现自修改代码,进行相应的处理。
(2)精确异常:
在一个以优化性能为主要目的的流水线中(或者是用于指令并行执行的设计中),系统的顺序执行只是一种抽象。如果硬件不是设计得特别聪明,中断使我们看到程序不是顺序执行的。当一个异常发生,系统的顺序执行被中断时,将会有几条指令处于流水线的不同阶段。因为我们不想中断处理破坏程序的正常执行,对于没有执行完的指令,我们必需记住它们执行到哪一个阶段,以便在中断处理之后能恢复程序执行。
如果处理器是精确异常的,那么异常的软件处理就会很简单。对于一个精确异常的处理器,在发生异常时,我们都会有一个引起异常的指令。该指令前面的所有指令都以执行完,该指令以及该指令以后的指令都不会有任何软件值得考虑的负作用。所以软件作异常处理时,可以完全忽略指令的乱序执行。
异常发生的顺序与指令的顺序相同。在非流水线的处理器上,这是显而易见的。然而在流水线的处理器上,异常可能会发生在指令执行的不同阶段,从而产生潜在的问题。比如,如果一个读内存指令产生一个地址异常,这个异常一直要到读写内存阶段才产生。如果它的后一条指令在取指阶段就产生错误,则后一条指令会想产生异常,从而破坏异常发生的顺序跟指令的顺序相同这个约定。为了避免这个问题,被发现的异常情况一直要到确认有异常情况的指令的前面的所有指令都不产生异常时才产生异常。在发现异常情况时,该情况只是被记下来沿着流水线传递下去直到某一级。如果在这个过程中以前的指令产生的异常被发现,该异常情况仅仅被简单的忽略掉,从而解决了整个问题。
当然对动态编译器来说,页错误、异步异常之类的计时器中断会提供一个当前机器执行的一致状态,得到这个一直状态之后,动态编译器可以模拟源体系结构上异常处理器的行为,从而在目标体系结构上精确重现这个异常。
(3)地址转换:
把一个虚地址翻译成物理地址时,处理器必须先得出虚页号和偏移量。处理器使用虚页号作为检索进程页表记录的索引。如果对应那偏移量的页表记录是有效的,处理器就从中拿出物理页号。如果记录是无效的,表明进程想存取一个不在物理内存中的地址。在这种情况下,处理器不能翻译这个虚地址,而必须把控制权传给操作系统,让它处理。当当前进程试图存取一个处理器无法翻译的虚地址时,处理器引发一个“页错误”,并将产生页错误的虚地址和原因告诉给操作系统。假设找到的是一有效的页表记录,处理器就取出物理页号并且乘以页的大小,得到内存中页的基地址。最后,处理器加上偏移量得到它需要指令或数据的地址。
可见,动态编译器必须能够处理这种情况:当指令计算出的虚拟地址所映射的物理地址并不是真正含有某个正确值的地址,或者当前进程试图存取一个处理器无法
翻译的虚地址导致处理器引发一个“页错误”。当然,还可能有更复杂的情况,比如,
在内存映射的输入输出中, 某些地址是不可缓存的。无论哪种情况,动态编译器必
须首先将其识别,然后采用某种机制加以解决。这是动态编译器不得不面对的问题。
(4)自引用代码
自引用代码把指令流作为数据进行处理,其典型应用就是计算自身的校验和,从而保证程序没有因为某些原因被损坏。动态编译器必须能够让自引用代码正确运行。一般我们通过在源地址空间中保留一段翻译后的自引用代码来解决这个问题。现在几乎所有的动态编译器都能很好地处理这个问题。
(5)翻译高速缓存的管理
把经常使用的翻译结果保存在内存里可以获得更加高的效率。这块特别的内存区域被命名为翻译高速缓存,它允许代码融合软件的重新使用,并消除冗余的代码。在遇到了以前曾经翻译的源指令顺序,代码冗余软件会忽略翻译的进程并直接执行在翻译缓存的缓存翻译结果。
由于翻译高速缓存的大小是有限的,不可能把所有的翻译结果都放在翻译高速缓存中。当翻译高速缓存全部用完之后,新翻译的结果就必须逐步替换以前翻译的结果。同样,如果以前翻译的结果由于某些原因,如自修改代码,必须被无效的时候,翻译高速缓存也必须体现出这个变化。
翻译高速缓存缓存和重新使用翻译在日常的工作中会高度重复出现。代码冗余软件使用在翻译缓存和优化的单个翻译来匹配重复执行的工作,这样在最少的开销下获得了全速。早期翻译的花费被后面重复的执行分次还清了。
翻译高速缓存的替换策略也是多种多样。总的思想还是只有两种。一是翻译高速缓存用完以后对其中存储的内容全部替换,二是用垃圾回收(garbage collection)算法维护高速缓存的正确性,当然垃圾回收算法有各种变形,不同的动态编译器根据实际需要选择合适的垃圾回收算法。
设计架构
动态编译器是连接两种体系结构的编译器,让其中一个体系结构上的
二进制代码经过翻译以后运行在另一个体系结构上,所以动态编译器中必然包含两种不同的体系结构。其中被翻译的那个体系结构成为源体系结构(source architecture),在这个体系结构上编译的程序是动态编译器的输入;而在底层实际运行,使得二进制代码能够正确运行的那个体系结构,我们称之为目标体系结构(target architecture)。图表述了动态编译器的运行环境。
根据程序的
局部性原理,即程序在执行时所呈现的局部性规律,即在一段较短时间内,程序的执行仅限于某个部分。相应地,它所访问的存储器空间也局限在某个空间。当硬件发展的速度大大加快的时候,动态编译器的优化机会也相应增加,所以我们可以利用反馈指导的优化思想识别出执行最频繁的代码,并对这些频繁代码进行特殊的优化。
这样一来,动态编译器的基本结构就很明朗了。我们把动态编译器的编译过程分为两个阶段。第一个阶段是普通代码,只需要逐条翻译,在翻译过程中记录下代码的执行频率,也就是将来可能成为频繁代码的代码段;第二个阶段是频繁代码,也就是我们准备充分优化的代码。在这两个阶段以外的工作,我们可以放到编译动态编译器的过程中完成,这就完全避免了运行时可能造成的代价了。