Linux库——库的制作和原理(2)_库的原理
文章目录
- 库的原理
- 理解目标文件
- ELF文件
- 读取ELF的工具——readelf
- ELF从形成到加载的轮廓
- ELF形成可执行文件
- ELF可执行的加载
- 理解链接与加载
- 静态链接
- ELF加载和进程地址空间
- 虚拟地址 & 逻辑地址
- 重新理解进程地址空间
- 动态链接和动态库的加载
- 进程如何找到动态库
- 多个进程之间如何共享动态库
- 动态链接
- 编译器对程序的修改
- 程序如何跳转动态库内执行
- 动态链接——函数重定向的时机和机制
- 全局偏移量表GOT
- PLT机制
- fPIC的解释
- 库间依赖
库的原理
上一个章节,已经讲解了库的基本制作和使用,同时讲解了一些细节。
本片文章,将重点对库背后相关的原理进行讲解。
理解目标文件
在学习gcc/g++编译器之前,我们大部分人都是使用IDE,如vs 2022进行编写代码和编译代码。但是,IDE是一个集成环境,很多事情是帮我们做好的,我们并不会过多关心代码编译的过程(如预处理、编译、链接,库加载等)。
但是我们学习了gcc/g++后,我们就对代码的翻译流程有了一个基本的认识,明白了背后的过程,已经相应的指令操作。
我们曾经说过一个事情:
即在工程上,编译代码的时候,更倾向于的是把所有的.c /.cpp源文件通通编译成.o目标文件,然后再统一链接目标文件和相应的库。
但是,真正的原因是什么呢?这样做有什么好处吗?
好处是大大的有:
因为所有的代码,在链接前都是独立的,并没有关系!只有在链接的时候才会相关联。
工程上来说,源文件会有很多,数量可达几万个或者十几万个。那么多个源文件,如果都要一次性的进行编译,那就很麻烦了。如果说,某一次只对其中某几个文件的某几个位置进行了小修改,那就需要把所有的文件重新编译链接。这十分浪费效率。
但是如果全部编成目标文件,那就不用担心。因为gcc/g++能够识别到哪些源文件被修改了,需要重新编译成目标文件,然后编译完修改后的目标文件后再全部一起链接,这提高了效率。
当然,实际上来说,工程上代码太多,所以会分成若干个模块,每个模块都会把所有的文件打包成静态库,然后让执行代码的模块统一进行库的链接即可!
目标文件,其实也叫做可重定向目标文件。我们学过文件的重定向,这里重定向目标文件的重定向,其实原理和文件的重定向有些类似,但在实现上和效果上,是有很大的区别。这里我们先不对这个概念解释,我们留在原理介绍完后再来理解。
我们现在要知道的是:
目标文件是二进制文件,库是这些二进制目标文件的集合!
也就是说,库是一些二进制目标文件按照一定格式打包的集合!
所以,这里我们就会猜测,既然满足一定的打包格式,那么是否是说,这些二进制文件是否满足一定格式呢?我们来看看:
这里我们把目标文件和库的文件类型用指令file展示出来,我们会发现,这些文件最后都有一个同样的内容:ELF。虽然静态库没有,但是静态库的本质和动态库是一样的,都是目标文件的集合。
所以,在这里我们可以猜测:
ELF是二进制目标文件的一种规定格式!
然后通过这一系列的相同格式的目标文件,可以按照一样的格式进行打包,把相同的内容放在同一个区域内,这就是库的打包。
ELF文件
其实,ELF是一种二进制文件格式,用于规范目标文件(.o)、静态库(.a)、动态库(.so)和可执行文件的存储结构。也就是说,这些二进制文件会按照ELF规定的格式进行存储内容。
下面我们一起来看一下ELF格式:
我们把二进制文件按照如上的ELF格式进行分区,真正存储相关数据和信息的位置,是 Sections部分,也称为节。这是ELF文件中的基本组成单位。
不同的数据会被存储在不同的节中,如代码节中存储了可执行代码,数据节存储了全局变量和静态数据等。
当然,这些节的一些描述信息会存储在Sections Header Table中,即节头表。这个表中是用来描述ELF中一个又一个的节的相关信息的。
这里我们只介绍几个我们需要知道的节:
1.text节,这个是代码节,用来保存机器指令,是程序的主要执行部分。
2.数据节(.data):保存已初始化的全局变量和局部静态变量。
3.(.bss节)
这个节我们稍微解释一下。在我们的c/c++代码中,有些变量:如未初始化的全局变量、显式初始化为0的变量,静态变量。
这些变量其实不是说,一声明就开辟空间的。
这些变量会通通的把名字记录在bss段内,虽然它们变量本身是有地址的,但是数据不是独自的地址。数据是共同指向一个数据空间。即这些变量都指向一个0的数据。直到真的需要用的时候,或者初始化变量的时候才会真正地开辟空间存储这些变量!所以这样就解释了为什么全局数据不初始化默认为0了。
这样做的好处是:可以极大的减少磁盘中存储ELF文件时候的空间!因为这些数据在不使用的时候不占据真实的磁盘空间!
程序头表(Program header table)的作用是,记录一些相关方法。用于指导操作系统和动态链接器如何将可执行文件或共享库(动态库)加载到内存并执行的关键结构。我们可以这么理解,即程序头表是用来记录可执行文件加载的方法等一系列信息的。
而最后,对于ELF Header,这是一个用来记录当前文件在ELF格式下的一些相关分区信息的。比如每个节占用多少容量,分区的边界等。
读取ELF的工具——readelf
对于上面的信息,我们直接说理论和概念是很难讲清楚的。如果能够看的到里面的对应内容就好了。但是ELF文件都是二进制文件,所以需要一定的工具来进行内容的读取。这个工具就是readelf,可以通过不同选项来控制读取ELF中哪个部分的内容:
选项 | 作用 |
---|---|
-h | 查看文件头 |
-l | 查看程序头表 |
-S | 查看节头表 |
-s | 查看符号表 |
-d | 查看动态段 |
-r | 查看重定位信息 |
-a | 显示所有信息 |
1.readelf -h xxx,查看xxx的ELF Header
果然,ELF Header中确实是记录了一些相关的分区信息:节的大小,程序表头的开始,节表头的开始等…
2.readelf -l xxx,查看xxx的程序头表(Program header table)。
显出出来的一些信息很难看得懂,但是我们知道这是用来系统加载库、控制链接的手段即可。
3.readelf -S xxx,查看xxx的节头表,即每个节的相关信息:
[ynp@hcss-ecs-1643 mylib_together]$ readelf -S libmyc.so
There are 28 section headers, starting at offset 0x2b40:Section Headers:[Nr] Name Type Address OffsetSize EntSize Flags Link Info Align[ 0] NULL 0000000000000000 000000000000000000000000 0000000000000000 0 0 0[ 1] .note.gnu.build-i NOTE 00000000000001c8 000001c80000000000000024 0000000000000000 A 0 0 4[ 2] .gnu.hash GNU_HASH 00000000000001f0 000001f00000000000000050 0000000000000000 A 3 0 8[ 3] .dynsym DYNSYM 0000000000000240 0000024000000000000002a0 0000000000000018 A 4 1 8[ 4] .dynstr STRTAB 00000000000004e0 000004e00000000000000109 0000000000000000 A 0 0 1[ 5] .gnu.version VERSYM 00000000000005ea 000005ea0000000000000038 0000000000000002 A 3 0 2[ 6] .gnu.version_r VERNEED 0000000000000628 000006280000000000000020 0000000000000000 A 4 1 8[ 7] .rela.dyn RELA 0000000000000648 0000064800000000000000c0 0000000000000018 A 3 0 8[ 8] .rela.plt RELA 0000000000000708 000007080000000000000168 0000000000000018 AI 3 22 8[ 9] .init PROGBITS 0000000000000870 00000870000000000000001a 0000000000000000 AX 0 0 4[10] .plt PROGBITS 0000000000000890 000008900000000000000100 0000000000000010 AX 0 0 16[11] .text PROGBITS 0000000000000990 000009900000000000000586 0000000000000000 AX 0 0 16[12] .fini PROGBITS 0000000000000f18 00000f180000000000000009 0000000000000000 AX 0 0 4[13] .rodata PROGBITS 0000000000000f21 00000f21000000000000001d 0000000000000000 A 0 0 1[14] .eh_frame_hdr PROGBITS 0000000000000f40 00000f400000000000000044 0000000000000000 A 0 0 4[15] .eh_frame PROGBITS 0000000000000f88 00000f880000000000000104 0000000000000000 A 0 0 8[16] .init_array INIT_ARRAY 0000000000201df8 00001df80000000000000008 0000000000000008 WA 0 0 8[17] .fini_array FINI_ARRAY 0000000000201e00 00001e000000000000000008 0000000000000008 WA 0 0 8[18] .jcr PROGBITS 0000000000201e08 00001e080000000000000008 0000000000000000 WA 0 0 8[19] .data.rel.ro PROGBITS 0000000000201e10 00001e100000000000000008 0000000000000000 WA 0 0 8[20] .dynamic DYNAMIC 0000000000201e18 00001e1800000000000001c0 0000000000000010 WA 4 0 8[21] .got PROGBITS 0000000000201fd8 00001fd80000000000000028 0000000000000008 WA 0 0 8[22] .got.plt PROGBITS 0000000000202000 000020000000000000000090 0000000000000008 WA 0 0 8[23] .bss NOBITS 0000000000202090 000020900000000000000008 0000000000000000 WA 0 0 1[24] .comment PROGBITS 0000000000000000 00002090000000000000002d 0000000000000001 MS 0 0 1[25] .symtab SYMTAB 0000000000000000 000020c000000000000006c0 0000000000000018 26 45 8[26] .strtab STRTAB 0000000000000000 0000278000000000000002c5 0000000000000000 0 0 1[27] .shstrtab STRTAB 0000000000000000 00002a4500000000000000f9 0000000000000000 0 0 1
Key to Flags:W (write), A (alloc), X (execute), M (merge), S (strings), I (info),L (link order), O (extra OS processing required), G (group), T (TLS),C (compressed), x (unknown), o (OS specific), E (exclude),l (large), p (processor specific)
比如像这里我们自己写的动态库,一共有28个Sections。打印出了每个节的名字、类型属性,地址,偏移量等信息。
这里简单说一下偏移量offset,因为这个涉及到后续对于原理的理解:
我们可以把整个ELF文件想象成一个大的一维数组。所谓文件上内容,其实就是数组里面一个又一个字节罢了。假设每个文件的起始地址都设定为0,那么只需要记录每个分区,每个节的起始位置相对于开头的偏移量,和结束位置的偏移量就能知道每个分区的大小。
4.readelf -s xxx,查看xxx的符号表
这里就不进行展示了,我们来简单说说符号表究竟是什么。
符号表,是ELF文件下用于记录程序中定义的函数、变量、以及引用的外部符号的名称、类型、地址等信息的。
ELF从形成到加载的轮廓
ELF形成可执行文件
首先,我们大概可以知道的是,ELF文件时二进制文件的一种格式。
假设当前有多个.c的源代码,那么只需要把它们通通翻译成.o的目标文件即可。此时就得到了许多ELF格式的.o目标文件。
我们可以这么理解,所谓生成ELF格式的二进制文件,其实就是把代码中的相关信息以二级制的方式,根据ELF的格式,写入到.o目标文件内。
然后,所有库、目标文件都是ELF格式,库的制作、可执行文件的链接,其实本质上就是把需要链接的所有二进制ELF文件的相对应的信息合并在一起:
把所有的Sections进行合并到一个ELF格式上,然后更新节表头的信息,然后根据当前分区情况重新调整ELF Header中的一些相关分区信息。这样,就完成了有多个ELF文件生成可执行文件了。
ELF可执行的加载
但是,由于可执行文件中合并了多个Sections,会导致Sections过多,系统会自动地将具有一些相同属性的Sections(如权限可读、可写、可执行、又或者运行时是否需要开空间)合并,形成段(sgement)。
这种合并的原则和合并的方式其实也是属于程序头表的内容,我们可以使用readelf -l xxx查看对应的segment:
在该指令展示的信息的最底下部分,我们可以看到Sections to Segment mapping,即节和段的映射。
我们可以发现,有很多节(以.开头的名称),都被合并成了一个段!
其中我们会发现,.data.rel.ro数据节和.bss节,即一些常量数据和全局数据,是被放在了同一个节上。这也就解释了为什么虚拟地址空间上常量和数据段是放在一起的。
将Sections合并成Sgements的原因:
Section合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占用3个页面,而合并后,它们只需2个页面。(因为一个页面内一般来说只有一个节的内容。)
此外,操作系统在加载程序时,会将具有相同属性的section合并成一个大的segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。
理解链接与加载
前面讲的,都是为了后序理解原理的前置知识。
接下来这个部分,我们将正式进入对库的加载和运行原理的理解。
静态链接
首先我们来看静态链接的过程。
静态链接指的是:自己写的.o文件进行链接或者和静态库进行链接。
我们现在来使用一份代码,来理解一下静态链接的过程:
//hello.c
#include<stdio.h>void run();int main(){printf("hello\n");run();return 0;
}//run.c
#include<stdio.h>void run(){printf("running\n");
}
首先,我们需要把这两个文件生成目标文件:
然后进行链接生成可执行文件:
这个时候,我们就得到了链接前后的ELF文件,我们需要查看它们有什么不同。
所以,我们需要介绍一个工具:objdump,这个可以对代码进行反汇编。
使用objdump -d 文件 > xxx.s可以把二进制文件反汇编代码重定向到xxx.s文件。
反汇编代码或许我们看不懂全部,但是一些基础的操作我们肯定还是知道的。比如调用函数的时候,其实是使用call 函数地址进行调用的。
我们重点观察上面这个两个没有链接过的二进制文件:
这就再一次证明了我们一直在说的一个结论:
即多个.o文件,在链接前,都是独立的,互不影响的!
读取一下run.o和hello.o的符号表,符号表内存储的是ELF文件内相关的代码、数据、函数、变量、地址等相关信息的。
printf的底层实现就是puts,puts还要调用系统调用接口。
我们从符号表读出来相关的函数信息发现,在没有进行链接前,系统是不需要管是否找的到对应的函数的。通通给上地址0。
我们来验证一下:
我们直接在hello.c加入一个没有定义的函数,我们尝试着编译一下:
发现照样是可以编译成.o目标文件的!
但是如果加入了一个找不到定义的函数,链接的时候就会报错了!
我们把代码修改成正确版本,然后再来看看生成的可执行文件对应的数据:
readelf -s main.exe
objdump -d main.exe > main.s
vim main.s
我们会发现,run的定义确实给找到了,但是puts,也就是printf仍然是UND状态!因为printf使用的是libc.so动态库,需要动态链接。这里先不讲如何动态链接。
而上面第一张图圈出来的地方中,我们会发现有数字16.,意思就是这些代码最终会被合并到编号为16的Sections:(即代码节)。
也就是hello.o和run.o的.text节被合并了,是main.exe的第16个Section
所以静态链接其实就是将编译之后的所有目标文件连同用到的一些静态库组合,将拼装成一个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。
所以,.o文件被称为可重定向目标文件是有原因的!因为链接的时候会修改每个函数中没有详细定义的函数地址!这就是目标文件内的重定向。
注:上述的所有过程中并没有涉及到动态链接的问题!
ELF加载和进程地址空间
在理解动态库链接的原理前,我们需要重谈一下进程地址空间的相关内容。
虚拟地址 & 逻辑地址
首先,我们引出一个问题:
即可执行程序没有被加载到进程上的时候,也就是不被cpu调度执行的时候,是否有地址呢?
答案是:有的。
这个理由很简单:因为我们能够在反汇编代码上看到各个变量最后都是被地址给替代的。最终系统在寻找变量的时候,都是通过寻址查找的。所以,必然是有地址的。
接下来,我们需要来聊聊虚拟地址、逻辑地址、物理地址的相关话题。
首先,我们现在有一个ELF文件,它里面会被分为各种区域和Segments存储相关数据和信息。这些的内容必然是存储在磁盘上的。那么,这些数据的分区很可能是在磁盘上不连续的!
也就是说,一个ELF文件内,可能每一个区域的数据块地址在磁盘中的物理地址都是不一样的。所以,如果某个区域内有数据/函数,想要寻址就得使用公式:
该节的起始地址 + 相对于该节起始地址的偏移量
所以逻辑地址,是磁盘内的。早期的系统都是直接把磁盘的逻辑地址拿来编址。但是这样子计算非常的不方便,因为要算每一个节的起始地址和相对节的起始地址的偏移量。
所以,后来的系统为了解决这个问题,相出了一个平坦编址的方法。
因为整个ELF文件内的所有起始地址都不会重复,也不会有数据的冲突。所以,操作系统干脆做了一层抽象映射,直接把ELF头部位置的地址设为0。然后其它的数据会按照虚拟地址空间的排布方式来决定平坦编址的时候不同区域数据的相对于ELF头部的偏移量。
所以,在如今的系统中,每个ELF文件(链接前后)都是使用平坦编址的:
看第一列数字就知道了。
(虽然每个ELF文件起始地址都是0,但实际上这是平坦编址模式编排过的)。
然后,如果是需要进行多个库 + 目标文件的链接,那么就会把所有的ELF文件重新进行全局的平坦编址:
但是地址不再是从0开始了!
因为文件经过链接后,会把所有的ELF文件重新进行全局的平坦编址。但是,最终链接生成的时可执行程序!可执行程序加载到内存的时候,进程时通过进程地址空间和页表来索引内存中的相关内容的。
所以,这个链接后的可执行文件,会再次进行映射,把重新编好的ELF头部,地址原本为0,映射到虚拟地址空间,得到该ELF头部的基址。然后ELF中其余的数据只需要使用基址 + 原来平坦编址下想对于ELF头部的偏移量就可以得到其余数据的地址!
所以,我们再可执行文件中看到的编址,其实是经过一层映射的!看的其实是该ELF在虚拟地址空间上的各个数据的地址!
而且,ELF映射到的虚拟地址空间,是和进程地址空间相对应的!进程运行前,需要对进程的地址空间进行分区,那么分区的数据怎么来?进程怎么知道每个区多大,有什么数据?
答案是从ELF映射的虚拟地址空间来!
重新理解进程地址空间
所以,通过上述的分析,我们终于知道了进程地址空间的相关分区是如何进行初始化的!
但是,还是有一个问题,即CPU在调度进程的时候,CPU怎么知道从哪里开始执行代码呢?刚刚的过程只是把可执行程序的相关内容编址后映射到了虚拟地址空间,从而初始化进程地址空间。但是CPU此时不知道该从哪里执行代码。
这个问题很好解决,在ELF的头部里面添加一个信息——即编址后代码开始执行的位置:
在ELF Header中,存在着一个信息Entry point addreee,这就是用来标识该可执行文件的开始执行的地址。
CPU执行代码具体的流程如下所示:
CPU从虚拟地址空间内拿到虚拟地址(从Entry point address开始拿),然后在cpu内通过MMU机制查询页表,进行虚拟地址向物理地址的转化,然后执行代码。
当然,进程地址空间还可以再进行细化,因为虚拟地址空间内只是宏观描述了数据存储的位置。但是对于一些容易造成内存片段的区域是需要更加细致的描述vm_area_struct
所以,最后我们了解了程序从生成到加载到执行的基本流程(除了动态库)。
我们也知道一个结论:
虚拟地址机制,不光OS要支持,编译器也要支持!
动态链接和动态库的加载
进程如何找到动态库
我们知道,动态库的本质其实也是多个目标文件的合并!只不过是,在系统中只需要存在一份,然后使用库中的方法的时候需要跳转到动态库内进行执行。
动态库存储在特定的目录下,本质上也是磁盘的某些位置。但是动态库不像静态库那样,在链接的时候会进行多个ELF的合并。那么,进程要跳转到动态库中进行执行代码,那就必须明白一个问题:进程如何找到动态库?
在进程地址空间中,存在着一个叫做共享区的区域。动态库的另外一个名字叫做共享库!
也就是说,可以对动态库的内容进行平坦编址,编址完成后再映射到虚拟地址空间的共享区即可。进程地址空间的共享区的分区信息就是由这虚拟地址进行转化的。
也就是说,先在磁盘上进行对动态库的编址,然后加载到物理内存的同时,把动态库在虚拟内存中的编址用来初始化进程地址空间的共享区!同时进行虚拟地址和物理地址的映射,完成页表的填写。
这样子,CPU在执行代码的时候,就能够找得到动态库的相关内容了。
多个进程之间如何共享动态库
我们又知道,动态库在系统中是只存在一份在指定目录下供多个进程进行调用的。那多个进程如何同时共享一份动态库呢?
还是一样的,首先让动态库在加载到内存前就完成好平坦编址。然后让动态库加载到物理内存的同时,再根据不同进程的进程地址内存分布情况,映射到不同进程对应的共享区位置。
也就是说,很有可能,同一个动态库在不同进程上看到的虚拟地址是不同的!但是不需要担心,因为有页表的存在,页表会完成从虚拟地址到真实地址的映射。
动态链接
首先这里需要输出一个结论:
和动态库的链接推迟到了程序加载掉用的时候!
因为我们知道,静态链接是直接把静态库和目标文件的ELF格式进行合并了,其实就是把静态库的内容放到了可执行文件内!
但是我们使用readelf -s 可执行文件查看符号表的时候发现,一个可执行程序,其使用动态库的状态仍是UND(undefined)。这其实就是上面结论的验证!
因为此时可执行程序还没有运行加载!和动态库的链接需要等到运行程序的时候才会进行!
上面是查询一个未执行的可执行程序的符号表的结果。
我们可以发现,确实是有一部分的函数/内容是UND状态!因为这些内容都是在动态库内的!此时还没有和动态库进行链接,也就导致符号表内部分函数还没有进行重定位!
这些函数需要等到程序加载到内存上运行的时候,才会和动态库进行链接,从而修改地址!
但是当我们查询main.exe的反汇编代码的时候,我们会发现动态库的函数是有地址的:
其实,这个地址是编译器假定动态库已经加载到进程地址空间上的。这个地址是假的!
当然,具体要怎么样修改成真的地址,需要等到后面动态库加载链接的原理来说。
所以,这里再一次证明,动态链接是被推迟到程序加载运行的时候!
编译器对程序的修改
我们可以使用ldd指令查看一下程序依赖的动态库:
我们发现,除了绿色的标识的是标准c库之外,几乎所有的指令程序都有依赖一个叫做/lib64/ld-linux-x86-64.so.2的库。这是用来干什么的呢?
我们曾说过,c语言的入口函数在我们看来,可能是main函数,但是实际上并不是。因为操作系统会默认的帮我们做好一些事情(比如打开stdout,stdin,stderr),所以这必然导致了编译器在编译代码的时候对我们的代码做了一些修改。
实际上,在Linux系统下,c程序的真正入口是_start函数。这个函数负责做什么?
1.设置堆栈:为程序创建一个初始的堆栈环境。
2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。(其实就是通过平坦编址后的可执行程序的虚拟地址来初始化进程地址空间相应位置)。
3. 动态链接:这是关键的一步, _start函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。
动态链接器
动态链接器(如 ld-linux.so)负责在程序运行时加载动态库。
当程序启动时,动态链接器会解析程序中的动态库依赖,并将这些库加载到内存中。
环境变量和配置文件
环境变量:
Linux 通过环境变量(如 LD_LIBRARY_PATH)指定动态库的搜索路径。
配置文件:
系统配置文件(如 /etc/ld.so.conf 及其子配置文件)也用于定义动态库的搜索路径。
动态链接器在加载库时会优先检查这些路径。
缓存文件
为提高加载效率,Linux 维护缓存文件 /etc/ld.so.cache。
该缓存记录了系统中所有已知动态库的路径和元数据,动态链接器会优先查询缓存以加速加载。
然后_start函数在完成了一系列初始化工作后,就会调用main函数。main函数结束后,再调用_exit退出进程。
我们只需要知道的是,对于一些默认行为(如打开std文件,加载动态库的事情),是编译器帮我们完成的,我们的代码是被修改过的!
程序如何跳转动态库内执行
动态库本质也是ELF文件。和静态库其它的ELF文件不同的是,这个文件并不是链接的时候才合并到可执行文件内的。而是等到进程运行的时候再来进行链接使用。
所以我们观察到可执行程序的反汇编代码中,即使库函数有地址,那也是假的。
现在问题来了,程序是如何与动态库进行关联的呢?也就是如何“跳转到动态库内”执行代码?
首先我们知道的是,动态库也是ELF文件,在磁盘上存储的时候,里面的数据内容可能是存储在磁盘上不同的位置,但是具体到ELF内部,也是采用平坦编址的方法进行编址:
然后再根据进程地址空间的情况,将动态库的内容地址分配到进程地址空间上!
此时,动态库内容的起始地址我们就得到了。
动态库内剩下的所有内容:由于在ELF内进行了相对编址,所以我们只需要知道记录每个内容的相对于动态库头部的偏移量,就可以使用动态库起始进程空间地址 + 偏移量来获取动态库中的所有进程空间地址,然后通过页表索引就能找到内存中的动态库的内容。
动态库中一般来说就是一些方法的实现和内容的引用。
所以,CPU真正执行的代码起始还是在代码区,从程序入口处开始执行。
但是因为在程序运行前,动态库其实就已经被_start函数调用连接器代码,加载到内存上了,也分配到了进程地址空间上。也就是在CPU看来,动态库的内容可以通过进程地址找到。
所以,在代码区执行代码的时候,如果是静态链接的部分,那不用担心,函数早就被重定向了,是可以通过地址来找到对应的函数。
但是如果是使用了动态库的部分,这也不怕。因为在代码运行的时候,会对动态库函数进程重定向,也就是再一次进行地址的修改,把原来假的改成正确的进程地址。同时填充页表!完成进程虚拟地址到物理内存地址的映射。
(这里对于动态库函数重定向的时机和机制没有讲清楚!将放在下一个部分进行讲解)。
因为知道动态库的起始进程地址和动态库中各个位置的偏移量,所以是可以正确修改代码中的那些函数的调用地址的!
所以,动态链接,其实是在进程准备运行的时候,再一次对代码中用到动态库中的函数和内容的地址进行重定向!这样子,CPU在执行到动态库的函数的时候,就可以跳转到这个地址,通过页表所以来运行动态库的相关内容!调用完后再回到代码区即可。
下面用一张图来演示:
即可执行程序的代码和数据加载到内存上后,也完成了进程地址的分配和页表填充。然后,在代码区执行代码的时候,如果碰到了是调用库函数/内容的地址,就会进行动态的绑定!
而且,程序执行前,动态库早已被加载到内存上,分配到进程地址空间上了!就是通过文件系统,进行路径解析,找到对应的文件inode,然后获取出相关信息,加载到内存上。
我们知道,未动态链接前,库函数地址是假的!其实就是相对于动态库头部地址的偏移量。如果运行到了某个库函数,那么只需要把其调用的地址 + 动态库在进程地址中的起始地址,就能找到该函数在进程地址空间的位置,从而进行也表映射,执行代码。
动态链接——函数重定向的时机和机制
上面我们是能够明白函数如何跳转到动态库内执行代码的,就是对用到动态库的地址进行重定向,修改地址。然后通过页表索引找到内容执行后,再回到代码区!
但是,有一个问题:
一旦代码和数据被加载到了数据区和代码区上,不是有只读保护的吗?也就是说,动态链接重定向的时候,是不可以直接把代码的调用地址给修改的。这怎么办?
全局偏移量表GOT
代码确实是不能修改!但是,可以间接来修改!
可以再加上一层映射,即运行到库函数的地址的时候,这个地址需要重新进行重定向。但是代码具有只读性不能修改。所以需要维护一张表:
这个表是库函数重定向前后地址的映射!
动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留一片区域用来存放函数
的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的⼀个全局变量或函数的地址。
且在合并的时候,got节和数据节会被合成一个Segment。所以,会在进程的虚拟地址空间的数据区进行维护!
这个表就是用来维护这个库函数重定向的映射关系的:
也就是说,使用库函数的时候,并不是真的对地址进行修改了!而是通过GOT表进行重定向后地址的映射,找到重定向的地址后,跳转到动态库内。通过该地址索引页表,找到动态库真实的内容进行执行!
所以,动态链接确实是被延迟到了运行的时候才来进行重定向的!
PLT机制
但是,GOT表是需要在进程运行前就维护起来的。也就是进程一开始的时候,就要把所有的库函数重定位后填充到GOT表!
但是这个非常耗时!所以系统内采用的是PLT机制。把真正重定向的时机放到了第一次执行库函数的代码的时候!也就是:
GOT中的跳转地址默认会指向一段辅助代码,它也被叫做桩代码/stup。在我们第一次调用函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调用函数的时候,就会直接跳转到动态库中真正的函数实现。
其实可以理解为缓存。即第一次出现的,使用后加载到一个缓存表中。如果后序使用的在缓存表内,直接在缓存表内索引后映射。反之需要去查找后再缓存。
所以,在ELF的Sections中,存在着一个.got.plt的节。这个节就是负责:
当GOT表中没有某库函数重定向的地址映射关系时,通过这个节中的相关方法来去查找该函数在进程地址中的真实位置!并且再加载到GOT表中!
fPIC的解释
我们前面制作动态库的时候,用到了一个选项:fPIC,这个是地址无关!
何为地址无关?
代码不依赖绝对地址,通过偏移量 + GOT/PLT 间接跳转实现动态绑定。
也就是说:
动态库地址无关的本质就是,所有的动态库加载到内存上之后,调用代码时不关心动态库真正的地址在哪里的。因为知道偏移量和库起始地址,再通过GOT表和PLT机制进行库代码跳转!
所以,制作动态库的时候是需要带上这个选项的!表明动态库的运行原理。
库间依赖
但是,库与库之间都是会存在依赖的。比如某个库调用了标准c库的printf函数。
这些库都被加载到了内存当中,分配到了进程地址空间上。如果按照前面讲的理论:
如果A库中调用printf,B库也调用,那么A、B两个库和标准c库之间怎么做到与位置无关呢?
因为A、B两个库中重定向前,调用printf函数的假地址可能是不相同的!
这不用怕,所有的库、可执行文件,都是ELF结构!
所以,每个库内都会维护一个GOT表,用来表示当前区域下与库函数的映射关系!
也就是说,A、B两库都有独立的GOT表,再配合PLT机制,就可以查找到真实的位置!
所以,这就保证了库与库之间,不同区域之间调用库代码的地址无关性!