13.【Linux系统编程】从ELF格式深入理解动静态库

目录

  • [1. 目标文件(.o都是ELF格式的)](#1. 目标文件(.o都是ELF格式的))
  • [2. ELF文件](#2. ELF文件)
  • [3. ELF从形成到加载轮廓](#3. ELF从形成到加载轮廓)
    • [3.1 ELF形成可执行](#3.1 ELF形成可执行)
    • [3.2 ELF可执行文件加载](#3.2 ELF可执行文件加载)
      • [3.2.1 Section Header 和 Program Header](#3.2.1 Section Header 和 Program Header)
      • [3.2.2 ELF Header](#3.2.2 ELF Header)
  • [4. 理解链接与加载](#4. 理解链接与加载)
    • [4.1 静态链接](#4.1 静态链接)
      • [4.1.1 查看code.o和hello.o的反汇编和符号表显示找不到函数](#4.1.1 查看code.o和hello.o的反汇编和符号表显示找不到函数)
      • [4.1.2 链接后查看main.exe文件的符号表、section表及反汇编可见地址call地址修改为正确地址](#4.1.2 链接后查看main.exe文件的符号表、section表及反汇编可见地址call地址修改为正确地址)
      • [4.1.3 总结-静态链接过程](#4.1.3 总结-静态链接过程)
    • [4.2 ELF加载与进程地址空间](#4.2 ELF加载与进程地址空间)
      • [4.2.1 虚拟地址/逻辑地址](#4.2.1 虚拟地址/逻辑地址)
      • [4.2.2 重新理解进程虚拟地址空间(重点)-可执行程序加载到内存并执行的流程](#4.2.2 重新理解进程虚拟地址空间(重点)-可执行程序加载到内存并执行的流程)
      • [4.2.3 磁盘、内存、可执行程序中:虚拟地址和物理地址的关系(总结)](#4.2.3 磁盘、内存、可执行程序中:虚拟地址和物理地址的关系(总结))
      • [4.2.4 连接多个.o目标文件形成可执行程序的目的(总结)](#4.2.4 连接多个.o目标文件形成可执行程序的目的(总结))
    • [4.3 动态链接与动态库加载](#4.3 动态链接与动态库加载)
      • [4.3.1 单进程调用动态库](#4.3.1 单进程调用动态库)
      • [4.3.2 多进程调用动态库(进程间如何共享库)](#4.3.2 多进程调用动态库(进程间如何共享库))
      • [4.3.3 动态链接](#4.3.3 动态链接)
        • [4.3.3.1 概要](#4.3.3.1 概要)
        • [4.3.3.2 我们的可执行程序被编译器动了手脚](#4.3.3.2 我们的可执行程序被编译器动了手脚)
        • [4.3.3.3 动态库中的相对地址](#4.3.3.3 动态库中的相对地址)
        • [4.3.3.4 我们的程序,怎么和库具体映射起来的](#4.3.3.4 我们的程序,怎么和库具体映射起来的)
        • [4.3.3.5 我们的程序,怎么进行库函数调用](#4.3.3.5 我们的程序,怎么进行库函数调用)
        • [4.3.3.6 全局偏移量表GOT(global offset table)](#4.3.3.6 全局偏移量表GOT(global offset table))
        • [4.3.3.7 库间依赖(简单说明即可)](#4.3.3.7 库间依赖(简单说明即可))
      • [4.3.4 总结](#4.3.4 总结)

1. 目标文件(.o都是ELF格式的)

  • ELF 本质:是一种跨组件的二进制文件格式标准,全称为 Executable and Linkable Format(可执行与可链接格式)。

  • 核心定位:ELF 是 Linux/Unix 类系统的 "统一文件格式规范",专门用于定义可执行文件、目标文件、共享库(动态库)、静态库等二进制文件的存储结构。

编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们一般都是一键构建非常方便,但一旦遇到错误的时候呢,尤其是链接相关的错误,很多人就束手无策了。在Linux下,我们之前也学习过如何通过gcc编译器来完成这一系列操作。

接下来我们深入探讨一下编译和链接的整个过程,来更好的理解动静态库的使用原理。

先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。

比如:在一个源文件hello.c里简单输出"hello world!",并且调用一个run函数,而这个函数被定义在另一个原文件code.c 中。这里我们就可以调用 gcc -c 来分别编译这两个原文件。

cpp 复制代码
// hello.c
#include<stdio.h>
void run();
int main() {
	printf("hello world!\n");
	run();
	return 0;
}

// code.c
#include<stdio.h>
void run() {
	printf("running...\n");
}
bash 复制代码
// 编译两个源文件
$ gcc -c hello.c code.c
$ ls
code.c code.o hello.c hello.o

可以看到,在编译之后会生成两个扩展名为.o的文件,它们被称作目标文件 。目标文件是一个二进制的文件,文件的格式是ELF ,是对二进制代码的一种封装。

注意:如果我们修改了一个源文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程。

bash 复制代码
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
# file命令用于辨识文件类型。

2. 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):保存已初始化的全局变量和局部静态变量。([在磁盘中]记录未初始化的变量的数量,[在内存中]加载程序时再展开并开辟空间初始化为0)

linux下查size命令看各个段大小(bss未初始化数据段,dec三个段总大小[十进制],hex三个段总大小[十六进制])

bash 复制代码
$ size usercode
   text	   data	    bss	    dec	    hex		filename
   1502	    572	      4	   2078	    81e		usercode

3. ELF从形成到加载轮廓

3.1 ELF形成可执行

  • step-1:将多份C/C++ 源代码,翻译成为目标.o 文件 + 动静态库(动态库.so是ELF格式,静态库.a是归档格式,但其内部封装的是ELF格式的.o文件)

  • step-2:将多份.o 文件的节(section)进行合并

注意:实际合并是在链接时进行的,但是并不是这么简单的合并,也会涉及对库合并,此处不做过多追究。

3.2 ELF可执行文件加载

  • 一个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成segment(段)

  • 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等.

  • 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到一起

  • 很显然,这个合并工作也已经在形成ELF 的时候,合并方式已经确定了,具体合并原则被记录在了ELF程序头表(Program header table)

查看可执行程序的section命令:

bash 复制代码
readelf  -选项  xxx.o/xxx.out
选项 功能
-h 查看ELF 文件的 "文件头[ELF头](ELF Header)" 信息
-l 查看section合并的segment,即 " 程序头表(Program Header)"(右图)
-S 查看可执行程序的section,即 " 节头表(Section Header)"(左图)
-s 查看 "符号表(Symbol Table)"(存储文件中的函数、变量等符号信息)。

3.2.1 Section Header 和 Program Header

左图为main.exesection(Section Header),右图为section合并的segment(Program Header)

图中Key to Flag标志的键值说明Section to Segment mapping节到段的映射

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

  • Section合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占用3个页面,而合并后,它们只需2个页面。
  • 此外,操作系统在加载程序时,会将具有相同属性的section合并成一个大的segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。
  • 关于4KB,在"写时拷贝"、"malloc"、"new"等内存使用时,都是以4KB为单位(哪怕只申请1Byte,操作系统给的都是4KB)。即磁盘和内存交互的单位是4KB。通过牺牲内存,提高访问时间的效率。可执行程序也是文件,也是以4KB为单位保存的。(回头再看第一条)

对于程序头表节头表又有什么用呢,其实 ELF 文件提供 2 个不同的视图/视角来让我们理解这两个部分:

  • 链接视图(Linking view) - 对应节头表Section header table(显示section)

    • 文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图,能够理解 ELF 文件中包含的各个部分的信息。
    • 为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整成可执行的段(segment)、可读写的段、只读段等。合并了后,空间利用率就高了,否则,很小的很小的一段,未来物理内存页浪费太大(物理内存页分配一般都是整数倍一块给你,比如4kB),所以,链接器趁着链接就把小块们都合并了。
  • 执行视图(execution view) - 对应程序头表Program header table(合并section)

    • 告诉操作系统,如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中,一定有program header table

说白了就是:一个在链接时作用,一个在运行加载时作用。

链接视图 来看:命令readelf -S hello.o 可以帮助查看ELF文件的 节头表。

Section 功能
.text节 是保存了程序代码指令的代码节。
.data节 保存了初始化的全局变量和局部静态变量等数据。
.rodata节 保存了只读的数据,如一行C语言代码中的字符串。由于.rodata节是只读的,所以只能存在于一个可执行文件的只读段中。因此,只能是在text段(不是data段)中找到.rodata节。
.BSS节 为未初始化的全局变量和局部静态变量预留位置
.symtab节 Symbol Table 符号表,就是源码里面那些函数名、变量名和代码的对应关系。 字符串表,例如:char lable[] = "helloworld\0func\0libc\0obj\0...",而symtab只需要记住各个函数名、变量名等的起始偏移量即可。
.got.plt节 (全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节一起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改。对于GOT的理解,我们后面会说。(使用 readelf 命令查看 .so 文件可以看到该节。)

执行视图来看:

  • 告诉操作系统哪些模块可以被加载进内存。
  • 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的。

3.2.2 ELF Header

我们可以在ELF头中找到文件的基本信息,以及可以看到ELF头是如何定位程序头表节头表的。例如我们查看下hello.o这个可重定位文件的主要信息:

bash 复制代码
# 查看目标文件hello.o的ELF头
readelf -h hello.o
bash 复制代码
# 查看可执行程序main.exe的ELF头
$ gcc -o main.exe hello.o code.o
$ readelf -h main.exe

对于ELF HEADER 这部分来说,我们只用知道其作用即可,它的主要目的是定位文件的其他部分。对ELF中几个内容的理解:

  • 魔术:通过Magic判断文件的格式。此处功能:系统判断要加载的文件是不是ELF格式。

  • Entry point address:可执行程序的入口虚拟地址。

  • 其他:size of this headers、size of program headers、Number of program headers等等图中有解释。

总结-ELF格式文件的宏观理解:包括四部分,分别是:ELF Header、Program Header Table、Section Header Table、Section。

📌 注意:课堂上一定要让同学们理解,每个ELF区域和文件偏移量之间的关系。(内容理解的第三条,其他部分为此关系)

4. 理解链接与加载

objdump命令:objdump -选项 xxx.o/xxx.so

分类 选项 功能
反汇编 -d 将可执行段输出汇编指令

4.1 静态链接

  • 无论是自己的.o , 还是静态库中的.o ,本质都是把.o文件进行连接的过程

  • 所以:研究静态链接,本质就是研究.o 是如何链接的

4.1.1 查看code.o和hello.o的反汇编和符号表显示找不到函数

​ 查看code.c和hello.c文件,及其反汇编和符号表如下:

我们可以看到反汇编(中间图片)的call指令,它们分别对应之前调用的printfrun函数,但是你会发现他们的跳转地址都被设成了0。那这是为什么呢?

其实就是在编译hello.c 的时候,编译器是完全不知道printfrun函数的存在的,比如他们位于内存的哪个区块,代码长什么样都是不知道的。因此,编译器只能将这两个函数的跳转地址先暂时设为0

**这个地址会在哪个时候被修正?链接的时候!**为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在一个重定位表,这张表将来在链接的时候,就会根据表里记录的地址将其修正。

📌 注意:printf涉及到动态库,这里暂不做说明

4.1.2 链接后查看main.exe文件的符号表、section表及反汇编可见地址call地址修改为正确地址

bash 复制代码
$ gcc -o main.exe code.o hello.o
$ readelf -s main.exe	#(符号表)
$ readelf -S main.exe	#(节头表Section Header Table)
$ objdump -d main.exe > main.s	#(反汇编),打印到main.s文件中

此处关注的是静态链接,而run是静态链接的,printf是动态链接的,所以此处只看run函数

4.1.3 总结-静态链接过程

静态链接就是:1. 把库中的.o进行合并(且合并前需先做 "符号解析");2. 修改函数调用call、全局变量、静态变量的所有地址引用(均属于"重定位")。(和上述过程一样)

解释:所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。

问:.o为什么叫做可重定位目标文件?

答:链接过程中会涉及到对.o中外部符号进行地址重定位。(链接时地址重定位)

4.2 ELF加载与进程地址空间

4.2.1 虚拟地址/逻辑地址

问题:

  • 一个ELF可执行程序,在没有被加载到内存的时候,有没有地址呢?
  • 进程mm_structvm_area_struct在进程刚刚创建的时候,初始化数据从里来的?

答案:

  • 一个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机工作的时候,都采用**"平坦模式"**进行工作。所以也要求ELF对自己的代码和数据进行统一编址,下面是objdump -S main.exe 反汇编之后的代码

    • 最左侧的就是ELF的虚拟地址 ,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量)但是我们认为起始地址是0 ,即将.text、.data等的起始地址都认为是0,从而进行统一编址,从而形成了线性地址,也就是虚拟地址
    • 所以,其实虚拟地址在我们的程序还没有加载到内存的时候,即存放在磁盘上的可执行程序,在链接阶段就已经确定了自身的虚拟地址(即完成对代码和数据的统一编址),仅动态依赖的外部共享库符号地址,需等到运行时由动态链接器绑定。
  • 进程mm_structvm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?从ELF各个segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的[start, end]等范围数据,另外在用详细地址,填充页表。所以:虚拟地址机制,不光光OS要支持,编译器也要支持.

补:汇编文件的后缀用成了.c,因此代码高亮有问题,正确应该是.s

4.2.2 重新理解进程虚拟地址空间(重点)-可执行程序加载到内存并执行的流程

ELF 在被编译好之后,会把自己未来程序的入口地址记录在ELF header的Entry字段中:

一张图说清楚可执行程序加载到内存中的流程 - 素材1:

  • 可执行程序在链接阶段已确定 .text 等段的虚拟地址范围(非 PIE 为绝对虚拟地址,PIE 为相对偏移),且程序入口虚拟地址(ELF 头 e_entry)也在此阶段固化;当程序被加载时,内核先创建进程 PCB(task_struct),其包含的 mm_struct 会通过独立的 vm_area_struct(VMA)记录 .text 等段的虚拟地址 [start, end] 及权限;随后 OS 建立页表(虚拟地址→物理地址映射),并采用按需分页机制分配物理内存;最终页表与进程绑定,mm_structVMA 供 OS 管理进程内存,进程被 CPU 调度时,内核通过上下文切换将入口地址加载到 EIP/RIP,程序正式启动执行。

进入到CPU中的地址全部都是虚拟地址。CPU执行代码的地址和磁盘上的地址是一摸一样的,即CPU 仅关注虚拟地址,虚拟地址到物理地址的转换由 MMU 通过页表自动完成,CPU 不直接处理物理地址。

📌 CPU怎么知道从哪里开始执行程序呢?即你的可执行程序的起始地址是什么?

(ELF Header中)Entry point address:可执行程序的入口虚拟地址。(回看3.2.2)

4.2.3 磁盘、内存、可执行程序中:虚拟地址和物理地址的关系(总结)

在目标文件链接成为可执行程序时,可执行程序就已经确定了虚拟地址

  • 磁盘上:虚拟地址是 "程序运行的地址约定",与磁盘物理地址无关;
  • 内存中:虚拟地址通过页表映射到 "实际的内存物理地址",CPU 按虚拟地址间接访问物理内存。

4.2.4 连接多个.o目标文件形成可执行程序的目的(总结)

1.第一步:解决符号依赖(符号解析)------ 链接的 "前提基础"

多个 .o 目标文件是分散编译的,彼此之间存在 "未定义符号" 的依赖(比如 main.o 调用了 func.o 中的 func(),引用了 global.o 中的全局变量 g_var)。

2.第二步:合并分散的代码和数据(节合并)------ 结构上的 "整合"

每个 .o 目标文件都有独立的 .text(代码)、.data(已初始化数据)、.bss(未初始化数据)等节。链接器会将所有 .o同名节合并,形成可执行文件的统一段布局:

3.第三步:统一编址(重定位)------ 链接的 "核心动作"

这就是你提到的 "统一编址",也是链接最核心的步骤。目的是:给合并后的所有代码、数据分配唯一的虚拟地址,并修正所有 "未确定的地址引用"(函数调用、变量访问)。

4.第四步:生成符合 OS 标准的可执行格式 ------ 最终 "交付物" 要求

链接器最终会将合并后的节、分配的虚拟地址、符号表(可选)等信息,打包成操作系统可识别的可执行文件格式(如 Linux 的 ELF、Windows 的 PE)。

4.3 动态链接与动态库加载

4.3.1 单进程调用动态库

库函数调用:

  1. 被进程看到:动态库映射到进程的地址空间(共享区)
  2. 被进程调用:在进程的地址空间中进行跳转(代码区跳转到共享区完成库函数调用再跳回代码区继续执行)

4.3.2 多进程调用动态库(进程间如何共享库)

动态库映射到进程地址空间确实不是复制代码 / 数据到进程空间 ,核心是「物理内存共享 + 进程虚拟地址空间 "预留 + 映射"」------ 所谓 "映射",本质是给动态库在进程的虚拟地址空间(共享区)分配一段虚拟地址范围,再通过页表将这段虚拟地址与物理内存中的动态库代码 / 数据建立关联,而非复制整个动态库。

因此多进程调用动态库根据各自虚拟地址映射到相同的物理地址即可,因为物理地址相同,所以可以得到:动态库只加载了一份,所以动态库也叫做共享库!

4.3.3 动态链接

4.3.3.1 概要

动态链接其实远比静态链接要常用得多。比如我们查看下hello这个可执行程序依赖的动态库,会发现它就用到了一个c动态链接库:

bash 复制代码
ldd命令用于打印程序或者库文件所依赖的共享库列表。
bash 复制代码
$ ldd main.exe
	linux-vdso.so.1 => (0x00007ffefd43f000)
	libc.so.6 => /lib64/libc.so.6 (0x00007f533380b000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f5333bd9000)

这里的 libc.so 是C语言的运行时库,里面提供了常用的标准输入输出文件字符串处理等等这些功能。

那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。照理来说应该更加方便才对是吧?

静态链接最大的问题:在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。

这个时候,动态链接的优势就体现出来了:我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。

动态链接到底是如何工作的?? :首先要交代一个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。

4.3.3.2 我们的可执行程序被编译器动了手脚
bash 复制代码
$ ldd /usr/bin/ls
    linux-vdso.so.1 (0x00007fffdd85f000)
    libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1(0x00007f42c025a000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f42c0068000)
    libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0(0x00007f42bffd7000)
    libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f42bffd1000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f42c02b6000)  # 动态链接器
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0(0x00007f42bffae000)
$ ldd main.exe
    linux-vdso.so.1 (0x00007fff231d6000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f197ec3b000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f197ee3e000)  # 动态链接器

在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的缓存文件。
  • 该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。
  1. 调用 __libc_start_main :一旦动态链接完成, _start 函数会调用__libc_start_main (这是glibc提供的一个函数)。__libc_start_main函数负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。
  2. 调用 main 函数:最后, __libc_start_main 函数会调用程序的main函数,此时程序的执行控制权才正式交给用户编写的代码。
  3. 处理main 函数的返回值:当main 函数返回时, __libc_start_main 会负责处理这个返回
    值,并最终调用 _exit 函数来终止程序。

上述过程描述了C/C++程序在main 函数之前执行的一系列操作,但这些操作对于大多数程序员来说是透明的。程序员通常只需要关注main 函数中的代码,而不需要关心底层的初始化过程。然而,了解这些底层细节有助于更好地理解程序的执行流程和调试问题。

4.3.3.3 动态库中的相对地址

动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法,统一编址,采用相对编址的方案进行编制的(其实可执行程序也一样,都要遵守平坦模式,只不过exe是直接加载的)。

动态库也是ELF格式的文件,我们也理解为 起始地址(0)+偏移量

bash 复制代码
# ubuntu下查看任意一个库的反汇编
objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less
ss
# Cetnos下查看任意一个库的反汇编
$ objdump -S /lib64/libc-2.17.so | less
4.3.3.4 我们的程序,怎么和库具体映射起来的

📌 注意:

  • 动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的
  • 让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中

tast_struct→mm_struct→vm_area_struct→struct file→struct path→struct dentry→struct ext2_inode找到磁盘文件中的数据块→加载库(待理解)

4.3.3.5 我们的程序,怎么进行库函数调用

📌 注意:

  • 库已经被我们映射到了当前进程的地址空间中
  • 库的虚拟起始地址我们也已经知道了
  • 库中每一个方法的偏移量地址我们也知道
  • 所以:访问库中任意方法,只需要知道库的 起始虚拟地址+方法偏移量 即可定位库中的方法
  • 而且:整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的.
4.3.3.6 全局偏移量表GOT(global offset table)

📌 注意:

  • 也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道
  • 然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置(这个叫做加载地址重定位)
  • 等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?代码区不能修改!!

所以:动态链接采用的做法是在.data(可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。

  • 因为.data区域是可读写的,所以可以支持动态进行修改
bash 复制代码
$ readelf -S main.exe
...
    [24] .got 				   PROGBITS    	  0000000000003fb8 00002fb8
   	 	 0000000000000048 0000000000000008 			WA 	0 	  0   8
...
$ readelf -l main.exe # .got在加载的时候,会和.data合并成为一个segment,然后加载在一起
...
	05 .init_array .fini_array .dynamic .got .data .bss
...
  1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
  2. 在单个.so下,由于GOT表与.text的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表。
  3. 在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
  4. 这种方式实现的动态链接就被叫做PIC 地址无关代码。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定**-fPIC**参数的原因,PIC=相对编址+GOT
bash 复制代码
$ objdump -S main.exe
...
    0000000000001050 <puts@plt>:
    1050: f3 0f 1e fa endbr64
    1054: f2 ff 25 75 2f 00 00 bnd jmpq *0x2f75(%rip) #3fd0 <puts@GLIBC_2.2.5>
...

...
0000000000001149 <main>:
    1149: f3 0f 1e fa endbr64
    114d: 55 push %rbp
    114e: 48 89 e5 mov %rsp,%rbp
    1151: 48 8d 3d ac 0e 00 00 lea 0xeac(%rip),%rdi
    # 2004 <_IO_stdin_used+0x4>
    1158: e8 f3 fe ff ff callq 1050 <puts@plt>
...

📌 备注:PLT是什么?

4.3.3.7 库间依赖(简单说明即可)

注意:

  • 不仅仅有可执行程序调用库

  • 库也会调用其他库!!库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的呢??

  • 库中也有.GOT,和可执行一样!这也就是为什么大家为什么都是ELF的格式!

由于GOT表中的映射地址会在运行时去修改,我们可以通过gdb调试去观察GOT表的地址变化。在这里我们只用知道原理即可,有兴趣的同学可以参考:使用gdb调试GOT

  • 由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,我们的操作系统还做了一些其他的优化,比如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。

思路是:GOT中的跳转地址默认会指向一段辅助代码,它也被叫做桩代码/stup。在我们第一次调用函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调用函数的时候,就会直接跳转到动态库中真正的函数实现。

总而言之,动态链接实际上将链接的整个过程,比如符号查询、地址的重定位从编译时推迟到了程序的运行时,它虽然牺牲了一定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利用磁盘空间和内存资源,以极大方便了代码的更新和维护,更关键的是,它实现了二进制级别的代码复用。

📌 解析依赖关系的时候,就是加载并完善互相之间的GOT表的过程.

4.3.4 总结

  • 静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件。
  • 我们知道静态链接会将编译产生的所有目标文件,和用到的各种库合并成一个独立的可执行文件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
  • 而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT方式进行调用(运行重定位,也叫做动态地址重定位)。
相关推荐
长沙红胖子Qt2 小时前
QGIS开发笔记(五):qgis加载标记点功能,基础标记数量与性能对比测试
c++
用户8356290780512 小时前
C# 自动化生成 PowerPoint 演示文稿
后端·c#
花生Peadar2 小时前
AI编程从入门到精通
前端·后端·代码规范
Java水解2 小时前
【Go】:Sentinel 动态数据源配置指南
后端
zyfts2 小时前
十分钟搞定Nestjs上传文件到阿里云OSS
后端·node.js
aiopencode2 小时前
怎么在 Windows 上架 iOS App?跨平台开发者完整实战流程解析
后端
一名机电研究生2 小时前
华为、阿里巴巴、字节跳动 100+ Linux面试问题总结(一)
linux·华为·面试
喵个咪2 小时前
Kratos 下使用 Protobuf FieldMask 完全指南
后端·go
讨厌下雨的天空2 小时前
环境变量与地址
linux