目录

文章目录

  • 目录
  • 多文件编程
  • 项目分割
  • 避免命名冲突
  • 项目生成的过程
    • 预处理
    • 编译
    • 汇编
    • 链接
  • 语言发展的过程
    • 机器语言
    • 汇编语言
    • C语言
    • 高级语言
  • 编译的本质
  • 目标文件里藏着什么
  • 可执行文件
  • 链接过程
  • 链接关键因素——符号
    • 强符号和弱符号
    • 强引用弱和引用
  • 静态库、动态库、可执行文件
  • 静态库和动态库的优缺点
  • C标准函数库
  • 编写头文件
  • 编写静态库
    • VS 2019
    • eclipse CDT
    • 使用dev C++创建静态库
  • 编写自己的调试库
  • 编写动态库
  • 动态库的加载方式
    • 隐式加载
    • 显示加载
  • 模块定义文件
    • 查看动态库中的内容
    • 使用eclipse CDT创建动态链接库
    • 使用dev c++创建动态库
  • dll调用事件
  • 修改项目类型
  • 模块依赖
  • 全局变量
  • 共享数据段
  • 总结

多文件编程

如果只写一个小程序用于学习和测试,将所有代码写到一个源文件中无可厚非,一旦涉及到项目,几乎是不可能完成的事情,成千上万行的代码不仅难以阅读和维护,调试起来简直是自虐。我们需要将代码按功能分散到一个个小文件中并有序组织,一个或多个文件集中起来实现某个功能,称为模块。使用模块开发项目很有讲究,模块如何编写,编译器如何将模块合并成项目,模块如何重用以及处理模块之间的依赖关系都是要面对的问题,这其中还包含被编译器隐藏的底层工作,不同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会有错误提示。在解决方案中新建项目,类型选择静态库,名称为moudleC如图:

此时会生成4个文件,如图:

我们要编写的内容在moudleC.cpp中,将moudleC.cpp改为moudleC.c,代码如下:

#include "pch.h"
#include "framework.h"char m = 'c';void moudleCTest()
{puts("moudleCTest");
}

在模块中定义了变量c和函数moudleCTest(),这是项目moudleTest要访问的内容,下面看看其它文件。pch.h和framework.h是VS生成的独有文件,用于加速静态库的编译速度,pch.h将不经常修改的头文件放入其中,如果没有修改VS编译时会跳过这些内容。framework.h里面定义了宏WIN32_LEAN_AND_MEAN,注释告诉我们它是利用这个宏排除极少使用的内容,pch.h自动载入framework.h。现在将stdio.h放入pch.h中,代码如下:

pch.h
// pch.h: 这是预编译标头文件。
// 下方列出的文件仅编译一次,提高了将来生成的生成性能。
// 这还将影响 IntelliSense 性能,包括代码完成和许多代码浏览功能。
// 但是,如果此处列出的文件中的任何一个在生成之间有更新,它们全部都将被重新编译。
// 请勿在此处添加要频繁更新的文件,这将使得性能优势无效。#ifndef PCH_H
#define PCH_H// 添加要在此处预编译的标头
#include "framework.h"
#include<stdio.h>
#endif //PCH_H

由于我们使用的是C语言,也需要将pch.cpp的扩展名改为c,pch.c是与pch.h对应的源文件,它的存在是为了保证编译成功,不用修改它。在新建模块时pch.h和framework.h被默认导入,现在我们要为模块添加头文件了,在头文件目录中新建项,类型选择头文件,如图:

VS在头文件第一行自动加入了#pragma once指令,但我们还是用传统的#ifndef方式以增加移植性。在moudleC.h中加上需要公开的接口,如下:

moudleC.h
#pragma once
#ifndef MOUDLEC_H_
#define MOUDLEC_H_
extern char m;
extern void moudleCTest();
#endif

由于变量需要被外部访问,必须加上extern,在菜单中选择生成生成moudleC或按快捷键ctrl+B即可生成静态库moudleC.lib,如果创建了用于测试模块的主项目MoudleTest并且它们属于同一个解决方案,为了方便使用VS会将moudleC.lib生成到主项目MoudleTest的Debug文件夹中;如果没有主项目会生成到moudleC的Debug文件夹中,这里头文件只包含模块内容的引用声明,因此模块自身无需导入它。

接下来如何在MoudleTest项目中添加静态库呢?如果没有IDE环境,我们必须使用如下代码将头文件和静态库加入到项目中,修改MoudleTest的代码:
MoudleTest.c

#include "../MoudleC/moudleC.h"
#pragma comment(lib,"Debug/moudleC.lib")extern char m;int main()
{putchar(m);moudleCTest();
}

测试代码,看看是否能够运行。这里使用#include导入moudleC的头文件,使用#pragma comment命令导入moudleC.lib,可以选择使用相对路径或绝对路径,如果是gcc可以直接向编译器添加编译参数来导入静态库,例如:
gcc moudleTest.c –I E:/VSSpace/moudleC/ -L E:/VSSpace/moudleC/Debug/ -l moudleC –o moudleTest.out
-I表示头文件路径,-L表示库路径,-l表示库名称,这里用的是绝对路径。有了IDE环境后,这些事情就可以通过图形界面代劳了。在解决方案资源管理器中,头文件、源文件、资源文件被分开存放,但这些目录仅仅辅助显示,并不代表存放路径,实际上头文件和源代码文件都放在项目中,资源文件默认路径为项目的Debug文件夹。现在右键点击资源文件,选择添加–>现有项,将Debug文件夹中的moudleC.lib加入到项目中,这个操作等同于为编译器添加编译参数,如图:

对于头文件来说,可以将moudleC.h导入到解决方案资源管理器的头文件目录中,但这仅仅是为了显示,不会自动生成#include命令,更不会复制moudleC.h到项目中,我们仍然需要使用#include将moudleC.h导入到项目。除了使用解决方案资源管理器,我们也可以配置项目属性来达到同样目的,现在我们在解决方案资源管理器中删除MoudleC.lib,打开项目属性面板,在链接器常规面板中将Debug作为附加目录添加进去,如图:

在链接器输入面板中将要导入的库名称添加进去,如图:

注释掉代码中的#pragma comment(lib,“Debug/moudleC.lib”),运行程序,可以发现效果相同,当VS找不到静态库时,会在配置的路径中寻找,这种方式虽不如前面明确,但它适合批量导入第三方开发的库。接着我们注释掉#include “…/MoudleC/moudleC.h”,发现程序依然能运行,这是因为当前先进的编译器即使不对函数进行引用声明,也能在内部或导入的外部库中找到其定义。最后我们注释掉extern char m,发现编译器报错,引用外部变量依然需要提前声明。

其实还有一种方法导入库文件,在常规选项中将moudleC项目作为附加库路径,如图:

这相当于将moudleC项目文件夹设置成了源代码文件夹,代码如下:

#include<stdio.h>
#include "moudleC.h"
#include "moudleC.c"int main()
{putchar(m);moudleCTest();
}

此时两个项目合并成了一个项目,moudleC.lib变得不需要了,两个项目再也不能分离,静态库失去了它的意义。如果只用#include "moudleC.h"导入头文件而不导入moudleC.c,moudleC.lib仍然作为链接导入,这样代码看起来就像这样:

#include<stdio.h>
#include "moudleC.h"int main()
{putchar(m);moudleCTest();
}

虽然现在moudleCTest的源文件路径中也包含moudleC.c,但VS会从moudleC.lib中优先选择已经编译好的目标文件而不是重新编译它。结合两种导入方式的优点,看起来使用moudleC就像标准库一样,这种方式在项目中最为普及。界面操作最后都会转化为了编译参数,我们可以结合这三种方式按照自己的喜好编译,IDE的好处是有图形化操作,还可以帮助我们管理依赖关系,例如果修改了静态库moudleC,每次都要重新生成moudleC.lib才能保证MoudleTest获取的是最新内容,能不能修改moudleC后自动告诉MoudleTest,然后编译MoudleTest的同时重新编译moudleC呢?当然可以,在解决方案资源管理器中为MoudleTest添加项目依赖,然后将MoudleC放在MoudleTest之前生成,这样每次编译MoudleTest时如发现moudleC被修改会按照生成顺序依次编译项目,如图:

现在尝试修改moudleCTest()中的输出内容,按F5运行项目发现显示结果随之发生变化,静态库自动被更新,如不生成依赖,需要手动一个个按顺序生成。项目依赖非常适合库和项目同时编写,如果改写了moudleC或moudleTest又不想一起生成,可以选择生成–>仅用于项目,选择单独生成moudleC或moudleTest。

eclipse CDT

eclipse是java开发的工业标准,它功能强大、快捷键使用方便、拥有众多插件,关键是开源受到用户青睐,CDT是eclipse用于编写c和c++的插件,虽然没有vs成熟,但也很强大,可以延续eclipse的优点,例如代码格式化等。现在我们来看看如何使用CDT如何创建静态库,如图:

编写库代码,如下:

moudleC.c
char m = 'c';void moudleCTest()
{puts("moudleCTest");
}moudleC.h
#pragma once
#ifndef MOUDLEC_H_
#define MOUDLEC_H_
extern char m;
extern void moudleCTest();
#endif

按ctrl+F9编译静态库moudleC,生成的库文件可以在项目浏览器的Archives目录看到,名为libmoudleC.a,实际存放在Debug目录中,如图:

新建MoudleTest项目,在项目属性面板中,打开Includes选项设置头文件路径和文件名,如图:

然后在Libraries选项中设置库路径和库名,如图:

从属性面板可以清楚的看到,第一个是设置源代码路径,第二个是设置库链接,这里要注意的是,库文件名为libmoudleC.a,但填入的名称不能包含前缀lib和扩展名a,这个问题是gcc编译器的规定,猜想gcc可能是为了降低文件重名的机率吧,但不加任何说明很让人头疼。现在我们创建用于测试的项目,如图:

代码如下:

 moudleTest.c
#include<stdio.h>int main()
{putchar(m);moudleCTest();
}

CDT似乎更加智能,设置源代码路径和链接后连头文件都自动帮我们导入了,对于项目依赖来说,让MoudleTest引用moudleC即可编译MoudleTest时连带编译moudleC,如图:

即使不引用moudleC,eclipse的快捷键ctrl+B就是编译所有项目,因此每次修改后按ctrl+B即可。要运行项目,先切换到Project,然后按ctrl+F11,选择本地调试后即可运行。

使用dev C++创建静态库

dev C++被誉为教学神器,可以用一句话来形容它:麻雀虽小,五脏俱全。小巧中透露着不平凡。使用dev C++也可以创建静态库和动态库,下面我们来创建一个静态库:
由于dev C++不能同时打开两个项目,我们只好先创建一个静态库项目,如图:

创建moudleC.c和moudleC.h两个文件,将前面CDT中库的代码复制进来,按F9编译可以生成库文件moudleC.a,这个文件可以在项目文件夹中找到。现在我们关闭当前项目新建一个C项目Project,如图:

在项目属性面板中将头文件路导入进来,如图:

设置要链接的文件moudleC.a,如下;

Dev c++不会自动帮我们导入头文件,此时项目需要手动导入moudleC.h,如下:

Project.c
#include <stdio.h>
#include"moudleC.h"int main()
{moudleCTest();printf("%d\n",c);
}

编译器会在包含文件目录添加的路径中找到moudleC.h,按F11即可运行代码测试结果。由于dev C++只能显示一个项目,要修改静态库只能先切换到moudleC项目,修改后重新生成a文件,然后再切换到Project项目测试,很不方便。

可以看到无论使用哪种IDE环境,都有设置源代码路径和设置链接这两个选项,IDE无非是使用图形界面帮助我们设置编译参数,指定到哪里寻找库文件和头文件,但万变不离其宗,藏在图形界面后面的原理是一样的。

编写自己的调试库

在编写项目时大多数时间都会花在调试上,最常用的调试方式是输出变量的值到控制台,但每次调用printf()输出数值都要编写格式符非常麻烦,我们可以编写若干函数快速输出基本类型的数值而不用键入格式符。对于数组来说每次都要编写循环输出数值也很麻烦,我们同样可以编写一个函数输出数组的所有元素。现在新建一个静态库,名称为MyLibs,以后如果有可以重用性的代码可以继续向里面添加,在库中添加DebugPrint.c和DebugPrint.h文件,代码如下:

DebugPrint.h
//此库用于加速数值到控制台//下面的宏可用于任何编译器
#pragma once
#ifndef MYLIBS_H_
#define MYLIBS_H_
#include "pch.h"
#include "framework.h"#define PD(X) printf("%d",X)
#define PDN(X) printf("%d\n",X)
#define PL(X) printf("%ld\n",X)
#define PLN(X) printf("%ld\n",X)
#define PF(X) printf("%f",X)
#define PFN(X) printf("%f\n",X)
#define PP(X) printf("%p",X)
#define PPN(X) printf("%p",X)
#define NN printf("\n")
#define PR(...) printf(__VA_ARGS__)//如果编译器支持泛型,可以使用如下宏
/**
#define P(x) _Generic(x, \
char:printf("%c",x),\
short:printf("%hd",x),\
unsigned short:printf("%hu",x),\
int:printf("%d",x),\
unsigned:printf("%u",x),\
long:printf("%ld",x),\
unsigned long:printf("%lu",x),\
float:printf("%f",x),\
double:printf("%f",x),\
short*:printf("%p",x),\
unsigned short*:printf("%p",x),\
int*:printf("%p",x),\
unsigned*:printf("%p",x),\
long*:printf("%p",x),\
unsigned long*:printf("%p",x),\
float*:printf("%p",x),\
double*:printf("%p",x),\
char*:printf("%s",x),\
default:printf("invariable type")\
)#define PN(x) _Generic(x, \
char:printf("%c\n",x),\
short:printf("%hd\n",x),\
unsigned short:printf("%hu\n",x),\
int:printf("%d\n",x),\
unsigned:printf("%u\n",x),\
long:printf("%ld\n",x),\
unsigned long:printf("%lu\n",x),\
float:printf("%f\n",x),\
double:printf("%f\n",x),\
short*:printf("%p\n",x),\
unsigned short*:printf("%p\n",x),\
int*:printf("%p\n",x),\
unsigned*:printf("%p\n",x),\
long*:printf("%p\n",x),\
unsigned long*:printf("%p\n",x),\
float*:printf("%p\n",x),\
double*:printf("%p\n",x),\
char*:printf("%s\n",x),\
default:printf("invariable type\n")\
)
**///输出数组中的元素,数组元素必须是基本数据类型
void printArr(void* arr, char* typeStr, int len);
#endifDebugPrint.c
#include "pch.h"
#include "framework.h"//输出数组中的元素,数组元素必须是基本数据类型
void printArr(void* arr, char* typeStr, int len)
{int i = 0;putchar('[');if (!strcmp(typeStr, "%hd")){short* p = arr;for (i = 0; i < len - 1; i++){printf(typeStr, p[i]);putchar(',');}if (i < len) printf(typeStr, p[i]);}else if (!strcmp(typeStr, "%hu")){unsigned short* p = arr;for (i = 0; i < len - 1; i++){printf(typeStr, p[i]);putchar(',');}if (i < len) printf(typeStr, p[i]);}else if (!strcmp(typeStr, "%d")){int* p = arr;for (i = 0; i < len - 1; i++){printf(typeStr, p[i]);putchar(',');}if (i < len) printf(typeStr, p[i]);}else if (!strcmp(typeStr, "%u")){unsigned* p = arr;for (i = 0; i < len - 1; i++){printf(typeStr, p[i]);putchar(',');}if (i < len) printf(typeStr, p[i]);}else if (!strcmp(typeStr, "%ld")){long* p = arr;for (i = 0; i < len - 1; i++){printf(typeStr, p[i]);putchar(',');}if (i < len) printf(typeStr, p[i]);}else if (!strcmp(typeStr, "%lu")){unsigned* p = arr;for (i = 0; i < len - 1; i++){printf(typeStr, p[i]);putchar(',');}if (i < len) printf(typeStr, p[i]);}else if (!strcmp(typeStr, "%f")){float* p = arr;for (i = 0; i < len - 1; i++){printf(typeStr, p[i]);putchar(',');}if (i < len) printf(typeStr, p[i]);}else if (!strcmp(typeStr, "%f")){double* p = arr;for (i = 0; i < len - 1; i++){printf(typeStr, p[i]);putchar(',');}if (i < len) printf(typeStr, p[i]);}else if (!strcmp(typeStr, "%c")){char* p = arr;for (i = 0; i < len - 1; i++){printf(typeStr, p[i]);putchar(',');}if (i < len) printf(typeStr, p[i]);}else if (!strcmp(typeStr, "%s")){char** p = arr;for (i = 0; i < len - 1; i++){printf(typeStr, p[i]);putchar(',');}if (i < len) printf(typeStr, p[i]);}else if (!strcmp(typeStr, "%p")){void** p = arr;for (i = 0; i < len - 1; i++){printf(typeStr, p[i]);putchar(',');}if (i < len) printf(typeStr, p[i]);}printf("]\n");
}

此后导入此库的项目可以使用更加方便的命令PD(),PF()等输出数值到控制台,对于支持泛型的编译器,可以去掉注释使用更加方便的P()和PN()。对于数组来说,只要告诉printArr()数组的类型和长度就可以很方便的输出数组的成员。

编写动态库

动态链接库是在程序运行时由动态链接器导入内存的,由于不参与编译,动态链接库如同一个黑箱,我们怎么才能获取里面的符号呢?这就需要动态链接库主动提供接口,那么如何描述接口呢?让我们先创建一个动态链接库,在VS中新建项目moudleD,类型为动态链接库,如图:


和静态库一样将所有cpp后缀改为c,在pch中导入stdio.h,相同操作不再赘述,现在编写moudleD的代码,如下:
#include “pch.h”

代码如下:

#include "pch.h"_declspec(dllexport) int d = 4;
_declspec(dllexport) void moudleDTest()
{printf("moudleDTest\n");
}void privateFunTest()
{printf("privateFunTest\n");
}

对于动态库来说,默认声明的变量和函数是外部不可见的,这和静态库刚好相反,如果需要公开接口,我们可以在函数之前添加_declspec(dllexport)命令而不是extern。_declspec是VS中的关键字(由于历史原因,在VS中前缀可以是一条也可以是两条下划线),它对c/c++标准进行扩充,这个关键字应该放在成员声明的前面。_declspec(dllexport)用于Windows中的动态库,将成员公布为外部可见,_declspec(dllimport)用于导入动态库中的成员,不使用_declspec(dllimport)也可以成功编译代码,但_declspec(dllimport)使得编译器可以生成更好的代码,因为它可以确定函数是否存在于dll中,使得编译器可以生成跳过间接寻址级别的代码,这些代码通常会出现在跨dll边界的函数调用中。在上面代码中我们公开变量d和函数moudleDTest(),另一个函数privateFunTest()不公开,我们也可以将需要公布的接口放到代码前面作为引用声明,例如:

moudleD.c
#include "pch.h"
#include "framework.h"_declspec(dllexport) int d;
_declspec(dllexport) void moudleDTest();int d=3;
void moudleDTest()
{printf("moudleDTest");
}void privateFunTest()
{printf("privateFunTest");
}
这样需要公开的接口一目了然,moudleD需要与之对应的头文件,如下:
moudleD.h
#pragma once
#ifndef MOUDLED_H_
#define MOUDLED_H_
_declspec(dllimport) int d;
_declspec(dllimport) void moudleDTest();
#endif

编译代码,打开Debug文件夹,会发现除了MoudleD.dll,还有一个MoudleD.lib,如图:

动态库的加载方式

根据ELF的定义,动态库可以当作静态库使用,也可以在程序运行时加载,而在运行时可以有两种加载方式:一种是在程序启动时加载,加载后项目可以直接访问动态库中的内容;一种是使用代码手动加载,必须在代码加载后才能使用动态库中的内容。我们先来看看把动态库当作静态库来加载有什么不同,将MoudleD.dll添加到资源文件中,然后将项目moudleD路径设为源代码路径,如图:

moudleTest测试代码如下:
#include<stdio.h>
#include"moudleD.h"int main()
{printf("%d",d);moudleDTest();
}

把动态库当作静态库使用和编译器有很大的关系,有些编译器支持有些会报错,其实将dll文件作为静态库加载意义不大,因为已经有静态库这种形式可以使用。

隐式加载

隐式加载指程序启动时自动加载动态库,当动态链接器发现可行性文件中包含动态链接库的lib时,会在程序启动时自动加载动态链接库,然后用动态链接库中的内容进行重定位。现在我们删除MoudleD.dll,换做moudleD.lib,如图:

可以根据需要添加依赖关系,重新编译运行可以看到结果。现在有一个问题,对于moudleD.h来说,如果其中包含模块有用的内容,则需要moudleD.c载入它,然而_declspec(dllimport)是给使用者的,moudleD.c需要_declspec(dllexport),此时需要区分是谁在使用这个头文件,可以使用如下方法区分:

moudleD.c
#include "pch.h"
#include "framework.h"
#define MOUDLED_C_
#include "moudleD.h"int d = 3;
void moudleDTest()
{printf("moudleDTest");
}void privateFunTest()
{printf("privateFunTest");
}moudleD.h
#pragma once
#ifndef MOUDLED_H_
#define MOUDLED_H_
#ifdef MOUDLED_C_
#define DLL_ _declspec(dllexport)
#else
#define DLL_ _declspec(dllimport)
#endif// MOUDLED_C_
DLL_ int d;
DLL_ void moudleDTest();
#endif

在moudleD中我们定义了一个宏MOUDLED_C_,它表示在模块中导入头文件,moudleD.h中判断MOUDLED_C_是否有效,有效执行#define DLL_ declspec(dllexport),否则执行#define DLL _declspec(dllimport),因此宏DLL_由模块导入时是公布接口,由外部导入时是导入接口。现在修改测试代码:

moudleTest.c
#include<stdio.h>
#include"moudleD.h"int main()
{printf("%d",d);moudleDTest();return 0;
}

运行程序,发现导入moudleD的头文件后可以访问模块内容了。如果不导入moudleD.h,会发现moudleDTest()可以调用,但变量d不能访问,这说明moudleD仅对于函数才能省略_declspec(dllimport)命令。

显示加载

显示加载指自己编写代码加载动态库,优点是可以控制加载的时机,缺点是要手写一大堆代码,动态库中的内容必须等待代码执行完毕后才能使用,现在将解决方案资源管理器中的moudleD.lib删除,修改moudleTest的代码如下:

#include<stdio.h>
#include<Windows.h>int test()
{return 1;
}int main()
{void* lib = LoadLibrary(L"moudleD.dll");//获取模块入口地址int* p = GetProcAddress(lib, "d");//获取变量地址printf("%d\n", *p);typedef void(*subfun)();subfun moudleDtest = (subfun)GetProcAddress(lib, "moudleDTest");//获取函数入口地址moudleDtest();FreeLibrary(lib);
}

首先需要导入Windows库,里面有我们要调用的函数和类型,我们通过LoadLibrary()加载外部动态链接库,它返回加载模块的入口地址,类型是一个void*,加载失败返回NULL。动态库会被加载到程序内存的动态链接库区域,这个区域是专门为动态库准备的,它独立于其它区域。获取模块入口地址后,就可以通过GetProcAddress()寻找模块中的变量和函数地址了,这个函数有两个参数,一个是模块入口地址,一个是要寻找的成员名(也可以是序号),如果找到返回成员内存地址,找不到返回NULL。难点在于GetProcAddress()的返回类型FARPROC,这是一个别名,它的定义是:
typedef int (WINAPI FARPROC)();
可以看出这是一个函数指针,当我们获取变量d的地址时将它强制转化为int
类型,当获取函数moudleDTest()的入口地址时,我们先用typedef void(*fun)()将moudleDTest()的类型定义为别名fun,因为在C中只能用别名表达函数的类型,定义别名后将函数指针强制转换为fun,指定给moudleDtest,此后可以用(*moudleDtest)()调用函数,对于VS来说也可以直接用moudleDtest()调用函数。LoadLibrary()和GetProcAddress()是两个宏,它们的返回类型分别是HINSTANCE和FARPROC,用它们来描述返回值会更加合适,如下:

#include<stdio.h>
#include<Windows.h>int test()
{return 1;
}int main()
{HINSTANCE lib = LoadLibrary(L"moudleD.dll");//获取模块入口地址,HINSTANCE是void*int* p = GetProcAddress(lib, "d");//获取变量地址printf("%d\n", *p);FARPROC moudleDtest = GetProcAddress(lib, "moudleDTest");//获取函数入口地址moudleDtest();FreeLibrary(lib);
}

HINSTANCE就是一个void*,FARPROC是函数指针类型,对于VS来说FARPROC可以自动转为基本数据类型,但对于gcc就不行了,这里不用自己定义函数别名了,可以直接调用moudleDtest()。由于moudleD中函数privateFunTest()并未公开,如果调用GetProcAddress()会返回NULL。如果不再使用动态链接库了,可以调用FreeLibrary()卸载它。显示加载无需导入动态库的头文件,因为载入moudleD和重定位操作都是运行时用代码手动实现的,但由于每个动态库都需要支持两种方式加载,因此头文件必不可少。

模块定义文件

dll还有一个优势,就是可以由多语言生成,例如c,c++,c#都可以生成dll,但也带来了麻烦,那就是不同编译器编译时会对符号名进行重命名,这是因为C++标准并没有规定Name-Mangling方案,这导致不同编译器编译出来的目标文件是不通用的,例如Borland C++跟Mircrosoft C++就不同,好在C标准规定了C语言的Name-Mangling规范,因此C编译器编译出来的动态链接库是可以共享的。除了C++和C的区别,还要考虑调用约定导致的Name Mangling,__stdcall的调用方式会在原来函数名上加上表示参数的符号,而__cdecl则不会附加额外的符号。为了使得dll可以通用些,很多时候都要使用C的Name-Mangling方式,例如C和VC编译器都是采用__cdecl约定,即不进行重命名。如果要给Win32汇编使用(或者其他的__stdcall调用方式的程序),那么就可以使用__stdcall。GetProcAddress()函数是根据编译后的文件查找函数的,因此需要传入Name-Mangling后的函数名,我们这里编写的是C语言,又在同一个编译器下工作,因此不会有命名问题,当载入第三方dll时就可能出现这个问题了。

现在我们可以讨论模块定义文件def了,它是解决命名混乱的救星,当使用def文件时,编译器将以def中的名称进行重命名,而且使用def后也不用__declspec(dllexport)命令了,def文件文件格式如下:
LIBRARY [dll文件名]
EXPORTS
[函数名] @ [函数序号]
[函数别名]=[函数名]
dll名称必须跟生成的dll名称一样,可以在函数后面添加序号,该序号可用于GetProcAddress()函数根据序号查找,也可以不加序号。现在我们去掉__declspec(dllexport)命令,为moudleC编写一个def文件,在VS中新建def文件,如图:

moudleC.def的内容如下:

LIBRARY moudleDEXPORTS
d
moudleDTest

将模块定义文件指定给动态链接库项目,如图:

现在删除moudleD中的#include “moudleD.h”,不再用_declspec(dllexport)公布接口,如下:

MoudleD.c
#include "pch.h"
#include "framework.h"int d = 3;
void moudleDTest()
{printf("moudleDTest");
}void privateFunTest()
{printf("privateFunTest");
}

重新编译moudleD,运行后发现运行效果相同。如果动态库没有使用moudleD.def也没有使用_declspec(dllexport)公布接口,那么这个动态库对外不可见,也不会生成moudleD.lib文件。除了编写代码调试,我们如何能够查看动态库接口呢?请接着往下看。

查看动态库中的内容

当我们因为粗心,或者重命名的原因获取不到动态库的接口时,不必苦苦寻找,可以拿出法宝dumpbin命令来查看dll中的内容,首先在命令行中进入到VS的安装目录下,运行一个名为VCVARS32.bat(64位请使用VCVARS64.bat)的批处理程序,它可以为dumpbin命令设置临时环境变量方便使用,我这里的路径为:
D:\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build
但更简单的方法是直接找到dumpbin.exe所在的位置,然后将路径设置为环境变量,dumpbin.exe分为64位和32位,按自己的需要添加,如下:

之后输入dumpbin –exports moudleD.dll即可查看其中的函数名,如图:

这里列出了函数的序号、地址和名称,当我们忘记使用_declspec(dllexport)导出函数或def中的内容有错误时,可以用dumpbin查看导出的动态库内容,如果缺少函数说明没有导出成功,如果函数名和我们想的不一样是因为编译器对函数名进行了修饰。

使用eclipse CDT创建动态链接库

使用CDT创建和导入动态链接库比VS简单很多,因为gcc不将动态库分为两个文件,也不需要公开接口,现在在CDT中新建一个动态链接库项目moudleD,如图:

然后为模块添加代码和头文件:

moudleD.c
#include<stdio.h>
int d = 4;void moudleDTest()
{printf("moudleDTest");
}void privateFunTest()
{printf("privateFunTest");
}
moudleD.h
#pragma once
#ifndef MOUDLED_H_
#define MOUDLED_H_
extern int d;
extern void moudleDTest();
#endif

编译项目,发现生成的文件名为libmoudleD.dll,gcc会对生成的库文件自动添加前缀lib。新建测试项目Project,在Project中导入头文件和库文件,这里的库文件就是dll自身,相当于moudleD.lib,用于隐式加载,如图:

这里模块名写MoudleD,不需要将前缀加上去。将libmoudleD.dll复制到Project的Debug中,这里的libmoudleD.dll用于动态加载,然后在Project中调用dll中的变量和函数:

Project.c
#include<stdio.h>
#include<Windows.h>
#include"moudleD.h"int main()
{moudleDTest();printf("%d\n", d);
}

运行程序可以发现dll中的变量和函数都可以访问,使用我们的法宝dumpbin命令查看gcc生成的dll,如图:

gcc自动将dll中所有的符号都公开了,这让人欢喜让人忧,欢喜的是步骤简单,忧的是把我们并不想公开的符号也公开了。如果不想公开privateFunTest(),可以将它声明为static,如下:

moudleD.c
#include<stdio.h>
int d = 4;void moudleDTest()
{printf("moudleDTest");
}static void privateFunTest()
{printf("privateFunTest");
}

重新编译后测试代码,发现privateFunTest()被禁止访问。如果要为moudleD定义模块文件,可以在Shared Library Settings中设置,如图:
实际上在创建动态库时,eclipse CDT已经为我们创建了def文件,只是这里没有指定。库名的前缀可以通过属性面板修改,如图:

这里每次修改moudleD后都要手动将dll文件复制到Project的Debug目录,能不能像VS那样将库发布目录设置为Project的Debug文件夹呢?很可惜eclipse CDT还没有这个功能,要实现这个功能只能使用Makefile,但不是这里要讨论的范围。

使用dev c++创建动态库

最后我们看看如何使用dev c++来创建动态库,新建动态库项目moudleD,如图:

新建moudleD.h和moudleD.c,将CDT中的代码分别拷贝进来,然后发布项目,发现项目生成moudleD.dll和libmoudleD.a两个文件,确定生成后将moudleD.dll拷贝到Project目录下,然后打开Project项目,在属性面板中将moudleD.h和libmoudle.a分别导入,如图:


按F11即可运行项目测试结果,CDT和dev c++都使用gcc编译器,在项目中都可以找到moudleD.def文件。对比3种IDE工具,VS和Dev c++都将动态库打包为两个文件,只有eclipse CDT将dll既作为动态库载入又作为提取接口的存档。其实不难发现,在dev c++中将链接的libmoudleD.a改为libmoudleD.dll同样可以通过编译,gcc都能从dll文件中取出符号,但并非静态载入,因为删除exe下的moudleD.dll会报告不能加载的错误。

dll调用事件

当我们用VS新建一个动态库时,可以发现IDE自动为我们创建的代码:

