Shared Libraries In Linux And C++
Linkers, Loaders and Shared Libraries in Linux and C++
这篇文章整理自 《Linkers, Loaders and Shared Libraries in Windows, Linux, and C++》 ,它主要围绕动态库,介绍了 GOT、PLT、PIC、链接器等概念,并对比了它们在 Windows 和 Linux 上的行为。这个演讲不仅能让你对这些概念有一个整体的认知,更重要的是它阐述了这些东西是从何而来,为了解决什么问题的,它们又会带来什么问题。鉴于我主要的工作经验都集中于 Linux,所以我对整体内容做了一些调整,只在最后会进行一些简单对比。
文章中涉及的术语,可能会根据中英文语境不同交替使用。
- 链接器: Linker
- 动态加载器: Loader, Dynamic Loader
- 动态库: (Dynamic) Shared Library, Shared Object, Dynamic Object, Dynamic Shared Object (DSO), Dynamic Load Library (DLL)
- 可执行文件: Binary, Executable, Shared Library
- 符号: Symbol,包括函数和全局变量
- 位置无关代码: Position-Independent Code (PIC)
Introduction
构建可执行文件的过程的第一阶段从源文件开始。编译器将源文件处理成目标文件(object file),而目标文件本质上只是 section 的容器。最典型的两个 section 是包含指令的代码段 .text 和包含程序数据的数据段 .data,除此之外还有许多其他类型的 section。第二阶段中,linker 首先把来自不同目标文件的同名 section 合到一起,拼接成一个更大的 section;然后重新排列这些 section,并使运行时权限要求相近的 section 彼此相邻。最后,可执行文件在运行时,由 dynamic loader 将这些相邻的数据块,也就是 segment,按照 page 对齐的边界映射到内存,并相应地调整各个内存 page 的权限。例如,存放代码的 page 需要读取和执行权限,因此 loader 映射完代码 segment 后,会将其权限设置为可读、可执行。

代码中通常存在大量函数调用。最简单的情况是可执行文件调用自身实现的函数,但实际情况并不总是如此。通常,一个进程可以将动态库映射到自己的地址空间,并调用其中实现的函数;动态库本身也可以调用其他动态库所实现的函数。

调用其他动态库的函数,也是在 loader 的帮助下完成的。要理解整个过程,我们先从重定位说起。
Relocation
假设有一段代码要调用函数 foo,而 foo 实现在另一个可执行文件中。代码段本身并不包含字符串 foo,而是包含一条调用指令,其目标地址在链接时仍然未知。因此,代码段中只能暂时放入一个占位值,例如一串零。

除此之外,linker 还会生成一个名为 .reloc 的 section,用来保存重定位(relocation)信息。重定位本质上是交给 loader 的一项小任务,相当于 linker 告诉 loader:“请在加载这个 binary 时,找到函数 foo,找到后用它的地址覆盖这个占位值。”

这种重定位在实践中的确存在,称为 text relocation,也称为 direct-access relocation。但通常不会使用这种重定位方法,原因有二。第一,修改代码段会使这个 binary 无法在多个进程之间共享。第二,这种重定位需要针对每个调用点分别执行,而不是每个函数只执行一次。如果同一个函数有成千上万个调用点,两者的差异会非常大。
为了减少重定位的开销,常见的方法就是使得同一个函数的多个调用,都指向一个占位符。这样 loader 只需要进行一次解析,并将结果写入到占位符所在位置,就能使所有调用该函数的位置完成重定位。在这种方案中,loader 读取重定位信息并完成符号解析后,只需要将 foo 的地址写入这一个占位符,而不必修改所有调用点。显然,由于所有函数调用,这种设计牺牲了一部分运行时性能,以期大幅节省加载时间。

可执行文件中有一整个 section 用来存放这样的占位符。在 Windows 中,它称为 IAT,即导入地址表(Import Address Table);在 Linux 中,与之对应的是 GOT,即全局偏移表(Global Offset Table)。
Linux Shared Library
Linux 可执行文件通过两个 section 记录相关信息:
.dynamicsection 包含一份原始的动态库名称列表。.dynsymsection 是更为人熟知的符号表,其中不仅包含需要导入的符号,还包含该可执行文件中的所有全局符号。

