在上一篇文章中,我们已经学习了库的制作与使用,掌握了如何创建和调用库文件。接下来,我们将进一步探讨 ELF 文件的链接与加载机制,深入理解程序在运行前的准备过程,从而更全面地掌握 Linux 下的程序执行流程。
1. 目标文件
编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。在编译之后会生成两个扩展名为 .o 的文件,它们被称作目标文件。目标文件是⼀个⼆进制的文件,文件的格式是 ELF ,是对二进制代码的⼀种封装。
对于下面目录中的文件使用gcc编译后file.o文件,可以看到hello.o文件的格式是ELF。
2. ELF文件
要理解编译链链接的细节,我们不得不了解一下ELF文件。
ELF(Executable and Linkable Format)文件是Linux系统下可执行文件、目标文件、共享库和核心转储(core dump)的标准格式。
2.1 四种ELF文件
- 可重定位文件(Relocatable File) :即xxx.o文件。包含适合与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。
- 可执行文件(Executable File) :即可执行程序。
- 共享目标文件(Shared Object File) :即xxx.so文件(动态库)。
- 内核转储(core dumps) :存放当前进程的执行上下文,用于dump信号触发。
2.2 ELF文件的组成
- ELF头(ELF header) :描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分。
- 程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在二进制文件中,需要段表的描述信息,才能把他们每个段分割开。
- 节头表(Section header table) :包含对节(sections)的描述。包括节的名称、类型、大小、地址、偏移等信息。
- 节(Section ):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。
在链接阶段,链接器(Linker)负责将多个目标文件合并,解析符号并生成最终的可执行文件或共享库。
在加载阶段,操作系统的程序加载器(Loader)会解析 ELF 文件,映射必要的段到内存,并进行动态链接,以确保程序的正常执行。
3. ELF从形成到加载
3.1 readelf
readelf 是 Linux 下用于解析和查看 ELF 文件结构的工具,可以查看可执行文件、目标文件(.o
)、共享库(.so
)等 ELF 格式文件的详细信息。
常用命令
readelf -h a.out:查看 ELF 头信息,包括文件类型(可执行文件、共享库等)、入口地址、架构等。
readelf -l a.out:查看程序头表(Program Header Table),显示 ELF 在运行时如何映射到内存。
readelf -S a.out:查看节(Section)表,包括 .text、.data、.bss 等。
readelf -s a.out:查看符号表(Symbol Table),用于了解变量和函数的地址等信息。
readelf -r a.out:查看重定位信息(Relocation),显示哪些地址在加载时需要调整。
readelf -d a.out:查看动态段(Dynamic Section),显示动态库依赖信息。
readelf -x .rodata a.out:查看某个具体节的十六进制数据,例如 .rodata 段存储只读字符串和常量。
例如使用readelf -S查看ls命令的节头表:
从上面的输出中,我们获取到了 ELF 文件的节(Section)表信息,包括各个节的名称、类型、大小、地址、偏移量等。例如:
(1)节的数量:该 ELF 文件包含 30 个节(Section Headers),起始偏移地址为 0x1c3e8。
(2)关键节信息:
- text节:是保存了程序代码指令的代码节。
- data节:保存了初始化的全局变量和局部静态变量等数据。
- rodata节:保存了只读的数据,如⼀行C语言代码中的字符串。由于 .rodata 节是只读的,所以只能存在于一个可执行文件的只读段中。因此,只能是在 .text 段(不是 .data 段)中找到 .rodata 节。
- BSS节:为未初始化的全局变量和局部静态变量预留位置。
- symtab节:Symbol Table 符号表,就是源码里面那些函数名、变量名和代码的对应关系。
- got.plt节(全局偏移表-过程链接表):.got 节保存了全局偏移表,.got 节和 .plt 节一起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改。对于 GOT 的理解,后面会说。
(3)地址(Address):
- A(Alloc) 表示该节在加载到内存时会被分配。
- AX(Alloc + Execute) 表示可执行代码段,如 .text。
- AI(Alloc + Initialized) 表示已初始化的数据段,如 .data。
(4)偏移量(Offset):文件中的具体位置。例如:
- .interp 位于 0x238,存储动态链接器路径。
- .text(代码段)存储在 .init 之后,包含实际的可执行指令。
综上所述,这个 ls 可执行文件是一个 ELF 共享目标文件(支持动态链接),包含完整的动态链接、符号表、重定位等信息,能够在 Linux 环境下运行并调用共享库。
以上是从链接视角来看。
3.2 ELF形成可执行
step-1:将多份 C/C++ 源代码,翻译成为目标 .o 文件。
step-2:将多份 .o 文件section进行合并。
3.3 ELF可执行文件加载
⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成segment。
合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等。这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起,并且这个合并工作的合并方式在形成ELF的时候就已经确定了,具体合并原则被记录在了ELF的 程序头表(Program header table) 中。
3.3.1 程序表头
程序表头中记录了哪些段要合并。
根据上面的输出结果,我们可以提取出以下几个要点来帮助理解 ELF 文件的结构:
-
文件类型和入口点信息:
ELF file type is EXEC (Executable file)
:该文件是一个可执行文件。Entry point 0x404324
:表示程序的入口点地址,程序的执行从这个地址开始。它通常指向程序的主函数或者第一个被调用的函数。
-
程序头表信息:
There are 9 program headers, starting at offset 64
:该 ELF 文件包含 9 个程序头,从文件的第 64 字节开始。- 对于每个程序头,它包含了许多有用的信息,例如:
- 类型(Type):描述了该段的功能,例如
PHDR
(程序头表)、INTERP
(程序解释器)、LOAD
(可加载段)、DYNAMIC
(动态链接信息)等。 - 偏移量(Offset):该段在 ELF 文件中的起始位置。
- 虚拟地址(VirtAddr):该段在虚拟内存中的起始位置,表示在内存中加载后的位置。
- 文件大小(FileSiz):该段在文件中的大小。
- 内存大小(MemSiz):该段在内存中的大小。
- 标志(Flags):描述该段的访问权限,如可读(R)、可写(W)、可执行(E)。
- 对齐(Align):该段在内存和文件中的对齐要求,帮助操作系统正确加载段。
- 类型(Type):描述了该段的功能,例如
- 特别的,INTERP 类型的程序头指定了
/lib64/ld-linux-x86-64.so.2
作为程序的动态链接器路径,这个链接器在程序加载时负责动态链接库的加载和符号解析。
-
段到节的映射信息:
- 段
01
包含.interp
节,表示这个段负责存储程序的解释器信息。 - 段
02
包含多个节,如.interp
、.note.ABI-tag
、.note.gnu.build-id
等,这些节分别存储与程序构建、ABI 兼容性和动态链接相关的信息。
以上是从加载视角来看。
为什么要将section合并成为segment?
(1)Section合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占用3个页面,而合并后,它们只需2个页面。
(2)此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个大的segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。
3.3.2 程序头表和节头表
ELF 文件提供2个不同的视图/视角来让我们理解这两个部分。
1. 链接视图(Linking View) - 节头表(Section Header Table)
- 作用:节头表用于描述 ELF 文件中的各个节(section)。它提供了关于 ELF 文件中各节的详细信息,如节的名称、类型、大小、地址等。它是链接器(Linker)在静态链接过程中使用的。
- 功能:
- 链接器使用节头表来处理和合并各个节(例如
.text
、.data
、.bss
)。在链接过程中,链接器将把这些节合并成更大的节,方便后续的程序加载。 - 这些节通常对应于程序的源代码、已初始化的数据、未初始化的数据等。
- 链接器在执行链接时会根据节头表的信息,决定如何将这些节合并成段(segment)以便优化空间利用。
- 链接器使用节头表来处理和合并各个节(例如
- 优化空间布局:节头表帮助链接器优化内存空间布局,避免在物理内存中浪费过多空间。例如,多个小的节可以合并为一个较大的节,在物理内存中更加高效。
2. 执行视图(Execution View) - 程序头表(Program Header Table)
- 作用:程序头表描述了如何加载可执行文件。它告诉操作系统如何将 ELF 文件加载到内存中并初始化进程的内存空间。程序头表中包含的信息主要涉及 ELF 文件的段(segment),而不是节(section)。
- 功能:
- 程序头表告诉操作系统每个段的虚拟地址、内存大小、文件偏移等,以便操作系统能够将文件的各个段加载到内存中。
- 它包含了各个段的描述信息,如
.text
段(包含可执行代码)、.data
段(包含已初始化的数据)和.bss
段(包含未初始化的数据)。 - 程序头表是运行时加载 ELF 文件的关键部分,操作系统会根据这些信息加载可执行程序并进行内存映射。
区别总结:
- 节头表:用于 链接阶段,描述 ELF 文件中的节,帮助链接器了解每个节的属性以及如何合并节,生成更大的节。它侧重于静态链接时如何处理文件中的不同部分。
- 程序头表:用于 加载阶段,描述 ELF 文件中的段,告诉操作系统哪些模块可以被加载进内存,加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行等。它侧重于运行时如何将文件中的各个段映射到进程的虚拟内存中。
这两者一个在链接时作用,一个在运行加载时作用。
3.3.3 EXL头
我们可以在 ELF头 中找到文件的基本信息,以及可以看到ELF头是如何定位程序头表和节头表的。例如我们查看下hello.o这个可重定位文件的主要信息:
下面是EXL头中的一些信息
1. 文件类别(Class):
ELF64
表示该字段是 64位的ELF文件,说明该文件适用于64位的处理器架构。
2. 数据编码(Data):
2's complement, little endian
表示该文件采用 补码表示数据,并且采用 小端序(little-endian)存储,即数据的低位字节存放在内存的低地址处。
3. 文件类型(Type):
EXEC (Executable file)
表示该文件是一个 可执行文件。
4. 入口点地址(Entry point address):
0x404324
表示程序的 入口点地址,即程序开始执行的起始位置。该地址通常对应程序的第一条指令,操作系统从这个地址开始加载并执行程序。
5. 程序头表起始位置(Start of program headers):
64 (bytes into file)
表示程序头表在文件中的 起始偏移位置为64字节。程序头表包含了文件中各个段(Segment)的信息,如代码段、数据段等。操作系统在加载程序时,依赖程序头表来正确地将段加载到内存中。
6. 节头表起始位置(Start of section headers):
115688 (bytes into file)
表示节头表在文件中的 起始偏移位置为115688字节。节头表描述了文件中的各个节(Section)的信息,比如代码节、数据节、符号表等。不同于程序头表,节头表是用来描述文件内部各个部分的组织结构。
7. 程序头表项数量(Number of program headers):
9
表示程序头表中有 9个条目,即该文件包含9个段(Segment)。每个段的描述都包含在一个程序头表项中,操作系统通过这些条目来理解如何加载和映射这些段。
8. 节头表项数量(Number of section headers):
30
表示节头表中有 30个条目,即该文件包含30个节(Section)。这些节包含不同类型的信息,例如代码、数据、符号等,操作系统或调试器会根据节头表来解析文件内容。
4. 连接与加载
4.1 静态链接
无论是自己的.o文件,还是静态库中的.o文件,本质都是把.o文件进行连接的过程,所以:研究静态链接,本质就是研究.o是如何链接的。
查看编译后的.o目标文件。objdump -d 命令:将代码段(.text)进行反汇编查看
hello.o 中的 main 函数不认识 print f和 run 函数,code.o 不认识 printf 函数,多个.o彼此不知道对方。
我们可以看到这里的callq指令,它们分别对应之前调用的printf和run函数,但是你会发现他们的跳转地址都被设成了0。那这是为什么呢?
其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的,比如他们位于内存的哪个区块,代码长什么样都是不知道的。因此,编辑器只能将这两个函数的跳转地址先暂时设为0。
这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表里记录的地址将其修正。
下面是hello.c和code.c的代码
puts:就是printf的实现
UND就是:undefine,表示未定义说白了就是本.o文件找不到
两个.o进行合并之后,在最终的可执行程序中,就找到了run 。
000000000040052d:地址
FUNC:表示run符号类型是个函数
13:就是run函数所在的section被合并最终的那⼀个section中了,13就是下标。
这里被合并到下面的.text里了
怎么证明上面的说法?关于hello.o或者code.o call后面的00 00 00 00有没有被修改成为具体的最终函数地址呢?
objdump -d main.exe # 反汇编main.exe只查看代码段信息,包含源代码
静态链接就是把库中的.o进行合并,和上述过程⼀样。所以链接其实就是将编译之后的所有目标文件连同用到的⼀些静态库运行时库组合,拼装成⼀个独立的可执行文件。其中就包括地址修正,当所有模块组合在⼀起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。
所以,链接过程中会涉及到对.o中外部符号(在当前目标文件中被引用,但却没有在该目标文件中定义的符号)进行地址重定位。
4.2 ELF加载与进程地址空间
4.2.1 虚拟地址/逻辑地址
⼀个ELF程序,在没有被加载到内存的时候,有没有地址呢?
进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?
答案:
⼀个ELF程序,在没有被加载到内存的时候,就已经有地址,当代计算机工作的时候,都采用"平坦模式"进行工作。所以也要求ELF对自己的代码和数据进行统⼀编址下面是 objdump -S 反汇编之后的代码
最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量),但是我们认为起始地址是0。也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统⼀编址了。
- 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?从ELF各个segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的[start,end]等范围数据,另外再用详细地址,填充页表。
所以:虚拟地址机制,不光OS要支持,编译器也要支持。
4.2.2 进程虚拟地址空间
ELF在被编译好之后,会把自己未来程序的入口地址记录在ELFheader的Entry字段中:
当一个程序启动时,操作系统为该程序分配虚拟地址空间。这些虚拟地址空间的布局和信息存储在进程的控制块(PCB)中的 mm_struct
结构体里。操作系统加载程序时,CPU 从 ELF 文件头部获取入口地址(Entry point address),并将程序的代码和数据加载到为该进程分配的虚拟地址空间中。
此时,虚拟地址空间并不会直接对应物理内存。物理地址是在程序运行过程中动态分配的,通常通过操作系统的内存管理系统在进程首次访问虚拟地址时,由页表和内存管理单元(MMU)完成虚拟地址到物理地址的映射。操作系统会通过页表将虚拟地址映射到物理地址,并且 MMU 在程序访问虚拟地址时,查找页表项来转换为对应的物理地址,进而访问物理内存。
这种机制使得每个进程拥有独立的虚拟地址空间,避免了进程间的地址冲突,同时操作系统可以在物理内存中灵活地进行虚拟地址到物理地址的映射,提升了内存管理的灵活性与安全性。
物理地址的分配:物理地址并不是在程序启动时就预先分配好的,而是在程序运行时,通过操作系统的内存管理机制按需分配。这是通过懒加载(Lazy Allocation)或按需分页来实现的。当程序访问某个虚拟地址时,操作系统会根据进程的页表动态分配相应的物理内存,并通过 MMU 完成虚拟地址到物理地址的映射。
CPU从物理内存中得到虚拟地址,通过页表得到物理地址。
ELF的segment初始化结构体的各区域。
当程序启动时,操作系统会执行以下步骤:
-
创建 PCB(进程控制块),并为进程分配虚拟地址空间,初始化
mm_struct
结构体,记录了各个区域的虚拟地址范围,但程序的代码和数据还没有真正加载到内存中。 -
解析可执行文件(main.exe):
main.exe
在内核中表示为struct file
结构。- 操作系统依次查找 path → dentry → inode → iblock,确定程序文件的位置,并读取 ELF 头信息,包括Entry 入口地址等关键数据。
-
构建地址空间和页表:
- 操作系统根据 ELF 格式解析可执行文件,确定代码段、数据段、bss 段、堆、栈等各个内存区域,并在
mm_struct
中记录这些虚拟地址空间信息。 - 此时虚拟地址空间已经建立,但尚未分配物理地址。
- 操作系统根据 ELF 格式解析可执行文件,确定代码段、数据段、bss 段、堆、栈等各个内存区域,并在
-
按需加载程序代码和数据:
- 操作系统将 ELF 文件中的代码和数据加载到物理内存,此时物理地址才真正被分配。
- 在按需加载(Lazy Loading)机制下,并不会一次性将整个程序加载到物理内存,而是通过 缺页异常(Page Fault)触发内核分配物理页。
-
建立页表映射:
- 操作系统根据虚拟地址到物理地址的对应关系,构建页表(Page Table),并通过 MMU 进行虚拟地址到物理地址的转换。
- CPU 访问内存时,MMU 依据页表查找物理地址,实现指令和数据的访问。
-
执行程序:
- CPU 从 ELF 头部的 Entry 地址开始执行,读取指令并运行进程。
关键点:
- 虚拟地址先于物理地址创建,物理地址是在加载代码/数据到内存时分配的。
- 页表是虚拟地址与物理地址的映射关系,页表的初始化在进程创建时完成,但物理地址的映射会在按需分配时动态更新。
- 可执行文件(main.exe) 在内核中是
struct file
结构,操作系统通过path → dentry → inode → iblock
解析文件,并最终加载到进程的虚拟地址空间中。 - 虚拟地址属于进程的虚拟地址空间,并不会直接对应物理内存,而是存储在 进程控制块(PCB) 的
mm_struct
结构体中。 - 物理地址主要存储在页表中,页表存放在内存里,由 MMU 进行管理。
4.3 动态链接与动态库加载
4.3.1 进程与动态库
库函数调用:
1. 被进程看到:动态库映射到进程的地址空间
2. 被进程调用:在进程的地址空间中进行跳转
4.3.2 动态链接
(1)概要
动态链接其实远比静态链接要常用得多。比如我们查看下 main.c 这个可执行程序依赖的动态库,会发现它就用到了⼀个c动态链接库(libc.so.6):
这里的libc.so
是 C 语言的运行时库,里面提供了常用的标准输入输出、文件处理、字符串处理等等这些功能。那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。照理来说应该更加方便才对是吧?
静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。
这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。
动态链接到底是如何工作的?
动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据、代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。
当动态库被加载到内存后,它的最终地址被确定,此时,程序中的函数调用地址仍然是未解析的,需要进行修正。操作系统或者运行时链接器(如 ld.so
)会负责重定位这些地址,使程序能够正确调用动态库中的函数。这样,同一个动态库在内存中只需要保留一份副本,就可以被多个进程共享,从而大幅节省内存资源,同时也方便了库的更新和维护。
(2)编译器对可执行程序
在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到main函数。实际上,程序的入口点是_start,这是一个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。在_start函数中,会执行一系列初始化操作,这些操作包括:
-
设置堆栈:为程序创建一个初始的堆栈环境。
-
初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
-
动态链接:这是关键的一步,_start函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。
动态链接器:
- 动态链接器(如ld-linux.so)负责在程序运行时加载动态库。
- 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
环境变量和配置文件:
- Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf及其子配置文件)来指定动态库的搜索路径。
- 这些路径会被动态链接器在加载动态库时搜索。
缓存文件:
- 为了提高动态库的加载效率,Linux系统会维护一个名为/etc/ld.so.cache的缓存文件。
- 该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。
-
调用__libc_start_main:一旦动态链接完成,_start函数会调用__libc_start_main(这是glibc提供的一个函数)。__libc_start_main函数负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。
-
调用main函数:最后,__libc_start_main函数会调用程序的main函数,此时程序的执行控制权才正式交给用户编写的代码。
-
处理main函数的返回值:当main函数返回时,__libc_start_main会负责处理这个返回值,并最终调用_exit函数来终止程序。
(3)动态库中的相对地址
动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法统⼀编址,采用相对编址的方案进行编址的(其实可执行程序也⼀样,都要遵守平坦模式,只不过exe是直接加载的)。
访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法。
而且:整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的。
也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道。
然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置(这个叫做加载地址重定位)。
等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?
所以:动态链接采用的做法是在数据区 .data(可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。
因为 .data 区域是可读写的,所以可以支持动态进行修改。
以图中puts
函数调用puts(0x112233)@libc.so
为例,这里的0x112233
是偏移量。在程序执行时,当动态库(如libc.so
)被加载到内存后,其起始虚拟地址确定(假设为0x44332211
),会修改 GOT 中对应的记录,将起始地址与偏移量结合起来,以便后续正确调用函数。
由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表。
在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
这种方式实现的动态链接就被叫做PIC(地址无关代码)。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定 -fPIC 参数的原因,PIC = 相对编址 + GOT。
(4)库间依赖
plt是什么
不仅仅有可执行程序调用库
• 库也会调用其他库!!库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的呢??
• 库中也有.GOT,和可执行⼀样!这也就是为什么大家为什么都是ELF的格式!
由于动态链接在程序加载时需要对大量函数进行重定位,这一过程显然极为耗时。为了进一步降低开销,操作系统采取了一些优化措施,例如延迟绑定,它也被称为PLT(过程连接表,Procedure Linkage Table )。 传统的动态链接方式会在程序一开始就对所有函数进行重定位,而延迟绑定的思路是:与其如此,不如将重定位过程推迟到函数第一次被调用的时候。这是因为在绝大多数情况下,动态库中的许多函数在程序运行期间可能根本不会被使用到。
具体来说,GOT(全局偏移表)中的跳转地址默认会指向一段辅助代码,这段代码也被称作桩代码(stub)。当我们第一次调用某个函数时,桩代码会发挥作用,它负责查询该函数真正的跳转地址,并且更新GOT表。这样一来,当我们再次调用这个函数时,程序就会直接跳转到动态库中该函数真正的实现代码处,而无需再次执行查询和更新操作。