C编译器剖析_4.2 语义检查_表达式的语义检查(4)_函数调用

4.2.4 函数调用的语义检查

     在这一小节中,我们来讨论一下函数调用的语义检查,语法上,函数调用对应的表达式属于后缀表达式PostfixExpression,UCC编译器exprchk.c的函数CheckFunctionCall()完成了对函数调用的语义检查,如图4.2.18所示。在阅读这份代码时,需要对语法分析后为函数调用构造的语法树有较好认识,请先参照”图3.1.21后缀运算符对应的语法树”或者先预览一下图4.2.19。

 

图4.2.18 CheckFunctionCall()

    对形如f(a,b,c)的函数调用进行语义检查时,我们需要先查找符号表,看看函数f是否已经声明过,图4.2.18第6和第7行进行了这个判断。按C标准的规定,如果f未经声明就使用,则将函数f视为旧式风格的函数声明,相当于f的声明为int f()。我们在“2.4节C语言类型系统”时已做过介绍,旧式风格函数会带来令人抓狂的噩梦。这也应是C++编译器禁止“函数未声明就使用”的原因。从这一点,我们也能再次体会,C++并非是C语言的超集,C++只是尽可能地去兼容已有的C,对于实在看不下去的部分也采取了“摒弃”的策略。第8行的DefaultFunctionType则代表“形如int f()的旧式风格函数声明所对应的类型”,第9至11行把这个隐式声明的int f(),通过函数AddFunction添加到全局符号表中。当然,如果我们之前已经对函数f做了形如int f(int,int,int)的声明,此时就能在符号表中找到函数f的相关信息,或者当我们是通过形如(*ptr)(a,b,c)的方式来调用函数,此时表达式(*ptr)不是op域为OP_ID的表达式结点,则在第13行调用CheckExpression函数来对表达式f或者(*ptr)进行语义检查。经过第15行的Adjust()函数的类型调整后,如果“f或者(*ptr)对应结点”的类型不是“指向函数”的指针,那我们在第18至20行进行错误处理;否则,我们在第22行记下函数的类型信息。对于图4.2.19中的f对应的结点来说,其类型为“指向函数int(int,int,int)”的指针,即int(*)(int,int,int),因此,我们在图4.2.18第22行记下的就是形如int(int,int,int)的类型信息,第56行则记下了函数返回值的类型,即表达式f(30,40,50)的类型为int。关于函数类型的数据结构,请参考第2章的”图2.4.9 函数的类型结构”。

     图4.2.18第24至39行用于对函数调用中的各个实参进行检查,主要的工作由第30行的CheckArgument()来处理,检查的内容包括实参个数是否与形参个数吻合,实参与形参在类型上是否匹配,这相当于要检查能否把实参赋值给形参。第40至55行在实参个数和形参个数不一致时,会给出警告或者错误提示。对于形如int f()的旧式风格的函数声明来说,形参列表并不是其函数接口的一部分,即第42和50行的hasProto为0(不存在原型prototype)。原型prototype的意思是“这是范本,我们得依样画葫芦,范本有几个形参,调用时就得有几个实参”,此时我们仿照Clang编译器的做法,给出警告,如第47和53行所示。对于形如int f(int ,int,int)的新式风格函数声明,我们就得照着原型来进行函数调用了,不然就要报错了,如第44和51行所示。图4.2.18第36至39行的代码用于检查多余的实参,例如函数调用f(30,40,50,60,70),因为在新式风格的声明int f(int,int,int)中,我们只声明了3个形参。第30行的CheckArgument()在处理新式风格函数的参数时,如果发现已经检查完3个实参了,就会置第27行的变量argFull为1,此后不必再执行第29行的while循环。由于语义检查时,语法树结点会发生变化,甚至是重新构建,所以我们要在第30行在记录下CheckArgument()的返回值,而对于多余的实参,则只是在第37行调用CheckExpression()检查一下,此时实际上已经遇到“形参个数与实参个数不一致”的错误了。


