2.4 C语言的类型系统
这一节,我们准备初步讨论一下C语言的类型系统,相关的代码主要在ucl\type.c和ucl\type.h中。我们知道,一个进程的地址空间可分为代码区和数据区。
对于数据区,C语言提供了char、short、int、long、float和double等基本类型来刻画基本的操作数。char、short、int和long等整型还进一步分有unsigned和signed,对大多数编译器而言,缺省时,整型默认为signed。当然也有C编译器默认char为unsigned char。C标准对基本类型要占多大内存空间,并没有规定得非常死。比如,在一些面向16位单片机的C编译器中,int就只占2个字节;而在32位机器上,int一般占4个字节。这些基本类型,就如最基本的化学元素一样,按照一定的组合规则,就可以构成更复杂的物质,最终构成了纷繁的大千世界。在C语言中,指针、数组和结构体等概念,就相当于是化学元素的组合规则,通过这些概念,C程序员可以描述更加复杂的数据。
C语言通过引入“函数”的概念,刻画了代码区,对C程序员而言,要访问代码区的函数代码时,我们需要知道这段代码的首地址、函数参数和返回值这样的信息,函数名实际上就代表了这段代码的首地址,这些信息可用C语言的函数声明来表达,如下所示。
int f(int,int);
int g(int,int);
如果忽略掉函数名,则函数f和函数g拥用相同的特征,同样的参数类型和同样的返回值类型。我们可以说函数f和函数g的类型是一样的。把“指针”这样的组合规则作用到“函数”上,我们就有了“函数指针 pointer to function”的概念,由此,我们可以把函数的首地址也当作一种数据来处理;而函数的参数和返回值,实际上表达了代码区要如何访问数据区。因为整数和浮点数等基本类型相当于基本的化学元素,而指针、结构体、数组和函数等组合规则是在这些基本类型之上衍生出来的,所以我们称这些类型为衍生类型(derived
type)。如果把基本类型看成是操作数,把这些组合规则看成是类型运算符,则C程序员通过函数或变量的声明,实际上构建了类型表达式,来告诉C编译器我们要如何访问代码区和数据区。
在UCC编译器内部,我们需要建立相应的数据结构来刻画基本类型和衍生类型。UCC编译器是用C语言来实现的,很自然的,我们就会用结构体来描述相关类型信息。图2.4.1中的代码来源于ucl\type.h,为了表述方便,删去了原有的一些注释。
图2.4.1 struct type
我们通过图2.4.1第17行的struct type来描述类型信息,而数组类型需要记录更多的信息,我们就用第22行的struct arrayType。可以看到,这两种结构体的开始部分都是TYPE_COMMON,我们在第1章时介绍过,这相当于struct arrayType继承了struct type。而宏定义TYPE_COMMON如图第10至15行所示,第11行的categ用来记录类别,取值范围由第2至第6行的枚举常量来定义。例如CHAR对应的是char,而UCHAR对应的是unsigned
char。第12行的qual用来记录类型声明时,是否有添加const或volatile等限定符(qualifier),其取值范围由第8行的枚举常量CONST和VOLATILE来指示。第12行的align表示是按多少字节进行对齐的,而第13行的size则记录该类型要占多少个字节。如果是衍生类型,第15行的bty用于指向其基类。
我们举以下几个具体的例子来说明。通过ucl\type.c中的函数ArrayOf()、PointerTo()和Qualify(),我们可以在基本类型int的基础上构造如图2.4.2所示的类型结构。结合图2.4.2,不难理解这几个函数的源代码,这里就不展开讨论。
int a;
const int b;
int c[4];
int * d;
图2.4.2 类型结构
由图2.4.2,我们可以知道数组c的类型由4个int构成,共占16字节,按4字节进行对齐,属于ARRAY类别;而指针d属于POINTER类别,占4字节。接下来我们再来看一下如何描述结构体类别,如图2.4.3所示。
图2.4.3 结构体的类型描述
我们仍然结合一个例子来说明。通过ucl\type.c的StartRecord()、AddField()和EndRecord()等函数,我们会为以下结构体struct Data构造一个形如图2.4.4的类型结构。
struct Data{
int abc:8;
int def:24;
double f;
} dt;
图2.4.4 结构体的类型结构
在图2.4.4中,recordType中记录了结构体struct Data类型的大小为16字节,其类别为STRUCT,按8字节进行对齐,其中的flds指针指向由struct field对象构成的链表。每个struct field对象描述了结构体中的一个数据域成员,tail指针相当于指向链尾的struct field对象。对于位域成员,在struct field的bits记录了其所占用的位数,UCC使用一个int或者unsigned
int来存放位域成员,而pos则记录了位域在一个整数中的起始位置。例如,在结构体struct Data中,成员abc和def都是位域,abc占8个bit,而def占24个bit,它们一共占了32位的空间,即4字节。UCC编译器为struct Data对象dt构造的内存布局如图2.4.5所示。由于在UCC对double类型按8字节进行对齐,所以在偏移offset为4开始的4个字节实际上没有放置任何数据。位域abc和def都处于偏移0字节处,但它们的pos信息是不一样的;而双精度浮点数f位于偏移8字节处,占用了8字节的内存空间。整个dt对象共占16字节。不同C编译器采取的对齐策略是不一样的,所产生的对象内存布局会有所不同。
图2.4.5 struct Data对象dt的内存布局
接下来,我们来看一下UCC的类型系统是如何描述“函数”的,如图2.4.6所示。
图2.4.6 函数的类型描述
图2.4.6的第159行的functionType描述了与函数相关的类型信息,TYPE_COMMON中的bty记录了函数返回值的类型信息,而第162行的sig则记录了参数列表的类型信息。C语言函数分为旧式风格old-style或者新式风格new-style,如图2.4.7所示的f1和f2实际上就是旧式风格;而f3和f4为对应的新式风格函数。由图2.4.7的第17至22行可知,旧式风格的函数甚至连实参的个数都不进行检查;由第23至28行可知,新式风格的函数会对参数进行检查,这也是图2.4.6第154行的hasProto的含义,hasProto是”has
prototype”的意思,换言之,参数列表成为函数接口的一部分。作为C程序员,应尽量不去使用旧式风格的函数定义或声明,毕竟,因为历史上使用旧式风格的函数引起了不少问题,我们才会引入类型检查更严格的新式风格函数。但是作为C编译器,却需要背上这个历史的包袱,新旧风格都需要去支持。而图2.4.6第155行的hasEllipsis则用于判断新式风格的函数中是否存在变参,ellipsis是省略号的意思,在C语言中printf就是一个最典型的变参函数,其函数接口如下所示。
int printf(const char *format, ...);
图2.4.7 旧式风格和新式风格
图2.4.8给出了图2.4.7的代码上机运行后的结果,我们给出了GCC、UCC和Clang三者的对比。Clang编译器给出的提示信息确实是最具可懂性的。在UCC编译器的警告和错误提示上,我们有时有意去模仿Clang或者GCC。
图2.4.8 新旧风格函数运行结果
接下来,我们就以如下所示的函数f5为例。通过ucl\type.c中的FunctionReturn()等函数,我们为之构造一个如图2.4.9所示的类型结构。
int f5(double a,float b);
图2.4.9 函数的类型结构
在图2.4.9中,结构体struct parameter描述了函数的某个参数的相关信息,id为形参的名字,ty为形参的类型,而reg表示形参声明时是否有register这样的说明符,该说明符只是建议C编译器把形参尽量放到寄存器中。而structsignature则描述了参数形表的相关信息,hasProto为1时,表示是新式风格的函数,此时params域指向一个向量,该向量包含多个struct
parameter对象。
通过这一节的图2.4.2、图2.4.4和图2.4.9,我们对UCC编译器是如何刻画C语言的数组、结构体和函数等类型信息会有一个非常直观的感觉。UCC编译器会在语法分析和语义检查时进行这些类型结构的构建,我们会在后续章节再进行讨论。而int和double等基本类型的结构,在main()函数中调用SetupTypeSystem()函数来构建,如图2.4.10所示。第1259至1276行创建了所有的基本类型,指定了这些类型的大小size、对齐align和类别categ信息。第1281至1293创建了一个形如”int
f();”的缺省函数类型DefaultFunctionType。
图2.4.10 SetupTypeSystem()
在C语言中,如果一个函数未经声明就直接使用,则C编译器会把这个函数的类型视为DefaultFunctionType。第1290行告诉我们这个类型的函数是旧式风格的,不对函数参数进行任何的检查。我们举一个例子来说明这会引起多让人莫名其妙的问题。
假设有两个C文件,一个文件名为b.c,其中定义了一个函数fadd,用于对两个float类型的浮点数进行加法运算;而另一个文件名为a.c,其中调用了fadd(3.0f,3.0f)。这个程序非常简单,只要学过几天C语言的人几乎都会预期这个程序的结果是6.0。
//a.c
#include <stdio.h>
int main(){
fadd(3.0f,3.0f);
return 0;
}
//b.c
#include <stdio.h>
void fadd(float a,float b){
float c;
c = a+b;
printf("%f\n",c);
}
但上机运行后的结果竟然是2.125,这一定会让我们大吃一惊。如图2.4.11所示。再一次的,我们看到Clang至少给了我们一点警告提示,让我们知道原来a.c中对函数fadd是没有声明就直接使用。这就导致在编译单元a.c中,函数fadd沦为旧风格的函数。C编译器视fadd的类型为图2.4.10中的DefaultFunctionType。
图2.4.11 调用未声明的函数
这就是噩梦的源头。只要在a.c中加上声明” void fadd(floata,float b);”后,再调用fadd()函数,我们就得到了想要的结果6.0。下面,我们就来分析一下,为什么旧式风格的函数会带到这么怪异的问题。为了说明方便,我们把上述程序稍微修改一下,适当加了一些输出语句,如图2.4.12所示。
图2.4.12 沦为oldstyle
再次上机运行,我们会得到如图2.4.13所示结果。由图2.4.13,我们可知,float类型的浮点数3.0f在内存中对应的十六进制数值为0x40400000,而2.125f则对应0x40080000,这也正好吻合IEEE754的浮点数编码;double类型的3.0在内存中对应8个字节,内容为[ 0x00000000 0x40080000]。同时,我们发现,在b.c中,形参a的值竟然是0x00000000,形参b的值竟然是0x40080000,这就相当于是2.125f和0.0f相加,结果当然是2.125f。
图2.4.13 沦为oldstyle的运行结果
在a.c中把fadd当作旧式风格的函数时,按IT大佬们的约定,C编译器会进行一个被称为实参提升的动作。如ucl\type.c中的Promote()函数所示,凡是低于int型的其他整型,包括char和short都会被提升为int,而单精度float则会被提升为double;其他类型保持不变。
Type Promote(Type ty){
return ty->categ< INT ? T(INT) : (ty->categ == FLOAT ? T(DOUBLE) : ty);
}
C编译器面对“未声明就使用”的函数调用”fadd(3.0f,3.0f);”时,默默地进行了实参提升的操作。真正执行的函数调用是”fadd(3.0,3.0);”,压入栈的是两个double类型的浮点数3.0,共占了16字节。如图2.4.14所示。在小端机器上,浮点数3.0的存放如图所示,在低地址4字节存放了0x00000000,在高地址的4字节中存放了0x40080000。在b.c的函数fadd()中,仍然是把形参a和b当作float来处理,按照C调用约定,参数向右向左入栈,所以形参a对应的是0x00000000,形参b对应的是0x40080000。
图2.4.14 栈示意图
总之,远离旧式风格的C函数,同时记住,函数要先声明再使用,否则我们就不知不觉地在使用旧式风格的函数声明。上述例子阐述了旧式风格的函数所带来的噩梦。