C陷阱与缺陷-疑难问题理解08

本文详细介绍了C程序的连接过程,包括连接器的工作原理、命名冲突的处理以及静态修饰符的作用。此外,还探讨了声明与定义的区别、形参与实参的匹配规则,以及外部类型一致性的重要性,强调了头文件在确保类型正确性中的作用。
摘要由CSDN通过智能技术生成

第4章 连接

​ 一个C程序可能是由多个分别编译的部分组成,这些不同部分通过一个通常叫做连接器(也叫连接编辑器,或载入器)的程序合并成一个整体。因为编译器一般每次只处理一个文件,所以它不能检测出那些需要一次了解多个源程序文件才能察觉的错误。而且,在许多系统中连接器是独立于C语言实现的,因此如果前述错误的原因是与C语言相关的,连接器对此同样束手无策。

​ 某些C语言实现提供了一个称为lint的程序,可以捕获到大量的此类错误, 但遗憾的是并非全部的C语言实现都提供了该程序。如果能够找到诸如lint的程 序,就一定要善加利用,这一点无论怎么强调都不为过。

4.1 什么是连接器

C语言中的一"重要思想就是分别编译(Separate Compilation),即若干个源 程序可以在不同的时候单独进行编译,然后在恰当的时候整合到一起。但是,连 接器一般是与C编译器分离的,它不可能了解C语言的诸多细节。那么,连接器 是如何做到把若干个C源程序合并成为一个整体的呢?尽管连接器并不理解C语言,然而它却能够理解机器语言和内存布局。编译器的责任是把C源程序“翻译”成对连接器有意义的形式,这样连接器就能够“读懂” C源程序了。

​ 典型的连接器把由编译器或汇编器生成的若干个目标模块,整合成一个被称 为载入模块或可执行文件的实体,该实体能够被操作系统直接执行。其中,某些目标模块是直接作为输入提供给连接器的;而另外一些目标模块则是根据连接过程的需要,从包括有类似printf函数的库文件中取得的。

连接器通常把目标模块看成是由一组外部对象(external object)组成的。每个外部对象代表着机器内存中的某个部分,并通过一个外部名称来识别。因此, 程序中的每个函数和每个外部变量,如果没有被声明为static,就都是一个外部对象。某些C编译器会对静态函数和静态变量的名称做一定改变,将它们也作为外部对象。由于经过了“名称修饰”,所以它们不会与其他源程序文件中的同名函数或同名变量发生命名冲突。

​ 大多数连接器都禁止同一个载入模块中的两个不同外部对象拥有相同的名称。然而,在多个目标模块整合成一个载入模块时,这些目标模块可能就包含了 同名的外部对象。连接器的一个重要工作就是处理这类命名冲突。

​ 处理命名冲突的最简单办法就是干脆完全禁止。对于外部对象是函数的情形, 这种做法当然正确,一个程序如果包括两个同名的不同函数,编译器根本就不应该接受。而对于外部对象是变量的情形,问题就变得有些困难了。不同的连接器对这种情形有着不同的处理方式,我们将在后面看到这一点的重要性。

​ 有了这些信息,我们现在可以大致想像出连接器是如何工作的情形了。连接器的输入是一组目标模块和库文件。连接器的输出是一个载入模块。连接器读入目标模块和库文件,同时生成载入模块。对每个目标模块中的每个外部对象,连接器都要检査载入模块,看是否已有同名的外部对象。如果没有,连接器就将该外部对象添加到载入模块中;如果有,连接器就要开始处理命名冲突。

​ 除了外部对象之外,目标模块中还可能包括了对其他模块中的外部对象的引 用。例如,一个调用了函数printf的C程序所生成的目标模块,就包括了一个对函数printf的引用。可以推测得出,该引用指向的是一个位于某个库文件中的外部对象。在连接器生成载入模块的过程中,它必须同时记录这些外部对象的引用。 当连接器读入一个目标模块时,它必须解析出这个目标模块中定义的所有外部对

4.2 声明与定义

下面的声明语句:

int a;

