(学习总结34)Linux 库制作与原理
Linux 库制作与原理
- 库的概念
- 静态库
- 操作归档文件命令 ar
- 静态库制作
- 静态库使用
- 动态库
- 动态库制作
- 动态库使用与运行搜索路径问题
- 解决方案
- 方案2:建立同名软链接
- 方案3:使用环境变量 LD_LIBRARY_PATH
- 方案4:ldconfig 方案
- 使用外部库
- 目标文件
- ELF 文件介绍
- ELF 形成与加载
- ELF 形成可执行文件
- ELF 可执行文件加载
- 理解链接与加载
- 静态链接
- ELF 加载与进程地址空间
- 虚拟地址 / 逻辑地址
- 进程虚拟地址空间
- 进程使用与共享动态库
- 动态链接与动态库加载
- 编译器在可执行程序中嵌入其它操作
- 动态库中的相对地址
- 程序与动态库的具体映射
- 可执行程序进行动态库函数调用
- 全局偏移量表 GOT(global offset table)
- 库间依赖
- 总结
库的概念
库是写好的、现有的、成熟的、可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在非常重要。
本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。
库有两种:
-
静态库,如:
.a (Linux 静态库后缀)
、.lib (windows 静态库后缀)
-
动态库,如:
.so (Linux 动态库后缀)
、.dll (windows 动态库后缀)
使用库时,库与应用程序两者的比例为 库 : 应用程序 == 1 : n
。且在 Linux 中默认安装大部分库时,都是优先使用动态库的形式。
静态库
静态库:程序在编译链接时把库的代码链接到可执行文件中,程序运行时将不再需要静态库。
gcc 编译默认使用动态链接库,只有在该库下找不到动态 .so
的时候才会采用同名静态库。我们也可以使用 gcc 的 -static 强制采用链接静态库(若找不到对应静态库则会报错)。
接下来先介绍一下制作静态库命令 ar
。
操作归档文件命令 ar
语法:ar [选项] [归档文件] [成员文件...]
功能:创建、修改和提取归档文件的命令行工具,常使用于制作静态库。
选项:
- r :插入文件到归档(替换同名文件,若不存在则添加)
- c :静默创建归档文件(不提示警告)
- s :生成或更新符号索引(等同 ranlib 命令)
- t :列出归档中的文件
- v :显示详细信息(如时间戳、权限)
- x :提取所有文件(或指定文件)到当前目录
- d :从归档中删除指定文件
- q :快速添加文件(不检查是否重复)
静态库制作
Linux 静态库的格式一般为:lib[库的名称].a
,前缀和后缀都是规定格式,中间部分才是静态库的名称。
无论是动态库还是静态库,本质都是源文件对应的 .o
文件,而静态库的本质就是将众多的 .o
文件打了一个包。
我们使用命令 ar -rc
命令制作静态库,首先准备自己的库文件:
// my_way.h#pragma oncevoid my_way(); // my_way.c#include "my_way.h"
#include <stdio.h>void my_way()
{printf("Hello Linux!\n");
} // my_string.h#pragma onceint my_strlen(const char* str); // my_string.c#include "my_string.h"int my_strlen(const char* str)
{int len = 0;while (str[len] != '\0'){ ++len;} return len;
}
使用 Makefile 自动化构建:
libmyWay.a:my_string.o my_way.oar -rc $@ $^ # 将目标文件打包成静态库rm *.o%.o:%.cgcc -c $<.PHONY:clean
clean:rm libmyWay.a output:libmyWay.a my_string.h my_way.h # 将头文件与静态库打包压缩成 zip 包mkdir -p ./my_lib/includemkdir -p ./my_lib/libcp -f *.h ./my_lib/includecp -f *.a ./my_lib/libzip -r my_lib my_lib
简单制作静态库:
将静态库与头文件一起打包:
静态库使用
静态库是一种归档文件,不需要将其拆开使用,只需要 gcc/g++ 使用链接即可:
// test.c 源文件
#include "my_string.h"
#include "my_way.h"
#include <stdio.h>int main()
{const char* str = "Hello Linux!\n";printf("str 长度: %d\n", my_strlen(str));my_way();return 0;
}
test.c 调用头文件 my_string.h 和 my_way.h 中的函数,此时需要链接打包好的静态库,使用命令 gcc -o test test.c -L . -l myWay
命令编译形成可执行文件:
其中的 -L
选项表示指定库的路径,-l
表示库的名称。
测试目标文件生成后,即便静态库删掉,程序照样也可以运行。
想了解 gcc/g++ 命令的更多选项可以参考:(学习总结25)Linux工具:vim 编辑器 和 gcc/g++ 编译器
动态库
动态库:程序在运行时链接动态库的代码,多个程序共享动态库的代码。
-
一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。
-
在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)
-
动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制,允许物理内存中的一份动态库被需要使用该库的所有进程共用,节省内存和磁盘空间。
动态库制作
同理于静态库,动态库格式一般为:lib[库的名称].so
。
gcc/g++ 带上 -shared
选项即可制作动态库,但文件从 .c
编译成 .o
时需要加上 -fPIC
(position independent code) 选项:
libmyWay.so:my_string.o my_way.ogcc -o $@ $^ -shared # 将目标文件打包成动态库rm *.o%.o:%.cgcc -fPIC -c $< # -fPIC 选项作用为产生位置无关码 .PHONY:clean
clean:rm libmyWay.sooutput:libmyWay.so my_string.h my_way.h # 将头文件与动态库打包压缩成 zip 包mkdir -p ./my_lib/includemkdir -p ./my_lib/libcp -f *.h ./my_lib/includecp -f *.so ./my_lib/libzip -r my_lib.zip my_lib
动态库使用与运行搜索路径问题
其它文件使用动态库时,gcc/g++ 执行的命令同静态库一样,使用命令 gcc -o test test.c -L . -l myWay
,但当编译执行 test 时却会报错:
其表示 test 文件加载 libmyWay.so 共享库时找不到 libmyWay.so 而出错。
这是因为我们只告诉了 gcc/g++ 动态库的路径,gcc/g++ 编译通过了,但系统并不知道动态库的路径在哪。
解决方案
我们可以使用命令 ldd
查看 test 需要的动态库:
可以看到,系统并没有寻找到 libmyWay.so 的路径。
动态库搜索路径解决方案:
-
拷贝对应
.so
文件到系统共享库路径下,一般指/usr/lib
、/usr/local/lib
、/lib64
库路径等 -
向系统共享库路径下建立同名软链接
-
更改环境变量
LD_LIBRARY_PATH
,但这个环境变量一般不存在,使用export
命令可以创建环境变量 -
ldconfig 方案:在
/etc/ld.so.conf.d/
中配置自己的文件,且在文件中写入对应动态库的路径,并使用命令ldconfig
更新
方案 1 的这里省略,我们来看看其它 3 个解决动态库路径的方案。
方案2:建立同名软链接
在 /usr/lib
等路径下建立文件需要提升权限,使用 sudo
+ ln -s
命令解决。
方案3:使用环境变量 LD_LIBRARY_PATH
但是要注意,这种方法只是内存级记录路径,结束 shell 会话后重启环境变量就没有了。如果想一直保存,可以在当前用户家目录的 .bashrc
文件末尾加入 export LD_LIBRARY_PATH=/home/user1/test_file
,之后每次登录当前用户时 shell 便会从 .bashrc
文件中获取对应的环境变量,则可以一直保存:
方案4:ldconfig 方案
在 /etc/ld.so.conf.d/
中配置好后使用 ldconfig
动态链接库管理命令,更新共享库缓存的搜索路径,便可以使用动态库了。
使用外部库
这里推荐一个 Linux 的图形库:ncurses。
# Centos 安装
sudo yum install -y ncurses-devel# ubuntu 安装
sudo apt install -y libncurses-dev
系统中其实有很多库,它们通常由一组互相关联的用来完成某项常见工作的函数构成。比如用来处理屏幕显示情况的函数(ncurses库)
这里有一个介绍 ncurse 库相关函数的文章:ncurse编程指南
目标文件
目标文件是一个二进制的文件,文件的格式是 ELF
,是对二进制代码的一种封装:
编译和链接这两个步骤,在 Windows 下被 IDE 封装的很完美(如 VS2022)。用户一般都是一键构建非常方便,但遇到错误时尤其是链接相关的错误,很多人可能就束手无策了。在 Linux 下,我们之前也分析过如何通过 gcc/g++ 编译器来完成这一系列操作。
之前的文章也介绍过动静态库,接下来我们再简单分析一下编译和链接的过程,来理解一下动静态库的使用原理。
编译:编译的过程其实就是将程序的源代码翻译成 CPU 能够直接运行的机器代码。
链接:多个文件链接形成可执行文件,对于库文件:
-
对于静态库,文件中需要的方法会直接拷贝静态库中的方法,执行时只需调用自己拷贝的那份。
-
对于动态库,文件中需要的方法只会拷贝找到动态库方法的地址,执行时会寻找动态库的方法。
比如:上面的使用案例中,源文件 test.c 里调用了 my_way 函数,而这个函数被定义在另一个源文件 my_way.c 中。当我们修改 test.c 时,只需要单独编译 test.c ,不用浪费时间重新编译整个工程,从而节省了时间。
ELF 文件介绍
要理解编译链接的细节,我们不得不了解一下 ELF 文件。其实有以下四种文件其实都是 ELF 文件:
-
可重定位文件(Relocatable File):即
.o
文件,包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。 -
可执行文件(Executable File):即可执行程序。
-
共享目标文件(Shared Object File):即
.so
文件。 -
内核转储(Core Dumps):存放当前进程的执行上下文,用于 dump 信号触发。
也就是说上部分的动静态库、可执行程序、.o
文件都是 ELF 格式。
一个 ELF 文件由以下四部分组成:
-
ELF 头(ELF header):包含 ELF 文件的基本信息,其位于文件的开始位置,它的主要目的是定位、描述文件的其它部分。比如文件类型、机器类型、版本、入口点地址、程序头表和节头表的位置和大小等。
-
程序头表(Program Header Table):列举了所有有效的段(segments) 和它们的属性。表里记着每个段开始的位置和位移(offset) 、长度。毕竟这些段都是紧密的放在二进制文件中,需要段表的描述信息,才能把它们每个段分割开。
-
节头表(Section Header Table):包含对节(sections) 的描述。如描述了 ELF 文件的各个节的起始地址、大小、标志等信息。
-
节(Section):ELF 文件中的基本组成单位,包含了特定类型的数据。ELF 文件的各种信息和数据都存储在不同的节中。如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。
最常见的节:
-
代码节(.text):用于保存机器指令,是程序的主要执行部分。
-
数据节(.data):保存已初始化的全局变量和局部静态变量。
ELF 形成与加载
ELF 形成可执行文件
ELF 形成可执行文件会将多份 .o
文件的 section 进行合并:
实际合并是在链接时进行的,但是并不是上图这么简单的合并。也会涉及对库合并,此处不做过多分析。
ELF 可执行文件加载
一个 ELF 会有多种不同的 Section,在加载到内存的时候也会进行 Section 合并,形成segment 。合并原则要求是相同属性,如:可读、可写、可执行、需要加载时申请空间等。
这样即便是不同的 Section,在加载到内存中,可能会以 segment 的形式加载到一起。
很显然这个合并工作在形成 ELF 时其合并方式已经确定了,具体合并原则被记录在了 ELF 的程序头表(Program Header Table) 中,使用命令 readelf -S [文件]
可以查看对应文件的 Sections 信息:
但为什么要将 Section 合并成为 segment 呢?
- Section 合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为 4096 字节(内存块基本大小、加载、管理的基本单位),如果
.text
部分为 4097 字节,.init
部分为 512 字节,那么它们将占用 3 个页面,而合并后,它们只需 2 个页面。 - 此外操作系统在加载程序时,会将具有相同属性的 Section 合并成一个大的 segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。
对于 程序头表 和 节头表 又有什么用呢,其实 ELF 文件提供 2 个不同的 视图 / 视角 来让我们理解这两个部分:
-
链接视图(Linking view) 对应节头表 Section Header Table:
- 文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图,能够理解 ELF 文件中包含的各个部分的信息。
- 为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整成可执行的段(segment)、可读写的段、只读段等。合并后空间利用率就大大提高了,否则很小的一段会导致物理内存页浪费太大,所以链接器趁着链接就把小块都合并了。
-
执行视图(Execution view) 对应程序头表 Program Header Table:
- 其会告诉操作系统,如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中,一定有 Program Header Table 。
-
总结来说就是:一个在链接时作用,一个在运行加载时作用。
从 链接视图 来看:
-
.text
节:保存了程序代码指令的代码节。 -
.data
节:保存了初始化的全局变量和局部静态变量等数据。 -
.rodata
节:保存了只读的数据,如一行C语言代码中的字符串。由于.rodata节是只读的,所以只能存在于一个可执行文件的只读段中。因此,只能是在text段(不是data段)中找到.rodata 节。 -
.bss
节:为未初始化的全局变量和局部静态变量预留位置。 -
.symtab
节:Symbol Table 符号表,就是源码里面那些函数名、变量名和代码的对应关系。其会用一个字符串将它们保存,中间用\0
分隔开。 -
.got.plt
节 (全局偏移表 - 过程链接表):.got
节保存了全局偏移表。.got
节和.plt
节一起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改。
从 执行视图 来看:
-
告诉操作系统哪些模块可以被加载进内存。
-
加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的。
在 ELF 头中可以找到文件的基本信息,以及可以看到 ELF 头是如何定位程序头表和节头表的。我们可以使用命令 readelf -h [文件]
查看可重定位文件的 ELF 头信息:
其中:
-
Magic :为 ELF 文件的标识符,用于验证文件是否为有效的 ELF 格式
-
Class :文件类型
-
Data :指定的编码方式
-
Type :指出 ELF 文件的类型
-
Machine :该程序需要的体系结构
-
Entry point address
:系统第一个传输控制的虚拟地址,在这启动进程。假如文件没有如何关联的入口点,该成员就保持为 0。 -
Size of this header :保存 ELF 头大小(以字节计数)
-
Size of program headers :保存着在文件的程序头表中一个入口的大小
-
Number of program headers :保存着在程序头表中入口的个数。因此 e_phentsize 和 e_phnum 的乘机就是表的大小(以字节计数),假如没有程序头表,变量为 0。
-
Size of section headers :保存着 section 头的大小(以字节计数)。一个 section 头是在 section 头表的一个入口。
-
Number of section headers :保存着在 section header table 中的入口数目。因此 e_shentsize 和 e_shnum 的乘积就是 section 头表的大小(以字节计数)。假如文件没有 section 头表,值为 0。
-
Section header string table index :保存着跟 section 名字字符表相关入口的 section 头表索引。
总之 ELF header 的主要目的是定位文件的其它部分。
注意:
虚拟地址空间不仅仅是进程看待内存的方式,在磁盘中没有加载到内存的一个可执行程序,代码和数据也是采用虚拟地址统一编址。
并且所有的可执行程序,都是一个 segment。Linux 使用的是平坦模式编址,所有函数、变量的编址起始偏移量都从 0 开始。
理解链接与加载
静态链接
无论是自己的 .o
文件,还是静态库中的 .o
文件,本质都是把 .o
文件进行链接的过程。所以研究静态链接,本质就是研究 .o
文件是如何链接的。
使用 objdump -d [文件]
命令会将代码段 .text
进行反汇编查看,这样可以查看编译后的 .o
目标文件:
我们可以看到这里的 call 指令,在 test.c
中它们对应之前调用的 printf
、my_string
和 my_way
函数,但是我们发现它们的跳转地址都被设成 0。
这是因为在编译 test.c
的时候,编译器是完全不知道 printf
、my_string
和 my_way
函数的存在。比如位于内存的哪个区块,具体代码都是不知道的。因此,编辑器只能将这两个函数的跳转地址先暂时设为 0。
这个跳转地址会在链接的时候被修正,为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块 .data
中还存在一个重定位表。这张表在链接时,会根据表里记录的地址将其修正。注意 printf
函数涉及到动态库,这里不讨论。
而静态链接就是把库中的 .o
进行合并。和上述过程一样所以链接其实是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立的可执行文件。其中就包括我们提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的 .o
文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。
静态库:
静态链接的 test
可执行文件:
所以,链接过程中会涉及到对 .o
中外部符号进行地址重定位。
ELF 加载与进程地址空间
虚拟地址 / 逻辑地址
一个 ELF 程序在没被加载到内存时,本来就有地址。当代计算机工作的时候,都采用 " 平坦模式 " 进行工作。所以也要求 ELF 对自己的代码和数据进行统一编址。其实严格意义上应该叫做逻辑地址(起始地址 + 偏移量),但是我们认为起始地址是 0。可以说虚拟地址在程序还没加载到内存时,就已经把可执行程序进行统一编址了。
下面是 objdump -S
反汇编之后的代码,最左侧的就是 ELF 的虚拟地址:
进程 mm_struct
、vm_area_struct
在进程刚刚创建的时候,初始化数据从 ELF 各个 segment 获取,每个 segment 有自己的起始地址和自己的长度,用来初始化内核结构中的 [start,end] 等范围数据,另外再用详细地址填充页表。
所以虚拟地址机制不仅操作系统要支持,编译器也要支持。
进程虚拟地址空间
ELF 在被编译好之后,会把自己未来程序的入口地址记录在 ELF header 的 Entry 字段中,CPU 执行程序时,只需要记录此地址便可以运行程序:
进程虚拟地址空间讲解在 (学习总结31)Linux 进程地址空间与进程控制 ,这里不赘述。
进程使用与共享动态库
第一个进程使用动态库时,先将该库的代码段(.text) 和 只读数据段(.rodata) 映射到物理内存,使用时需要在页表中建立地址对应的映射关系即可使用动态库。而后续的进程使用此动态库,只需要页表映射,直接复用这块物理内存:
动态链接与动态库加载
动态链接远比静态链接要常用得多。静态链接由于会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,虽然不需要额外的依赖就可以运行,但最大的问题在于生成的文件体积大,相当耗费内存资源。
随着软件复杂度的提升,操作系统也越来越臃肿,不同的软件很有可能包含了相同的功能和代码,使用静态链接将会浪费大量的内存和硬盘空间。
这时动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存。这样可以节省大量空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。
那动态链接是如何工作的?
首先要说明一点,动态链接是将链接的整个过程推迟到了程序加载的时候。比如运行一个程序,操作系统会先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。
当动态库被加载到内存以后,一旦它的内存地址被确定,就可以去修正动态库中的那些函数跳转地址了。
编译器在可执行程序中嵌入其它操作
在 C/C++ 程序开始执行时,首先并不会直接跳转到 main 函数。而是 _start 函数,这是一个由 C 运行时库(通常是 glibc)或链接器(如 ld)提供的特殊函数。
在 _start 函数中会执行一系列初始化操作:
-
设置堆栈:为程序创建一个初始的堆栈环境。
-
初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
-
动态链接:这是很关键的一步, _start 函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。
-
调用 __libc_start_main :一旦动态链接完成, _start 函数会调用 __libc_start_main(这是 glibc 提供的一个函数)。 __libc_start_main 函数负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。
-
调用 main 函数:最后 __libc_start_main 函数会调用程序的 main 函数,此时程序的执行控制权才正式交给用户编写的代码。
-
处理 main 函数的返回值:当 main 函数返回时, __libc_start_main 会负责处理这个返回值,并最终调用 _exit 函数来终止程序。
其中的动态链接:
-
动态链接器:
-
动态链接器(如 ld-linux.so)负责在程序运行时加载动态库。
-
当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
-
-
环境变量和配置文件:
-
Linux 系统通过环境变量(如
LD_LIBRARY_PATH
)和配置文件(如/etc/ld.so.conf
及其子配置文件)来指定动态库的搜索路径。 -
这些路径会被动态链接器在加载动态库时搜索。
-
-
缓存文件:
-
为了提高动态库的加载效率,Linux 系统会维护一个名为
/etc/ld.so.cache
的缓存文件。 -
该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。
-
上述过程描述了 C/C++ 程序在 main 函数之前执行的一系列操作,但这些操作对于大多数程序员来说是不透明的。程序员通常只需要关注 main 函数中的代码,而不需要关心底层的初始化过程。然而了解这些底层细节有助于更好地理解程序的执行流程和调试问题。
动态库中的相对地址
动态库为了 能随时进行加载 和 支持并映射到任意进程的任意位置,对其中的方法进行统一编址,采用相对编址的方案进行编址的(可执行程序也一样,都遵守平坦模式,只不过 exe 是直接加载的)。
但注意每个独立模块起始地址都是 0,合并时只需确定谁先谁后,再将自己的起始到结束地址整体的加上前一个模块最后的地址即可串联起来。
程序与动态库的具体映射
动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的。
让进程找到动态库的本质也是文件操作。不过访问库函数时,是通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中。
在 vm_area_struct
中有一个 struct file
指针,其指向的 file
有 struct path
类型,而这个 struct path
其中一个变量就是 struct dentry
指针类型。
我们在 (学习总结33)Linux Ext2 文件系统与软硬链接 - 路径缓存 曾提到过 struct dentry
,它维护树状路径结构并记录对应文件的 inode,通过 inode 的 i_block
寻址便可以找到磁盘文件中动态库的数据块,但注意库内容是在文件内核缓冲区中。
可执行程序进行动态库函数调用
动态库被我们映射到当前进程的地址空间中时,库的虚拟起始地址进程已经知道,库中每一个方法的偏移量地址进程也知道。则可以说,所有访问库中任意的方法,只需要知道 库的起始虚拟地址 + 方法偏移量
即可定位库中的方法。
而且调用过程是从代码区跳转到共享区,调用完毕再返回到代码区,整个过程完全在进程地址空间中进行的。
全局偏移量表 GOT(global offset table)
可执行程序运行之前会先把所有库加载并映射,所有库的起始虚拟地址都应该提前让进程知道,然后对程序里加载到内存中的库函数调用进行地址修改,在内存中二次完成地址设置(这个叫做加载地址重定位)。
但是我们知道代码区在进程中是只读的,所以动态链接采用的做法是在 .data
或 .got
(可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做 全局偏移表GOT。表中每一项都是运行模块要引用的一个全局变量或函数的地址。
又因为 .data
和 .got
区域是可读写的,所以可以支持动态进行修改。
注意:
-
由于代码段只读不能直接修改代码段。但有了 GOT表,代码便可以被所有进程共享。考虑到不同进程的地址空间中各动态库的绝对地址、相对位置都不同,反映到 GOT表 上就是每个进程的每个动态库都有独立的 GOT表 ,所以进程间不能共享 GOT表。
-
在单个
.so
下,由于 GOT表 与.text
的相对位置是固定的,我们完全可以利用 CPU 的相对寻址来找到 GOT表。 -
在调用函数的时候会首先查 GOT表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
-
这种方式实现的动态链接就被叫做
PIC 地址无关代码
。或者说我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享。这也是为什么之前我们在动态库制作部分,给编译器将.c
编译成.o
时指定-fPIC
参数的原因,而PIC = 相对编址 + GOT
。
库间依赖
注意不仅仅只有可执行程序调用库。库之间是有依赖的,则库也会调用其它库。
而且库和可执行程序一样都是 ELF 的格式,即也有 GOT表,则可以做到库和库之间互相调用也是与地址无关的。
GOT表 中的映射地址会在运行时修改,我们可以通过 gdb 调试去观察 GOT表 的地址变化。有兴趣的读者可以参考:通过GDB学透PLT与GOT
由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步是非常耗时的。为了进一步降低开销,操作系统还做了一些其它的优化,比如延迟绑定,或者也叫 PLT 过程连接表(Procedure Linkage Table)。与其在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候,因为动态库中绝大多数的函数在程序运行期间可能一次都不会被调用。
PLT 大致思路是:GOT 的跳转地址默认会指向一段辅助代码,它也被叫做 桩代码/stub。在第一次调用函数时,这段代码会负责查询真正函数的跳转地址,并且去更新 GOT 。于是再次调用函数时,就会直接跳转到动态库中真正的函数实现。
总而言之,动态链接实际上将链接的整个过程,比如符号查询、地址的重定位从编译时推迟到了程序的运行时。它虽然牺牲了一定的性能和程序加载时间,但绝对是值得的。因为动态链接能够更有效的利用磁盘空间和内存资源,也极大方便了代码的更新和维护,更关键的是它实现了二进制级别的代码复用。
系统在解析其中的依赖关系时,就是加载并完善动态库之间的 GOT 过程。
总结
-
静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件。
-
静态链接会将编译产生的所有目标文件,和用到的各种库合并成一个独立的可执行文件,其中链接器会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
-
而动态链接将链接的整个过程推迟到程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过 GOT 方式进行调用(运行重定位,也叫做动态地址重定位)。