Linux动静态库制作与原理
什么是库
库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。
本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:
• 静态库 .a[Linux]、.lib[windows]
• 动态库 .so[Linux]、.dll[windows]
静态库
・静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。
・⼀个可执行程序可能用到许多的库,这些库运行有的是静态库,有的是动态库,而我们的编译默认为动态链接库,只有在该库下找不到动态.so 的时候才会采用同名静态库。我们也可以使用 gcc 的 -static 强转设置链接静态库。
静态库的制作
在本篇文章中使用只使用下面的.c和.h代码进行操作
#include "mytring.h"// 计算字符串长度
size_t my_strlen(const char *str) {const char *s = str;while (*s) s++;return s - str;
}// 字符串复制
char *my_strcpy(char *dest, const char *src) {char *d = dest;while ((*d++ = *src++));return dest;
}
#include <stddef.h>// 计算字符串长度
size_t my_strlen(const char *str);// 字符串复制
char *my_strcpy(char *dest, const char *src);
在制作静态库的过程中需要将.c文件先编译成.o文件 。
为什么需要先编译成.o文件呢?
若直接编译源文件到库,每次修改一个源文件都需要重新编译整个库。而使用 .o 文件,只需重新编译修改过的源文件,然后更新静态库。
目标文件(.o)是独立的编译单元:每个 .c 文件单独编译,不依赖其他源文件。例如,一个库可能包含 string.c、math.c 等多个源文件,编译后生成 string.o、math.o,可以独立更新其中一个而不影响其他。
静态库是 .o 文件的集合:通过 ar 工具将多个 .o 文件打包成库,方便复用。例如,多个项目可以共享同一个 libmystring.a。
这里介绍一下ar指令(归档指令)
ar [选项] [归档文件] [文件...]
归档文件(Archive File)是一种通过特定工具将多个文件或目录打包成一个独立文件的集合,主要用于存储、备份、分发或压缩数据。
静态库的本质就是将.o文件进行打包
这里使用ar指令进行归档
ar -rcs libmystring.a mytring.o
此时文件中就形成了libmystring.a。但是库的名字并非是libmystring.a,而是去掉前面的lib和后面的.a,所以库名字是mystring
假设我们有一个使用该库的程序main.c,可以这样进行编译:
gcc main.c -L. -lmystring -o main
-L:gcc在链接外部的库时,默认不会在当前目录下寻找,让操作系统在-L后面的路径中搜索库,所以-L.就表示在当前目录下搜索库
-l:链接名为mystring的库文件,-l后面只要库的真实名字
最后就形成了依赖与libmystring.a的可执行文件main
但是在删除掉libmystring.a后,main依旧可以运行,这就是静态库的特点。
动态库
• 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
• ⼀个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的⼀个表,而不是外部函数所在目标文件的整个机器码
• 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)
• 动态库可以在多个程序间共享,所以动态链接使得可执行文件件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的⼀份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间
动态库的制作
动态库的依旧采用最开始的两份代码来实验,两份代码分别是mytring.c和mytring.h
动态库必须编译为位置无关代码(Position Independent Code, PIC),使用 -fPIC 选项:
gcc -fPIC -c mytring.c -o dong.o
为什么需要编译成位置无关码?
动态库的核心特性是可以被加载到内存的任意地址,并被多个程序共享。如果库代码中包含固定地址的引用(如绝对跳转、直接变量地址),则当库被加载到不同地址时会出错。位置无关代码通过特殊机制解决了这个问题。
使用 -shared 选项将目标文件链接为动态库:
gcc -shared -o libdong.so dong.o
最后将main.c文件进行编译
gcc main.c -L. -ldong -o main
此时就出现了main可执行文件,但是./运行却出现提示
为什么能够形成可执行文件却无法运行呢?
这是由于我们只把目标文件所依赖的库告诉了gcc而已,编译的时候只有gcc在处理,但是在运行的时候就不需要gcc,转而需要操作系统进行处理,此时所依赖的库只有gcc直到,但是操作系统并不知道,所以就会报错
这是由于操作系统找不到文件所依赖的动态库,但是为什么静态库不会出现这个问题呢?
这是由于二者对链接方式的差别,静态库是直接将库方法记录到代码中的,动态库是将地址记录到代码中
这就是为什么静态链接的文件没有静态库也能够运行,而动态链接的文件缺失动态库就无法运行了,此时文件就无法通过地址来查找库方法了
如何解决?
1.修改临时环境变量
只需要将LD_LIBRARY_PATH修改,../dong表示父目录中的dong
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:../dong
2.修改配置文件
将库复制到系统默认搜索路径(如 /usr/lib 或 /usr/local/lib)
sudo cp libmath.so /usr/local/lib
sudo ldconfig # 更新动态链接器缓存(Linux)
3.使用 rpath 硬编码路径
编译时使用 -Wl,-rpath 选项指定运行时搜索路径
gcc main.c -L. -lmath -Wl,-rpath=. -o main
-Wl:将后面的参数传递给链接器(ld)
-rpath:运行时搜索路径,在可执行文件中硬编码动态库的搜索路径,告诉动态链接器(如 Linux 的 ld-linux.so)在程序运行时优先搜索哪些目录。
总结动静态库
gcc/g++默认使用动态库
非得使用静态库只能使用-static,一旦使用-static就必须拥有对应的静态库
在Linux系统下,默认安装的大部分库,默认都优先安装动态库
目标文件
• 编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们⼀般都是⼀键构建非常方便,但⼀旦遇到错误的时候呢,尤其是链接相关的错误,很多人就束手无策了。在Linux下,我们之前也学习过如何通过gcc编译器来完成这⼀系列操作。接下来我们深入探讨⼀下编译和链接的整个过程,来更好的理解动静态库的使用原理。
• 先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。
• 比如:在一个源文件 hello.c 里便简单输出"hello world!",并且调用⼀个run函数,而这个函数被定义在另⼀个原文件 code.c 中。这⾥我们就可以调用 gcc -c 来分别编译这两个原文件。
在编译之后会生成两个扩展名为 .o 的文件,它们被称作目标文件。要注意的是如果我们修改了⼀个原文件,那么只需要单独编译它这⼀个,而不需要浪费时间重新编译整个⼯程。目标文件是⼀个二进制的文件,文件的格式是 ELF ,是对二进制代码的⼀种封装。
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
ELF文件
• 要理解编译链链接的细节,我们不得不了解⼀下ELF文件。其实有以下四种文件其实都是ELF文件:
• 可重定位文件(Relocatable File) :即 xxx.o 文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。
• 可执行文件(Executable File) :即可执行程序。
• 共享目标文件(Shared Object File) :即 xxx.so文件。
• 内核转储(core dumps) ,存放当前进程的执行上下文,用于dump信号触发。
• ⼀个ELF文件由以下四部分组成:
• ELF头(ELF header) :描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分。
• 程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在⼆进制文件中,需要段表的描述信息,才能把他们每个段分割开。
• 节头表(Section header table) :包含对节(sections)的描述。
• 节(Section ):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。
• 最常见的节:
• 代码节(.text):⽤于保存机器指令,是程序的主要执行部分。
• 数据节(.data):保存已初始化的全局变量和局部静态变量
ELF从形成到加载轮廓
ELF形成可执行
• step-1:将多份 C/C++ 源代码,翻译成为目标 .o 文件
• step-2:将多份 .o 文件section进行合并
实际合并是在链接时进行的,但是并不是这么简单的合并,也会涉及对库合并
readelf
readelf 是 Linux 系统中用于分析 ELF(Executable and Linkable Format)格式文件的强大工具。ELF 文件包括可执行文件、动态库(.so)、目标文件(.o)等。通过 readelf,可以查看文件的各种元信息、段表、符号表等,是调试和逆向工程的重要工具。
readelf [选项] <elf-file>
1. 查看文件头信息(-h)
2.查看段表信息(-S)
3. 查看程序头信息(-l)
4. 查看动态链接信息(-d)
5.查看符号表(-s)
ELF可执行文件加载
• ⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成segment
• 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等
• 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
• 很显然,这个合并⼯作也已经在形成ELF的时候,合并方式已经确定了,具体合并原则被记录在了ELF的程序头表(Program header table) 中
可以通过readelf来查看可执行程序的section以及合并的segment
为什么要将section合并成为segment
•在文件中与内存的交互基本上是以4kb为基本单位,如果想要存储数据,不足4kb的内容也是按照4kb来进行存储的,而section所占空间一般很小,如果单个section进行存储的话会浪费空间
• Section合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为4096字节(内存块基本大小,加载、管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占用3个页面,而合并后,它们只需2个页面。
• 此外,操作系统在加载程序时,会将具有相同属性的section合并成一个大的segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。
对于程序头表和节头表又有什么用呢?其实ELF文件提供2个不同的视图/视角来让我们理解这两个部分:
• 链接视图(Linking view)- 对应节头表 Section header table
文件结构的粒度更细,将文件按功能模块的差异进行划分。静态链接分析时一般关注的是链接视图,能够理解ELF文件中包含的各个部分的信息。
为了空间布局上的效率,在链接目标文件时,链接器会把多个节(section)合并,规整成可执行的段(segment)、可读写的段、只读段等。合并后空间利用率更高,否则很小的段会导致物理内存页浪费(物理内存页分配一般为整数倍,如4K),因此链接器在链接阶段就将小块合并。
• 执行视图(Execution view)- 对应程序头表 Program header table
告诉操作系统如何加载可执行文件,完成进程内存的初始化。一个可执行程序格式中必须有program header table。
说白了就是:一个在链接时作用,一个在运行加载时作用。
• 从链接视图来看:
• 命令 readelf -S hello.o可以帮助查看ELF文件的节头表。
• .text节:保存了程序代码指令的代码节。
• .data节:保存了初始化的全局变量和局部静态变量等数据。
• .rodata节:保存了只读的数据(如C语言代码中的字符串)。由于.rodata节是只读的,只能存在于可执行文件的只读段中(如text段,而非data段)。
•.BSS节:为未初始化的全局变量和局部静态变量预留位置。
• .symtab节(Symbol Table符号表):保存源码中函数名、变量名与代码的对应关系。
• .got.plt节(全局偏移表-过程链接表):.got节保存全局偏移表,与.plt节共同提供对导入共享库函数的访问入口,由动态链接器在运行时修改(后文详述)。
• 使用 readelf 命令查看.so文件可以看到该节。
• 从执行视图来看:
• 告诉操作系统哪些模块可被加载进内存。
• 定义加载后各分段的权限(可读、可写、可执行)。
在ELF头中可以查看文件基本信息,并了解程序头表和节头表的定位。例如查看 hello.o可重定位文件的主要信息:
$ readelf -h hello.o
ELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 # ⽂件类型Data: 2s complement, little endian # 指定的编码⽅式Version: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: REL (Relocatable file) # 指出ELF⽂件的类型Machine: Advanced Micro Devices X86-64 # 该程序需要的体系结构Version: 0x1Entry point address: 0x0 # 系统第⼀个传输控制的虚拟地址,在那启动进程。假如⽂件没有如何关联的口点,该成员就保持为0。Start of program headers: 0 (bytes into file)Start of section headers: 728 (bytes into file)Flags: 0x0Size of this header: 64 (bytes) # 保存着ELF头⼤⼩(以字节计数)Size of program headers: 0 (bytes) # 保存着在⽂件的程序头表
(program header table)中⼀个⼊⼝的⼤⼩Number of program headers: 0 # 保存着在程序头表中⼊⼝的个数。因此,e_phentsize和e_phnum的乘机就是表的⼤⼩(以字节计数).假如没有程序头表,变量为0。
Size of section headers: 64 (bytes) # 保存着section头的⼤⼩(以字节计数)。⼀个section头是在section头表的⼀个⼊⼝ Number of section headers: 13 # 保存着在section header table中的⼊⼝数⽬。因此,e_shentsize和e_shnum的乘积就是section头表的⼤⼩(以字节计数)。
假如⽂件没有section头表,值为0。 Section header string table index: 12 # 保存着跟section名字字符表相关⼊⼝的section头表(section header table)
在操作系统中,想要对ELF文件进行解读,首先需要对文件判断是否为ELF文件,使用readelf -h查看文件会出现Magic,Magic中存储的是一段随机值,操作系统可以根据这段随机值来判断文件是否是ELF文件
理解连接与加载
静态链接
• 无论是自己的.o,还是静态库中的.o,本质都是把.o文件进行连接的过程
• 所以:研究静态链接,本质就是研究.o是如何链接的
那么静态库是如何形成可执行程序的
第一段代码是hello.c,第二段代码是code.c
#include<stdio.h>
void run();
int main() {printf("hello world!\n");run();return 0;
}#include<stdio.h>
void run() {printf("running...\n");
}
将他们编译成.o文件后使用objdump进行反汇编
我们能够发现在hello.o的反汇编中的printf和run对应的call找不到地址,e8后面是8个0
此时我们能够得出结论:hello.o不认识printf和run,run.o不认识printf。
我们可以看到这里的call指令,它们分别对应之前调用的printf和run函数,但是你会发现它们的跳转地址都被设成了0。那这是为什么呢?
其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的(比如它们位于内存的哪个区块、代码长什么样都是未知的)。因此,编译器只能将这两个函数的跳转地址先暂时设为0。
这个地址会在什么时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.text)中还存在⼀个重定位表,这张表会在链接阶段根据表里记录的地址进行修正。
将code.o和hello.o文件一起编译成可执行程序,再次进行反汇编
此时发现了main函数和run函数中的call中的e8都找到了对应的地址,并非是全0了
1.两个.o的代码段合并到了⼀起,并进行了统⼀的编址
2.链接的时候,会修改.o中没有确定的函数地址,在合并完成之后,进⾏相关call地址,完成代码调用
静态链接就是把库中的.o进行合并,和上述过程⼀样
所以链接其实就是将编译之后的所有目标文件连同用到的⼀些静态库运行时库组合,拼装成⼀个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。
ELF加载与进程地址空间
虚拟地址/逻辑地址
问题:
• ⼀个ELF程序,在没有被加载到内存的时候,有没有地址呢?
• 进程 mm_struct、vm_area_struct 在进程刚刚创建的时候,初始化数据从哪里来的?
答案:
•一个ELF程序还没加载到内存中的时候就已经有地址了,ELF程序的地址采用起始地址和偏移量的方式进行计算,目前的计算机体系中,起始地址都是从0开始的,所以起始地址不可能是真实的物理地址,它是虚拟地址。所以磁盘上的可执行程序,代码,数据编址,起始就是虚拟地址的统一编址
• ⼀个ELF程序,在没有被加载到内存的时候,本来就有地址。当代计算机⼯作时采用"平坦模式",因此ELF需要对自己的代码和数据进行统⼀编址。通过 objdump -S 反汇编后的代码
最左侧显示的是ELF的虚拟地址(严格来说是逻辑地址:起始地址+偏移量)。默认起始地址为0,这意味着程序未被加载时已预先完成虚拟地址编址。
• 进程 mm_struct、vm_area_struct 的初始化数据来源:ELF文件的各个 segment 提供起始地址和长度,用于初始化内核结构中的 [start, end] 范围数据,并通过详细地址填充页表。
结论:
• 虚拟地址机制不仅需要操作系统⽀持,也需要编译器配合。
重新理解进程虚拟地址空间
ELF 被编译后,会将程序入口地址记录在ELF header的 Entry 字段中。
通过查看ELF表头,能够发现里面记录了Entry point address,这就是程序的入口地址
在磁盘中,可执行程序的虚拟地址就已经记录在磁盘中了,当可执行程序加载到物理内存中,进程会创建task_struct,task_struct中的mm_struct会使用磁盘中可执行程序的起始地址到结尾地址进行初始化,代码加载到物理内存中,代码也是要占据物理内存的,所以每一行代码都有自己的物理地址,然后页表会将虚拟地址加载到页表的左侧,物理地址加载到页表的右侧
现在的问题是:我们想要运行程序,地址的映射关系都建立好了,但是cpu怎么直到起始地址?
cpu中有两种寄存器,分别是EIP和CR3,EIP负责读取程序的起始地址,而CR3负责加载起始地址的内容,cpu就是通过这种方法来执行程序的
动态链接与动态库加载
进程如何看到动态库
当运行进程A的时候,进程A所依赖的库也会加载到物理内存中,task_struct的页表就会建立与库的映射关系,因为库也是ELF文件,也有自己的虚拟地址空间
当库函数被调用的时候,程序会从代码区跳转到共享区中执行库函数,执行后便返回到代码区中
进程间如何共享库的
原理与单个进程类似,都是把所依赖的库加载到物理内存中,不同的进程中的共享区都会在页表中创建一块空间指向加载在物理内存中的库,但是库只会在内存中出现一次,在内存中并不会出现相同的库
当运行库函数时,程序运行会从代码区跳转到各自的共享区中,执行完库函数就返回到各自的代码区
动态链接
动态链接其实远比静态链接要常用得多。比如我们查看下 hello 这个可执行程序依赖的动态库,会发 现它就用到了一个c动态链接库: 这里的 libc.so 是C语言的运行时库,里面提供了常用的标准输入输出文件字符串处理等等这些功能。
那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种 库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。照理来说应该更加方便才对是吧?
静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,我们 的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。
这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。
动态链接到底是如何工作的??
首先要交代⼀个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行⼀个程序,操作系统会首先将程序的数据代码连同它用到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配⼀段内存。
当动态库被加载到内存以后,⼀旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
我们的可执行程序被编译器动了手脚
在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数。实际上,程序的入口点 是 _start ,这是一个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。 在 _start 函数中,会执行一系列初始化操作,这些操作包括:
1. 设置堆栈:为程序创建一个初始的堆栈环境。
2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位 置,并清零未初始化的数据段。
3. 动态链接:这是关键的一步, _start 函数会调用动态链接器的代码来解析和加载程序所依赖的
动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调
用和变量访问能够正确地映射到动态库中的实际地址。
动态链接器:
◦ 动态链接器(如ld-linux.so)负责在程序运行时加载动态库。
◦ 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
环境变量和配置文件:
◦ Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf及其子配置
文件)来指定动态库的搜索路径。
◦ 这些路径会被动态链接器在加载动态库时搜索。
缓存文件:
◦ 为了提高动态库的加载效率,Linux系统会维护一个名为/etc/ld.so.cache的缓存文件。
◦ 该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先
搜索这个缓存文件。
4. 调用 __libc_start_main :一旦动态链接完成, _start 函数会调用
__libc_start_main (这是glibc提供的一个函数)。 __libc_start_main 函数负责执行
一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。
5. 调用 main 函数:最后, __libc_start_main 函数会调用程序的 main 函数,此时程序的执
行控制权才正式交给用户编写的代码。
6. 处理 main 函数的返回值:当 main 函数返回时, __libc_start_main 会负责处理这个返回
值,并最终调用 _exit 函数来终止程序。
动态库中的相对地址
动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法,统一编址采用相对编址的方案进行编制的(其实可执行程序也一样,都要遵守平坦模式,只不过exe直接加载的)。
# ubuntu下查看任意⼀个库的反汇编
objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less# Cetnos下查看任意⼀个库的反汇编
$ objdump -S /lib64/libc-2.17.so | less
我们的程序,怎么和库具体映射起来的
在创建task_struct时,mm_struct 指向 struct file 指向 struct path 指向 struct dentory 最终指向inode ,通过inode信息就可以找到磁盘文件中的数据块,这样就建立了与动态库的映射关系
注意:
• 动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的
• 让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进
行跳转访问的,所以需要把动态库映射到进程的地址空间中
我们的程序,怎么进行库函数调用
注意:
• 库已经被我们映射到了当前进程的地址空间中
• 库的虚拟起始地址我们也已经知道了
• 库中每一个方法的偏移量地址我们也知道
• 所有:访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法
• 而且:整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的
先从inode获取库的位置,再将库的代码和数据加载到物理内存中,然后和页表建立映射关系,最终就得到了库的起始虚拟地址,在代码汇编的时候,会将库函数替换成在库中的偏移量,最终在建立映射关系之后,库函数就变成了库的起始虚拟地址+偏移量
全局偏移量表GOT(global offset table)
注意:
• 也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道
• 然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置(这个叫做加载地址重定位)
• 等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?
所以:动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。
• 因为.data区域是可读写的,所以可以支持动态进行修改 、
1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
2. 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表。
3. 在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
4. 这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
库间依赖
注意:
• 不仅仅有可执行程序调用库
• 库也会调用其他库!!库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的呢??
• 库中也有.GOT,和可执行一样!这也就是为什么大家都为什么都是ELF的格式!
由于GOT表中的映射地址会在运行时去修改,我们可以通过gdb调试去观察GOT表的地址变化。
• 由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,我们的操作系统还做了一些其他的优化,比如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。
思路是:GOT中的跳转地址默认会指向一段辅助代码,它也被叫做桩代码/stup。在我们第一次调用函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调用函数的时候,就会直接跳转到动态库中真正的函数实现。总而言之,动态链接实际上将链接的整个过程,比如符号查询、地址的重定位从编译时推迟到了程序的运行时,它虽然牺牲了一定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利用磁盘空间和内存资源,以极大方便了代码的更新和维护,更关键的是,它实现了二进制级别的代码复用。解析依赖关系的时候,就是加载并完善互相之间的GOT表的过程。