如果其位置出现在所有的函数体之外,那么它就被称为外部对象a的定义。 这个语句说明了a是一个外部整型变量,同时为a分配了存储空间。因为外部对 象a并没有被明确指定任何初始值,所以它的初始值默认为0。(某些系统中的连 接器对以其他语言编写的程序并不保证这一点,c编译器有责任以适当方式通知 连接器,确保未指定初始值的外部变量被初始化为0)。

下面的声明语句

int a = 7

在定义a的同时也为a明确指定了初始值。这个语句不仅为a分配内存,而且也 说明了在该内存中应该存储的值。

下面的声明语句

extern int a;

并不是对a的定义。这个语句仍然说明了 a是一个外部整型变量,但是因为它包 括了 extern关键字,这就显式地说明了a的存储空间是在程序的其他地方分配的。 从连接器的角度来看,上述声明是一个对外部变量a的引用,而不是对a的定义。 因为这种形式的声明是对一个外部对象的显式引用,即使它出现在一个函数的内部,也仍然具有同样的含义。下面的函数srand在外部变量random_seed中保存了 其整型参数n的一份拷贝:

void srand(int n)
{ 
		extern int random_seed;
		random_seed = n;
}

每个外部对象都必须在程序某个地方进行定义。因此,如果一个程序中包括 了语句

extern int a;

那么,这个程序就必须在别的某个地方包括语句

int a;

这两个语句既可以是在同一个源文件中,也可以位于程序的不同源文件之中。

如果一个程序对同一个外部变量的定义不止一次,又将如何处理呢?也就是说,假定下面的语句

int a;

出现在两个或者更多的不同源文件中,情况会是怎样呢?或者说,如果语句

int a = 7

出现在一个源文件中,而语句

int a = 9;

出现在另一个源文件中,将出现什么样的情形呢?这个问题的答案与系统有关, 不同的系统可能有不同的处理方式。严格的规则是,每个外部变量只能够定义一 次。如果外部变量的多个定义各指定一个初始值,例如:

int a = 7

出现在一个源文件中,而

int a = 9

出现在另一个源文件中,大多数系统都会拒绝接受该程序。但是,如果一个外部变量在多个源文件中定义却并没有指定初始值,那么某些系统会接受这个程序, 而另外一些系统则不会接受。要想在所有的C语言实现中避免这个问题,惟一的解决办法就是每个外部变量只定义一次。

4.3 命名冲突与static修饰符

​ 两个具有相同名称的外部对象实际上代表的是同一个对象,即使编程者的本 意并非如此,但系统却会如此处理。因此,如果在两个不同的源文件中都包括了 定义

int a;

那么,它或者表示程序错误(如果连接器禁止外部变量重复定义的话),或者在两个源文件中共享a的同一个实例(无论两个源文件中的外部变量a是否应该共享)。

​ 即使其中a的一个定义是岀现在系统提供的库文件中,也仍然进行同样的处理。当然,一个设计良好的函数库不至于定义a作外部名称。但是,要了解函数库中定义的所有外部对象名称却也并非易事。类似于read和write这样的名称不难猜到,但其他的名称就没有这么容易了。

​ ANSI C定义了 C标准函数库,列出了经常用到因而可能会引发命名冲突的所有函数。这样,我们就容易避免与库文件中的外部对象名称发生冲突。如果一 个库函数需要调用另一个未在ANSI C标准中列出的库函数,那么它应该以“隐藏名称”来调用后者。这就使得程序员可以定义一个函数,比如函数名为read, 而不用担心库函数getc本应调用库文件中的read函数,却调用了这个用户定义的 read函数。但大多数C语言实现并不是这样做,因此这类命名冲突仍然是一个问 题。

static修饰符是一个能够减少此类命名冲突的有用工具。例如,以下声明语句

static int a;

其含义与下面的语句相同

int a;

只不过,a的作用域限制在一个源文件内,对于其他源文件,a是不可见的。因此, 如果若干个函数需要共享一组外部对象,可以将这些函数放到一个源文件中,把它们需要用到的对象也都在同一个源文件中以static修饰符声明。

