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 CodePIC编译也就是编译时添加-fPIC参数。PIC 的核心是相对编址动态库中的函数调用、变量访问均使用相对于当前指令的偏移量进行编址而非绝对地址。这样无论动态库被加载到虚拟地址空间的哪个位置只要根据偏移量计算就能正确找到目标函数或变量实现地址无关性。✅️注意我们的程序怎么和库具体映射起来的动态库也是一个文件要访问也是要被先加载要加载也是要被打开的让我们的进程找到动态库的本质也是文件操作不过我们访问库函数通过虚拟地址进行跳转访问的所以需要把动态库映射到进程的地址空间中2.3 运行时的地址重定位从符号到实际地址当动态库被加载到进程的虚拟地址空间后其虚拟起始地址就被确定了。动态链接器会完成两步核心工作实现程序对动态库符号的访问符号解析根据可执行程序记录的符号名如函数名、变量名在已加载的动态库中找到对应的符号地址计算结合动态库的虚拟起始地址和符号在库中的相对偏移量计算出符号的实际虚拟地址地址重定位将计算出的实际虚拟地址写入程序的指定位置让程序能通过该地址调用访问动态库函数。简单来说程序调用动态库函数的地址最终是动态库起始虚拟地址 函数在库中的相对偏移量这也是进程能正确调用动态库函数的关键。三. GOT/PLT动态链接的核心实现机制程序的代码段在内存中是只读的无法直接在代码段中修改函数调用的地址因此 Linux 通过全局偏移量表GOT和过程链接表PLT解决这一问题实现了只读代码段的动态地址重定位也是 PIC 的核心实现。3.1 全局偏移量表GOTGOT 是位于程序数据段.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 系统中绝大多数程序都采用动态链接的原因。✅️小补充那为什么编译器默认不使用静态链接呢静态链接会将编译产生的所有目标文件连同用到的各种库合并形成一个独立的可执行文件它不需要额外的依赖就可以运行。照理来说应该更加方便才对是吧静态链接最大的问题在于生成的文件体积大并且相当耗费内存资源。随着软件复杂度的提升我们的操作系统也越来越臃肿不同的软件就有可能都包含了相同的功能和代码显然会浪费大量的硬盘空间。这个时候动态链接的优势就体现出来了我们可以将需要共享的代码单独提取出来保存成一个独立的动态链接库等到程序运行的时候再将它们加载到内存这样不但可以节省空间因为同一个模块在内存中只需要保留一份副本可以被不同的进程所共享。动态链接到底是如何工作的首先要交代一个结论动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存其中每个动态库的加载地址都是不固定的操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。当动态库被加载到内存以后一旦它的内存地址被确定我们就可以去修正动态库中的那些函数跳转地址了。