【Linux】动态链接和动态加载

目录

1. 进程和动态库

2. 动态链接

3. ldd 命令

4. 加载器(动态链接器)

5. 延迟绑定

6. 库间依赖

7. 总结


1. 进程和动态库

▶进程如何看到动态库?

进程在加载本身的代码和数据时,还会把要用到的库文件也加载到内存里,库文件也是ELF文件,也有自己的虚拟地址和映射关系,不过动态库最后会通过页表映射到进程虚拟地址堆栈之间的共享区,如下图所示:

有时候进程不一定只用到一个库,所以共享区可能会映射很多个库。

▶进程如何共享动态库?

如果多个进程想要使用同一个动态库,那么进程通过各自独立的页表,将自身虚拟地址空间中的“共享区”映射到物理内存中的同一块动态库代码副本上,从而实现共享。虽然不同进程中库的虚拟地址可能不同,但它们通过页表指向相同的物理内存页,使得一份库代码能被多个进程同时使用,极大地节省了内存资源。

基于上述共享原理,动态库在内存中的生命周期不应与单个进程绑定。正确的管理机制是采用引用计数:每当一个进程映射该动态库,计数便加一;反之,进程退出或卸载库时,计数则减一。当引用计数降为零时,表明已无任何进程使用该库,操作系统方可安全地将其从内存中移除。这种机制确保了系统资源的高效利用。

2. 动态链接

当程序调用动态库中的函数时,编译阶段只能确定该函数在动态库内的相对偏移量。动态库被加载到进程的虚拟地址空间时,其动态库的起始位置在运行时才能确定。因此,函数的完整虚拟地址计算公式为:函数虚拟地址 = 动态库起始位置 + 函数在库内的相对偏移。这个就是加载时位置重定向。

程序中的 call 指令调用的正是这个计算出的虚拟地址。随后,在程序执行该指令时,CPU中的MMU会通过查询页表,实时地将这个虚拟地址转换为最终的物理地址,从而完成对函数代码的访问和执行。

之后在不同的进程中即使动态库映射到了不同进程的不同虚拟地址空间中,也能使用同一份函数代码。因为动态库的代码段被编译成了位置无关代码(PIC,Position Independent Code),它内部不包含任何绝对地址,所有指令都使用相对寻址。

那么我们的程序是怎么和动态库具体映射起来的呢?

下面这张图已经画的非常清楚了:

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

我们可以总结一下:库函数调用是在进程的虚拟地址空间范围内调用的,且动态库的函数都是采用相对编址的方案。所以动态库无论映射到进程的哪个位置,进程都能调用;多进程映射时,每个进程映射动态库的位置可能不同,但并不影响各个进程访问动态库。

3. ldd 命令

ldd = List Dynamic Dependencies(列出动态依赖)

功能:用于打印一个可执行文件或共享库所依赖的共享库。

ldd [选项] <可执行文件/共享库>

示例:

4. 加载器(动态链接器)

这个叫加载器/动态链接器/运行时链接器,那么问题来了,什么是加载器呢?有什么用呢?我们的可执行文件所依赖的共享库为什么会有这个加载器呢?

加载器顾名思义作用就是加载和链接,它是负责加载和管理所有其他普通共享库的特殊程序。在ELF可执行文件中,有一个专门的INTERP 段,里面存储的就是这个动态链接器的路径。当你在Shell中运行一个程序时,内核实际上并不是直接执行你的程序,而是先加载并执行这个动态链接器。然后,由动态链接器来负责加载你的程序以及所有它依赖的共享库(如 libc.so.6),完成符号解析和重定位后,再将控制权交给你的程序的入口点(如 _start)。

所以对于程序的开始我们又有了新的认识,以前我们认为程序是从main函数开始的,但是在其实main函数前面还有一个_start段,它里面调用了包含main函数的库,并且会进行一大堆的初始化进程的初始化动作,然后才调用的main函数。

这些初始化动作中就包含:

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

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

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

5. 延迟绑定

看到这张图,我们可能会有一个疑问:难道call的地址是在代码区被修改的吗?但是代码区的数据不是应该是只读的吗?那是怎么修改的呢?

