前面提到了经过了词法分析->语法分析->语义分析->中间代码优化,最后的阶段便是在目标机器上运行的目标代码的生成了。目标代码生成阶段的任务是:将此前的中间代码转换成特定机器上的机器语言或汇编语言,这种转换程序便被称为代码生成器。
之所以将编译原理分成这种多阶段多模块的组织形式,本质的考虑其实只有两个方面:
一、代码复用:尽可能在不增加程序员工作量的前提下,增加应用程序的可移植性。
( 可我们知道不同机器的机器指令集和硬件结构,千差万别,而前面又提到了为了将程序的执行效率提升到最大,显然是需要将软件按照硬件特性极尽可能地定制化优化,其实现在AI芯片的发展趋势也是这种思想的另一面体现。现今AI芯片为了加速深度学习为代码的超强度计算效率,出现了针对具体程序架构定制化芯片的需求,如CPU->GPU的发展,便是GPU迎合了深度学习单轮多次简单运算的需求,CPU->FPGA的矩阵运算侧重也是Facebook引领的另一硬件绑定具体软件计算需求的工程案例,而最为极端者莫过于当下Google推出的TPU计算芯片,完全剔除不必要的硬件设备,根据Google的深度学习Tensorflow架构定制化的计算芯片,更是以极致的定制化获取比CPU计算速度高200倍的显著成果。)。
所以再一次验证了“银弹”理论,如果有问题解决不了,那就加一层抽象层了。所以提高代码移植性的集大成者Java,便是通过引入JVM将应用程序层和具体硬件层隔开。
图1. Java-多目标机
显然对于Java程序可以共享一套“词法分析+语法分析+语义分析+中间代码优化引擎”这一套前端套件,而针对不同机型的定制化目标代码生成器则可以通过官方渠道的支持和更新来组装成自己所需的后端套件组。这样便可以让程序员尽可能地有较多的自由编写的空间,极大地提升代码可移植性,而不像C++编写的DLL库,即便采用复杂的COM规则,极为小心地编写,但也可能因为编译器版本不同导致移植性不通过。
二、特定机器的优化器和生成器复用:针对特定机器的目标代码生成和优化器的工作极为复杂精深,有一套成功的,当然要尽可能复用它。
由于代码生成阶段的目标代码和具体计算机的结构有关,如指令格式、字长以及寄存器的个数和种类,并与指令的语义和所用操作系统等都密切相关,特别是高级语言的语义功能复杂,并且计算机硬件结构多样性都给代码生成的理论研究带来很大的复杂性,因此实际实现起来是非常困难的。所以难得生成一款后端的代码生成器,当然是想让它可以独立出来,被多次组装参与其他编译器的生产过程。
图2. 多种语言-一种目标机型
这种情况被称为定计算机情况:当限定某种计算机时,而高级语言为多种情况下所设计的中间语言,应能充分反映限定计算机的特点称MSIL(Machine Specific Intermediate language)。对这种机器的所有编译程序在分析阶段都生成MSIL,在实现一个编译程序时,尽量把编译过程的大量工作放在代码生成阶段,即MSIL到目标程序的翻译上,以减轻不同语言翻译的分析任务。因不管多少种高级语言,MSIL到目标程序的代码生成只需做一次即可。
当然也正是这种组织特性,让本来是集团作战的编译器生成工作,现如今变得不再是难以企及。同时也让各行各业根据自身需求和侧重点不同,甚至可以定制化自己的行业的专属语言(Domain Specific Language)变得可实现。
说了这么多,其实只需要明白代码生成器是结合目标机器平台定制化的一套后端套件,这也是为何业界的主流计算机都是按照x86、x64_86这些业界标准来设计的,如果你家公司另辟蹊径自己设计出一套硬件体系,即便你能设计生产出来,但你确定IT界会有下游供应商给你设计类似于代码生成器这种配套套件吗?
衡量最终生成的目标代码的质量无非是从两个方面来进行评估:空间占用和运行效率,这其中涉及到诸多和具体硬件绑定的细节,大多数都属于难度高且并不通用的范畴。而寄存器的使用规则则是少数具有通用的手段,故而可以借分析寄存器分配来分析一下目标代码优化和生成过程。
Q: 为什么在代码生成时要考虑充分利用寄存器?
A: 因为当变量值存在寄存器时,引用的变量值可直接从寄存器中取,减少对内存的存取次数,这样便可提高运行速度。因此如何充分利用寄存器是提高目标代码运行效率的重要途径。
Q: 寄存器分配的原则是什么?
A: (1)逻辑有效范围内尽量保留: 当生成某变量的目标代码时,尽量让变量的值或计算结果保留在寄存器中,直到寄存器不够分配时为止。
(2) 逻辑块出口处,和内存中的源数据同步:当到基本块出口时,将变量的值存放在内存中,因为一个基本块可能有多个后继结点或多个前驱结点,同一个变量名在不同前驱结点的基本块内出口前存放的R可能不同,或没有定值,所以应在出口前把寄存器的内容放在内存中,这样从基本块外入口的变量值都在内存中
(3) 及时释放,提升寄存器使用效率:对于在一个基本块内后边不再被引用的变量所占用的寄存器应尽早释放,以提高寄存器的利用效率。
可以看到在进行缓存淘汰更新时我们采用了LRU策略,LRU策略是属于经典的“线性思维”–即过去常被使用的,在未来也会常被使用。而这里寄存器则不能采用“线性思维”,因为我们看自己的代码都知道,变量的使用虽然在局部范围还算符合“局部性原理”,但是稍微放宽到一定尺度上的变量集使用情况,则发现更多是无序的。所以在进行寄存器分配策略的研究上,只能比较粗暴点,事先将目标代码再次遍历一遍,将变量的使用情况事先整理好,并用所谓“待用信息链”的数据结构来保存每个变量的使用情况。这种策略并非LRU那种后视推导逻辑,而是耍赖的先知般论断逻辑,但是考虑到一旦将目标代码的寄存器使用效率最大化,则无疑是一劳永逸的,故而也是划算的。
变量的待用信息链计算方法
前面根据寄存器的使用原则可以看到,寄存器的分配是以基本块为单位的,因为基本块作为程序流的最小单元,存在着数据同步和异步的问题,故而在进行寄存器分配时,要审核的代码范围只需要涉及到当前基本块即可。
首先为任一变量设置两个信息链:待用信息链和活跃变量信息链。
考虑到处理的方便,可假定对基本块中的变量在出口处都是活跃的,而对基本块内的临时变量可分为两种情况处理。
a) 一般情况下基本块内的临时变量在出口处都认为是不活跃的。
b) 如果中间代码生成时的算法允许某些临时变量在基本块外可以被引用时,则这些临时变量也是活跃的。
在基本块内计算变量的使用信息链(觉得采用栈式更符合这种信息链的更新情况),步骤如下:
① 对各基本块的符号表中的”待用信息”栏和”活跃信息”栏置初值,即把”待用信息”栏置”非待用”,对”活跃信息”栏按在基本块出口处是否为活跃而置成”活跃”或”非活跃”。现假定变量都是活跃的,临时变量都是非活跃的。
② 倒着来从基本块出口到基本块入口由后向前依次处理每个四元式。对每个四元式i:A:=B op C,依次执行下述步骤:
a) 把符号表中变量A的待用信息和活跃信息附加到四元式i上。
b) 把符号表中变量A的待用信息栏和活跃信息栏分别置为 “非待用” 和 “非 活跃”。由于在i中对A的定值只能在i以后的四元式才能引用,因而对i以前的四元式来说A是不活跃也不可能是待用的。
c) 把符号表中B和C的待用信息和活跃信息附加到四元式i上。
d) 把符号表中B和C的待用信息栏置为”i”,活跃信息栏置为”活跃”。
说的麻烦,举个例子就好了:
(1) T∶=A-B(2) U∶=A-C(3) V∶=T+U(4) D∶=V+U
加上信息链条之后,则标记情况如下
(1) T【(3)L】:= A【(2)L】 - B【FL】(2) U【(3)L】:= A【FL】 - C【FL】(3) V【(4)L】:= T【FF】 + U【(4)L】(4) D【FL】:= V【FF】 + U【FF】
这样根据信息链表法,在每次运算到一个表达式时,如果寄存器数目不够,即无空余寄存器可用,则可以遍历一下当前在寄存器中的变量的待用信息链,然后选择接下来最远将被调用的变量释放其所占用寄存器,分配寄存器的算法为:
① 如果B
的现行值在某寄存器Ri中,且该寄存器只包含B
的值,或者B
与A
是同一标识符,或B
在该四元式后不会再被引用,则可选取Ri
作为所需的寄存器R
,并转(4)
;
② 如果有尚未分配的寄存器,则从中选用一个Ri
为所需的寄存器R
,并转(4)
;
③ 从已分配的寄存器中选取一个Ri
作为所需寄存器R
,其选择原则为:占用该寄存器的变量值同时在主存中,或在基本块中引用的位置最远,这样对寄存器Ri
所含的变量和变量在主存中的情况必须先做如下调整:即对RVALUE[Ri]
中的每一变量M
,如果M
不是A
且AVALUE[M]
不包含M
,则需完成以下处理;
a) 生成目标代码ST Ri,M
即把不是A的变量值由Ri中送入内存中;
b) 如果M
不是B
,则令AVALUE[M]={M}
,否则,令AVALUE[M]={M, Ri}
;
c) 删除RVALUE[Ri]
中的M
;
④ 给出R
,返回。
至此可以看到,单纯是寄存器分配便是需要较多数据结构配合和工作时间消耗的,管中窥豹,可以看到代码生成器的一个部件工作量便是很复杂的。