Linux库制作与使用(三):ELF加载与动态链接机制

目录

一、地址的产生与映射

[1. ELF文件的地址属性](#1. ELF文件的地址属性)

[2. 地址空间来源](#2. 地址空间来源)

二、ELF与虚拟地址

[1. 平坦模型](#1. 平坦模型)

[2. ELF 地址本质](#2. ELF 地址本质)

[3. entry 字段](#3. entry 字段)

协作机制小结

三、进程地址空间

[1. exec加载](#1. exec加载)

[2. 地址空间来源](#2. 地址空间来源)

四、程序入口

[1. 程序真正入口](#1. 程序真正入口)

[2. _start 核心作用](#2. _start 核心作用)

五、动态链接

[1. 从预先绑定到即时绑定](#1. 从预先绑定到即时绑定)

[2. 加载与运行:推迟的两个阶段](#2. 加载与运行:推迟的两个阶段)

六、动态库加载

[1. 动态链接器](#1. 动态链接器)

[2. 动态库映射](#2. 动态库映射)

[3. 进程共享](#3. 进程共享)

[七、GOT 与 PLT](#七、GOT 与 PLT)

[1. 为什么不能改代码区](#1. 为什么不能改代码区)

[2. GOT](#2. GOT)

[3. PLT](#3. PLT)

[4. 调用流程](#4. 调用流程)

[八、PIC 位置无关代码](#八、PIC 位置无关代码)

[1. PIC 的核心哲学](#1. PIC 的核心哲学)

[2. 库依赖](#2. 库依赖)

总结


一、地址的产生与映射

在深入探讨 Linux 系统下 ELF 文件的加载与动态链接机制之前,必须首先理清 ELF 的地址属性,以及如何转化为内核结构。本章将重点讨论 ELF 文件的地址定义及其对进程虚拟地址空间初始化的影响


1. ELF文件的地址属性

在 Linux 系统中,ELF 文件确实具有地址属性 ,但这一地址并非指代物理内存中的确定位置,而是 虚拟地址

对于可执行文件而言,其内部的符号与指令拥有明确的虚拟内存地址 。这些地址由链接器在静态链接阶段根据预设的内存布局脚本分配

  • 指令地址:.text 节中的每一条机器指令都关联了一个特定的虚拟地址,用于处理跳转(Jump)与调用(Call)

  • 数据地址:.data 与 .bss 中的全局变量在编译链接后,其在内存中的固定坐标已经确定

这种地址的确定性是程序执行的前提,但在现代操作系统中,这些地址仅仅是逻辑上的约定,真正的物理分配发生在进程启动之后


2. 地址空间来源

当操作系统执行一个 ELF 程序时,它并不会直接将文件内容机械地拷贝进物理内存,而是通过内核中的内存管理机制,为该进程构建一个独立的虚拟地址空间

这一空间的构建逻辑完全参考了 ELF 文件的程序头表

内存管理结构的初始化

在 Linux 内核中,每个进程的虚拟地址空间由 mm_struct 结构体进行描述。当加载器读取 ELF 文件时,会完成以下关键映射:

  1. mm_struct 的建立:内核为新进程创建 mm_struct

  2. vm_area_struct 的分配: 内核会扫描 ELF 的程序头表,针对每一个类型为 LOAD 的 Segment,在进程空间内分配一个对应的虚拟内存区域,即 vm_area_struct

    • 代码段映射 :ELF 中具有只读与执行权限的 Segment,对应内核中标记为

      VM_READ | VM_EXEC

    • 数据段映射:具有读写权限的 Segment,对应标记为 VM_READ | VM_WRITE

延迟加载与请求分页

值得注意的是,此时 VMA 仅仅是在内核中登记了虚拟地址的范围与权限,并未实际分配物理内存。 当 CPU 第一次尝试执行 .text 段中的指令时,会触发缺页异常。内核随后定位到 ELF 文件在磁盘上的偏移位置,将对应的数据页调入物理内存,并建立页表映射

通过这种方式,ELF 文件的静态地址配置转化为内核动态管理的内存区域,完成了从磁盘二进制映像到活跃进程实体的转变

在这种映射机制下,如果一个 ELF 文件内部的虚拟地址与其加载时的物理页面完全解耦,那么在多个进程共享同一个动态库时,内核是如何确保它们互不干扰的?

二、ELF与虚拟地址

1. 平坦模型

在现代计算机体系结构中,内存管理普遍采用平坦模型。在这种模型下,CPU 将内存视为一个连续的、线性的字节序列,而不是离散的片段

为了适配这一机制,编译器和链接器在生成 ELF 文件时,必须对程序内部的所有代码(指令)和数据进行统一编址

  • 统一编址的含义:这意味着每一个函数名、每一个全局变量,在生成的二进制文件中都被分配了一个唯一的、不重叠的数值坐标

  • 编译器的角色:虚拟地址机制不仅需要操作系统的硬件支持,也需要编译器的软件支持。编译器在翻译源码时,就已经根据平坦模型的逻辑,预先为程序规划好了在虚拟内存中的布局


2. ELF 地址本质

ELF 文件在尚未加载到物理内存时,其内部就已经包含了地址信息。这些地址在技术上可以被理解为一种逻辑地址

根据逻辑地址的定义,其公式通常表达为:

在 ELF 文件的上下文中,我们可以这样理解其地址本质:

  • 基地址为 0 :在目标文件(.o)阶段,起始地址被视为 0。此时,反汇编显示的地址实际上就是其相对于文件节开头的偏移量

  • 预编址:可执行程序在链接完成后,其内部指令的虚拟地址就已经固定。这意味着程序在进入内存之前,其在虚拟空间中的位置已经通过编址过程被精确定义

这种预先分配的地址为操作系统提供了加载蓝图,确保了指令间的跳转和数据访问在运行前即具备逻辑上的可行性


3. entry 字段

当操作系统启动一个进程时,它需要将 ELF 文件中的静态编址信息转化为内核的动态管理数据。这一过程主要涉及内核数据结构的初始化

(1) mm_struct 与 vm_area_struct

Linux 内核通过 mm_struct 描述进程的完整虚拟地址空间,通过 vm_area_struct 描述该空间内的具体区域。这些结构的初始化数据直接来源于 ELF 的 Segment

  • 范围界定:内核读取 ELF 的程序头表,获取每个 Segment 的起始地址和长度

  • 结构填充:内核使用这些数据来填充 vm_area_struct 中的 vm_start 和 vm_end 字段,从而定义进程内存布局的各个范围

(2) Entry 字段

在 ELF 头部(ELF Header)中,存在一个关键字段:e_entry**(入口地址)**

  • 功能:该字段记录了程序执行的第一条指令在虚拟内存中的地址

  • 作用机制:当内核完成内存环境的初始化并准备将控制权交给用户态程序时,它会读取 e_entry 的数值,并将其设置到 CPU 的指令指针寄存器中


协作机制小结

ELF 地址机制体现了工具链与操作系统的深度协作:

  1. 编译器与链接器:负责在平坦模型下完成统一编址,生成包含虚拟地址预设的可执行文件

  2. 操作系统加载器:负责读取这些预设地址,初始化内核映射结构(mm_struct 和 vm_area_struct),并建立页表

  3. CPU:最终根据 e_entry 指向的地址开始执行指令,通过页表完成从虚拟地址到物理内存的实时转换

这种设计使得程序在未运行前就拥有了空间蓝图,而运行时的内存布局则是这份蓝图在物理硬件上的真实投射

三、进程地址空间

1. exec加载

在 Linux 系统中,当执行 execve 系列系统调用时,内核会启动可执行文件的加载过程。这一过程并非简单的文件拷贝,而是建立一种从磁盘文件到虚拟地址空间的映射关系

加载器首先会读取 ELF 文件的头部信息,解析出程序头表。此时,内核会根据 ELF 文件中定义的虚拟地址,在进程的地址空间内预留出相应的区域。虽然此时物理内存尚未分配,但进程的虚拟内存框架已经依照 ELF 的描述搭建完成。这意味着,exec 的加载过程本质上是将 ELF 文件的静态地址空间布局转化为进程动态运行时的虚拟地址空间布局


2. 地址空间来源

进程地址空间中每一个具体的虚拟内存区域(VMA)的初始化信息,均直接源自 ELF 文件的 程序头表

Segment 到 VMA 的映射机制

内核通过遍历程序头表中类型为 PT_LOAD 的段,为每一个段创建一个对应的 vm_area_struct 结构体。该结构体是内核管理进程地址空间的核心单位

  • 内核读取 Program Header 中的 virtaddr(段的起始虚拟地址)和 memsiz(段在内存中的长度),并以此填充 vm_area_struct 中的 vm_start 和 vm_end 字段

  • 权限属性同步:Program Header 中的 flag 决定了该 VMA 在内存中的访问权限。内核据此设置 vm_page_prot 字段,确保代码段(.text)为只读执行,而数据段(.data)为可读写

ELF 与磁盘

1. 磁盘:Entry Point Address

观察图片右侧的磁盘区域:

  • Entry point address (0x1060):这是 ELF Header 中记录的程序入口地址。

  • 指令编码:此时程序躺在磁盘上,每一行汇编指令都关联着一个逻辑上的虚拟地址

  • 本质:这时的地址只是蓝图,告诉操作系统:如果我要跑起来,请把我放在这个坐标

2. 内核:task_struct 与 mm_struct

观察图片左侧的两个方块:

  • task_struct:这是进程控制块

  • mm_struct:这是内存描述符,它就是我们上一段提到的核心。它通过读取 ELF 的 Program Header,在内部构建起众多的 VMA

  • 逻辑关系:task_struct 链接到 mm_struct,从而让内核知道:这个进程拥有哪些虚拟内存区域

3. 页表与 CR3

观察图片左下角的黄色格子:

  • 页表 :这是映射表。它负责把 0x1060 这样的虚拟地址 转换成物理内存的真实位置

  • CR3 寄存器:这是 CPU 内部的一个控制寄存器,它指向当前进程页表的基地址

  • 意义:没有页表,CPU 看到的地址就是一串无意义的数字;有了页表,虚拟地址空间才真正落地

4. EIP

观察图片最左下角的 CPU 圆圈:

  • EIP :在 64 位下叫 RIP。它存储着 下一条即将执行的指令地址

  • 联动过程

    1. 内核将 ELF Entry point (0x1060) 的填入 CPU 的 EIP 寄存器

    2. CPU 尝试去 0x1060 拿指令

    3. MMU 通过 CR3 找到页表,发现 0x1060 对应物理内存中的某个位置

    4. 物理内存中的机器码被加载进 CPU 执行

四、程序入口

在 ELF 文件格式中,程序的执行起点并不是开发者所熟知的 main 函数,而是由 _start 符号定义的地址。要验证这一点,我们需要对比 ELF 头部信息与符号表数据


1. 程序真正入口

(1) 获取 ELF 头部入口地址

通过 readelf -h 命令可以读取 ELF 文件的头部,其中包含一个关键字段:Entry point address这个地址记录了操作系统在完成进程环境初始化后,指令指针寄存器应当跳转到的绝对位置

(2) 验证入口点指向 _start

通过符号表查看命令 readelf -s 或反汇编命令 objdump -d,可以找到 _start 符号对应的虚拟地址

结论 :对比可知,ELF 头部记录的 Entry point address(0x1060)与符号表中 _start 符号的地址完全一致。这证明了操作系统内核在执行用户态代码的第一步,就是精确跳转到 _start 所在的位置


2. _start 核心作用

_start 是由 C 运行时库提供的程序入口,通常包含在 crt1.o 等标准库文件中。它是连接操作系统内核与用户业务逻辑(main 函数)的纽带

其核心作用体现在以下三个方面:

(1) 运行环境的初始化

在 main 函数执行之前,程序需要一个准备就绪的运行环境。_start 负责:

  • 栈帧设置:初始化栈指针寄存器。

  • 参数传递:从内核传递的堆栈中提取命令行参数 argc、argv 以及环境变量 envp,并按照 C 语言标准的调用约定将它们存入相应的寄存器或压入栈中

  • 动态链接:这是关键的一步, _start 函数会调用动态链接器的代码来解析和加载程序所依赖的 动态库。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址

(2) 调用 C 库初始化函数

现代 C 程序依赖大量的库函数(如 printf、malloc 等)。在进入 main 之前,_start 实际会调用如 __libc_start_main 这样的内部函数,进而触发:

  • 全局构造函数执行:执行所有标记为 attribute((constructor)) 的函数

  • 线程本地存储初始化:为多线程环境分配和初始化必要的内存空间

(3) 程序的收尾与退出

当 main 函数执行完毕并返回一个整数值(如 return 0)时,控制权并没有直接交还给内核,而是返回到了 _start 调用的后续流程中

  • 清理工作:执行全局析构函数

  • 系统调用退出:显式调用 exit 系统调用。这一步至关重要,它确保了进程能够正常释放资源,并将退出码传递给父进程

五、动态链接

动态链接的核心哲学可以概括为一句话:将符号解析与地址重定位的动作,从编译链接阶段推迟到程序的加载阶段甚至运行阶段


1. 从预先绑定到即时绑定

在传统的静态链接中,链接过程发生在开发者的机器上。链接器在生成可执行文件的那一刻,就已经完成了所有符号的地址计算

而动态链接改变了这一时序:

  • 静态链接(预先绑定):在程序运行前,所有的逻辑依赖已经转化为物理地址。可执行文件不再需要外部干预即可运行

  • 动态链接(推迟绑定) :链接器在构建可执行文件时,故意不完成链接工作。它仅仅在 ELF 文件中记录下程序需要哪些外部符号(如 printf),以及这些符号预期分布在哪些库中。真正的地址绑定工作被交给了操作系统及其动态链接器

这种推迟意味着,可执行程序在磁盘上时,其内部跳转指令的目标地址依然是空的或者是占位符

通过反汇编可以看到,main 函数在调用 puts 时(地址 115b),其跳转的目标并非库函数的真实物理地址,而是一个名为 puts@plt 的本地桩函数(Stub)

这行指令中的操作数 f0 fe ff ff 只是一个指向 PLT 表的相对偏移。这证明了在磁盘阶段,可执行文件并没有被赋予 puts 的真实坐标。它就像一张未兑现的支票,指令里只写了'去 1050 号窗口取钱',而 1050 号窗口里现在只有一段如何联系动态链接器的说明书,并没有真正的函数地址


2. 加载与运行:推迟的两个阶段

本段中提到的加载或运行阶段,分别对应了动态链接的两种细分行为:

加载阶段的推迟

当内核启动进程并建立 mm_struct 映射后,动态链接器会介入。它会扫描程序依赖的列表,找到所需的共享库,并计算出这些库在当前进程虚拟空间中的基地址

  • 实质:在程序的第一条指令执行前,完成大部分全局变量和基础函数的地址修正

运行阶段的推迟

为了进一步优化启动速度,现代动态链接允许将链接过程推迟到指令真正执行的那一刻

  • 实质 :如果程序中有 1000 个库函数,但在本次运行中只用到了 10 个,那么剩余 990 个函数的链接过程将永远被推迟下去,直到它们被显式调用。这种机制被称为延迟绑定

六、动态库加载

在验证了动态链接将地址绑定"推迟"之后,接下来的核心问题是:这些缺失的外部代码(动态库)是如何进入进程空间,并实现多进程共享的?


1. 动态链接器

对于动态链接的 ELF 文件,内核在初始化进程环境后,并不会直接跳转到程序的 _start 处执行。相反,它会先加载一个特殊的共享对象------动态链接器(Dynamic Linker)

  • 在 Linux x86_64 系统上,这通常是 /lib64/ld-linux-x86-64.so.2。我们可以通过

    readelf -l a.out | grep interpreter 看到它的路径

  • 核心职责 :动态链接器本质上是一个为了运行其他程序而存在的程序 。它的任务是读取 ELF 文件的 .dynamic 节,确定程序运行所需的共享库列表(NEEDED 项),并负责搜索、加载这些库

动态链接器的主要工作流程包括以下几个关键步骤:

  1. 解析 ELF 文件结构:

    • 读取 ELF 文件的程序头表
    • 定位 .dynamic 节,这个节包含了动态链接所需的所有关键信息
  2. 处理 .dynamic 节内容:

    • 解析 NEEDED 项,获取程序依赖的所有共享库列表
    • 读取其他重要信息如符号表、重定位表等
  3. 库搜索与加载:

    • 按照预定义的搜索路径(如 /lib、/usr/lib 等)查找所需共享库
    • 递归处理这些库的依赖关系(这些库可能还依赖其他库)
    • 将库映射到进程的地址空间
  4. 符号解析与重定位:

    • 解析所有未定义的符号引用
    • 执行必要的重定位操作
    • 处理延迟绑定相关事项

2. 动态库映射

动态库进入进程地址空间的过程称为映射。这主要依靠内核提供的 mmap 系统调用完成

  • 映射位置:动态库不会被映射到程序代码段或栈底,而是映射在进程地址空间的内存映射区。这个区域通常位于堆与栈之间的大片空闲地址空间内

  • 独立性:每一个动态库在加载时,内核都会为其分配一段连续的虚拟地址范围。这意味着,libc.solibm.so 以及你的主程序,在虚拟地址空间中是彼此独立的模块


3. 进程共享

动态库之所以被称为共享库,是因为它在物理内存中实现了真正的共享。这是动态链接相对于静态链接最显著的优势

(1) 物理内存的一份拷贝

当多个进程(例如进程 A 和进程 B)同时调用 printf 时,内核并不会为每个进程都加载一份 libc.so 的代码。相反,物理内存中只存在一份 libc.so 的代码段拷贝

(2) 虚拟内存的多重映射

通过页表映射机制,内核将同一个物理地址空间的库代码,同时映射到不同进程的虚拟地址空间中

  • 代码段共享:由于代码段在运行时是只读的,所有进程可以安全地共享同一份物理页面

  • 数据段私有:对于库中的全局变量(数据段),内核采用写时拷贝。虽然初始映射可能指向同一物理页,但一旦某个进程尝试修改变量,内核就会为该进程分配私有的物理副本

七、GOT 与 PLT

1. 为什么不能改代码区

在探讨动态链接的底层实现时,我们必须面对一个逻辑冲突。

如果按照传统的加载时重定位逻辑:

  1. 加载阶段:动态链接器确定库函数(如 printf)在当前进程空间中的真实起始虚拟地址

  2. 重定位:链接器找到代码段中所有调用该函数的地方,将原本的占位符修改为真实地址

核心问题在于:

  • 权限冲突 :在 Linux 的内存管理机制中,为了安全,代码段对应的虚拟内存区域(VMA)被设置为只读。动态链接器作为用户态程序,无法直接改写受硬件保护的只读代码区

  • 共享失效:如果动态链接器强行修改代码段,那么这块内存页面就发生了变化。根据写时拷贝机制,内核会为该进程克隆一份物理页面。这样一来,原本希望在多个进程间共享的物理内存就会退化为每个进程私有一份,极大地浪费了内存资源


2. GOT

为了在保持代码段只读且共享 的前提下完成地址修正,动态链接引入了一个关键的设计思想:增加一个间接层

这种设计将程序分为两个部分:

  1. 位置无关代码:位于代码段,保持不变。它不包含任何绝对地址跳转,而是使用相对偏移来访问一个特定的表格

  2. 全局偏移表(GOT) :位于数据段。数据段本身就是可读写的,且每个进程拥有一份独立的副本

GOT 的核心逻辑

  • 地址容器:GOT 本质上是一个指针数组。每一个条目对应一个外部符号(如 puts 或全局变量)

  • 重定位目标:动态链接器不再修改代码段,而是修改数据段中的 GOT 表项。由于数据段本来就是进程私有的,修改 GOT 不会影响其他进程对库代码的共享,也避开了只读权限的限制


3. PLT

虽然 GOT 解决了在哪里修改地址的问题,但代码段仍然需要一种方式来触发这种间接寻址。这便是过程链接表(PLT)的作用

PLT 的执行流程

当你调用 puts@plt 时,实际执行的是代码段中的一段小程序:

  1. 第一步:跳转到 GOT 表中存储的地址。

  2. 第二步(初次调用):如果 GOT 表中尚未填入真实地址,它会跳转回 PLT 的后续指令,引导 CPU 进入动态链接器的符号解析逻辑

  3. 第三步(后续调用):一旦动态链接器将 puts 的真实虚拟地址写入 GOT 槽位,后续所有的 call 都会通过 PLT 直接命中 GOT 中的真实地址,实现瞬间跳转


4. 调用流程

1. 首次调用的符号解析流程

当程序第一次执行到 call puts@plt 指令时,地址尚未确定,系统会经历一个完整的寻找与绑定过程:

  1. 跳转至 PLT 第一条指令 : CPU 执行 main 函数中的 call 1050 <puts@plt>。此时指令流进入 PLT 段

  2. 间接跳转至 GOT : puts@plt 的首条指令是 jmp *GOT_ENTRY(jmp *0x2f76(%rip))。执行时,CPU 会先计算目标地址 3fd0(即 GOT 表中的特定槽位),然后读取该位置存储的实际跳转地址

  3. 返回 PLT 执行压栈 : 在程序第一次运行、还没找到 puts 真实地址时,3fd0 里填的地址是 jmp 指令的下一行指令。因此,CPU 按照该地址的指示跳转至 105a 处,随后程序执行流程自然进入了 .plt 节

  4. 进入动态链接器PLT0 负责跳转到动态链接器的解析函数中。此时,链接器获得两个关键信息:哪个库需要解析,以及具体的函数索引

  5. 回填地址 : 动态链接器在内存中定位 libc.so 的基址,找到 puts 的实际虚拟地址。随后,链接器将该真实地址写入 GOT 表对应的槽位中

  6. 执行目标函数: 完成回填后,链接器顺便执行一次 puts 函数,随后返回主程序。

后续调用的快速跳转流程

一旦首次调用完成,GOT 表中已经存储了真实的函数地址,后续过程将变得极度简化:

  • 执行 call puts@plt:CPU 再次进入 PLT

  • 命中 GOT 真实地址:执行 jmp *GOT_ENTRY。由于 GOT 槽位已被动态链接器修改为 puts 在 libc.so 中的真实 VMA 地址,指令直接跳转至库代码执行

  • 无需重回链接器:后续调用完全跳过了符号解析逻辑,其效率几乎等同于直接调用

八、PIC 位置无关代码

在之前的讨论中,我们已经知道动态链接器会将 puts 的地址填入 GOT 表。但这里有一个终极矛盾:既然动态库可以被加载到内存的任何位置,指令又是如何精准定位到那个可写的 GOT 表的?


1. PIC 的核心哲学

动态库之所以能被所有进程共享,是因为它的代码段在物理内存中只有一份。但问题是,每个进程分配给这个库的起始虚拟地址可能完全不同

  • 矛盾:代码段是只读的,不能因为加载地址变了就去改指令

  • 虽然动态库在内存里的绝对基地址会变,但同一个 .so 文件内部,代码段与 GOT 表之间的相对偏移量在编译链接阶段就已经固定了

无论这个库被分配内存的哪个角落,它的代码段起始点到 GOT 表起始点的距离永远不变。就像你在高速公路上,无论你在哪一段,服务区永远在前进方向的 500 米处

寻址机制

现在的 CPU(特别是 x86_64)支持 RIP 相对寻址。这正是 PIC 实现的技术基石

关于这段反汇编中的指令:

  • 它的含义是去距离当前指令位置偏移 0x2f76 的地方找地址

  • 为什么有效?:因为无论 .so 加载到哪里,指令地址(RIP)和 GOT 地址同步移动,它们的差值始终是 0x2f76

  • 独立性:代码不需要知道自己在内存里的绝对坐标,它只需要通过这个相对偏移找到属于当前进程的 GOT 表

这里有一个关键的细节:代码段是共享的,但 GOT 表是私有的

  1. 代码段(共享):包含了相对寻址指令。所有进程运行的是同一套指令

  2. 数据段/GOT(私有):虽然每个进程通过同样的相对偏移找到 GOT,但它们找到的是自己虚拟空间里的那份 GOT 副本

  3. 结果:进程 A 的 GOT 里填的是 A 进程中 puts 的地址;进程 B 的 GOT 里填的是 B 进程中 puts 的地址。互不干扰,独立存在

我们可以把这种实现动态库随处可用的技术总结为一个公式:

PIC = 相对编址 + GOT 表

  • 相对编址 :解决了代码段如何找到 GOT 表的问题(利用 RIP 相对寻址)

  • GOT 表 :解决了如何找到外部函数真实地址的问题(动态链接器回填)

这就是为什么我们在编译动态库时,必须给编译器指定 -fPIC 参数。如果不加这个参数,编译器可能会生成针对绝对地址的指令,导致这个 .so 文件必须加载到某个特定的地址才能运行,从而失去了共享的灵活性


2. 库依赖

要实现库与库之间的互相调用,最关键的一点是:每一个库文件都拥有自己的一套 GOT 和 PLT

  • 独立性libA.so 内部有一份自己的 .got.plt,libB.so 内部也有一份

  • 互不干扰 :当 libA 编译时,它并不知道自己将来会被映射到哪个地址,也不知道 libB 会在哪。所以,libA 绝不会尝试直接跳转到 libB 的绝对地址,而是跳转到libA 自己内部的 PLT

全局符号表

当动态链接器(ld-linux.so)启动程序时,它不仅会加载主程序,还会沿着依赖链(NEEDED 标签)把所有的 .so 全部加载进内存

  • 链接器会在内存中维护一张全局符号表。这张表记录了当前进程空间内所有已加载库所导出的函数名称及其对应的虚拟地址

  • 如果 libA 和 libB 都定义了同名函数,链接器会根据加载顺序和作用域规则来决定解析哪一个

跨库调用的具体全流程

假设 main 调 libA,libA 再调 libB 中的 funcB:

  1. libA 的代码执行到 call funcB@plt。这个 plt 是 libA 内部的

  2. 查询 libA 的 GOT:通过相对寻址(RIP+Offset),指令进入 libA 自己的 GOT 表

  3. 如果是第一次调用,触发 libA 的延迟绑定逻辑。动态链接器在全局符号表中查找 funcB

  4. 链接器找到 libB 在内存中的真实地址,然后回到 libA 的数据段,把地址填进 libA 的 GOT 表

  5. 下一次 libA 再调 funcB,直接通过自己的 GOT 就跳到了 libB 的代码区

为什么这依然是地址无关的?

这种设计精妙地实现了库与库之间的物理隔离,逻辑连接:

  • 无需相对位置固定:libA 和 libB 在内存中的相对距离是多少完全不重要。它们可能相邻,也可以隔着几 GB 的地址空间

  • 代码段保持:libA 的代码段不需要因为 libB 的加载位置而做任何修改。所有的跨库对接工作都发生在 libA 私有的 GOT 表里

  • 灵活替换:如果你用一个兼容的 libB_v2.so 替换了旧版本,只要函数名不变,动态链接器在启动时就会自动把 libA 的 GOT 指向新库的地址,整个过程不需要重新编译 libA

总结

综上所述,从 ELF 文件中的虚拟地址,到进程创建时 mm_struct 与 vm_area_struct 的构建,再到动态链接器对共享库的加载与 GOT / PLT 的地址修正,我们逐步串联起了程序从磁盘文件到运行进程的完整链路。程序之所以能够被正确执行,本质上依赖于操作系统对地址空间、页表映射以及动态链接机制的统一管理

与此同时,我们也进一步理解了:代码区之所以能够保持只读,是因为动态链接并不会直接修改 .text 段,而是借助 GOT 等可写区域完成运行时地址绑定,从而实现共享库的灵活加载与进程共享

至此,我们已经基本打通了程序如何运行这一主线。而当多个进程同时存在时,一个新的问题也随之出现:彼此独立的进程之间,究竟该如何交换数据、协同工作?

在下一篇中,我们将正式进入进程间通信(IPC)的世界,深入理解管道、共享内存、消息队列等机制背后的实现思想

相关推荐
seabirdssss2 小时前
闲置笔记本改造成 Ubuntu 开发测试服务器
linux·服务器·ubuntu
拾贰_C2 小时前
【OpenAI | Ubuntu | environment | env configuration】Ubuntu 怎么/如何配置环境变量
linux·运维·ubuntu
同聘云2 小时前
阿里云国际站服务器DNS服务器设置成什么?服务器dns怎么填写?
服务器·阿里云·云计算·云小强
小此方2 小时前
Re:Linux系统篇(六)权限篇 · 一:用户切换与进程嵌套&&sudo提权与sudoers设置精讲
linux·运维·服务器
原来是猿2 小时前
Linux线程同步与互斥(五):线程池的全面实现
linux·服务器·开发语言
王九思2 小时前
Ansible 自动化运维基础—模板
运维·自动化·ansible
嵌入式×边缘AI:打怪升级日志2 小时前
从零开始学习 Linux SPI 驱动开发(基于 IMX6ULL + TLC5615 DAC)
linux·驱动开发·学习
feng_you_ying_li2 小时前
linux之进程控制
linux
Mr_pyx2 小时前
CompletableFuture 使用全攻略:从异步编程到异常处理
linux·前端·python