http://c.biancheng.net/c/150/

多文件编程就是指把一个程序的代码,拆分为多个文件来编写。如果只用一个文件来学代码,太多太乱会造成阅读性和维护性降低。

多文件编程既涉及到了内存,也涉及到了编译原理。

extern关键字与多文件编程

我们就来演示一下多文件编程。在下面的例子中,我们创建了两个源文件 main.c 和 module.c:

  • module.c 是整个程序的一个模块,我们在其中定义了一个全局变量和一个函数;
  • main.c 是程序的主模块(主文件),它使用到了 module.c 中的变量和函数。

module.c 源码:

1
2
3
4
5
#include <stdio.h>
int m = 100;
void func(){
printf("Multiple file programming!\n");
}

main.c 源码:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
extern void func();
extern int m;
int n = 200;
int main(){
func();
printf("m = %d, n = %d\n", m, n);
return 0;
}

在 Linux GCC 中,可以使用下面的命令来编译和运行程序:

1
2
$gcc main.c module.c
$./a.out

程序最终的运行结果为:

1
2
Multiple file programming!
m = 100, n = 200

m 和 n 是在所有函数之外定义的全局变量(Global Variable),它的作用域默认是整个程序,也就是所有的代码文件,包括.c和.h文件。

如果你一直在编写单个源文件的程序,那么请注意,全局变量的作用范围不是从变量定义处到该文件结束,在其他文件中也有效。

这里需要重点理解的是 extern 关键字,它用来声明一个变量或函数。

extern 关键字

C语言代码是由上到下依次执行的,不管是变量还是函数,原则上都要先定义再使用,否则就会报错。但在实际开发中,经常会在函数或变量定义之前就使用它们,这个时候就需要提前声明。

函数的声明

我们可能在学习声明函数的时候,并没有使用extern关键字,这是因为函数的定义有函数体,函数的声明没有函数体,编译器很容易区分定义和声明,所以对于函数声明来说,有没有 extern 都是一样的。编译器还是比较贴心的,索性就是使不使用都可以。

总结起来,函数声明有四种形式:

1
2
3
4
5
6
//不使用 extern
datatype function( datatype1 name1, datatype2 name2, ... );
datatype function( datatype1, datatype2, ... );
//使用 extern
extern datatype function( datatype1 name1, datatype2 name2, ... );
extern datatype function( datatype1, datatype2, ... );

变量的声明

变量和函数不同,编译器只能根据 extern 来区分,有 extern 才是声明,没有 extern 就是定义。

变量的定义有两种形式,你可以在定义的同时初始化,也可以不初始化:

1
2
datatype name = value;
datatype name;

而变量的声明只有一种形式,就是使用 extern 关键字:

1
extern datatype name;

另外,变量也可以在声明的同时初始化,格式为:

1
extern datatype name = value;

这种似是而非的方式是不被推荐的,有的编译器也会给出警告,我们不再深入讨论,也建议各位读者把定义和声明分开,尽量不要这样写。

extern 是“外部”的意思,很多教材讲到,extern 用来声明一个外部(其他文件中)的变量或函数,也就是说,变量或函数的定义在其他文件中。

不过我认为这样讲不妥,因为除了定义在外部,定义在当前文件中也是正确的。例如,将 module.c 中的int m = 100;移动到 main.c 中的任意位置都是可以的。所以我认为,extern 是用来声明的,不管具体的定义是在当前文件内部还是外部,都是正确的。

GCC xxx.c都干了啥?

demo.c

1
2
3
4
5
#include <stdio.h>
int main(){
printf("Hello World\n");
return 0;
}

Linux 下使用 GCC 来编译,使用最简单的$gcc demo.c命令,就可以在当前目录下看到 a.out。

事实上,从源代码生成可执行文件可以分为四个步骤,分别是预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。下图是 GCC 生成 a.out 的过程:

预处理

预处理过程主要是处理那些源文件和头文件中以#开头的命令,比如 #include、#define、#ifdef 等。预处理的规则一般如下:

  • 将所有的#define删除,并展开所有的宏定义。
  • 处理所有条件编译命令,比如 #if、#ifdef、#elif、#else、#endif 等。
  • 处理#include命令,将被包含文件的内容插入到该命令所在的位置,这与复制粘贴的效果一样。注意,这个过程是递归进行的,也就是说被包含的文件可能还会包含其他的文件。
  • 删除所有的注释//和/_ … _/。
  • 添加行号和文件名标识,便于在调试和出错时给出具体的代码位置。
  • 保留所有的#pragma命令,因为编译器需要使用它们。

预处理的结果是生成.i文件。.i文件也是包含C语言代码的源文件,只不过所有的宏已经被展开,所有包含的文件已经被插入到当前文件中。当你无法判断宏定义是否正确,或者文件包含是否有效时,可以查看.i文件来确定问题。

在 GCC 中,可以通过下面的命令生成.i文件:

1
$gcc -E demo.c -o demo.i

-E表示只进行预编译。

编译Compilation

编译就是把预处理完的文件进行一些列的词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件。有兴趣可以看编译原理相关资料。

在 GCC 中,可以使用下面的命令生成.s文件:

1
$gcc -S demo.i -o demo.s

或者

1
$gcc -S demo.c -o demo.s

汇编(Assembly)

汇编过程相对于编译来说比较简单,没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编语句和机器指令的对照表一一翻译就可以了。

汇编的结果是产生目标文件,在 GCC 下的后缀为.o,在 Visual Studio 下的后缀为.obj。

链接(Linking)

