【Linux系统】动静态库
目录
一、什么是库?
二、库的本质
三、静态库
1. 打包制作静态库
2. 如何使用静态库?
方法1:使用gcc指令选项
方法2:拷贝头文件和库文件到系统目录下
方法3:使用软链接
四、动态库
1. 打包制作动态库
2. 如何使用动态库?
找不到动态库?
解决加载找不到库的方法(4种)
3. 理解动态库的加载
宏观理解
程序地址空间(2):关于地址的认识
程序加载到内存之前:逻辑地址
程序加载到内存之后:虚拟地址
动态库的地址理解 --- 为什么要使用gcc的 -FPIC 选项?
五、补充内容
1. 动态链接和静态链接
2. gcc 链接第三方库
一、什么是库?
当我们写好了一些包含了很多方法函数的代码(没有main函数)之后,想要将我们写的这些代码给别人用,我们有两种方法:
- 一种就是将源代码直接给别人。
- 一种则是让我们写的源代码的方法实现想办法打包成库,然后再配合头文件,把库拿给别人用。这个库中就包含了我们写好的函数方法的定义,而头文件中则包含了这些函数的声明,是库中方法的使用说明书。
所以库就是我们写好的函数方法,而我们拿给别人用的库有两种类型:静态库和动态库。
在Linux下,静态库的名字叫做libxxxx.a.zzz,其中 lib 和.a.zzz只是前缀和后缀,静态库真正的名字是这里的 xxxx ;动态库的名字叫做libyyyy.so.zzz,其中lib是前缀,.so.zzz是后缀,动态库的名字是这里的 yyyy 。例如 libc.so.6 就是C标准库,是一个动态库,版本号是6。
区别
- 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库。此时即使删掉静态库,程序也能运行。
- 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。如果删掉动态库,程序就不能运行了。
二、库的本质
注:以下我们称有main函数的源文件为 main.c 。
我们知道,一堆.c源文件和.h头文件要形成可执行程序,就必须经历4个步骤:预处理->编译->汇编->链接 。这些代码经过前三个步骤之后,每一个.c文件就会形成一个的.o 文件,直到第四部链接的时候,才会将所有.o文件进行链接从而形成可执行文件。这些.o文件可以分成两种:
- 包含 main 函数的 .o 文件:这是程序的入口,决定了程序的执行起点。
- 不包含 main 函数的 .o 文件:这些文件包含了几乎都是函数的定义。这些函数会通过main被调用。
所谓的库,本质上就是将这些 “ 常用的且不包含 main 函数的 .o 文件 ” 按照特定的格式打包起来的一个文件。此后,我们想链接形成可执行程序时,只需要让main.o与这个库链接就行了。
举个例子:如果有 test1.c,test2.c,test3.c 这几个源文件,且它们的函数会被不同项目(即不同的 main 函数)重复使用。为避免每次编译都重复处理这些代码,我们可以将它们打包成库:先将这几个 .c 文件分别编译为 test1.o、test2.o、test3.o,再通过特定工具将这些 .o 文件打包为一个文件——这就是“库”的形成过程。此时,若某个项目的 main.c 需要调用这些库中的函数,只需将 main.c 编译为 main.o,再与打包好的库链接,就能生成可执行文件。
无论静态库(如 libxxx.a)还是动态库(如 libxxx.so),本质都是 .o 文件的集合,内部封装了各类函数(或变量等)。调用这些功能(函数或变量)时,只需在链接阶段指定对应的库即可。
三、静态库
1. 打包制作静态库
我们已经知道,静态库其实就是我们要用的几个 .c 源代码编译写成的 .o 文件打包起来的一个文件,那么我们要怎么做一个静态库呢?
我们现在有以下头文件和源文件:要让这两个源文件中的方法打包成库,我们可以通过以下步骤实现:
- 首先,将 .c 文件编译成机器码形式的 .o 文件。注意这里只编译不链接,需要使用gcc的 -c 选项,即以下指令:
gcc -c my_add.c -o my_add.o gcc -c my_sub.c -o my_sub.o - 然后,使用归档工具 ar将这两个 .o 文件打包成一个 .a 文件。通常库的命名规范是lib + 库名 + .a。假设我们将这个库命名为 mymath,则文件名为 libmymath.a。需要使用以下指令和选项:
其中选项解释如下:ar -rc libmymath.a my_add.o my_sub.o
-r:替换或添加文件到归档中。若库中已存在同名 .o 文件,则覆盖;若不存在,则新增。
-c:创建归档文件。若指定的 .a 文件不存在,则自动创建;若已存在,则不报错(配合 -r 使用更安全) - 最后,我们可以得到一个库 libmymath.a 。
之后,要让我们的库能够被别人使用,我们需要给别人两个文件,一个文件中放了的是我们与这个库相关的头文件,另一个文件中放的是所有的库文件。
所以接下来,我会将以上过程中的头文件和库文件分开。假如要要放在mylib目录下,则将头文件放在include下目录,库文件放在 lib 目录下。得到如下结果:
此后,如果别人要用my_add.c和my_sub.c中的方法,则只需要将这里的所有头文件给他(头文件是函数使用的说明书),以及库目录下的所有库,这里是把 libmath.a给他。
2. 如何使用静态库?
在以上mylib的基础上建立一个main.c ,在其中我们要调用这个库中的方法,该怎么做?
方法1:使用gcc指令选项
其中这个main的代码如下:
#include <stdio.h> #include "my_add.h" #include "my_sub.h" int main() { int a = 10; int b = 20; printf("%d + %d = %d\n", a, b, add(a, b)); printf("%d - %d = %d\n", a, b, sub(a, b)); return 0; }接下来,我们需要使用 gcc 进行将代码编译形成可执行程序,但是如果当前我们直接使用gcc ,不带特定选项,则就无法编译,因为gcc找不到我们的代码在那里?如图:所以我们还需要使用到以下三个选项:
-I:大写的 i ,表示在指定头文件的搜索路径-L:大写的 l,表示在指定库文件的搜索路径-l:小写的 L,指定要链接的库名称。这里后跟库的名称(即去掉前缀 lib和后缀 .a 和 .so 之后的部分,剩余的部分就是库名),一般是 -l 后面紧跟库文件名。
示例:
说明:
- 指定头文件搜索路径,是为了找到源代码中包含的头文件 my_add.h 和 my_sub.h 在哪里。
- 指定库文件搜索路径,是为了找到我们使用到的库在哪里。因为头文件只有函数声明,没有实现,具体的代码逻辑都在库里。
- 如果我们不将其头文件和库文件拷贝到系统目录下,那么这三个选项缺一不可,并且它们后面库加空格,也可以不加空格。
- 如果要链接多个库,则直接在后面一直添加-l库名即可。
方法2:拷贝头文件和库文件到系统目录下
这种方法的核心就是:通过系统的默认路径来找头文件和库。而拷贝具体过程如下:
- 把我们要使用的头文件拷贝到系统目录 /usr/include/ 下;
- 库文件拷贝到 /lib64/ 下。
以上操作使用cp指令拷贝即可。然后我们就可以使用gcc进行编译了,但是要注意此时还是需要指明是链接系统路径库文件目录下的那一个库,不然也编不过。
如下图所示:
这时可能会问:为什么使用 C 语言自己的库(如 stdio.h、stdlib.h 等标准库)不需要手动用 -l 指定?
这是因为 gcc 编译器本来就是设计出来编译C语言的,在设计时它就将C 标准库(libc)设定为了默认自动链接的对象。 这意味着,只要编译 C 程序,链接器就会无条件地把 libc 加入链接;而我们写的自定义库(如 libmymath)或特定的扩展库(如数学库 libm)并非所有程序都需要,所以编译器不会自动加载它们,因此必须通过 -l 参数让我们显式指定。
拓展:
- 其实我们拷贝头文件和库文件到系统路径下,这两个操作就是安装库底层的核心操作。
- 但是我们不推荐将我们自己的写的代码做成库拷贝到系统目录下,因为这样会污染系统环境,甚至导致系统工具崩溃或产生难以排查的冲突。
方法3:使用软链接
这种方法的核心也是通过系统的默认路径来找头文件和库,这种方法的操作如下:
- 对我们使用的头文件的目录的绝对路径建立软链接到系统路径 /usr/include/ 下;
- 当前库文件所在的绝对路径建立软链接到系统链接 /lib64/ 下。
注意:这里一定要使用绝对路径。
如下图所示:
注意如果按照上面方法,则我们代码中的头文件也需要修改,如图所示:
因为软链接文件中存的是路径,所以在包头文件时就应该认识到我们是通过软链接来找到我们的头文件的,所以代码中具体头文件之前要写上 “ 软链接名/ ” ,不然也编不过。
四、动态库
1. 打包制作动态库
动态库也是库,它本质也是几个.o 文件打包形成的一个文件。但是打包形成动态库和打包静态库的操作也有区别,动态库的打包过程如下:
我们仍然以以下代码为例:
第一步:将源代码编译形成 .o 文件。与静态库不同,动态库的 .o 文件的形成需要使用 gcc 的 -FPIC 选项,这样选项的意思是与位置无关码(关于它的具体理解在本章后面的动态库加载小节解释,这里暂且认为动态库形成 .o 必须带该选项即可)。如图所示:
第二步:将形成的 .o 文件打包形成动态库。与静态库不同,形成动态库不需要 ar 指令,只需要使用 gcc 编译器即可,但是需要用到 gcc 的 -shared 选项。如图所示:
第三步:将库文件和头文件组织起来。和静态库一样,要让库能被别人用,我们需要给他两个文件:一个放所有头文件,一个放所有库文件。所以这里我就将头文件放在 mylib 目录下的 include 目录下即,库文件放在 mylib 目录下的 lib 目录下,如图:
2. 如何使用动态库?
找不到动态库?
我们仍然以以下 main.c 的文件代码来测试:
#include <stdio.h> #include "my_add.h" #include "my_sub.h" int main() { int a = 10; int b = 20; printf("%d + %d = %d\n", a, b, add(a, b)); printf("%d - %d = %d\n", a, b, sub(a, b)); return 0; }具体的文件结构如下图所示:然后我们使用 gcc 的
-I,-L,-l这三个选项来编译写成可执行程序。得到以下结构:但是,我们可以看到通过链接动态库形成的可执行程序并不可以直接运行,它会提示找到不到文件。其原因如下:
通过 gcc 的
-I,-L,-l这三个选项是告诉了编译器我们使用的头文件和库文件在哪里了,而一旦可执行程序形成了就和编译器没有关系了。当要将程序运行时,负责加载程序的是操作系统的加载器。此时,程序会去系统的标准路径(如 /lib, /usr/lib)或者环境变量指定的路径下寻找那个 .so 文件。 因为我们的 libmymath.so 只是放在当前项目目录下的 mylib/lib 里,并不在系统的标准搜索路径中,所以加载器找不到它,就会报错:cannot open shared object file。
所以我们还需要告诉操作系统的加载器我们的动态库在哪里。
我们可以通过ldd指令来查看程序所依赖的动态链接库,如图:
同时我们也可以发现,上面的程序除了链接我们指定的库,也要链接C标准库,而C标准库就是在系统的标准路径下(/lib64/)的,所以它才找得到。
那么,我们要如何解决找不到动态库的问题呢?这里方法有4种,如下......。
解决加载找不到库的方法(4种)
方法1:将库文件拷贝到系统的库路径 /lib64/ 或 /usr/li64/ 下
因为加载器在程序启动时,会优先搜索系统内置的“标准库目录”,如 /lib64、/usr/lib64。所以将我们的 .so 文件(动态库)直接复制进去,系统自然能找到。使用cp指令即可:
# sudo cp 需要的库文件 系统路径 sudo cp ./mylib/lib/libmymath.so /lib64/如图:
方法2:在系统默认库路径/lib64/ 或 /usr/li64/ 下建立软链接
与方法1类似,但不移动原文件,而是创建一个指向原文件的软链接。系统访问该软链接时,会跳转到真实的库文件所在路径。
sudo ln -s 当前路径+文件名 /lib64/libmymath.so如图:
方法3:将自己库所在的路径添加到系统的环境变量 LD_LIBRARY_PATH中
LD_LIBRARY_PATH 是 Linux 系统中用于指定动态库(.so 文件)搜索路径的环境变量,它在程序运行时由加载器读取,用于在标准系统路径(如 /lib, /usr/lib)之外,额外查找所需的动态库。使用export来添加路径:
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:当前路径/mylib/lib/这种方法只是临时的,重启我们的shell之后,添加的信息就没有了。
如图:
注意:一般云服务器或 Linux 系统默认没有设置这个环境变量(或者它是空的)。如果你发现当前系统中存在该变量,通常是因为之前的用户手动配置过,或者某些特定软件在安装时自动添加了路径。
方法4:在 /etc/ld.so.conf.d/ 建立配置文件,然后使用 ldconfig 更新
系统在启动时会读取 /etc/ld.so.conf 中列出的所有路径,并将这些路径下的库信息缓存到 /etc/ld.so.cache 文件中。ldconfig 命令用于更新这个缓存。所以我们可以通过以下两个步骤来实现:
- 在 /etc/ld.so.conf下添加一个.conf文件,在·其中保存我们库的路径,
- 然后使用 ldconfig 更新一下配置文件即可。
如图所示:
最后,虽然我们介绍了4种方法,但是实际上,我们使用的库都是别人的成熟的库,一般都是直接采用安装到系统的方式(相当于方法1)。
3. 理解动态库的加载
宏观理解
共享库的概念
我们知道动态库在进程运行时是要被加载到内存中的,而静态库则是在程序在编译链接阶段,库代码会被完整拷贝到可执行程序中。
往往大多数程序会依赖相同的常见动态库(例如,所有使用C语言标准库的程序都需要libc.so)。此时,这些动态库被称为共享库。 共享的核心价值就是“一份代码,多进程复用”,即: 当多个程序同时运行并依赖同一个共享库时,内存中只加载一份库代码来共用(而非每个程序都加载一份,因为没必要),从而大幅节省内存资源。
那么动态库加载之后,会被所有进程共享,是怎么做到的?
动态库加载的宏观理解
在一个进行运行时,会有它自己的task_struct ,进程地址空间,和页表,还有内存中加载的整个进程的代码和数据,然后通过页表将物理内存中的代码和数据映射到进程地址空间。
那么如果进程使用了动态库,因为程序编译时就已经告诉这个程序需要的动态库在哪里,动态库也是一个文件,所以当这个进程启动时,加载器就会将动态库这个文件加载到内存,然后通过该进程的页表将动态库映射到进程地址空间的共享区中。
当进程执行到调用动态库函数的指令时,CPU 会根据页表中的记录跳转去执行共享区中动态库对应的代码,执行完毕后再返回主程序继续运行。这就完成了一次对动态库的访问。
动态库的共享
因为操作系统在运行时一定会存在多个动态库,所以动态库都要被操作系统管理(先描述,再组织)起来,因此,对于所以动态库的加载情况,操作系统一定非常清楚。所以当第二个进程启动并需要同一个动态库时,操作系统会进行检查:如果发现该库的代码已经在物理内存中存在,就不会再次加载副本,而是直接修改新进程的页表,将其指向同一块物理内存区域。如图所示:注意:库中也有一些全局变量(比如错误码 errno),虽然多个进程是共有同一个库的,但是如果多个进程要修改库中的变量,则就会发生写时拷贝。
通过以上理解,我们并不能够知道动态库加载的具体过程,仍有一些问题,比如:CUP中读到的地址是什么?以及CUP怎么知道要访问的下一个地址是什么呢?所以,接下来我们还需要理解一下关于地址的问题,如下所示。
程序地址空间(2):关于地址的认识
程序加载到内存之前:逻辑地址
在程序加载到内存之前,也就是编译形成可执行程序之后,此时的程序内部的每一条指令都是有地址的,但这时的地址并不是物理地址(即物理内存上的地址),而是逻辑地址(指的是段地址+偏移量的方法的地址,在如今也可以叫虚拟地址)。
因为在编译的时候,程序还不知道自己将来会被加载到物理内存的哪个位置(可能是在地址 0x1000,也可能是在地址 0x100000)。如果写死了物理地址,程序就可能与内存其他进程发生冲突,无法灵活运行了。 所以编译器编译时会假设程序是从 0 地址开始存放的(或者从某个固定的基址,如 Linux 下的 0x08048000),所以编译好后,再磁盘中的可执行程序的机器码中,像其中的跳转指令(如 call ,jmp等等)的地址,全部都是基于这个假设基址的相对偏移量,也就是逻辑地址。
当编译器把源代码编译成目标文件,链接器再把它链接成可执行文件时,它并不知道这个程序将来会被放在内存的哪个角落(照顾操作系统)。因此,编译器也会按照功能把代码和数据切分成不同的“段”,如下:
- .code 段: 存放 CPU 执行的指令(比如图片里的 main, fun)。
- .data 段: 存放已经初始化了的全局变量和静态变量。
- .bss 段: 存放未初始化的全局变量(只记录大小,不占磁盘空间)。
- ......
而对于每一个段中的各个地址通常是相对于该段起始位置的偏移量。
所以我们可以得出一个结论:
编译链接后生成的可执行文件,其内部包含的地址本质上都是基于“假设基址”的逻辑地址(在磁盘文件中, 为相对于文件头或段头的偏移量,此时还没有真正的“虚拟地址”概念,因为程序还没被操作系统接管;在加载到内存后, 操作系统会把这些地址映射到进程的虚拟地址空间中。此时才正式被称为虚拟地址)
程序加载到内存之后:虚拟地址
当程序加载如内存之后,因为程序的每一条指令都会在物理内存中占一个地址,此时表示物理内存中的地址叫做物理地址。所以当程序加载到内存一个有两个地址,一个是程序内部的逻辑地址(记载到内存之后就没有逻辑地址这个概念了,在内存中称为虚拟地址),一个是内存中的物理地址。
当程序被加载到内存后,操作系统会为它分配task_struct,虚拟地址空间,页表,当要执行第一条指令时,CPU的程序计数器(PC)或指令指针(IP)寄存器会被设置为该程序的入口地址(这个入口地址在程序编译好后就有了,在程序内部,所以这个地址在磁盘中是一个逻辑地址,在CPU中就称为虚拟地址)。当CPU尝试根据这个虚拟地址去获取指令时,会通过取查询页表。由于程序刚刚加载,其代码段所在的页面可能尚未映射到物理内存(或者根本没有加载到内存),这次查询会失败,从而触发一个缺页中断。让操作系统从磁盘中找到程序对应的代码和数据,并将它们加载到空闲的物理内存页框中,加载完成后,操作系统会更新该进程的页表,建立起程序的虚拟地址与新分配的物理内存地址之间的映射关系,这样CUP就找到第一个指令开始执行了。
那么当执行到call,jmp这样的跳转地址的指令时,call 后跟着的地址(指令间跳转)也是虚拟地址。
当程序加载到内存之前它就已经是虚拟地址了,所以CUP从读取程序当中的地址到分析处理后二次访问它的整个过程中的读到指令的地址全部都是虚拟地址,只是读到虚拟地址之后要找到物理地址需要页表映射,但是指令间的定位看到的都是虚拟地址。
动态库的地址理解 --- 为什么要使用gcc的-FPIC 选项?
通过上面的理解,我们知道了CPU要指令一个程序的代码指令,都是通过编译好之后程序的虚拟地址(内存中叫虚拟,磁盘上叫逻辑)执行访问与执行跳转的。那么当执行到一个程序中的访问动态库的代码时,按照之前的逻辑(编译采用的绝对编址的方法),比如在程序的代码为printf("xxx"),则编译之后这条代码就会变成call 0x11223344类似这样的指令,所以此时在CPU看了现在要跳转到进程地址空间的虚拟地址必须为0x11223344这个地方去执行程序代码。如果是这样,会有什么问题?
这就意味着,printf 函数必须永远固定在虚拟内存的 0x11223344 这个位置。共享库(如 libc.so)很大,而且可能被很多个程序同时使用。如果把它固定加载到某个位置(比如所有程序都规定放在 0x90000),一旦那个位置被别的程序占用了怎么办?或者不同程序对库的加载地址要求冲突了怎么办?
所以操作系统不需要把库固定在某个死板的地址。它可以根据当前内存的空闲情况,灵活地把 libc.so 映射到进程虚拟地址空间的“共享区”或者其他空闲区域。即实现库可以在虚拟地址中,任意位置加载!!
因此,在编译动态库函数代码的时候就不要采用绝对编址,而让这里编译的地址只表示每个函数在库中的偏移量即可!!也就是说,让之前的绝对编址call 0x11223344这样的指令,改成基于库起始位置的相对跳转。,这样一来:无论操作系统把这个库加载到内存的哪个角落,只要知道库的起始地址(因为操作系统要对库做管理,那它就一定知道库的起始地址在哪里),加上这里固定的偏移量,就能瞬间算出函数的真实运行地址,找到库函数代码并执行了。
以上这就是位置无关代码技术:直接使用偏移量对库中的函数进行编址。所以为什么我们在制作动态库的时候形成 .o 文件时要加上-fPIC,-fPIC 就是告诉编译器生成这种位置无关的代码,即产生位置无关码。
补充问题:为什么静态库不谈加载?不谈与位置无关?
在程序编译链接阶段,链接器会把静态库(.a 文件)中所有被引用的目标文件(.o)直接合并到最终的可执行文件中。程序运行时,这些代码已经是可执行文件的一部分,不需要像动态库那样在启动时由操作系统“加载”到内存;并且静态库中的代码在链接时就已经被分配了固定的虚拟地址(相对于可执行文件的起始地址)。程序运行时,这些地址是确定的,不需要通过“基址 + 偏移”来动态计算。
五、补充内容
1. 动态链接和静态链接
链接方法可以分为动态和静态链接:
- 静态链接:库的代码被复制到了最终的可执行文件内部。
- 动态链接:库的代码独立存在于外部的 .so (Linux) 或 .dll (Windows) 文件中。你的我们的可执行程序里只留了一个“地址”或“名字”,运行时操作系统才去外面找对应文件来用。
如果使用到的库的类型不同,gcc的链接方法也有不同,一般有一些三种情况:
- 如果只有静态库(.a / .lib): 只能进行静态链接。
- 如果只有动态库(.so / .dll): 只能进行动态链接。
- 当同时有静态库和动态库时: 默认优先选择动态链接。如果要使用静态链接,则需要在使用 gcc 的 -static 选项。
2. gcc 链接第三方库
使用gcc 链接第三方库时,首先需要三个选项,这三个选项都支持重复出现。
-I(头文件路径):指定 #include 搜索路径。如果多个库的头文件散落在不同目录,必须为每个目录单独写一个-I参数-L(库路径):告诉链接器去哪里找 .so 或 .a 文件。如果库文件分布在不同的路径下,则需要写写多个 -L 分别指定。-l(库名):需要列出所有需要的库。指定具体要链接的库名(去掉 lib 前缀和后缀)。如果要链接多个库,列出所有需要的库时,必须严格遵守依赖顺序,原则:使用者在前,被使用者在后。
示例:如果库 A 用到了库 B 的函数,那么-lA必须写在-lB的前面。如果写反了,链接器可能会因为扫描顺序问题报错 undefined reference。
感谢各位观看!希望能多多支持!