目录
1.动静态库
1.1.如何制作一个库
1.2.静态库的使用和管理
1.3.安装和使用库
1.4.动态库
1.4.1.动态库的实现
1.4.2.动态库与静态库的区别
1.4.3.共享动态库给系统的方法
2.动态链接
2.1.操作系统层面的动态链接
1.动静态库
- 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静 态库
- 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文 件的整个机器码
- 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个 过程称为动态链接(dynamic linking)
- 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚 拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。
1.1.如何制作一个库
库(Library)是一种预先编写好的可重用代码集合,它包含了一组函数、类、数据结构或其他可执行代码的集合。库的目的是为了提供一些常用的功能,以便开发人员可以在自己的程序中直接调用这些功能,而不需要从头开始编写代码。
在我们实际开发的场景下,当我们写好一个库时,我们往往是将源代码(.c文件)经过编译后的文件(.o文件)和头文件一起打包给库的使用者,也就是我们写好的库是不包含源代码的。
文件编译
一堆源文件和头文件最终变成一个可执行程序需要经历以下四个步骤:
- 预处理: 完成头文件展开、去注释、宏替换、条件编译等,最终形成xxx.i文件。
- 编译: 完成词法分析、语法分析、语义分析、符号汇总等,检查无误后将代码翻译成汇编指令,最终形成xxx.s文件。
- 汇编: 将汇编指令转换成二进制指令,最终形成xxx.o文件。
- 链接: 将生成的各个xxx.o文件进行链接,最终形成可执行程序。
库中包含着.h文件和.o文件
接下来我们写一个demo并把它打包成库
我们以实现计算器这个库
一般来说我们要编译这个test.c这个可执行文件,需要将三个.c文件同时编译使用
gcc test.c add.c sub.c
但是这样子就需要将源代码给到别人,而开发中的源代码一般是闭源的,所以我们需要先将这几个实现.h文件的.c文件编译成.o文件
gcc -c add.c sub.c // 生成对应的.o文件
那么我们现在开始创建库的雏形,只给.h文件和.o文件(这个图add.c应该为add.h)
用户在使用时通过先将自己的测试文件转换为.o文件然后再一起编译这几个.o文件
gcc test.o add.o sub.o
假设在实际开发中,有10000个.o文件那这样子编译会不会容易漏掉,所以这时候我们需要打包文件,生成静态库
// 通过这条指令我们打包了一个静态库mycal ar -rc libmycal.a *.o
如图所示我们就打包好了一个静态库
一般来说我们通常通过makefile来打包好静态库
static-lib=libmycal.a $(static-lib):add.o sub.o ar -rc $@ $^ %.o:%.c gcc -c $< .PHONY:clean clean: rm -f *.o
1.2.静态库的使用和管理
我们在1.1.中制作了一个mycal的静态库,那么从库的使用角度出发我们怎么来使用这个库呢?
首先当我们写了一个库给他人使用时,此时作为第三方库,gcc无法认识这个库,需要链接指定的库
这时我们需要连接上这个静态库,输入链接指令
// gcc 需要编译的文件名 -l库名称 -L 库所在的路径 gcc test.c -lmycal -L.
注意这里的 -l 直接连接对应的库名称,且库名称需要去掉 lib 和 .a
这样我们就完成了静态库的使用了!!!
值得注意的是:一个可执行文件,可以实现动静态库的混合链接,如何连接时加入 -static 选项,表示只使用该文件编译时需要的静态库。
我们在lib文件夹中发现.h文件和.a库放在一起,在实际开发场景中,可能存在大量的这些文件,为了便于管理,我们通常是将他们分组存放后,然后打包挂到云端提供给他人使用
回到最初的起点,我们开始准备打包
这里是makefile内的代码块
static-lib=libmycal.a $(static-lib):add.o sub.o ar -rc $@ $^ %.o:%.c gcc -c $< .PHONY:output output: mkdir -p mycal_lib/include mkdir -p mycal_lib/lib cp -f *.h mycal_lib/include cp -f *.a mycal_lib/lib .PHONY:clean clean: rm -f *.o *.a output
接着我们make编译一下
这时就回到了我们之前讲的需要管理的场景,我们在makefile里写了一个脚本output
形成了一个目录,我们查看一下
这样子就实现了我们对这个库进行管理了
tar czf mycal_lib.tgz mycal_lib
最后的最后我们打包好这个文件就可以挂载服务器端让他人下载使用了
1.3.安装和使用库
书接上文,假设我们从网上下载一个mycal_lib这个库,我们需要如何加载进我们的库环境中呢?
首先需要解压这个包,获得库,接着就是将库安装到开发环境中!!!(本质上是拷贝)
安装开发环境的本质就是将第三方库提供的.h、.a文件分别放到系统中的指定位置,一般是拷贝到对应的include、lib文件夹里
那么当我们运行时,gcc默认就会在系统的开发环境中来寻找我们download的库和头文件了,使用时就只需要连接需要的库即可
gcc test.c -lmycal
如果我们不安装到系统里,我们需要怎么使用这个库呢?
这时我们需要输入指令来寻址(因为头文件和库文件在mycal_lib中,gcc找不到)
// -I 新增头文件搜索路径 -l为连接的库名称 -L 新增库文件的搜索路径
gcc test.c -I mycal_lib/include -lmycal -L mycal_lib/lib
那么这样子我们就完成了,对下载库使用的学习了
1.4.动态库
因为动态库的制作和静态库的制作思路一致都是形成.o文件然后打包,但是会有一些细节上的不同,接下来我们来实现一下动态库
1.4.1.动态库的实现
首先是编译文件时和打包指令不同
// 编译成.o文件时需要添加 -fPIC
gcc -fPIC -c add.c sub.c
// 打包成库时用gcc指令
gcc -shared -o libmycal.so *.o
我们只需要修改一下makefile就也可以通过脚本实现之前讲的内容
dy-lib=libmycal.so
$(dy-lib):add.o sub.o
gcc -shared -o $@ $^
%.o:%.c
gcc -fPIC -c $<
.PHONY:output
output:
mkdir -p mycal_lib/include
mkdir -p mycal_lib/lib
cp -f *.h mycal_lib/include
cp -f *.so mycal_lib/lib
.PHONY:clean
clean:
rm -f *.o *.so output
实现的部分我们不再赘述了
1.4.2.动态库与静态库的区别
那我们通过使用静态库的方式来调用一下这个动态库
我们发现就算我们声明了路径,链接了调用的库,最后可以正常生成这个a.out文件但是我们运行时发现 -> 报错:加载共享库,无法打开这个共享库文件
这里我们解释一下: 静态库在编译后,可执行程序和静态库打包在一起相当于加载进了整个文件中,运行期间不需要找到静态库。而运行动态库是和可执行程序分离的,所以需要将动态库加载到内存中。
可执行程序 和 动态库 同时存在于内存中才可以实现动态库!!!
我们发现这时系统找不到动态库的位置,因为我们之前只给了test.c寻找到了动态库的位置,但是系统还是不知道动态库在哪里,所以需要我们把动态库的位置共享给系统。
1.4.3.共享动态库给系统的方法
- 将头文件和库文件添加到系统默认的区域,也就是拷贝到相应的include和lib
// 拷贝库中的所有.h文件到系统默认的include中 sudo cp mycal_lib/include/*.h /usr/include/ // 拷贝.so库文件 sudo cp mycal_lib/lib/*.so /lib64/
- 建立软连接,动态库会在pwd中寻找库
ln -s mycal_lib/lib/libmycal.so libmycal.so
动态库在运行时会查找当前路径是否能找到这个动态库,所以我们可以建立软连接来实现!
这时候我们可以思考一下:我们能不能把这个软连接安装到系统里面,而不是安装整个整个库呢?也是可以的!
- 添加至环境变量LD_LIBRARY_PATH中(内存级别,退出shell会被清空)
// 通过pwd找到动态库所在的路径添加到环境变量里 export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/Czh_Linux/test/mycal_lib/lib
- 更改系统的配置文件(不会因为重启shell而被清空)
系统中存在一些管理动态库的配置文件
// 文件的路径 /etc/ld.so.conf.d/
接着我们就需要写入进这个目录里面,在里面创建一个自己的动态库配置文件
// 需要sudo权限或者root touch /etc/ld.so.conf.d/my_so.conf
接着在这个my_so.conf文件里面写入你库的地址,进入动态库的地址用pwd打印出地址然后拷贝进这个文件中。
// 直接拷贝这一段话到你创建的.conf文件中 /home/Czh_Linux/test/mycal_lib/lib
这时已经完成了写入动态库信息到配置文件中!!!
// 如果没有生效就需要刷新一下 sudo ldconfig
2.动态链接
在Linux中可执行文件一般以ELF的格式出现,符号表中存放着调用某些库的方法的地址,那也就是当可执行文件加载进内存时,进入内存的只有文件中对应库方法的地址,而没有库中的实现,也就对应了我们之前没有将动态库加载进内存中会出现报错!
所以进行动态链接的过程就是将对应库的方法的地址存放在可执行文件的符号表中,再配合着将对应库加载进内存中!
我们接着思考,当程序加载进内存中时是以什么形式出现的呢?从汇编语言中我们就能看出大部分的函数变量加载进内存时,最终是以“地址”的形式加载进内存的。那么这个问题引申下去就是如何对代码进行编址呢?
在编译器中,也是通过“虚拟地址空间”编址,虚拟地址空间可以看成是一种编译标准
文件通过编译后在形成进程加载进内存之前,代码和数据就已经具有了自己的地址(虚拟地址),大多数情况叫做处于磁盘文件的“逻辑地址”,核心是:基地址 + 偏移量
一般情况下:设定基地址为0,然后偏移量来进行定位区分(Linux的平坦模式)
在计算机中对程序进行编址时,一般是两种方式:绝对编址(以基地址为标准) 和 相对编址(以其他个体地址为标准)
库当中形成地址一般是通过相对编址,记录函数间的偏移量
假如:Entry的main函数地址为0x1234,进入main函数后遇到的第一个地址为0x1236那么他们的偏移量固定为(0x1236 -0x1234)
未来库在内存中的任意位置加载,那么库内部函数的相对编址始终不变,所以无论是加载在内存的某个区域,只要找到Entry我们都能通过不变的“地址”来使用库中的所有的函数。
这就是 fPIC 与位置无关码
2.1.操作系统层面的动态链接
我们知道了ELF文件(编译后的代码)也实现了类似操作系统中的虚拟地址空间,也就是操作系统和磁盘通过这个虚拟地址空间进行代码的交互,又动态库在内存中会映射在pcb的虚拟内存的共享区内,这样子就穿起来了操作系统和磁盘内的ELF可执行文件(这个不就是进程运行文件的动态链接吗)
但是我们感觉还是有一点抽象,究竟代码的地址和进程的地址是如何联系起来的呢?
我们之前强调了相对编址这个概念,在ELF的符号表中存储着各个调用库的函数的地址(只要知道main函数入口地址),我们可以计算出来他们相对的偏移量,如果main为a,printf为b,那么偏移量就为b-a,这是从库角度出发。当我们从文件的代码区出发,运行代码的本质就是地址的跳转,但是代码区相当于只存储了代码的声明,实现在库中,所以我们就需要代码区进行到某个函数时跳转到对应共享区的部分,通过偏移量,映射到内存中代码的实现!
接着我们再换一个角度,从进程的调度中出发
CPU在进行进程调度时也是通过访问虚拟地址来实现的,也就是在我们所说的代码区和共享区进行跳转通过CPU中的指令寄存器。我们知道代码在内存中拥有自己的物理地址,而CPU指令寄存器读入代码虚拟地址通过页表映射找到物理地址,因此CPU可以实现某一行代码的功能。
- 操作系统可以读取编译后的ELF程序的入口Entry地址(虚拟地址),这个地址被操作系统用于进程pcb对应的正文代码的main函数的入口,接下来操作系统将这个入口地址加载到指令寄存器中。
- 从main函数还是执行,一步一步调用不同的代码,读取对应的虚拟地址,然后通过页表映射到物理内存,实现库方法和代码的运行,接着继续跳转回CPU,读取下一条虚拟地址
- 如此往复这个循环,最终完成我们进程的调度
找代码是通过物理地址,代码之间跳转通过虚拟地址。每个程序加载进物理内存中,每个变量、函数都有自己的物理地址和虚拟地址。
核心:
- 只要获得起始地址再配合偏移量就能实现库中的任何方法
- 通过虚拟地址映射到物理地址来实现代码的定位
- 操作系统和编译器用同样的虚拟地址空间标准,实现CPU进行虚拟地址之间的转化