需要导入的符号在符号表 .dynsym 中被标记为 undefined。链接时它们仍未定义,需要由 loader 找到相应的定义并完成正确绑定。

通过 .dynamic 和 .dynsym,一个 Linux 可执行文件告知 loader:“这些是我希望你加载到进程中的动态库,而这些是我希望你从这些动态库中的任意位置查找的符号。”
需要注意的是,在 Linux 中,.dynsym 符号表中的符号可以由 .dynamic 中的动态库中的任意一个提供。而 loader 会在符号解析时候按照一定顺序来查找这些动态库,直到搜到第一个提供这个符号的动态库。
从积极的一面来看,这可能正是开发者有意需要的行为。例如,你引入了某个第三方动态库,但希望用自己的实现覆盖其中的某个函数。这种机制就是 Interposition。最常见的使用 Interposition 的例子就是通过链接各种 malloc 库,比如 jemalloc, tcmalloc 等来覆盖默认 malloc。
Interposition
Interposition 是指从一个可执行文件中覆盖另一个可执行文件内符号的能力,它是 Linux 执行模型的基石之一。
我们前面提到,在符号解析时,loader 会按照一定顺序来查找这些动态库。具体来说,Linux 中动态库的搜索顺序是广度优先。假设有一个可执行程序加载了 lib1 和 lib2,而它们又分别加载了 lib3、lib4 和 lib5。现在假设 lib5 想要使用符号 foo。loader 搜索符号 foo 的顺序是:首先搜索可执行程序,然后依次搜索 lib1、lib2、lib3 和 lib4,最后才搜索 lib5。即使 lib5 自己实现了 foo,在 loader 搜索到 lib5 之前,其他所有可执行文件都有机会 interpose foo。尤其需要注意的是,可执行程序的搜索优先级高于当前动态库。

这是默认行为,但可以通过链接的选项进行调整。最直接的方式是在链接 lib5 时使用 linker 选项 -Bsymbolic。-Bsymbolic 告诉 linker:解析 lib5 中的未定义符号时,应当先在 lib5 内部查找,然后再按照通常的广度优先顺序继续搜索。
这里还需要介绍一下 LD_PRELOAD。LD_PRELOAD 是一个环境变量;如果它存在并包含一组动态库名称,loader 会在加载可执行程序之后、加载任何依赖库之前加载这些动态库。它们也会在这个位置加入广度优先搜索顺序。
接下来我们再看一下,什么时候会进行符号解析。这里仍然使用上面的依赖关系树,假设 lib5 想要导入符号 foo,而可执行程序想要导入符号 bar。