图4.2.19 函数调用的语法树

    接下来我们来分析一下图4.2.18第30行的CheckArgument函数,如图4.2.20所示。第5行用于获取新式风格的函数的形参个数,第6行仍然是递归地调用CheckExpression函数进行实参表达式的语义检查,如果新式风格的函数声明形如f(void),即不存在参数,则在第8行设置相应标志位为1,表示对新式风格的函数实参已检查完毕,然后从第9行直接返回。对于形如f(int,int,int)的函数声明来说,f不是变参函数,若当前要检查的实参是最后一个,则第11行的条件成立,此时在第12行置相应标志位为1。而对于形如int f()的旧式风格的函数来说,形参列表并不是函数接口的一部分,我们需要对函数调用中的每个实参进行实参提升的操作,这由第15行的PromoteArgument()函数来完成。对于新式风格的函数,我们需要检查一下实参能否赋值给对应的有名形参,第20行的CanAssign()函数用来做这个判断。出于内存对齐的考虑,C编译器通常会把小于int类型的实参(例如char或者short)转换成int后入栈,第23行执行了这个由编译器隐式进行的转型操作。但是,对于新式风格函数中的float类型实参,并没有进行提升到double类型的动作,这与PromoteArgument()是有所区别的。因为进行过第20行的CanAssign()的判断,所以在第25行,我们在进行实参给形参赋值时,可以进行安全的类型转换。第28行则是用于处理新式风格函数中的变参函数,用于对“无名参数”进行实参提升的操作。阅读CheckArgument()的代码时,若对函数的类型结构不是太清楚,请参考”图2.4.9 函数的类型结构”。


图4.2.20 CheckArgument()

    图4.2.20第20行的CanAssign()函数几乎是严格按照C标准文档ansi.c.txt第” 3.3.16.1Simple assignment”节来编写的,该小节的文档规定了哪些情况下可以进行赋值操作,相关代码如图4.2.21所示。我们有意在第9至12行保留了一段来自ansi.c.txt中的语义规则,第13行的if语句实现了这个判断,即如果赋值运算的左右操作数都是算术类型,则可以进行赋值。实参赋值给形参时可能需要的强制转型操作,我们已在图4.2.20第22至25行完成。


图4.2.21 CanAssign()

    根据赋值运算的左操作数和右操作数的类型,我们在以下情况下可以进行赋值操作,对这些情况的判断是按以下排列依次进行的。

(1) 两者类型一致,对应图4.2.21第6行。

(2) 两者都是算术类型,对应上图第13行。

(3) 两者是兼容的指针类型,例如T1 * 和T2 *,其中T1和T2的类型兼容,函数IsCompatibleType()会对类型是否兼容进行判断,我们会在后续章节对这个函数进行分析。若T1和T2在限定符上也一致(即有相同的const或volatile)。上图第16行对此进行判断

(4) 两者都是指针类型,形如T1 * 和T2 *,其中T1和T2在限定符上要一致,且其中一个是void,但另一个不能是函数类型(即要求是结构体和double等描述数据的对象类型),对应上图第19行。

(5) 左操作数的类型为指针类型,右操作数为常数0,对应上图第23行。

(6) 两者都是指针类型,对应上图第26行,此时在第27行给出一个警告。

(7) 一个是指针类型,另一个是整数类型,但两者占同样大小的内存空间,对应上图第30行,此时在第32行给出一个警告。

     从中,我们可以发现,不同类型的结构体对象是不能进行赋值的。接下来,我们来分析

一下图4.2.20第23行用到的Cast()函数,其相关代码如图4.2.22所示。真正构建转型运算OP_CAST结点的代码在图中第31行的CastExpression(),第37行创建一个语法树结点,第39行置其op域为OP_CAST,第40行记录转型后的结点类型,第41行则记录转型前的表达式。当然,如果是对形如第34行的常量3进行转型,则可以在编译时进行简化,我们直接取3.0f即可,这个工作由FoldCast()函数完成,该函数在fold.c中,应该较好理解,我们不再啰嗦。图4.2.22第48至53注释中列出了UCC编译器内部的各种数据类型,第2行的I4表示要占用4个字节的有符号整数,在32位系统上对应int或者long;U4表示要占4个字节的无符号整数;F4表示占4字节的浮点数,对应float;F8表示要占8字节的浮点数,V表示VOID,而B则代表Block对象,对应联合体、数组或结构体对象。第55行的optypes[]用于记录这样的映射关系,这样通过调用第44行的函数TypeCode,我们可以快速地得到与类型结构对应的形如I4这样的类型编码。第2行注释里的类型编码先后次序,是有意进行安排的,按照这样的顺序,我们能比较快捷地进行类型判断,例如第8至9行的if语句就是用来判断“两个类型是否都为占据相同大小内存空间的整型”,例如short和unsigned short就满足这个条件。


