一个C程序可能是由多个分别编译的部分组成,这些不同部分通过连接器合并成一个整体。在本章中,我们将考查一个典型的连接器,注意它是如何对C程序进行处理的,从而归纳出一些由于连接器的特点而可能导致的错误。
4.1 什么是连接器
在C语言中,一个重要的思想就是分别编译,即若干个源程序可以在不同的时候单独进行编译,然后通过连接器整合到一起。但是连接器一般是与C编译器分离的,连接器如何做到把若干个C源程序合并成一个整体呢?
尽管连接器并不理解C语言,但它理解机器语言和内存布局。只要编译器将C源程序“翻译”成对连接器有意义的形式,这样连接器就能够“读懂”C源程序了。
典型的连接器把由编译器或汇编器生成的若干个目标模块,整合成一个被称为载入模块或可执行文件的实体,该实体能够被操作系统直接执行。其中,某些目标模块是直接作为输入提供给连接器的;而另外一些目标模块
则是根据连接过程的需要,从包括有类似printf函数的库文件中取得的。
连接器通常把目标模块看成是由一组外部对象组成的。每个外部对象代表着机器内存中的某个部分,并通过一个外部名称来识别。因此,程序中的每个函数和每个外部变量,如果没有被声明为static,就都是一个外部对象。某些C编译器会对静态函数和静态变量的名称做一定改变,将它们也作为外部对象。由于经过了“名称修饰”,所以它们不会与其他源程序文件中的同名函数或同名变量发生命名冲突。
大多数连接器都禁止同一个载入模块中的两个不同外部对象拥有相同的名称。然而,在多个目标模块整合成一个载入模块时,这些目标模块可能就包含了同名的外部对象。连接器的一个重要工作就是处理这类命名冲突。
处理命名冲突的最简单办法就是干脆完全禁止。对于外部对象是函数的情形,这种做法是正确的。一个程序如果包括两个同名的不同函数,编译器根本就不应该接受。而对于外部对象是变量的情形,问题就变得困难了。
不同的连接器对这种情形有着不同的处理方式。
现在讲讲连接器是如何工作的?
连接器的输入是一组目标模块和库文件。连接器的输出是一个载入模块。连接器读入目标模块和库文件,同时生成载入模块。对每个目标模块中的每个外部对象,连接器都要检查载入模块,看是否已有同名的外部对象。
如果没有,连接器就将该外部对象添加到载入模块中;如果有,连接器就要开始处理命名冲突。
除了外部对象之外,目标模块还可能包括了对其他模块中的外部对象的引用。例如:一个调用了函数printf的C程序所生成的目标模块,就包括了一个对函数printf的引用。可以推测得出,该引用指向的是一个位于某个库文件中的外部对象。
在连接器生成载入模块的过程中,它必须同时记录这些外部对象的引用。当连接器读入一个目标模块时,它必须解析出这个目标模块中定义的所有外部对象的引用,并作出标记说明这些外部对象不再是未定义的。
4.2 声明与定义
下面的声明语句:
int a;
如果其位置出现在所有的函数体之外,那么它就将被称为外部对象a的定义。
下面的声明语句:
extern int a;
并不是对 a 的定义。这个语句仍然说明了 a 是一个外部整型变量,但是因为它包括了extern关键字,这就显式地说明了a的存储空间实在程序的其它地方分配的。从连接器的角度来看,上述声明是对一个外部对象 a 的引用,而不是对 a 的定义。
4.3 命名冲突与static修饰符
两个具有相同名称的外部对象实际上代表的是同一个对象,因此如果在两个不同的源文件中都定义
int a;
那么将会造成命名冲突,程序报错。(多数情况下)
static修饰符是一个能够减少此类命名冲突的有用工具。例如,以下声明语句:
static int a;
a 的作用域被限制在一个源文件内,对于其他源文件, a 是不可见的。static起到了“屏蔽”变量的作用。
static不仅适用于变量,也适用于函数。 如果函数f需要调用另一个函数 g,而且只有函数 f 需要调用函数 g,我们可以把函数 f 与函数 g 都放到同一个源文件中,并且声明函数 g 为static。
static int g(x)
{
/* 函数体 */
}
void f()
{
b = g(a);
}
我们可以在多个源文件中定义同名的函数 g ,只要所有的函数 g 都被定义为static,或者仅仅只有一个函数不是static。因此,为了避免可能出现的命名冲突,如果一个函数仅仅被同一个源文件中的其它函数调用,我们就应该声明该函数为static。
4.4 形参、实参与返回值
任何C函数都有一个形参列表,列表中的每个参数都是一个变量,该变量在函数调用过程中被初始化。下面这个函数有一个整型形参:
int abs(int n)
{
return n<0 ? -n: n;
}
函数调用时,调用方将实参列表传递给被调函数。在下面的例子中, a - b 是传递给函数abs的实参:
if( abs(a - b) > n )
printf("difference is out of range\n");
任何一个函数都有返回类型,要么是void,要么是函数生成结果的类型。
因为函数printf与函数sanf在不同情形下可以接受不同类型的参数,所以它们特别容易出错。这里有一个值得注意的例子:
#include <stdio.h>
int i;
char c;
for(i=0; i<5; i++)
{
scanf("%d",&c);
printf("%d",i);
}
printf("\n");
表面上,这个程序从标准输入设备读入5个数,在标准输出设备上写5个数:
0 1 2 3 4
实际上,这个程序不一定得到上面的结果。例如,在某个编译器上,它的输出是:
0 0 0 0 0 1 2 3 4
为什么呢?问题的关键在于,这里c被声明为char类型,而不是Int类型。当程序要求scanf读入一个整数,应该传递给它一个指向整数的指针。而程序中scanf函数得到的却是一个指向字符的指针,scanf函数并不能分辨这种情况,它只是将这个指向字符的指针作为指向整数的指针而接受,并且在指针指向的位置存储一个整数。因为整数所占的存储空间要大雨字符所占的存储空间,所以字符c附近的内存将被覆盖。所以才会出现这种情况。
字符c附近的内存中存储的内容是由编译器决定的,本例中它存放的是整数i的低端部分。因此,每次读入一个数值到c时,都会将i的低端部分覆盖为0,而i的高端部分本来就是0,相当于i每次被重新设置为0,循环将一直进行。当到达文件的结束位置后,scanf函数不再试图读入新的数值到c。这时,i才可以正常地递增,最后终止循环。
4.5 检查外部类型
假定我们有一个C程序,它由两个源文件组成。一个文件中包含外部变量n的声明:
extern int n;
另一个文件中包含外部变量n的定义:
long n;
这里假定两个语句都不再任何一个函数体内,因此n是外部变量。
这是一个无效的C程序,因为同一个外部变量名在两个不同的文件中被声明为不同的类型。(在很大且开发中期很长的C项目工程中有可能出现这种情况)
当这个程序运行时,可能发生以下情况:
- 一、C语言编译器能检测到冲突。
- 二、两者数值在内部表示上一样,例如都是32位,程序很可能正常工作。
- 三、共享存储空间,long的低端部分赋给了int类型的n,能正常工作。
- 四、共享存储空间,但是对其中一个赋值掩盖了另一个值,将不能正常工作。
因此,保证一个特定名称的所有外部定义在每个目标模块中都有相同的类型,一般来说是程序员的责任。而且,“相同的类型”应该是严格意义上的相同。例如,考虑下面的程序,在一个文件中包含定义:
char filename[] = "/etc/passwd";
而在另一个文件中包含声明:
extern char *filename;
尽管在某些上下文环境中,数组与指针非常类似,但它们毕竟不同,所以上面的外部声明是非法的。
有关外部类型类型(extern),另一种容易带来麻烦的方式是忽略了声明函数的返回类型,或者声明了错误的返回类型。例如下面的程序:
main()
{
double s;
s = sqrt(2);
printf("%g\n", s);
}
这个源文件没有包括对函数sqrt的声明,因此函数sqrt的返回类型只能从上下文进行推断。C语言中的规则是,如果一个未声明的标识符后跟一个开括号(),那么它将被视为一个返回整型的函数。因此,这个程序完全等同于下面的程序:
extern int sqrt();
main()
{
double s;
s = sqrt(2);
printf("%g\n", s);
}
当然这种写法是错误的。函数sqrt应该返回双精度类型,而不是整型。因此这个程序的结果也是不可预测的。所以要注意外部类型的声明。
4.6 头文件
有一个好办法可以避免大部分此类问题:每个外部对象只在一个头文件中进行外部声明,需要用到该外部对象的所有模块都应该包括这个头文件。另外,定义该外部对象的模块也应该包括这个头文件。
在模块源文件中定义一个外部对象:
char fileName[];
在该模块头文件外部声明该对象:
extern fileName[];