C编译器剖析_2.4 C语言的类型系统

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函数,同时记住,函数要先声明再使用,否则我们就不知不觉地在使用旧式风格的函数声明。上述例子阐述了旧式风格的函数所带来的噩梦。

时间: 2024-10-31 21:55:07

C编译器剖析_2.4 C语言的类型系统的相关文章

C编译器剖析_1.5 结合C语言来学汇编_指针、数组和结构体

让我们再来看一份C代码,及其经UCC编译器编译后产生的主要汇编代码,如图1.33所示,其中包含了数组.指针和结构体. 图1.33 数组.指针和结构体 按照C的语义,图1.33第9行的C代码是对局部数组number的初始化,需要把number[0]初始化为2015,而数组中的其他元素皆被初始化为0.UCC编译器采取的翻译方法是:先调用memset函数来把数组number所占的内存空间清0,然后再把number[0]设为2015,如图1.33的第17至24行所示.C库函数memset的API如下所示

C编译器剖析_C语言的变参函数

C语言的变参函数 UCC编译器中有不少地方使用了C语言的变参函数,这里我们专门用一小节来对C语言变参函数的实现原理进行分析.C标准库中的printf函数就是一个典型的变参函数,其接口如下所示,函数声明中的省略号-表明这是一个变参函数. int printf(const char *format, ...); 下面我们举一个简单的例子来说明printf函数的调用过程,如图4.2.12所示.图中第1至11行对应是hello.c,而第12至25行是由UCC编译器生成的抽象语法树hello.ast,第2

Python源码剖析笔记0 ——C语言基础

python源码剖析笔记0--C语言基础回顾 要分析python源码,C语言的基础不能少,特别是指针和结构体等知识.这篇文章先回顾C语言基础,方便后续代码的阅读. 1 关于ELF文件 linux中的C编译得到的目标文件和可执行文件都是ELF格式的,可执行文件中以segment来划分,目标文件中,我们是以section划分.一个segment包含一个或多个section,通过readelf命令可以看到完整的section和segment信息.看一个栗子: char pear[40]; static

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() 对形

C语言编译器为什么能够用C语言编写?

不知道大家有没有想过一个问题:C语言编译器为什么能够用C语言编写? 所谓C语言编译器,就是把编程得到的文件,比如.c,.h的文件,进行读取,并对内容进行分析,按照C语言的规则,将其转换成cpu可以执行的二进制文件. 在学习C/C++或者想要学习C/C++可以加入我们的学习交流QQ群: 954607083 ,领取学习资料 其本质在于对文件的读入,分析,及处理.这些操作,C语言都是可以实现的. 所以用C语言来做C语言的编译器是完全可行的. 但是,历史上的第一个C语言编译器,肯定不是C语言写的,因为在

C编译器剖析_尾声

尾声 总有曲终人散时,不知不觉我们已经完成了对UCC编译器的剖析,一路走来,最深的体会仍然是"纸上得来终觉浅,绝知此事要躬行".按这个道理,理解UCC编译器的最好办法应是"直接阅读其源代码,思考UCC编译器在不同的执行点应处于怎样的状态,加入一些打印语句,输出相应的调试信息来验证自己的判断是否正确,如果发现Bug,就写一些测试程序来触发Bug,然后修改UCC编译器的源代码".在遇到困惑时,或许拙作能带来一点点的帮助和提示,但拙作不能代替,也不应取代对UCC源代码的主

C编译器剖析_6.3.5 汇编代码生成_为类型转换产生汇编代码

6.3.5  为类型转换产生汇编代码 在这一小节中,我们来讨论一下整型和浮点型之间的类型转换.有些类型转换并不需要在汇编层次进行数据转换,例如int和unsigned  int之间的转换只是改变了表达式的类型,对数据本身并无影响,以下表达式"(unsigned int) a"对应的二进制数据为0xFFFFFFFF,而表达式"a"对应的二进制数据也为0xFFFFFFFF.但对相同内容的二进制数据来说,进行"有符号整数的右移"和"无符号整数

C编译器剖析_6.1 汇编代码生成_简介

6.1 汇编代码生成简介 历经词法分析.语法分析.语义检查和中间代码生成阶段,我们终于来到了"目标代码生成阶段",由于UCC编译器的目标代码即为32位x86汇编代码,因此我们就把本章称为"汇编代码生成".UCC编译器中的大部分源代码都适用于Windows和Linux平台,但Windows平台上缺省的汇编器支持Intel风格的x86汇编代码,而Linux平台默认的汇编器则采用AT&T风格的x86汇编代码,两者在汇编语法上有一些差别,为节省篇幅,我们主要针对Li

C编译器剖析_5.2.2 中间代码生成及优化_再论符号symbol与公共子表达式

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.由于我