图4.2.22 Cast()

    对于占据相同内存大小的整数类型来说,例如char和unsigned char, short和unsigned short,当进行有符号char和unsigned char之间的类型转换时,存放在内存单元的数据并没有发生任何变化,我们只需要记录该单元的类型发生了改变。在这种情况下,我们只要执行图4.2.22第17行的代码,在相应语法树结点上记下转型后的新类型即可,这个新类型会影响我们在代码生成阶段时的指令选择。例如,对于有符号整数的右移,我们要在最高位补上符号位,我们选择的汇编指令为sar;而对于无符号整数的右移,我们在最高位补0即可,我们选择的汇编指令是shl。在UCC编译器中,即使是在把char类型的变量c1强制转型为float时,即(float) c1,我们也是分两步走,第一步先把char提升为int,之后再进行int到float的转型操作数。在早期,C语言的int类型的大小反映的是CPU通用数据寄存器的大小,CPU当然期望操作数恰好就放在其数据寄存器里。图4.2.22第20至23行完成了第一步由char到int的提升,而第28行则进行了第二步由int到float的转换。反之,如果要把float类型的变量f强制转型成char类型,我们也分两步走,第一步执行float到int的转换,第二步再进行int到char的转换,第25至28行完成了这两步的工作。而第5至第7行的代码则用于把表达式强制转换成void,这通常用于以下情况,对于未在函数体中被使用的参数arg,有些编译器会给出一个警告,避免这个警告的一个做法就是加上(void) arg的语句。

         void f(int arg){

                   (void) arg;

                   // …

         }

    而图4.2.22第11至16行的代码则相对比较微妙,下面,让我们结合一个具体的例子来解释这些代码,如图4.2.23所示。在UCC编译器中,参与算术运算的char或short都会被先提升为int类型,再进行算术运算,例如对图4.2.23第8行的算术右移来说,short类型的操作数s会先被转型为int,如第19至20行的语法树(cast int s)所示,之后再把int型的结点按照第8行的C语句的要求转型为unsigned int。由于int和unsigned int同样占4个字节,如果没有图4.2.22第11至16行的代码,则我们不会调用CastExpression函数去构造一个OP_CAST运算的语法树结点,而只是把s>>1对应结点的类型设置为unsigned int,但是图4.2.23第8行的(int)转型又会把s >> 1对应结点的类型设置为int。在UCC编译器的汇编代码生成时,例如ucl\x86.c的函数EmitAssign (IRInst inst)中,我们是根据中间代码inst的类型来决定选用sar还是shr指令,而inst的类型又来源于语法树结点s>>1的类型,我们在讨论中间代码生成的函数TranslateBinaryExpression()时就能看到这一点。这会导致我们错误地把s >>1结点的类型当作有符号数int,从而在代码生成时选用算术右移指令sar,从而产生错误的结果。按照第8行的C语句,我们应该选用逻辑右移指令shr,在最高位补0而补上符号位。由于这样的原因,当进行I4和U4之间的类型转换时,我们调用图4.2.22第15行的CastExpression()函数来显式地构造一个转型运算的语法树结点,如图4.2.23第18行所示。

        

图4.2.23 转型所对应的语法树

     稍微小结一下,在UCC编译器中,参与算术运算的小于int的操作数(char,unsigned char,short或unsigned short)都会被提升到int,例如图4.2.23第8行的short类型的变量s。要注意的是即使是进行两个char类型变量的加法,例如c1+c2,我们也是先会c1和c2提升为int,然后做32位的加法运算。而进行强制类型转换时,例如图4.2.23第9行的(float) c1和第10行的(char) f,我们也是以int类型作为中转,如图4.2.23第22至30行的语法树所示。以int型作为操作数,实际上意味着我们总是试图充分利用CPU的通用数据寄存器。理解了Cast()函数,那么再理解前文提及的用于实参提升函数PromoteArgument,就是件很容易的事情了。

         static AstExpressionPromoteArgument(AstExpression arg){

         Typety = Promote(arg->ty);

         returnCast(ty, arg);

}

