5.5.1 编译器的结构
在4.1.7部分中,为了把分析与执行分开,我们已经修改了我们的原来的元循环的解释器。
我们分析每个表达式来生成一个执行程序,它带有一个环境为实际参数并且执行要求的操作。
在我们的编译器中,我们将做必要的相同的分析。代替生成执行程序的是,然而,我们将生成
被我们的寄存器机器运行的指令的序列。
程序compile是在编译器中的最顶层的分发程序。它对应着4.1.1部分中的eval程序,4.1.7部分
中的analyze程序,和在5.4.1部分中的显式控制的解释器的eval-dispatch入口点。编译器像
解释器一样,使用在4.1.2部分中定义的表达式语法的程序。compile执行一个类型分析,根据
是被编译的表达式的语法类型。对于每一个表达式的类型,它都分发一个特定的代码生成器:
(define (compile exp target linkage)
(cond ((self-evaluating? exp)
(compile-self-evaluating exp target linkage))
((quoted? exp) (compile-quoted exp target linkage))
((variable? exp)
(compile-variable exp target linkage))
((assignment? exp)
(compile-assignment exp target linkage))
((definition? exp)
(compile-definition exp target linkage))
((if? exp) (compile-if exp target linkage))
((lambda? exp) (compile-lambda exp target linkage))
((begin? exp)
(compile-sequence (begin-actions exp)
target
linkage))
((cond? exp) (compile (cond->if exp) target linkage))
((application? exp)
(compile-application exp target linkage))
(else
(error "Unknown expression type -- COMPILE" exp))))
* 目标与连接
compile和代码生成器两个参数再加上要编译的代码。这是一个目标,它指定在编译后的代码中
哪个寄存器是表达式返回的值。也有一个连接的描述符,它描述当它完成它的执行后,从表达式
的编译中得到的结果代码如何继续。连接描述符能要求代码做如下的三件事其中之一:
.在序列中继续下一条指令(这由连接描述符next指定)
.从被编译的程序返回(这由连接描述符return指定)
.跳入一个被命名的入口点(这通过使用目标的标签作为连接描述符来指定)
例如,编译表达式5,(它是自解释的)带有一个val寄存器的目标和一个next的连接描述符,应该生成
指令如下:
(assign val (const 5))
编译相同的表达式带有连接描述符是return应该生成如下的指令
(assign val (const 5))
(goto (reg continue))
在第一个例子中,执行将继续到序列中的下一条指令。在第二个例子中,
我们将从一个程序调用中返回。在这两个例子,表达式的值将被放在目
标val寄存器中。
* 指令序列与栈的使用
每个代码生成器返回一个指令序列包括了为表达式生成的目标代码。通过组合更简单
的为子表达式的代码生成器的输出完成了复合的表达式的代码生成,正如通过解释子
表达式来完成了复合的表达式的解释。
组合指令序列的最简单的方法是叫做append-instruction-sequence的程序。它以
任意数量的指令的能被顺序的执行的序列为参数;它把它们合并起来并且返回组合
的序列。也就是,如果<seq1>和<seq2>是指令的序列,那么解释
(append-instruction-sequences <seq1> <seq2>)
生成序列
<seq1>
<seq2>
无论何时,寄存器可能需要被保存,编译器的代码生成器使用preserving,它是一个组合
指令序列的更微妙的方法。preserving带有三个实际参数,一个寄存器的集合,两个被
顺序执行的指令序列。它以这样的一种方式来合并序列,就是如果第二个序列的执行需
要寄存器,第一个序列的执行之上保留寄存器集合中的每个寄存器的内容。也就是,如
果第一个序列修改了寄存器,并且第二个序列实际需要寄存器的原来的内容,那么preserving
能在第一个序列与第二个序列合并之前,在第一个序列的前后包装上寄存器的一个保存
与恢复的指令。否则,preserving简单地返回合并的指令序列。因此,例如:
(preserving (list <reg1> <reg2>) <seq1> <seq2>)
生成了如下的四种指令序列之一,依赖于<seq1> 和<seq2> 如何 使用 <reg1>和<reg2>:
<seq1> | (save <reg1>) | (save <reg2>) | (save <reg2>) |
<seq2> | <seq1> | <seq1> | (save <reg1>) |
| (restore <reg1>)|(restore <reg2>) | <seq1> |
| <seq2> | <seq2 > |(restore <reg1>)|
| | |(restore <reg2>) |
| | | <seq2> |
通过使用preserving来组合指令序列,编译器避免了不必要的栈操作。用preserving
也分离了是否生成保存与恢复指令的细节,把它们从写每个独立的代码生成器的关注
中分离出来。在事实上,没有保存与恢复的指令是由代码生成器显式地生成的。
在原则上,我们能把一个指令序列简单地表示为一个指令的列表。通过执行普通的
列表的append操作append-instruction-sequence能合并指令序列。然而,
preserving将成为一个复杂的操作,因为它可能不得不分析每个指令序列,来确定
序列如何使用它的寄存器。Preserving像它的复杂一样而没有效率,因为它不得不
分析每个指令序列的实际参数,即使这些序列可能它们本身通过调用preserving而
组装的,而它的每个部分可能已经被分析过了。为了避免这种重复的分析,我们将
把每个指令序列与它的寄存器的使用的某些信息关联起来。当我们组装一个基本的
指令序列时我们将显式地提供这种信息,并且组合指令序列的程序将为了组合的序列
从子序列中关联的信息进行推导寄存器的使用的信息。
一个指令序列将包括了三个方面的信息:
. 在序列中的指令被执行之前,寄存器的集合必须被初始化(这些寄存器被序列所需要)
. 在序列的指令修改了寄存器的值,这些值所属的寄存器的集合
. 在序列中的实际的指令(也叫做语句)
我们将表示一个指令序列作为一个有三个部分的列表。指令序列的组装子如下:
(define (make-instruction-sequence needs modifies statements)
(list needs modifies statements))
例如,两个指令的序列是在当前的环境中,查找变量x的值,把结果赋给val,并且然后返回,
要求寄存器env 和continue已经被初始化,并且修改了寄存器val.因此,这个序列被组装成:
(make-instruction-sequence '(env continue) '(val)
'((assign val
(op lookup-variable-value) (const x) (reg env))
(goto (reg continue))))
我们有时需要组装一个没有语句的空的指令序列:
(define (empty-instruction-sequence)
(make-instruction-sequence '() '() '()))
组合指令序列的程序被显示在5.5.4部分中。
练习5.31
在解释一个程序应用中,显式控制的解释器在操作符的解释的前后
总是保存与恢复env寄存器,在每个操作数的解释前后,除了最后一个操作数,
在每个操作数的解释前后,保存与恢复arg1寄存器, 在操作数的序列的解释前后,
保存与恢复proc寄存器。对于如下的组合中的每一个,说出这些保存与恢复的操作,
哪个是多余的,并且因此能被编译器的preserving机制给消除的:
(f 'x 'y)
((f) 'x 'y)
(f (g 'x) y)
(f (g 'x) 'y)
练习5.32
使用preserving机制,编译器将避免了在操作符中一个符号的情况下,在组合的操作符
解释前后 加上保存与恢复寄存器env.我们也把这样的优化构建进了解释器。的确,5.4
部分中的显式控制的解释器已经准备好了执行一个相似的优化,通过处理没有操作数
的组合作为一个特殊的例子。
a.扩展显式控制的解释器,让它把表达式的组合中的操作符是符号的识别为一个单独的类别,
在解释这样的表达式时利用这个事实。
b. 阿丽莎建议通过扩展解释器来识别越来越多的特例,我们能集成所有的编译器的优化,
并且这将削弱了编译的优势,你如何看待这种想法?