代码之锚:洞悉Linux库的打包与复用法则
目录
1:动静态库的基本原理
那么在正式接触动静态库之前,那么我们得先有一个前置知识的储备,那么便是从源文件形成一个可执行文件的一个过程,这里博主在以前的blog中也有讲过,就带着uu们简单回顾下
- 预处理:完成头文件展开、去注释、宏替换、条件编译等,最终形成xxx.i文件
- 编译:完成词法分析、语法分析、语义分析、符号汇总等,即检查是否产生语法错误.检查无误后将代码翻译成汇编指令,最终形成xxx.s文件.
- 汇编:将汇编指令换成二进制指令(会生成符号表),最终形成xxx.o文件.
- 链接:链接目标文件和链接库形成可执行程序(合并段表,符号表的合并与重定向).

例如用test1.c、test2.c、test3.c、test4.c以及main1.c形成可执行文件,需要先得到各个文件的目标文件test1.o、test2.o、test3.o、test4.o以及main1.o,然后再将这些目标文件链接起来,最终形成一个可执行程序。

如果我们在另一个项目当中也需要用到test1.c、test2.c、test3.c、test4.c和项目的main2.c或者main3.c分别形成可执行程序,那么可执行程序生成的步骤也是一样的。

对于可能频繁用到的源文件,例如这里的test1.c、test2.c、test3.c、test4.c,我们可以将它们的目标文件test1.o、test2.o、test3.o、test4.o进行打包,之后需要用到这四个目标文件时就可以之间链接这个包当中的目标文件了,而这个包实际上就可以称之为一个库。

实际上,所有库本质都是一堆目标文件(xxx.o)的集合,库的文件当中并不包含主函数而只是包含了大量的方法以供调用,所以说动静态库本质是可执行程序的“半成品”。
2:认识动静态库
在Linux下创建文件编写以下代码,并生成可执行程序.
示例代码
main.c
#include <stdio.h> #include <string.h> int MyAdd(int x,int y) { return x + y; } int MySub(int x ,int y) { return x - y; } int main() { int a = 10; int b = 20; printf("%d + %d = %d\n",a,b,MyAdd(a,b)); return 0; }这是最简单的代码,运行结果大家也都知道,就是10 + 20 = 30;

下面我们就通过这份简单的代码来认识一下动静态库
在这份代码当中我们可以通过调用printf函数输出结果,主要原因是gcc编译器在生成可执行程序时,将C标准库也链接进来了.在Linux下,我们可以通过ldd 文件名来查看一个可执行程序所依赖的库文件。

这其中的libc.so.6就是该可执行程序所依赖的库文件- 在Linux当中,以
.so为后缀的是动态库,以.a为后缀的是静态库。 - 在Windows当中,以
.dll为后缀的是动态库,以.lib为后缀的是静态库。
这里可执行程序所依赖的libc.so.6实际上就是C动态库,当我们去掉一个动静态库的前缀lib,再去掉后缀.so或者.a及其后面的版本号,剩下的就是这个库的名字。
3:静态库的打包与使用
为了更容易理解,下面演示动静态库的打包与使用时,都以下面的三个文件为例,其中两个源文件mymath.c与main.c,一个头文件mymath.h
mymath.h
#pragma once int MyAdd(int,int); int MySub(int,int);mymath.c
int MyAdd(int x,int y) { return x + y; } int MySub(int x ,int y) { return x - y; }main.c
#include <stdio.h> #include <mymath.h> int main() { int a = 10; int b = 20; printf("%d + %d = %d\n",a,b,MyAdd(a,b)); return 0; }3.1:打包
下面我们就利用这四个文件打包生成一个静态库.

第一步:让所有源文件生成对应的目标文件.

第二步:使用ar命令将所有目标文件打包为静态库.
ar命令式gnu的归档工具,常用于将目标文件打包为静态库,接下来博主使用ar命令的-r选项和-c选项进行打包.
- -r(replace):若静态库文件当中的目标文件有更新,则用新的目标文件替换旧的目标文件.
- -c(create):建立静态库文件.

此外,可以用ar命令的-t选项和-v选项查看静态库当中的文件。
-t:列出静态库中的文件。-v(verbose):显示详细的信息。

第三步:将头文件和生成的静态库组织起来
- 当我们把自己的库给别人用的时候,实际上需要给别人两个文件夹,一个文件夹下面放的是头文件的集合,另一个文件夹下面放的是所有的库文件。
- 因此,在这里我们可以将mymath.h这个头文件放到一个名为include的目录下,将生成的静态库文件
libmy.a放到一个名为lib的目录下,然后将这两个目录都放mylib下,此时就可以将mylib给别人使用了.
3.2:使用

