目录

linux仓库深入解析Linux动态链接与动态库加载理解背后的原理与技巧

[linux仓库]深入解析Linux动态链接与动态库加载:理解背后的原理与技巧

[https://csdnimg.cn/release/blogv2/dist/pc/img/activeVector.png 【双节征文】月满华诞 · 码向未来–代码寄明月,指尖庆华诞 10w+人浏览 150人参与

https://csdnimg.cn/release/blogv2/dist/pc/img/arrowright-line-White.png]( )

https://i-blog.csdnimg.cn/direct/b7a1ee05c5bf4d628c7f0dd1cc330502.jpeg

🌟 各位看官好,我是 !

🌍 Linux == Linux is not Unix !

🚀 今天来学习Linux的指令知识,并学会灵活使用这些指令。

👍 如果觉得这篇文章有帮助,欢迎您一键三连,分享更多人哦!


进程如何看到动态库

为什么之前不谈进程如何看到静态库 ?

没必要。静态库就是将 .o 打了包,会把自己的代码实现拷贝到自己的可执行程序里。程序一旦运行时就跟静态库无关了,就看不到静态库也不需要看到。

前面我们用ldd命令可以查看一个可执行程序所依赖的动态库:

https://i-blog.csdnimg.cn/direct/bce5833dd79e456eb28ed21a39dcdd4d.png

结论一 : 让我们自己的程序跑起来,除了要加载myexe本身,也要加载myexe依赖的库文件!!

那么进程是如何看到对应的动态库文件的?库文件也要加载到物理内存,要建立虚拟到物理映射关系,那么就可以通过虚拟地址空间找到需要的物理内存.

结论二 : 通过自己的虚拟地址空间中的共享区看到的

https://i-blog.csdnimg.cn/direct/c83e61a826154cd1a9c4491db27fc8dd.png

结论三:介于栈和堆中间的共享区就是用来存放动态库的

https://i-blog.csdnimg.cn/direct/9ce81a2ae3b547a998895b3cff760321.png

进程间如何共享库

一个进程可以看到一个库,那么能不能多个进程看到一个库呢?如何做到多个进程看到同一个库的?

结论四 : 每一个进程把要的库映射到自己的虚拟地址空间中,即多个进程能看到同一个库.这种方式不需要重复加载代码和数据在物理内存中,有效节省内存空间.

动态库的本质:通过地址空间映射,对公共代码进行去重!!!

https://i-blog.csdnimg.cn/direct/fd64249a3f5d4c60bd4f1714eab91c35.png

动态链接

动态链接其实远比静态链接要常用得多,而这里myexe可执行程序依赖libc.so是什么呢?

是C语言的运行时库,里面提供了常用的标准输入输出文件字符串处理等功能.

https://i-blog.csdnimg.cn/direct/bce5833dd79e456eb28ed21a39dcdd4d.png

为什么编译器默认不使用静态链接呢?

我们前面说过静态链接实际上会合并形成一个独立的可执行文件.虽然它不需要额外的依赖就能运行,照理来说应该更方便才对?

静态链接最⼤的问题在于⽣成的⽂件体积⼤,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费⼤量的硬盘空间。

这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同⼀个模块在内存中只需要保留⼀份副本,可以被不同的进程所共享。

原理:动态链接又是如何进行的呢?

⾸先要交代一个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行⼀个程序,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使⽤情况为它们动态分配⼀段内存。当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。

编译器对可执行程序动手脚

https://i-blog.csdnimg.cn/direct/bce5833dd79e456eb28ed21a39dcdd4d.png

在C/C++程序中,当程序开始执行时,实际上并不会直接跳转到 main 函数。有一个程序的入口点是 _start ,这是⼀个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。

在 _start 函数中,会执行一系列初始化操作,这些操作包括:

1.设置堆栈:为程序创建⼀个初始的堆栈环境。

2.初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。

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

动态链接器:

  • 动态链接器(如ld-linux.so)负责在程序运行时加载动态库。
  • 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。

环境变量和配置⽂件:

  • Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置⽂件(如/etc/ld.so.conf及其⼦配置⽂件)来指定动态库的搜索路径。
  • 这些路径会被动态链接器在加载动态库时搜索。

缓存文件:

  • 为了提高动态库的加载效率,Linux系统会维护⼀个名为/etc/ld.so.cache的缓存文件。
  • 该⽂件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会⾸先搜索这个缓存文件。

https://i-blog.csdnimg.cn/direct/5ee94615f9e4470c87b001bd4a5e9a6a.png

4.调用 __libc_start_main :一旦动态链接完成, _start 函数会调用 __libc_start_main(这是glibc提供的一个函数).  __libc_start_main 函数负责执行⼀些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。

  1. 调用 main 函数:最后, __libc_start_main 函数会调用程序的 main 函数,此时程序的执行控制权才正式交给用户编写的代码。

  2. 处理 main 函数的返回值:当 main 函数返回时, __libc_start_main 会负责处理这个返回值,并最终调用 _exit 函数来终⽌程序.

上述过程描述了C/C++程序在 main 函数之前执⾏的⼀系列操作,但程序员通常只需要关注 main 函数中的代码,而不需要关心底层的初始化过程。了解这些底层细节有助于更好地理解程序的执行流程和调试问题。

动态库相对地址

动态库,静态库,可执行,.o等,都是ELF格式的文件 –> 编译器要对它们进行ELF格式编址:

动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法,统一编址,采用相对编址的方案进行编制的(其实可执⾏程序也⼀样,都要遵守平坦模式,只不过exe是直接加载的)。

逻辑地址 = 起始地址 + 偏移量的方式.

由于有了平坦模式的加持,即起始地址是从0开始的.所以对于相对编址和绝对编址二者是等价的:

  • 相对编址:偏移量
  • 绝对编址:全0到全f

逻辑地址 = 0 + 偏移量 = 相对编址

https://i-blog.csdnimg.cn/direct/9b5beb7e2c4b466a806dda85cb631e66.png

https://i-blog.csdnimg.cn/direct/0916e0ca217942bcb21bb6a408fd12a9.png

程序如何和库具体映射

  • 动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的
  • 让我们的进程找到动态库的本质:也是⽂件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中

https://i-blog.csdnimg.cn/direct/807a4321e857482b8b12498e0d0b95f2.png

程序怎么进行库调用

  • 库已经被我们映射到了当前进程的地址空间中
  • 库的虚拟起始地址我们也已经知道了
  • 库中每⼀个方法的偏移量地址我们也知道
  • 所以:访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法
  • 而且:整个调用过程,是从代码区跳转到共享区,调⽤完毕在返回到代码区,整个过程完全在进程地址空间中进行的.

https://i-blog.csdnimg.cn/direct/cbc16128fc2447c3884a0d3f520d7f2e.png

将库函数替换为起始虚拟地址,这种工作由谁来做啊?

就是前面说的动态链接器和OS:尤其是在动态加载时,进行地址重定向!

https://i-blog.csdnimg.cn/direct/05b1e532fb994675b7b394355306dd04.png

https://i-blog.csdnimg.cn/direct/2d4db0fcb627408d90d1432ef3f3b5fe.png

全局偏移量表GOT

注意:也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道.然后对我们加载到内存中的程序的库函数调⽤进⾏地址修改,在内存中二次完成地址设置(这个叫做加载地址重定位)

等等,修改的是代码区?不是说代码区在进程中是只读的吗?为什么能修改?当我要修改代码区时,页表会通过权限拒绝进行修改。那这是怎么回事呢?

所以:动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引用的⼀个全局变量或函数的地址。

因为.data区域是可读写的,所以可以⽀持动态进行修改

https://i-blog.csdnimg.cn/direct/d14bcb98aafe4b53a7f4e56bd1221723.png

https://i-blog.csdnimg.cn/direct/1aed3d0bd428417cb112bca411f68900.png

  1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独⽴的GOT表,所以进程间不能共享GOT表。
  2. 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找到GOT表。
  3. 在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会被修改为真正的地址。
  4. 这种⽅式实现的动态链接就被叫做 PIC 地址⽆关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。

https://i-blog.csdnimg.cn/direct/0ca971caf25145c3a67b08798a8e4e28.png

库间依赖(看看即可)

  • 不仅仅有可执行程序调用库
  • 库也可以调用其他库!!因为库之间也能存在依赖.那么如何做到库和库之间互相调⽤也是与地址⽆关的呢?
  • 库中也有.GOT , 和可执行⼀样!这也就是为什么⼤家为什么都是ELF的格式!

https://i-blog.csdnimg.cn/direct/1a4361caaf47466c822f7d6c1f036ce3.png

由于GOT表中的映射地址会在运⾏时去修改,我们可以通过gdb调试去观察GOT表的地址变化。在这⾥

我们只用知道原理 :

  • 由于动态链接在程序加载的时候需要对⼤量函数进⾏重定位,这⼀步显然是⾮常耗时的。为了进一步降低开销,我们的操作系统还做了⼀些其他的优化,⽐如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序⼀开始就对所有函数进⾏重定位,不如将这个过程推迟到函数第⼀次被调⽤的时候,因为绝⼤多数动态库中的函数可能在程序运⾏期间⼀次都不会被使⽤到。

思路是:GOT中的跳转地址默认会指向⼀段辅助代码,它也被叫做桩代码/stup。在我们第⼀次

调⽤函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次

调⽤函数的时候,就会直接跳转到动态库中真正的函数实现。

总结

本文探讨了Linux动态链接库的工作原理。动态库通过虚拟地址空间映射实现多个进程共享,有效节省内存空间。与静态链接不同,动态链接将链接过程推迟到程序加载时进行,由动态链接器负责库加载和地址重定位。文章详细介绍了程序启动流程(从_start到main函数)、动态库的相对编址原理,以及全局偏移表(GOT)在动态链接中的作用。还分析了库间依赖关系和延迟绑定(PLT)优化技术,展示了动态链接如何通过地址无关代码实现高效共享。这些机制共同构成了Linux动态链接的核心技术。