连接
一个C语言程序可能是由多个分别编译的部分组成的,这些不同部分通过一个通常叫做连接器(也叫连接编辑器,或载入器)的程序合并成一个整体。因为编译器一般每次只处理一个文件,所以它不能检测出那些需要一次了解多个源程序文件才能察觉的错误。而且,在许多系统中连接器是独立于C语言实现的,因此如果前述错误的原因是与C语言相关的,连接器对此同样束手无策。
某些C语言实现提供了一个称为lint的程序,可以捕获到大量的此类错误,但遗憾的是并非全部的C语言实现都提供了该程序。如果能够找到诸如lint的程序,就一定要善加利用,这一点无论怎么强调都不为过。
什么是连接器
C语言中的一个重要思想就是分别编译(Separate Complication),即若干个源程序可以在不同的时候单独进行编译,然后再恰当的时候整合到一起。连接器就是起到把若干个C源程序合并成一个整体的作用。连接器不理解C语言,然而它却能理解机器语言和内存布局。那么,编译器把C源程序“翻译”成对连接器有意义的形式,这样连接器就能够“读懂”C源程序。
典型的连接器把由编译器或汇编器生成的若干个目标模块,整合成一个被称为载入模块或可执行文件的实体,该实体能够被操作系统直接执行。其中,某些目标模块是直接作为输入提供给连接器的;而另外一些目标模块则是根据链接过程的需要,从包含有类似printf函数的库文件中取得的。
连接器通常把目标模块看成是一组外部对象(external object)组成的。每个外部对象代表着机器内存中的某个部分,并通过一个外部名称来识别。因此,程序中的每个函数和每个外部变量,如果没有被声明为static,就都是一个外部对象。某些C编译器会对静态函数和静态变量的名称做一定改变,将它们也作为外部对象。由于经过了“名称修饰”,所以它们不会与其他源程序文件中的同名函数或同名变量发生命名冲突。
大多数连接器都禁止同一个载入模块中的两个不同外部对象拥有相同的名称。然而,在多个目标模块整合成一个载入模块时,这些目标模块可能就包含了同名的外部对象。连接器的一个重要工作就是处理这类命名冲突。
处理命名冲突的最简单方法就是干脆完全禁止。对于外部对象是函数的情形,这种做法当然正确,一个程序如果包含两个同名的不同函数,编译器根本就不应该接受。而对于外部对象是变量的情形,问题就变得有些困难了。不同的连接器对这种情况有着不同的处理方式。
那么以上信息大概能让我们了解连接器是如何工作了:
- 连接器输入是一组目标模块和库文件。
- 连接器输出是一个载入模块。
- 连接器读入目标模块和库文件,同时生成载入模块。
- 对每个目标模块中的每个外部对象,连接器都要检查载入模块,看看是否已有同名的外部对象。
- 如果没有同名的外部对象,连接器将该外部对象添加到载入模块中。
- 如果有同名的外部对象,连接器要开始处理命名冲突。
除了外部对象之外,目标模块中还可能包括了对其他模块中的外部对象的引用。例如,一个调用了函数printf的C程序所生成的目标模块,就包括了一个对函数printf的引用。可以推测得出,该引用指向的是一个位于某个库文件中的外部对象。在连接器生成载入模块的过程中,它必须同时记录这些外部对象的引用。当连接器读入一个目标模块时,它必须解析出这个目标模块中定义的所有外部对象的引用,并作出标记说明这些外部对象不再是未定义的。
声明与定义
1 | int a; |
如果这条声明语句出现在所有的函数体之外,那么它就被称为外部对象a的定义。这个语句说明了a是一个外部整型变量,同时为a分配了存储空间。因为外部对象a并没有被明确指定任何初始值,所以它的初始值默认为0。C编译器有责任以适当方式通知连接器,确保未指定初始值的外部变量被初始化为0。
1 | extern int a; |
这条语句不是对a的定义。这个语句仍然说明a是一个外部整型变量,但是因为它包括了extern关键字,这就显式地说明了a的存储空间是在程序的其他地方分配的。从连接器的角度来看,上述声明是一个对外部变量a的引用,而不是对a的定义。因为这种形式的声明是对一个外部对象的显式引用,即使它出现在一个函数的内部,也仍然具有同样的含义。
每个外部对象都必须在程序某个地方进行定义。因此,extern的指定外部对象必然要有对应的定义。但是如果有多个同一个外部对象名称的定义,将会出现什么样的情形呢?
例如在一个源文件出现:
1 | int a = 7; |
另一个源文件中出现:
1 | int a = 9; |
这种情况下,实际结果与系统有关。不同系统可能有不同的处理方法。严格的规则是:每个外部变量只能够定义一次。如果外部的变量的多个定义各指定一个初始值,大多数系统都会拒绝接受该程序。
形参、实参与返回值
如果一个函数在被定义或声明前被调用,那么它的返回类型就默认为整型。例如下面整个程序可以正确的被连接:
1 | int main() |
但是如果func函数的返回值变成了非int型,而是char之类,那么当连接器将调用命令和func函数连接时就会得出错误的结果。
然后我们再来看形参与实参。C语言中形参与实参匹配的规则稍微有一点复杂。ANSI C允许程序员在声明时指定函数的参数类型:
1 | double square(double); |
根据这个声明,square(2)是合法的:整数2将会被自动转换成双精度类型,跟double square((double)2)或double(2.0)一样。
如果一个函数没有float、short或char类型的参数,在函数声明中完全可以省略参数类型的说明。即在ANSI C下,像这样声明square也是可以的:
1 | double square(); |
这样做依赖于调用者能够提供数目正确且类型恰当的实参。这里的“恰当”并不意味着“等同”:float类型的参数会自动转换为double类型,short或char类型的参数会自动转换为int类型。但是反过来,省略float等类型的参数不能够正确通过。
检查外部类型
假如我们有一个C程序,它由两个源文件。一个文件中包含外部变量n的声明:
1 | extern int n; |
另一个文件中包含外部变量n的定义:
1 | long n; |
这里假定两个语句都不在任何一个函数体内,因此n是外部变量。
这是一个无效的C程序,因为同一个外部变量名在两个不同的文件中被声明为不同类型。然而,大多数C语言实现却不能检测出这种错误。编译器对这两个不同的文件分别进行处理,这两个文件的编译时间可以相差好几个月。因此,编译器在编译一个文件时,并不知道另一个文件的内容。连接器可能对C语言一无所知,因此它也不知道如何比较两个n的定义中的类型。当这个程序编译或运行时,可能会发生的情况:
- C语言编译器足够“聪明”,能够检测到这一类型冲突。编译者将会得到一条诊断消息,报告变量n在两个不同的文件中被给定了不同的类型。
- C语言实现对
int类型的数数值与long类型的数值在内部表示上是一样的。在这种情况下,程序很可能正常工作。本来错误的程序因为某种巧合却能够工作。 - 变量
n的两个实例虽然要求的存储空间的大小不同,但是它们共享存储空间的方式却恰好能够满足这样的条件:赋给其中一个的值,对另一个也是有效的。例如char与int。 - 变量
n的两个实例共享存储空间的方式,使得对其中一个赋值时,其效果相当于同时给另一个赋了完全不同的值。在这种情况下,程序将不能正常的工作。
因此,保证一个特定名称的所有外部定义在每个目标模块中都有相同的类型,一般来说是程序员的责任。而且,“相同的类型”应该是严格意义上的相同。
下面的例子将重现这一个陷阱的发生:
1 | // a.h |
然后按下面的顺序生成例子:
1 | gcc -c a.c |