目标文件已经是二进制文件,与可执行文件的组织形式类似,只是有些函数和全局变量的地址还未找到,程序不能执行。链接的作用就是找到这些目标地址,将所有的目标文件组织成一个可以执行的二进制文件。

目标文件的结构、可执行文件的结构、链接的过程是我们要重点研究的,它能够让我们明白多文件编程以及模块化开发的原理,这是大型项目开发的基石。

目标文件和可执行文件

从文件结构上来讲,目标文件已经是二进制文件,它与可执行文件的组织形式非常类似,只是有些变量和函数的地址还未确定,程序不能执行。

链接的一个重要作用就是找到这些变量和函数的地址。

另外需要明确的是:编译是针对单个源文件的,有几个源文件就会生成几个目标文件,并且在生成过程中不受其他源文件的影响。也就是说,不管当前工程中有多少个源文件,编译器每次只编译一个源文件、生成一个目标文件。

Windows和Linux下的文件格式

现在PC平台上流行的可执行文件格式主要是 Windows 下的 PE(Portable Executable)和 Linux 下的 ELF(Executable Linkable Format),它们都是 COFF(Common File Format)格式的变种。

COFF 是 Unix V3首先提出的规范,微软在此基础上制定了 PE 格式标准,并将它用于 Windows。后来 Unix V4 又在 COFF 的基础上引入了 ELF 格式,被 Linux 广泛使用。这也就是为什么 Windows 和 Linux 上的可执行文件如此相似的主要原因,因为它们都是源于同一种可执行文件格式 COFF。

从广义上讲,目标文件与可执行文件的存储格式几乎是一样的,我们可以将它们看成是同一种类型的文件,在 Windows 下,将它们统称为 PE 文件,在 Linux 下,将它们统称为 ELF文件。

另外,动态链接库(DLL,Dynamic Linking Library)(Windows 下的.dll和 Linux 下的.so)和静态链接库(Static Linking Library)(Windows 下的.lib和 Linux 下的.a)也是按照可执行文件的格式存储的。

静态链接库稍有不同,它是把多个目标文件捆绑在一起形成一个文件,再加上一些索引,你可以简单地把它理解为一个包含了很多目标文件的包。

其他不太常见的可执行文件格式还有 Intel/Microsoft 的 OMF(Object Module Format)、Unix a.out、MS-DOS .COM 等。

在 Linux 的 ELF 标准中,主要包含以下四类文件:

文件类型 说明 实例
可重定位文件
(Relocatable File) 这类文件包含了代码和数据,可以被用来链接成为可执行文件或动态链接库。静态链接库其实也是可重定位文件。 Linux下的.o和.a,Windows下的.obj和.lib。
可执行文件
(Executable File) 这类文件包含了可以直接执行的程序。 Windows下的.exe,Linux下的可执行文件没有固定的后缀,一般不写。
共享目标文件
(Shared Object File) 这种文件包含了代码和数据,可以在以下两种情况下使用:一种是链接器可以使用这种文件跟其他的可重定位文件和共享目标文件链接,产生新的目标文件;第二种是动态连接器可以将几个共享目标文件与可执行文件结合,作为进程的一部分来运行。 Linux下的.so,Windows下的.dll。
核心转储文件
(Core Dump File) 当进程意外终止时,系统可以将该进程的地址空间的内容以及其他信息保存到核心转储文件。 Linux下的core dump。

目标文件的组织形式

从整体上看,编译生成的目标文件被划分成了多个部分,每个部分叫做一个段(Section)。下图是 Linux GCC 生成的目标文件的格式:

段名大都以.作为前缀,表示这些名字是系统保留的。下面是对各个部分的说明:

段 名 说 明
ELF Header 文件头,描述了整个目标文件的属性,包括是否可执行、是动态链接还是静态链接、入口地址是什么、目标硬件、目标操作系统、段表偏移等信息。
.text 代码段,存放编译后的机器指令,也即各个函数的二进制代码。一个C语言程序由多个函数构成,C语言程序的执行就是函数之间的相互调用。
.data 数据段,存放全局变量和静态变量。
.rodata 只读数据段,存放一般的常量、字符串常量等。
.rel.text.
rel.data 重定位段,包含了目标文件中需要重定位的全局符号以及重定位入口,我们将在《符号——链接的粘合剂》一节中讲解。
.comment 注释信息段,存放的是编译器的版本信息,比如“GCC:(GUN)4.2.0”。
.debug 调试信息。
.line 调试时的行号表,即源代码行号与编译后指令的对应表。
Section Table 段表,描述了ELF文件包含的所有段的信息,比如段的名字、段的长度、在文件中的偏移、读写权限以及其他属性。可以说,ELF文件的段结构是由段表来决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的。
.strtab 字符串表,保存了ELF文件用到的字符串,比如变量名、函数名、段名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难,常见的做法就是把字符串集中起来存放到一个表中,然后使用字符串在表中的偏移来引用字符串。
.symtab 符号表,保存了全局变量名、局部变量名、函数名等在字符串表中的偏移。

可执行文件的组织形式

可执行文件的组织形式和目标文件非常类似,也被划分成多个部分,如下图所示:

图中左半部分是可执行文件的结构:带阴影的是可执行文件增加的一些段,另外可执行文件删除了可重定位段(.rel.text和.rel.data)以及段表(Section Table)。

总体来说,目标文件包含了10个左右的段,而可执行文件包含了将近30个左右的段,上面的两张图只列出了一些关键段,剩下的段都隐藏在“Other Data(其他数据)”。

图中右半部分是进程的地址空间。

不同颜色的箭头表明了可执行文件应该被加载到地址空间的哪一个区域,可以发现,操作系统并不是为每个段都分配一个区域,而是将多个具有相同权限的段合并在一起,加载到同一个区域。

