动态链接
一、为什么需要动态链接
静态链接有缺陷
- 内存占用大。如果是静态库,每个运行的程序都需要使用公共的静态库(例如 glibc.a),那么如果一个静态库 1MB,100 个进程就需要浪费 100MB 的内存
- 磁盘占用大,如果是静态库,每个二进制都包含了公共的静态库(例如 glibc.a),那么如果一个静态库 1MB,系统上 1000 个二进制,就占用 1GB 的磁盘空间
- 如果使用静态库,程序的开发、更新、部署、发布都比较困难。一个模块的改动需要整个二进制重新编译。
动态链接的思想是把链接这个过程推迟到了运行时再进行。有如下好处:
- 解决了共享的目标文件多个副本浪费磁盘和内存空间的问题
- 在内存中共享模块还可以减少物理页面的换入换出,也可以增加 CPU 缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上
- 程序的更新、部署、发布,也只需要更新某一模块即可,当程序再次运行的时候,新版本的目标文件会被自动装载到内存并且链接起来,程序也就完成了升级的目标
- 还可以加强程序的兼容性。一个程序在不同的平台运行时可以动态的链接到由操作系统提供的动态链接库。这些动态链接库相当于在程序和操作系统之间增加一个中间层,从而消除了程序对不同平台之间依赖的差异性。比如:操作系统 A 和 操作系统 B 对于 printf 的实现机制不同,如果是静态链接的程序,那么程序需要分别链接成能够在 A 运行和在 B 运行的两个版本并且分开发布。但是如果是动态链接,只要操作系统 A 和 B 提供一个动态库包含 printf,并且这个 printf 使用相同的接口,那么程序只需要一个版本在两个操作系统上运行。
二、装载时重定位
共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。而可执行文件基本可以确定自己在进程虚拟地址空间中的起始位置,因为可执行文件往往是第一个被加载的文件,他可以选择一个固定空闲的地址,比如 linux 下一般是 0x08040000
。
因此我们想要的是:共享对象可以在任意地址装载。这个想法的基本思路就是,在链接时,对所有绝对地址的引用不做重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。假设函数 foo 相对于代码段的起始地址是 0x100,当模块被装载到 0x1000_0000
时,假设代码段位于模块的最开始,代码段的转载地址为 0x1000_0000
,那么我们就可以确定函数 foo 的地址为 0x1000_0100
。这时候,系统遍历模块中的重定位表,把所有对 foo 的地址引用都重定位至 0x1000_0100
即可。
于是:静态链接又叫做链接时重定位;动态链接被称为装载时重定位。
动态链接的缺点:程序每次被装载时都要重新进行链接,会导致程序在性能上的一些损失。但对于动态链接的的链接过程可以通过 “延迟绑定” 进行优化,使性能损失尽可能的减少。与静态链接相比,动态链接的性能损失大约在 5% 以下,这点性能损失用来换取程序在空间上的节省和程序构建和升级时的灵活性,是相当值的。
动态链接问题产生:动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然,动态链接库中的可修改数据部分对于不同的进程来说有多个副本,所以他们可以采用装载时重定位的方法来解决。
Linux 中如果只使用 GCC 中参数 “-shared”,不使用 “-fPIC”,那么输出的共享对象就是使用装载时重定位的方法。
- 仅仅
-shared
:装载时重定位。代码不是地址无关的,无法被多个进程共享;无法节省内存。实现需要修改指令(代码段内容) -fPIC
:地址无关代码,指令部分保持不变,数据部分在每个进程中拥有一个副本。- 注意:由于地址无关代码都是和硬件平台相关的,不同的平台有着不同的实现,“-fpic” 在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而“-fPIC” 则没有这样的限制。所以请使用 “-fPIC”
- 如何区分一个 so 是否为 PIC。使用:
readelf -d foo.so | grep TEXTREL
。如果此命令有任何输出,那么 foo.so 就不是 PIC 的。PIC 的 so 是不会包含任何代码段重定位表的。TEXTREL 表示代码段重定位表地址。 - 地址无关代码技术不仅可以用在共享对象上,也可以用于可执行文件。一个以地址无关方式编译的可执行文件被称为:地址无关可执行文件(PIE,Position-Independent Executable)。使用 gcc 参数:
-fPIE
。
三、代码段地址无关性
装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是他有一个很大的缺点:指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。
我们的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是 “地址无关代码”(PIC,Position-independent Code) 的技术。
1 | static int a; |
共享对象模型中的地址引用可以划分成四类:
1. 模块内部的函数调用、跳转等
可以是相对地址调用,或者是基于寄存器的相对调用,这种指令是不需要重定位的。
存在全局符号介入问题。也就是一个二进制依赖的两个 so 库中有相同的符号,linux 下的动态链接器是这样处理的:当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
2. 模块内部的数据访问,比如模块中定义的全局变量、静态变量
ELF 文件的数据段和代码段,也就是指令与数据的相对位置是固定的。那么只需要当前指令加上固定的偏移就可以访问模块的内部数据。 ELF 获取 “当前指令地址”(PC值)的方式如下:
1 | 080480db <nomain>: |
- 当处理器执行了 call 指令后,下一条指令的地址会被压到栈顶,而 esp 寄存器就是始终指向栈顶的。这个函数的作用就是把栈顶地址值放到 eax 寄存器中
- 最终通过 栈顶地址+偏移量,即可得到指令与数据的相对位置。从而访问到数据的值
对于模块中的全局变量需要注意,编译器在编译的时候无法根据上下文判断这个全局变量是定义在同一个模块的其他目标文件还是定义在另外一个共享对象之中,也就是说无法判断是否跨模块间的调用。
由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来,因此链接器会在可执行文件的 “.bss” 段创建全局变量的副本。因此出现同一个变量出现在多个位置,ELF 会让所有使用这个变量的指令都指向位于可执行文件中的那个副本。
ELF 共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把 GOT 中的相应地址指向该副本。如果这个全局变量在共享模块中被初始化,那链接器还需要将其值复制过来。
3. 模块外部的数据访问,比如其他模块中定义的全局变量
因为模块间的数据访问目标地址要等到装载时才决定。而我们要做到代码地址无关,基本的思想就是把跟地址有关的部分放到数据段。ELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT)。GOT 是指向这些变量的指针数组。模块在编译的时候可以确定 GOT 相对于当前指令的偏移,然后再根据变量地址在 GOT 中的偏移就可以得到变量的地址。
当指令要访问变量 a 时,程序会先找到 GOT,然后根据 GOT 中变量所对应的项找到变量的目标地址。每个变量都对应一个 4 字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充 GOT 中的各个项,以确保每个指针所指向的地址正确。由于 GOT 本身是放在数据段的,所以他可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。
4. 模块外部的函数调用、跳转等
和模块外部的数据访问方式类似。GOT 中保存函数地址。当模块要调用目标函数时,可以通过 GOT 中的项进行间接跳转。
后面针对延迟绑定的优化会详细说明。
5. 小总结
如上四种代码段地址引用方式在理论上都实现了地址无关性
指令跳转、调用 | 数据访问 | |
---|---|---|
模块内部 | 相对跳转和调用 | 相对地址访问 |
模块外部 | 间接跳转和调用(GOT) | 间接访问(GOT) |
四、共享模块的全局变量问题
有一种情况比较特殊,就是定义在模块内部的全局变量。比如:
1 | # cat module.c |
当编译器编译 module.c
时,他无法根据这个上下文判断 global 是定义在同一个模块的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。
如果和模块内部的静态变量一样处理,那么链接器会在创建可执行文件时,在他的 “.bss” 段创建一个 global 变量的副本;但是如果这个 global 变量定义在其他共享对象中,就会出现同一个变量同时存在于多个位置。这在程序实际运行过程中是不行的。
于是,ELF 共享库在编译时,默认把定义在模块内部的全局变量当作定义在其他模块的全局变量,通过 GOT 来实现变量的访问。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把 GOT 中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。
- 如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本
- 如果该全局变量在程序主模块中没有副本,那么 GOT 中的相应地址就指向模块内部的该变量副本
五、数据段地址无关性
1 | static int a; |
问题:指针 p 的地址就是一个绝对地址,指向变量 a,而变量 a 的地址会随着共享对象的装载地址改变而改变。
- 数据段对于每个进程来说,都是独立的,因此装载时重定位即可解决数据段中绝对地址引用问题
- 对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。
六、延迟绑定
动态链接慢的原因:
- 动态链接对于全局和静态的数据访问、模块间的调用要进行复杂的 GOT 定位,然后间接寻址、跳转。如此一来,程序的运行速度必定会减慢
- 动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都要进行一次链接工作。在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者是一些用户很少用到的功能模块等,如果一开始就把所有函数都链接好实际上是一种浪费。
ELF 的优化,不用一开始就链接共享库的所有函数,采用一种叫做延迟绑定的做法,当函数第一次被用到时才进行绑定(符号查找、重定位等)。这种做法可以大大加快程序的启动速度,特别有利于一些有大量函数引用和大量模块的程序。
我们先举个例子,假如 liba.so 需要调用 libc.so 中的 bar()
函数,那么当 liba.so
第一次调用 bar()
时,需要调用动态链接器中的某个函数来完成地址绑定工作,这个函数就是 _dl_runtime_resolve
,他需要知道这个地址绑定发生在那个模块,那个函数。也就是需要得知:_dl_runtime_resolve(module, function)
。
ELF 增加 PLT(Procedure Linkage Table)表作为中介。每个外部函数在 PLT 中都有一个相应的项。比如 bar()
函数在 PLT 中的项的地址我们称为 bar@plt
1 | bar@plt: |
bar@plt
的第一条指令是一条通过 GOT 间接跳转的指令。bar@GOT
表示 GOT 中保存bar()
这个函数相应的项。- 如果链接器在初始化阶段已经初始化了该项,并且将
bar()
的地址填入该项,那么这个跳转指令的结果就是我们期望的,跳转到bar()
,实现函数的正确调用。 - 但是为了实现延迟绑定,链接器在初始化阶段并没有将
bar()
的地址填入到该项,而是将上面代码的第二条指令push n
的地址填入到bar@GOT
中,这个步骤不需要查找任何符号,所以代价很低。因为第一条指令的效果是跳转到第二条指令,相当于没有进行任何操作。 - 第二条指令
push n
将一个数字 n 压入栈中,这个数字是 bar 这个符号引用在重定位表.rel.plt
中的下标。 - 接着
push moduleID
将模块的 ID 压入到栈中,然后跳转到_dl_runtime_resolve
。 - 相当于我们先将所需要决议的符号的下标压入栈,再将模块 ID 压入栈中,然后调用动态链接器的
_dl_runtime_resolve()
函数来完成符号解析和重定位工作。最终将bar()
的真正地址填入到bar@GOT
中 。 - 一旦
bar()
这个函数被解析完毕,当我们再次调用bar@plt
时,第一条 jmp 指令就能过跳转到真正的bar()
函数中,bar()
函数返回的时候会根据栈里面保存的 EIP 直接返回到调用者,而不会继续执行bar@plt
中第二条指令开始的那段代码,那段代码只会在符号未被解析时执行一次。
ELF 将 GOT 拆分成了两个表叫做:.got
和 .got.plt
。其中 .got
用来保存全局变量引用的地址,.got.plt
用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到了 .got.plt
中。另外,.got.plt
还有一个特殊的地方在于他的前三项是有特殊意义的,含义如下:
- 第一项保存的是
.dynamic
段的地址,这个段描述了本模块动态链接相关的信息 - 第二项保存的是本模块的 ID
- 第三项保存的是
_dl_runtime_resolve()
的地址
其中第二项和第三项由动态链接器在装载共享模块的时候负责将他们初始化。.got.plt
的其余项分别对应每个外部函数的引用。
PLT 在 ELF 文件中以独立的段存放,段名通常叫做 .plt
,因为他本身是一些地址无关的代码,所以可以跟代码段等一起合并成同一个可读、可执行的 Segment
被装载入内存。
七、动态链接相关结构
- 段 “.interp”:保存的是动态链接器的路径
- 段 “.dynamic”:保存了动态链接器所需要的基本信息,比如依赖于那些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等
- 动态符号表 “.dynsym”:只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有 “.dynsym” 和 “.symtab” 两个表,”.symtab” 中往往保存了所有符号,包括 “.dynsym” 中的符号
1. 动态链接重定位表
共享对象需要重定位的主要原因是导入符号的存在。动态链接下,无论是可执行文件或共享对象,一旦他依赖于其他共享对象,也就是说有导入的符号时,那么他的代码或数据中就会有对于导入符号的引用。在编译时这些导入符号的地址未知,在静态链接中,这些未知的地址引用在最终链接时被修正。但是在动态链接中,导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号的引用修正,即需要重定位。
对于动态链接来说,如果一个共享对象不是以 PIC 模式编译的,那么毫无疑问需要在装载时重定位;如果一个共享对象是 PIC 模块编译的,同样的也需要在装载时重定位。
对于使用 PIC 技术的可执行文件和共享对象来说,虽然他们的代码段不需要重定位(因为地址无关),但是数据段还包含了绝对地址的引用。因为代码段中绝对地址相关的部分被分离了出来,变成了 GOT,而 GOT 实际上是数据段的一部分。除了 GOT 以外,数据段还可能包含绝对地址引用(static int a; static int* p = &a;
)。
在静态链接中,目标文件里面包含有专门用于表示重定位信息的重定位表,比如 “.rel.text” 表示的是代码段的重定位表,“.rel.data” 是数据段的重定位表。在动态链接中,也有重定位表:
- “.rel.dyn” 是对数据引用的修正,他所修正的位置位于 “.got” 以及数据段
- 而 “.rel.plt” 是对函数引用的修正,他所修正的位置位于 “.got.plt”
我们使用 readelf 来查看一个动态链接的文件的重定位表:
1 | # readelf -r liba.so |
- 我们看到有几种重定位入口类型:
R_X86_64_RELATIVE
、R_X86_64_GLOB_DAT
、R_X86_64_JUMP_SLOT
。不同的重定位类型表示重定位时有不同的地址计算方法 - 其中
R_X86_64_GLOB_DAT
、R_X86_64_JUMP_SLOT
这两种类型表示,被修正的位置只需要直接填入符号的地址即可。
2. R_X86_64_JUMP_SLOT 类型的重定位
比如我们看 malloc 这个重定位入口,他的类型为 R_X86_64_JUMP_SLOT
,他的偏移地址为 0x0000_0000_4020
。他实际上位于 “.got.plt” 中,因为 “.rel.plt” 就是对函数引用的修正,修正的位置即是 “.got.plt” 中。我们知道,“.got.plt” 的前三项是被系统占据的,从第四项开始才是真正存放导入函数地址的地方。而我们从 “.rela.plt” 表可以得知 printf 应该在第四项,malloc 应该在第五项。我们来看看 “.got.plt” 表中存储的内容。
1 | Disassembly of section .got.plt: |
不会理会这些反汇编,这个表中的内容不是指令,而是数据。第 4 项的地址为:0x4000 + 8*3 = 0x4018
,存储的值为 0x1036(注意是小端)。第 5 项的存储的值是 0x1046。我们来看一下这两个地址分别是什么:
1 | 0000000000001030 <printf@plt>: |
和我们在 “延迟绑定” 哪里说的一样,在装载前,链接器会把 “push n” 的地址填入 func@GOT
。在装载时,再进行重定位。多说一句,jmp QWORD PTR [rip+0x2fe2]
这句代码解释一下,rip 是指令指针寄存器,用来存储 CPU 即将要执行的指令地址。因此 rip+0x2fe2 => 0x1036+0x2fe2 => 0x4018
。同样 malloc 的 4020 地址的算法也是同理。
紧接着,装载时,当动态链接器需要进行重定位时,他先查看 printf 位于 libc-2.2.5.so
中。假设链接器在全局符号表里面找到 printf 的地址为 0x8888_8888
,那么链接器就会将这个地址填入到 “.got.plt” 中偏移为 0x4018
的位置中去,从而实现了地址的重定位,这是动态链接最关键的一个步骤。
类似于 R_X86_64_JUMP_SLOT
是对 “.got.plt” 的重定位。 R_X86_64_GLOB_DAT
类型是对 “.got” 的重定位,他与 R_X86_64_JUMP_SLOT
的原理一模一样。
3. R_X86_64_RELATIVE 的重定位
R_X86_64_RELATIVE
这种类型的重定位,实际上是基址重置。我们来解释下哈,共享对象的数据段是没有办法做到地址无关的,他可能会包含绝对地址的引用,对于这种绝对地址的引用,我们必须在装载时将其重定位。比如,有一个全局指针变量被初始化为一个静态变量的地址:
1 | static int a; |
在编译时,共享对象的地址是从 0 开始的,我们假设该静态变量 a 相对于起始地址 0 的偏移为 B,即 p 的值为 B。一旦共享对象被装载到地址 A,那么实际上该变量 A 的地址为 A+B,即 p 的值需要加上一个装载地址 A。R_X86_64_RELATIVE
类型的重定位就是专门用来重定位指针变量 p 这种类型的,变量 p 在装载时需要加上一个装载地址值 A,才是正确的结果。
八、动态链接的步骤与实现
动态链接分为三步:
- 启动动态链接器本身
- 转载所有需要的共享对象
- 重定位和初始化
动态链接器本身也是一个共享对象,那么动态链接器本身的重定位工作如何完成?
- 动态链接器本身不可以依赖其他任何共享对象,可以人为控制,不使用任何系统库、运行库。
- 动态链接器所需要的全局和静态变量的重定位工作由他本身完成。这部分代码被称为自举。不能使用全局变量和静态变量,也不允许使用函数。这种具有一定限制条件的启动代码往往被称为“自举”。
动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行。自举代码首先会找到他自己的 GOT。而 GOT 的第一个入口保存的是 “.dynamic” 段的偏移地址,由此找到了动态链接器本身的 “.dynamic” 段。通过 “.dynamic” 中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将他们全部重定位。从这一步开始,动态链接器代码才可以开始使用自己的全局变量和静态变量。
动态链接器完成自举之后,将可执行文件和链接器本身的符号表都合并到一个符号表中,也就是全局符号表。全局符号表包含进程中所有的动态链接所需要的符号。
- 对于符号的优先级。当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
- 这里注意,多个共享库的全局符号可能会导致程序存在非期望的逻辑。如果不是一个导出符号,最好加上 static 修饰。
重定位完成之后,如果某个共享对象有 “.init” 段,那么动态链接器会执行 “.init” 段中的代码,用以实现共享对象特有的初始化过程,比如常见的 c++的全局/静态对象的构造就需要通过 “.init” 段来初始化。进程退出时,也会执行 “.finit” 段中的代码,用来实现类似 c++ 全局对象析构之类的操作
链接器的工作至此完成,将进程的控制权转交给程序的入口并且开始执行。
链接器不仅是一个 so 库,同时也是可执行的。动态链接器本身是静态链接的,并且也是 PIC 的。
九、显式运行时链接
显式运行时链接,也叫做运行时加载,就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。
一般的共享对象不需要进行任何修改就可以进行运行时装载,这种共享对象往往被叫做动态装载库。不需要一开始就将他们全部装载进来,从而减少了程序启动时间和内存使用。并且程序可以在运行的时候重新加载某个模块,这样使程序本身不必重新启动而实现模块的增加、删除、更新等。这对于需要长期运行的程序来说是很大的优势。
动态库的装载通过一系列由动态链接器提供的 API,dlopen、dlsym、dlerror、dlclose 四个函数可以帮助我们显示使用动态库。
1. 打开动态库(dlopen)
dlopen 用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程,原型如下:
1 | void* dlopen(const char* filename, int flag); |
第一个参数是加载动态库的路径。
- 如果是绝对路径,会直接尝试打开该动态库
- 如果是相对路径,dlopen 会尝试 1. 环境变量 LD_LIBRARY_PATH 指定的一系列目录;2. 查找由
/etc/ld.so.cache
里面指定的共享库路径;3./lib、/usr/lib
路径 - 如果 filename 是 NULL,那么 dlopen 返回的将是全局符号表中符号的句柄。全局符号表包括了程序的可执行文件本身、被动态链接器加载到进程中的所有共享模块、以及在运行时通过 dlopen 打开并且使用 RTLD_GLOBAL 方式的模块中的符号
第二个参数 flag 表示函数符号的解析方式。
RTLD_LAZY 表示延迟绑定,当函数第一次被用到时才进行绑定。
RTLD_NOW 表示当模块被加载时即完成所有函数的绑定工作,如果有未定义的符号引用的绑定没法完成,那么 dlopen 返回错误
RTLD_GLOBAL:可以和上面两个一起使用(或的关系)。表示被加载的模块的全局符号合并到进程的全局符号表中,使得以后加载的模块可以使用这些符号。
RTLD_LAZY 和 RTLD_NOW 两种绑定方式二选一。
如果被加载的模块之间有依赖关系,比如模块 A 依赖于模块 B,那么程序员必须手工加载被依赖的模块,比如先加载 B,再加载 A。
dlopen 加载模块时也会执行 “.init” 段的代码,dlclose 释放模块时也会执行 “.finit” 段的代码。
2. 找到符号(dlsym)
函数原型:
1 | void* dlsym(void* handle, char* symbol); |
- 第一个参数是由 dlopen 返回的动态库的句柄
- 第二个参数是所要查找的符号的名字,一个以
\0
结尾的 C 字符串。
如果查找的符号是个函数,那么他返回函数的地址;如果是个变量,他返回变量的地址;如果这个符号是个常量,那么他返回是该常量的值。这里有个问题,如果常量的值刚好是 NULL 或者 0 呢?我们如何判断 dlsym 是否找到了该符号呢?
使用 dlerror()
函数,如果找到了 dlerror()
返回 NULL,否则返回相应的错误信息。
3. 符号优先级
装载序列:当多个同名符号冲突时,先装入的符号优先。
实际上,不管是之前由动态链接器装入的还是之后由 dlopen 装入的共享对象,动态链接器在进行符号的解析以及重定位时,都是采用装载序列。
dlsym()
对符号的查找优先级分两种类型:
- 第一种情况,如果我们是在全局符号表中进行符号查找,即 dlopen 时,filename 的参数为 NULL,那么由于全局符号表使用的是装载序列,所以 dlsym 使用的是也是装载序列。
- 第二种情况,如果我们是对某个通过 dlopen 打开的共享对象进行符号查找的话,那么采用依赖序列。即以被
dlopen
打开的那个共享对象为根节点,对他所有依赖的共享对象进行广度优先遍历,直到找到符号为止。
4. 卸载动态库(dlclose)
将一个已经加载的模块卸载。系统会维持一个加载引用计数器。只有计数器值减到 0 时,模块才被真正卸载掉。卸载时先执行 .finit
段的代码,然后将相应的符号从符号表中去除,取消进程空间跟模块的映射关系,然后关闭模块文件。