Type Promote(Type ty){

return ty->categ< INT ? T(INT) : (ty->categ == FLOAT ? T(DOUBLE) : ty);

}

     而对于前文提及的用于判断两个类型是否相容的函数IsCompatibleType(),我们会在后

续章节中进行讨论。要理解这个函数,我们需要对“2.4节 C语言的类型系统”中介绍的各种类型结构有感性的认识,还是那句话,看起来很笨,但很有效的办法是用一个纸制的笔记本,把我们在2.4节给出的类型结构和在第3章构造的语法树画在纸上,对照着这些图来阅读代码,才不至于在庞大的语法树上和复杂的类型结构里迷失方向。

    前文中,另两个比较微妙的函数是图4.2.18第7行的LookupFunctionID函数和第10行的AddFunction函数。让我们结合图4.2.24中的例子来对此进行讨论。在函数f的函数体中,我们在图4.2.24第3行中对函数h进行了声明,这导致我们无法在第5行中再次声明int类型的变量h。但是,在函数体f中,对函数h的声明不仅要在局部符号表中占据一项(由此,当第5行的局部变量h企图填入局部符号表时,我们在对声明进行语义检查时就能报错),而且还要在全局符号表中占据一项(这样,我们可以第8行成功调用h(3,4))。第3行对函数h的声明,与第4行对局部变量a的声明有较大不同,第4行的a在函数f的函数体外是无法进行访问的。


图4.2.24 函数体中的函数声明

         另外,对于以下两个函数声明来说,C编译器会把它们视为彼此相容Compatible的函数声明,函数IsCompatibleType()会用来检测两个类型是否相容。

         int  g(int (*)(), double (*)[3]);

         int  g(int (*)(char *), double (*)[]);

         在这种情况下,C编译器并不会报错,而是为这两个相容的声明类型,构造一个“相当于最大公因子的”类型,这个“最大公因子”在C标准文档中被称为合成类型CompositeType,对以上两个声明来说,最终合成的最大公因子如下所示。UCC编译器type.c中的函数CompositeType(ty1,ty2)用于实现这个合成操作。我们会在后续章节对IsCompatibleType和CompositeType等与类型系统相关的函数做进一步分析。

         int  g(int (*)(char *), double (*)[3]);

     由于有这样的微妙的语义,我们期望只在全局符号表中存放函数声明的实际类型信息,这样但要通过CompositeType()函数来改变已有函数声明g的类型信息时,我们只要改变全局符号表中的相应符号就可以,而不用去改动其他的符号表。而在局部符号表中,我们只放置一个占位符,以便能检测出形如图4.2.24第5行注释所示的重定义错误。UCC编译器中的AddFunction函数实现了往全局符号表中添加函数的操作。UCC编译器原始的ucc162版本中,并没有LookupFunctionID函数,原有的代码中只有LookupID函数。为了实现上述函数声明的语义,同时能尽量少地对原来的代码进行改动,我们在ucc162.2版中,添加了LookupFunctionID函数,当然,得很不好意思地承认,其中的代码较为晦涩,这个补丁打得相当之ugly,在后续版本中,我们需要再做改进。原有LookupID函数对符号表的检索是从当前符号表开始,如果找不到,则再查找外层的符号表,直到全局符号表为止,其代码如图4.4.25所示。对符号表的查询操作实际上由第1行的DoLookupSymbol函数来完成,第3行计算出哈希值,第6至9行的for循环根据这个哈希值在相应的哈希桶上进行查找。如果存在更外层的符号表,且我们想查找外层的符号表,则第11行的while条件会成立。


图4.4.25 LookupId()函数

    在UCC编译器的内部,用来存放符号的数据结构主要有两种,一种是哈希表,一种是单链表,ucl\symbol.c中定义的一些变量用于此目的,如图4.4.26所示。第3行的哈希表GlobalTags用于存放在函数体的外部声明的结构体struct、联合体union和枚举enum的名称,这些名称在C标准文档中被称为Tag。这个单词常被译为“标签”,而语句gotoAgain中的标号Again对应的英文单词为label。较易混淆的是label这个词也常被译为标签。而第5行的GlobalIDs用于存放全局的变量名和函数名,常量(例如123)则存放于第7行的哈希表Constants中。由此,我们可以看到,即使是在同一作用域中声明的结构体名struct Data和变量名int abc,也是存放在不同的哈希表中的。第9行的指针Tags指向了当前作用域的用于结构体名的符号表,而第11行的指针Identifiers则指向了当前作用域的存放变量名和函数名的符号表。为了后续阶段生成代码的方便,我们还会把函数名对应的符号链接在一起,其链首为第16行的Functions,而全局变量和静态变量所在符号链的链首为第18行的指针Globals。第21行的Strings和第23行的FloatConstants分别为字符串和浮点数的符号链的链首。而第14行的FunctionTails等指针则始终指向相应符号链的链尾,由此可方便地进行插入操作。