static修饰符不仅适用于变量,也适用于函数。如果函数f需要调用另一个函数g,而且只有函数f需要调用函数g,我们可以把函数f与函数g都放到同一个 源文件中,并且声明函数g为static;

static int
g(int x)
{
		/* g函数体*/
}
void f() 
{
		/*其他内容*/
		b = g(a)}

我们可以在多个源文件中定义同名的函数g,只要所有的函数g都被定义为 static,或者仅仅只有其中一个函数g不是static。因此,为了避免可能出现的命名冲突,如果一个函数仅仅被同一个源文件中的其他函数调用,我们就应该声明该函数为static。

4.4 形参、实参与返回值

任何c函数都有一个形参列表,列表中的每个参数都是一个变量,该变量在 函数调用过程中被初始化。下面这个函数有一个整型形参:

int abs(int n)
{
		return n<0? -n:n;
}

而对某些函数来说,形参列表为空。例如,

void eatline()
{
		int c;
		do c = getchar();
		while (c != EOF && c != '\n');
}

函数调用时,调用方将实参列表传递给被调函数。在下面的例子中,a-b是 传递给函数abs的实参:

if (abs{a - b) > n)
		printf("difference is out of range\n");

一个函数如果形参列表为空,在被调用时实参列表也为空。例如,

eatline():

​ 任何一个C函数都有返回类型,要么是void,要么是函数生成结果的类型。 函数的返回类型理解起来要比参数类型相对容易一些,因此我们将首先讨论它。

​ 如果任何一个函数在调用它的每个文件中,都在第一次被调用之前进行了声 明或定义,那么就不会有任何与返回类型相关的麻烦。例如,考虑下面的例子, 函数square计算它的双精度类型参数的平方值:

double square(double x)
{ 
		return x*x;
}

以及,一个调用square函数的程序:

main()
{
		printf("%g\n", square(0.3))}

要使这个程序能够运行,函数square必须要么在main之前进行定义:

double
square(double x)
{
		return x*x;
}

main()
{
		printf("%g\n",square(0.3));
}

要么在main之前进行声明:

double square(double);
main()
{
		printf("%g\n",square(0.3));
}

double square(double x)
{
		return x*x;
}

如果一个函数在被定义或声明之前被调用,那么它的返回类型就默认为整型。 上面的例子中,如果将main函数单独抽取岀来作为一个源文件:

main()
{
		printf("%g\n",square(0.3));
}

因为函数main假定函数square返回类型为整型,而函数square返回类型实际上是双精度类型,当它与square函数连接时就会得出错误的结果。

如果我们需要在两个不同的文件中分别定义函数main与函数square,那么应 该如何处理呢?函数square只能有一个定义。如果square的调用与定义分别位于不同的文件中,那么我们必须在调用它的文件中声明square函数:

double square(double)main()
{
		printf("%g\n", square(0.3));
}

C语言中形参与实参匹配的规则稍微有一点复杂。ANSI C允许程序员在声明 时指定函数的参数类型:

double square(double);

上面的语句说明函数square接受一个双精度类型的参数,返回一个双精度类型的 结果。根据这个声明,square(2)是合法的;整数2将会被自动转换为双精度类型, 就好像程序员写成square((double)2)或者square(2.0)一样。

如果一个函数没有float, short或者char类型的参数,在函数声明中完全可以省略参数类型的说明(注意,函数定义中不能省略参数类型的说明)。因此,即使是在ANSIC中,像下面这样声明square函数也是可以的:

double square();

这样做依赖于调用者能够提供数目正确且类型恰当的实参。这里,“恰当”并 不就意味着“等同":float类型的参数会自动转换为double类型,short或char类 型的参数会自动转换为int类型。例如,对于下面的函数:

int isvowel(char c)
{
		return c == 'a' || c == 'e' || c == 'i' ||
				c == 'o' || c == 'u';
}

因为其形参为char类型,所以在调用该函数的其他文件中必须声明:

int isvowel(char);

