目录
文章目录
多文件编程
如果只写一个小程序用于学习和测试,将所有代码写到一个源文件中无可厚非,一旦涉及到项目,几乎是不可能完成的事情,成千上万行的代码不仅难以阅读和维护,调试起来简直是自虐。我们需要将代码按功能分散到一个个小文件中并有序组织,一个或多个文件集中起来实现某个功能,称为模块。使用模块开发项目很有讲究,模块如何编写,编译器如何将模块合并成项目,模块如何重用以及处理模块之间的依赖关系都是要面对的问题,这其中还包含被编译器隐藏的底层工作,不同IDE构建项目的方式以及如何掌控编译的过程。万事开头难,我们还是从最简单的方式入手,然后采用理论加实践的方式层层深入。
项目分割
在项目中将一个大文件分割为诸多小文件是最简单直接的方式,我们常常将包含main()函数的文件称为主文件,其他文件称为模块文件,现在我们创建一个模块文件moudleA,然后在主文件中调用模块中的函数。
moudleA.c
#include<stdio.h>
int a=1;
void moudleTest()
{
printf("moudleA\n");
}
main.c
#include<stdio.h>
extern int a;
extern void moudleTest();
int main()
{
printf("%d\n",a);
moudleTest();
}
上面代码可以使用任何一款编译器或集成IDE环境生成,集成IDE环境最大的好处是能够帮你管理项目文件,以便于维护,当然如果你倾向于文本编辑器+编译器纯手工构建项目也能完成的相当出色,这取决你的习惯爱好。默认情况下,moudleA.c和main.c都会参与编译,由于项目需要访问模块中的内容,在main中我们用extern先声明了变量a和moudleTest()函数,聪明一点的编译器即使不进行函数声明也会在模块中寻找moudleTest()的定义,为了保险起见我们还是先进行声明。注意外部变量和函数必须先使用extern声明,如果在同一个文件中声明函数,extern可以省略,但变量声明依然需要加上extern。
避免命名冲突
学会分割文件后我们就可以与他人合作项目了,每个人可以编写项目中的一个模块,然后进行合成,但是在合成过程中会发现一个问题,如果两个模块中都定义了变量a或者主文件中定义了变量a,编译器就会报告定义重复,为了避免命名冲突可以使用关键字static将非全局变量限定到模块内,修改后的代码如下:
moudleB.c
int b;
void moudleBTest();
main.c
#include<stdio.h>
int b=1;
void moudleBTest()
{
printf("main");
}
int main()
{
printf("%d\n",b);
moudleBTest();
}
现在将moudleA中的a声明为模块变量,当main中也定义了变量a后就不会发生命名冲突了,globalA和moudleTest()由于没有使用static声明,仍然作为全局变量和函数使用。从我们学习编写模块开始就要区分模块变量和全局变量,注意函数中用static声明的变量不属于全局变量,它是保留局部变量为静态变量,不要将它们混淆了。
项目生成的过程
现在我们开始向下深入,一个项目生成的过程是怎样的呢?那些被编译器隐藏的步骤是什么?请看下图:
事实上,从源代码生成可执行文件分为四个步骤,分别是预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。为了简化对编译的理解,一些IDE开发工具将预处理、编译和汇编看作一个过程,甚至将链接也纳入编译过程,统称为编译。例如VS提供编译和链接两个命令,Dev C++只提供一个编译命令,但是它们在项目设置中又对预处理,编译和链接提供了很多设置选项,只有搞清楚隐藏在编译器后面的原理才能以不变应对万变。
预处理
我们编写的代码是不会直接进行编译的,因为里面包含大量的预处理命令,例如#include, #define等,必须经过预处理器处理后才会开始编译,处理的结果通常放入扩展名为i的文本中。预处理作的工作涵盖如下内容:
将所有的#define 删除,并展开所有的宏定义。
处理所有条件编译命令,比如 #if、#ifdef、#elif、#else、#endif 等。
- 处理#include命令,将被包含文件的内容插入到该命令所在的位置,这与复制粘贴的效果一样。注意,这个过程是递归进行的,也就是说被包含的文件可能还会包含其他的文件。
- 删除所有的注释//和/* … */。
- 添加行号和文件名标识,便于在调试和出错时给出具体的代码位置。
- 保留所有的#pragma 命令,因为编译器需要使用它们。
处理后的i文件不再包含宏命令,也不会包含注释,#include实际上是代码插入,如果在头文件中包含了变量或函数定义的代码,多次导入就会导致重复定义的错误。唯有#pragma命名会被保留,它告诉编译器如何编译代码,例如#pragma once表示这个文件只被包含一次,#pragma comment(lib,“XXX.lib”)表示导入静态库,一个强大的IDE工具可以通过界面设置编译参数,这是IDE的优势。如果对预编译文件有兴趣,可以用gcc生成可以查看的i文件,例如:
gcc -E demo.c -o demo.i
在vs中也可以设置保留i文件,将下面选项改为是,如图:
编译
编译就是对预处理后的i文件进行词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件。编译是整个程序构建的核心部分,也是最复杂的部分,涉及到的算法非常多,不在这里的讨论范围,详情可以查看《编译原理》这本书,可以说如果完全掌握了编译原理,你已经有能力发明一种新的语言和C语言竞争了。
汇编
汇编过程相对简单,没有复杂的算法,也没有语义,甚至不需要做指令优化,只是根据汇编语句和机器指令的对照表翻译就可以了。汇编的结果产生目标文件,在 GCC 下的后缀为.o,在 Visual Studio 下的后缀为.obj。
链接
到这里模块和主程序已经被编译成一个个的目标文件,链接就是将这些目标文件组织起来,形成一个可执行的二进制文件。目标文件和可执行文件都是二进制文件,然而链接的过程也相当复杂,是我们要讲解的重点。
语言发展的过程
为什么生成一个项目要进行如此复杂的过程,这要从语言发展的历史讲起,抛开那些已经被淘汰和淹没在历史长河中的编程语言,语言发展过程大致如下:
机器语言
计算机刚刚诞生时,没有所谓的编程语言,人和机器是直接对话的,用的是二进制编码,相当于我们直接用0和1编写一个可执行命令,在二进制编码中变量名、函数名等都是地址,运算符、流程操作都是指令,例如c=a+b用二进制表示为:
1010 0X1000 0X1004 //将两个数据相加的值保存在一个临时区域
1110 0X1008 //将临时区域中的数据复制到地址为 0X1008 的内存中
其中加法运算的机器指令为 1010,赋值运算的机器指令为 1110,机器语言除了难以记忆,还有一个更大的缺点是难以修改,假设有一种跳转指令,它的二进制形式为 0001,如果需要执行地址为 1010 的代码,那么可以这样写:
0001 1010
如果我们在地址 1010 之前插入了其他指令,那么原来的代码就得往后移动,上面的跳转指令的跳转地址也得相应地调整,这个调整操作称为重定位(Relocation),当程序结构发生变化时,程序员需要人工重新计算每个子程序或者跳转的目标地址,繁琐且容易出错,当程序包含成上千行代码时,这种黑暗的工作是无法容忍的。但是当年的程序员都是这么走过来的,是他们的付出才有了后来的计算机发展,后来他们一致认为,这种人给机器当奴隶的工作颠倒了角色,必须有一种适合人类的语言来解放生产力,以这种语言来编写程序,然后翻译成机器语言,于是汇编语言由此诞生了。
汇编语言
汇编语言使用接近人类语言的各种符号和标记来帮助记忆,它通过汇编器翻译成二进制,比如用 jmp 表示跳转指令,用 func 表示一个子程序的起始地址,这种符号方法使得人们从具体的机器指令和二进制地址中解放出来。将上面的机器指令使用汇编代码来书写是这样的:
jmp func
不管在 func 之前增加或者减少了多少条指令导致 func 的地址发生了变化,汇编器在每次汇编程序的时候都会重新计算 func 这个符号的地址,然后把所有使用到 func 的地方修正为新的地址,整个过程不需要人工参与,人们终于摆脱了这种低级的繁琐的计算地址的工作,符号(Symbol)这个概念也随着汇编语言的普及被广泛接受,它用来表示一个地址,这个地址可能是一段子程序的起始地址,后来发展为函数,也可以是一个变量的地址,在后面模块编程中也可以看到符号的威力,它是链接各个模块的关键元素。
C语言
虽然汇编为机器指令提供了助记符,但编写程序的思维方式还是面向硬件的,随着程序规模日渐庞大,汇编语言的缺点逐渐暴漏出来,由于是对硬件编程,程序员要考虑很多细节问题和边界问题,并且不利于模块化开发。为了摆脱硬件对程序员的束缚,将注意力集中到程序的逻辑上,人们发明了C语言。
高级语言
随着程序规模进一步扩大,C语言面向过程的方式也爆露出明显的缺陷:代码封装性不强,重用性不强,编写效率低下等,为了适应模块化要求更高的开发方式,人们发明了面向对象的高级语言,面向对象不在这里的讨论范围,这又是一个大课题,有兴趣可以尝试编写C++或java代码。
编译的本质
从语言发展历史可以看到,编译过程实际上是由高级语言逐步翻译成低级语言的过程,拿java来说,java代码被虚拟机翻译为C++语言,C++被翻译为汇编,汇编被翻译为机器语言。随着程序规模逐渐庞大,项目开发由单人开发走向多人开发,由面向过程走向面向对象,由单程序走向模块化。语言层次越高,开发效率越高,相反性能越低,权限越低,因此从开发效率上看java>c>汇编,从性能上看汇编>c>java。现代项目开发一般用c/c++这样的高性能程序编写底层、内核或驱动程序,应用层通常采用java,c#,pheon这样跨平台的程序,有时也将它们结合使用,例如对性能没有要求的文字处理、表单用开发效率高的pheon,对性能要求高的计算、搜索等用c/c++编写,然后用pheon调用它。
目标文件里藏着什么
编译过程中的i文件和汇编文件都可以打开查看(前提是你能看懂汇编代码),那么目标文件到底是个什么文件?它里面隐藏着什么?对我们来说一直是个谜,现在我们就来解开这个谜团。目标文件是一个二进制文件,它与可执行文件的组织形式非常类似,只是有些变量和函数的地址还未确定,程序不能执行,需要连接器将其中的地址重定位,然后组织到可执行文件中。一个目标文件被划分成多个部分,每个部分称为段(Section),大致结构如下:
可以看到目标文件中存放了模块类型,代码包含函数定义、全局变量、静态变量、常量等,还包含调试信息以及用于重定位的符号。应用程序也可以使用其它名字定义自己的段,比如可以插入一个叫做music 的段来保存 MP3 音乐,目标文件不仅包含代码,还可以包含图片、音乐、视频等多媒体资源。最重要一点是每个目标文件都是独立于其它文件的,如果目标文件引用了其它目标文件中的变量和函数,编译时编译器不会将引用的内容纳入进来,否则这些重复的定义不仅浪费空间还会发生冲突。
可执行文件
可执行文件在目标文件的基础上增加了一些段,并且链接成功后删除了可重定位的段,在程序执行后段被载入到对应的内存,如下:
需要说明的是操作系统并不是为每个段都分配一个内存区域,而是将多个具有相同权限的段合并在一起,加载到同一个内存区域。站在文件结构的角度,每个段存放代码的不同功能的数据,站在代码执行的角度,操作系统只关心数据的权限,将相同权限的数据加载到同一个内存区域以实现内存优化。具有相同权限的内存也称为段,但英文名字称为segment,一个 Segment 由多个权限相同的 Section 构成。
链接过程
了解目标文件和可执行文件的结构后,就可以揭开链接过程的秘密了,两种二进制文件之间就相差一个步骤——链接。链接由链接器完成,它要做的第一件事就是合并代码,将每个模块生成的目标文件中的段合并到可执行文件的段中,如下图所示:
在这个过程中,有用的段例如代码段、数据段等被合并到可执行文件相应的段中,无用的段例如重定位段、段表等会被删除,重定位段的地址会被重新调整。这个调整过程就依赖我们前面讲的符号,是我们要讲解的重点。
从设计上讲,链接器所做的主要工作跟前面提到的“人工调整地址”本质上没有什么两样,只不过现代的编译器和链接器更为复杂,功能更为强大。把指令中使用到的地址加以修正,这个过程称为重定位(Relocation)或符号决议(Symbol Resolution)。在C中代码不仅包含我们自己编写的源文件,还包含C标准库提供的库函数和头文件以及第三方库提供的内容,这个过程如图:
对于之前我们编写的模块moudleA和项目Project在编译时时是单独编译的,文件没有顺序可言,vs甚至可以开启多核编译同时编译多个模块,假设编译主函数main时moudletest()这个外部引用的函数地址是未知的,编译器会将函数地址设置为0,等到将目标文件moudleA.obj和Project.obj连接起来时,会使用汇编的mov指令将函数moudletest()的入口地址改为绝对地址,这就是重定位的底层实现。
链接关键因素——符号
在汇编代码中,函数和变量在本质上是一样的,都是地址的助记符,在链接过程中,它们被称为符号(Symbol)。链接器的一个重要任务就是找到符号的地址,并对每个重定位入口进行修正。符号可以看做是链接中的粘合剂,整个链接过程是基于符号完成的。目标文件中的段.symtab记录了当前目标文件用到的所有符号,包含:
- 全局符号,也就是函数和全局变量,它们可以被其他目标文件引用。
- 外部符号(External Symbol),也就是在当前文件中使用到、却没有在当前文件中定义的全局符号。
- 局部符号,也就是局部变量。它们只在函数内部可见,对链接过程没有作用,所以链接器往往也忽略它们。
- 段名,这种符号往往由编译器产生,它的值就是该段的起始地址,比如.text、.data 等。
在前面编写的代码中,模块moudleA中的globalA和moudleATest ()是全局符号,Project中globalA和moudleATest ()是外部符号,在目标文件Project.o中globalA和moudleA()的地址都是0,它们作为重定位入口(Relocation Entry)被记录到rel.text 和.rel.data中。当链接时,链接器首先扫描所有的目标文件,获得各个段的长度、属性、位置等信息,并将目标文件中的所有符号收集起来,统一放到一个全局符号表。然后链接器会将目标文件中的各个段合并到可执行文件,并计算出合并后的各个段的长度、位置、虚拟地址等。在目标文件的符号表中由于保存了各个符号在段内的偏移,生成可执行文件后,原来各个段起始位置的虚拟地址就确定了下来,这样使用起始地址加上偏移量就能够得到符号的重定位位置。最后链接器会根据重定位表调整代码中的地址,使符号它指向正确的内存位置,至此可执行文件就生成了。
强符号和弱符号
在C语言中将初始化了的全局变量和函数称为强符号(Strong Symbol),未初始化的全局变量称为弱符号(Weak Symbol)。强符号之所以强,是因为它们拥有确切的数据,变量有值,函数有函数体;弱符号之所以弱,是因为它们还未被初始化,没有确切的数据。链接器会按照如下规则处理强符号和弱符号:
- 不允许强符号被多次定义,如果有多个强符号,那么链接器会报符号重复定义错误。
- 如果一个符号在某个目标文件中是强符号,在其他文件中是弱符号,那么选择强符号。
- 如果一个符号在所有的目标文件中都是弱符号,那么选择其中占用空间最大的一个。
在dev C++中我们新建一个moudleB,代码如下:
moudleB.c
int b;
void moudleBTest();
main.c
#include<stdio.h>
int b=1;
void moudleBTest()
{
printf("main");
}
int main()
{
printf("%d\n",b);
moudleBTest();
}
运行代码会发现moudleB.c中定义的弱符号b和moudleBTest()被Project中的同名强符号代替了,dev c++使用的是gcc编译器,gcc将未初始化的符号视作如符号,还允许通过__attribute__((weak))强制定义为弱符号,现在修改代码如下:
moudleB.c
#include<stdio.h>
int b=2;
void moudleBTest()
{
printf("moudleBTest");
}
Project.c
#include<stdio.h>
int __attribute__((weak)) b=1;
void __attribute__((weak)) moudleBTest()
{
printf("main");
}
int main()
{
printf("%d\n",b);
moudleBTest();
}
在moudleB中将变量b和函数moudleBTest()声明为强符号,在main中使用__atribute__((weak))将同名变量和函数声明为弱符号,再次运行会发现主函数中的弱符号被模块中的强符号替换了。需要注意的是,attribute((weak))只对链接器有效,对编译器不起作用,编译器不区分强符号和弱符号,只要在一个源文件中定义两个相同的符号,不管它们是强是弱,都会报“重复定义”错误。弱符号功能类似面向对象中的多态,例如将库中的函数定义为弱符号,在主函数中进行改写从而增强代码的灵活性。
强引用弱和引用
目前我们定义的变量和函数,被链接成可执行文件时,它们的地址都要被找到,如果没有符号定义,链接器就会报符号未定义错误,这种被称为强引用(Strong Reference)。还有一种弱引用(Weak Reference),如果符号有定义,就使用它对应的地址,如果没有定义也不报错。在变量声明或函数声明的前面加上__attribute__((weak))就会使符号变为弱引用。例如:
#include<stdio.h>
__attribute__((weak)) extern int i;
//int i = 0;
int main(int argc, char **argv)
{
if (&i)
printf("i = %d\n", i);
return 0;
}
这段代码不一定在所有的gcc编译器中通过,因为现在的链接器更加严格,如果发现i未被定义仍然会报错,使用int i=0将弱引用改为强引用后即可通过编译。对比弱符号,弱引用强制要求覆盖而不是选择性覆盖。实际上VS并不支持弱符号和弱引用,因为强弱之分除了会使代码变得更加复杂,还会增加出错的机率,高版本的gcc对弱引用的支持也和原来不一样,由于不能通用,实际开发中很少用到。
静态库、动态库、可执行文件
在讲静态库之前我们先将前面要掌握的重点内容理一理,C语言由源代码转化为目标文件的过程可以统称为编译过程,由目标文件转化为可执行文件的过程称为链接过程,链接的过程就是对符号进行重定位,说的简单一点就是合并代码后修改变量和函数的地址。之前我们讲过可以将一个项目分割为不同功能的模块,每个模块由一个或多个目标文件组成,可以在项目中将目标文件放到一个子目录中来规划一个模块,但更好的方式是创建静态库和动态库,它们的封装能力更强,功能也更强。
静态库、动态库、可执行文件的概念可以追溯到Unix,COFF(Common File Format)是Unix V3 首先提出的规范,微软在此基础上制定了 PE (Portable Executable)格式标准并将它用于 Windows,后来 Unix V4 又在 COFF 的基础上引入了 ELF(Executable Linkable Format) 格式,被 Linux 广泛使用。从广义上讲,目标文件与可执行文件的存储格式相似,我们可以将它们看成是同一种类型的文件,在Windows 下,将它们统称为 PE 文件,在 Linux 下,将它们统称为 ELF 文件。另外,动态链接库DLL(Dynamic Linking Library)和.so,静态链接库lib(Static Linking Library)和.a也是按照可执行文件的格式存储的。在linux的ELF标准中,主要定义了以下四类文件:
ELF标准告诉我们,静态库和动态库都是编译好的二进制文件,它们实际上是多个目标文件的组合,再加上一些索引。动态链接库既可以由链接器将其中内容写进执行文件,又可以由动态链接器在运行时加入进程,是最灵活的方式。在程序运行之前确定符号地址的过程叫做静态链接(Static Linking),如果需要等到程序运行期间再确定符号地址,就叫做动态链接(Dynamic Linking)。可以看到我们之前说的模块,可以制作成静态库或动态库,发布成静态库或动态库后最大的一个优点是不用将源码提供给使用者,只要给出相应的的头文件,用户导入头文件后就可以使用了。静态库和动态库都只经历了编译过程,没有链接过程,只有将静态库和动态库合成到可执行文件中时才会执行链接,因此静态库和动态库可以作为可重复利用的资源单独发布,它可以和任何可执行文件或者说可执行项目无关。反过来,如果模块中包含了项目中的代码,那就失去了重用性,它只能用于该项目中,虽然这样的模块也大量存在,但是它们不能单独提供给其它项目使用。
静态库和动态库的优缺点
如何将项目有规划的分割成模块以适应增量式开发或分布式开发?哪些设计为静态库,哪些设计为动态库?模块之间如何降低耦合性以及模块之间如何协作?这是一个很大的课题,关乎到软件架构设计等专业领域,这里我们不进行深入探讨,C语言也不适合做大型项目开发,但我们至少需要知道静态库和动态的库的优缺点,以便在模块化编程中知道如何选择。
- 静态库的优缺点
静态库最大的优点是调用性能,静态库被链接到项目中后成为项目不可分割的一部分,随时可以调用而且不受环境变化的影响,因为需要调用的内容就在项目中,不依赖操作系统和安装环境。缺点是由于将所有内容都打包进项目,使得项目发布体积变得很大,启动变得很慢,而且占用内存多。然而最大的问题还不是体积问题,而是不利于代码修改,只要修改了某个静态库,对此依赖的模块和项目都需要重新发布。
- 动态库的优缺点
动态库最大的优点是灵活性,可以按照项目需求随时加载和卸载,这就节省了内存开销。不仅节省内存开销还减小发布体积,因为无需将动态库链接到项目文件中,这使得项目启动程序可以变得非常小,而且由于项目所需的动态库有一部分操作系统或安装环境会提供,因此不必纳入到项目安装文件中,这就减少了整个项目的发布体积。而这种灵活性付出的代价就是调用性能,因为动态库只有先载入内存中才能调用,如果载入动态库时间过长就需要用户等待,使得程序变得卡顿。另外一个缺点就是容易受到环境影响,因为项目一部分动态库依赖操作系统或安装环境提供,如果换一台计算机缺少这些支持就会导致程序不能运行,我们经常可以看到程序启动时提示缺少vc++运行库的错误,原因就是项目使用了VS某个版本开发,但是系统没有安装这个版本的运行库。
了解静态库和动态库的优缺点后我们就知道如何选择了,选择静态库还是动态库实际上是对项目进行性能和体积上的权衡,对于项目启动时必须加载的模块,或者这个模块在整个项目中都需要调用时可以选择静态库。否则应该选择动态库,因为动态库从整体上看优点多余缺点,现在的计算机硬件性能越来越强大,内存也越来越大,这使得动态库在载入性能上的影响越来越小,而且我们还可以通过在用户操作闲置时使用智能预加载技术进一步减少卡顿,这使得性能的影响微乎其微。另外一些大型的项目都有安装程序,可以在程序安装时将所需要的动态库装入操作系统,一些操作系统在系统更新时也会提供VS运行库的安装。综上所述动态库的使用比静态库更加普及,然而静态库也不是完全可以由动态库取代,它的内嵌方式因为简单可靠被广泛采纳,C语言标准库就是以静态方式提供的。
C标准函数库
C和C++标准函数库均以静态库提供,并提供了相应的头文件。linux一般将静态库和头文件放在/lib和/user/lib目录下,可以通过find命令查找:
windows中的标准库函数由IDE工具提供,在VS中的项目属性面板中可以找到,如图:
这里的库目录和包含目录分别放置库文件和头文件,它们被放置到多个位置。C标准函数库包含众多模块,比如标准输入输出库,文件库,数学函数库等,如果将它们全部打包到项目中那么项目体积将会相当可观,并且很多模块实际上用不上,因此编译器在链接标准库时仅链接使用到的模块,未使用到的模块不会纳入项目中,对于我们自己编写的模块也是这样,如果你编写了一个模块而没有用在可执行项目中,那么这个模块也是不会被链接到项目中的,这样即使静态库中的部分内容被链接到多个可执行项目中,也不会造成巨大的浪费。一个模块是否被使用,其标志为是否导入了该模块的头文件,如果头文件中有该模块的变量或函数的引用声明,那么该模块就会被链接到项目中,因此头文件成为模块的标配,静态库和动态库都必须配置头文件。
编写头文件
到这里所有的概念都讲完了,轮到将所学到的知识用到实践中了,在编写模块之前首先我们要学会为模块编写头文件。头文件描述该模块的接口,也就是使用引用声明的方式公布模块可以访问的变量和函数。我们知道#include是将代码复制到插入点,因此头文件一个最基本的原则是不要在里面放置定义的内容。另外一个问题是重复导入,因为模块中可以导入自身的头文件,头文件中也可以导入其它头文件,这就很难避免同一个文件中重复导入头文件的情况,因为导入是递归性的。在实际使用过程中头文件的作用还被放大了,头文件中不仅放置引用声明,还放置宏和别名的定义,多次导入宏和别名也不会引发冲突,因为它们不是定义,但反复编译相同的代码也没有必要,因此我们通常还会通过编译指令或条件编译防止头文件被反复编译。通常我们用#ifndef命令来防止头文件重复导入, 也可以用#pragma once,现在我们创建一个头文件moudleC.h来测试这条指令:
moudleC.h
#pragma once
int c;
main.c
#include<stdio.h>
#include"moudleC.h"
#include"moudleC.h"
int main()
{
printf("%d\n",c);
}
如果多次导入头文件编译不报错证明指令生效,如果报错说明编译器并不支持#pragma once指令,使用条件编译是保守可靠的方法,它通过宏限制编译次数,修改moudleC.h如下:
#ifndef _MOUDLEC_H
#define _MOUDLEC_H
int c;
#endif
再次测试会发现moudleC.h同样只会编译一次,虽然略显复杂但可以用于任何编译器,因为条件编译是所有编译器都支持的,唯一要注意的是宏__MOUDLEC__H名称必须是唯一的,通常我们用头文件名加下划线来命名。需要注意的是防止重复导入是指的在同一文件中防止头文件重复导入,由于宏只在一个文件中有效,ifndef中的宏换到别的文件中依然是未定义的,因此其它文件仍然会导入这个头文件代码并生效。
对于当前先进的编译器来说,即使不对函数进行引用声明,编译器也能找到它,甚至这个函数在另外一个模块中;但变量就不同了,外部变量必须使用extern引用后才能使用,在同一个文件中如果变量定义在当前使用代码之后也必须先用extern声明。在这种情况下假设我们编写的模块中只对外公开函数没有变量,此时不用导入头文件中的声明也可以通过编译,但为每个模块定义一个头文件是标准的做法,对于库开发来说,一些编译器发现头文件和源文件缺少之一会拒绝编译。另外模块是否有必要导入自身的头文件要视情况而定,如果头文件中除了引用声明,还包含模块需要的宏、别名等内容,这些内容外部也可能会用到,那么模块需要载入自身的头文件;如果头文件仅用于引用声明不包含其它内容则不需要导入。如果头文件用于模块自身和外部有差别,例如某个宏对于模块自身和外部意义不同,我们还需要通过条件编译来区分,这种用法在后面编写动态库时会看到。
编写静态库
如果我们编写的模块具有重用性,想将它制作为静态库给多个项目使用步骤是怎样的呢?这个问题没有标准答案,因为每个IDE工具都不相同,现在对几个流行的IDE环境进行讲解。
VS 2019
Virsual studio以其美观的界面、强大的功能、对编译过程详细入微的设置已成为编写c和c++的工业标准,这里使用的版本是VS 2019,我们先在VS中创建一个空项目MoudleTest,这里将解决方案和项目放在同一目录,如图:
在源文件中新建项,将下面代码写入主函数中:
MoudleTest.c
#include<stdio.h>
extern char m;
int main()
{
putchar(m);
moudleCTest();
}
此时缺少模块,vs会有错误提示。在解决方案中新建项目,