【C 陷阱与缺陷】(四)连接

码字不易,对你有帮助 点赞/转发/关注 支持一下作者

微信搜公众号:不会编程的程序圆

看更多干货,获取第一时间更新

代码,练习上传至:

https://github.com/hairrrrr/C-CrashCourse

一 链接

0. 什么是连接器

C 语言的一个重要思想就是分别编译(separate compilation),即若干个源程序可以在不同的时候单独进行编译,然后在恰当的时候整合在一起。但是,连接器一般是与 C 编译器分离的,它不可能了解 C 语言的诸多细节。

**连接器的工作原理:**连接器的输入是一组目标模块和库文件。连接器的输出是一个载入摸块。连接器读入目标模块和库文件同时生皮载入模块。对每个目标核块中的每个外部对象,链接器都要检查载入模块。查看是否有同名的外部对象。如果没有,连接器就将该外部对象添加到入模块中,如果有,连接器就要开始处理命名冲突。

**外部对象:**程序中的每个函数和每个外部变量,如果没有被声明为 static,就都是一个外部对象。

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

1. 声明与定义

声明语句:

int a;

如果其位置出现在所有函数体之外,那么它就被称为外部对象 a 的定义。这个语句说明了 a 是一个外部整型变量,同时为 a 分配内存空间。它的初始值默认为 0 。

下面的声明语句:

int a = 7;

不仅为 a 分配了内存空间,而且说明了在该内存中应该存储的值。

下面的声明语句:

extern int a;

并不是对 a 的定义。这个语句仍然说明了 a 是一个外部整型变量,但是 a 的存储空间是在程序的其他地方分配的。从连接器的角度来看,上面的声明是对 a 的引用,而不是定义。

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

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

extern int a;

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

int a;

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

严格的规则是,每个外部变量都只能被定义一次

2. 命名冲突与 static 修饰符

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

int a;

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

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

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

static int a;

其含义与下面的语句相同

int a;

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

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

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

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

3. 形参,实参与返回值

如果任何一个函数在调用它的每个文件中,都在第一次被调用之前进行了声明或定义,那么就不会有任何与返回类型相关的麻烦。

比如一个调用 square 函数的程序:

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

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

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

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

要么在 main 函数前进行声明:

double square(double);

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

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

如果一个函数在被定义或声明之前被调用,那么它的返回类型就默认为整型。比如将上面的 main 函数放到一个独立的源文件中:

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

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

如果我们需要在两个不同的源文件中分别定义函数 main 和函数 square ,那么应该在调用 square 函数的文件中声明 square 函数。比如:

double square(double);

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

ANSI C 允许程序员在声明时指定函数的参数类型(省略也是可以的,但是在函数定义时是不能省略参数类型的说明)。

double square(double);

像下面这样声明也是可以的:

double square();

默认实参提升

  • float 类型参数会转换为 double 类型
  • char 类型,short 类型参数会转换为 int 类型

对于声明:

int isvowel(char);

如果在使用 isvowel 函数前没有这样声明,调用者将把传递给 isvowel 函数实参自动转换为 int 类型。

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

上面的程序不能正常运行,原因有两个:

  • sqrt 函数本该接受一个双精度的值作为实参,而实际上被传递了一个整型
  • sqrt 函数的返回类型是双精度类型,但却没有这样声明。

一种更正方式是:

double sqrt(double);

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 的实参已经修改为 2.0,然而即使仍然写成 2,在符合 ANSI C 的编译器上,这个程序也能确保实参会被转换为恰当的类型。

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

#include<stdio.h>

int main() {

	int i;
	char c;
	for (i = 0; i < 5; i++) {
		scanf("%d", &c);
		printf("%d ", i);
	}
	printf("\n");

	return 0;
}

表面上,这个程序从标准输入设备读入 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. 检查外部类型

假定我们有一个 C 程序,它由两个源文件组成。一个文件包含外部变量 n 的声明:

extern int n;

另一个文件中包含外部变量 n 的定义:

long n;

