前言
在日常开发过程中,我们经常会调用库中提供的方法来完成一些操作,比如调用printf
,scanf
等等,那么为什么我们可以使用这些方法?我们自己根本没有实现这些功能,他是怎么被调用的?有这么多的库,编译器怎么知道我要调用哪一个?
接下来我将围绕这些问题,详细的解释动静态库是什么,如何制作动静态库,以及如何使用动静态库。
动静态库是什么
在日常开发过程中,我们如果想要在自己的代码中使用别人实现的库或方法,有什么办法吗?
- 直接将别人的源代码拷贝到自己的文件中,在进行调用。
那么如果别人不愿意将源代码公开,但是也希望别人使用自己写的库,怎么办???
学过C语言都知道,可执行程序的形成必须经过:预处理,编译,汇编,链接。
其中代码经过编译后会生成汇编代码,此时我们依旧是可以看懂汇编代码的,而经过汇编之后就会生成二进制目标文件,而我们是无法阅读二进制文件的。所以根据这个思路,我们能不能将一个代码经过预处理,编译,汇编后再发送给别人使用?
答案当然是可以的,只要我们有别人的头文件,并且其中声明了调用方法,编译器就会认为有这些方法不会报错,只要在链接时编译器能够找到这些方法即可,关于编译器如何找到在后文会详细介绍。
以上就是动静态库形成的全过程;所以动静态库就是一个已经编译好的源代码文件,通过链接库使得我们可以使用其中的方法。
我们也可以在自己的电脑中查看自己下载的动静态库文件:
在Windows中:静态库以
.lib
结尾,动态库以.dll
结尾;在Linux中:静态库以
.a
结尾,动态库以.so
结尾。
编写动静态库
在上面,我们已经知道动静态库就是编译好的文件,那么我们也就可以将自己的代码进行编译并将头文件打包形成库。
制作静态库
下面简单实现一个加法函数并形成库:
// add.h
int add(int x , int y);
// add.c
int add(int x , int y){return x + y ; }
此时再见add.c
文件进行编译生成生成二进制目标文件gcc -c -o add.o add.c
,形成目标文件之后还要进行打包ar
操作,可以多个目标文件放入到一个静态库中,只不过我们这里只有一个。
ar -cr libmath.a add.o
通过ar
进行打包,-c -r
分别表示创建一个打包文件,如果已经存在打包文件就进行覆盖。
通过ar
也可以查看一个静态库中有哪些二进制目标文件,通过-t
选项,-v
选项可以显示详细信息。
注意:在Linux下,静态库的命名必须是:lib
开头,.a
结尾,中间才是静态库的名称。
一般我们不会直接见库和自己的程序放在同一个目录下,而是专门设置一个目录存放头文件和库,此处我们也采用这种方式,将头文件,静态库和可执行程序分开:
如上图所示,我们将头文件存放到include
目录下,将libmath.a
静态库存放到libmath
目录下。
好了,一切就绪,此时可以编写一个可执行程序test.c
使用我们的动态库了:
#include <stdio.h>
#include "add.h"
int main()
{
int x = add(10 , 20);
printf("%d\n" , x);
return 0;
}
编译报错了,找不到头文件。确实,编译器只会到默认路径/usr/include
和当前路径下查找头文件,而我们刚刚将头文件规整到其他目录下了,所以此时可以通过添加编译选项-I
来添加头文件查找路径:
此时他又告诉我们add
函数没有定义,根据上面的经验也可以理解:编译器查找静态库的方式也是在系统目录下进行查找,所以此处我们也要添加查找静态库的路径:使用-L
选项。
还是没有找到add
函数的定义,这又是为什么,我们不是添加了静态库的查找路径嘛。这是因为对于外部库,编译器不会主动进行链接,所以还需要添加选项,告诉编译器去链接哪个静态库:使用-l
选项,后面加静态库的名称,注意此处的名称是要去掉前缀lib
和后缀.a
的。
最终经过多次的试错终于生成了可执行程序,并且确实能够正常运行。
但是我们看到我们的编译指令真是太麻烦了,而导致这一问题的原因就是编译器要到指定目录下取查找头文件,静态库…
而想要解决这一繁琐的操作,其根本在于让编译器能自动找到我的文件,而不是我指定。常用的的方法有两种:
将头文件拷贝到编译器查找头文件的目录下,同时也将静态库做相同操作。这种直接将我们的代码拷贝带系统的操作被称为安装库,是我们使用第三方库最常用的方法:
将我们的头文件和静态库都放到系统系统路径下,再进行编译,在不到add()
函数的定义。与上面第三种情况一样,编译器知道静态库在哪,但是编译器不会主动连接外部库,所以还是需要使用-l
选项。在系统查找路径下建立软连接,这样就不需要将文件拷贝到系统路径下也可以找到,与上面使用方法一样。
制作动态库
动态库的制作和静态库的制作大同小异,该差异源自于动静态库之间使用上的不同,关于动静态库的不同在后面会详细介绍,此时先详细介绍动静态库的制作。
与静态库一样,动态库也是编译好的二进制目标文件,只不过打包的方式不一样。
编写一个加法函数的动态库,还是使用上面的代码,与上面代码一样,还是采用文件分开处理的方式进行。
- 在进行编译形成目标二进制文件的时候,gcc要多带一个选项:
gcc -fPIC -c -o add.o add.c
,该选项的功能在后面动态库的原理种讲解。-fPIC
产生与位置无关码,后面介绍原因。 - 对动态库打包的时候,并不是采用
ar
,而是使用gcc
让其形成可执行程序:gcc -shared add.o -o libmath.so
。
经过编译,打包后,形成的动态库有x权限,这也就意味着其可以执行,而静态库是没有x权限的,这就与这两种库的区别有关了。
下面先谈谈动态库与静态库的区别:
在前言部分我们说过,使用别人写的程序有两种方式;这两种方式在库种被分成了静态库和动态库。
- 静态库并不是可执行程序,静态库在编译的时候,直接将方法的实现拷贝给你,就是将源码拷贝到你的代码中;毫无疑问这样程序就会变大,但是当程序运行的时候就可以摆脱静态库的关联了;就好像静态库"大方"直接给你源码一样;
- 动态库就不一样,动态库是可执行程序(当然也不能独自运行)。动态库并不给你源码,当程序运行时,你要使用动态库中的方法实现的时候,就会从自己的程序跳到动态库中去执行方法实现,执行完后再跳转回来,继续执行剩余的代码。
这也就是为什么我们在进行打包的时候,静态库使用ar
,而动态库直接使用gcc
形成可执行程序,
而-shared
选项就代表其实动态库。
根据上面的分析不难得出:动态库要被加载到内存中;他要被加载到内存中,那么有多个不同的程序可以同时使用这一个动态库,这样就不需要将代码实现给每个程序都拷贝一份,可以大大节约内存空间,因此动态库也被称为共享库。
回归正题,现在可以使用动态库了吗???
与静态库的使用一个,我们也需要指明头文件,动态库所在的位置:
编译通过了,确实生成了可执行程序,但是当运行的时候出现问题了,还是找不到动态库。
这种情况已经不是在编译的时候找不到动态库了,而是运行时。
在前面我们说过,动态库要加载到内存中,动态库与可执行程序有关联的,那么找不到动态库是不是因为动态库没有加载到内存中???
是的,确实是这样的。在编译阶段,要让编译器找到动态库的位置,在运行时要让动态链接器也能找到动态库,那么怎么让动态链接器找到动态库?有4种方法:
将动态库放到系统路径下,即放到
/usr/lib64
路径下:建立软连接,与上面方法类似,此处不再演示;
修改环境变量
LD_LIBRARY_PATH
,该环境变量是专门用来存储用户自定义库路径的:
注意:该环境变量与静态库无任何关系,静态库在程序运行时就不再需要使用了。在
/etc/ld.so.conf.d
目录种,创建一个自己的动态库路径配置文件以.conf
结尾,创建好后使用ldconfig
复用:
在实际开发过程中,我们大多数会直接采用第一种安装库的方式来使用别人提供的库。
动态库在进程运行的时候,会被加载到内存中,并且被所有程序共享,都可以去动态库中执行代码,那么这个过程具体是如何实现的呢???
下面我将围绕这一话题,聊一下动态库与进程地址空间的位置关系。
动态库与地址空间
一个进程是有自己的进程地址空间的,通过页表与物理内存建立联系:
如果一个程序要调用动态库中的方法,那么毫无疑问也要将动态库的物理地址映射到进程的虚拟地址中,那么映射到虚拟地址的哪一个位置呢???
答案时映射到共享区上;通过将动态库映射到共享区中就可以实现进程去动态库中执行方法的实现:进程正在执行自己的代码,执行正文部分的代码,当进程发现自己无法实现对应的操作时,就会从正文代码区跳到共享区,然后执行动态库中的方法实现,执行结束后再回到原来的正文代码区,继续执行后面的代码。
通过将动态库与共享区建立映射关系,使得调用动态库中的方法就好像调用该程序自己代码中的方法一样。
当然,一个计算机中有多个进程,每个进程调用的动态库可能不一样,因此操作系统就会将这些动态库进行管理,操作系统知道那些库已经被加载到内存中了;
- 当一个进程要调用动态库中的方法时,如果动态库没有与共享区建立映射,操作系统就会检查需要的动态库是否已经加载到内存中,如果没有就会发生缺页中断,将库加载到内存中;如果已经加载到内存中,就直接与共享区建立映射;
- 对于同一个动态库被多个进程使用时,可能动态库映射到进程中共享区的位置是不一样的,但是这些位置通过页表映射后一定指向同一个物理内存。
上面就是动态库被进程调用的逻辑,以及多个进程是如何共享一个动态库的。
共享区空间如何分配
使用objdump -d
+ 二进制目标文件可以进行反汇编,一下是将test.o
进行反汇编的截取代码:
在汇编中我们可以看到一条call
指令,这就是在调用我们动态链接的add()
函数,前面是call
的地址,注意是后8位,也就是00000000,这里的地址是虚拟地址。
此处的00000000并不是add()
的地址,因为要进行动态链接编译器才知道方法的实现在哪,此处表
示地址还没有分配,会在连接时进行分配。在连接的过程中就会将此处的地址进程初始化。
如图是可执行程序的汇编代码:
思考:动态库是在程序运行的时候才映射到虚拟内存中的,那么形成的可执行程序中会call地址来找动态库的虚拟地址位置,动态库都还没有加载进来他怎么知道地址在哪的???难道是在还没有加载之前就将共享区中所有位置的地址都进程分配好了???
对于后面的猜想是不现实的,因为共享区中的地址是在动态变化的,如果一个动态库调用完了,就可以将这块共享区的空出来了,再让后面使用。如果一开始就分配好势必会造成空间上的浪费。
下面我将围绕这一问题介绍一个程序形成的全过程。
可执行程序形成后的地址
实际上在程序还没有加载到内存之前,程序内部就已经存在地址了,这并不难理解,因为上面的汇编代码中要进行call
地址操作,那么上面的可执行程序一定分配了地址。
在编译的时候,gcc就已经将地址进行了分配,这些地址就是从00 00 00 00 开始向上生长的,就是线性地址,文件中的内容依据ELF格式进行排列:
也就是说,编译器在编译代码的时候也会考虑操作系统将进程加载到内存中的操作,gcc将可执行程序的地址按照线性地址进行排列,这使得操作系统在设置进程虚拟地址空间的时候更方便。
加载到内存中的地址
内存中地址分配的最小单位时页帧,内存中每个位置就天然具备地址。所以当一个程序加载到内存中时,其中的代码和数据就天然具备了物理地址,只需要将这些物理内存地址与之前编译器分配的线性地址建立联系,即将映射关系添置页表即可。
对于一个大型程序,并不会直接将其所有的代码和数据加载到内存中,因为代码执行有先后顺序,位于后面的代码后执行,如果直接将所有代码都加到内存中,必定会造成空间上的浪费。
所以一个程序的代码和数据是一边执行,一边加载的。
- 当CPU拿着虚拟地址去页表中查找对应的物理地址时,如果有对应的物理地址,即代码和数据已经加载到内存中了,就可以直接执行程序的代码;
- 如果去页表中查找,发现没有对应的物理地址时,就会发生缺页中断,让操作系统将代码和数据尽快加载进来,并建立页表映射,然后CPU再继续执行。
动态库的地址
在上面我们谈到调用动态库中的方法实现的时候也是通过call
地址的函数进行的,只不过动态库时在程序运行的时候才加载到内存中的,所以它怎么知道动态库在运行的时候会放到那个地址?进程call
的这个地址是什么意思?
答案是:因为动态库是在运行时使用的,所以让动态库加载到固定地址是不可能的,操作系统允许库加载到共享区的任意部分。
动态库被加载到内存中后,就天然具有了物理地址,只不过还差得是虚拟地址,如果通过上述所说的随意存放,那么一个动态库中可能存在多个函数,如何去定位我们要调用的方法在哪一个位置呢???
换句话说就是:动态库随意加载到共享区,那么程序可以跳转到动态库中,但是跳转到动态库的哪一个位置呢?
此时就与我们之前在程序中call
的地址有关了,该地址是我们要调用方法与动态库起始位置的偏移量,通过动态库起始位置+偏移量,就可以实现跳转到指定函数位置,调用方法。
这也是为什么在动态库形成二进制目标文件是要加fPIC
选项,产生与位置无关码,使用偏移量来对库中的函数进行编址,而非绝对路径。