本篇要谈论的内容是关于动静态库的问题,具体的逻辑框架是建立在库的制作,库的使用,和库的原理来展开,基于上述的三个模块来对动静态库有一个较为清楚的认知
在前面的学习中知道,在用户写完代码后,想要将写完的代码转换成可以执行的可执行程序过程是一个相当复杂的过程,那么在这段过程中要处理的过程基本有,例如预处理,编译,汇编,链接,可执行程序,而在学习编译工具gcc的时候又提到过,在代码进行链接的过程中,系统必须要提供对应的动静态库,因此就引入了动静态库的概念
所谓动态链接,就是让程序和库产生这种地址性的关联,而静态库,就是把目标的库文件直接拷贝到对应的可执行程序当中
对于静态库,第一步要先引入的是静态库的原理,静态库就是在进行编译链接的过程中,把静态库中所包含的代码都拷贝到可执行程序中,之后在可执行程序的运转就不需要静态库了
对于动态库,原理是在程序运行的时候才会链接动态库的代码,多个程序会共享使用库的代码,而一个与动态库链接的可执行文件只有一个函数入口地址的表,而不是整个文件的内容,上述是关于动态库的基本原理,关于这些内容后面就进行一个一个的解析
首先,创建出对应的文件,这里假设要实现一个计算器,那么实现对应的函数声明和实现过程:
再创建一个对应的测试函数
#include "Add.h"
#include "Sub.h"
#include "Mul.h"
#include "Div.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));
printf("%d * %d = %d\n", a, b, Mul(a, b));
return 0;
}
那么现在准备工作就完成了,下面的问题是,我想要编译生成一个可执行程序,应该输入什么指令呢?
gcc -o test.exe test.c Add.c Sub.c Mul.c Div.c
这样就完成了编译,生成了一个可以被运行的可执行文件,那么下面对于上面的现象提出一些问题
1. 为什么在编译的时候不带头文件?
因为头文件所属的位置就在当前路径下,编译器是直接可以找到的,如果头文件对应的查找路径是在当前目录或者是指定的目录,是不需要写在编译选项中的
2. 上面的步骤是否每一步都需要?
这个问题问法很奇怪,怎么说是每一步都需要,假设现在要生成的是一个可执行程序,其实根本不太需要把源文件全部编译生成一个可执行文件,因为这样的过程需要经过预处理编译汇编链接等等,而实际上在编译这样的多文件项目的过程中,只需要把源文件都编译为.o后缀的文件,再将这些文件进行链接形成一个可执行文件,这是被倡议的一种链接方式
这也就是在任何项目中,都会存在这样的文件的原因,在进行编译生成可执行文件的过程中,如果是直接将已经生成的这些.o后缀的文件进行一定的链接组合,就能生成可执行程序
基于上面的原因,我们重新进行一次编译,这次按照上述的过程生成对应的.o文件即可,为了方便后续进行其他的使用,写一个Makefile来自动进行编译比较好:
%.o:%.c
gcc -c $<
Test:Add.o Sub.o Mul.o Div.o test.o
gcc -o $@ $^
.PHONY:clean
clean:
rm -rf *.o Test
那么上面就是实现了Makefile,但是和前面写的不太一样,这个Makefile是直接将这些.o后缀的文件生成了一个可执行程序,所以现在就要先生成这样的.o后缀的文件,借助gcc编译工具就可以生成,之后就可以运行出结果了
这里补充一点上面写的这个语句,%.c的意义就是类似于一种通配符,因为后续生成test可执行文件是依赖于.o文件的,但是它们都不存在,那么此时就需要根据依赖关系来进行推导,而在推导的过程中,当Makefile在进行被编译的时候,就会把%.c全部展开,之后就会进行不断的推导,展开成四个gcc的编译方式语句,而这里的$<表示的是把文件依赖列表中的内容一个一个的传递到下面的命令中,最后连起来,就能解析成对应的内容了
那么现在的问题是,如果源文件已经不需要了,而是只需要这些.o为后缀的文件,也就是说,把这些文件进行打包,作为一个库,而把这个包交给使用者后,使用者就只需要写出自己的.c文件,再编译成.o文件,就能和我刚才打包好的包直接进行链接,就省去了前面的很多步骤,像这样的过程就是前面所述的核心观点,基于这样的原因,现在当前的主要任务就是生成一个库
那么就对Makefile进行改造,改造的核心思路就是基于上述的这一系列原理,将文件编译成.o文件,再将文件整合到一个固定的地方,这就是库的概念
上面的思路原理存在,下面的一个问题是,直接把.o文件存到一个固定的位置,显然是不太合适的方式,如果此时有几百个文件呢?如果也是一个一个的进行转移,那么可能会有所遗漏,这都是不被建议和允许的,基于这样的原因,有了打包的概念
ar指令
ar -rc $@ $^
上述就是在Linux中打包的指令,ar命令就是把所有的源文件进行打包形成对应库文件的过程,其中这个rc表示的是replace和create的意思,表示的是如果不存在就创建,存在就替换,总之这样就可以形成一个完整的.a文件
下面要做的就是生成一个库文件,库文件的生成也能放到Makefile中来写,具体的书写过程如下
# 库的名字是mymath,是个静态库
static-lib=libmymath.a
# 生成库需要Add.o Sub.o Mul.o Div.o,实现方式是ar指令
$(static-lib):Add.o Sub.o Mul.o Div.o
ar -rc $@ $^
# 生成.o文件需要把.c文件按照下面的gcc编译选项一个一个生成($<)
%.o:%.c
gcc -c $<
# 建库
.PHONY:output
output:
mkdir -p mymath_lib/include
mkdir -p mymath_lib/lib
cp -f *.h mymath_lib/include
cp -f *.a mymath_lib/lib
# 清空内容
.PHONY:clean
clean:
rm -rf *.o *.a mymath_lib
上述的完整Makefile进行推导解析:第一行表示这是一个静态库,静态库的名字叫做mymath,前面的lib和后面的.a都是前缀和后缀,静态库真正的名字叫做mymath,而后面对于这个静态库的生成方式有了一个定义,静态库的生成依赖的是后面的四个.o文件,而具体的生成方式是ar指令,那么此时Makefile就会进行推导,Makefile现在需要.o文件,但是现在没有,所以在后面就提到了生成.o文件的过程,是利用.c文件来生成的,这样就完成了Makefile的过程,后面的两个操作就是建库和清除的过程
执行结果如下:
这样就完成了一个静态库,那么接下来要进入的问题是,库的使用问题
进入这个话题,就意味着现在我们已经有了静态库,但是这个库怎么用呢?
朴素做法
现在已经有了库,别人给我提供了这些方法,我该如何使用?所以就用到了这些头文件提供的函数,但是现在如果直接编译会发现根本编译不过去,说明就现在而言,还是不可行的
原因在于什么呢?从报错信息来看,找不到这里对应的头文件,原因在于这些库文件都是被保护起来的,现在在本地编写之后的代码是无法找到对应的内容,说明现在还得想办法把这些库文件都让编译工具能够找见才可以,那么就把头文件都放到代码所在的目录中,再进行编译:
此时报错信息是,没有定义,说明现在已经找到对应的头文件了,但是没有找到定义头文件的地方,这个就叫做链接报错,那我写的这个库为什么用不了呢?
对于我们自己写的库函数,都叫做第三方库,而gcc不认识第三方库,哪怕是就在当前路径下,也依旧不认识这个第三方库,因此就引出了要链接库的概念,所以就要引出一个选项,大I
-I 选项表示的意义是link,也就是链接指定的一个库,也就是说告诉编译器,你在进行编译的时候要使用这个库,所以执行下面的指令
gcc test.c -I mymath_lib/include/ -l mymath -L mymath_lib/lib
上面这一串是很长的指令,但是不急,一点一点的分析
首先是,要进行编译的对象是test.c,后面的这个选项表示的是新增头文件的搜索路径,后面紧跟着的就是头文件的搜索路径,而后面的小l表示的是指明链接的库名称,而大L表示的是新增库文件的搜索路径,基于上面这么一长串的选项,就能最终编译出来我们想要的结果,事实上也确实生成了,这说明我们的静态库已经使用成功了
之前我们使用的C标准库从来不需要指定,而此时为什么这里就需要指定了呢?因为这里我们自己实现的叫做第三方库,而gcc是专门用来处理C语言的编译工具,所以在进行编译的时候会直接到指定的路径下去寻找,gcc已经认识了C语言提供的官方库,而我们自己实现的第三方库它并不认识,即使看见了也不认识,需要我们主动的为gcc和自己写的库建立起合适的联系,才能让他们之间认识,编译器才能进行工作编译链接等等的后续操作,最终生成一个可执行程序
因此得出的结论是,未来我们把我们写的静态库提供给别人去使用,只需要把对应的.h头文件和对应的.a文件交给别人就够了,其中这个.a文件就是我们前面所说的.o文件的集合
那么下一个问题是,当使用ldd指令去查看依赖关系的时候,却发现一个问题
问题是,我们生成的这个a.out并不依赖我们写的库文件,这是因为在默认的情况下,可执行程序都是动态链接,因此ldd指令只能查询动态库,而静态库在编译期间就已经被拷贝到可执行程序当中了,因此也就查不到对应的信息,静态库是无法检查的
这里引出一个结论:gcc默认采取的是动态链接,但是对于个别库来说,如果你只提供.a的方式,那编译器也无能为力,只会把内容局部性的作为静态链接,而其他库则采取的是正常的的动态链接,如果带有 -static选项,那就必须要采取静态链接的方式了
所以说,在使用gcc进行编译的时候,如果这个程序依赖10个库,那么gcc就会尽量的把这10个库对应的.so文件都拿到,但是如果没有动态库也没关系,还可以去拿静态库
加入现在需要某个库,我们从网上去下载,得到了库,下一步应该安装库,那如何安装库?实际上就是把对应的头文件和库文件都安装到系统当中,怎么安装到系统?本质上就是把对应的文件安装到usr路径下的include路径和lib路径下,所以说,安装的本质,就是把头文件和库文件分别拷贝到系统的指定路径下,只要拷贝到gcc的默认路径下,那么gcc在进行搜索的过程就不是问题
关于动态库如何制作呢?其实也和静态库类似,从原理上将和静态库都相同,都是在源文件编译成.o文件后,给这些个文件进行打包,就形成了动态库,区别是,在形成对应的.o文件时,需要带上一个fPIC的选项,这个选项的意思是与位置无关码,至于这个是什么意思在后续会有讲解,这里只需要知道是这样的原理即可,具体原因主要是因为,动态库本身没有把内容拷贝到可执行程序当中去,因此动态库和可执行程序之间只是地址方面的关联,因此使用了动态链接后,只是告诉了可执行程序,你所需要的内容在哪里,在哪一个文件的什么位置,你需要的时候自己去找就可以,那么这个过程就叫做动态链接,所以在使用的时候只需要带上一个fPIC就可以了,之后再对生成的.o文件进行打包,使用的命令还是gcc命令,生成一个.so的文件,但是要带上-shared选项,表示的这个文件我想要生成的是一个共享库,也叫做是动态库
不管是在Linux中还是Windows中也好,动态库是比较重要的,形成动态库不需要额外的工具,只需要gcc就可以帮助我们完成这个过程,从这个角度也能看出,形成动态库的方法直接内置到了编辑中,但是静态库没有做出对应的内置,这也就说明动态库的重要性,那么下面对于Makefile进行对应的改造,生成我们所需要的动态库:
# 库的名字是mymath,并且是个动态库
dy-lib=libmymath.so
# 动态库的生成方式是用gcc,带上编译选项,直接编译就可以
$(dy-lib):Add.o Div.o Mul.o Sub.o
gcc -shared -o $@ $^
# 生成动态库所需要的.o文件需要依赖于.c文件生成,并且也需要带上特殊选项,表示的是与位置无关码
%.o:%.c
gcc -fPIC -c $<
# 整体将生成的内容进行打包
.PHONY:output
output:
mkdir -p mymath_lib_so/include
mkdir -p mymath_lib_so/lib
cp -f *.h mymath_lib_so/include
cp -f *.so mymath_lib_so/lib
# 对部分内容做出清理
.PHONY:clean
clean:
rm -rf *.o *.so mymath_lib_so
如果想对于这个动态库把它安装到系统中,那么就需要放到指定的路径下,那么现在我们先不对于它做出任何操作,只是和静态库一样来尝试编译它,结果是:
事实上,用静态库的编译方式来对动态库进行编译,也成功了,说明到现在为止和静态库比起来没有任何区别,那么接下来接续:
在运行程序的过程中失败了,不过这也是可以预见的,因为静态库相当于直接把内容拷贝进去了,而动态库只是告诉你该去哪找,而在运行的时候,程序并不知道去哪找,所以找不见,这样的结果也是意料之内的
动态库和你的可执行程序是分离的,是两个文件,当执行程序的时候,程序需要被加载到内存,而库中的文件也要能够被系统找到,也是要加载到内存中,所以说,动态链接的程序,程序和库文件是分开的,在进程进行加载的过程中,程序库也要被找到并且加载,只有程序库被加载了,才能跑的起来,动态链接非常依赖动态库
那么如何能找到对应的内容呢?下面讨论的就是这个问题
1. 直接安装到系统中
这个是简单粗暴的方法,当然也是简单可行的,看下面的操作
把头文件放到系统中
把库文件放到系统中
此时运行程序,就可以运行起来了,因为进程在运行的过程中可以在默认路径下找到我们所需要的内容,就能把这些内容加载到内存中供进程使用,调度等等
如果把对应的文件从对应的库中删除,那么就不能再使用了:
2. 软链接的方式
建立链接后,再进行编译运行,也能正常运行出结果
说明这也是可行的,同时查看ldd情况,发现确实是存在链接情况
现在解除链接,链接断开也就无法运行了
3. 通过环境变量的方式
这个方式也很好理解,默认的寻找方式是到lib64下去寻找,然而在这里可以通过修改环境变量,使得环境变量中新增一个配置变量,这样就能继续寻找了,具体操作过程如下:
但是环境变量的修改是临时的,这样的修改只在当次生效,当下次重新登陆就不存在了,这是因为环境变量在每次登陆时,都会由配置文件对其进行修饰,所以最根本的方法,其实是修改配置文件,这样就能每次都修改成功环境变量
4. 修改配置文件
在Linux中,动态库的配置文件的位置在 /etc/ld.so.conf.d/
那么修改的原理就是在这里新增一个文件,在文件中写入我们需要的地址就可以了!
具体操作展示如下:
所以往后你自己需要使用动态库,不管是用别人的还是你自己写的库文件,如果运行是找不到,那么这里就有四种做法
如果同时同一组方法动静态库同时被提供,那么默认使用的是动态库,并且同一组库会提供动静态两种方式,gcc默认使用的是动态库,如果想使用静态库,就带上-static选项
这是本篇探讨的最后一个问题,也是基于上面的两个问题做出的一个原理剖析
静态库我们就不考虑加载了,主要原因是因为静态库本身就已经被加载到了程序的内部,所以没有过多的意义,那么动态库加载有什么意义呢?
在使用gcc进行编译的时候,使用的选项是-c,然后就会形成.o文件,将这样的文件打包就形成了静态库,动态库也是这样的原理,只是在这之前多了一个叫做fPIC的选项,这个东西叫做与位置无关码,那么什么是与位置无关码?该如何理解呢?
在了解它之前,先要清楚的概念是,现如今形成的一般的可执行程序的格式叫做elf文件,也就是说给了一份源代码,经过编译后会形成一个elf格式,生成的这个二进制是有规则的,格式是elf,而对于可执行程序中的elf中会存在很多很多的内容,比如包括有代码区,全局数据区,只读数据区等等,同时也会生成一张表,在这张表上会记录的是函数的具体位置,我在这里调用的这个函数所在的位置是某个.so库里面的某个地址,这样就把可执行程序中用到的全部方法都列到了这张表上,最终达成的效果是,把每一个库里面所用到的方法的地址都填进来,使得最终可执行程序和库中的特定方法的地址产生了关联,这样的过程就叫做动态链接
在经过了动态链接后,就要进行加载,也就是把可执行程序加载到内存中,但是这不够,前面也说到了,库函数同样需要加载到内存中,现在的可执行程序中只有库函数的地址,但是却没有库函数的视线,所以想要让程序真正运行起来,需要的就是把程序所依赖的函数库也要加载进来,虽然可能并不是要立刻加载进来,但是当执行到这个代码语句的时候,这个库必须存在可以让进程调用这个函数
在调用的过程时,跳转到库内的对应位置,调用后再返回到原来的位置之后就可以继续执行了,因此我们说,动态链接要加载的不止是自己,与动态链接关联的库也要全部加载,如果我们要链接的库本身不存在,就会直接报错,在程序加载期间,加载器就会报错说不存在
可执行程序在编译形成可执行文件后,但是还没有加载到内存中,对于这个单独的文件,它有地址吗?答案是有的,这是因为当程序被编译之后,所有的函数名变量名等等内容,都不再会存在,取而代之的是一个一个的地址,从汇编代码的角度来讲,每一步的跳转背后都是一个一个的地址,比如当前在函数的内部定义了一个临时变量,这个临时变量是在栈区存在的,但是此时可执行程序中可并没有栈,栈是在程序加载到内存中运行的时候才有栈区的概念,但是不影响,这个变量是在函数的内部形成的,这也就意味着在形成变量的时候,是通过寄存器为出发点,再加上偏移量,就可以进行访问,所以最终函数名变量名都是地址了
在C/C++程序中,当调用取地址操作的时候,实际上是在打印的时候,地址数字直接把取地址变量名替换掉了,所以就能看到对应的地址了,包括函数也是这样,所有的函数都是没有函数名的,只有一个二进制的代码块,只要找到代码块的位置,就能从上往下进行执行了,最后执行到return语句就返回了
上面这个模块引出的观点是,程序在编译好,没有被加载到内存中的这个独立的过程中,实际上在内部就已经有地址了,只是这个地址需要考虑到另外一个问题,就是在代码中是如何对于各个变量函数进行编址,也就是说,在编译的过程中是如何对这些内容编出对应的地址的呢?理论依据就前面所说的虚拟地址空间的概念
虚拟地址空间不仅仅是操作系统的一种映射技术,更重要的是,虚拟地址空间是一套标准,操作系统在内部要为进程创建地址,代码区,堆区栈区等等内容,而在编译可执行程序的时候,可以按照虚拟地址空间的方式来对可执行程序进行编译,所以说在形成的代码区域的对应位置就会有各种各样的区,这样就完成了编址的目的,未来在对这部分内容进行加载的时候,就相当于直接按照内存进行加载,内存是这样的,磁盘也是这样的,加载的时候就更容易进行模块化的对应过程,因此这个部分想要输出的观点是,虚拟地址空间,不仅仅是操作系统里面的概念,更是在编译器编译的时候,也要按照这样的规则来编译可执行程序,这样才能在加载的时候,进行从磁盘文件到内存的一种映射
因此基于上述的原理,在可执行程序进行编制的时候,函数对应的长度和起始地址都是已经确定好的,对应的区间起始地址和长度也都是确定好的,这些地址都是在加载到内存之前就已经全部确认好的,得出的结论是,我们所有的可执行程序在进行还没有加载到内存的时候,我们的代码和数据其实已经具备了虚拟地址空间这样的概念
上述的可执行程序已经有了虚拟地址,那么对应在磁盘当中也应该有同样的一套地址,只不过在磁盘中不叫做虚拟地址,名字叫做逻辑地址,而逻辑地址其实可以理解为是相对地址,所谓相对地址就是基地址和偏移量组合起来的概念,那么在这里所说的代码和数据都可以借助这样的原理,在编译的时候都写好对应的位置,就能进行快速的跳转,把内容放置到磁盘中,虽然这个程序还没有被用户所执行,但是这个程序基本上应该是遵循什么样的逻辑思路已经被确定下来了
如何理解逻辑地址?其实最简单的一种方法,就是假定基地址是0,那么对于32位的机器来说,它的偏移量的取值范围就是从[0,FFFFFFFF],这种起始偏移量为零的可执行程序的编址方式,在Linux中就叫做平坦模式,所以这里我们就引出了平坦模式的概念
所以,程序是有代码区和数据区的,本质上就是规定代码区的起始地址是什么,偏移量是多少,未来代码区的起始地址就会放到寄存器中,数据区的起始地址是多少,偏移量是多少,每一个区域都会形成一个段,每一个数据段里面的起始地址和偏移量都会采用这样的方式来进行定位,最初的Linux使用了虚拟地址,所以就规定了基地址就是零,偏移量是多少是根据未来的不同情况来决定
不过对于现在来说,基本上逻辑地址和虚拟地址的概念没什么太大的区别,对于一个可执行程序,在磁盘中所使用的地址叫做逻辑地址,只不过这个逻辑地址采用的是起始地址为0,偏移量是从0到全F的这样的一种方式进行编址,这个就叫做逻辑地址
计算机在对程序进行编址的过程中,会有上面的两套编址方式,这两种编址方式其实就是参考点的选择问题,也很好理解,这里不再过多赘述,想得出的结论是,如果采用绝对编址的方式,当一个模块发生了变动,其他的所有模块都会发生变动,但是如果采取的是相对编址,不管相对于参考的内容如何进行改变,区域和区域之间的地址不会有任何变化,只需要在对应的位置加上所谓的偏移量就足够了,这就是想要输出的核心观点
通过上述的这一系列过程,就引出了与位置无关码的概念,说白了就是与位置无关,采用的是函数在库中的起始偏移量是多少,未来这个库在内存中的什么位置加载到对应的位置,函数的地址都不会改变,这就是与位置无关