否则,调用者将把传递给isvowel函数的实参自动转换为int类型,这样就与 形参类型不一致了。如果函数isvowel是这样定义的:

int isvowel(int c) 
{
		return c == 'a' || c == 'e' || c == 'i' ||
				c == 'o' || c == 'u';
}

那么调用者就无需进行声明,即使调用者在调用时传递给isvowel函数一个char 类型的参数也是如此。

ANSI C标准发布之前出现的C编译器,并不都支持这种风格的声明。当我 们使用这类编译器时,有必要如下声明isvowel函数:

int isvowel()

以及这样定义它:

int isvowel(c)
		char c;
{
		return c == 'a' || c == 'e' || c == 'i' ||
				c == 'o' || c == 'u';
}

​ 为了与早期的用法兼容,ANSIC也支持这种较“老”形式的声明和定义。这 就带来一个问题:如果一个文件中调用了 isvowel函数,却又不能声明它的参数 类型(为了能够在较"老"的编译器上工作),那么编译器如何知道函数形参是 char类型而不是int类型的呢?答案在于,新旧两种不同的函数定义形式,代表 不同的含义。上面isvowel函数的最后一个定义,实际上相当于:

int isvowel(int i)
{
		char c = i;
		return c == 'a' || c == 'e' || c == 'i' ||
				c == 'o' || c == 'u';
}

现在我们已经了解了函数定义与声明的有关细节,再来看看这方面容易出错的一些方式。下面这个程序虽然简单,却不能运行:

main()
{
    double s;
    s = sqrt(2);
    printf("%g\n", s);
}

原因有两个:第一个原因是,sqrt函数本应接受一个双精度值为实参,而实际上却被传递了一个整型参数;第二个原因是,sqrt函数的返回类型是双精度类型,但却并没有这样声明。

一种更正方式是:

double sqrt(double);
main()
{
    double s;
    s = sqrt(2);
    printf("%g\n", s);
}

若用另一种方式,则更正后的程序可以在ANSI C标准发布之前就存在的C 编译器上工作,即:

double sqrt();
main()
{
		double s;
		s = sqrt(2.0}; 
		printf("%g\n", s);
}

当然,最好的更正方式是这样:

#include <math.h>
main()
{
		double s;
		s = sqrt(2.0}; 
		printf("%g\n", s);
}

​ 这个程序看上去并没有显式地说明sqrt函数的参数类型与返回类型,但实际 上它从系统头文件math.h中获得了这些信息。尽管本例中为了与早期C编译器兼容,已经把实参写成了双精度类型的2.0而不是整型的2,然而即使仍然写作整型的2,在符合ANSIC标准的编译器上,这个程序也能确保实参会被转换为恰当的类型。

​ 因为函数printf与函数scanf在不同情形下可以接受不同类型的参数,所以它们特别容易出错。这里有一个值得注意的例子:

#include <stdio.h>
main()
{
    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语

言一无所知,因此它也不知道如何比较两个n的定义中的类型。

当这个程序运行时,究竟会发生什么情况呢?存在很多的可能情况:

  1. C语言编译器足够“聪明”,能够检测到这一类型冲突。编程者将会得到一 条诊断消息,报告变量n在两个不同的文件中被给定了不同的类型。

  2. 读者使用的C语言实现对int类型的数值与long类型的数值在内部表示上 是一样的。尤其是在32位计算机上,一般都是如此处理。在这种情况下,程序很 可能正常工作,就好像n在两个文件中都被声明为long (或int)类型一样。本来 错误的程序因为某种巧合却能够工作,这是一个很好的例子。

  3. 变量n的两个实例虽然要求的存储空间的大小不同,但是它们共享存储空 间的方式却恰好能够满足这样的条件:赋给其中一个的值,对另一个也是有效的。 这是有可能发生的。举例来说,如果连接器安排int类型的n与long类型的n的 低端部分共享存储空间,这样给每个long类型的n赋值,恰好相当于把其低端部 分赋给了 int类型的n。本来错误的程序因为某种巧合却能够工作,这是一个比第 2神情况更能说明问题的例子。

  4. 变量n的两个实例共享存储空间的方式,使得对其中一个赋值时,其效果 相当于同时给另一个赋了完全不同的值。在这种情况下,程序将不能正常工作。

    ​ 因此,保证一个特定名称的所有外部定义在每个目标模块中都有相同的类型, 一般来说是程序员的责任。而且,“相同的类型”应该是严格意义上的相同。例如, 考虑下面的程序,在一个文件中包含定义:

char filename [] =  "/etc/passwd";

而在另一个文件中包含声明:

extern char* filename;

尽管在某些上下文环境中,数组与指针非常类似,但它们毕竟不同。在第一 个声明中,filename是一个字符数组的名称。尽管在一个语句中引用句ename的 值将得到指向该数组起始元素的指针,但是filename的类型是“字符数组。而不 是"字符指针”。在第二个声明中,filename被确定为一个指针。这两个对filename 的声明使用存储空间的方式是不同的;它们无法以一种合乎情理的方式共存。第 一个例子中字符数组filename的内存布局大致如图41所示。

filename 

第二个例子中字符指针filename的内存布局大致如图4.2所示。

filename

要更正本例,应该改变filename的声明或定义中的一个,使其与另一个类型匹配。因此,既可以是如下改法:

char filename [] = "/etc/passwd" ; 	/* 文件 1 */
extern char filename [ ] ;  				/* 文件 2 * /

也可以是这种改法:

char* filename = "/etc/passwd"/* 文件 1 */
extern char* filename; 						/* 文件 2 */

有关外部类型方面,另一种容易带来麻烦的方式是忽略了声明函数的返回类 型,或者声明了错误的返回类型。例如,回顾一下我们在4.4节中讨论的程序:

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返回双精度类型,而不是整型。因此, 这个程序的结果是不可预测的。事实上,该程序似乎能够在某些机器上工作。举例来说,假定有这样一种机器,无论函数的返回值是整型值还是浮点值,它都使 用同样的寄存器。这样的计算机,将直接把函数sqrt的返回结果按其二进制表示 的各个位传递给函数printf,而并不去检査类型是否一致。函数printf得到了正确 的二进制表示,当然能够打印出正确的结果。某些机器在不同的寄存器中存储整 数与指针。在这样的机器上,即使不牵涉到浮点运算,这种类型的错误也仍然可 能造成程序失败。

4.6 头文件

​ 有一个好方法可以避免大部分此类问题,这个方法只需要我们接受一个简单 的规则:每个外部对象只在一个地方声明。这个声明的地方一般就在一个头文件 中,需要用到该外部对象的所有模块都应该包括这个头文件。特别需要指出的是, 定义该外部对象的模块也应该包括这个头文件。

​ 例如,再来看前面讨论过的filename例子。这个例子可能是一个完整程序的 一部分,该程序由多个模块组成,每个模块都需要知道一个特定文件名。我们希 望能够做到只在一处改动这个特定的文件名,所有模块中的文件名就同时得到更 新。我们可以这样来做,创建一个文件,比如叫做file.h,它包含了声明:

extern char filename[];

需要用到外部对象filename的每个C源文件都应该加上这样一个语句:

#include "fi1e.h"

最后,我们选择一个C源文件,在其中给Hl filename的初始值。我们不妨称 这个文件为file.c:

#include "fi1e * h"
char filename[] = "/etc/passwd"

注意,源文件file.c实际上包含filename的两个声明,这一点只要把include 语句展开就可以看岀:

extern char filename[]char filename [] = "/etc/passwd"

只要源文件file.c中filename的各个声明是一致的,而且这些声明中最多只 有一个是filename的定义,这样写就是合法的。

​ 让我们来看这样做的效果。头文件file,h中声明了 filename的类型,因此每 个包含了 file.h的模块也就自动地正确声明了 Rename的类型。源文件file.c定义 f filename,由于它也包含了 file.h头文件,因此filename定义的类型自动地与声 明的类型相符合。如果编译所有这些文件,filename的类型就肯定是正确的!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

奈斯编程

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值