3.2.1:方法一(非安装到系统路径下使用)
此时使用gcc编译main.c生成可执行程序时需要携带三个选项:
- -I:指定头文件搜索路径.
- -L:指定库文件搜索路径.
- -l:指明需要链接库文件路径下的哪一个库.
gcc main.c -I ./mylib/include/ -L ./mylib/lib/ -lmy


因为编译器不知道你所包含的头文件mymath.h在哪里,所以需要指定头文件的搜索路径.因为头文件mymath.h只有MyAdd函数声明,没有该函数的定义,所以还需要指定所要链接库文件的搜索路径.实际中,在库文件的lib目录下可能会有大量的库文件,因此我们需要指明需要链接库文件路径下的哪一个库.库文件名去掉前缀lib,再去掉后缀.so或者.a及其后面的版本号,剩下的就是这个库的名字.-I,-L,-l这三个选项后面可以加空格,也可以不加空格。3.2.2:方法二(安装到系统路径下)
既然编译器找不到我们的头文件和库文件,那么我们直接将头文件和库文件拷贝到系统路径下不就可以了.
sudo cp mylib/include/*.h /usr/include sudo cp mylib/lib/*.a /lib64已经将自己写的库安装到了系统党总,那么为什么编译还编不过呢
gcc/g++默认是认识C/C++库,libmy.a----->是第三方提供的---->因此gcc/g++并不认识.

因此当使用gcc编译main.c生成可执行程序时,还是需要指明链接库文件路径下的哪一个库.

PS:实际上我们拷贝头文件和库文件到系统路径下的过程,就是安装库的过程。但并不推荐将自己写的头文件和库文件拷贝到系统路径下,这样做会对系统文件造成污染。
4:动态库的打包与使用
mymath.h
#pragma once int MyAdd(int,int); int MySub(int,int);mymath.c
int MyAdd(int x,int y) { return x + y; } int MySub(int x ,int y) { return x - y; }main.c
#include <stdio.h> #include <mymath.h> int main() { int a = 10; int b = 20; printf("%d + %d = %d\n",a,b,MyAdd(a,b)); return 0; }4.1:打包
动态库的打包相对于静态库来说有一点点差别,但大致相同,我们还是利用这三个文件进行打包演示:
第一步:让除了main.c以外的源文件生成对应的目标文件

此时用源文件生成目标文件时需要携带-fPIC选项:
-fPIC(position independent code):产生位置无关码.
简要说明-fPIC作用于编译阶段,告诉编译器产生与位置无关的代码,此时产生的代码中没有绝对地址,全部都使用相对地址,从而代码可以被加载器加载到内存的任意位置都可以正确地执行.这正是共享库所要求的,共享库被加载时,在内存的位置不是固定的.如果不加-fPIC选项,则加载.so文件的代码段时,代码段引用的数据对象需要重定位,重定位会修改代码段的内容,这就造成每个使用这个.so文件代码的进程在内核里都会生成这个.so文件代码段的拷贝,并且每个拷贝都不一样,取决于这个.so文件代码段和数据段内存映射的位置.不加-fPIC编译出来的.so是要在加载时根据加载到的位置再次重定位的,因为它里面的代码BBS位置无关代码.如果该.so文件被多个应用程序共同使用,那么它们必须每个程序维护一份.so的代码副本(因为.so被每个程序加载的位置都不同,显然这些重定位后的代码也不同,当然也就不能共享)我们总是用-fPIC来生成.so,但从来不用-fPIC来生成.a.但是.so一样可以不用-fPIC选项进行编译,只是这样的.so必须要在加载到用户程序的地址空间时重定向所有表目.
第二步:使用-shared选项将所有目标文件打包为动态库
与生成静态库不同的是,生成动态库时我们不必使用ar命令,我们只需使用gcc的-shared选项即可。
gcc -shared mymath.o -o libmyc.so.

第三步:将头文件和生成的动态库组织起来.

4.2:使用
我们还是用刚才使用过的main.c来演示动态库的使用。

简单说明一下,使用该动态库的方法与刚才我们使用静态库的方法一样,我们既可以使用-I,-L,-l这三个选项来生成可执行程序,也可以先将头文件和库文件拷贝到系统目录下,然后仅使用-l选项指明需要链接的库名字来生成可执行程序,下面博主以第一种方法为例进行演示.此时使用gcc编译main.c生成可执行程序时,需要用-I 选项指定头文件搜索路径,用-L选项指定库文件搜索路径,最后用-l选项指明需要链接库文件路径下的哪一个库.
gcc main.c -I mylib/include/ -L mylib/lib/ -lmyc与静态库的使用不同的是,此时我们生成的可执行程序并不能直接运行.
有个地方需要注意,我们使用-I,-L,-l这三个选项都是在编译期间告诉编译器我们使用的头文件和库文件在哪里以及是谁,但是当生成的可执行程序生成后就与编译器没有关系了,此后该可执行程序运行起来后,操作系统找不到该可执行程序的所依赖的动态库,我们可以使用ldd命令进行查看.

有的uu好奇,为什么编译编过了,但程序没法运行呢?原因在于:动态库要在程序运行的时候,要找到动态库然后加载并运行,那么为什么会找不到动态库的路径呢,因为在编译的时候,告诉了gcc/g++编译器动态库的路径,但是并没有告诉操作系统.静态库为什么没有这个问题,因为在编译期间,静态库已经被拷贝到我们的可执行程序的内部了,那么此时加载就跟库没有关系了.
解决该问题的方法有以下三个:
方法一:拷贝.so文件到系统共享库路径下.
sudo cp mylib/lib/libmyc.so /lib/x86_64-linux-gnu.

方法二:建立软链接.

方法三:通过导入环境变量(LD_LIBRARY_PATH----->加载库路径).

方法四:修改.bashrc配置文件,让环境变量永久生效.

第五种方式:在/etc/ld.so.conf.d的目录下,新增动态库的配置文件(.conf结尾),然后使用ldconfig加载即可~

5:动静态库各自的特征
静态库
- 静态库是程序在编译链接的时候把库的代码复制到可执行文件当中的,生成的可执行程序在运行的时候不再需要静态库,因此使用静态库生成的可执行的大小一般比较大.
优点:使用静态库生成可执行程序后,该可执行程序就可以独自运行,不再需要库了.
缺点使用静态库生成可执行程序会占用大量空间,特别是当有多个静态程序同时加载而这些静态程序使用的都是相同的库,这时在内存当中就会存在大量的重复代码.
动态库
- 动态库是程序在运行的时候才去链接相应的动态库代码的,多个程序共享使用库的代码,一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码.
- 在可执行文件开始运行前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程被称为动态链接,动态库在多个程序间共享,节省了磁盘空间,操作系统采用虚拟内存机器允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间.

优点:节省磁盘空间,且多个用到相同动态库的程序同时运行时,库文件会通过进程地址空间进行共享,内存当中不会存在重复代码。
缺点:必须依赖动态库,否则无法运行。
6:动态库加载
6.1:站在OS角度理解
6.1.1:动态库概念
1:动态库:也称为共享库,是一种包含代码和数据,可以在多个程序之间共享的文件,存放在磁盘上.
与静态库不同,静态库在程序编译时会被完全复制到可执行程序中,而共享库则在程序运行时被加载到内存中,如果多个程序使用同一个共享库,OS会让这些进程共享内存中的同一份库代码和数据,即:动态库的代码和数据在内存中只存在一份.
2:管理:系统中可以同时存在多个已经被加载的库,OS需要管理它们,那么就会先描述再组织.
6.1.2:动态库加载的过程
- 检查依赖:程序启动时,动态链接器会检查该程序依赖的所有动态库.
- 搜索路径:动态链接器会在预设的库搜索路径中查找所需的动态库文件.
- 加载与映射:第一次加载、后续加载.
第一次加载:如果动态库尚未被加载到内存中,动态链接器会将该库加载到内存中,并映射到进程地址空间的共享区中.后续加载:如果其他进程也需要共享这个库,动态链接器会检查内存中是否已存在该库;如果已经存在,只需修改进程地址空间中的映射关系,指向已存在的库副本;如果不存在则重复第一次加载的过程.
优点:节省内存、易于更新、提高了程序的性能和安全性.
我们举一个简单的例子,以下面这张图为例

磁盘中有test.exe这个可执行程序和libmyc.so这个动态库.当test.exe这个可执行程序编译执行后加载到了物理内存中,由于test.exe这个进程在加载的时候,使用了libmyc.so这个动态库,所以就需要将libmyc.so通过页表,映射到地址空间的共享区里面.不一定只有一个进程会使用libmyc.so这个动态库,那么当有多个进程使用libmyc.so这个动态库的时候,是不是每一个进程都要将libmyc.so这个动态库都加载一遍呢?答案是不需要,因为在后面进程的使用libmyc.so这个动态库之后,它已经被加载到了内存里面,那么如果需要使用动态库的话,首先将自己的代码和数据加载到内存里面,然后将已经加载到内存里面的动态库通过页表映射到自己的地址空间里面.
6.2:编址
我们的可执行程序,编译成功,没有加载运行,那么所对应的二进制代码中有"地址"吗答案是包含了地址.还有一点,在Linux当中形成的可执行程序叫做ELF的可执行程序.uu们不要简单地认为程序编译完就变成了二级制,二进制是有自己的固定的格式的,固定格式里面一定要有ELF可执行程序的头部,头部又有很多可执行程序的属性.可执行程序在编译之后,会变成很多的汇编语句,每条汇编语句都有对应的地址.
那么有的uu有些疑问,程序都没有加载,那么编译出来的地址是什么地址呢,那么又是如何对多条汇编语句进行编址的呢?在编址时,就一定要有个地址范围,当前我们的编址范围就是从0000 0000(全零,有多少位就有多少0)~~~FFFF.FFFF ------>不一定从全0开始,也不一定从全F开始.在系统当中,把一个可执行程序从全0到全F去进行绝对编址的做法叫做平坦模式.
- 可执行程序在编译和链接完成了以后就已经具有了明确的格式信息,且在文件中按照类别划分了不同的区域(如:代码段、未初始化数据段(BBS段)、初始化数据段),每个区域都有其对应的地址信息,通常是虚拟地址.
- 编址:在编译和链接阶段,为程序和库中的符号(变量、函数)分配地址的过程,主要有绝对编址、相对编址两种方式.
可编址的范围:32位平台,[0,2^32]----->[0,4GB],64位平台:[0,2^64]---->[0,16GB].
3.绝对编址:在编译和链接的过程中,符号的地址是固定的,即:已经确定了符号的实际的物理的内存地址.这种方式要求程序运行时,必须加载到特定的物理地址处,否则无法正确的运行.
PS:绝对编址中的地址 == 实际的物理内存地址.
4.相对编址:也成为逻辑地址、虚拟地址.在编译和链接过程中,符号的地址是不固定的,而是相对于某个基地址的偏移量.这种方式允许程序在加载时动态确定实际地址,从而实现位置无关代码.
符号地址 = 基地址 + 偏移量.基地址在编译链接阶段是未知的,通常是由OS在程序加载时分配的虚拟地址,是在地址空间内的一个起始地址,如:0x800000.
那么地址空间、页表中的数据来自哪里.
- 当基地址确定后,OS会使用可执行程序中各个区域的虚拟地址,来初始化进程地址空间、页表.每个区域在进程地址空间都有一个相对应的虚拟地址范围.在程序被加载到内存中,OS会自动分配物理内存,并构建页表来建议虚拟地址和物理地址之间的关系.
每个可执行程序大小不同,说明了每个程序中各个区域虚拟地址范围也会不同.相应地,当这些程序被加载到内存变为进程时,则每个进程地址空间中各个区域的虚拟地址的范围也是不同的.
6.3:理解动态库链接与加载
6.3.1:一般程序加载的过程
一般程序加载的过程.
- 读取可执行文件,并解析文件中各个阶段的信息:OS先从存储介质(如硬盘)中读取可执行文件(程序代码、数据以及依赖的库),然后OS会解析可执行文件中的各个段(如代码段、数据段、堆栈段等)的信息.这些包含了程序运行所需的所有指令和数据.
- 分配虚拟地址空间、并确定基地址.
- 加载程序段、初始化地址空间:OS将可执行文件中的各个段加载到地址空间的相应位置.
- 重定义:在加载过程中,OS会进行重定位操作,这包括解析程序中的符号引用(如函数名、变量名等),并将它们转换为实际的物理内存地址.
- 初始化数据和未初始化数据段、构建页表.
- 设置程序计数器(PC指针):在程序开始之前,OS会将PC指针设置为程序入口点的地址(main函数的地址),这是可执行程序的第一条指令的虚拟地址.
- 执行程序:CPU会根据程序计数器中的地址从内存中读取指令,并执行它.
地址空间的构建和管理,需要由CPU、编译器、OS三者共同配合完成.
- CPU提供硬件支持,cr3寄存器存储页表的指针,MMU负责将虚拟地址转换为物理地址.
- 编译器负责将源代码编译成机器码,并生成可执行文件,这个文件包含了各个段的信息;编译器在编译过程将程序中的符号地址编译成相对于基地址的偏移量.
- OS负责创建地址空间,并选择一个基地址;OS负责加载程序的各个段来初始化地址空间,并进行重定位来确定实际物理内存;OS负责创建和初始化页表,将虚拟地址转化为物理地址;OS处理缺页中断,动态分配物理内存.
CPU执行程序的过程

OS将可执行程序加载到内存,然后拿每行代码的物理地址构建页表的右侧,拿虚拟地址构建页表的左侧,然后就能对代码进行范围性的映射.OS读取可执行程序表头中的入口地址(main),将其交给CPU,CPU的程序计数器指向main函数的虚拟地址117b,CPU从这个地方开始执行指令:取指令(虚拟地址)--->地址转换---->分析指令---->执行指令----->更新PC指针(虚拟地址). PC指针会保存正在执行的指令的下一条指令的地址----->pc指针指向哪里,CPU就去执行哪里的代码.
6.3.2:动态库的加载
对于库的数据和方法的访问,都是可以通过库在地址空间的起始地址+程序内部的偏移量来实现。
