我们之前学 gcc
的时候也有接触过一点动静态库的知识,现在要把它单独拿出来讲,主要是因为我们后面肯定在自己开发的时候需要包装自己的库,此时就需要有动静态库的原理知识和使用知识!
一般库名称都是中间部分,也就是去掉前缀和后缀的部分剩下的内容,如:libc.so
,去掉前缀 lib
,去掉后缀 .so
-> c
动态库。
问题来了:为什么需要库呢 ❓❓❓ 我们给使用者提供我们写的函数的时候,一般是不会将源代码发给他们的,所以都会将
.c
文件或者其它文件编译成可重定位目标二进制文件也就是.o
文件再发给对方。 那我们不是将自己编译好.o
(方法的实现) 文件和.h
(方法的声明) 文件发给我们的使用者就能达到使用条件吗,但是想一下,我们如果开发的是一项需要很多自己封装的库函数等等,那么此时单单.o
文件可能就达到上百份,那么管理起来肯定是非常的麻烦的,所以我们就需要将这些我们自己写的函数封装成一个库!库的本质就是.o
文件的集合! 那么封装成一个库就非常的好管理,而封装成库有两种封装方法,一个是静态库,一个是动态库,
静态库和动态库最本质的区别就是:该库是否被编译进目标(程序)内部。
下面我们一一介绍它们!
在介绍之前我们先来介绍两个我们也曾经讲过的指令:
第一个就是 ldd
指令,它的功能是 显示可执行文件依赖的库 。
第二个指令就是 file 可执行文件
指令,用于 查看程序是动态还是静态链接。
静态库:这类的函数库通常扩展名为 libxxx.a
或 xxx.lib
。工作原理是程序在编译链接的时候把库的代码链接 (拷贝) 到可执行文件中,变成可执行文件中的一部分,所以 程序运行的时候将不再需要静态库。
这类库在编译的时候会直接整合到目标程序中,所以利用 静态函数库编译成的文件会比较大,这类函数库最大的优点就是编译成功的可执行文件 可以独立运行,而不再需要向外部要求读取函数库的内容;但是从升级难易度来看明显没有优势,如果函数库更新,需要重新编译。
静态链接:链接静态库,每个程序将自己在库中用到的指令代码单独写入自己可执行程序中,程序 运行时无依赖,加载运行速度快,但是程序运行后有可能会有 冗余代码 在内存中。
ar
指令 ar
(archiver
)命令可以用来 创建、查询、修改库。库是一组单独的文件,里面包含了按照特定的结构组织起来的源文件,原始文件的内容、模式、时间戳、属性、组等属性都保留在库文件中。
下面是命令选项:
-d:删除库文件中的成员文件
-m:变更成员文件在库文件中的次序
-p:显示库文件中的成员文件内容
-q:将文件附加在库文件末端
-r:将文件插入库文件中
-t:显示库文件中所包含的文件
-x:从库文件中取出成员文件
-a<成员文件>:将文件插入库文件中指定的成员文件之后
-b<成员文件>:将文件插入库文件中指定的成员文件之前
-c:建立库文件
-f:截掉要放入库文件中过长的成员文件名称
-i<成员文件>:将文件插入库文件中指定的成员文件之前
-o:保留库文件中文件的日期
-s:若库文件中包含了对象模式,可利用此参数建立备存文件的符号表
-S:不产生符号表
-u:只将日期较新文件插入库文件中
-v:程序执行时显示详细的信息
-V:显示版本信息
下面介绍几个常用的:
r
:在库中插入或替换模块。当插入的模块名已经在库中存在,则替换同名的模块。如果若干模块中有一个模块在库中不存在,ar
显示一个错误消息,并不替换其他同名模块。默认的情况下,新的成员增加在库的结尾处,不过也可以使用其他任选项来改变增加的位置。c
:创建一个库。不管库是否存在,都将创建。s
:创建目标文件索引,这在创建较大的库时能加快时间。(补充:如果不需要创建索引,可改成大写 S
参数;如果 .a
文件缺少索引,可以使用 ranlib
命令添加)t
:比如 ar t libxxx.a
,表示显示库文件中有哪些目标文件,只显示名称。v
:比如 ar tv libxxx.a
,表示显示库文件中有哪些目标文件,显示文件名、时间、大小等详细信息。nm -s libxxx.a
:显示库文件中的索引表。ranlib libxxx.a
:为静态库文件创建索引表。 封装库就是将多个 .o
文件打包到一个文件中,所以我们可以使用 GNU
中的归档指令 ar -rc
(其中 ar
代表 archiver
,rc
选项表示 replace and create
)封装一个静态库。
所以下面我们用 makefile
将封装指令使用起来,形成我们的静态库 libmymath.a
:
libmymath.a : add.o sub.o # 使用ar指令封装静态库
ar -rc $@ $^
%.o : %.c # %的作用是匹配目录下的.c文件集合生成.o文件集合,与*号类似,但是%多用于makefile,且使用范围不太一样
gcc -c $<
.PHONY:clean
clean:
rm -rf *.o libmymath.a mylib
Makefile的静态模式%.o : %.c 需要更详细的了解
makefile
的话可以看陈皓的笔记!
这样子还不够,因为我们不仅仅需要将库发给对方,我们还需要将头文件也打包起来发给对方,考虑到如果头文件太多也不好管理的情况,我们最好自己将头文件和库放在一个目录下打包起来,所以我们可以在 makefile
中添加一个伪目标 output
,其中我们调用 make output
的时候希望其能创建一个目录将我们需要打包的头文件和库打包到一个目录下:
.PHONY : output # output作为伪目标进行打包头文件和静态库
output:
mkdir -p mylib/include
mkdir -p mylib/lib
cp -f *.a mylib/lib
cp -f *.h mylib/include
除此之外,如果我们想安装,也就是说这个库和头文件我希望不只是被对方使用,还能在我这个系统上面使用,所以可以安装这些库和头文件,其实本质就是将头文件放到 /usr/include
中,库放到对应的库目录中比如 /lib64
中,所以我们现在也能清楚,安装的本质就是将头文件拷贝到系统的头文件目录下,库拷贝到系统的库目录下!
参考下面代码,这里就不贴调用效果了:
.PHONY:install
install:
sudo cp *.h /usr/include # 注意一般拷贝到系统目录的时候要sudo一下
sudo cp *.a /lib64
最后我们将这个 mylib
目录压缩变成一个包,一般采用 tar
指令进行压缩,现在,我们的软件就已经发布出来了,我们就可以将其打包然后放在网站或者 yum
的资源中供别人进行下载使用了:
为什么要把静态库的使用单独拎出来说呢,因为其中有很多坑,许多人打包完却因为很多这些坑导致这些库都调不起来,所以我们要好好来讲一下!
下面假设我们在别的目录下的 main.c
中调用该库,在这之前先将这个包解压到 main.c
目录下:
接下来有了这个 mylib
,我们不就可以直接编译链接 main.c
为可执行文件了吗,下面我们来试试看:
奇怪,明明我们的库和头文件都有啊,为什么还报错说找不到头文件呢 ❓ ❓ ❓
仔细一想,我们之前在学C语言的时候讲过,如果头文件使用双引号括起来的,那么它首先会到源文件的当前目录下查找,但是我们好像把库和头文件都放在了 mylib
中,深度相对于源文件更深了一点,所以 main.o
在链接的时候就找不到了!
不仅如此,就算我们等会解决了这个问题还会遇到其它问题,这里就不卖关子了,直接将几个问题的解决方法一次性给出:
当我们链接库时,必须指定库的名称,这是因为同一路径下可能同时存在许多库(头文件不需要指定名称,只需指定路径,因为 main
中指明了我们需要的头文件名称),同时,库需要去掉前缀 lib
和 后缀 .a
或者 .so
才是库真正的名称,也就是在我们编译链接可执行文件的时候需要在 gcc
或者其它指令后面 指定头文件路径、库文件路径、库文件名称(注意要去掉前缀和后缀):
gcc -o main main.c -I./mylib/include -L./mylib/lib -lmymath
-I 指定头文件路径:告诉编译器在./xxx路径中找头文件
-L 指定库文件路径:告诉编译器在./xxx路径找库
-l 指定库文件名:库名称(去掉前缀lib,去掉后缀.so或.a)
其中不管 -I
还是 -L
,其实它们和路径之间是可以不留空格的,一般我们的书写习惯也是不留空格!
为了方便,我们可以在 makefile
中将这些选项加入:
libmymath.a : add.o sub.o # 使用ar指令封装静态库
ar -rc $@ $^ -I./mylib/include -L./mylib/lib -lmymath
%.o : %.c
gcc -c $<
.PHONY:output
output:
mkdir -p mylib/include
mkdir -p mylib/lib
cp -f *.a mylib/lib
cp -f *.h mylib/include
.PHONY:clean
clean:
rm -rf *.o libmymath.a mylib
平时我们使用编译器提供的库并不需要带这些选项,是因为编译器有自己的环境变量(LIBRARY_PATH
),能够找到位于 /lib64
库文件的存放目录和 /usr/include
头文件的存放目录。
所以我们可以将我们写的静态库和头文件放入这些目录或其他相关目录下,这就是一般软件的安装过程。但是不推荐,因为放进去会污染标准库(可能不安全)。
接下来还有一个问题,我们查看一下我们生成的可执行文件的属性看看:
这里还存在一个奇怪的地方:main
的依赖库中并看不到 libmymath.a
,并且 main
是动态链接的;这是由如下原因造成的:
1、gcc
默认使用动态链接(只是建议行为),这是针对动静态库都存在的情况说的,如果只存在静态库,那么 Linux
也只能使用静态链,但是如果存在动态库,即使指明 static
选项也只会使用动态链接;
2、一个可执行程序的形成可能不仅仅只依赖一个库,如果依赖的库中有一部分不只有静态库,有一部分库有动态库,那么形成的可执行程序整体是动态链接的,但其中只有静态库的地方才会进行静态链接;
3、这里的现象和第二点一样,main
的形成不仅仅依赖一个库 (使用了 C
语言库函数),且 Linux
中存在 C
语言动态库,所以这里是使用动态链接的,而我们自己的库 libmymath.a
以静态的方式进行链接。
动态库:这类函数库通常名为 libxxx.so
或 xxx.dll
。
与静态函数库被整个捕捉到程序中不同,动态函数库在编译的时候,在程序里只有一个 “指向库” 的位置而已,也就是说当 可执行文件需要使用到函数库的机制时,程序才会去读取函数库来使用;也就是说 可执行文件无法单独运行。这样从产品功能升级角度方便升级,只要替换对应动态库即可,不必重新编译整个可执行文件。
动态库可以在多个程序间共享,所以动态链接使得 可执行文件更小,节省了磁盘空间。操作系统采用 虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。
一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。这种链接方式,是用于解决静态库存在的浪费内存和磁盘空间,以及解决模块更新困难等问题。
动态链接生成可执行程序,可执行程序中会记录自己依赖的库列表以及库中的函数地址信息,等到运行程序的时候,由操作系统将库加载到内存中(多个程序可以共享,不需要加载多份相同实例),然后根据库加载后的地址在对每个程序内部用到的库函数的地址进行偏移计算。
动态库也叫运行时库,是运行时加载的库,将库中数据加载到内存中后,每个使用了动态库的程序都要根据加载的起始位置计算内部函数以及变量地址,因此动态链接动态库加载及运行速度是不如静态链接的,但是它也有好处,就是多个程序在内存中只需要加载一份动态库就可以共享使用。
基于这么一种思想,动态链接具有以下优缺点:
优点:
缺点:
动态库的制作和静态库存在很多相似的地方,但也有不同:
.o
文件需要指定 fPIC
选项,用于 形成位置无关码(与位置无关,库文件可以在内存的任意位置加载,不影响其他程序的关联性)ar
指令,而是 在 gcc
中指定 shard
选项就可以完成归档工作,表示生成共享库形式。 这里我们需要理解一下位置无关的概念,假设在一个班级中,由于某种特殊原因,无论怎么换位置,张三和李四永远是同桌,那么此时要定位张三有两种方法,一是指明某排某列,二是指明李四的位置,即绝对位置定位和相对位置定位,而位置无关就相当于相对位置定位,不用管位置变动,只需要确定李四的位置以及张三与李四之间的偏移量即可。
同样的,知道封装动态库的知识后,我们将所有 .o
文件进行打包并与头文件合并成目录:
libmymath.so : add.o sub.o # 加上-shared封装静态库
gcc -shared -o $@ $^
%.o : %.c # %的作用是匹配目录下的.c文件集合生成.o文件集合,与*号类似,但是%多用于makefile,且使用范围不太一样
gcc -fPIC -c $<
#-fPIC:产生.o目标文件,程序内部的地址方案是:与位置无关,库文件可以在内存的任意位置加载,不影响其他程序的关联性
.PHONY:output # output作为伪目标进行打包头文件和静态库
output:
mkdir -p mylib/include
mkdir -p mylib/lib
cp -f *.so mylib/lib
cp -f *.h mylib/include
.PHONY:install
install:
sudo cp *.h /usr/include
sudo cp *.so /lib64
.PHONY:clean
clean:
rm -rf *.o libmymath.so mylib
和静态库一样,我们先将 mylib
目录文件压缩,然后发到使用方那边再解压。接着还是一样,我们在编译链接成可执行文件的时候必须加上三个选项:头文件路径、库文件路径、库文件名!
很奇怪啊,为什么找不到我们写的动态库呢,我们明明已经把头文件路径、库文件路径和库文件名都加上去了啊,为什么还是找不到啊 ❓ ❓ ❓
其实是因为虽然说我们是告诉了 gcc
我们的头文件路径等,但是当我们编译链接生成可执行文件之后,gcc
可就不管我们了啊,我们执行一个可执行文件,这是和操作系统有关系的,通过加载到内存变成进程从而管理,但是操作系统哪里知道我们告诉了它这些头文件路径等等呢,并且我们的库也不在系统中,所以 操作系统 和 shell
才会找不到!
所以要执行可执行文件的话我们必须告诉 操作系统 和 shell
关于库的路径!下面介绍四种方法!
LD_LIBRARY_PATH
– 短暂性 在系统中有个环境变量叫做 LD_LIBRARY_PATH
,其中该环境变量中放的就是一些指定的动态链接库的路径,我们可以利用 export
指令将我们要存放的动态链接库的路径添加进去,注意要添加绝对路径!
这里需要注意的是:添加环境变量后,默认只在本次登录有效,下次登录时无效(默认清理登录前一次添加环境变量)。如果想让这个环境变量永久生效,可以把这个环境变量添加到登录相关的启动脚本里,下面两个都行,但是不建议,如果真要改,多开几个终端,防止改了之后登不上 Linux
:
vim ~/.bash_profile
vim ~/.bashrc
这个我们上面讲过,这里就不讲了,并且不推荐,因为会污染系统文件池!
[liren@VM-8-2-centos use_library]$ ll -d /etc/ld.so.conf.d/
drwxr-xr-x. 2 root root 4096 Jul 25 2022 /etc/ld.so.conf.d/
在我们系统中存在一个系统搜索动态库的路径配置文件目录 /etc/ld.so.conf.d/
,我们可以在这个目录创建 .conf
配置文件,向配置文件中添加我们的动态库的 绝对路径 即可!(这个配置文件的名称是可以随便取的)
添加绝对路径后,我们还要使用 ldconfig
指令来更新一下这些配置文件,才能生效!
在当前文件路径下建立软链接,注意这个软链接的名称要和动态库的名称一样,因为在寻找库的时候我们指定了用库文件名!
首先,我们要知道,静态库不需要加载!
这个过程,在磁盘中 main.c
和 lib.c
库,会先在磁盘中形成一段代码,然后对这段代码进行编译,编译的本质就是预处理,编译-查找错误,形成二进制代码,然后进行汇编形成二级制指令。在编译阶段的时候就已经形成了虚拟地址空间。
在虚拟地址空间中,这段代码也就被存入代码区,这个是根据不同区的特性所决定的。当执行这段代码的时候,操作系统就会直接在代码区进行访问。
通过 虚拟进程地址空间 的学习我们知道,进程地址空间不仅使得进程能够以统一的视角来看待内存的各个区域,即每个进程都认为自己独享整个内存空间,且自己的数据被放置在对应的区域,如代码段、数据区、栈区等等;同时它还让编译器也以相同的视角来进行代码的编译工作,即程序在编译时就已经按照进程地址空间的划分规则来对不同的数据进行地址分配了。 而静态链接是在多个可重定向文件进行链接时直接将静态库中的代码拷贝到代码段中,最终形成可执行程序;那么后面程序运行时将对应数据加载到虚拟内存的对应区域、建立页表映射、执行代码等系列过程与静态库就完全无关了,所以静态库不需要加载。 虽然静态库不需要加载,但是它存在另一个缺陷:如果多个进程调用同一个静态库,由于每个进程的代码段中都存在该静态库代码,那么程序加载后物理内存中也会存在多份静态库代码,然后通过页表映射到不同进程的地址空间代码段处,造成 物理内存浪费。
动态库加载的过程,在磁盘中有一个 my.exe
(可执行)和 lib.so
(动态库),在形成可执行之前,编译阶段时,我们用到了 fPIC
(产生位置无关码)。
在这个阶段,动态库会将指定的函数地址,写入到可执行文件中。这个地址可以理解成 my_add.c
(地址) +
偏移地址。
形成可执行文件之后,磁盘将可执行文件拷贝到内存中,内存通过页表映射到虚拟地址空间的代码区中,当 OS
执行程序时,扫描到 my_add.c
是需要调用动态库的时候,程序会停下来,OS
会再通过函数的地址,然后页表映射去内存到磁盘中找动态库中,找到后拷贝到内存,又通过页表映射到共享区中。OS
再去 共享区 调用该方法,然后向下执行程序。
注意:动态库可以避免静态库内存空间浪费的问题,这是由于如果多个进程链接了同一个动态库,动态库也只需要加载一次。动态库被加载到物理内存中并通过页表映射到某一个进程(假设A进程)的共享区之后,操作系统会记录该动态库在A进程共享区中的地址,当其他进程也需要执行动态库代码时,操作系统会根据记录的地址加上偏移量通过页表跳转到A进程的共享区中执行函数,执行完毕后再跳回到当前进程地址空间的代码段处。
所以 从始至终物理内存中都只有一份动态库代码。
如图,对于动态链接来说,可执行程序中存放的是动态库中某具体
.o
文件的地址,同时,由于组成动态库的可重定向文件是通过位置无关码fPIC
生成的,所以这个地址并不是.o
文件的真正地址,而是该.o
文件在动态库中的偏移量; 然后就是程序运行的过程:操作系统会将磁盘中的可执行程序加载到物理内存中,然后创建mm_struct
,建立页表映射,然后开始执行代码,当执行到库函数时,操作系统发现该函数链接的是一个动态库的地址,且该地址是一个外部地址,操作系统就会暂停程序的运行,开始加载动态库; 加载动态库:操作系统会将磁盘中动态库加载到物理内存中,然后通过页表将其映射到该进程的地址空间的共享区中,然后立即确定该动态库在地址空间中的地址,即动态库的起始地址,然后继续执行代码; 此时操作系统就可以根据库函数中存放的地址,即.o
文件在动态库中的偏移量,再加上动态库的起始地址得到.o
文件的地址,然后跳转到共享区中执行函数,执行完毕后跳转回来继续执行代码段后面的代码。这就是完整的动态库的加载过程。