5.2.2 再论符号symbol与公共子表达式
在介绍算术表达式的翻译前,让我们简单重温一下第2.5节中的“图2.5.4 公共子表达式”及“图2.5.5 valueDef和valueUse”。为阅读方便,我们再次给出这两张图,更详细的说明请参见第2.5节。对于图2.5.4第2行的a+b,我们会由第7行的中间代码来对a+b进行求值,其结果存于临时变量t1中,之后在第3行中再次遇到表达式a+b时,a和b的值并没有发生变化,我们可在第9行直接把t1赋给变量d。由于我们在第4行对a进行赋值,导致t1中保存的值不再有效,所以我们需要重新进行计算第5行的a+b。我们用形如第7行的“t1:a+b;”来表示临时变量t1由a和b相加产生,这与编译原理相关教材中使用“t1 = a+b;”的式子略微有所区别。
图2.5.4 公共子表达式
对公共子表达式进行重用的思路并不难理解,第2.5节的图2.5.5主要是用于说明要实现这样的想法,我们需要定义出相应的数据结构。图2.5.5中的struct valueDef结构体用来描述一个公共子表达式“t1:a+b”,其中临时变量t1、变量a和变量b各用一个struct variableSymbol符号对象来刻画。在变量a发生变化时,为了能让公共子表达式a+b失效,我们需要在a中记录“a在哪些公共子表达式中被使用”,图2.5.4中定义的结构体struct valueUse就用于此目的。由于变量a可在多个公共子表达式中被使用,因此我们需要一条由若干个struct valueUse对象构成的链表来记录这些表达式,其链首就存于图2.5.5的结构体对象struct variableSymbol的uses域中。当a发生变化时,我们要沿着uses域所指向的链表,使链表上的各个公共子表达式失效。当然,变量b中也有类似的结构,当变量b发生变化时,我们也要做类似的处理。
图2.5.5 valueDef和valueUse
图2.5.4第7行使用“t1:a+b”的一个原因是,这隐含着UCC编译器只对t1进行唯一的一次赋值(单赋值)。但对于C语句“b = a > 3? 50:60;”来说,与其对应的中间代码如下所示,我们可以发现临时变量t0会被赋值两次。为了与单赋值的情况有所区别,此处我们用MOV指令“t0 = 50”,其中用的是赋值号’=’,而非冒号’:’。在生成中间代码后,我们在进行优化时,可把以下对t0进行赋值的语句改为对b进行赋值。但由于UCC编译器的优化只在一个基本块内进行,而此处的t0显然出现在多个基本块中,这就需要我们在生成中间代码时做一些特殊处理,以便优化时可对“t0=50;”指令做修改。产生这样困境的原因在于UCC编译器在翻译表达式“a > 3? 50:60”时,并没有考虑其所处的上下文,而总是将条件表达式的结果先存放到一个临时变量中。
if (a <= 3) goto BB2;
BB1:
t0 = 50; // 优化后改为 b = 50;
goto BB3;
BB2:
t0 = 60;// 优化后改为 b = 60;
BB3:
b = t0; // 优化后可删去
由于UCC编译器在翻译函数调用时,也没有考虑函数调用所处的上下文,总是先把函数调用的返回值存于临时变量中,因此我们就会得到以下两条中间代码。在优化阶段,我们完全可以把这两条中间代码改为“num: f();”。这就需要我们删去“num =t1;”,同时把指令“t1 : f();”改为“num:f();”。由于函数调用是有副作用的,因此函数调用f()不可以作为公共子表达式使用。若num又是一个临时变量,若还存在形如“num = 50;”这样的MOV指令,则我们还要考虑对“num:f();”和“num=50;”做进一步的优化。在遇到形如“b = a > 3? f():50;”的C语句时,就会出现这样的情况。因此,在为函数调用生成中间代码“t1:f()”时,我们需要做一些特殊的预处理,以便对中间代码进行修改。
t1 : f();
num = t1;
打印出来的中间代码,对临时变量的赋值号是用‘=’还是‘:’,其实只是为了方便中间代码的阅读。UCC编译器中的函数GenerateAssign用于生成形如“t1:a+b;”的中间代码,该中间代码的运算符实际上是ADD,其中的冒号很清楚地告诉我们这不是一条MOV指令,而是ADD指令。而函数GenerateMove则用于生成形如“t0 = 50;”的中间代码,运算符是MOV,其中的‘=’一目了然地告诉我们这是一条MOV指令。函数GenerateFunctionCall用于生成形如“t1 : f();”的中间代码,该中间代码的运算符实际上是CALL。这几个函数的代码如图5.2.6所示,第1至12行是函数GenerateMove的代码,第2行用于生成一条中间代码,第3行把“源操作数src和目的操作数dst”的引用计数加1,第4至5行对中间代码进行初始化,通过第6行的AppendInst函数把MOV指令(形如“t1 = 50;”)添加到当前基本块中。如果目的操作数dst是变量(全局、静态或局部变量),由于当前生成的MOV指令会对dst重新赋值,这就使得以dst作为操作数的公共子表达式失效,第8行的TrackValueChange就会沿着图2.5.5中的uses链表来完成这个工作。如果dst是临时变量,为了能在后续优化时,能对当前MOV指令进行修改,我们在第11行传递给DefineTemp函数的第3个实参(即inst)是MOV指令的首地址,而不是算术运算的操作数。与之形成对比的是用于产生公共子表达式(形如“t1:a+b”)的函数GenerateAssign,如图5.2.6第28至41行所示,我们在第40行传递给DefineTemp函数的实参是“目的操作数dst、运算符的编码opcode、源操作数src1和源操作数src2”。图5.2.6第13至27行的GenerateFunctionCall函数用于生成CALL指令(形如“t1:f();”),第13行的recv对应函数返回值t1,第14行的faddr相当于函数的首地址f,第14行的args向量用于存放多个实参,第16至20行会把这些符号对象的引用计数加1,第21至24行用于初始化CALL指令,并添加到当前基本块中。在后续优化时,可能要修改当前CALL指令,因此在第26行调用DefineTemp函数时,第3个实参仍然是CALL指令的首地址inst,这与MOV指令的情况类似。我们在GenerateMove的第10行调用DefineTemp函数,是为了把“对同一临时变量进行赋值的多条MOV指令”链接到一起,以便后续的优化。而在函数GenerateAssign中第40行调用DefineTemp函数,则确实为了创建一个struct valueDef对象来表示公共子表达式。
图5.2.6 GenerateMove()
图5.2.6第42至62行的函数DefineTemp用来创建一个struct valueDef对象,该对象描述了一个公共子表达式,第44至48行对其进行初始化。如果当前指令是MOV或CALL指令时,通过第51至52行的链表插入操作,我们把对同一个临时变量进行赋值的若干条指令链接到一起。例如,在介绍“b=a > 3? 50:60”的中间代码时,我们遇到了“t0=50;”和“t0=60;”这两条指令,图5.2.6第51至52行会把这两条MOV指令链到一起,链首存放在t0对应符号对象的def域中。之后在执行优化函数PeepHole时,我们遇到中间代码“b = t0;”时,就可通过t0对应符号对象的def域,找到这些中间指令,并把其中的t0都改为b,从而就可以得到“b=50;”和“b = 60;”这两条优化后的指令,之后还可删去“b = t0;”。当源操作数src1是“局部、静态或全局变量”,我们要在src1变量的uses域所指向的链表上添加一个struct valueUse对象,用来记录src1会在公共子表达式(t: src1 op src2)中被使用,这个工作由图5.2.6第56行的TrackValueUse函数来实现。当然如果存在源操作数src2,我们也做类似处理,图5.2.6第58至60行用于此目的。用于对MOV指令和CALL指令进行优化的主要代码,如图5.2.7所示。
图5.2.7 PeepHole()
图5.2.7第6至24行的的代码用于对CALL指令进行优化,我们想把“形如第11和12行注释里的中间代码”优化为第14行的中间代码,第16行用于把临时变量t1的引用计数减2,第17行实现了把“t1:f();”改为“num:f();”,第18至20行用于删除指令“num = t1;”。当num本身又是临时变量时,我们需要把指令“num:f()”添加到num对应符号对象的def域中,以便后续的进一步优化,这是通过在第22行调用DefineTemp函数来实现。图5.2.7第25至61行用于对MOV指令进行优化,我们希望能把“第28至32行的中间代码”优化为第34至38行的代码,注释中的例子就是我们前面在介绍C语句“b = a > 3? 50:60;”时遇到的中间代码。当前MOV指令形如第32行的“b=t0;”,其中t0为临时变量,由于在这情况下,对t0进行赋值的指令(第28行的“t0=50;”和第30行的“t0=60;”)都不在当前基本块中,我们可通过第40行的代码,取出我们之前在def域中保存的MOV或CALL指令链表,第40至47行的while循环用于把链表中的形如“t0=50;”的指令改为“b=50;”。我们在第45行要把t0的引用次数减1,在第46行把b的引用数次加1。由于我们要删除第38行的“b=t0;”,就需要在第50和51行把b和t0的引用次数都减1,第58至60行从当前基本块中删除了该指令。如果b本身也是个临时变量,我们就把原先所有对b进行赋值和对t0进行赋值的指令链接到一起,为对b的进一步优化做好准备,第55行的函数AppendVarDefList实现了这两个链表的合并操作(优化前对b进行赋值的指令构成一个链表,而对t0进行赋值的指令又构成另外一个链表,合并之后,新的链表中就可能有CALL指令,也可能有MOV指令,例如“b = a > 3? f():50;”对应的中间代码)。函数AppendVarDefList的代码并不复杂,我们从略。
公共子表达式“t1:a+b”中的临时变量t1则由C编译器产生,并不是由C程序员给出。而源操作数a和b可能是临时变量,也可能是由C程序员命名的变量。全局变量或静态变量对应“全局静态数据区”中的一块内存单元,而局部变量或者形式参数则对应“栈区”中的一块内存单元。临时变量通常只用于暂存一个计算结果,对应的是“栈区”中的一块内存单元或者直接对应C编译器分配的一个寄存器。这些临时变量名对C程序员是不可见的,C程序员不可能对其进行赋值。
图2.5.5中的struct valueDef刻画了一个公共子表达式,第2.5节中的图2.5.8中的哈希表valueNumTable用于存放多个公共子表达式。第2.5节“图2.5.9”介绍的TryAddValue函数实现了该哈希表的查找或插入操作。当我们遇到形如a+b这样的表达式时,我们通过调用TryAddValue函数,先在哈希表中找一找,看看在同一基本块内,之前是否已经计算过a+b,若a+b的值仍然有效(即a和b都没有被改动),则没有必要重新计算。但是,由于C语言中可以取a或b的地址,之后再通过地址去访问a或b对应的内存单元,而不必通过变量名a或b来访问,因此,要检测操作数a或b是否被改动过,并不是件容易的事情,这需要进行较复杂的别名分析(Aliase Analysis)。为简单起见,UCC编译器采取了保守的策略,即一个变量a若被进行过“取地址”运算(即&a),即认为a可能发生变化,公共子表达式a+b不再有效。
在C语言中,对数组元素和结构体成员的访问方式也较为灵活,UCC编译器为了简单起见,也认为这些操作数被进行了“取地址”操作,即不再重用含有这些操作数的表达式。图5.2.8中的例子对此进行了说明,虽然第20行只是对a进行取地址,并没有通过*ptr来改变a的值,但UCC编译器保守地认为a的值会发生变化,因此对第21行的a+b进行重新计算。而含有结构体成员dt.a或者数组元素arr[0]的表达式,也不被当作公共子表达式来处理。在第23行对dt.a+dt.b进行了重新计算,与之对应的中间代码在第48行,其中的dt[0]对应的就是dt.a,而dt[4]对应的就是db.b。在中间代码层次,通过保存在符号表中的类型信息,我们可以知道dt.a在结构体对象dt中偏移为0,而dt.b在结构体对象dt中的偏移为4。这是一种“基地址base + 偏移 offset”的寻址模式,符号dt相当于是基地址,而0或4为偏移。在图5.2.8第25行,我们也对C表达式arr[0]+arr[1]重新计算,与之对应的中间代码在第52行。在中间代码层次,我们已经把C程序员编写的第25行的arr[1],改用“基地址base + 偏移offset”的形式来表示,即表示为第52行的arr[4],其中arr相当于基地址,而4相当于偏移。不过,对于第9行的数组int arr2[3][5]来说,由于C程序员在第26行用arr2[i][2]这样的方式来访问数组元素,其中i为变量,而2为常量,这就意味着对数组元素的寻址要按“基地址+常量偏移+非常量偏移”来进行,其中常量偏移为2*sizeof(int),即8;非常量偏移则为i*sizeof(int)*5,即i*20,第54至57行用于计算出arr2[i][2]的地址并存于临时变量t12中。对图5.2.8第58行的中间代码“*t12 = 30;”而言,若把t12的值存于CPU的寄存器中,我们就可以通过寄存器间接寻址来对arr2[i][2]进行赋值操作。
图5.2.8 取地址与偏移
我们再来看一下图5.2.8第29行的dt.num[3],dt.num在结构体对象dt中的偏移为8,而num[3]在数组dt.num中的偏移为12,两者相加,可得到dt.num[3]在结构体对象dt中的偏移为20。因此,与第29行dt.num[3]对应的是第59行的dt[20],我们仍然是用“基地址+偏移”的模式来访问dt.num[3]。
对图5.2.8第16行的局部变量dt2的初始化而言,在第4.4节对初始化进行语义检查时,我们已介绍过如下所示的struct initData结构体,第15行的注释表示由3个struct initData对象构成的链表,其中的(8:3)表示我们要用表达式3,来初始化局部变量dt2从偏移8开始的内存单元,对应的中间代码为图5.2.8第38行的“dt2[8] = 3;”。在中间代码层次,我们还是用 “基地址+偏移”的模式来访问要被初始化的内存单元。不过对于局部变量dt2来说,由于其对应的内存单元在动态分配的栈区中,在汇编代码中,我们就没办法使用变量名dt2,而是使用形如“movl $3,-40(%ebp)”这样的指令,寄存器ebp在运行时会指向动态分配的栈区。图5.2.8第35行的中间代码“dt2:24”表示,要把对象dt2所占24字节栈空间先清0,然后再通过第36至38行的中间代码对相应偏移位置进行初始化操作。
这里我们初步介绍了对结构体成员或数组元素进行寻址的概念,在下一节中,我们对“偏移”做进一步讨论,分析UCC编译器中与此相关的函数,如Offset()等。struct initData{
int offset;
AstExpression expr;
InitData next;
};