图4.2.26 与符号有关的数据结构

    对于通过typedef关键字建立的类型名,例如如下所示的typedef int  Data,UCC编译器也把Data存入变量名a所对应的符号表中,即由图4.2.26第11行的Identifiers所指向的当前符号表,而struct Data中的结构体名Data则存入由图4.2.26第9行Tags所指向的符号表。且在C语言中,使用结构体名时,我们需要带上struct关键字,因此,在以下代码中,我们可以无歧义地把局部变量a声明为Data类型,即int型,而非struct Data类型。而在C++中,使用结构体或类名时,可以不需要struct或class关键字,反而引起“在Data a = 3中到底用哪个Data”的二义,因此C++编译器会对以下代码报错。

void f(void){

         struct Data{

                   int a;

         };

         typedef int Data;

         Data a = 3;

}

    函数AddFunction()的代码如图4.2.27所示。图4.2.27第4至11行创建了一个类别为SK_Function的符号,UCC编译器会把函数名对应的符号通过第15行的AddSymbol函数加入到全局符号表GlobalIDs中。UCC编译器还用一个单链表来记录所有的函数名对应的符号,其链首为图4.2.26第16行的Functions变量。由于同一个符号对象即可能在单链表中,又可能在哈希表中,所以在与第4行FunctionSymbol对应的结构体struct  functionSymbol对象中,next域用于形成单链表,而link域则用于形成哈希桶中的链表。



图4.2.27AddFunction()

    而在使用图4.2.27第19行的LookupFunctionID()函数来检索当前符号表时,参数placeHolder决定我们是否需要在当前符号表中添加一个占位符。由于占位符的存在,当删去图4.2.24第5行时的注释号//时,UCC编译器就会检测到重定义的错误” error: redefinition of  h”。当前符号表可能是全局符号表,也可能是局部符号表,符号表的嵌套结构请参见第2章的” 图2.5.12  多个作用域的符号表” 。语法上,C语言复合语句的一对大括号就代表了一个新的作用域,对应一张新的符号表。图4.2.27第26至31行完成了往局部符号表中添加一项类型为DefaultFunctionType类型函数的符号。由于我们把函数声明真正的类型信息都存放在全局符号表中,所以我们会在第33和38行来完成对全局符号表GlobalIDs的检索。由LookupFunctionID函数返回的符号不一定函数类型,也可能是普通的变量,LookupFunctionID函数的调用者会根据其调用时的上下文,来决定要根据返回值来做何处理。通过SourceInsight来查看函数LookupFunctionID在何处被使用,结合其上下文就能较好地理解,这里不再重复。想啰嗦的是,阅读代码时,有时需要结合其调用的上下文才能更好地理解其含义。函数LookupFunctionID是作为LookupID函数的补丁,在ucc162.2.tar.gz版本中贴上去的,勉强补漏而已,谈不上美观,与晦涩倒是沾点边。其中的部分原因是,在C源代码中,可能存在多个对彼此相容的函数声明,我们希望只用一个struct symbol对象来记录一个函数名的类型信息。按UCC编译器的现有代码,这个struct symbol对象只存于一个哈希表中,因为struct symbol的结构体中只有一个link域用来构成哈希桶的链表。如果要让一个struct symbol对象可以同时存在于多个哈希表中,我们可以干脆不用struct symbol中定义的link域,而是在把一个符号通过AddSymbol()函数插入哈希桶时,生成一个如下所示的struct BucketLinker对象,由sym域来指向要插入的struct symbol对象,而linker域则用来构成哈希桶中的链表。在后续版本中,或许我们可按这个思路进行改进。

         struct BucketLinker{

                   struct  BucketLinker * linker;

                   struct  symbol * sym;

         };

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值