站在文件结构的角度,可执行文件包含了众多的段(Section),每个段都有不同的作用;站在加载和执行的角度,所有的段都是数据,操作系统只关心数据的权限,只要把相同权限的数据加载到同一个内存区域,程序就能正确执行。

常见的数据权限无外乎三种:只读(例如 .rodata 只读数据段)、读写(例如 .data 数据段)、读取和执行(例如 .text 代码段),我们将一块连续的、具有相同权限的数据称为一个 Segment,一个 Segment 由多个权限相同的 Section 构成。

不巧的是,“Segment”也被翻译为“段”,但这里的段(Segment)是针对加载和执行的过程。

在 Linux 下,相信很多读者都遇到过一种叫做Segment fault(段错误)的错误,这种错误发生在程序执行期间,在编译和链接时无法检测,一般都是代码的权限不足导致的。例如:

1
2
3
4
5
6
#include <stdio.h>
char *str = "c.biancheng.net";
int main(){
str[1] = '@';
return 0;
}

程序执行到 6 行时就会出现“Segment fault(段错误)”,这是因为字符串 str 保存在地址空间的常量区,只能读取,不能写入,而修改字符串显然是越权操作。

在目标文件中,段表(Section Table)用来描述各个 Section 的信息,包括它的名字、长度、在文件中的偏移、读写权限等,通过段表可以详细地了解目标文件的结构。

而在可执行文件中,段表被删除了,取代它的是程序头表(Program Header Table);程序头表用来描述各个 Segment 的信息,包括它的类型、偏移、在进程虚拟地址空间中的起始地址、物理装载地址、长度、权限等。操作系统就是根据程序头表将可执行文件加载到内存,并为各个 Segment 分配内存空间、确定起止地址。

也就是说,可执行文件不再关注具体的文件结构,而是关注程序的加载和执行过程。

由于可执行文件在加载时实际上是被映射的虚拟地址空间,所以可执行文件很多时候又被叫做映像文件(Image)

段(Section)的合并

编译器生成的是目标文件,而我们最终需要的是可执行文件,链接(Linking)的作用就是将多个目标文件合并成一个可执行文件。

在链接过程中,链接器会将多个目标文件中的代码段、数据段、调试信息等合并成可执行文件中的一个段。段的合并仅仅是一个简单的叠加过程,如下图所示:

除了合并有用的段(例如代码段、数据段等),链接器还会删除多余的段(例如重定位段、段表等),增加其他段(例如程序头表等)。

到底什么是链接,它起到了什么作用?

几十年以前,计算机刚刚诞生,人们编写程序时,将所有的代码都写在同一个源文件中,经过长期的积累,程序包含了数百万行的代码,以至于人们无法维护这个程序了。于是人们开始寻找新的方法,迫切地希望将程序源代码分散到多个文件中,一个文件一个模块,以便更好地阅读和维护,这个时候,链接器就粉墨登场了。

一切都是地址

我们知道,数据是保存在内存中的,对于计算机硬件来说,必须知道它的地址才能使用。变量名、函数名等仅仅是地址的一种助记符,目的是在编程时更加方便地使用数据,当源文件被编译成可执行文件后,这些标识符都不存在了,它们被替换成了数据的地址。

假设变量 a、b、c 的地址分别为 0X1000、0X1004、0X1008,加法运算的机器指令为 1010,赋值运算的机器指令为 1110,那么在C语言中实现加法运算的代码为:

1
c = a + b;

生成可执行文件后的机器码为:

1
2
1010  0X1000  0X1004  //将两个数据相加的值保存在一个临时区域
1110 0X1008 //将临时区域中的数据复制到地址为0X1008的内存中

编译器和链接器的一项重要任务就是将助记符替换成地址。

模块化开发

现代软件的规模往往都很大,动辄数百万行代码,程序员需要把它们分散到成百上千个模块中。这些模块之间相互依赖又相互独立,原则上每个模块都可以单独开发、编译、测试,改变一个模块中的代码不需要编译整个程序。

在C语言中,一个模块可以认为是一个源文件(.c 文件)。

在程序被分隔成多个模块后,需要解决的一个重要问题是如何将这些模块组合成一个单一的可执行程序。在C语言中,模块之间的依赖关系主要有两种:一种是模块间的函数调用,另外一种是模块间的变量访问。

函数调用需要知道函数的首地址,变量访问需要知道变量的地址,所以这两种方式可以归结为一种,那就是模块间的符号引用。

模块间依靠符号来“通信”类似于拼图版,定义符号的模块多出一个区域,引用符号的模块刚好少了那一块区域,两者刚好完美组合。如下图所示:

这种通过符号将多个模块拼接为一个独立的程序的过程就叫做链接(Linking)

符号——链接的粘合剂

链接(Linking)就是通过符号将各个模块组合成一个独立的程序的过程。

链接的主要内容就是把各个模块之间的相互引用部分处理好,使得各个模块能够正确地衔接。链接器所做的主要工作跟前面提到的“人工调整地址”本质上没有什么两样,只不过现代的高级语言拥有诸多的特性,使得编译器和链接器更为复杂,功能更为强大,但从原理上来讲,无非是找到符号的地址,或者把指令中使用到的地址加以修正。这个过程称为符号决议(Symbol Resolution)或者重定位(Relocation)

对于简单的C语言程序,链接过程如下图所示。每个模块的源文件(.c 和 .h)先被编译成目标文件,再和系统库一起链接成可执行文件。库(Library)其实是一组目标文件的包,是将一些最常用的代码编译成目标文件后打包存放。

