Linux 库制作与原理
文章目录
- 1. 库的概念
- 2. 静态库
- 2.1 静态库的制作
- 2.2 静态库的原理
- 3. 动态库的制作
- 4.ELF文件
- 4.1 ELF文件内容
- 4.2 ELF文件链接与加载
- 5. ELF与进程地址空间,动静态链接和库
- 5.1 ELF与静态链接
- 5.2 ELF与进程地址空间
- 5.3 ELF与动态链接、动态库
- 5.3.2 动态库
- 5.3.2 动态链接
1. 库的概念
以往在学习C语言,C++语言时,常常谈及库,诸如C语言库,C++标准模板库,那么库到底是什么呢?
本质上,库是由别人实现好的,现成的的二进制文件,也就是说,库就是文件。
库,有静态库与动态库之分。在Linux和windows两种操作系统中,动静态库分别具有不同的后缀:
linux中,动态库为.so,静态库为.a
windows中,动态库为.dll,静态库为.lib
接下来,我们来谈一谈静态库和动态库的制作及其原理。
2. 静态库
2.1 静态库的制作
静态库是什么呢?
以C语言为例,静态库本质上将包含方法的源文件编译形成目标文件后,在打包成为一个文件得到的。当然,严格来说,静态库 = 静态库的库文件(即所有目标文件打包后得到的一个文件) + 所有的头文件.
这样讲可能还不是很清楚,我们来看接下来,建立我们自己静态库的过程。
首先,我们实现方法源文件:**mymath.c 和 mystring.c **
然后再分别创建相应的头文件mymath.h和mystring.h
最后,test.c
文件如下:
我们可以对这三个源文件分别进行编译,形成目标文件后,再链接:
这样,我们就得到了一个可执行文件:
原则上,只要拿到方法源文件的目标文件和头文件,再自己编写源文件去使用这些方法,自己的源文件生成目标文件后,再链接,就可以得到有效的可执行文件,而不需要方法源文件。
但是,如果方法目标文件很多的话,在传输或链接的过程中,可能人为地会有所遗漏,所以我们会将这些目标文件制作成一个静态库。
如何将目标文件制作成一个静态库呢?我们使用ar
命令,再加上rc
(replace and create)选项。
具体命令:ar -rc lib.a *.o
上述命令有几个注意点:
rc
前表示选项的符号,可带,可不带,这是比较特殊的一点- 静态库的命名一定要规范,一定是以
lib
开头,以.a
后缀结尾,这开头和结尾之间可以进行个性化的命名。库名称后,接用以制作库的目标文件。
将目标文件制作成库后,我们再来进行链接:
无法进行链接,会报上述错误,这是因为gcc
编译器总是会到一个默认的系统路径下寻找指定名称的库,对于C语言自身实现的库,gcc
作为C语言编译器,它肯定是默认知道在哪里,且知道是哪些库的。
但是,对于我们自己实现的库,gcc
不仅不知道在哪里。也不知道叫什么名称。
所以,如果我们想要成功链接到我们自己的库,必须要告诉gcc
,我的库在哪个路径下,我的库叫什么名称。
具体命令如下:
上述命令有几个注意点:
- 可以直接对
test.c
操作,也可以直接对test.o
操作 -L
后接上库所在的路径-l
接上库的名称去掉前缀lib
和后缀.a
后的结果
上述过程便是我们自己制作的静态库的使用,但是有一点还与C语言库的使用不同,就是我们的头文件包含时,使用的是双引号,而不是尖括号。
使用双引号代表查找头文件时,会优先到当前工作路径下查找,然后再到系统默认存储头文件的路径下查找;而尖括号则直接表示到系统默认存储头文件的路径下查找。
所以,如果我们想要使用尖括号包含我们自己头文件的话,一种解决方法就是把我们的头文件拷贝到系统的默认路径下,不过这种做法并不推荐。还有一种做法就是在gcc
命令行中,指定头文件路径,如下所示。
上述命令中,通过添加一个-I
选项,后接路径,即可指定头文件路径——实质上,这个选项是额外添加一条编译器在查找时的路径,而不是让编译器只在这个路径下查找。
2.2 静态库的原理
静态库本质上在链接时,会把所有的代码和数据都拷贝到一个可执行文件中,因此静态链接,最终得到的文件通常会比较大,因为它包含了所有了目标文件中的代码和数据。
不过链接了静态库后的可执行文件,即便把静态库删除之后,依然能够正常运行,因为这个文件本质上已经包含了所有需要的代码和数据了,不再依赖于静态库了,独立性高。
3. 动态库的制作
在上述内容中,我们已经学习了静态库的制作,接下来,我们继续学习动态库如何制作。
动态库的制作同样依赖于编译生成的目标文件,不过它对于生成的目标文件有额外的要求,要包括位置无关码等额外信息,因此我们在编译生成目标文件时,需添加额外内容。
需要增添fPIC
这个额外选项,表示生成的目标文件,包含位置无关码等信息。
然后,与静态库的制作过程类似,通过所有包含位置无关码的文件,去生成动态库。
上述生成动态库的命令行中,有两点需要注意:
- 关于动态库的命名。以
lib
开头,以.so
结尾,这二者之间可以进行个性化命名。 - 动态库的制作使用
gcc
,并且需要额外加上-shared
选项。
这样,我们就制作出动态库了。
由于是我们自己实现的动态库,因此gcc
也是不知道这个动态库在哪,并且不知道这个动态库叫什么名字,因此需要我们进行额外设置。不过动态库这里的解决方法,相比静态库会更多。
- 方法一:将动态库文件拷贝到系统的默认路径下
- 方法二:在系统的默认路径下设置动态库文件的软链接
- 方法三:更改
LD_LIBRARY_PATH
这个环境变量,当然如果环境变量表中没有这个变量,则需要使用export
命令导入。gcc
编译器在进行动态库搜索时,回到这个环境变量所包含的路径中查找,因此将相应动态库的路径添到该环境变量中即可。需要说明的是,这样的修改是内存级的修改,重启系统或重新链接到云服务器后,是不会保留的. - 方法四:在
/etc/ld.so.conf.d/
这个路径下,创建一个自己的配置文件,该配置文件内容即为相应动态库的路径。配置完成后,需要使用ldconfig
重新加载相关配置文件,这样gcc
才会在查找动态库时,使用我们用户额外添加的路径。
myconf.conf
为用户添加的配置文件,其中的内容即为相应动态库的路径。
配置好后,再使用ldconf
重新加载,此时即可不用指定用户自己实现的动态库路径了。
4.ELF文件
4.1 ELF文件内容
ELF某些文件是文件内容的一种存储格式。我们曾经学过的文件中,静态库文件、动态库文件(共享库文件)、可重定位目标文件(源文件编译后即得到可重定位目标文件,即以.o为后缀的目标文件)、可执行文件等。
文件=文件内容+文件属性,ELF文件针对的文件内容,即在磁盘上,文件内容以该格式进行存储。
那么ELF文件的内容格式具体如何呢?
我们如何来看待整个ELF文件内容?
因为本质上整个ELF文件是由编译器生成的,而加载到内存中时,是由操作系统加载到内存当中的,所以,我们要弄清楚编译器和操作系统是如何看待ELF文件的。
编译器和操作系统看待ELF文件,从0开始编址的,整个ELF
文件的内容是可以连续看待的,可以理解成一个顺序表的结构。绝对编址 = 起始地址 + 偏移量,相对编址 = 偏移量,整个ELF文件的定位,使用偏移量就可以实现,因为编译器和操作系统看待ELF文件,采用平坦编址,即从0开始编址。
接下来,我们来介绍ELF文件各个部分。
ELF Header
:这个部分是介绍整个ELF文件的相关信息,用于定位ELF文件中其余部分。
section
:节是ELF文件的基本组成单位,节有很多,不同的节中记录不同的数据和信息,比如说.text,表示代码节,其中存储了可执行代码,比如说数据节,存储初始化的全局变量和静态数据。
Section Header Table
:这个区域是节头表,包含了对各个节的描述,因为不同的节,偏移量不同,大小也不同,节头表中就记录了相关信息。
Program Header Table
:程序头表,其中描述的是各个段(segment)。段是什么呢?段本质上是节的集合。因为整个ELF文件中,有很多节,总会有一些节具有相同或相近的性质,那么此时就可以把这些节合并起来,成为段。本质上,段和节是看待ELF文件的不同方式——编译器通过节看待ELF文件,操作系统通过段看待ELF文件。可是这样会出现一个问题?如何知道哪些节合并成一个段呢?程序头表中描述的是各个段的信息,因此必然记录了每个段是由哪些节合并而成的。所以,通过程序头表,节合并成段,段拆分成节,完全是可实现的。
下面,我们介绍一些指令,在Linux命令行中,来查看ELF文件中的各部分内容。我们以一个可执行程序为例,来进行查看。
readelf -a + ELF文件
:查看文件所有内容,一般不推荐用这个指令,因为ELF文件的内容可能会比较多。
readelf -h + ELF文件
:查看ELF文件表头内容,即ELF Header
.
通过ELF文件的表头结构,我们可以知道段开始位置处的偏移量,节开始位置处的偏移量,每个节的大小,每个段的大小,总过有多少个段,总共有多少个节,最后一个节的编号是多少(从零开始编号).
readelf -S ELF文件:查看ELF文件的各个节信息。
从中我们可以看到ELF文件中,各个节的大小,类型,偏移量等。
readelf -l ELF文件:
这个命令可以用于查看ELF文件中,段的信息,以及每个段是由哪些节合并而成的(并不是所有的段都由节合并而成)
上半部分介绍的是各个段的信息,下半部分介绍的是各个段由哪些节合并而成,我们可以清楚看到,一共有9个段。
4.2 ELF文件链接与加载
我们说过,可重定位目标文件,即以.o
为后缀的文件,就是ELF文件,而可执行文件,也是ELF文件,所以将可重定位目标文件链接成可执行文件的过程,本质上就是将多个ELF文件合并为一个ELF文件的过程,由于节section是ELF文件的基本组成单位,所以链接的过程,主要就是将各个ELF文件的节进行合并,使之成为一个ELF文件。
编译器看待ELF文件以节,即section的形式看待,但ELF文件加载到内存中时,由操作系统完成,而操作系统看待ELF文件以段,即segment的形式看待,因此操作系统将ELF文件加载到内存后,会将节进行合并一个个段,最终操作系统看待ELF文件的基本单位就是段。
5. ELF与进程地址空间,动静态链接和库
5.1 ELF与静态链接
无论是直接链接.o
文件,还是链接静态库,本质上都是静态链接。
我们之前说,静态链接是把相关的文件内容重新拷贝一份,那么还能更深入的理解静态链接吗?
我们来看下面的示例:
我们将两个文件分别编译成.o
可重定位目标文件后,使用objdump -d
命令查看main
函数所在目标文件的反汇编。
我们可以看到,call指令,即进行函数调用,call后面的地址为全0,即无效。这是因为add函数的实现并不在该文件中,该文件仅有add的声明,因此不知道add的具体地址。
除此之外,我们还可以使用readelf -s
命令,读取elf
文件的符号表(符号表位于数据节)
我们可以看到最后一条是add,但是前面显示UND,即undefine的意思,也就是未定义。
接着,我们再来查看经过静态链接后,得到的可执行文件的反汇编和符号表情况。
此时我们可以看到,main函数的反汇编中,call指令已经有了具体地址,而最终所到达的地址是40050e,这个地址恰好就是add函数第一条指令的地址。
接着,我们再来查看符号表的情况。
此时,我们可以看到符号表中的add
,就不再是未定义的了,40050e
也就是add函数的起始地址。
所以,通过上面的示例,我们可以回答静态链接到底是在做什么。
静态链接,本质上就是将各elf文件合并成一个文件后,到数据节
中的符号表去查看有哪些处于未定义状态的全局变量和全局函数,然后对这些全局变量和全局函数进行地址重定位,将无效地址变为实际有效地址,这就是符号表的合并与重定位。 这样,最终生成的可执行文件中,就不会有无效地址的情况了。
当然,如果地址重定位过程出错,实际上并没有具体的全局变量或函数实例,那么就会出现链接错误,也就无法生成可执行文件了。
5.2 ELF与进程地址空间
在重谈进程地址空间之前,我们来思考两个问题:
- 一个ELF文件,在没有被加载到内存之前,ELF文件中的内容,有没有对应的地址呢?
- 我们之前说,进程地址空间,本质是虚拟地址空间,即其中所有的地址都不是物理内存上的地址,而是通过页表映射,使得虚拟地址的分配和物理内存实际的地址解耦合,那么一个进程的虚拟地址从哪来呢?
ELF文件在没有被加载到内存前,就已经有地址了——采用平坦模式编址,起始地址为0,故可以单用偏移量进行定址,这个地址,实际上就是虚拟地址。
一个可执行ELF文件,是经过链接,由多个ELF文件合并而成的。所以,最终的可执行ELF文件的地址,是将多个ELF文件合并后,再统一编址得到的,而这个可执行ELF文件的地址,就是该文件被加载到内存中变为进程时,进程的虚拟地址。
所以,进程地址空间从哪来,从ELF文件中来。而ELF文件,本质上都是由编译器产生的,其中的编址,也是编译器完成的,因此不仅操作系统要支持虚拟地址空间技术,编译器也要支持。
现在,我们可以来复现一下整个从虚拟地址映射到物理地址的过程。
操作系统首先通过路径解析拿到该可执行ELF文件的inode号
,然后根据分区挂载的情况,到指定分区中通过inode号,拿到指定ELF文件的inode
,再通过其中记录的文件内容所对应的数据块号,拿到相应的文件内容,并全部加载到物理内存中——操作系统会创建struct file
,同时ELF文件内容全部被加载到内存中(实际上这块空间就是文件内核缓冲区),而磁盘上的inode
结构中的信息也会被加载到内存中的一个struct inode
结构中。
ELF文件内容被加载到文件内核缓冲区后,OS会根据其中的具体内容和虚拟编址,来初始化整个进程地址空间,也就是初始化结构体mm_struct和vm_area_struct
中的内容,当然还要创建页表,再填充页表中虚拟地址和物理地址的映射关系以及相关段(如数据段和代码段)的权限。
实际指令交由CPU执行时,首先将整个进程的入口地址交给CPU的寄存器EIP(即程序计数器,用以存储CPU即将执行的下一条指令的地址),而CPU中有一个CR3
的寄存器,其中存储了当前在CPU上运行进程的页表物理地址,CR3
和EIP
各自将其中的内容交给CPU中的另一个集成单元MMU
,即内存管理单元,该单元会与操作系统交互,从而拿到实际的物理地址——有了物理内存地址后,CPU就可以通过操作系统拿到相应物理内存处的内容,进而执行了。
上述过程就是一个进程虚拟地址到物理地址映射的全过程,我们可以看到,编译器,操作系统,CPU在整个虚拟地址映射中扮演重要角色,因此,这三个硬件或软件,实质上都要支持虚拟地址空间技术。
额外说明一下,一个可执行ELF文件的入口虚拟地址在其ELF文件的ELF HEADER
中是有记录的,这个入口处,不是main
函数,因为main
函数实际上也是被调用的,入口处实际上是一个名为_start
的函数。
上述内容中有一项名为Entry point address,这一项即记录该可执行ELF
文件的入口地址,该入口地址对应的是一个_start
函数。
5.3 ELF与动态链接、动态库
接下来,我们要重点讲述动态链接和动态库加载的原理。
5.3.2 动态库
在讲述之前,我们要搞清楚两个问题:
- 一个进程是如何看到动态库的?
- 多个进程之间是如何共享库的?
进程是如何看到它所链接的动态库呢?
首先,动态链接本质上是全局函数跳转到动态库中执行,因此一个可执行ELF文件加载到内存中,变为进程时,相关的动态库也肯定要加载到内存中。
而进程地址空间中,有一个区域是共享区,动态库加载到内存中时,相应进程的共享区就会存储动态库中相关函数调用的虚拟地址(不包括具体函数内容),然后通过页表,相关函数调用的虚拟地址就可以映射到实际物理地址,这样一个进程就可以拿到它所链接的动态库了。
那么多个进程是如何共享动态库的呢?
多个进程,对应有多个进程地址空间,在每个进程地址空间的共享区中,都有对应动态库中函数调用的虚拟地址,这些虚拟地址可能不相同,但通过页表映射后,一定找到的是同一个动态库,进而实现动态库的共享。
所以,动态库实际上是通过地址空间映射,实现了公共代码去重。
5.3.2 动态链接
在实际工程中,动态链接使用的次数要远远多于静态链接,gcc/g++编译器,默认采用的链接模式也是动态链接。
静态链接在链接时,会将静态库的文件于其它ELF文件进行合并,合并之后就会进行地址重定位,也就是链接时重定位;而动态链接,也要进行地址重定位的过程,但是,它是在程序运行起来时,再进行重定位,也就是运行时重定位。
那这个运行时重定位的过程是如何完成的呢?
我们想要明白,动态库的重定位是如何完成的,必须先清楚,动态库是如何被加载到内存中的?
我们使用ldd
查看C语言可执行程序所链接的动态库时,除了能看到libc
,即C语言标准库外,总是还能看到一个名称中包含有ld
的库。
实际上,这是一个与动态链接器相关的动态库。
前面在讲到可执行程序的程序入口时,提到了一个_start
函数。
那start
函数中在做什么呢?
简单来说,start函数
会完成对堆栈环境的初始化,对全局变量区(或静态区)的初始化,最重要的一步,_start
会进行动态链接器的调用,即调用上述介绍的那个动态库。
调用动态链接器的相关代码后,动态链接器首先会解析当前进程中所有的动态库依赖,然后将相应的动态库加载到内存中。除此之外,最重要的是,动态链接器会完成所有的符号解析和重定位,确保该进程能够找到其所依赖的动态库中的全局变量与全局函数。
总结一下就是,动态链接是在运行时重定位,而重定位是由动态链接器相关的代码实现的。
下面来讲一讲是如何重定位的。
我们之前讲过,ELF文件采用的是平坦模式编址,也就是说起始地址为0,所以其中的任意内容,都可用偏移量来定位的。
所以,一个可执行ELF文件所依赖的动态库在进程地址空间的共享区中映射的位置是确定的(可以通过ldd命令查看),那么通过偏移量,该动态库中任意一个函数的虚拟地址也是确定的。而虽然动态库加载到物理内存中的起始地址虽然不是固定的,但是一旦加载到内存中后,起始地址确定了,通过偏移量,也能确定任意一个函数的地址,因此动态链接的重定位,本质上就与动态库加载到内存的具体位置无关了,这就叫做位置无关。我们在动态库的制作过程中,使用gcc -fPIC来生成位置无关码,正是得名于此。
通过上述介绍,我们明白了动态链接是由动态链接器在进程运行,进行重定位,可是有一点很奇怪,既然要进行地址重定位,那么肯定要修改函数调用时call
所对应的地址,也就说要修改代码区中的内容,可是代码区不是只读的吗,为什么能够被修改?
这就说明一点,动态链接器进行地址重定位时,根本不是修改代码区的内容。
实际上,在ELF文件中,存在一个section
,叫做全局偏移量表,即.got。实际上,我们在main
函数中,进行函数调用时,直接call
的也不是相应函数的虚拟地址,而是该函数在这个全局偏移量表中的一个地址。由于.got
这个节在合并成段时,会与.data 已初始化全局变量和静态变量
和.bss未初始化全局变量和静态变量
等合并为一个段,即数据段,而数据段是可读可写的,因此是可以被动态链接器做重定位修改。
我们可以看到.got .bss .data
都被合并到编号为3的段中,也就是数据段中。
所以,整个重定位的过程是:main函数中进行函数调用时,call
的是数据段中的全局偏移量表中的某个地址,这个地址不会被修改,在动态链接器做重定位时,根据相应offset
和动态库的起始加载位置,计算出绝对地址后,写入全局偏移量表,即完成重定位工作。
实际上,一个动态库是非常庞大的,如果将动态库中所有的函数和全局变量都完成重定位工作的话,消耗太大了,而实际上,一个动态库中很多函数是不会用到的。
因此操作系统对动态链接时重定位进行了额外的优化,引入了PLT
,即过程链接表,也被称为延迟绑定。简单来说,就是引入了一种惰性重定位机制——即当动态库中的一个函数第一次被调用时,对其进行重定位,然后再正常调用执行该函数。与其一开始就对动态库中所有函数进行重定位,不如将这个过程推迟到该函数第一次被调用的过程中。
说一些题外话,我们上述所讲的动态链接和重定位,都是在一个进程,即一个可执行ELF文件与动态库之间的依赖关系,但实际上动态库与动态库之间同样存在库依赖,同样存在从一个动态库跳转到另一个动态库的过程,所以动态库文件中,同样会存在全局偏移量表.got
,同样要维护全局偏移量表``.got,这也反向说明了动态库为什么也是ELF文件格式的。