ELF文件从形成到加载

在上一篇文章中,我们已经学习了库的制作与使用,掌握了如何创建和调用库文件。接下来,我们将进一步探讨 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 文件的节头表信息,包括各个节的名称、类型、大小、地址、偏移量等。例如:

(1)节的数量:该 ELF 文件包含 30 个节(Section Headers),起始偏移地址为 0x1c3e8。

(2)关键节信息:

 - text节:是保存了程序代码指令的代码节。
 - data节:保存了初始化的全局变量和局部静态变量等数据。
 - rodata节:保存了只读的数据,如一行C语言代码中的字符串。由于 .rodata 节是只读的,所以只能存在于一个可执行文件的只读段中。因此,只能是在 .text 段(不是 .data 段)中找到 .rodata 节。
 - BSS节:为程序中未显式初始化的全局变量、局部静态变量,以及显式初始化为 0 的全局 / 局部静态变量预留虚拟地址空间。该节仅记录变量的大小和数量,不存储具体值(默认值 0 由操作系统加载时自动填充),因此不占用可执行文件的磁盘空间,仅在程序加载到内存时分配物理内存。
 - symtab节:Symbol Table 符号表,存储程序中所有 “符号” 的映射关系,核心包含函数名、全局变量名、局部静态变量名等标识符,以及每个符号对应的关键信息 —— 如符号所在的虚拟地址(或偏移量)、符号类型(函数 / 变量)、符号所属的段(代码段 / 数据段)等。其核心作用是为编译链接(如地址重定位)、调试(如通过变量名定位内存地址)提供符号与内存地址的对应依据。
 - .got.plt节(全局偏移表-过程链接表):.got 节保存了全局偏移表,.got 节和 .plt 节一起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改。对于 GOT 的理解,后面会说。

(3)地址(Address):

 - A(Alloc) 表示该节在加载到内存时会被分配。
 - AX(Alloc + Execute) 表示可执行代码段,如 .text。
 - AI(Alloc + Initialized) 表示已初始化的数据段,如 .data。

(4)偏移量(Offset):文件中的具体位置。例如:

 - .interp 位于 0x338,存储动态链接器路径。
 - .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..程序头表

程序头表中记录了哪些节要合并。

根据上面的输出结果,我们可以提取出以下几个要点来帮助理解 ELF 文件的结构:

(1)文件类型和入口点信息:

        - ELF file type is EXEC (Executable file):该文件是一个可执行文件。
        - Entry point 0x404324:表示程序的入口点地址,程序的执行从这个地址开始。它通常指向程序的主函数或者第一个被调用的函数。

(2)程序头表信息:

        - There are 9 program headers, starting at offset 64:该 ELF 文件包含 9 个程序表头,从文件的第 64 字节开始。

        - 对于每个程序头,它包含了许多有用的信息,例如:
1.类型(Type):描述了该段的功能,例如 PHDR(程序头表)、INTERP(程序解释器)、LOAD(可加载段)、DYNAMIC(动态链接信息)等。
2.偏移量(Offset):该段在 ELF 文件中的起始位置。
3.虚拟地址(VirtAddr):对应段在虚拟内存中的起始位置,表示在内存中加载后的位置。
4.文件大小(FileSiz):表示当前程序表头描述的内容在 ELF 文件中的实际字节大小。
5.内存大小(MemSiz):表示对应 “段” 加载到内存后占用的字节大小。
6.标志(Flags):描述对应 “段” 的内存访问权限,如可读(R)、可写(W)、可执行(E)。
7.对齐(Align):表示当前程序表头描述的内容(段或信息)在文件和内存中的对齐要求

        - 特别的,INTERP 类型的程序头指定了 /lib64/ld-linux-x86-64.so.2 作为程序的动态链接器路径,这个链接器在程序加载时负责动态链接库的加载和符号解析。

(3)段到节的映射信息:

        - 段 01 包含 .interp 节,表示这个段负责存储程序的解释器信息。

        - 段 02 包含多个节,如 .interp、.note.ABI-tag、.note.gnu.build-id 等,这些节分别存储与程序构建、ABI 兼容性和动态链接相关的信息。

以上是从加载视角来看。

为什么要将section合并成为segment?

(1)操作系统在加载程序时,会将具有相同属性的section合并成一个大的segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。

(2)Section合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占用3个页面,而合并后,它们只需2个页面。

3.3.2 程序头表和节头表

ELF 文件提供2个不同的视图/视角来让我们理解这两个部分。

1.链接视图(Linking View) - 节头表

节头表用于描述 ELF 文件中的各个节(section)。它提供了关于 ELF 文件中各节的详细信息,如节的名称、类型、大小、地址等。它是链接器(Linker)在静态链接过程中使用的。

 - 功能:

        - 链接器使用节头表来处理和合并各个节(例如 .text、.data、.bss)。在链接过程中,链接器将把这些节合并成更大的节,方便后续的程序加载。

        - 这些节通常对应于程序的源代码、已初始化的数据、未初始化的数据等。

        - 链接器在执行链接时会根据程序头表的信息,决定如何将这些节合并成段(segment)以便优化空间利用。

2. 执行视图(Execution View) - 程序头表

程序头表描述了如何加载可执行文件。它告诉操作系统如何将 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指令,它们分别对应之前调用的 print f和 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进行合并,和上述过程一样。所以链接其实就是将编译后的目标文件、静态库通过符号解析与地址重定位,整合为一个可独立执行的文件

