Linux 动态链接与动态库加载深度解析

前言:
在 Linux 程序开发中,动态库是实现代码复用、减小程序体积、节省系统资源的核心技术,而动态链接则是程序运行时加载和使用动态库的底层机制。相较于静态链接将库代码直接合并到可执行程序的方式,动态链接把链接过程推迟到程序运行阶段,实现了多个进程共享同一份库代码,大幅提升了系统的资源利用率。本文将从进程与动态库的关联、动态链接的工作原理、GOT/PLT 机制等方面,深度解析 Linux 下的动态链接与动态库加载过程,带你吃透这一 Linux 开发的核心知识点。
一. 进程如何感知并加载动态库
动态库本质上是一个符合 ELF 格式的二进制文件,进程要使用动态库中的函数和数据,首先要让动态库被加载到内存并映射到进程的虚拟地址空间中,这是进程能访问动态库的前提。
1.1 进程对动态库的 “可见性”
进程本身并不能直接识别磁盘上的动态库文件,而是通过操作系统的文件操作和内存映射机制实现对动态库的访问。当程序运行时,操作系统会根据程序的依赖信息,找到对应的动态库文件并打开,随后通过mmap系统调用将动态库的代码段、数据段等映射到进程的虚拟地址空间的共享区,让进程在虚拟地址层面能 “看到” 动态库的内容。
1.2 多进程共享动态库的实现
Linux 系统中,多个依赖同一动态库的进程,并不会在物理内存中加载多份库的副本,而是通过虚拟内存的页表映射机制实现共享:
动态库被加载到物理内存后,操作系统会为其建立一份物理内存映射;
每个使用该动态库的进程,其页表会将虚拟地址空间共享区的一段地址,映射到这份物理内存;
进程对动态库的访问,最终都会转化为对同一份物理内存的访问,从而实现物理内存层面的库共享。
这种机制极大节省了物理内存资源,也是动态链接相比静态链接 的核心优势之一。
二. 动态链接的核心工作原理
动态链接的核心是将符号解析和地址重定位从编译链接阶段推迟到程序运行阶段。编译器编译生成可执行程序时,并不会将动态库的函数地址、变量地址直接写入程序,而只是记录下依赖的动态库和符号信息;当程序运行时,动态链接器会完成符号的解析和地址的重定位,让程序能正确调用动态库中的函数。
2.1 程序运行前的动态链接准备
C/C++ 程序的入口并非我们编写的main函数,而是链接器提供的_start函数,动态链接的初始化工作正是在_start函数中完成的,其流程如下:
设置堆栈:为程序创建初始的堆栈环境,保证函数调用的栈操作正常;
初始化数据段:将初始化的全局变量、静态变量从可执行程序复制到内存,清零未初始化的bss段;
加载动态链接器:调用系统接口加载 Linux 的动态链接器ld-linux.so,由其负责后续的动态链接工作;
解析库依赖:动态链接器读取可执行程序的动态段信息,解析出程序依赖的所有动态库(可通过ldd命令查看程序的库依赖);
加载并映射动态库:按依赖顺序加载所有动态库,将其映射到进程的虚拟地址空间;
调用__libc_start_main:完成信号处理、线程库初始化等工作后,最终调用main函数,将程序控制权交给用户代码。
其中,动态链接器是动态链接的核心执行者,Linux 下的ld-linux.so负责处理所有动态库的加载、符号解析和地址重定位。
$ ldd main.exe
linux-vdso.so.1 => (0x00007ffefd43f000)
libc.so.6 => /lib64/libc.so.6 (0x00007f533380b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f5333bd9000)
# ldd命令⽤于打印程序或者库⽂件所依赖的共享库列表。
2.2 动态库的地址无关性:PIC 编译
动态库被加载到进程虚拟地址空间的地址是不固定的,操作系统会根据当前内存的使用情况,为动态库分配合适的虚拟地址区间。为了让动态库能在任意地址加载后都能正常运行,动态库必须采用位置无关代码(Position Independent Code,PIC) 编译,也就是编译时添加-fPIC参数。
PIC 的核心是相对编址:动态库中的函数调用、变量访问,均使用相对于当前指令的偏移量进行编址,而非绝对地址。这样无论动态库被加载到虚拟地址空间的哪个位置,只要根据偏移量计算,就能正确找到目标函数或变量,实现地址无关性。
✅️ 注意:我们的程序,怎么和库具体映射起来的
动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的
让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中
2.3 运行时的地址重定位:从符号到实际地址
当动态库被加载到进程的虚拟地址空间后,其虚拟起始地址就被确定了。动态链接器会完成两步核心工作,实现程序对动态库符号的访问:
符号解析:根据可执行程序记录的符号名(如函数名、变量名),在已加载的动态库中找到对应的符号;
地址计算:结合动态库的虚拟起始地址和符号在库中的相对偏移量,计算出符号的实际虚拟地址;
地址重定位:将计算出的实际虚拟地址写入程序的指定位置,让程序能通过该地址调用访问动态库函数。
简单来说,程序调用动态库函数的地址,最终是动态库起始虚拟地址 + 函数在库中的相对偏移量,这也是进程能正确调用动态库函数的关键。
三. GOT/PLT:动态链接的核心实现机制
程序的代码段在内存中是只读的,无法直接在代码段中修改函数调用的地址,因此 Linux 通过全局偏移量表(GOT) 和过程链接表(PLT) 解决这一问题,实现了只读代码段的动态地址重定位,也是 PIC 的核心实现。
3.1 全局偏移量表(GOT)
GOT 是位于程序数据段(.data) 的一片可读写内存区域,其核心作用是存放动态库符号的实际虚拟地址。数据段的可读写属性,让动态链接器能在程序运行时,动态修改 GOT 表中的地址值。
GOT 表中的每一项,对应一个程序需要访问的动态库符号(函数或变量);
编译时,编译器会为每个动态库符号在 GOT 表中分配一个条目,此时条目值为无效地址;
程序运行时,动态链接器会将解析后的符号实际虚拟地址,写入 GOT 表对应的条目;
程序访问动态库符号时,会先从 GOT 表中读取实际地址,再通过该地址进行访问。
还有些没提到的东西可以看看下面图中的细节补充一下
同时,GOT 表与动态库的相对位置是固定的,程序可以通过CPU 的相对寻址找到 GOT 表,保证了地址无关性。需要注意的是,每个进程都有自己独立的 GOT 表,因为不同进程的动态库加载地址可能不同,进程间无法共享 GOT 表。
3.2 过程链接表(PLT):延迟绑定优化
动态链接器如果在程序启动时,就对所有动态库符号进行解析和重定位,会增加程序的启动时间 —— 因为程序运行过程中,很多动态库函数可能一次都不会被调用。为了解决这一问题,Linux 引入了延迟绑定(Lazy Binding) 机制,其核心实现就是过程链接表(PLT)。
PLT 是一段位于程序代码段的桩代码(stub code),每个动态库函数对应一个 PLT 条目,其工作流程分为第一次调用和后续调用:
(1)函数第一次被调用
程序调用动态库函数时,首先跳转到该函数对应的 PLT 条目;
PLT 条目会读取 GOT 表中对应的条目,此时 GOT 表中的值指向 PLT 条目的下一条指令;
该指令会调用动态链接器的符号解析函数,动态链接器会解析出函数的实际虚拟地址,并将其写入 GOT 表对应的条目;
动态链接器跳转到函数的实际地址,执行函数逻辑。
(2)函数后续被调用
程序再次跳转到 PLT 条目时,会直接读取 GOT 表中的值,此时该值已经是函数的实际虚拟地址;
程序直接跳转到该地址执行函数,不再经过动态链接器的解析,实现了调用的优化。
延迟绑定将符号解析的工作推迟到函数第一次被调用时,大幅减少了程序的启动时间,是 Linux 动态链接的重要优化手段。
✅️ 思路是:GOT中的跳转地址默认会指向一段辅助代码,它也被叫做桩代码/stup。在我们第一次调用函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调用函数的时候,就会直接跳转到动态库中真正的函数实现。
3.3 库间依赖的处理
动态库之间也存在依赖关系(如库 A 依赖库 B),其处理方式与程序依赖动态库一致:
动态链接器会按依赖顺序加载所有的动态库,包括库的依赖库;
每个动态库也都有自己独立的 GOT 表,动态链接器会依次解析所有库间的符号依赖,完善各个 GOT 表;
库间的函数调用,同样通过GOT 表 + 相对偏移的方式实现,保证了库间调用的地址无关性。
所有动态库的 GOT 表完善后,整个程序的动态链接过程才算完成,程序才能正常运行。
由于GOT表中的映射地址会在运行时去修改,我们可以通过gdb调试去观察GOT表的地址变化。在这里我们只用知道原理即可,大家有兴趣的可以参考:使用gdb调试GOT
四. 动态链接与静态链接的核心对比
为了更清晰地理解动态链接的优势和特点,我们将其与静态链接做核心维度的对比,如下表所示:
对比维度 静态链接 动态链接
链接时机 编译链接阶段 程序运行阶段
可执行程序体积 大,包含所有库代码 小,仅记录库依赖和符号信息
内存占用 高,每个进程加载一份库代码 低,多进程共享物理内存中的库副本
磁盘占用 高,多个程序包含重复库代码 低,系统中仅存一份动态库文件
程序更新 需重新编译链接整个程序 仅更新动态库文件,无需重新编译程序
运行性能 略高,无运行时链接开销 略低,存在启动时的动态链接开销
兼容性 好,可执行程序独立运行 依赖库版本,库版本不兼容可能导致程序崩溃
静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件。
我们知道静态链接会将编译产生的所有目标文件,和用到的各种库合并成一个独立的可执行文件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT方式进行调用(运行重定位,也叫做动态地址重定位)。
可以看到,动态链接以微小的运行性能开销,换来了系统资源的高效利用和程序的灵活更新,这也是 Linux 系统中绝大多数程序都采用动态链接的原因。
✅️ 小补充:那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。照理来说应该更加方便才对是吧?
静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。
这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。
动态链接到底是如何工作的??
首先要交代一个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
————————————————
版权声明:本文为CSDN博主「草莓熊Lotso」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/2503_91389547/article/details/157945028
上一篇 NVIDIA显卡闭源驱动安装办法
下一篇 MySQL连接报错处理:1130-host ... is not allowed to connect to this MySql server