系统库这个概念比较模糊,专业一点应该叫做运行时库(Runtime Library)。“运行时”就是程序运行期间,“运行时库”包含了程序运行期间所需要的基本函数,是程序运行不可或缺的,例如输入输出函数 printf()、scanf(),内存管理函数 malloc()、free() 等。

假设一个程序有两个模块 main.c 和 module.c,我们在 module.c 中定义了函数 func(),并在 main.c 中进行了多次调用,当所有模块被编译成一个可执行文件后,每一处对 func() 函数的调用都会被替换为一个绝对地址。但由于每个模块都是单独编译的,编译器在处理 main.c 时并不知道 func() 的地址,所以需要把这些调用 func() 的指令的目标地址搁置,等到最后链接的时候再由链接器将这些地址修正。

如果没有链接器,我们必须手工修正 func() 的地址。当 module.c 被修改并重新编译时,func() 的地址极有可能改变,那么在 main.c 中所有使用到 func() 函数的地方,都要全部重新调整地址。这些繁琐的工作将成为程序员的噩梦。

有了链接器,我们可以直接调用其他模块中的函数而无需知道它们的地址,因为在链接的时候,链接器会根据符号 func 自动去 module.c 模块查找 func 的地址,然后将 main.c 模块中所有使用到 func 的指令重新修正,让它们的目标地址成为真正的 func() 函数的地址。

这种在程序运行之前确定符号地址的过程叫做静态链接(Static Linking);如果需要等到程序运行期间再确定符号地址,就叫做动态链接(Dynamic Linking)

Windows 下的 .dll 或者 Linux 下的 .so 必须要嵌入到可执行程序、作为可执行程序的一部分运行,它们所包含的符号的地址就是在程序运行期间确定的,所以称为动态链接库(Dynamic Linking Library)。

变量和函数一样,都是符号,都需要确定它的地址。例如在 a.c 中有一个 int 类型的全局变量 var,现在需要在 b.c 中对它赋值 42,对应的C语言代码是:

var = 100;

对应的汇编代码为:

mov 0x2a, var

mov 用来将一份数据移动到一个存储位置,这里表示将 0x2a 移动到 var 符号所代表的位置,也就是对 var 变量赋值。

当被编译成目标文件后,得到如下的机器指令:

c705  00000000  0000002a

由于在编译时不知道变量 var 的地址,编译器将这条 mov 指令的目标地址设置为 0,等到将目标文件 a.o 和 b.o 链接起来的时候,再由链接器对其进行修正。

假设生成可执行文件后变量 var 的地址为 0x1100,那么上面的机器指令就变为:

c705  00001100  0000002a

这种地址修正的过程就是前面提到的重定位,每个需要被修正的地方叫做一个重定位入口(Relocation Entry)。重定位所做的工作就是给程序中每个这样的绝对地址引用的位置“打补丁”,使它们指向正确的地址。

符号的概念

函数和变量在本质上是一样的,都是地址的助记符,在链接过程中,它们被称为符号(Symbol)。链接器的一个重要任务就是找到符号的地址,并对每个重定位入口进行修正。

我们可以将符号看做是链接中的粘合剂,整个链接过程正是基于符号才能正确完成。

在《目标文件里面有什么,它是如何组织的》一节中讲到,目标文件被分成了多个部分,其中有一个叫做符号表(Symbol Value),它的段名是.symtab。符号表记录了当前目标文件用到的所有符号,包括:

全局符号,也就是函数和全局变量,它们可以被其他目标文件引用。

外部符号(External Symbol),也就是在当前文件中使用到、却没有在当前文件中定义的全局符号。

局部符号,也就是局部变量。它们只在函数内部可见,对链接过程没有作用,所以链接器往往也忽略它们。

段名,这种符号往往由编译器产生,它的值就是该段的起始地址,比如.text、.data等。

对链接来说,最值得关注的是全局符号,也就是上面的第一类和第二类,其它符号都是次要的。

所有的符号都保存在符号表.symtab中,它一个结构体数组,每个数组元素都包含了一个符号的信息,包括符号名、符号在段中的偏移、符号大小(符号所占用的字节数)、符号类型等。

确切地说,真正的符号名字是保存在字符串表.strtab中的,符号表仅仅保存了当前符号在字符串表中的偏移。

符号决议(Symbol Resolution)

当要进行链接时,链接器首先扫描所有的目标文件,获得各个段的长度、属性、位置等信息,并将目标文件中的所有(符号表中的)符号收集起来,统一放到一个全局符号表。

在这一步中,链接器会将目标文件中的各个段合并到可执行文件,并计算出合并后的各个段的长度、位置、虚拟地址等。

在目标文件的符号表中,保存了各个符号在段内的偏移,生成可执行文件后,原来各个段(Section)起始位置的虚拟地址就确定了下来,这样,使用起始地址加上偏移量就能够得到符号的地址(在进程中的虚拟地址)。

这种计算符号地址的过程被称为符号决议(Symbol Resolution)

重定位表.rel.text和.rel.data中保存了需要重定位的全局符号以及重定位入口,完成了符号决议,链接器会根据重定位表调整代码中的地址,使它指向正确的内存位置。

至此,可执行文件就生成了,链接器完成了它的使命。

强符号和弱符号

我们在编写代码的过程中经常会遇到一种叫做符号重复定义(Multiple Definition)的错误,这是因为在多个源文件中定义了名字相同的全局变量,并且都将它们初始化了。

例如,在 a.c 中定义了全局变量 global:

1
int global = 10;

在 b.c 中又对 global 进行了定义:

1
int global = 20;

