技术书籍精读笔记:程序员的自我修养-链接,转载与库(陆)
本文介绍了Linux系统下动态链接的核心概念与实现机制。动态链接将符号解析和重定位推迟到运行时,通过动态链接器实现共享库的加载和地址解析。相比静态链接,动态链接具有节省内存、便于更新、提高缓存命中率等优势。文章详细解析了动态链接过程、运行时地址空间分布,并重点阐述了地址无关代码(PIC)技术,包括固定装载地址、装载时重定位及PIC的实现原理。PIC通过全局偏移表(GOT)和过程链接表(PLT)实现模块间引用,使指令部分可被多进程共享,解决了早期技术的局限性。

前言
在本篇博客中主要讲解在Linux系统下关于动态链接的内容,Linux中动态链接的思想是什么?Linux中动态链接的好处是什么?Linux中动态链接的相关结构以及步骤和实现,动态链接的技术演进过程如何。相信通过该片文章你能很好的了解在Linux系统下的动态链接的过程,对Linux系统中的动态链接也会有一个新的认识。
Linux系统动态链接的思想
不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接,把链接的过程推迟到了运行时再进行
PS:静态链接是一次性在运行前完成所有的符号解析和重定位,运行时无需链接操作;动态链接是将符号解析和重定位推迟到运行时,也就是第一次使用该符号时,运行时需要动态链接器的支持来完成实际的链接操作。而这也会导致动态链接的程序在性能上会有一些损失,后续会讲到延迟绑定,使动态链接的性能损失尽可能的减小
使用动态链接的理由
对于使用动态链接的理由,我们要将静态链接和动态链接进行比较,动态链接相比于静态链接有哪些优点。对此,以下几点便是使用动态链接的理由:
1.静态链接对于每一个程序的运行都需要对应的静态链接库,尽管这些程序可能会使用到同一个静态链接库,但是当这些程序运行时都会创建对应的副本占用内存空间。而动态链接则开可以存储一份动态链接的文件,使多个程序在运行时进行复印,节省占用的内存空间
2.对于静态链接的程序,倘若供应商提供的静态链接库更新,那么自己开发的程序需要程序打包发布,而动态链接库只需要更新对应的动态链接库即可。而客户也由原来的更新整个程序调整为更新单个模块
3.动态链接的程序在调用动态链接库的时候会把动态链接库调到内存,当下一个程序需要使用该链接库的时就不需要再去调到内存使用了。这可以减少物理页面的换入换出,也可以增加CPU缓存的命中率
4.对于静态链接库,假设操作系统A和操作系统B对于某一个函数的实现机制不同,对于静态链接则需要程序分别链接成能够在操作系统A和操作系统B的两个版本进行发布,而动态链接库则不需要针对不同的系统进行不同的链接,其程序会动态地选择相应的函数实现版本
Linux中动态链接机制
在Linux系统中,ELF动态链接文件被称为动态共享对象,它们一般是以“.so”为扩展名的一些文件。对于Linux中的动态链接库的编译可以参考: 《Linux:动态库和静态库的编译与使用》。
在Linux中动态链接主要由动态链接器实现,在程序启动时内核会先加载动态链接器,由动态链接器其负责以下工作:
1.解析依赖:读取可执行文件中的.dynamic段,获取所需的共享库列表
2.查找库文件:按照一定顺序搜索共享库路径
3.加载库:将共享库映射到进程的虚拟地址空间
4.重定位:修正符号的引用地址
5.执行初始化代码:调用库中的.init段或构造函数
6.跳转到主程序入口:开始执行main()
可以通过下图简单的理解动态链接的过程:

图1.动态链接过程
动态链接程序运行时地址空间分布
对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身。但是对于动态链接来说,除了可执行文件本身之外,还有它所依赖的共享目标文件。那么这种情况下,进程的地址空间分布又会怎样呢?以下是在Windows中输出的可执行文件相关的信息:
== Progrma1.exe runtime output ==Printing from Lib.dll 1== Loaded modules == 0 0x00007ff650400000-0x00007ff650414000 81920 Progrma1.exe 1 0x00007ffa0c6c0000-0x00007ffa0c926000 2514944 ntdll.dll 2 0x00007ffa0b600000-0x00007ffa0b6c9000 823296 KERNEL32.DLL 3 0x00007ffa098e0000-0x00007ffa09cde000 4186112 KERNELBASE.dll 4 0x00007ffa09f30000-0x00007ffa0a07c000 1359872 ucrtbase.dll 5 0x00007ff9f8280000-0x00007ff9f8294000 81920 Lib.dll在这段输出的数据中,我们可以观察到进程里实际装入了哪些模块,例如Progrmal.exe,Lib.dll,ntdll.dll和kernel32.dll等。除此之外,我们可以从以下信息中知道代码段、数据段、堆、栈里的典型地址分别落在哪里。
== Marker addresses ==EXE base 0x00007ff650400000DLL base 0x00007ff9f8280000main 0x00007ff650401c6afoobar 0x00007ff9f8281310exe_global 0x00007ff650404000exe_text 0x00007ff650405000library_anchor() 0x00007ff9f8283010library_message() 0x00007ff9f8284000heap allocation 0x000001a3de00af20stack variable 0x000000692e5ffb64而对于Windows系统中加载dll文件的地址如何分配,我们可以参考使用objdump命令输出的Lib.dll信息分析。
Lib.dll: file format pei-x86-64Characteristics 0x2026 executable line numbers stripped large address aware DLLTime/Date Sun Jul 26 11:55:14 2026Magic 020b (PE32+)MajorLinkerVersion 2MinorLinkerVersion 45SizeOfCode 0000000000001400SizeOfInitializedData 0000000000001600SizeOfUninitializedData 0000000000000200AddressOfEntryPoint 0000000000001200BaseOfCode 0000000000001000ImageBase 00000001d5a40000SectionAlignment 00001000FileAlignment 00000200MajorOSystemVersion 4MinorOSystemVersion 0MajorImageVersion 0MinorImageVersion 0MajorSubsystemVersion 5MinorSubsystemVersion 2Win32Version 00000000SizeOfImage 00014000SizeOfHeaders 00000600CheckSum 0000b702Subsystem 00000003 (Windows CUI)DllCharacteristics 00000160 HIGH_ENTROPY_VA DYNAMIC_BASE NX_COMPATSizeOfStackReserve 0000000000200000SizeOfStackCommit 0000000000001000SizeOfHeapReserve 0000000000100000SizeOfHeapCommit 0000000000001000LoaderFlags 00000000NumberOfRvaAndSizes 00000010通过objdump -p Lib.dll可以看到,Lib.dll是一个pei-x86-64格式的64位Windows动态链接库,其首选装载基址ImageBase为 0x00000001d5a40000,映像总大小SizeOfImage为0x00014000。同时,输出中的DllCharacteristics包含DYNAMIC_BASE标志,说明该动态链接库支持地址空间布局随机化,加载器在运行时可以根据当前进程虚拟地址空间的空闲情况,将其装载到不同的虚拟地址,而不必固定装载到ImageBase指定的位置。此外SectionAlignment为0x1000说明该映像装入内存时按页对齐,这也符合进程虚拟内存分页管理的方式。
结合程序运行时输出可以看到,Lib.dll在本次执行中实际被装载到地址0x00007ff9f8ac0000,而不是PE头中给出的首选基址0x00000001d5a40000。这说明Windows下动态链接库的最终装载地址并不是在编译时固定决定的,而是在装载时由系统加载器动态分配的。也就是说,动态链接库和Linux下的共享对象一样,其运行时装载位置依赖于当前进程地址空间的实际使用情况;如果首选基址不可用,或者系统启用了地址随机化机制,加载器就会通过重定位把映像映射到其他可用区域。因此,从实验结果可以得出结论:Windows 环境下DLL 的装载同样具有运行时动态分配地址空间的特征。
地址无关代码
本小节间针对地址无关代码进行讲解,主要讲解地址无关代码是什么,以及地址无关代码的发展过程和演进过程。
1.固定装载地址
在早期一些系统中,并没有动态链接这个概念。为了实现库的共享有人提出了将每一个模块在导入系统的内存中时都指定该模块的地址,即固定每一个模块的装载地址,这种做法叫做静态共享库。例如以下一些系统:
1.UNIX System V Release 3.2(COFF format)
2.旧的Linux system(a.out format)
3.BSD/OS derivative of 4.4BSD(a.out and ELF format)
对于固定装载地址的实现是比较简单的,但是当系统维护的人多,并且开发的模块多时就很容易导致模块的地址指定变多,而为了管理这些模块地址还需要不少的精力,具体可以参考下图:
图2.固定装载地址的困扰
2.装载时重定位(基址重置)
为了解决固定装载地址的困扰便出现了装载时重定位,在Windows系统中也叫基址重置。因为可执行文件往往是第一个被加载的文件,Linux下一般是0x08040000。装载时重定位会在链接时,对模块中所有的绝对地址的引用不作重定位处理,把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统便会对重新中所有使用该模块的绝对地址引用进行重定位。具体可以参考下图:

图3.装载时重定位
转载时重定位可以解决地址冲突的问题,但是并不能解决复用模块的问题,即指令部分无法在多个进程之间共享。因为系统对每一个进程都维护一份独立的,已重定位的代码副本。即存在程序A和程序B的使用模块A时,两者会各自导入一份模块A的副本到内存中,当程序A导入模块A到地址0x10000000时,那么foobar()函数则会重定位至0x10000100,当程序B导入模块A到地址0x20000000时,那么foobar()函数则会重定位至0x20000100。这就导致了导入同一个模块A但是不能复用
3.地址无关代码(PIC)
为了解决装载时重定位导致的指令无法复用的问题,我们希望重新模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,其实现思路是把指令中那些需要被修改的部分分离出来,跟数据部分放到一起,这样指令部分就可以保持不变,而数据部分可以在每一个进程中拥有一个副本,从而解决复用指令的问题。
为了把需要修改的指令分离到数据部分,我们需要分析模块中各个类型的地址引用方式,看看模块中对应类型要如何实现。具体的模块存在的地址引用类型如下:
1.模块内部的函数调用,跳转等:由于这种情况下被调用的函数或者对象和调用者都处于同一个模块中,它们之间的相对位置是固定的,所以这种指令不需要进行重定位
void fun(){ printf(“fun函数的调用”);}int main(){ fun(); // 在同一个模块中调用函数 int a = 0; printf("输出:%d\n",a); // 在同一个模块中调用对象}2.模块内部数据范围:把“访问绝对地址”转换为“访问当前位置附近的某个固定偏移”。只要代码段和数据段在模块内部的相对布局不变,那么无论模块被映射到哪里,这个偏移始终成立。
static int counter = 10;int read_counter(void) { return counter; // 模块内部全局数据访问}3.模块间数据访问:对模块外部数据的访问,PIC不再直接计算“变量本体”的地址,而是先找到该变量在GOT中对应的表项,再由这个表项间接得到真实地址。这样装载器只需要改GOT 表项,而不需要改text`指令本身,于是代码段仍然可以保持只读和可共享。这也是PIC真正节省内存的关键点之一:多个进程可以共享同一份代码页,但各自拥有自己的GOT/重定位结果。
extern int shared_value;int read_shared(void) { return shared_value;}4.模块间函数调用、跳转:通过PLT+GOT完成。对外部函数的调用,代码通常先跳到PLT表项,再由PLT配合GOT找到目标函数的真实入口地址。这样既避免了在代码段中写死外部函数绝对地址,也为延迟绑定提供了基础。

图4.模块间函数调用跳转流程