背景
之前了解了静态链接的原理,就想着把动态链接的原理也学习一下,提高编程能力。
关键知识点
动态链接的工作原理:
- 编译时的处理:
当程序被编译时,编译器知道程序需要某些库函数,但并不把这些函数的代码直接编译进程序。相反,它会留下一个占位符或引用。 - 动态链接库(DLL):
所需的函数被编译在独立的文件中,通常被称为动态链接库(在Windows上是**.dll
文件,在Unix-like系统上是.so
**文件)。 - 运行时的链接:
当程序运行时,动态链接器(运行时系统的一部分)负责查找这些DLL或SO文件,并将它们加载到内存中。然后,程序中对库函数的调用会被指向这些已加载的库函数。
优点和缺点:
- 优点:
- 节省内存:多个程序可以共享同一份库的副本。
- 更新方便:更新库时,不需要重新编译使用该库的程序。
- 缺点:
- 运行时依赖:程序运行时需要确保所需的库可用。
- 启动时间可能较长:因为加载和链接库需要额外时间。
示例:
假设你有一个C++程序,使用了标准库函数**std::cout
**来输出文本。
cppCopy code
#include <iostream>int main() {std::cout << "Hello, world!" << std::endl;return 0;
}
当你编译这个程序时,编译器知道**std::cout
来自C++标准库。它不会把整个标准库编译进你的程序。相反,它会留下一个引用,程序运行时,操作系统的动态链接器会加载C++标准库**(如果尚未加载),并确保你的程序可以使用**std::cout
**。
这样,如果标准库更新,你的程序不需要重新编译,就可以利用新版本的库。同时,如果系统上有多个程序都使用C++标准库,它们都可以共享同一个库的副本,而不是每个程序都包含一个副本,从而节省了大量内存。
实现步骤
多个进程共享一个动态链接库(DLL或SO文件)的功能主要通过操作系统的内存管理和动态链接器来实现。
内存共享机制:
-
加载库时的处理:
当第一个进程启动并请求加载某个动态链接库时,操作系统的动态链接器会把这个库加载到物理内存中。如果库是首次被加载,它会被放置在内存的一个特定区域。 -
内存映射:
操作系统会使用内存映射(memory mapping)技术将库映射到进程的地址空间(虚拟地址)。这意味着虽然物理内存中只有一个库的副本,但在每个使用该库的进程的虚拟地址空间中,这个库都有一个入口点。这里解释一下内存映射和库的入口点:
内存映射:这是操作系统的机制,用于将物理内存中的共享库映射到每个进程的虚拟地址空间中。这确保了物理内存中只有一个共享库的副本,但这个库可以被多个进程访问。
库的入口点:这是内存映射后,在每个进程的虚拟地址空间中创建的对应区域。每个进程通过它自己的虚拟地址空间中的这个入口点来访问共享库。尽管所有进程都映射到物理内存中同一库的位置,但由于虚拟内存的使用,每个进程看到的库地址可能不同。
例如,假设有一个共享库在物理内存中的地址是**0x7000
。在进程A中,这个库可能映射到虚拟地址0x4000
,而在进程B中,它可能映射到另一个虚拟地址0x5000
。这些虚拟地址(0x4000
和0x5000
**)就是各自进程中的“库的入口点”。
总结来说,内存映射使得多个进程可以在它们各自的虚拟地址空间中访问同一个物理内存中的库,这样既保持了地址空间的隔离,又实现了内存的有效利用。
- 后续进程的加载:
当其他进程也需要这个库时,操作系统会检测到这个库已经在内存中了。它不会重新加载同一库的另一个副本,而是将已经加载的库映射到这些后续进程的地址空间(虚拟地址)。 - 独立的地址空间:
虽然每个进程有自己的虚拟地址空间,但由于操作系统的内存管理,它们可以共享同一物理内存中的库。这样,每个进程都可以独立地使用库,就好像它们各自有一个库副本一样。
共享的内容:
- 只有库的代码(只读部分)通常是被共享的。这包括函数和方法的实现。
- 对于库中的全局变量或数据段(可写部分),情况稍微复杂一些。通常,每个进程会有自己的数据副本,或者库需要特别设计来支持跨进程数据共享。
优势:
这种方式节省了大量的内存资源,因为不需要为每个进程单独加载库的副本。此外,更新和维护变得更加简单,因为只需要替换内存中的一个库副本即可。
实现方案
动态链接实现的难点
动态链接的核心挑战之一是在编译时无法知道最终的内存地址。
当编译一个程序或库时,编译器并不知道这些代码最终会被加载到内存的哪个位置。
这是因为共享库可以被多个程序共享,并且每个程序可能被加载到不同的地址空间。
内存映射解决共享方式
动态链接的时候,物理内存中只有一份库文件,其他的程序都共享该库。实现的方式就是内存映射。通过内存映射可以让每个程序访问到同一个库文件。
加载时确定地址
这个是为了解决什么问题? — 与地址无关。
内存地址不确定问题
由于是动态链接,所以只有在加载程序时,才会知道库文件的地址。
因为每个程序内部的地址分配都是不同的,所以不可能提前预设库文件的地址,所以代码中如果要引用库文件中的变量和函数,则固定不下来,但是代码段又是可读的,考虑到数据段是可读可写的,所以我们在数据段中增加了GOT,PLT,让GOT,PLT中的数据指向库中的函数和全局以及静态变量。而代码中直接使用GOT.PLT中的变量即可,这些变量的地址是确定的,因为相对于代码段的偏移是固定的。
总结下来就是对于程序中访问库中的函数和全局以及静态变量,增加了一层(GOT,PLT),来实现程序代码对于库地址不确定的依赖问题。
地址有关性/无关性
编译的时候如果有指定地址,相当于是指定了加载的时候,在虚拟内存中的地址,如果没有加载到指定的地址,就会出问题。对于引用了这些函数或者变量的代码,实际得到的都不是这些函数或者变量。
地址的无关性,则是编译的时候没有指定地址,可以加载到任何地址都是可行的。
库的编译地址问题
库在编译时,如果是指定了地址的,会存在问题。因为加载到不同的程序中时,不确定库的地址是否在程序进程中是可用的,有可能库的地址与程序本身的地址有冲突,导致库加载不成功。
那么需要将库编译成地址无关的,这样就可以将库加载到程序进程中的任何地址。解决了多程序共享同一个库的地址冲突问题。
编译的时候确定地址?不是静态链接的时候确定地址?
动态链接库编译的时候是选择地址无关编译的。使用动态链接库的.o文件在自己编译的时候,会标记
全局偏移表(Global Offset Table, GOT)
在动态链接的环境下,程序中的某些变量和函数的实际地址直到程序运行时才被确定。为了管理这些地址,使用了全局偏移表(GOT)。GOT与过程链接表(Procedure Linkage Table, PLT)一起工作,用于解决动态链接库中函数和变量的地址。
- 作用:
GOT用于存储那些在运行时才能确定地址的全局变量和函数的地址。编译时,这些地址不是硬编码在程序中,而是以在GOT中的偏移形式存在。 - 运行时填充:
当程序启动时,动态链接器负责填充GOT,为每个条目指定正确的运行时地址。 - 访问机制:
程序中对这些全局变量和函数的访问会通过GOT进行间接引用。这意味着程序会先查看GOT中的相应条目,然后跳转到那里记录的实际地址。
GOT的编译时创建
- 编译时:在编译时,编译器创建GOT,并为每个需要动态链接的全局变量或函数分配一个条目。这些条目在编译时还未填充具体的地址,仅是占位符。
- 条目:GOT中的每个条目对应一个全局变量或函数的引用,例如,库中的函数或全局变量。
GOT的运行时填充
-
程序启动:当程序开始执行时,动态链接器(如Linux中的**
ld-linux.so
**)介入工作。 -
加载共享库:动态链接器加载程序需要的共享库(如**
.so
**文件)到内存中。 -
解析地址:对于GOT中的每个条目,动态链接器查询共享库,找出相应的全局变量或函数在内存中的实际地址。
-
填充GOT:动态链接器将这些解析得到的地址填充到GOT的相应条目中。这意味着,GOT中原来的占位符被替换为正确的地址。
链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确。由于GOT本身是放在数据段的,所以它可以在**模块装载时被修改**,并且每个进程都可以有**独立的副本**,相互不受影响。
-
访问符号:当程序运行并访问这些全局变量或函数时,它通过GOT进行间接访问。程序使用GOT中的地址来定位数据或调用函数。
地址无关性
GOT为什么要做到指令地址的无关性?
GOT如何做到指令的地址无关性?模块在编译时可以确定模块内部变量相对于当前指令的偏移,那么也可以在编译时确定GOT相对于当前指令的偏移,然后根据变量地址在GOT中的偏移就可以得到变量的地址。GOT中每个地址对应于哪个变量是由编译器决定的。
过程链接表(Procedure Linkage Table, PLT)
与GOT相似,PLT用于动态链接过程中的函数调用。当程序调用一个库函数时,这个调用实际上是定向到PLT的一个条目。PLT中的条目再将控制转移到函数的实际地址。
- 作用:
PLT允许程序在运行时进行函数调用,而这些函数的地址可能直到运行时才被确定(如动态链接库中的函数)。 - 延迟绑定:
当程序第一次调用一个库函数时,控制跳转到PLT中的一个条目。如果这是对该函数的第一次调用,PLT条目会与动态链接器通信,以确定函数的实际地址,然后更新PLT,使得后续的调用可以直接跳转到正确的地址。 - 实现:
在编译时,函数调用被编译为跳转到PLT中的相应条目。PLT条目包含必要的指令来处理动态链接并跳转到目标函数。
具体例子
假设有一个函数**foo()
在动态链接库中。程序中对foo()
的调用不会直接跳转到foo()
的地址,而是跳转到PLT中foo()
的条目。这个PLT条目然后负责找到foo()
**的实际地址(可能涉及与动态链接器通信),并跳转到那里。
结论
通过GOT和PLT,程序可以在运行时动态地解析和链接到动态链接库中的符号,即使这些库可能在程序编译时不可用或未知。这种机制增加了程序的灵活性和模块化,允许库的共享和更新,同时减少了程序的总体大小。
GOT和PLT的区别
GOT(全局偏移表)和PLT(过程链接表)虽然在动态链接中都扮演着重要的角色,但它们的功能和使用场景有所不同。它们不是两种不同的动态链接方案,而是在同一动态链接过程中共同工作,各自负责不同的部分。
GOT 的作用:
GOT 主要用于存储全局变量和静态变量的地址。当程序需要访问动态链接库中的全局变量时,它会通过GOT中的条目来间接访问这些变量。这是因为直到运行时,这些变量的实际地址才被确定。
PLT 的作用:
PLT 专门用于管理动态链接库中函数的调用。当程序调用一个库函数时,调用实际上是先定向到PLT中的对应条目,然后由PLT条目跳转到函数的实际地址。这种机制支持所谓的“懒绑定”(lazy binding),即函数地址的解析和绑定可以推迟到首次调用该函数时进行。
GOT 和 PLT 的协同作用:
- 运行时绑定:在程序运行时,动态链接器使用GOT和PLT来解析和链接程序中对动态链接库中变量和函数的引用。
- 延迟绑定:对于函数调用,PLT 允许延迟绑定过程,从而提高程序启动的效率。只有在首次调用函数时,链接器才会确定并设置PLT中的正确地址。
- 地址间接:对于全局变量的访问,GOT 提供了一个间接层,使得程序可以在运行时确定这些变量的实际地址。
总之,GOT 和 PLT 是动态链接机制中协同工作的两个重要部分。它们通过使程序能够在运行时动态地解析外部库中的符号(即变量和函数),来实现动态链接和共享库的功能。
动态链接器
动态链接器是操作系统中的一个关键组件,负责在程序运行时(而不是在编译时)解析并加载动态链接库(DLLs在Windows上,SO文件在Unix-like系统上)。
动态链接器的主要功能:
- 解析依赖:
当程序启动时,动态链接器检查程序需要哪些动态链接库,并确定这些库的位置。这包括解析库的依赖关系,因为一个库可能依赖于另一个库。 - 加载库:
动态链接器将必要的库加载到内存中。如果库已经被加载(比如被其他程序使用),链接器会使用现有的内存副本,而不是重新加载。 - 符号解析:
在库被加载后,链接器解析程序中的符号引用(例如,函数和变量的名称)。对于每个引用,链接器查找它在库中的实际地址,并更新程序中的引用,使其指向正确的位置。 - 重定位:
如果库没有被加载到它的首选地址,链接器还会进行重定位,调整库代码和数据中的地址引用,使之适应实际的加载地址。
动态链接器的类型:
- 运行时动态链接器:
这是最常见的类型,它在程序运行时动态地加载和链接库。在Linux中,这通常是**ld-linux.so
**;在Windows中,这是操作系统的一部分。 - 链接时动态链接器:
这种类型的链接器在程序被构建时,创建所需的链接。它不是运行时的一部分,而是开发过程中的一部分。在Linux中,GNU链接器**ld
**就是这样的工具。
为什么需要GOT来实现动态链接
- 动态链接的挑战:
动态链接的核心挑战之一是在编译时无法知道最终的内存地址。当编译一个程序或库时,编译器并不知道这些代码最终会被加载到内存的哪个位置。这是因为共享库可以被多个程序共享,并且每个程序可能被加载到不同的地址空间。 - 地址无关代码(Position-Independent Code, PIC):
为了解决这个问题,共享库通常被编译为地址无关代码。这意味着库中的代码可以在内存的任何位置运行,而不需要修改。 - GOT的作用:
GOT允许程序在运行时动态地定位到其所需的全局变量和函数。程序中的代码不直接包含这些变量和函数的实际内存地址,而是包含它们在GOT中的偏移量。这样,无论程序被加载到内存的哪个位置,它都可以通过GOT找到正确的地址。
GOT的地址无关性
- 意义:
GOT的地址无关性是指GOT机制允许程序和库在内存中的任何位置运行,而无需修改其代码。这是因为GOT中的条目在程序运行时被动态链接器填充,而不是在编译时静态确定。 - 作用:
- 灵活性:程序和库不必担心具体的内存地址,可以在不同的环境和条件下运行。
- 共享:多个程序可以共享同一个库的单一副本,因为库不依赖于特定的加载地址。
- 更新和维护:库可以独立于使用它们的程序更新,因为程序总是在运行时查找最新的库地址。
问题
readelf查到got,got.plt,plt三个段的作用?
在 ELF (Executable and Linkable Format) 文件中,GOT
、GOT.PLT
和 PLT
是动态链接时用于地址解析和函数调用的关键段。这些段使得编译后的代码能够在运行时定位和调用动态链接库中的函数。下面是它们的作用:
PLT(过程链接表)
.plt
段提供了一个用于实现函数调用的跳转表,特别是对于那些在共享库中定义的函数。- 当程序调用一个动态链接的函数时,它实际上是先跳转到 PLT 中的一个条目。这个 PLT 条目包含必要的代码来协调与动态链接器的交互,如果需要,动态链接器会填充这个条目以后续直接跳转到函数
实际地址。
GOT(全局偏移表)
.got
段包含了全局数据(如全局变量)的地址,这些数据在程序运行时可能被加载到不同的地址。- GOT 允许程序代码在不知道变量最终地址的情况下被编译和链接。运行时,GOT 的条目会被动态链接器填充,指向这些变量的实际地址。
GOT.PLT
.got.plt
是 GOT 的一部分,专门用于 PLT 条目。它包含了动态链接函数的地址,这些地址在程序运行时被动态链接器填充。- 当 PLT 中的代码执行时,它通常会访问 GOT.PLT 条目来获取函数的实际地址。如果函数地址尚未解析,PLT 代码会触发动态链接器来解析地址并更新 GOT.PLT 条目。
这三个段的合作使得程序能够在运行时动态链接到共享库,而不需要在编译时知道这些库的实际内存地址。这是实现动态链接和共享库功能的关键机制之一。
C++编译步骤以及每步输出
在 C++ 代码从源代码转换成可执行文件的过程中,会经历多个步骤。以下是这个过程的概述以及每个步骤产生的输出:
- 预处理(Preprocessing):
- 预处理器(cpp)处理源代码文件(
.cpp
),执行所有以#
开头的指令,包括包含头文件(#include
)、宏定义(#define
)和条件编译(#ifdef
,#ifndef
,#endif
)等。 - 预处理后的文件通常不直接可见,它是一个临时文件,准备送入编译器。
- 预处理器(cpp)处理源代码文件(
- 编译(Compilation):
- 编译器(如 g++)将预处理后的文件转换成汇编语言文件。
- 这个步骤中,源代码被翻译成 CPU 可以理解的汇编指令。此时,还未涉及地址分配。
- 汇编(Assembly):
- 汇编器(如 as)将汇编语言文件转换成机器代码,并将其打包成一个目标文件(
.o
文件)。这是一个二进制文件,包含了机器语言指令和符号表等信息。 - 在这个阶段,代码中的符号(如变量和函数名)被转换成地址和索引。但是,如果符号引用了其他编译单元中的实体,那么这些符号的确切地址可能还未确定。
- 汇编器(如 as)将汇编语言文件转换成机器代码,并将其打包成一个目标文件(
- 链接(Linking):
- 链接器(如 ld)将一个或多个
.o
文件以及任何必要的库文件链接成一个单一的可执行文件(.exe
或无后缀)。 - 在链接过程中,所有跨文件的符号引用将被解析,所有模块的相对地址将被调整,以便形成一个连续的地址空间。
- 链接器(如 ld)将一个或多个
关于您的问题:
.o
文件确实包含二进制代码,但这些二进制代码可能还没有最终的地址分配。它们可能包含相对地址或外部符号引用,这些需要在链接时解析。- 对于
.o
文件中的本地或静态变量,它们的地址通常在编译时确定,因为它们不需要跨文件引用。但是,如果一个变量或函数引用了其他.o
文件中的符号,那么它的最终地址将在链接时确定。 - 汇编是在编译步骤之后进行的。编译器生成汇编代码,然后汇编器将这些汇编代码转换为机器代码和目标文件。
为什么dll,so,bin,exe,o文件都是ELF文件格式的?后缀是否与文件格式有关?
文件格式与后缀无关。
PIC
PIC 允许生成的代码在内存中的任何位置正确地执行,而无需通过重定位来修改代码。
对于静态链接的可执行文件,.o
文件中的代码可能不是地址无关的。
为什么是在数据段中增加GOT来实现动态链接,而不是代码段?
代码段的不变性
- 共享内存优势:
- 代码段通常包含程序的指令。如果多个进程可以共享相同的代码段,这将显著减少内存占用。每个进程不需要有自己的代码副本,而是共享相同的物理内存页面。
- 优化缓存使用:
- 由于代码不变,它可以被有效地缓存,减少对内存的读取操作,提高整体性能。
- 简化代码维护和更新:
- 如果代码段不需要针对每个使用它的进程进行修改,这简化了维护和更新共享库的过程。
数据段的动态性
- 每个进程的独立状态:
- 数据段通常包含全局变量和静态变量,这些变量的值可能在每个进程中都是不同的。因此,它们不能被多个进程共享,与代码段相反。
- 动态链接的灵活性:
- 使用 GOT(全局偏移表)和 PLT(过程链接表)等结构允许在运行时确定变量和函数的实际地址。这种方法允许库在任何地址加载,并与其他库或主程序动态交互。
- 实现地址无关性:
- 通过动态修改数据段中的地址引用(而非代码段),程序或库可以在内存中的任何位置运行。这对于动态链接和共享库是必要的。
总结
总的来说,保持代码段不变并在数据段进行动态修改的策略是为了优化内存使用(通过共享代码)和保持程序的灵活性(通过在运行时解析数据地址)。这样,共享库可以被多个进程共享,同时每个进程可以有其独立的数据实例,这在现代操作系统中是一个非常重要的特性。
如果一个.o应用其他动态库,GOT是否为空?
一个动态链接库的 GOT 的内容取决于该库是否使用了全局变量以及是否引用了其他库的符号。即使一个库不引用其他库,它的 GOT 也可能包含该库自己的全局变量的地址。