BOOL WINAPI DllMain(HINSTANCE hinstDLL,  // handle to DLL moduleDWORD fdwReason,     // reason for calling functionLPVOID lpReserved )  // reserved
{// Perform actions based on the reason for calling.switch( fdwReason ) { case DLL_PROCESS_ATTACH:// Initialize once for each new process.// Return FALSE to fail DLL load.break;case DLL_THREAD_ATTACH:// Do thread-specific initialization.break;case DLL_THREAD_DETACH:// Do thread-specific cleanup.break;case DLL_PROCESS_DETACH:// Perform any necessary cleanup.break;}return TRUE;  // Successful DLL_PROCESS_ATTACH.
}

这些代码有什么作用呢?我们知道动态库可以在程序运行时根据项目需求加载和卸载,但加载和卸载是需要时间的,我们如何知道模块已经加载成功,或者卸载成功呢?这需要一个事件机制,VS提供的这些代码就是dll加载和卸载的事件。DllMain()是一个事件函数,如果在dll中定义了它,在dll加载和卸载时操作系统会调用它。先来看一下这个函数的参数:
HINSTANCE hinstDLL
该dll实例的句柄,也就是dll载入后的模块地址。
DWORD fdwReason
此参数标示了调用DllMain函数的原因,有四种值,就是函数中case后的取值,各个取值的含义稍后论述。
LPVOID lpReserved
保留的值。

现在我们来讨论一下fdwReason的四种取值,这些取值也直接反映了操作系统会在何种情况下调用DllMain。

  • DLL_PROCESS_ATTACH

    当系统第一次将一个dll映射到进程地址空间中时会调用DllMain,并为fdwReason传入DLL_PROCESS_ATTACH。注意,只有在第一次映射的时候,才会这样。如之后另一线程再次显式加载此dll,则操作系统只是增加该dll的使用计数而不会再次使用DLL_PROCESS_ATTACH来调用DllMain。对DLL_PROCESS_ATTACH的处理代表了dll的初始化。DllMain的返回值也是针对DLL_PROCESS_ATTACH消息的,对于其余的三种取值不起作用。对于隐式加载,如DllMain返回FALSE则程序会启动失败;对于显式加载,则会使LoadLibrary()返回NULL。

  • DLL_PROCESS_DETACH

    当系统将一个dll从进程地址空间中撤销映射时则会向DllMain传入DLL_PROCESS_DETACH,我们应当在此处放置一些清理代码。当使用FreeLibrary()时,如该线程的使用计数为0,操作系统才会使用DLL_PROCESS_DETACH来调用DllMain。如使用计数大于0,则只是单纯的减少该dll的计数。

  • DLL_THREAD_ATTACH

    当进程创建一个线程,则系统会检查当前已映射到该进程空间中的所有dll映像,并用DLL_THREAD_ATTACH来调用每个dll的DllMain。只有当所有dll都完成了对DLL_THREAD_ATTACH的处理后,新线程才会执行它的线程函数。另外主线程不可能用DLL_THREAD_ATTACH来调用DllMain,因为主线程必然是在进程初始化的时候,用DLL_PROCESS_ATTACH调用DllMain的。

  • DLL_THREAD_DETACH

    线程若要终止,会调用ExitThread,但是此函数不会立即终止线程,而是会利用DLL_THREAD_DETACH来调用当前进程地址空间中的所有dll镜像的DllMain。当每个dll的DllMain都处理完后,系统才会真正的结束线程。

这四个事件很好理解,前面两个事件用于dll进程中的加载和卸载,后面两个事件用于线程,当dll被多次加载时不会反复载入内存,只会增加加载计数;当dll被卸载时不会马上从内存中卸载,只有计数变为0时才会被卸载。下面我们看一下什么是DllMain的序列化调用,举个例子:
进程中有两个线程A与B,进程的地址空间中映射了一个名为SomeDll.dll的dll,两个线程都准备通过CreateThread来创建另两个线程C和D。当线程A调用CreateThread来创建线程C的时候,系统会用DLL_THREAD_ATTACH来调用SomeDll.dll的DllMain,当线程C执行其中代码的时候,线程B也调用CreateThread来创建线程D。这时系统同样会用DLL_THREAD_ATTACH来调用SomeDll.dll的DllMain,这次是让线程D来执行其中的代码,但是此时系统会对DllMain执行序列化,它会将线程D挂起,直至线程C执行完DllMain中的代码返回为止,当C线程执行完DllMain中的代码并返回时,可以继续执行C的线程函数,此时系统会唤醒线程D,让D执行DllMain中的代码,当返回后,线程D开始执行线程函数。换句话说两个线程同时调用dll时会有先后,系统会自动排序,后面的线程调用只有等之前的调用结束后才会调用。现在我们在VS中修改moudleD中的代码,让模块报告载入和卸载事件,修改代码如下:

#include "pch.h"
#include "framework.h"
#define MOUDLED_C_
#include "moudleD.h"int d = 3;
void moudleDTest()
{printf("moudleDTest");
}void privateFunTest()
{printf("privateFunTest");
}BOOL APIENTRY DllMain(HMODULE hModule,DWORD  ul_reason_for_call,LPVOID lpReserved
)
{switch (ul_reason_for_call){case DLL_PROCESS_ATTACH:puts("process attach");break;case DLL_THREAD_ATTACH:puts("process attach");break;case DLL_THREAD_DETACH:puts("thread deach");break;case DLL_PROCESS_DETACH:puts("process deach");break;}return TRUE;
}#include "pch.h"
#include "framework.h"
#define MOUDLED_C_
#include "moudleD.h"int d = 3;
void moudleDTest()
{printf("moudleDTest");
}void privateFunTest()
{printf("privateFunTest");
}BOOL APIENTRY DllMain(HMODULE hModule,DWORD  ul_reason_for_call,LPVOID lpReserved
)
{switch (ul_reason_for_call){case DLL_PROCESS_ATTACH:puts("process attach");break;case DLL_THREAD_ATTACH:puts("process attach");break;case DLL_THREAD_DETACH:puts("thread deach");break;case DLL_PROCESS_DETACH:puts("process deach");break;}return TRUE;
}

重新编译模块和项目文件,此时可以看到process attach和process deach事件被触发, 这里我们并没有创建线程,因此没有线程消息。

修改项目类型

当我们使用IDE编写代码时,项目类型其实是可以修改的,在VS中如下:

eclipse CDT中如下:

dev c++中如下:

如果动态库使用隐式加载,可以轻松的将静态库和动态库相互转换,将可执行文件转换为库通常发生在这个项目具有了重用性,例如将一个exe转化为activeX控件,或者这个项目被纳入到一个更大的项目中,此时应该屏蔽项目中的主函数,以免与新项目中的主函数发生冲突。

moudleD.c
#include<stdio.h>
int d = 4;void moudleDTest()
{printf("moudleDTest");
}void privateFunTest()
{printf("privateFunTest");
}int main()
{puts("main");return 0;
}

模块依赖

当我们使用模块化编程时,可以将实现某个功能的代码封装到模块中,模块可以制作成静态库也可以是动态库,动态库可以隐式调用也可以显示调用。如果这些模块都各自独立,只提供给可执行文件使用,那么这种结构最为理想,但这种理想结构几乎不会出现,因为许多模块具有重用性,一个模块不仅可以提供给项目使用,还可以提提供给其它模块使用,因此模块与模块之间可能相互调用,存在依赖关系,如图:

项目Project里面调用了静态模块libA和动态模块dll F中的内容,而lib A又调用了lib B和lib C中的内容,dll F依赖lib E和dll D中的内容,所有模块又都调用了C标准库中的函数,这样就形成了模块之间的依赖,这种具有层次的依赖关系是项目中典型的结构,然而作为一个设计混乱的结构甚至连层级结构都没有,例如lib A和lib B之间也存在依赖关系,lib B和lib C互相依赖,lib E甚至调用了项目中的成员以至于和Project形成相互依赖关系,混乱的局面会导致模块与模块之间耦合性极高,修改时牵一发动全身,项目修改成本极高,因此我们在模块化开发时要致力于降低模块之间的耦合性,否则根本无法进行分布式开发。这里面会有一个问题:在模块出现相互依赖时,会不会出现冲突?相同内容会不会被重复编译?链接时遇到相同内容时会如何处理?这些问题都可以从前面讲述的概念中找到答案。前面我们讲过,每个c文件都被编译成不依赖其它文件的独立目标文件,虽然在文件中用#include引用了其它头文件的内容,但由于头文件仅包含内容声明,其定义是不会编译进来的,静态库和动态库是多个目标文件的集合,因此它们也是独立编译的,它们与可执行文件的关键区别是没有链接过程,因此模块之间不管如何相互依赖,都是独立编译的,不会将其它模块中的定义纳入进来。也因为这个原因,如果你要将该模块独立发布给其它用户使用,也要将此模块依赖的模块一起提供给使用者,否则会报缺少定义的错误。我们再来看看链接过程,之前讲过只有发布可执行文件时才会有链接过程,在项目链接过程中,只有用到的目标文件才会被链接,没有用到的目标文件不会合并到项目中。其实还有一点需要补充,那就是在链接过程中如果发现目标文件已被链接,再次链接时会跳过该目标文件,这种情况通常发生在相同的源文件同时放到两个不同的库中编译,例如模块MoudleB和MoudleC需要依赖MoudleA,但我们没有将MoudleA制作成一个静态库项目,而是将MoudleA的源码文件MoudleA.c直接放到MoudleB和MoudleC中一同发布,此时MoudleB.lib包含MoudleB.o和MoudleA.o两个目标文件,MoudleC.lib包含MoudleC.o和MoudleA.o两个文件,其实对于库项目来说,不管模块是否使用了MoudleA中的内容,都会将MoudleA一起发布出去,是否链接MoudleA那是可执行文件的事。而当项目Project执行链接时,如果在链接MoudleB中的目标文件时已经纳入了MoudleA.o,在链接MoudleC中的目标文件时发现了同名的目标文件MoudleA.o会直接跳过以避免重复链接。对于链接过程,如果发现目标文件中有同名的变量或函数一定会报重复定义的错误,因为产生了同名符号链接时就无法进行重定位,如果收到此错误不要怀疑模块或项目中的目标文件被链接多次,而是模块或项目中的定义发生了同名冲突,需要通过重命名来解决。然而链接只是对于静态库而言的,动态库是在运行时加载的,没有链接过程,这样问题就来了,由于链接过程是编译器处理冲突的关键步骤,没有了这个步骤后相同的目标文件会被重复载入到内存,更可怕的是还会出现同名的变量和函数,对于显示加载的动态库,我们可以对动态库中的成员进行重命名,但对于隐式加载的动态库就麻烦了,如果加载的两个动态库有同名函数,编译器对该函数的调用都将指向第一个加载的动态库的同名函数,如果加载的动态库函数与文件中的函数同名,文件中的函数会覆盖动态库中的同名函数,而且不同的编译器可能处理方式也不同,这就会导致程序执行混乱,面临崩溃的危险,因此隐式加载动态库是有风险的,它有可能会破坏程序的运行, c++中有了命名空间,可以改善这种情况,但动态加载仍然不如静态库可靠。

全局变量

现在我们知道一个项目实际上由静态库、动态库及格各类资源组成,一个项目的运行由操作系统、安装环境和项目自身提供的模块协作运行。如果一个模块只封装了函数,那么它是纯功能性的,例如标准库中的数学函数库;如果模块中还定义了公开的变量,那么这个模块就具有了状态,具有状态的模块可以通过全局变量交换数据进行通信,然而这样会导致新的问题:如果模块和依赖该模块的其它调用受到该模块中定义的全局变量的影响,就会造成状态污染,因为状态改变会影响所有的调用结果,前面说过如果模块之间相互依赖会增加耦合性,如果再加上状态污染会进一步加大项目修改的难度和调试难度,导致隐藏的bug,基于这个原因我们除了需要减少模块之间的依赖,还要尽量避免在模块中定义全局变量。但在项目开发中不可能不用到全局变量,我们该如何定义全局变量呢?通常我们会将所有的全局变量集中放到一个模块中而不是项目中,要避免模块反过来访问项目形成双向依赖,这样每个模块都不能从项目中单独分离出去了。用一个模块存放所有全局变量是一个很好的方式,让其它模块都使用这个模块进行通信,这样既可以避免全局变量过度分散,又可以避免状态污染,还可以减少模块之间的依赖。存放全局变量的模块必须是静态库,动态库放置全局变量意义不大,如果将全局变量放入动态库中,那么这个动态库就不能卸载了。

共享数据段

如果一个项目还需要依赖调用其它项目来完成任务,并且需要获取执行返回的结果,那么全局变量也不能作为通信的手段了,这就需要进程之间的通信,我们知道程序中的地址实际上是操作系统分配的虚拟地址,这是为了让各个程序在内存段中各自运行互不影响,因此操作系统实际上默默的在各个进程之间筑起了一道墙,而动态库可以被多个进程共享,那么当这些进程载入同一个动态库时会破坏这道墙吗?非也!动态库中的变量都归它的进程或线程所有,当进程载入动态库时会将动态库中的变量映射到自己的私有空间,而且这种映射实际上是复制,也就是说每个进程所拥有的都是这个变量的副本,它们之间并不同步。换句话说动态库中默认只保存模块的定义而不是模块的状态,那么如何让多个进程共享动态库中的变量呢?可以使用共享数据段,共享数据段是在动态库中指定一个共享段,让多个进程使用这个变量的真实地址而不是创建副本,这样一来不同进程调用动态库时其值都是同步的,进程之间使用这个同步的变量进行通信,现在我们创建一个动态库MoudleE来演示共享数据段:

MoudleE.h
#pragma once
extern int globalG;
extern int getglobalG();
extern void setglobalG(int g);MoudleE.c
#include "pch.h"#pragma data_seg("share")
_declspec(dllexport) int globalG = 0; //这里一定要初始化
#pragma data_seg()#pragma comment(linker, "/SECTION:share,rws")//设置权限_declspec(dllexport) int getglobalG()
{return globalG;
}_declspec(dllexport) void setglobalG(int g)
{globalG = g;
}

将要定义的全局变量放在两个#pragma data_seg之间,#pragma comment(linker, “/SECTION:share,rws”)设置共享数据段的名称为share,linker表示链接地址而不是复制,rws表示读、写和共享权限,如果不设置共享权限共享段将不起作用,也可以在项目属性中设置共享段权限,如图:

还可以使用模块定义文件设置权限:
LIBRARY
DATA READ WRITE
SECTIONS
share READ WRITE SHARED

这里DATA READ WRITE表示将要创建读写数据
SECTIONS
share READ WRITE SHARED
表示创建共享数据段shar,并将share设置为读写和共享。使用dumpbin命令查看生成的MoudleE.dll,可以看到除了公布的接口还多了共享段share,如图:

在MoudleTest中导入MoudleE库,修改MoudleTest的代码,尝试修改全局变量:
MoudleTest

#include<stdio.h>
#include "MoudleE.h"int main()
{for (int i = 0; i < 5; i++){setglobalG(i);system("pause");printf("%d\n", getglobalG());}
}

在命令行同时运行MoudleTest的两个进程,在两个命令窗口中交换按键,查看跳动结果,如图:

共享数据段也常常用于记录执行程序打开的进程数,例如将程序进程限制为1个,如下:

Demo.c
#include<stdio.h>#pragma data_seg("share")
_declspec(dllexport) int openCount = 0; //这里一定要初始化
#pragma data_seg()#pragma comment(linker, "/SECTION:share,rws")//设置权限int main()
{if (openCount > 0){printf("%d\n",openCount);puts("只能打开程序的一个实例");return 0;}else{printf("%d\n", openCount);puts("正常打开");for (int i = 0; i < 5; i++){openCount++;system("pause");}}
}

dumpbin也可以查看exe的属性,打开命令行,先用dumpbin输出Demo.exe的结构,然后运行2个实例查看结果,如下:

总结

多文件编程是迈向项目开发的必经之路,如果搞不清楚项目生成的原理,会被IDE开发工具各种面板设置弄的不知所措,相反如果搞清楚了底层原理,则万变不离其中,即使遇到一个陌生的IDE也是轻车熟路。模块编程还需要解决全局变量,模块之间的依赖、通信问题。选择静态库还是动态库需要在可靠性和灵活性之间权衡。C语言多数用于开发小型的项目,这些问题并不是很突出,大型项目适合c++这样的面向对象开发,除了更好的封装模块,还有强大的继承树改善模块之间的依赖,以及命名空间机制防止命名冲突。如果不明白什么什么是虚拟内存,为什么程序之间会有隔离墙,操作系统和应用程序内存结构是怎么样的,建议和我的这篇帖子一起阅读:https://bbs.csdn.net/topics/398959341?spm=1001.2014.3001.5508

C语言模块化开发,深入多文件编程相关推荐

  1. C语言 06.函数和多文件编程

    1.函数的作用: 提高代码的复用率 提高程序模块化组织性. 2.函数分类: 系统库函数: 标准C库. libc (1). 引入头文件 - 声明函数 (2). 根据函数原型调用. [随机数]: 1. 播 ...

  2. 学会c语言能开发软件吗,学编程什么时候能够编写像酷狗音乐一样的程序?

    这个问题是很多新手都想知道的.因为刚开始学习编程的时候,都是从C语言开始的.C语言是所有编程语言的基础,只要你学会了C语言,其它语言学起来就会特别轻松.有疑问的读者可以参考这篇文章:浅谈编程:初学者如 ...

  3. Javascript实现浏览器模块化开发

    概述 js使用import实现模块化开始,对于大型项目开发来说非常有用,而且结构清晰,ES6就有相关的规范,现在不光node.js可以无阻使用,浏览器也可以原生支持了.现就简单使用及一些部署问题作一归 ...

  4. C语言模块化编程的例子

    以往写C语言程序都是一个文件里面写个几十.几百行,在练算法的时候还可以,现在搞开发需要模块化编程,所谓模块化编程,就是指一个程序包含多个源文件(.c 文件和 .h 文件),每个 .c 文件可以被称为一 ...

  5. 编程之法-C语言应用开发与工程实践-C语言概述

    浅谈计算机系统架构 计算机硬件系统 现代计算机是由运算器.控制器.存储器.输入设备.输出设备五大部分组成,它们各司其职,完成了数据的计算.存储.传输任务,整体架构如下图所示 下面是它们各个组件的功能介 ...

  6. 手机c语言多文件编程,C语言多文件编程

    今天,IT培训网小编为大家总结的是C语言,C语言多模块开发(多文件编程). 目前为止,我们编写的大部分C语言程序都只包含一个源文件,没有将代码分散到多个模块中,对于只有几百行的小程序来说这或许可以接受 ...

  7. 模块加载及第三方包:Node.js模块化开发、系统模块、第三方模块、package.json文件、Node.js中模块的加载机制、开发环境与生产环境、cookie与session

    1.Node.js模块化开发 1.1 JavaScript开发弊端 JavaScript 在使用时存在两大问题,文件依赖和命名冲突. 1.2 软件中的模块化开发 一个功能就是一个模块,多个模块可以组成 ...

  8. keil c语言模块化编程,keil C模块化编程总结

    昨晚看了下模块化编程的东西,把自己的工程整了整,可惜没成功.今早发神经似的起床敲代码,很快就发现了错误,原来是条件宏定义的头文件名忘改了,汗!!! 整理下模块化编程的要点,感谢以下三位UP主的帖子: ...

  9. C语言模块化编程样例

    模块化编程向来不是面向对象语言的专利,即使是C语言,为了降低文件.模块间的耦合度,依然要注意对变量.函数进行封装. 以下举例对C语言模块化编程进行浅析:项目中包含a.c和b.c文件,其中a.c中定义了 ...

最新文章

  1. 神奇的 Object.defineProperty 解释说明
  2. java neo4j_Neo4j Java REST绑定–第2部分(批处理)
  3. (50)常见命名方式
  4. 长春理工大学第十四届程序设计竞赛(重现赛)B
  5. JAVA对map进行分组
  6. webpack3+node+react+babel实现热加载(hmr)
  7. 在vue中,如何禁止回退上一步,路由不存历史记录
  8. python rarfile_Python中zipfile压缩文件模块的基本使用教程
  9. MATLAB车牌识别系统
  10. 计算机隐藏功能表格行,Excel如何一键隐藏、显示某些行(excel表格)
  11. IObit Unlocker超实用工具,专治各种不服
  12. Linux移植Windows摄像头驱动,Arm-Linux摄像头驱动程序的移植
  13. iOS企业ipa(299)证书制作、打包发布全流程(亲测,成功)
  14. 为什么1//0.1等于9.0,而1//-0.1=-10?
  15. python获取元素在数组中的位置
  16. Unity 引擎开始从 Mono 迁移到 .NET CoreCLR
  17. [h5棋牌项目]-05-重载配置导致的内存泄露
  18. 周末了,看,首富出门遛狗。
  19. oracle sql outer join,解答Oracle LEFT JOIN和LEFT OUTER JOIN的区别
  20. 用google搜索图书的方法

热门文章

  1. 关于seata的详细使用成功案例
  2. 通过管理账户的途径来使得计算机加速
  3. 关于NAT——网络地址转换
  4. GUI的各种网站(自用)
  5. 增加mysql运行内存
  6. Serialization(序列化)机制
  7. VS2017+Fortran(Intel Parallel Studio XE 2018)+MPI
  8. 使用C# Winform制作线下抓阄小程序
  9. opengl绘制立方体(二)
  10. 51.com“彩虹”上线 矛头直指腾讯(每日关注:2009.12.24)