符号解析:为程序中所有 “未定义的符号”(如跨文件调用的函数、引用的全局变量)找到其 “定义所在的目标文件”,建立唯一关联。

假设我们有两个目标文件 a.o 和 b.o:
 - a.o 中定义了函数 void foo() { ... }(全局符号,类型为 “定义”)。
 - b.o 中调用了 foo(),但未定义 foo(此时 foo 在 b.o 中是 “未定义符号”,类型为 “引用”)。

链接器在符号解析阶段会:
 - 收集所有目标文件中的符号(包括定义和引用),形成全局符号表
 - 为 b.o 中未定义的 foo 查找对应的定义 —— 最终匹配到 a.o 中的 foo 函数,确定 “b.o 中的 foo 引用指向 a.o 中的 foo 定义”。

地址重定位:当符号的最终地址确定后,修正所有引用该符号的指令或数据,将原来的 “符号占位符” 替换为实际的虚拟地址。

延续上面的场景,符号解析已确定 b.o 中的 foo 引用对应 a.o 中的 foo 定义。此时:
 - 链接器会为合并后的代码段分配虚拟地址(假设 foo 函数的起始地址最终为 0x00401100)。     - b.o 中调用 foo() 的指令,在编译时仅记录了 “调用 foo” 的逻辑(机器码中用一个临时占位值表示目标地址)。
 - 重定位阶段,链接器会根据 a.o 中 foo 的实际地址(0x00401100),修正 b.o 中调用指令的目标地址,使其准确指向 0x00401100。

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 结构体里。

ELF 可执行文件在编译链接阶段已确定各部分的逻辑地址(即虚拟地址的雏形)。操作系统加载程序时,会根据这些逻辑地址,在已分配的虚拟地址空间中完成代码段、数据段等的布局初始化,并从 ELF 文件头部读取入口地址(Entry point address),作为程序开始执行的虚拟地址起点。

此时,虚拟地址空间尚未与物理内存建立关联。物理地址是在程序运行过程中动态分配的,通常通过操作系统的内存管理系统在进程首次访问虚拟地址时,由页表和内存管理单元(MMU)完成虚拟地址到物理地址的映射。操作系统会通过页表将虚拟地址映射到物理地址,并且 MMU 在程序访问虚拟地址时,查找页表项来转换为对应的物理地址,进而访问物理内存。

这种机制使得每个进程拥有独立的虚拟地址空间,避免了进程间的地址冲突,同时操作系统可以在物理内存中灵活地进行虚拟地址到物理地址的映射,提升了内存管理的灵活性与安全性。

CPU从物理内存中得到虚拟地址,通过页表得到物理地址。

 ELF的segment初始化结构体的各区域。

当程序启动时,操作系统会执行以下步骤:

(1)创建 PCB,并为进程分配虚拟地址空间,初始化 mm_struct 结构体,记录了各个区域的虚拟地址范围,但程序的代码和数据还没有真正加载到内存中。

(2)解析可执行文件(main.exe):

        - main.exe 在内核中表示为 struct file 结构。

        - 操作系统依次查找 path → dentry → inode → iblock,定位存储文件内容的数据块,并读取 ELF 头信息,包括Entry入口地址等关键数据。

(3)构建虚拟地址空间布局

        - 操作系统根据 ELF 格式解析可执行文件,确定代码段、数据段、bss 段、堆、栈等各个内存区域,并在 mm_struct 中记录这些虚拟地址空间信息。

        - 此时虚拟地址空间已经建立,但尚未分配物理地址。

(4)按需加载程序代码和数据:

        - 当程序执行到特定代码或访问特定数据时,操作系统才会将这部分内容加载到物理内存,并为其分配实际的物理地址。

        - 在按需加载机制下,操作系统不会一次性将整个 ELF 文件的代码和数据加载到物理内存,而是通过 缺页异常(Page Fault)触发内核分配物理页。

(5)建立页表映射:

        - 操作系统根据虚拟地址到物理地址的对应关系,构建页表,并通过 MMU 进行虚拟地址到物理地址的转换。

        - CPU 访问内存时,MMU 依据页表查找物理地址,实现指令和数据的访问。

(6)执行程序:

        - CPU 从 ELF 头部的 Entry 地址开始执行,读取指令并运行进程。

关键点:

 - 虚拟地址先于物理地址创建,物理地址是在加载代码/数据到内存时分配的。

 - 页表是虚拟地址与物理地址的映射关系,页表的初始化在进程创建时完成,但物理地址的映射会在按需分配时动态更新

 - 可执行文件(main.exe) 在内核中是 struct file 结构,操作系统通过 path → dentry → inode → iblock 解析文件,并最终加载到进程的虚拟地址空间中。

 - 虚拟地址属于进程的虚拟地址空间,并不会直接对应物理内存,而是存储在进程控制块的 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函数中,会执行一系列初始化操作,这些操作包括:

(1)设置堆栈:为程序创建一个初始的堆栈环境。

(2)初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。

(3)动态链接:这是关键的一步,_start函数会调用动态链接器的代码来解析和加载程序所依赖的动态库。

        - 动态链接器:当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中

        - 环境变量和配置文件: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函数来终止程序。

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表。这样一来,当我们再次调用这个函数时,程序就会直接跳转到动态库中该函数真正的实现代码处,而无需再次执行查询和更新操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值