这是一个无效的 C 程序,因为同一个外部变量在两个文件中不能被声明为不同类型。然而编译器和连接器可能检查不出这种错误。

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

  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 的内存布局大致如图:

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

要更正本例,改法如下:

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

extern char filename[]; // 文件 2

或:

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

extern char* filename; // 文件 2

现在我们回顾前面的程序:

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

这个程序在调用函数 sqrt 前没有对函数 sqrt 进行声明或定义。因此,这个程序完全等同于下面的程序:

extern int sqrt();

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

这样的写法当然是错误的。

5. 头文件

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

例如,创建一个文件叫 file.h,它包含声明:

extern char filename[];

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

#include "file.h"

最后我们选择一个 C 源文件,在其中给出 filename 的初始值。

file.c

#include "file.h"
char filename[] = "/etc/passwd";

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

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

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

二 练习

练习4-1.

假定一个程序在一个源文件中包含了声明:

long foo;

而在另一个源文件中包含了:

extern short foo;

又进一步假定,如果给long类型的 foo 赋一个较小的值,例如37,那么short类型的foo就同时获得了一个值37。我们能够对运行该程序的硬件作出什么样的推断?如果short类型的foo得到的值不是37而是0,我们又能够作出什么样的推断?

如果把值 37 赋给 long 型的 foo,相当于同时把值 37 也赋给了short型的foo,那么这意昧着 short 型的 foo,与 long 型的foo中包含了值37的有效位的部分,两者在内存中占用的是同一区域。long 型的 foo 的低位部分与 short 型的 foo 共享了相同的内存空间,因此我们的一个可能推论就是,运行该程序的硬件是一个低位优先(little-endian:小端) 的机器。

同样道理,如果在 long 型的 foo 中存储,了值 37,而 short 型的 foo 的值却是 0,我们所用的硬件可能是一个高位优先(big-endian:大端)的机器。

注:小端就是将数字的低位放在低地址;大端则相反。

练习4-2

.本章第 4节中讨论的错误程序,经过适当简化后如下所示:

#include <stdio.h>

main()
{
	printf("qg\n"sqrt(2) ) ;
}

在某些系统中,打印出的结果是 %g 请问这是为什么?

在某些 C 语言实现中,存在着两种不同版本的 printf 函数:其中一-种实现了用于表示浮点格式的项,如 %e、%f、%g 等;而另一种却没有实现这些浮点格式。库文件中同时提供了printf 函数的两种版本,这样的话,那些没有用到浮点运算的程序,就可以使用不提供浮点格式支持的版本,从而节省程序空间、减少程序大小。

在某些系统上,编程者必须显式地通知连接器是否用到了浮点运算。而另一些系统,则是通过编译器来告知连接器在程序中是否出现了浮点运算,以自动地作出决定。

上面的程序没有进行任何浮点运算!它既没有包含 math.h 头文件,也没有声明 sqrt 函数,因此编译器无从得知 sqrt 是一个浮点函数。这个程序甚至都没有传送一个浮点参数给sqrt 函数。所以,编译器“自认合理”地通知连接器,该程序没有进行浮点运算。

那 sqrt 函数又怎么解释呢?难道 sqrt 函数是从库文件中取出的这个事实,还不足以证明该程序用到了浮点运算? 当然,sqrt 函数是从库文件中取出的这一点没错;但是,连接器可能在从库文件中取出 sqrt 函数之前,就已经作出了使用何种版本的printf 函数的决定。

注:其实 %g 被 printf 函数当作了字符串输出,后面的参数被舍弃掉了,你可以用下面这个例子来理解:

#include<stdio.h>

int main(void) {

	printf("Hello World\n", 123);

	return 0;
}

参考资料《C 缺陷与陷阱》


以上就是本次的内容,感谢观看。

如果文章有错误欢迎指正和补充,感谢!

最后,如果你还有什么问题或者想知道到的,可以在评论区告诉我呦,我在后面的文章可以加上。

最后,关注我,看更多干货!

我是程序圆,我们下次再见。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章