那么在链接时就会出现下面的错误:

1
2
b.o: multiple definition of `global'
a.o: first defined here

这种符号的定义可以被称为强符号。

_在C语言中,编译器默认函数和初始化了的全局变量为强符号(Strong Symbol),未初始化的全局变量为弱符号(Weak Symbol)_。强符号之所以强,是因为它们拥有确切的数据,变量有值,函数有函数体;弱符号之所以弱,是因为它们还未被初始化,没有确切的数据。

链接器会按照如下的规则处理被多次定义的强符号和弱符号:

1.
不允许强符号被多次定义,也即不同的目标文件中不能有同名的强符号;如果有多个强符号,那么链接器会报符号重复定义错误。

2.
如果一个符号在某个目标文件中是强符号,在其他文件中是弱符号,那么选择强符号。

3.
如果一个符号在所有的目标文件中都是弱符号,那么选择其中占用空间最大的一个。

比如目标文件 a.o 定义全局变量 global 为 int 类型,占用4个字节,目标文件 b.o 定义 global 为 double 类型,占用8个字节,那么被链接后,符号 global 占用8个字节。请尽量不要使用多个不同类型的弱符号,否则有时候很难发现程序错误。

在 GCC 中,可以通过attribute((weak))来强制定义任何一个符号为弱符号。假设现在有下面的一段代码:

1
2
3
4
5
6
7
extern int ext;
int weak1;
int strong = 100;
__attribute__((weak)) weak2 = 2;
int main(){
return 0;
}

weak1 和 weak2 是弱符号,strong 和 main 是强符号,而 ext 既非强符号也非弱符号,它是一个对外部变量的引用(使用)。

弱符号对于库来说十分有用,我们在开发库时,可以将某些符号定义为弱符号,这样就能够被用户定义的强符号覆盖,从而使得程序可以使用自定义版本的函数,增加了很大的灵活性。

强引用和弱引用

所谓引用(Reference),是指对符号的使用。在下面的代码中:

1
2
int a = 100, b = 200, c;
c = a + b;

第一行是符号定义,第二行是符号引用。

目前我们所看到的符号引用,在所有目标文件被链接成可执行文件时,它们的地址都要被找到,如果没有符号定义,链接器就会报符号未定义错误,这种被称为强引用(Strong Reference)

与之相对应的还有一种弱引用(Weak Reference),如果符号有定义,就使用它对应的地址,如果没有定义,也不报错。

链接器处理强引用和弱引用的过程几乎是一样的,只是对于未定义的弱引用,链接器不认为它是一个错误,一般默认其为 0(地址为 0),或者是一个特殊的值,以便程序代码能够识别。

在变量声明或函数声明的前面加上attribute((weak))就会使符号变为弱引用。比如下面这段代码:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
__attribute__((weak)) extern int a;
__attribute__((weak)) extern void func(); //也可以不写extern
int main(){
printf("&a: %d, func: %d\n", &a, func);
printf("a = %d\n", a);
func();
return 0;
}

我们可以将它编译成一个可执行文件,GCC 并不会报链接错误。但是当程序运行时,输出&a: 0, func: 0后就会发生段错误(Segment Fault),这是因为符号 a 和 func 的地址都为 0,这个地址是禁止访问的。

一个改进的例子是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
__attribute__((weak)) extern int a;
__attribute__((weak)) extern void func();
int main(){
printf("&a: %d, func: %d\n", &a, func);
if(&a){
printf("a = %d\n", a);
}else{
printf("a is undefined!\n");
}
if(func){
func();
}else{
printf("func() is undefined!\n");
}
return 0;
}

运行结果:

1
2
3
&a: 0, func: 0
a is undefined!
func() is undefined!

代码中需要判断的是地址,不是值,所以变量 a 前面需要加&;而函数名本身就表示地址,所以 func 前边不需要&。

弱引用和强引用非常利于程序的模块化开发,我们可以将程序的扩展模块定义为弱引用,当我们将扩展模块和程序链接在一起时,程序就可以正常使用;如果我们去掉了某些模块,那么程序也可以正常链接,只是缺少了某些功能,这使得程序的功能更加容易裁剪和组合。

模块化编程中的头文件

在实际开发中,一般是将函数和变量的声明放到头文件,再在当前源文件中 #include 进来。如果变量的值是固定的,最好使用宏来代替。

.c.h文件都是源文件,除了后缀不一样便于区分外和管理外,其他的都是相同的,在.c中编写的代码同样也可以写在.h中,包括函数定义、变量定义、预处理等。

.h 和 .c 在项目中承担的角色不一样:.c 文件主要负责实现,也就是定义函数和变量;.h 文件主要负责声明(包括变量声明和函数声明)、宏定义、类型定义等。这些不是C语法规定的内容,而是约定成俗的规范,或者说是长期形成的事实标准。

根据这份规范,头文件可以包含如下的内容:

  • 可以声明函数,但不可以定义函数。
  • 可以声明变量,但不可以定义变量。
  • 可以定义宏,包括带参的宏和不带参的宏。
  • 结构体的定义、自定义数据类型一般也放在头文件中。

在项目开发中,我们可以将一组相关的变量和函数定义在一个 .c 文件中,并用一个同名的 .h 文件(头文件)进行声明,其他模块如果需要使用某个变量或函数,那么引入这个头文件就可以。

这样做的另外一个好处是可以保护版权,我们在发布相关模块之前,可以将它们都编译成目标文件,或者打包成静态库,只要向用户提供头文件,用户就可以将这些模块链接到自己的程序中。

标准库以及标准头文件

源文件通过编译可以生成目标文件(例如 GCC 下的 .o 和 Visual Studio 下的 .obj),并提供一个头文件向外暴露接口,除了保护版权,还可以将散乱的文件打包,便于发布和使用。

实际上我们一般不直接向用户提供目标文件,而是将多个相关的目标文件打包成一个静态链接库(Static Link Library),例如 Linux 下的 .a 和 Windows 下的 .lib。

打包静态库的过程很容易理解,就是将多个目标文件捆绑在一起形成一个新的文件,然后再加上一些索引,方便链接器找到,这和压缩文件的过程非常类似。

C语言在发布的时候已经将标准库打包到了静态库,并提供了相应的头文件,例如 stdio.h、stdlib.h、string.h 等。

Linux 一般将静态库和头文件放在/lib和/user/lib目录下,C语言标准库的名字是libc.a,大家可以通过locate命令来查找它的路径:

1
2
3
4
5
6
7
8
9
$ locate libc.a
/usr/lib/x86_64-redhat-linux6E/lib64/libc.a

$ locate stdio.h
/usr/include/stdio.h
/usr/include/bits/stdio.h
/usr/include/c++/4.8.2/tr1/stdio.h
/usr/lib/x86_64-redhat-linux6E/include/stdio.h
/usr/lib/x86_64-redhat-linux6E/include/bits/stdio.h

ANSI C 标准共定义了 15 个头文件,称为“C标准库”,所有的编译器都必须支持,如何正确并熟练的使用这些标准库,可以反映出一个程序员的水平:

  • 合格程序员:<stdio.h>、<ctype.h>、<stdlib.h>、<string.h>
  • 熟练程序员:<assert.h>、<limits.h>、<stddef.h>、<time.h>
  • 优秀程序员:<float.h>、<math.h>、<error.h>、<locale.h>、<setjmp.h>、<signal.h>、<stdarg.h>

除了C标准库,编译器一般也会附带自己的库,以增加功能,方便用户开发,争夺市场份额。这些库中的每一个函数都在对应的头文件中声明,可以通过 #include 预处理命令导入,编译时会被合并到当前文件。

头文件的路径

引入编译器自带的头文件(包括标准头文件)用尖括号,引入程序自定义的头文件用双引号,例如:

1
2
#include <stdio.h>  //引入标准头文件
#include "myFile.h" //引入自定义的头文件

使用尖括号< >,编译器会到系统路径下查找头文件;而使用双引号” “,编译器首先在当前目录下查找头文件,如果没有找到,再到系统路径下查找。也就是说,使用双引号比使用尖括号多了一个查找路径,它的功能更为强大,我们完全可以使用双引号来包含标准头文件,例如:

1
2
#include "stdio.h"
#include "stdlib.h"

在实际开发中,我们都是将头文件放在当前工程目录下,非常建议大家使用相对路径,这样即使后来改变了工程所在目录,也无需修改包含语句,因为源文件的相对位置没有改变。

当使用相对路径的方式引入头文件时,如果使用< >,那么“相对”的就是系统路径,也就是说,编译器会直接在这些系统路径下查找头文件;如果使用” “,那么首先“相对”的是当前路径,然后“相对”的才是系统路径,也就是说,编译器首先在当前路径下查找头文件,找不到的话才会继续在系统路径下查找。

而使用绝对路径的方式引入头文件时,< >和” “没有任何区别,因为头文件路径已经写死了(从根部开始查找),不需要“相对”任何路径。

防止C语言头文件被重复包含

头文件包含命令 #include 的效果与直接复制粘贴头文件内容的效果是一样的,预处理器实际上也是这样做的,它会读取头文件的内容,然后输出到 #include 命令所在的位置。

头文件包含是一个递归(循环)的过程,如果被包含的头文件中还包含了其他的头文件,预处理器会继续将它们也包含进来;这个过程会一直持续下去,直到不再包含任何头文件,这与递归的过程颇为相似。

递归包含会导致一个问题,就是重复引入同一个源文件。例如在某个自定义头文件 xyz.h 中声明了一个 FILE 类型的指针,以使得所有的模块都能使用它,如下所示:

1
extern FILE *fp;

FILE 是在 stdio.h 中自定义的一个类型(本质上是一个结构体),要想使用它,必须包含 stdio.h,因此 xyz.h 中完整的代码应该是这样的:

1
2
#include <stdio.h>
extern FILE *fp;

现在假设程序的主模块 main.c 中需要使用 fp 变量和 printf() 函数,那么就需要同时引入 xyz.h 和 stdio.h:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include "xyz.h"
int main(){
if( (fp = fopen("demo.txt", "r")) == NULL ){
printf("File open failed!\n");
}
//TODO:
return 0;
}

这样一来,对于 main.c 这个模块,stdio.h 就被包含了两次。stdio.h 中除了有函数声明,还有宏定义、类型定义、结构体定义等,它们都会出现两次,如果不做任何处理,不仅会出现重复定义错误,而且不符合编程规范。

有人说,既然已经知道 xyz.h 中包含了 stdio.h,那么在 main.c 中不再包含 stdio.h 不就可以了吗?是的,确实如此,这样做就不会出现任何问题!

现在我们不妨换一种场景,假设 xyz1.h 中定义了类型 RYPE1,xyz2.h 中定义了类型 TYPE2,并且它们都包含了 stdio.h,如果主模块需要同时使用 TYPE1 和 TYPE2,就必须将 xyz1.h 和 xyz2.h 都包含进来,这样也会导致 stdio.h 被重复包含,并且无法回避,上面的方案解决不了问题。

实际上,头文件的交叉包含是非常普遍的现象,不仅我们自己创建的头文件是这样,标准头文件也是如此。例如,标准头文件 limits.h 中定义了一些与数据类型相关的宏(最大值、最小值、一个字节所包含的比特位等),stdlib.h 就包含了它。

我们必须找到一种行之有效的方案,使得头文件可以被包含多次,但效果与只包含一次相同。

在实际开发中,我们往往使用宏保护来解决这个问题。例如,在 xyz.h 中可以添加如下的宏定义:

1
2
3
4
#ifndef _XYZ_H
#define _XYZ_H
/* 头文件内容 */
#endif

第一次包含头文件,会定义宏 _XYZ_H,并执行“头文件内容”部分的代码;第二次包含时因为已经定义了宏 _XYZ_H,不会重复执行“头文件内容”部分的代码。也就是说,头文件只在第一次包含时起作用,再次包含无效。

标准头文件也是这样做的,例如在 Visual Studio 2010 中,stdio.h 就有如下的宏定义:

1
2
3
4
#ifndef _INC_STDIO
#define _INC_STDIO
/* 头文件内容 */
#endif

这种宏保护方案使得程序员可以“任性”地引入当前模块需要的所有头文件,不用操心这些头文件中是否包含了其他的头文件。

static变量和函数

我们知道,全局变量和函数的作用域默认是整个程序,也就是所有的源文件,这给程序的模块化开发带来了很大方便,让我们能够在模块 A 中调用模块 B 中定义的变量和函数,而不用把所有的代码都集中到一个模块。

但这有时候也会引发命名冲突的问题,例如在 a.c 中定义了一个变量 n,在 b.c 中又定义了一次,链接时就会发生重复定义错误,原因很简单,变量只能定义一次。

如果两个文件都是我们自己编写的或者其中一个是,遇到这样的情况还比较好处理,修改变量的名字即可;如果两个文件都是其他程序员编写的,或者是第三方的库,修改起来就颇费精力了。

实际开发中,我们通常将不需要被其他模块调用的全局变量或函数用 static 关键字来修饰,static 能够将全局变量和函数的作用域限制在当前文件中,在其他文件中无效。下面我们通过一个实例来演示。

module.c 源码:

1
2
3
4
5
#include <stdio.h>
static int n = 999;
static void say(){
printf("Hello World!\n");
}

main.c 源码:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
int n = 100;
void say(){
printf("http://c.biancheng.net\n");
}
int main(){
say();
printf("n = %d\n", n);
return 0;
}

运行结果:

1
2
http://c.biancheng.net
n = 100

我们在 module.c 中定义了变量 n 和函数 say(),它们都被 static 修饰,所以只在 module.c 中有效,在 main.c 中是不可见的。从输出结果也可以看出,main.c 使用了自己的变量和函数,而没有使用 module.c 中的。

使用 static 修饰的变量或函数的作用域仅限于当前模块,对其他模块隐藏,利用这一特性可以在不同的文件中定义同名的变量或函数,而不必担心命名冲突。

static 局部变量

static 除了可以修饰全局变量,还可以修饰局部变量,被 static 修饰的变量统称为静态变量(Static Variable)。

不管是全局变量还是局部变量,只要被 static 修饰,都会存储在全局数据区(全局变量本来就存储在全局数据区,即使不加 static)。

全局数据区的数据在程序启动时就被初始化,一直到程序运行结束才会被操作系统回收内存;对于函数中的静态局部变量,即使函数调用结束,内存也不会销毁。

注意:全局数据区的变量只能被初始化(定义)一次,以后只能改变它的值,不能再被初始化,即使有这样的语句,也无效。

请看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
int func(){
// 也可以不赋初值 0,静态数据区的变量默认初始化为 0
static int n = 0;
n++;
printf("Function is called %d times.\n", n);
return n;
}
int main(){
int i, n = 0;
for(i = 1; i<=5; i++){
func();
}
printf("n = %d\n", n);
return 0;
}

运行结果:

1
2
3
4
5
6
Function is called 1 times.
Function is called 2 times.
Function is called 3 times.
Function is called 4 times.
Function is called 5 times.
n = 0

我们在 func() 中定义了一个静态局部变量 n,它存储在全局数据区,func() 函数执行结束不会被销毁,下次调用继续有效。全局数据区的变量只能初始化一次,第一次调用 func() 时已经对 n 进行了初始化,所以再次调用时就不会重复初始化了,也就是说static int result = 0;语句无效。

静态局部变量虽然存储在全局数据区,但是它的作用域仅限于函数内部,func() 中的 n 在函数外无效,与 main() 中的 n 不冲突,除了变量名一样,没有任何关系。

*总结起来,static 变量主要有两个作用:

1. 隐藏

程序有多个模块时,将全局变量或函数的作用范围限制在当前模块,对其他模块隐藏。

2. 保持变量内容的持久化

将局部变量存储到全局数据区,使它不会随着函数调用结束而被销毁。*

一个比较规范的C语言多文件编程的例子

学习完C语言的基本课程,很多老师都会布置一个大作业,让同学们编写出学生信息管理系统。学生信息管理系统可以用来管理班级学生,对学生信息(包括姓名、性别、年龄、成绩等)进行增加、删除、更改、查询等操作。

该系统主要有两种实现方案:第一种将学生信息直接保存在二进制文件中,当需要查询或者更新时,要能够使用文件操作函数精确定位学生信息;第二种使用数据结构中的链表,将学生信息暂时缓存在链表,如果有变化,才更新到文件。

本节我们以第一种方案为例,搭建起学生信息管理系统的框架,将前面几节的知识综合运用起来。

创建一个文件夹,命名为 StuInfo,并添加以下几个文件:

可以看到,我们将所有的头文件放在了 include 目录,将所有的源文件放在了 module 目录,而 main.c 作为主模块,直接放在了 StuInfo 目录。下面是对各个文件的说明:

  1. main.c

程序主模块,执行入口,会调用其他模块中的函数。
2. stu.c 和 stu.h

该模块包含了程序的大部分代码,实现了对学生信息的增删改查操作。
3. tools.c 和 tools.h

包含了一些通用的函数,比如文件复制、获取文件大小、数据插入、数据删除等与文件操作有关的函数,这些函数不仅可以用于当前项目,也可以不加修改地移植到其他项目,通用性很强,不妨将它们称为工具型函数。
4. vars.c 和 vars.h

定义了一些全局变量。比如文件指针 fp、学生总数、文件长度等。
5. config.h

配置文件,几乎都是宏定义,比如规定了学号的最大值、姓名的最大长度、成绩的最大值、文件路径等。

下面给出了每个文件包含的简略代码(伪代码)。

main.c 源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
#include <stdlib.h>
#include "./include/config.h"
#include "./include/stu.h"
#include "./include/vars.h"
int main(){
init();
//addStuInfo();
//deleteStuInfo();
//findStuByID();
//findStuByName();
fclose(fp);
return 0;
}

stu.c 源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../include/config.h"
#include "../include/stu.h"
#include "../include/tools.h"
#include "../include/vars.h"
//初始化
void init(){
//打开文件
if( (fp=fopen(FILENAME, "rb+")) == NULL && (fp=fopen(FILENAME, "wb+")) == NULL ){
pause("Error on open %s file!", FILENAME);
exit(EXIT_FAILURE);
}
//获取文件长度
fileSize = getFileSize(fp);
}
//添加学生信息
void addStuInfo(){
//finsert(fp, nPreEleCount*stuSize, &stu, stuSize);
pause("提示:添加成功!按任意键返回...");
}
//删除学生信息
void deleteStuInfo(){
//fdelete(fp, index*stuSize, stuSize);
pause("提示:删除成功,按任意键返回...");
}
//修改学生信息
void alterStuInfo(){
//fwrite(&stu, stuSize, 1, fp);
pause("提示:修改成功,按任意键返回...");
}
//根据学号查询学生信息
void findStuByID(){
pause("错误:该学生信息不存在,查询失败!按任意键返回...");
}
//根据姓名查询学生信息
void findStuByName(){
pause("错误:没有查询到相关记录!按任意键返回...");
}
//根据成绩查询学生信息
void findStuByScores(){
pause("错误:没有查询到相关记录!按任意键返回...");
}
//显示所有学生信息
void showAllStu(){
pause("\n共有%d条学生信息,按任意键返回...", n);
}

stu.h 源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#ifndef _STU_H
#define _STU_H
//学生信息结构体
typedef struct _STU{
int id; //学号
char name[20]; //姓名
char sex[4]; //性别
int age; //年龄
float math; //数学成绩
float cn; //语文成绩
float en; //英语成绩
}STU;
//初始化
extern void init();
//学生信息增删改查
extern void addStuInfo();
extern void deleteStuInfo();
extern void alterStuInfo();
extern void findStuByID();
extern void findStuByName();
extern void findStuByScores();
extern void showAllStu();
#endif

tools.c 源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <conio.h>
#include <stdarg.h>
//获取文件大小(以字节计)
long getFileSize(FILE *fp){
long fsize;
//TODO:
return fsize;
}
//文件复制函数
long fcopy(FILE *fSource, long offsetSource, long len, FILE *fTarget, long offsetTarget){
long nBytes = 0; //总共复制了多少个字节
//TODO:
return nBytes;
}
//向文件中插入内容
int finsert(FILE *fp, long offset, void *buffer, int len){
//TODO:
return 0;
}
//从文件中删除内容
int fdelete(FILE *fp, long offset, int len){
//TODO:
return 0;
}
//暂停程序
void pause(const char *str, ...){
va_list vl;
char buf[500] = {0};
va_start(vl, str);
vsnprintf(buf, 500, str, vl);
va_end(vl);
printf(buf);
getch();
printf("\n");
}

tools.h 源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifndef _TOOLS_H
#define _TOOLS_H
extern long getFileSize(FILE *fp); //获取文件大小
extern long fcopy(FILE *fSource, long offsetSource, long len, FILE *fTarget, long offsetTarget); //文件内容复制
extern int finsert(FILE *fp, long offset, void *buffer, int len); //向文件中插入数据
extern int fdelete(FILE *fp, long offset, int len); //删除文件内容
extern void pause(const char *str, ...); //暂停程序
#endif
vars.c 源码:
FILE *fp = NULL; //文件指针
int stuCount = 0; //总共有多少条学生信息
long fileSize = 0; //文件长度(占用的字节数)
vars.h 源码:
#ifndef _VARS_H
#define _VARS_H
extern FILE *fp;
extern int stuCount;
extern long fileSize;
#endif

config.h 源码:

1
2
3
4
5
6
7
8
9
10
#ifndef _CONFIG_H
#define _CONFIG_H
#define FILENAME "stu.data" //保存学生信息的文件名称(含路径)
#define MAX_STU_ID 99 //学号的最大值
#define MAX_STU_NAME 10 //姓名的最大长度
#define MAX_STU_AGE 100 //年龄的最大值
#define MAX_STU_MATH 150 //数学成绩的最大值
#define MAX_STU_EN 150 //英语成绩的最大值
#define MAX_STU_CN 150 //语文成绩的最大值
#endif