在这一小节中,我们准备分析一下exprchk.c中的函数CheckPostfixExpression,此函数用于对后缀表达式进行语义检查。后缀表达式所对应的语法树请参照第3章的图3.1.21,这里不再重复。我们先从数组索引入手,图4.2.4给出了hello.c经UCC编译后生成的语法树hello.ast,中间代码hello.uil及最终生成的汇编代码hello.s的主要部分。
图4.2.4 数组索引ArrayIndex
在图4.2.4第2行中,我们定义一个二维数组arr[3][4],第4行的ptr是一个指向int[4]数组的指针,而第5行的ptr1是一个指向int的指针,第6行的ptr2是一个指向int *的指针。第8至11行给出了对数组元素进行访问的一些后缀表达式。在UCC编译器内部构造的语法树形如第3章的图3.1.21,当然在语义检查时,我们会对语法分析时构造的语法树进行一些修改,为了便于调试分析,UCC编译器可以把语义检查后的语法树的主要部分打印出来,如上图hello.ast所示。例如,第9行的arr[1][2] = 2对应的抽象语法树AST为第21至24行,一对小括号表示了一棵AST子树,其中([] arr 16)表示一棵以[]运算符为根,左操作数为arr,右操作数为16的语法树,我们不妨记这棵子树为arr[16];而([] ([] arr 16) 8)则表示了一棵以[]运算符为根,左操作数为子树arr[16],右操作数为8的抽象语法树,我们不妨把16和8加一起,记这棵子树为arr[24]。而(= ([] ([] arr 16) 8) 2)则表示一棵以赋值运算符=为根,左操作数为arr[24],右操作数为2的抽象语法树。为了可读性,UCC编译器在打印(= ([] ([] arr 16) 8) 2)时,增加了一些回车换行和缩进,如下所示。
arr[1][2] = 2;
//
(= ([] ([] arr
16)
8)
2)
稍微接触一下人工智能和LISP语言,我们就会发现图4.2.4中hello.ast的代码和LISP语言非常相似,实质上LISP语言就是一种直接建立在抽象语法树上的语言。换句话,LISP程序员编写的LISP代码,就是用字符串表示的抽象语法树。由图4.2.4第2行int arr[3][4]可知,arr是二维数组,由3个int[4]数组构成的数组,若每个int占4个字节,则每个int[4]数组要占用16个字节,因此在语义检查时,我们实际上把arr[1][2]转换成([] ([] arr 1*16) 2*4),即([] ([] arr 16) 8)。UCC源代码exprchk.c中的函数ScalePointerOffset(),会根据数组arr声明时的大小,把数组索引值1和2进行倍乘放大,进而得到16和8。当然如果索引值是变量,例如arr[x][y],则需要进行16*x+y*4的计算,才能得到arr[x][y]所在的内存单元。由于x和y的值没法在编译时确定,所以对16*x+y*4的求值,需要在运行时进行,编译时只能生成相应的代码,因此在语义检查时,我们需要为16*x这样的倍乘运算构造相应的语法树结点。函数ScalePointerOffset()的代码如图4.2.5所示。
图4.2.5 ScalePointerOffset()
对于arr[x][y]而言,我们期望进行16*x的计算,则会调用ScalePointerOffset(x, 16),图4.2.5第65至72行构建了一个根结点为OP_MUL的语法树,第69行把x作为左操作数,而第72行则把倍乘系数16作为右操作数。当面对arr[1][2]时,相当于第60行的参数offset为编译时的常量1,此时我们完全可以在编译时进行1*16的乘法计算,没有必要把这个计算推迟到运行时,第74行的FoldConstant()函数完成了这个被称为“常量折叠”的操作。函数FoldConstant的代码在fold.c中,虽然较长,却很好理解,我们就不再啰嗦。
由图4.2.4,我们可以发现,虽然在语法上第10和第11行的两条赋值语句几乎一模一样,但经语义检查后,生成的语法树却完全不同。其原因在于两者的寻址模式完全不一样,对于arr[0][0]而言,在汇编指令中,arr就是是数组的首地址,而arr+0就是元素arr[0][0]的地址,图4.2.3第51行的movl $3, arr+0实现了arr[0][0]=3的赋值功能。但对ptr2[0][0]而言,ptr2是个int **类型的指针,ptr2[0][0]的含义实际上是**ptr2,这需要进行两次的间接寻址,才能得到所要的内存单元。因此,在语义检查时,我们需要把ptr2[0][0]的语法树修改成形如(* (+ (* (+ ptr2 0)) 0)),其中的子树(* (+ ptr2 0))完成了第一次间接寻址,此处的*表示的Dereference,而不是表示乘法运算,这个英文单词经常被译为“解引用”或者“提领”。第一次看到“提领”的翻译应是在侯捷先生的相关C++译作中,初看“提领”之词,似乎很唐突,但仔细品味,确实是再形象不过。相当于我们去逛商场时寄存物品,把私人物品存入寄存柜后,我们会得到一个小纸条,上面或者是箱号或者是一个条形码,之后我们凭着这个小纸条去“提取或领取”我们寄存的物品,而这个小纸条就相当于是C语言中的地址,所以“提领”之译实在是再妙不过。
arr[0][0] = 3;
ptr2[0][0] = 5;
//
(= ([] ([] arr 0) 0) 3)
(= (* (+ (* (+ ptr2 0)) 0)) 5)
图4.2.4第8至11行中各语句对应的寻址模型如图4.2.6所示,我们可以发现,指针变量ptr2、ptr1和ptr都有相应的内存单元,数组arr的起始地址为0x804a070,而ptr1和ptr内存单元中的内容都是0x804a070,这意味着这两个指针都指向数组arr的开始位置。并且,arr[0][0]和ptr2[0][0]对应相同的内存单元,ptr[1][2]和arr[1][2]对应相同的内存单元。要通过指针ptr定位到ptr[1][2],我们需要先计算出0x804a070+1*16+2*4,即0x804a070+24,然后再由地址进行“提领”运算即可,相应的中间代码如图4.2.4第37和38行所示,而汇编代码如第47至49行所示。而要由ptr2定位到ptr2[0][0],则需要经过ptr2+0*4的运算,此处0*4中的4来源于sizeof(int *)为4,编译器进行常量折叠后,即为ptr2,然后进行*(ptr2)的提领操作,即得到ptr1中的内容0x804a070,再进行0x804a070+0*4的运算,其中0*4中的4是由于sizeof(int)为4,之后再根据常量折叠后的0x804a070进行提领操作。与之对应的中间代码如图4.2.4第41至42行所示,而汇编代码如图第52至54行所示。而arr[0][0]和arr[1][2]的定位则不需要在运行时进行“提领”操作,要访问的内存单元的首地址在编译时就可以计算出来,即arr和arr+1*16+8(即arr+24),对应的中间代码如图第39和40行所示,汇编代码如图50和51行所示。
图4.2.6 数组寻址
我们会在后续章节讨论中间代码和汇编代码的生成,这里把相应的中间代码和汇编代码放在一起讨论的目的是为了进一步熟悉这些概念。对arr[0][0]和ptr[0][0]而言,两者在语法分析后对应的语法树是同构的,但由上述分析可见,在语义检查后,两者的语法树有较大差别,引起这种区别的源头在于arr和ptr2具有不一样的类型,在对arr和ptr2的声明进行语义检查后,arr是int[3][4]类型,而ptr2是int **类型,我们会把它们的类型信息记录到符号表中。经过这些准备工作后,现在让我们来看看对数组索引的语义检查的相关代码,如图4.2.7所示。图中第4至31行完成了对数组索引OP_INDEX运算符表达式的语义检查,而第32至33行则调用CheckFunctionCall(expr)来完成对函数调用OP_CALL的语义检查。而“后加加”与“后减减”则通过调用TransformIncrement(expr)来检查,成员选择运算符OP_MEMBER和OP_PTR_MEMBER(即.和->运算符)则通过CheckMemberAccess(expr)来处理。我们会在稍后对这几个函数进行分析。
图4.2.7 CheckPostfixExpression()
对于arr[1][2]而言,我们先来分析arr[1],如果把[]看成是运算符,则该后缀运算符实际上也有两个操作数,一个是arr,另一个是1。我们需要在图4.2.7第5和第6行先对[]运算符的左右子树进行语义检查,这通过递归地调用CheckExpression()来完成,此处,左操作数arr和右操作数1实际上已是一个基本表达式PrimaryExpression,真正被调用的函数是CheckPrimaryExpression()函数,我们在稍后会对这个函数进行分析。在此函数中,会查符号表,得到arr的类型为int [3][4]。此时,按照C的语义,arr对应的语法树结点的类型需要被调整为指向数组元素的指针类型,对数组类型int [3][4]而言,其数组元素为int[4]为类型,所以arr对应的语法树结点的类型为int (*)[4]。函数Adjust()完成了这个类型调整的工作,我们已经在上一小节中讨论过函数Adjust()。令大多数C程序员大跌眼镜的是” 1[arr][2] = 1;”竟然也是合法的C语句,其等效于”arr[1][2] = 1;”,实际开发中,恐怕只有孔乙已穿越到现代才能写出如此奇葩的C语句来。不过C编译器还是要支持之,第7至9行用于把1[arr]转换成arr[1]来处理。坦白讲,实在看不出C编译器支持如此语法有何意义,权当满足可交换性和孔乙已对茴香豆的“茴”有4种写法的特殊癖好吧。当[]的左操作数是指向对象的指针类型(即除了函数指针外的指针类型),而右操作数是整数类型,则第10行的条件成立,我们就可进入第11行进行处理;否则跳到第31行进行错误处理,此时[]的左右操作数的类型不正确。这里,我们能看到类型系统再次派上用场。因为arr对应结点的类型已经被调整数int (*)[4],同时该结点的isarray域也会在Adjust()函数中被置为1,代表arr对应的语法树结点的原有类型为数组类型。第11行把[]对应的语法树结点的类型置为int[4],而第13行则把右操作数1进行整数提升,即把short或char类型提升为int类型,第14行则调用我们前面讨论过的ScalePointerOffset()函数来实现1*16的运算,因为sizeof(int[4])正好就是16。因为arr对应的结点的isarray域被置为1,且[]对应的语法树结点的类型int[4]也是数组类型,所以第15行的条件不成立,此时我们直接从第29行返回。这样,我们就完成了对arr[1]的语义检查,我们会返回如下语法树,其根结点的类型为int[4],即arr[1]的类型为int[4]。
([] arr 16)
按照后根遍历的次序,接下来我们会再对arr[1][2]进行检查,此时左子树为arr[1],而右子树为2,类似的,我们会得到以下语法树,其根结点的类型为int。第12行会标记该语法树根结点为左值,此处即arr[1][2]可充当左值,故arr[1][2]=1是合法的。当然,按照图4.2.7的代码,当检查语句“arr[1] = 6;”时,我们会在第12行把arr[1]对应的结点的lvalue域也误设为1,但因为arr[1]的类型为数组类型int[4],在检查赋值运算符=时,即后续要分析的函数CheckAssignmentExpression中,我们会通过Adjust()函数,再次把arr[1]结点对应的lvalue置为0,即arr[1]不可充当左值,所以会报错“error:Theleft operand cannot be modified”。
([] ([] arr 16) 8)
同理,对于ptr[1][2]和arr[0][0],图4.2.7第15行的条件都不会成立,所以我们分别得到以下语法树。
([] ([] ptr 16) 8)
([] ([] arr 0) 0)
但是,对ptr2[0][0]来说,图4.2.7第15行的条件就会成立。我们先来看ptr2[0],查符号表可得ptr2原来的类型就是int **,而ptr2[0]对应的语法树结点的类型就应是int*,即ptr2不是数组类型且ptr2[0]也不是数组类型,所以第15行的条件会成立。第16至27行的代码则用于构造语法树(* (+ ptr2 0) ) 。按后根遍历的次序,完成对ptr2[0]的检查后,我们会再对ptr2[0][0]进行检查,此时ptr2[0]为左子树,而0为右子树,同理可得到如下语法树。
(* (+ (* (+ ptr2 0)) 0))
ptr2[0][0]仍然可访问到数组元素arr[0][0],但如果想通过ptr2[1][2]来访问arr[1][2]则就是大错特错。因为ptr2[1][2]在语义检查后,对应的语法树为:
(* (+ (* (+ ptr2 4) ) 8))
按图4.2.6所示,ptr2的内容为0x804a54,所以ptr2+4的结果为0x804a58,之后根据地址0x804a58去做“提领”操作,天知道该单元中存放的是什么内容。God Bless You.