Linux 基础IO(下)
目录
前言
深入动静态库
1.概念
2.动静态库和动静态链接
3.ELF文件
a.ELF形成可执行文件
b.ELF可执行文件的加载
4.理解链接与加载
a.静态链接
b.ELF文件的加载
c.动态库是如何和我们的可执行程序关联
d.动态库是如何加载的
结尾
前言
本篇是我们linux基础IO的最后一篇,也是最难理解的一篇,不过相信大家如果能坚持看完,对文件系统的理解一定是会很深的,加油!!
深入动静态库
1.概念
什么是库:
库是写好的现有的,成熟的,可以复⽤的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个⼈的代码都从零开始,因此库的存在意义⾮同寻常。
本质上来说库是⼀种可执⾏代码的⼆进制形式,可以被操作系统载⼊内存执⾏。库有两种:
• 静态库 .a[Linux]、.lib[windows]
• 动态库 .so[Linux]、.dll[windows]
静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库
动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
如果我们不想给对方我们的源代码,直接给他提供我们的.o可重定位目标二进制文件,让对方用自己的代码进行链接(gcc *.o)就行,除此之外还需要.h文件,未来我们可以给对方提供.o(方法的实现)、.h(都有什么方法),但是这样当.o文件大多,就很麻烦,所以我们就尝试着将所有的“.o文件”打一个包(.h文件打不了包,因为.h文件要给使用者阅读看方法是怎么调用的),给对方提供一个库文件即可 —— 也就是产生库的原因,多个.o ——> 库 ——>采用的打包工具和方式的不同又产生了动静态库
所以头文件的本质:对源文件的方法说明文档
所以库的本质:就是源文件对应的.o文件的集合
静态库的本质:就是.o打了个包,linux提供的一个工具就是把所有的.o打包生成一个文件
(具体则是下面的内容)
2.动静态库和动静态链接
动静态库中不需要main函数
生成静态库:
ar -rc libmain.a *.o(这样就可以直接取到所有.o文件然后进行归档打包了)
一般静态库的命名规范:前缀lib,后缀.a
-rc:表示如果在libmain.a中已存在相同.o,直接替换,不存在,直接创建
.a静态库本质是一种归档文件,不需要使用者解包,而用gcc/g++直接进行连接即可
交付库 ——>库文件.a / .so + 匹配的头文件都需要交付
Makefile在发布库之前的前置工作是需要把.c文件形成.o,再所有的.o文件形成静态库
发布库的makefile
然后再将这个目录打包压缩(tar czf mylib.tgz mylib)好,然后把这个软件包放入yum资源当中,别人就可以使用yum下载,或者放到网站当中,然后别人下载解压好,要安装的本质就是把对应的可执行程序拷贝到指定的系统能找到的目录下
如果要链接第三方的库,必须指明库名称;所以在编译时得在后面加 -I(大i)头文件所在路径,以及-L 库文件所在路径还要指明这个库的名称:-l(小L)库名称(要去掉lib和.后面的动静态库的标识);但是我们之前写的c、c++编译时却不需要这些,是因为gcc、g++默认就能对应上c和c++的库
带不带空格都行
形成一个可执行程序,可能不仅仅依赖一个库!!
gcc默认是动态链接的(建议行为),对于特定指定具体的一个库,究竟是动,还是静,取决于你提供的是动态库还是静态库,如果动静态库都有,那么取决于gcc,只要有一个动态库,那么就是动态链接,如果非要静态链接,必须要在最后带-static(一旦带了-static,就必须存在对应的静态库)
以上的操作都是建立在我们的gcc/g++默认就找不到我们的头文件和库的情况下的
如果不想像上面这么麻烦,可以直接把我们的库安装到我们系统头文件路径下
但即便是安装好了,在编译时也是需要把我们的库名称写上去
动态库:
生成.o文件:gcc/g++ -fPIC -c *.c
生成动态库不需要ra来打包直接gcc/g++后加shared即可:gcc/gc++ -shared -o libmyc.so *.o
这种相对位置就是位置无关
生成动态库之后也链接好了,但是我们运行这个程序却会出现:
所以我们生成动态库然后链接之后是没有办法立即运行的
[^] 我们的可执行程序是依赖libmyc.so这个动态库的,但是现在它这里是没有找到的
开始运行的时候,我们的可执行程序所依赖的动态库是需要被操作系统知道的,系统 != gcc
静态库就不会有这个运行时找不到依赖库的问题,原因:静态库在链接时是直接把库的实现拷贝到可执行程序中,一旦形成可执行程序,可执行程序不再依赖静态库
要如何才能运行:
-
我们已经知道主要的原因就是系统找不到这个动态库,而系统主要是在我们的系统目录下或者用户安装库的路径下去找,所以方法1就是把我们自己的动态库拷贝到操作系统内部
-
-
我们有一个环境变量:LD_LIBRARY_PATH,操作系统运行程序,也会在该环境变量下查找动态库,这个环境变量经常都是空的,我们可以把我们的库路径导入到这个环境变量中,进而操作系统就可以找到这个库了
当然这种方法我们把操作系统一关,它这个环境变量就没了,是临时的
-
系统当中会存在/etc/ld.so.conf.d/这样一个路径,在这个路径中都会包含要求系统提前准备好要查找/确认的动态库的路径;所以我们可以在这个路径下新建一个文件,文件内容就是我们动态库所在的路径,这样系统也可以查找到
然后把我们动态库的路径写入到这个路径下的我们创建的.conf文件中就好
最后我们重新加载一下配置(sudo ldconfig),把这个配置文件在系统中重新生成一下,操作系统就也可以找到我们的动态库了
第三个方法每次关闭重启之后还会有效(永久有效)
-
可以在当前路径对这个动态库建立软链接
-
也可以在系统路径下建立软链接
对我们的以上内容做一个总结
结论1:我们要使用运行用动态库链接的可执行程序有以下几种方法
-
拷贝至系统
-
建立软链接
-
更改LD_LBRARY环境变量
-
在/etc/ld.so.conf.d/路径下新增内容为我们动态库路径的.conf文件,
结论2:在linux下,默认安装的大部分库都是优先安装动态库
结论3:库 :应用程序 = 1 :n
结论4:我们用的vs不仅仅可以形成可执行程序,也能形成动静态库(感兴趣具体可以问问ai)
其他的第三方库
3.ELF文件
上面的动静态库、可执行程序和.o文件都是二进制的,这些二进制文件都有自己固定的格式,这种格式叫做ELF,他们都是以这种格式放入到二进制文件中的
我们首先了解一下ELF文件的构成:
$ size main text data bss dec hex filename 2092 612 4 2708 a94 main
a.ELF形成可执行文件
所以从ELF角度来看通过.o文件加动静态库形成可执行程序实际是这样的:
-
将多分c/c++源代码,翻译成为目标 .o 文件
-
将多份 .o 文件section进行合并
本质上就是进行二进制文件的合并
[^] 以上只是一个形象图,实际当中合并是在链接时进⾏的,但是并不是这么简单的合并,也会涉及对库合并,此处不做过多追究
b.ELF可执行文件的加载
我们之前学习到的虚拟地址空间中各区的划分和编译器、二进制可执行程序ELF格式有关,加载到操作系统之前,操作系统要读取可执行程序ELF格式,然后用ELF格式中的相关字段初始化地址空间的结构体变量,进而形成地址空间
查看一个可执行程序的section headers可用readelf -S 可执行程序(比如readelf -S a.out)
抬头分别是:名字 类型 起始地址 偏移量
我们目前只需要知道section headers是一个数组,其中0和30位置是每一个数据节的开始和结束
通过起始地址加偏移量就能把每一个节划分出来
查看一个可执行程序的数据节可以用:size 可执行程序
其中的bss段是用来存储未初始化的全局变量的,等加载到内存时,才会开辟对应的类型值的空间,所以bss可以根据字母缩写记忆成更好的节省我们形成的可执行程序所占据的磁盘空间,因为有很多的全局变量如果没有初始化,不需要把它的初始值记录下来
查看一个可执行程序合并之后的segment(程序头)可用:readelf -l 可执行程序
原本的30个数据节合并成了9个数据节
在链接的时候把合并的规则放入Program Header Table中,在这个程序要加载,操作系统就会读取Program Header Table,根据这个Program Header Table所描述的找到要加载的在文件当中的位置,加载指定大小,根据大小把它的若干个数据节依次连续加载到空间里,这样就完成了加载的过程
目前我们只需要知道,之前在文件系统部分,我们知道了在磁盘是被我们分区分组划分成以4kb为单位的,那么同样的,在内存中也是一样被分成以4kb为单位的大小,无论是我们之前的写时拷贝开的空间还是以new开辟空间,操作系统都是统一以4kb为单位来给我们分配空间的;都是和上面section合并成segment一样是通过用统一的4kb大小来提供空间,进而减少页面碎片化,统一且提高内存使用效率的做法
我们的可执行程序也是文件——ELF文件
所以这些数据段整体都是按4kb进行保存的
那么节头表(Section header table)又有什么用呢?
当我们在进行链接时,我们需要把所有的.o文件各自的.test和.data还有各种数据节合并一下,合并之后文件整体变大,每一个节点的起始和结束地址都不一样了,所以需要更新Section header table,此时,它表示的是可执行程序是如何形成的,同时在连接时也会把Program header table形成
节头表也告诉操作系统哪些模块可以被加载进内存,以及加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执⾏的也会让操作系统知道
查看一个可执行程序的ELF Header可以用:readelf -h 可执行程序
Magic可以让系统判断这个文件是不是ELF格式的文件
4.理解链接与加载
理解链接的本质就是在深入理解.o文件
a.静态链接
查看编译后的.o目标文件可用:objdump -d(一部分反汇编代码) .o文件
所以.o文件在链接时它的地址会被重新修改(链接过程中会涉及到对.o外部符号进行地址的重定位),因此叫做可重定向目标文件,
b.ELF文件的加载
所以进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?从ELF中各个segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start, end]等范围数据,另外在⽤详细地址,填充⻚表;
所以:虚拟地址机制,不光光操作系统要支持,编译器也要支持;多个section进行合并之后就是在对可执行程序进行统一编址,有了统一的地址之后,用真实地址填充在.o文件中call后的空地址,就产生了可执行程序内部互相之间的调用关系
用图梳理上述内容:ELF程序是如何转换成为进程的
可执行程序加载进内存时,系统创建tast_struct进程pcb,还要创建对应的mm_struct(管理进程虚拟地址空间的数据结构)结构体
entry point address(入口地址)就是虚拟地址,在磁盘上也叫逻辑地址
再放大到mm_struct的构建:
动静态库都适用的ELF程序是如何加载到内存中的图
c.动态库是如何和我们的可执行程序关联
静态库一般不考虑它的加载过程,不存在加载的问题,因为静态库和.o文件链接形成一个可执行程序了,静态库就是以ELF为载体加载的,所以只要把ELF文件的加载过程弄清楚,就不需要考虑其他的了
库函数的调用分两步
-
被进程看到:动态库映射到进程的地址空间(映射到对应的共享区)
-
被进程调用:在进程的地址空间中进行跳转(从代码区跳转到共享区去访问库)
d.动态库是如何加载的
先给一个结论:动态链接实际上将链接的整个过程推迟到了程序加载的时候
1.要弄清动态库的加载,首先我们的知道:其实我们的可执行程序被编译器动了手脚
动态链接器:
动态链接器(如 ld - linux.so)负责在程序运行时加载动态库。
当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
环境变量和配置文件:
Linux 系统通过环境变量(如 LD_LIBRARY_PATH)和配置文件(如 /etc/ld.so.conf 及其子配置文件)来指定动态库的搜索路径。
这些路径会被动态链接器在加载动态库时搜索。
缓存文件:
为了提高动态库的加载效率,Linux 系统会维护一个名为 /etc/ld.so.cache 的缓存文件。
该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。
上述过程描述了C/C++程序在main函数之前执⾏的⼀系列操作,但这些操作对于⼤多数程序员来说是透明的。程序员通常只需要关注main函数中的代码,⽽不需要关⼼底层的初始化过程。然⽽,了解这些底层细节有助于更好地理解程序的执⾏流程和调试问题
2.动态库也是ELF文件,我们也理解成为:统一编址,采用绝对编址的方案进行编址的(平坦模式):起始地址(0) + 偏移量
注意:
• 动态库也是⼀个⽂件,要访问也是要被先加载,要加载也是要被打开的
• 让我们的进程找到动态库的本质:也是⽂件操作,不过我们访问库函数,是通过虚拟地址进⾏跳转访问的,所以需要把动态库映射到进程的地址空间中
动态库加载和调用的过程
就是在可执行程序中找到要用到相关动态库,确定它的起始偏移地址,然后加载到内存中,内存通过页表映射到PCB指向的进程的虚拟地址处的共享区,此时进程就确定了这个库的起始地址,而我们进程在运行时代码段的该动态库的函数方法要使用时,这些函数方法内部会包含它在库中的偏移量,通过起始地址加上偏移量进而成功加载调用
• 库已经被我们映射到了当前进程的地址空间中
• 库的虚拟起始地址我们也已经知道了
• 库中每⼀个⽅法的偏移量地址我们也知道
• 所有:访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅法
• ⽽且:整个调⽤过程,是从代码区跳转到共享区,调⽤完毕在返回到代码区,整个过程全 在进程地址空间中进⾏的
调用
• 也就是说,我们的程序运⾏之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道
• 然后对我们加载到内存中的程序的库函数调⽤进⾏地址修改,在内存中⼆次完成地址设置(这个叫做加载地址重定位)
但是但是!!!,我们发现上图中的操作居然对代码区进行了修改,我们要知道,代码区是不可以被修改的(在进程中是只读的),所以我们不能通过上图的方式来取得库方法地址进行调用,而是沿用起始地址+偏移量的思想,动态链接采⽤的做法是在 .data(可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址,(因为.data区域是可读写的,所以可以⽀持动态进⾏修改)
(加载时地址重定位)
[^] 在编译的时候只初始化偏移量,在加载库链接成功之后,动态修改该data数据区所对应的库的虚拟地址,从而可以完成从可执行程序到库的跳转,整个过程代码区不受影响
由于代码段只读,我们不能直接修改代码段。但有了GOT表,库代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独⽴的GOT表,所以进程间不能共享GOT表。
在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找到GOT表。
在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会被修改为真正的地址。
这种⽅式实现的动态链接就被叫做PIC地址⽆关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
上面我们了解了可执行程序调用依赖库,那库之间也有相同的依赖关系吗?
答:有的,那么如何做到库和库之间互相调⽤也是与地址⽆关的呢,其实库中也有.GOT,和可执⾏⼀样!这也就是为什么⼤家为什么都是ELF的格式
但是这样也就意味着无论是可执行程序调库,还是库之间调用依赖都需要让动态链接在程序加载的时候需要对⼤量函数进⾏重定位,这⼀步显然是⾮常耗时的。为了进⼀步降低开销,我们的操作系统还做了⼀些其他的优化,⽐如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序⼀开始就对所有函数进⾏重定位,不如将这个过程推迟到函数第⼀次被调⽤的时候,因为绝⼤多数动态库中的函数可能在程序运⾏期间⼀次都不会被使⽤到。
思路是:GOT中的跳转地址默认会指向⼀段辅助代码,它也被叫做桩代码/stup。在我们第⼀次调⽤函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调⽤函数的时候,就会直接跳转到动态库中真正的函数实现
[^] 解析依赖关系的时候,就是加载并完善互相之间的GOT表的过程
总⽽⾔之,动态链接实际上将链接的整个过程,⽐如符号查询、地址的重定位从编译时推迟到了程序的运⾏时,它虽然牺牲了⼀定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利⽤磁盘空间和内存资源,以极⼤⽅便了代码的更新和维护,更关键的是,它实现了⼆进制级别的代码复⽤
结尾
大家看完下来是不是正如我所说的那样,对于文件系统有了比较深层次的理解呢,感觉比较乱和不理解也没关系,因为这部分内容本来就比较杂且具有深度,下来可以再反复消化一下