代码区是只读的,这一点没法改变,那我们只能去间接解决这个问题,因为数据区是可修改的,所以我们要利用数据区来实现地址的修改,具体方案如下: 

实际上,call 指令调用的目标,是一个固定的地址,即 func@plt(在 liba.so 的 .text 段内)。GOT表位于数据段,而数据段是可读写的。

第一次调用:在动态链接器解析 func() 的真实地址之前,GOT[n] 里存储的是一个“默认值”,这个值指向一段能触发解析流程的代码(通常是PLT里的一部分)。

动态链接器工作:当解析流程启动,动态链接器找到 func() 在物理内存中的真实地址后,它所做的唯一修改,就是将这个真实地址写入 GOT[n] 这个内存位置。

后续调用:之后,当 call func@plt 再次执行,func@plt 依然执行 jmp *GOT[n]。但此时 GOT[n] 中已经是 func() 的真实地址了,于是CPU直接跳转过去。

func@plt 是一小段固定的、位于代码区的桩代码(负责转发请求到真正的实现代码)。它的逻辑永远是:jmp *GOT[n] (跳转到GOT表中第n个槽所指向的地址)。

6. 库间依赖

我们在平时写代码的时候会发现,不只是可执行程序会调用库,库也会调用库。那么库之间就会存在依赖关系,所以库中也有.GOT表,这也就是为什么库文件也是ELF格式。

当动态链接器加载一个库时,它会像处理主程序一样,为该库创建对应的 vm_area_struct,并处理其 .dynamic段。库中的代码在访问其他库提供的函数或全局数据时,同样需要通过它自己的 GOT 进行间接寻址。因此,整个进程的地址空间实际上是由多个 ELF 模块(主程序 + 所有依赖库)的 GOT/PLT 结构共同编织成的一张完整的动态链接网络。

7. 总结

最后我们再梳理一遍动态链接的整个流程:

内核:你在Shell中输入 ./my_program 后,内核的加载器首先工作。它读取ELF文件头,发现这是一个动态链接的可执行文件(因为包含INTERP 段)。内核为进程创建虚拟地址空间,并将程序本身的代码段(.text)、数据段(.data)等LOAD 段映射到内存中。内核根据INTERP 段指定的路径,将动态链接器(例如 /lib64/ld-linux-x86-64.so.2)这个特殊的共享库加载到内存。内核不执行程序的 _start,而是直接将控制权跳转到动态链接器的入口点。至此,内核的工作基本完成。

加载/链接器:动态链接器开始执行它的核心任务:链接器读取主程序的 .dynamic 段,找到所有直接依赖的共享库(如 libc.so.6)。它将这些库加载到内存的共享区域,并递归地加载这些库所依赖的其他库,直到整个依赖树全部加载完毕。链接器扮演“全局符号表”的角色。当程序或库需要找一个符号(如函数 printf)时,链接器在所有已加载的模块中搜索其定义。找到 printf 的真实内存地址后,链接器会修改程序中所有调用 printf 的地方(这些地方在编译时只是预留的空白或占位符),将它们修正为正确的地址。链接器初始化全局偏移表(GOT)等数据结构,为程序的执行做好准备。所有准备工作完成后,动态链接器跳转到程序的入口点 _start,程序终于开始正式执行。

程序运行与延迟绑定:程序开始运行后,动态链接仍在继续,这就是延迟绑定。当你的代码第一次调用 printf 时,你实际上调用的是 printf@plt(PLT中的一小段桩代码)。printf@plt 的代码会去检查GOT中 printf 对应的条目。GOT中的地址指向的是回到PLT、触发解析流程的指令。这个解析流程会调用动态链接器。动态链接器找到真正的 printf 地址,然后回填到GOT中对应的位置。之后,任何对 printf 的调用,printf@plt 会再次检查GOT。此时GOT中已经存储了 printf 的真实地址,于是代码直接跳转到该地址执行,无需动态链接器再次介入。这个开销非常小,只有一次间接跳转。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值