动态链接最佳实践(2021)

Lobsters Hottest 新闻

摘要

本文介绍了在UNIX-based系统中动态链接和共享库的最佳实践,涵盖版本管理、链接器、加载器以及可移植实现策略。

<p><a href="https://lobste.rs/s/nkzapz/dynamic_linking_best_practices_2021">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/09/04 14:17

# 动态链接最佳实践 | begriffs.com 来源: https://begriffs.com/posts/2021-07-04-shared-libraries.html - 代码 (https://begriffs.com/code) - 订阅源 (https://begriffs.com/atom.xml) - [电子邮件](mailto:[email protected]) --- ## 动态链接最佳实践 2021\-07\-04 在本文中,我们将学习如何构建共享库并在多个平台上正确安装它们。作为指南,我们将探讨基于UNIX操作系统的动态链接的目标与历史。 本文内容源自对创建共享库方法的研究、梳理网络上推荐的不严谨惯例,以及在多个类Unix系统上的测试。希望它能正本清源,助力提升开源库的质量。 - [常见的UNIX模式](#the-common-unix-pattern) - [版本控制](#versioning) - [版本标识符](#version-identifiers) - [API vs ABI](#api-vs-abi) - [链接器和加载器的系统差异](#variance-of-linker-and-loader-by-system) - [链接器 (ld, lld)](#linkers-ld-lld) - [加载器 (ld\.so, dyld)](#loaders-ld.so-dyld) - [可移植性最佳实践](#portable-best-practices) - [链接](#linking) - [加载](#loading) - [示例代码](#example-code) ### 常见的UNIX模式 当今动态链接(用于BSD、MacOS和Linux)的典型设计源自1988年的SunOS。论文《Shared Libraries in SunOS》(https://www.cs.cornell.edu/courses/cs414/2001FA/sharedlib.pdf) 清晰地阐述了其目标、设计和实现。 作者的主要动机是节省磁盘和内存空间,以及在升级库(或操作系统)时无需重新链接程序。在当今强大的个人计算机上,资源使用的动机可能不如1988年时重要。然而,升级库的灵活性一如既往地有用,同样易于检查每个应用程序使用了哪些库版本。 动态链接并非没有批评者,也不适用于所有情况。由于位置无关代码和延迟加载,它的运行速度稍慢。(SunOS论文称之为“经典的空间与时间权衡”)某些系统上加载器的复杂性增加了攻击面 (http://www.nth-dimension.org.uk/pub/BTL.pdf)。最后,升级的库可能对某些程序的影响与对其他程序的影响不同,例如破坏那些依赖未公开行为的程序。 #### 链接编辑器与加载器 在编译时,链接编辑器解析指定库中的符号,并在生成的二进制文件中记录需要加载这些库的信息。在运行时,应用程序调用代码以在正确的内存地址将共享库符号映射到内存中。 SunOS及后续的类Unix系统为链接器添加了编译时标志,用于生成或链接动态链接库。设计者还添加了一个特殊的系统库(ld\.so),其中包含为应用程序查找和加载其他库的代码。程序在调用`main()`之前的初始化例程会加载ld\.so,并在程序内部运行它,以查找和加载其余所需的库。 ### 版本控制 如前所述,应用程序可以利用更新后的库而无需重新编译。库更新可分为三类: 1. 当前接口的实现改进。错误修复、性能优化。(补丁版本) 2. 新功能,接口扩展。(次版本) 3. 与接口或其操作的向后不兼容更改。(主版本) 针对某个主版本链接的应用程序,在加载任何更新的次版本或补丁版本时,应能继续正常工作。在加载不同的主版本,或比链接时使用的次版本更早的次版本时,应用程序可能无法正常工作。 一台机器上可以同时存在多个应用程序,每个应用程序可能需要单个库的不同版本。系统应提供一种存储多个库版本并为每个应用程序加载正确版本的方式。不同的系统有不同的方法,我们稍后将看到。 #### 版本标识符 每个库版本都可以用版本标识符(或“版本”)标记,旨在捕捉库发布历史的信息。将发布历史映射到版本标识符有多种方式。 两种最常见的映射系统是*语义版本控制*和*libtool版本控制*。语义版本控制统计已发生的各类发布数量,并按词典顺序书写。Libtool版本控制则统计不同的库接口数量。 语义版本控制写作`主版本.次版本.补丁`,libtool写作`当前版本:修订版本:兼容版本`。其直观理解是,`当前版本`统计接口变化。无论接口发生微小还是重大的更改,`当前版本`都会增加。以下是两个系统记录相同发布事件历史的方式: | 事件 | 语义版本 | Libtool | | :----- | :------- | :------- | | 初始 | 1\.0\.0 | 1:0:0 | | 次版本 | 1\.1\.0 | 2:0:1 | | 次版本 | 1\.2\.0 | 3:0:2 | | 补丁 | 1\.2\.1 | 3:1:2 | | 主版本 | 2\.0\.0 | 4:0:0 | | 补丁 | 2\.0\.1 | 4:1:0 | | 补丁 | 2\.0\.2 | 4:2:0 | | 次版本 | 2\.1\.0 | 5:0:1 | 应用程序如何回答这个问题,**“我能加载给定的库吗?”** | 语义版本 | 要加载的库是否与链接的库具有相同的主版本,且次版本号至少一样大? | | :------- | :------------------------------------------------------------------------------------------- | | Libtool | 链接的库的`当前版本`接口号是否在要加载的库的`当前版本 - 兼容版本`和`当前版本`之间? | 本指南将使用语义版本控制,因为libtool版本控制仅与libtool相关,而libtool是一个跨平台抽象库创建的工具。我相信我们可以不借助libtool构建可移植的库。我提到这两种系统只是为了说明构建版本标识符不止一种方法。 最后一点:版本标识符说明事物*已经*改变,但省略了*什么*改变了。存在更复杂的系统来跟踪库兼容性。例如,Solaris开发了一个名为符号版本控制的系统。符号版本控制以操作复杂性为代价来追求空间节省,我们稍后将考虑它。 #### API vs ABI 版本控制的一个微妙之处在于,更改可能发生在库的*编程*接口或*二进制*接口中。C库的编程接口通过其头文件定义。向后不兼容的API更改意味着为前一版本编写的程序在包含新版本的头文件时将无法编译。 相比之下,二进制接口是运行时概念。它涉及函数的调用约定,或程序与库之间共享数据的内存布局(及含义)。ABI确保加载时和运行时的兼容性,而API确保编译时和链接时的兼容性。 这两个接口通常会同时变化,人们有时会混淆它们。但一个改变而另一个不变也是可能的。 **打破ABI但保持API稳定性的示例:** 在这些库更改中,应用程序代码不需要修改,但需要使用新的库头文件重新编译才能在运行时工作。 - `#define`常量背后的数值发生了更改。在更改前编译的程序会向库传递错误的值。 - 结构体中的元素顺序被重新排列。程序和库会读取内存中不同的偏移量,却以为它们引用的是同一个元素,这绝对是ABI破坏。即使在其他元素*之后*添加一个元素也会影响结构体的大小,从而影响其在数组中的布局。在末尾添加字段可能会影响也可能不会影响特定库的ABI。 - 函数参数变宽。例如,在架构/编译器中`short int`和`long int`大小不同时,将`short int`参数改为`long int`。需要重新编译来处理如符号扩展或下一个参数的偏移量。 - 其他语言(包括C\+\+)有更多意外ABI破坏的可能性。 **保持ABI稳定性但破坏API的示例:** 在这些库更改中,应用程序代码需要修改才能成功针对新库编译,即使在更改前编译的代码可以加载和调用库而没有问题。 - 将参数从`const foo \*`更改为`foo \*`。指向const对象的指针不能隐式转换为指向非const对象的指针。但ABI并不关心,它会移动相同的字节。(当然,如果库确实修改了解引用的值,对应用程序来说可能是个不愉快的意外。) - 更改结构体元素的名称,同时保持其含义和相对于其他元素的位置不变。 通常很容易判断你是在添加功能还是破坏了向后兼容性,但有工具可以确定。例如,ABI合规检查器 (https://lvc.github.io/abi-compliance-checker/) 可以检测C和C\+\+库的破坏。 鉴于前面的版本控制讨论,版本标识符应该描述哪些变化?至少,是ABI。当加载器搜索库时,ABI决定了该库在运行时是否兼容。然而,我认为采用更保守的版本方案是明智的,即在API或ABI发生变化时都进行版本号递增。最终安装的库版本可能会更多,但每个共享的API/ABI版本将在编译时和运行时都提供保证。 ### 链接器和加载器的系统差异 #### 链接器 (ld, lld) 编译目标文件后,编译器前端(gcc, clang, cc, c99)将调用链接器(ld, lld)来解析未定义的符号,并在目标文件或共享库之间匹配它们。链接器仅搜索前端请求的共享库,并按照命令行指定的顺序。如果在列出的库中找到未定义的符号,链接器会在生成的可执行文件中标记对该库的依赖。 `\-l`选项将一个库添加到符号搜索的候选列表中。要添加`libfoo\.so`(或Mac上的`libfoo\.dylib`),指定`\-lfoo`。链接器在其搜索路径中查找库文件。要向默认搜索路径添加目录,请使用`\-L`,例如`\-L/usr/local/lib`。 如果同一目录中存在多个版本的库会怎样?例如两个主版本`libfoo\.so\.1`和`libfoo\.so\.2`?OpenBSD知道版本号,会为`\-lfoo`自动选择最高版本。Linux和Mac则无法匹配,因为它们正在寻找`libfoo\.so`(或`libfoo\.dylib`)的精确匹配。同样,如果同一目录中同时存在静态库和动态库`libfoo\.a`和`libfoo\.so`会怎样?所有系统都会选择动态库。 需要更精细的控制。GCC有一个冒号选项来解决这个问题,例如`\-l:libfoo\.so\.1`。然而clang没有,因此真正可移植的构建不应依赖它。某些系统通过创建从`libfoo\.so`到所需特定库的符号链接来解决此问题。但是当在系统位置(如`/usr/local/lib`)这样做时,它为整个系统指定了一个单一的、不灵活的链接时版本。我稍后会建议一种不同的解决方案,涉及将链接时文件与加载时库分开存储。 #### 加载器 (ld\.so, dyld) 在启动时,具有动态库依赖关系的程序会加载并运行ld\.so(或Mac上的dyld)以查找和加载其余的依赖项。加载库检查DT\_NEEDED ELF标签(或Mac上Mach\-O中的LOAD\_DYLIB名称)以确定需要在系统上查找哪个库文件名。有趣的是,这些值并非由程序开发者指定,而是由库开发者指定。它们在链接时从库本身提取。 动态库包含一个内部“运行时名称”,在ELF中称为SONAME,在Mach\-O中称为install\_name。应用程序可能链接到一个名为`libfoo\.so`的文件,但库的SONAME可以说:“在加载时,按文件名libfoo\.so\.1\.2查找我。”加载器只关心文件名,从不查阅SONAME。相反,链接器的输出只关心SONAME,而不关心输入库文件名。 不同操作系统中的加载器查找依赖库的方式略有不同。OpenBSD的ld\.so非常忠实于SunOS模型,并且理解语义版本 (https://www.openbsd.org/faq/ports/specialtopics.html#SharedLibs)。例如,如果要求加载libfoo\.so\.1\.2,它将尝试查找具有最大x ≥ 2的libfoo\.so\.1\.x。FreeBSD也声称具有此行为 (https://docs.freebsd.org/en/books/developers-handbook/policies/#policies-shlib),但我在测试中未观察到 (https://github.com/begriffs/test-ld.so)。 1995年,Solaris 2\.5创建了一种在符号级别跟踪语义版本的方式,而不是为整个库跟踪。通过符号版本控制,会有一个例如libfoo\.so的单一文件,它随着时间增长。其中的每个函数都用版本号标记。同名函数甚至可以存在多个版本,具有不同的实现。 符号版本控制的优点是可以节省空间。作为替代,按库而非按符号进行版本控制时,很大一部分目标代码通常未经修改地从一个库版本复制到下一个库版本。符号版本控制的缺点是: 1. 更难确切看到系统上安装了哪些版本。版本隐藏在库内部,而不是在文件名中可见。 2. 库开发者必须为链接器维护单独的符号映射文件。 符号版本控制很快进入了Linux,并成为Glibc的支柱。由于Linux偏好符号版本控制,其ld\.so并未努力与最新的次版本库(如SunOS或OpenBSD)会合。Ld\.so在SONAME和文件名之间进行精确匹配搜索。 然而,即使在Linux上,大多数库也不使用符号版本控制。此外,它们的SONAME通常只记录主版本(如libfoo\.so\.2)。在那个主版本内,你只能希望隐藏的次版本对于系统上编译或安装的所有应用程序来说足够新。如果应用程序依赖于较新的次版本库中添加的函数,它在尝试调用时会崩溃。(设置环境变量`LD\_BIND\_NOW=1`将尝试在程序启动时解析所有符号,从而预先检测到失败。) MacOS使用完全不同的对象格式(Mach\-O而非ELF)和名称不同的加载器库(dyld而非ld\.so)。Mac的动态链接库命名为`.dylib`,其版本号位于扩展名之前。 原生Mac应用程序通常安装在自己的专用目录中,库捆绑在其中。因此加载器有特殊规定来查找库,例如`install\_name`中的关键字`@executable\_path`、`@loader\_path`和`@rpath`。MacOS也支持系统库,dyld会查阅`DYLD\_FALLBACK\_LIBRARY\_PATH`,默认为`$(HOME)/lib:/usr/local/lib:/lib:/usr/lib`。 像Li

相似文章

在dsymutil中采用并行DWARF链接器

Hacker News Top

苹果的dsymutil工具用于将DWARF调试信息链接到自包含的捆绑包中,现在正在采用并行DWARF链接器来解决类型去重中的单线程瓶颈,尽管由于输出并非二进制完全相同而在验证方面面临挑战。

Red编程语言:静态链接支持

Hacker News Top

Red编程语言宣布支持对C库的静态链接,从而可以分发单个自包含的可执行文件。该功能已集成到现有工具链中,只需一个简单的命令行标志即可使用。

关于C扩展、可移植性和替代编译器

Lobsters Hottest

本文讨论了编写可移植C代码的实际挑战,这些挑战源于对非标准编译器扩展和glibc条件头文件的依赖,并通过构建C编译器的示例进行说明。

LD_DEBUG环境变量 (2012)

Hacker News Top

解释如何在Linux上使用LD_DEBUG环境变量来调试共享库加载问题,包含示例和相关工具。