可执行程序的符号解析会在链接时接受检查。如果 linker 无法解析 bar,就会拒绝链接该可执行程序。
但动态库并非如此:即使 linker 找不到 foo,仍然会正常链接 lib5。因为在 linker 看来,foo 的实现可能位于可执行程序中,而链接动态库时它没有机会发现这个实现。
默认行为由 linker 选项 --allow-shlib-undefined 控制。最简单的干预方式是在链接可执行程序时使用 --no-allow-shlib-undefined,此外也有其他可以应用于动态库的选项,比如 -z defs 以及 --no-undefined。
Position-Independent Code
还有一个问题是,作为一个动态库,我们自然希望能被不同的可执行程序加载。而同一个动态库被多次加载时,其基地址是不同的。那么该如何保证,所有加载这个动态库的进程都能正确调用动态库的相关方法呢?
答案就是 Position-Independent Code,也就是位置无关代码。所有动态库必须都是位置无关的,而位置无关就是通过前面提到的 GOT 而达成的。如果一个动态库是由没有指定 fPIC 的 object 文件构造出来的,那么在链接这个动态库的时候就会见到这个报错。
error: relocation R_X86_64_PC32 against symbol `global' can not be
used when making a shared object; recompile with -fPIC
通常一个 binary 中可能看到如下三种调用形式:

-
调用一个固定的硬编码地址并不具备位置无关性。显然,如果整个可执行文件被加载到另一个地址,写死在代码中的调用目标就会失效。
-
另一种形式是 PC-relative call。在这种调用中,目标地址相对于指令寄存器计算,因此它是位置无关的;但这种方式不支持 interposition,因为 loader 没有可以介入并替换函数实现的位置。因此,它只用于 hidden symbol,后文还会进一步讨论。
-
实现位置无关代码的默认机制是再增加一层间接寻址:通过地址表中的一个固定偏移间接完成调用。这张表就是全局偏移表(Global Offset Table,GOT)。如果整个可执行文件发生重定位,GOT 中的地址确实需要改变。因此,整个可执行文件并不是位置无关的,但代码段是位置无关的。除非使用一些非常规选项,否则这就是动态库中的默认实现方式。
如何理解“整个可执行文件并不是位置无关的,但代码段是位置无关”?
PIC 把需要修改的绝对地址集中放到 GOT 这类数据区,使代码段本身不必被修改。也就是说,在 PIC 中,由于函数调用变成了通过 GOT 的间接调用。.text 代码段本身可以不依赖加载地址,但整个动态库中仍然有一些内容(例如 GOT)需要根据加载地址进行修改。当同一个动态库被不同的可执行程序加载时,其中的 GOT 显然需要随之改变。即 dynamic loader 在加载动态库时,会对 GOT 进行 relocation。
到这我们总结一下前半部分,dynamic loader 在加载动态库的时候,会完成以下工作:
- 根据
.dynamic得到其依赖的其他动态库 - 根据
.dynsym符号表需要导入符号,按照广度优先的顺序进行查找这些动态库,找到第一个提供该符号的动态库 - 根据当前加载的动态库的位置,即基地址偏移,修改 GOT 中的地址,保证动态库是位置无关的,这样其他可执行文件或者动态库就能调用当前动态库的方法了。
这不代表 loader 就完美无缺了,想象一下,如果一个动态库需要导入成千上万个符号,那么在加载这个动态库的时候,其开销不可忽视的。但当我们去使用一些比较大的可执行文件时,比如 gcc --version 时,却发现执行的很快,这就涉及到下面要讨论的内容:延迟绑定。
Lazy Binding
Lazy binding 将符号解析推迟到函数第一次被调用时,这样做的原因是:一个大型可执行文件可能包含许多符号,但某次运行实际只会用到其中少数几个。如果预先解析全部符号,大量解析工作都会被浪费,并可能造成明显的加载延迟。因此,ELF 的设计者提出了一种机制:只在某个符号第一次真正被使用时解析它。
Linux 默认使用 lazy binding。如果不进行干预,Linux 中的符号会采用延迟绑定。可以通过编译器选项、作用于单个函数的 attribute,或者环境变量改变这一行为。
这部分在之前介绍动态库加载有过详细分析,这里就简单过一下流程。
为了理解 lazy binding,可以回顾前面介绍的 Linux 函数调用模型。采用 lazy binding 时,GOT 占位符中的地址会在运行时发生改变。最初,这个地址会将执行流程引导至真正执行符号解析的代码;解析完成后,该代码再用解析得到的 foo 地址覆盖原来的占位符。
// todo
- 第一次调用
这里的函数调用实际上并不是直接对 GOT 槽位进行间接调用,而是直接调用一个过程链接桩(procedure linkage stub,即图中的 PLTn)。
这个桩的第一条指令会通过 GOT 槽位执行一次间接跳转。最初,该槽位中保存的是执行符号解析代码的地址;巧妙的是,这个地址恰好就是桩中的下一条指令。

第一次调用时,间接跳转会跳到桩中的下一条指令。这条指令为 resolver 准备参数,然后调用 resolver(即图中的 _dl_runtime_resolve)。resolver 是 loader 的一部分;loader 属于另一个可执行文件,但同样被加载到了当前进程的地址空间中。resolver 找到目标函数后,会用得到的地址覆盖 GOT 槽位,随后将执行流程交给 foo 本身。
此时 GOT 中对应的条目已经更新。

- 第二次调用
第一次调用之后,流程就简单得多了:桩中第一条指令执行的间接跳转会直接跳到 foo。
函数指针比较
- C++ 标准 [expr.eq]§3.2:“……如果两个指针均为空、均指向同一个函数,或者均表示同一个地址(6.8.2),则二者比较结果相等。”
实际的函数调用会进入 PLT 槽位,而每个可执行文件都有各自的 PLT。来自不同可执行文件的调用可能经过不同的 PLT,那么不同可执行文件中的函数指针又如何保证比较结果相等呢?
为了实现这一点,Linux 做了大量额外工作。
我们分成两种情况来看:
- 可执行文件获取其他动态库定义的函数的指针
- 动态库获取可执行文件定义的函数的指针
第一种:
假设一个可执行程序和一个动态库都引用了 foo,并且动态库取得了它的地址。实际上,可执行程序的符号表会将一个地址保存为符号 foo 的值。当 loader 需要解析 foo 的地址时,会从可执行程序的符号表中取得这个值。这样一来,可执行程序和 lib1 得到的 foo 地址就会相同;这个地址也是有效的函数指针,调用它最终会执行 foo。

Loader 使用可执行程序的 dynsym 条目解析
lib1中的&foo。 即使foo尚未被调用,p1和p2也都会变成foo@plt。 Lazy binding 仍然可以在之后发生。
第二种:

可执行程序中获取的 &foo 就是 foo 的真实地址,而动态库 lib1.so 被加载时,dynamic loader 会解析其中的未定义符号,并按照前面介绍的符号解析顺序找到可执行程序中的 foo。
符号可见性
可执行文件中有一张包含所有全局符号的符号表,这些符号都有可能对其他可执行文件可见。符号也可以被标记为 undefined,从而要求 loader 导入它们。
除此之外,还有另一个控制可见性的维度:显式的 visibility attribute。符号可以具有 default visibility、protected visibility 或 hidden visibility。还有一种 internal visibility,但如今它实际上不会产生任何效果。
之所以需要关注 visibility,是因为只有 hidden symbol 才会使用 PC-relative call,而具有 default visibility 的符号需要经过更长的 PLT/GOT 路径。

具有 default visibility 的符号可以被其他可执行文件使用,可以被 interpose,并且会出现在 GOT 和符号表中。hidden symbol 则不具备上述任何特性。protected symbol 与 default symbol 类似,区别在于它不会被 interpose。

默认情况下,所有符号都会经过较长的 PLT/GOT 路径。
GCC 的 man page 指出,使用 -fvisibility=hidden 将所有符号默认设为 hidden,可以显著改善动态库的链接和加载时间、生成优化程度更高的代码、实现近乎完美的 API 导出,并避免符号冲突。对于任何需要分发的 shared object,GCC 都强烈建议使用该选项。
这几乎等同于官方承认:“我们的默认设置错了。”
Linux 与 Windows 的区别
todo: 加一点过渡

可以从可执行程序中覆盖动态库的符号吗?
- Linux:可以。
- Windows:不可以。
如何创建进程级的 singleton?
- Windows:常规的 singleton 设计模式会为每个可执行文件分别创建一个 singleton。需要从动态库中导出 singleton 变量,并将所有可执行文件链接到这个动态库。
- Linux:直接将它放在可执行程序中即可。
动态库之间可以存在循环依赖吗?
- Linux:可以。
- Windows:不可以。
默认使用 lazy binding 吗?
- Linux:是。
- Windows:否。
ld仍然默认使用–z lazy。但许多发行版(Debian、Ubuntu、Fedora)会将编译器配置为使用–z relro构建,其中 relro 表示“relocate + read only”。 todo 解释–z relro以及-z now
总结
Interposition 需要 GOT
Lazy Binding 需要 PLT
todo: explain why
参考资料
Linkers, Loaders and Shared Libraries in Windows, Linux, and C++ - Ofek Shilon - CppCon 2023