C-C---高级编译教程-二-

175 阅读42分钟

C/C++ 高级编译教程(二)

原文:Advanced C and C++ Compiling

协议:CC BY-NC-SA 4.0

五、使用静态库

Abstract

在这一章中,我将回顾处理静态库的典型生命周期。我将从创建静态库的简单指南开始,然后我将提供典型用例场景的概述,最后我将仔细研究某些专家级的设计技巧和诀窍。

在这一章中,我将回顾处理静态库的典型生命周期。我将从创建静态库的简单指南开始,然后我将提供典型用例场景的概述,最后我将仔细研究某些专家级的设计技巧和诀窍。

创建静态库

当编译器从源文件集中创建的目标文件被捆绑在一起成为单个归档文件时,静态库被创建。这个任务是由一个叫做归档器的工具来执行的。

创建 Linux 静态库

在 Linux 上,归档工具(简称 ar)是 GCC 工具链的一部分。下面的简单示例演示了从两个源文件创建静态库的过程:

$ gcc -c first.c second.c

$ ar rcs libstaticlib.a first.o second.o

按照 Linux 惯例,静态库名称以前缀 lib 开头,文件扩展名为. a。

除了执行将目标文件绑定到档案(静态库)的基本任务外,ar还可以执行几个额外的任务:

  • 从库中删除一个或多个目标文件。
  • 替换库中的一个或多个目标文件。
  • 从库中提取一个或多个目标文件。

支持功能的完整列表可以在ar工具手册页( http://linux.die.net/man/1/ar )中找到。

创建 Windows 静态库

在 Windows 上创建静态库的任务与在 Linux 上执行的相同任务没有实质性的不同。尽管它可以从命令行完成,但事实是,在大多数情况下,创建静态库的任务是通过创建一个专用的 Visual Studio(或其他类似的 IDE 工具)项目来执行的,该项目带有构建静态库的选项。当检查项目命令行时,您可以在图 5-1 中看到,该任务本质上归结为使用一个归档工具(尽管是 Windows 版本)。

A978-1-4302-6668-6_5_Fig1_HTML.jpg

图 5-1。

Creating a Win32 static library

使用静态库

静态库用于构建可执行文件或动态库的项目的链接阶段。静态库的名称通常与需要链接的目标文件列表一起传递给链接器。如果项目还链接到动态库中,则它们的名称是同一链接器输入参数列表的一部分。

推荐的用例场景

静态库是二进制共享代码的最基本方式,在动态库发明之前很久就已经存在了。同时,更复杂的动态库范例已经接管了二进制代码共享的领域。然而,在一些场景中,静态库的使用仍然是有意义的。

静态库非常适合于实现各种算法(主要是专有算法)核心的所有场景,从搜索和排序等基本算法到非常复杂的科学或数学算法。以下因素可以为决定使用静态库作为交付代码的形式提供额外的推动力:

  • 整个代码架构可以更好地描述为“各种能力的广泛集合”,而不是“具有严格定义的接口的模块”
  • 实际的计算不依赖于特定的操作系统资源(如图形卡的设备驱动程序,或高优先级系统定时器等)。)这需要加载动态库。
  • 最终用户想使用你的代码,但不一定想和其他人分享。
  • 代码部署需求表明需要整体部署(即交付给客户端机器的二进制文件总数很少)。

使用静态库总是意味着对代码更严格的控制,尽管代价是灵活性降低。模块化通常会降低,新代码版本的出现通常意味着重新编译使用它的每个应用程序。

在多媒体领域,信号处理(分析、编码、解码、DSP)例程通常以静态库的形式交付。另一方面,它们与多媒体框架(DirectX、GStreamer、OpenMAX)的集成是以动态库的形式实现的,这些动态库链接到与算法相关的静态库中。在该方案中,与框架通信的简单且严格的任务被委托给动态库部分的薄壳,而信号处理的复杂性属于静态库部分。

静态库提示和技巧

下一节涵盖了与使用静态库相关的重要提示和技巧列表。

失去符号可见性和唯一性的可能性

链接器将静态库部分和符号集成到客户端二进制文件中的方式非常简单明了。当链接到客户机二进制文件时,静态库部分与来自客户机二进制文件的本地对象文件部分无缝地结合在一起。静态库符号成为客户端二进制符号列表的一部分,并保留其原始可见性;静态库的全局符号成为客户端二进制文件的全局符号,静态库的局部符号成为客户端二进制文件的局部符号。

当客户端二进制文件是动态库(即不是应用程序)时,这些简单明了的集成规则的结果可能会受到其他动态库设计规则的影响。

转折在哪里?

动态库概念中隐含的假设是模块化。将动态库想象成一个模块是没有错的,它被设计成在需要出现时可以被容易地替换。为了正确地实现模块化概念,动态库代码通常围绕接口构造,该接口是将模块的功能暴露给外部世界的一组函数,而动态库的内部通常远离库用户的窥探。

幸运的是,静态库通常被设计成提供动态库的“心脏和灵魂”。不管静态库对其宿主动态库的整体功能的贡献有多宝贵,设计动态库的规则规定它们应该仅导出(即,使可见)库与外部世界通信所需的最低限度。

作为这种设计规则的直接结果(正如您将在下面的章节中看到的),静态库符号的可见性最终被抑制了。静态库符号不是保持全局可见(它们在链接完成后立即可见),而是立即降级为私有库符号,或者甚至可能被去除(即,从动态库符号列表中完全消除)。

另一方面,一个特殊但非常重要的细节是动态库对它们的本地符号享有完全的自主权。事实上,几个动态库可以被加载到同一个进程中,每个动态库都具有与其他动态库的局部符号同名的局部符号。然而链接器设法避免任何命名冲突。

允许同名符号的多个实例存在可能会导致许多不希望的后果。一个场景被称为单例类悖论的多个实例,这将在第十章中更详细地说明。

违反指示的用例场景

假设您有一段提供特定功能的代码,您必须决定是否以静态库的形式封装它。下面是一些典型的场景,在这些场景中,静态库的情况是相反的:

  • 当链接静态库需要链接几个动态库(可能除了libc)时,那么静态库可能不应该被使用,而匹配的动态库选项应该被偏爱。

匹配的动态库选项可能意味着下列之一:或或

  • 可用的静态库应该被分解成目标文件,这些文件(除了极少数情况)可能会在构建动态库的构建项目中使用。

  • 应该重新构建库源代码(如果可用)来创建动态库。

or

  • 应该使用同一库的现有动态库版本。

or

这与有特殊需求(特殊饮食习惯或特殊医疗/环境条件要求)的人在访问朋友居住的城镇时决定住在朋友家的情况完全类似。为了满足客人的特殊需求,他需要重新安排自己的日常生活,以便额外跑一趟特色食品店,或者提供一些他自己在日常生活中并不真正需要的特殊条件。让来访者扮演一个更独立的角色更有意义,比如得到一个旅馆房间或者为他的特殊需求安排支持;一旦他自己的推荐信解决了,就和他要去的城市的朋友联系。

  • 如果您实现的功能需要一个类的单个实例(单例模式),遵循良好的动态库设计实践将最终导致强烈建议将您的代码封装在动态而不是静态库中。这背后的理由在前一段已经解释过了。这种场景的一个很好的真实例子是日志记录实用程序的设计。它通常具有对各种功能模块可见的类的单个实例,专门用于序列化所有可能的日志语句并将日志流发送到记录介质(标准输出、硬盘或网络文件等)。).如果功能模块实现为动态库,强烈建议将 logger 类托管在另一个动态库中。

链接静态库的特定规则

在 Linux 中链接静态库遵循以下规则:

  • 链接静态库是按顺序进行的,一个静态库一个静态库地链接。
  • 链接静态库从传递给链接器(从命令行或通过 makefile)的静态库列表中的最后一个静态库开始,并向后朝着列表中的第一个库进行。
  • 链接器详细搜索静态库,在静态库中包含的所有目标文件中,它只链接包含客户端二进制文件真正需要的符号的目标文件。

由于这些特定的规则,有时需要在传递给链接器的同一静态库列表上多次指定同一静态库。当一个静态库提供几组不相关的功能时,发生这种情况的可能性会增加。

将静态库转换为动态库

静态库可以相当简单地转换成动态库。你只需要做以下事情:

  • 使用 archiver (ar)工具从库中提取所有的目标文件,就像$ ar -x <static library>.a一样,这会将从静态库中提取的目标文件收集到当前文件夹中。在 Windows 上,您可以使用通过 Visual Studio 控制台提供的lib.exe工具。基于 MSDN 在线文档( http://support.microsoft.com/kb/31339 ),可以提取至少一个目标文件(首先需要列出静态库内容,这也可以通过使用lib.exe工具来实现)。
  • 从提取的目标文件集到链接器构建动态库。

这个食谱几乎在所有情况下都有效。接下来介绍必须满足附加要求的特殊情况。

64 位 Linux 上的静态库问题

在 64 位 Linux 上使用静态库会带来一个有趣的极端情况。以下是概要:

  • 将静态库链接到可执行文件与在 32 位 Linux 上做同样的事情没有区别。
  • 然而,将静态库链接到共享库中需要使用-fPIC编译器标志(由编译器的错误打印输出建议)或-mcmodel=large编译器标志来构建静态库。

这是一个非常有趣的场景。

首先,在静态库的上下文中仅仅提到-fPIC编译器标志可能会有点混乱。正如我将在下一章讨论动态库时所讨论的,使用-fPIC标志传统上与构建动态库联系在一起。

人们普遍认为,将-fPIC标志传递给编译器是动态库严格要求的两个关键要求之一,但编译静态库从来不需要。在静态库的上下文中提到-fPIC编译器标志有点令人震惊。

事实上,这种信念并不完全正确,但它是相当安全的。事实是,-fPIC标志的使用并不是静态或动态库将被创建的决定性因素;它是-shared 链接器标志。

回到残酷的现实。编译器坚持用-fPIC标志编译静态库的真正原因是,在 64 位平台上,使用 32 位寄存器的普通编译器汇编程序结构无法覆盖地址偏移量的范围。为了用 64 位寄存器实现相同的代码,编译器需要一次排序(使用-fPIC-mcmodel=large编译器标志)。

解决现实生活场景中的问题

在 64 位操作系统时代之前,并不是完全不可能设计出软件包,在 64 位操作系统时代,静态库是在没有-fPIC(或-mcmodel=large)标志的情况下构建的。此外,交付他们的静态库的人不一定是处理与编译器/连接器/库相关的问题的超级明星/(不像那些读完这本书的人;).如果您有幸(像我一样)从不了解这种特定场景的第三方开发人员那里获得了静态库,那么有一些坏消息:对于这种问题没有简单的解决方法。

试图将静态库分解到目标文件中并不能改变这种情况,哪怕是一丁点儿;目标文件没有使用这个特定场景所需的编译器标志进行编译,没有库转换魔法可以帮助避免重新编译静态库源代码的需要。

这类问题的唯一真正解决方案是,拥有源代码的人(代码发布者或最终用户)通过向编译器标志集添加所需的标志来修改构建参数(编辑 Makefile)。

如果这能安慰你的话,想象一下你根本没有库源代码。现在,那会很可怕,是吧?

六、设计动态库:基础

Abstract

第四章详细介绍了静态库概念背后的基本思想,所以现在是时候研究处理动态库的细节了。这很重要,因为这些细节会影响程序员/软件设计师/软件架构师的日常工作。

第五章详细介绍了静态库概念背后的基本思想,所以现在是时候研究处理动态库的细节了。这很重要,因为这些细节会影响程序员/软件设计师/软件架构师的日常工作。

创建动态库

编译器和连接器通常提供丰富多样的标志,这些标志最终可能会为构建动态库的过程提供许多风格。对于真正有趣的事情来说,即使是最简单的、广泛使用的、需要一个编译器和一个链接器标志的方法也可能不像最初看起来那样简单明了,更深入的分析可能会揭示出一组真正有趣的事实。不管怎样,让我们从头开始。

在 Linux 中创建动态库

构建动态库的过程传统上由以下最小标志集组成:

  • -fPIC编译器标志
  • -shared左旗

下面的简单示例演示了从两个源文件创建动态库的过程:

$ gcc -fPIC -c first.c second.c

$ gcc -shared first.o second.o -o libdynamiclib.so

按照 Linux 惯例,动态库以前缀lib开始,文件扩展名为.so

如果你遵循这个食谱,你就不会误入歧途。如果将这些标志分别传递给编译器和链接器,那么每当您打算构建一个动态库时,最终的结果将是正确且可用的动态库。然而,把这个食谱当作无可争议的普遍真理并不是正确的做法。更准确地说,尽管将-shared标志传递给链接器并没有什么错,但是使用-fPIC编译器标志确实是一个有趣的话题,值得特别关注。

本节的其余部分将主要集中在 Linux 方面(尽管一些概念也存在于 Windows 中)。

关于-fPIC 编译器标志

关于使用-fPIC标志的细节可以通过以下问题和答案的顺序得到最好的说明。

问题 1:fPIC 代表什么?

-fPIC中的“PIC”是位置无关代码的首字母缩写。在与位置无关的代码的概念出现之前,创建动态库是可能的,加载程序能够将动态库加载到进程内存空间中。然而,只有首先加载动态库的进程才能享受它的存在带来的好处;所有其他需要加载同一个动态库的正在运行的进程别无选择,只能将同一个动态库的另一个副本加载到内存中。加载特定动态库所需的进程越多,内存中必须存在的副本就越多。

这些限制的根本原因是次优的加载程序设计。在将动态库加载到进程中时,加载程序更改了动态库的代码(。使所有动态库的符号仅在加载该库的进程范围内有意义。尽管这种方法适合最基本的运行时需求,但最终结果是加载的动态库被不可逆地改变了,因此任何其他进程都很难重用已经加载的库。这种原始的加载程序设计方法被称为加载时重定位,将在后续段落中更详细地讨论。

事先知情同意的概念显然是一个巨大的进步。通过重新设计加载机制来避免绑定已加载库的代码(。text)段映射到加载它的第一个进程的内存映射,通过为多个进程提供将已经加载的动态库无缝映射到其内存映射的方式,实现了所需的额外功能。

问题 2:使用-fPIC 编译器标志是构建动态库的严格要求吗?

答案不是唯一的。在 32 位体系结构(X86)上,这不是必需的。但是,如果没有指定,动态库将遵循旧的加载时重定位加载机制,其中只有首先加载动态库的进程才能将其映射到其进程内存映射中。

在 64 位架构(X86_64 和 I686)上,简单地省略-fPIC编译器标志(试图实现加载时重定位机制)将导致链接器错误。本书后面将讨论为什么会发生这种情况以及如何解决这个问题。这种情况的补救方法是将-fPIC标志或-mcmodel=large传递给编译器。

问题 3:fPIC 编译器标志的使用是否严格限制在动态库的范围内?在构建静态库时可以使用它吗?

人们普遍认为,-fPIC标志的使用严格限制在动态库领域。事实有点不一样。

在 32 位架构(X86)上,是否使用-fPIC标志编译静态库并不重要。会对编译后代码的结构产生一定的影响;但是,它对库的链接和整体运行时行为的影响可以忽略不计。

在 64 位架构上(当然是 X86_64),事情就更有趣了。

  • 链接到可执行文件的静态库可以使用或不使用-fPIC编译器标志进行编译(即,您是否指定它并不重要)。

然而:

  • 链接到动态库的静态库必须用-fPIC标志编译!!!(或者,你可以指定-mcmodel=large编译器标志来代替-fPIC标志。)

如果静态库没有用这两个标志中的任何一个进行编译,试图将它链接到动态库会导致如图 6-1 所示的链接器错误。

A978-1-4302-6668-6_6_Fig1_HTML.jpg

图 6-1。

Linker error

与这个问题相关的一个有趣的技术讨论可能会在下面的网络文章中找到: www.technovelty.org/c/position-independent-code-and-x86-64-libraries.html

在 Windows 中创建动态库

在 Windows 中构建一个简单的动态库的过程需要遵循一个相当简单的方法。截图的顺序(图 6-2 到 6-6 )说明了创建 DLL 项目的过程。创建项目后,构建 DLL 只需要启动 Build 命令。

A978-1-4302-6668-6_6_Fig6_HTML.jpg

图 6-6。

Created DLL linker flags

A978-1-4302-6668-6_6_Fig5_HTML.jpg

图 6-5。

Created DLL compiler flags

A978-1-4302-6668-6_6_Fig4_HTML.jpg

图 6-4。

Available Win32 DLL Settings

A978-1-4302-6668-6_6_Fig3_HTML.jpg

图 6-3。

Click the Next button to specify DLL choice

A978-1-4302-6668-6_6_Fig2_HTML.jpg

图 6-2。

The first step in creating Win32 dynamic library (DLL)

设计动态库

一般来说,设计动态库的过程与设计任何其他软件没有太大的不同。鉴于动态库的特殊性质,有几个特别重要的地方需要详细讨论。

设计二进制接口

就其本质而言,动态库通常向外部世界提供特定的功能,这种方式应该最小化客户对内部功能细节的参与。实现的方式是通过接口,在这个接口上,客户端可以最大程度地了解它不需要担心的任何事情。

在面向对象编程领域中无处不在的接口概念,在二进制代码重用领域中获得了额外的味道。正如在第五章的中的“二进制重用概念的影响”一节所解释的,动态链接的构建时和运行时阶段之间的应用程序二进制接口(ABI)的不变性是成功的动态链接的最基本要求。

乍一看,ABI 的设计与 API 的设计没有太大区别。接口概念的基本含义保持不变:为了使用专门模块提供的服务,需要向客户端提供一组功能。

事实上,只要程序不是用 C++ 编写的,动态库的 ABI 的设计工作就不需要比设计可重用软件模块的 API 更多的思考。事实上,ABI 只是一组需要在运行时加载的链接器符号,这并没有使事情发生实质性的变化。

然而,C++ 语言的影响(最明显的是缺乏严格的标准化)需要在设计动态库 ABI 时进行额外的思考。

C++ 问题

生活中一个不幸的事实是,在编程语言领域的进步之后,并没有对称地出现连接器的设计,或者准确地说,并没有出现软件领域标准制定机构的严格性。不这样做的充分理由将在本节中指出。一篇阐述这些问题的优秀文章是 www.lurklurk.org/linkers/linkers.html 的“初学链接者指南”。

让我们从简单的事实开始,回顾一些问题。

问题 1: C++ 强加了更复杂的符号名要求

与 C 编程语言不同,C++ 函数到链接器符号的映射给链接器设计带来了更多的挑战。C++ 面向对象的特性带来了以下额外的考虑:

  • 一般来说,C++ 函数很少是独立的;相反,它们倾向于隶属于各种代码实体。首先想到的是,在 C++ 中,函数通常属于类(因此甚至有一个特殊的名字:方法)。此外,类(以及它们的方法)可能属于名称空间。当模板发挥作用时,情况变得更加复杂。为了唯一地标识函数,链接器必须在它为函数入口点创建的符号中包含函数从属信息。
  • C++ 重载机制允许同一类的不同方法具有相同的名称、相同的返回值,但是在输入参数列表方面有所不同。为了唯一地标识共享相同名称的函数(方法),链接器必须以某种方式将关于输入参数的信息添加到它为函数入口点创建的符号中。

响应这些复杂得多的需求的链接器设计工作导致了名为名称管理的技术。简而言之,名称管理是将函数名、函数的附属信息和函数的参数列表结合起来创建最终符号名的过程。通常,函数从属关系是在前面(前缀),而函数签名信息是在函数名后面(后缀)。

麻烦的主要来源是名称混淆约定不是唯一标准化的,直到今天仍然是特定于供应商的。维基百科的文章( http://en.wikipedia.org/wiki/Name_mangling#How_different_compilers_mangle_the_same_functions )说明了不同链接者在名称篡改实现上的差异。正如文章中所述,除了 ABI 之外,还有许多因素在实现处理机制中发挥作用(异常处理堆栈、虚拟表的布局、结构和堆栈帧填充)。考虑到各种各样的需求,带注释的 C++ 参考手册甚至建议维护单独的 mangling 方案。

C-STYLE FUNCTIONS

使用 C++ 编译器时,使用 C 风格的函数会发生有趣的事情。即使 C 函数不需要 mangling,默认情况下,链接器也会为它们创建 mangled 名称。在希望避免篡改的情况下,必须应用特殊的关键字,以便建议链接器不要应用篡改。

该技术基于使用extern "C"关键字。当函数以如下方式声明时(通常在头文件中)

#ifdef __cplusplus

extern "C"

{

#endif // __cplusplus

int myFunction(int x, int y);

#ifdef __cplusplus

}

#endif // __cplusplus

最终的结果是,链接器创建了它的符号,没有任何混乱。在这一章的后面,关于输出 ABI 的部分将包含一个更详细的解释,为什么这是一个非常重要的技术。

问题#2:静态初始化顺序失败

C 语言的继承之一是链接器可以处理相当简单的初始化变量,无论是简单的数据类型还是结构。链接器需要做的就是在.data部分保留存储,并将初始值写入该位置。在 C 语言的领域中,变量初始化的顺序通常并不特别重要。重要的是在程序启动前完成变量的初始化。

而在 C++ 中,数据类型一般是对象,它的初始化是在运行时通过对象构造的过程来完成的,对象构造是在类构造函数方法完成执行时完成的。显然,为了初始化 C++ 对象,链接器需要做更多的事情。为了方便链接器的工作,编译器将需要为特定文件执行的所有构造函数的列表嵌入到目标文件中,并将该信息存储到特定的目标文件段中。在链接时,链接器检查所有的目标文件,并将这些构造列表组合成将在运行时执行的最终列表。

在这一点上,重要的是要提到,链接器确实遵守基于继承链的构造函数的执行顺序。换句话说,保证首先执行基类构造函数,然后执行派生类的构造函数。这种嵌入到链接器中的逻辑对于大多数可能的场景都是足够的。

然而,链接器并不是无限智能的。不幸的是,有一整类情况,程序员没有以任何方式偏离 C++ 语法规则,然而链接器有限的逻辑仍然导致非常严重的崩溃,这种崩溃发生在程序加载之前,任何调试器都无法捕捉到它。

当一个对象的初始化依赖于其他一些预先初始化的对象时,这种典型的情况就会发生。我将首先解释问题的潜在机制,然后为程序员建议避免这些问题的方法。在 C++ 程序员的圈子里,这类问题通常被称为静态初始化顺序的惨败。

Note

Scott Meyer 的经典之作《有效的 C++》一书(“第 47 条:确保非局部静态对象在被使用前被初始化”)很好地说明了这个问题和解决方案。

问题描述

非局部静态对象是 C++ 类的实例,其可见性范围超出了类的边界。更具体地,这样的对象可以是以下之一:

  • 在全局或命名空间范围内定义
  • 在类中声明为静态
  • 在文件范围内定义了静态

在程序开始运行之前,这些对象通常由链接器初始化。对于每个这样的对象,链接器维护创建这样的对象所需的构造器的列表,并按照继承链指定的顺序执行它们。

不幸的是,这是链接器识别和实现的唯一对象初始化排序方案。现在是整个故事发生特殊转折的时候了。

让我们假设这些对象中的一个依赖于其他一些预先被初始化的对象。例如,假设您有两个静态对象:

  • 对象A(类a的实例),它初始化网络基础设施,查询可用网络列表,初始化套接字,并建立与认证服务器的初始连接。
  • 对象B(类b的实例),通过调用类b的实例上的接口方法,通过网络将消息发送到远程认证服务器。

显然,正确的初始化顺序是对象B在对象A之后初始化。显然,违反对象初始化的顺序很有可能造成严重破坏。即使设计者已经足够小心地设想了初始化没有完成的情况(即,在进行实际调用之前检查指针值),最好的情况也是类B的任务没有按预期完成。

事实上,没有规则规定静态对象初始化的顺序。实现将检查代码的算法的尝试识别这样的场景,并向链接器建议正确的顺序,已经被证明属于非常难以解决的问题类别。其他 C++ 语言特性(模板)的存在只会增加问题解决的难度。

最终的结果是,链接器可以决定以任何顺序初始化非局部静态对象。更糟糕的是,链接器决定遵循哪个顺序可能取决于难以想象的大量不相关的运行时环境。

现实生活中,这样的问题很吓人,原因多种多样。首先,它们很难跟踪,因为它们会导致崩溃发生在进程加载连接之前,远远早于调试器可以提供任何帮助的时间。此外,崩溃事件可能不是持久的;崩溃可能不时发生,或者在某些情况下每次都有不同的症状。

回避问题

尽管这个问题不适合心脏虚弱的人,但有一种方法可以避免这种丑陋的混乱。链接器规则不指定初始化变量的顺序,但是对于在函数体内声明的静态变量,这个顺序是非常精确地指定的。也就是说,在函数(或类方法)内部声明为静态的对象,当在调用该函数的过程中第一次遇到它的定义时,就被初始化。

这个问题的解决方案变得显而易见。实例不应在数据内存中自由漫游。相反,它们应该是

  • 在函数中声明为静态变量。
  • 函数应该被方便地用作访问这种在文件范围内静态定义的变量(例如,返回对对象的引用)的唯一方式。

总之,以下两种可能的解决方案传统上用于解决这类问题:

  • 解决方案 1:提供_init()方法的自定义实现,这是一个加载动态库时立即调用的标准方法,其中一个类静态方法实例化对象,从而强制构造初始化。因此,可以提供标准_fini()的定制实现,即在动态库被卸载之前立即调用的标准方法,其中可以完成对象解除分配。
  • 解决方案 2:用对自定义函数的调用替换对这种对象的直接访问。这样的函数将包含一个 C++ 类的静态实例,并将返回对它的引用。在第一次访问之前,将构造一个声明为 static 的变量,确保它的初始化发生在第一次实际调用之前。GNU 编译器和 C++11 标准保证了这个解决方案是线程安全的。
问题#3:模板

引入模板的概念是为了消除相同算法的重复和可能分散的实现,这些实现只在算法操作的数据类型上有所不同。尽管这个概念很有用,但它给链接过程带来了额外的问题。

问题的本质是模板的不同专门化有完全不同的机器码表示。幸运的是,一旦编写完成,模板可能会以无数种方式专门化,这取决于模板用户希望如何使用它。以下模板

template <class T>

T max(T x, T y)

{

if (x>y) { return x;}

else   536:26  { return y;}

}

可能专门用于支持比较运算符的尽可能多的数据类型(从char一直到double的简单数据类型是直接候选)。

当编译器遇到模板时,它需要将其具体化为某种形式的机器代码。但是,在检查完所有其他源文件以确定代码中发生了哪个特定的专门化之前,这是不可能的。由于这对于独立应用程序来说可能相对容易,所以当模板由动态库导出时,这项任务需要认真考虑。

有两种解决这类问题的通用方法:

  • 编译器可以生成所有可能的模板特化,并为每个模板特化创建弱符号。弱符号概念的完整解释可以在关于链接器符号类型的讨论中找到。请注意,一旦链接器确定在最终版本中实际上不需要弱符号,它就可以自由地丢弃它们。
  • 另一种方法是,链接器直到最后都不包含任何模板专门化的机器码实现。一旦完成了所有其他的工作,链接器就可以检查代码,准确地确定哪些专门化是真正需要的,调用 C++ 编译器来创建所需的模板专门化,最后将机器码插入到可执行文件中。这种方法受到 Solaris C++ 编译器套件的青睐。

设计应用程序二进制接口

为了最大限度地减少潜在的麻烦,提高对不同平台的可移植性,甚至增强不同编译器创建的模块之间的互操作性,强烈建议实践以下准则。

准则#1:将动态库 ABI 实现为一组 C 风格的函数

有很多很好的理由说明为什么这个建议很有意义。例如,您可以

  • 避免基于 C++ 与链接器交互的各种问题
  • 提高跨平台可移植性
  • 提高不同编译器生成的二进制文件之间的互操作性。(有些编译器倾向于生成可供其他编译器使用的二进制文件。著名的例子是 MinGW 和 Visual Studio 编译器。)

为了将 ABI 符号导出为 C 风格的函数,使用extern "C"关键字来指示链接器不要在这些符号上应用名称篡改。

准则 2:提供带有完整 ABI 声明的头文件

“完整的 ABI 声明”不仅指函数原型,还指预处理器定义、结构布局等。

准则#3:使用广泛支持的标准 C 关键字

更具体地说,使用特定于项目的数据类型定义,或特定于平台的数据类型,或任何不被不同编译器和/或不同平台普遍支持的东西,只会招致将来的问题。所以,尽量不要表现得像个自以为聪明的家伙;相反,尽可能简单明了地编写代码。

准则#4:使用类工厂机制(C++)或模块(C)

如果动态库的内部功能是由 C++ 类实现的,这并不意味着你应该违反准则#1。相反,你应该遵循所谓的类工厂方法(图 6-7 )。

类工厂(class factory)是一个 C 风格的函数,向外界表示一个或多个 C++ 类(类似于好莱坞代理人在与电影制片厂的谈判中代表很多明星演员)。

一般来说,类工厂对 C++ 类的布局非常了解,这通常是通过将其声明为同一个 C++ 类的静态方法来实现的。

当感兴趣的客户端调用时,类工厂创建它所代表的 C++ 类的一个实例。为了不让客户窥探 C++ 类布局的细节,它从不将类的实例转发回调用者。相反,它将 C++ 类强制转换为 C 风格的接口,并将指向创建的 C++ 对象的指针强制转换为接口指针。

A978-1-4302-6668-6_6_Fig7_HTML.jpg

图 6-7。

The class factory concept

当然,为了让这个方案正确运行,由类工厂表示的 C++ 类必须实现导出接口。在 C++ 的特殊情况下,这意味着类应该公开继承接口。这样,将类指针转换为接口指针就非常自然了。

最后,这种方案要求某种分配跟踪机制跟踪由类工厂函数分配的所有实例。在 Microsoft 组件对象模型(COM)技术中,引用计数确保分配的对象在不再使用时被销毁。在其他实现中,建议保留指向已分配对象的指针列表。在终止时(通过调用某种清理函数来描述),每个列表元素都将被删除,列表最终被清理。

类工厂的 C 等价体通常被称为模块。它是通过一组精心设计的接口函数向外部世界提供功能的代码体。

模块化设计是低层内核模块和设备驱动程序的典型设计,但它的应用决不局限于那个特定的领域。典型模块导出函数,如Open()(或Initialize())、一个或多个工人函数(Read()Write()SetMode()等。),最后是Close()(或者Deinitialize())。

对于模块来说,非常典型的是使用handle,一种模块实例标识符,经常被实现为 void 指针,这是 C++ 中this指针的前身。

handle通常在Open()方法中创建,并返回给调用者。在对其他模块接口方法的调用中,handle是必需的第一个函数参数。

在 C++ 不是一个选项的情况下,设计 C 模块是完全可行的,相当于类工厂的面向对象概念。

准则 5:只导出真正重要的符号

由于本质上是模块化的,动态库的设计应该使其功能通过一组明确定义的函数符号(应用程序二进制接口,ABI)向外界公开,而所有其他只在内部使用的函数的符号应该可以被客户端可执行文件访问。

这种方法有几个好处:

  • 增强了对专有内容的保护。
  • 由于导出符号数量的显著减少,库加载时间可能会大大缩短。
  • 不同动态库之间冲突/重复符号的机会显著减少。

这个想法相当简单:动态库应该只导出加载库的人绝对需要的函数和数据的符号,所有其他的符号都应该是不可见的。下一节将介绍有关控制动态库符号可见性的更多详细信息。

准则#6:使用名称空间来避免符号命名冲突

通过将动态库的代码包含到唯一的名称空间中,您消除了不同的动态库使用相同命名的符号的可能性(函数Initialize()是一个很好的例子,它可能出现在功能范围完全不同的动态库中)。

控制动态库符号的可见性

从高层次的角度来看,导出/隐藏链接器符号的机制在 Windows 和 Linux 中几乎是相同的。唯一的实质性区别是,默认情况下,所有 Windows DLL 链接器符号都是隐藏的,而在 Linux 中,所有动态库链接器符号都是默认导出的。

实际上,由于 GCC 为实现跨平台一致性而提供的一组功能,符号导出的机制看起来非常相似,做的事情也非常相似,从某种意义上说,最终只有包含应用程序二进制接口的链接器符号被导出,而所有剩余的符号都被隐藏/不可见。

导出 Linux 动态库符号

与 Windows 不同,在 Linux 中,所有动态库的链接器符号都是默认导出的,所以无论是谁试图动态链接该库,它们都是可见的。尽管这样的缺省使得处理动态库变得容易,但是出于许多不同的原因,保持所有的符号导出/可见并不是推荐的做法。过多地暴露在顾客窥探的目光下从来都不是一个好习惯。此外,加载所需的最少数量的符号与加载大量符号相比,可能会在加载库所需的时间上产生明显的差异。

很明显,需要对哪些符号被导出进行某种控制。此外,由于这种控制已经在 Windows DLLs 中实现,实现并行性将极大地促进可移植性工作。

有几种机制可以在生成时实现对符号导出的控制。此外,可以通过在动态库二进制文件上运行strip命令行工具来应用强力方法。最后,为了控制动态库符号的可见性的同一个目标,可以组合几种不同的方法。

构建时的符号导出控件

GCC 编译器提供了几种设置链接器符号可见性的机制:

方法 1:(影响整个代码体)

-fvisibility compiler flag

正如 GCC 手册页所述( http://linux.die.net/man/1/gcc ),通过传递

对于试图动态链接动态库的人来说,可以使每个动态库符号不导出/不可见。

方法 2:(仅影响单个符号)

__attribute__ ((visibility("<default | hidden>")))

通过用 attribute 属性修饰函数签名,可以指示链接器允许(默认)或不允许(隐藏)导出符号。

方法 3:(影响单个符号或一组符号)

#pragma GCC visibility [push | pop]

该选项通常用在头文件中。通过做这样的事情

#pragma visibility push(hidden)

void someprivatefunction_1(void);

void someprivatefunction_2(void);

...

void someprivatefunction_N(void);

#pragma visibility pop

你基本上是使所有在#pragma语句之间声明的函数不可见/不导出。

这三种方法可以以程序员认为合适的任何方式组合。

其他方法

GNU 链接器支持处理动态库版本的复杂方法,其中一个简单的脚本文件被传递给链接器(通过-Wl,--version-script,<script filename>链接器标志)。尽管该机制的最初目的是指定版本信息,但它也具有影响符号可见性的能力。它完成任务的简单性使这种技术成为控制符号可见性的最优雅的方式。关于这项技术的更多细节可以在第十一章讨论 Linux 库版本控制的章节中找到。

符号导出控件演示示例

为了说明可见性控制机制,我创建了一个演示项目,其中构建了两个具有不同可见性设置的相同动态库。这些库被恰当地命名为libdefaultvisibility.solibcontrolledvisibility.so。库构建完成后,使用nm实用程序检查它们的符号(在第十二章和第十三章中有详细介绍)。

默认符号可见性情况

清单 6-1 显示了libdefaultvisibility.so的源代码。

清单 6-1。libdefaultvisibility.so

#include "sharedLibExports.h"

void mylocalfunction1(void)

{

printf("function1\n");

}

void mylocalfunction2(void)

{

printf("function2\n");

}

void mylocalfunction3(void)

{

printf("function3\n");

}

void printMessage(void)

{

printf("Running the function exported from the shared library\n");

}

对构建的库二进制文件中存在的符号进行检查并不会带来什么意外,因为所有函数的符号都被导出并可见,如图 6-8 所示。

A978-1-4302-6668-6_6_Fig8_HTML.jpg

图 6-8。

All library symbols are originally exported/visible

受控符号可见性情况

在你想要控制符号可见性/可导出性的动态库的情况下,-fvisibility编译器标志是在项目 Makefile 中指定的,如清单 6-2 所示。

清单 6-2。-fvisibility 编译器标志

...

#

# Compiler

#

INCLUDES        = $(COMMON_INCLUDES)

DEBUG_CFLAGS    = -Wall -g -O0

RELEASE_CFLAGS  = -Wall -O2

VISIBILITY_FLAGS = -fvisibility=hidden -fvisibility-inlines-hidden

ifeq ($(DEBUG), 1)

CFLAGS          = $(DEBUG_CFLAGS) -fPIC $(INCLUDES)

else

CFLAGS          = $(RELEASE_CFLAGS) -fPIC $(INCLUDES)

endif

CFLAGS          += $(VISIBILITY_FLAGS)

COMPILE          = g++ $(CFLAGS)

...

当仅使用这种特殊的符号可见性设置构建库时,对符号的检查表明功能符号尚未导出(图 6-9 )。

A978-1-4302-6668-6_6_Fig9_HTML.jpg

图 6-9。

All library symbols are now hiden

接下来,当应用带有可见性属性的函数签名修饰时,如清单 6-3 所示,最终结果是用__attribute__ ((visibility("default")))声明的函数变得可见(图 6-10 )。

清单 6-3。应用了可见性属性的函数签名修饰

#include "sharedLibExports.h"

#if 1

#define FOR_EXPORT __attribute__ ((visibility("default")))

#else

#define FOR_EXPORT

#endif

void mylocalfunction1(void)

{

printf("function1\n");

}

...etc...

//

// also supported:

//              FOR_EXPORT void printMessage(void)

// but this is not supported:

//      void printMessage FOR_EXPORT (void)

// nor this:

//              void printMessage(void) FOR_EXPORT

//

// i.e. attribute may be declared anywhere

// before the function name

void``FOR_EXPORT

{

printf("Running the function exported from the shared library\n");

}

A978-1-4302-6668-6_6_Fig10_HTML.jpg

图 6-10。

Visibility control applied to function printMessage

使用去废工具

控制符号可见性的另一种机制是可用的。它没有那么复杂,也不可编程。相反,它是通过运行strip命令行实用程序来实现的(图 6-11 )。这种方法要残酷得多,因为它有能力完全擦除关于任何库符号的任何信息,以至于任何通常的符号检查实用程序都无法看到任何符号,无论它是否在.dynamic部分。

A978-1-4302-6668-6_6_Fig11_HTML.jpg

图 6-11。

Using the strip utility to eliminate certain symbols Note

关于strip工具的更多信息可以在第十三章中找到。

导出 Windows 动态库符号

在 Linux 中,默认情况下,客户端可执行文件可以访问动态库中的所有链接器符号。然而,在 Windows 中,情况并非如此。相反,只有正确导出的符号对客户端可执行文件可见。强制实施这一限制的重要部分是在构建阶段使用单独的二进制文件(导入库),它只包含计划导出的符号。

幸运的是,导出 DLL 符号的机制完全在程序员的控制之下。事实上,有两种受支持的机制可以声明 DLL 符号用于导出。

使用 __declspec(dllexport)关键字

这种机制是 Visual Studio 标准提供的。在新建项目对话框中勾选“导出符号”复选框,如图 6-12 所示。

A978-1-4302-6668-6_6_Fig12_HTML.jpg

图 6-12。

Selecting the “Export symbols” option in Win32 DLL Wizzard dialog

在这里,您指定希望项目向导生成包含代码片段的库导出头,看起来有点像图 6-13 。

A978-1-4302-6668-6_6_Fig13_HTML.jpg

图 6-13。

Visual Studio generates project-specific declaration of __declspec(dllexport) keywords

如图 6-13 所示,导出头既可以在 DLL 项目内部使用,也可以由客户端可执行项目使用。当在 DLL 项目中使用时,特定于项目的宏在 DLL 项目中计算为关键字__declspec(dllexport),而在客户端可执行项目中计算为__declspec(dllimport)。这是由 Visual Studio 强制执行的,它会自动将预处理器定义插入到 DLL 项目中(图 6-14 )。

A978-1-4302-6668-6_6_Fig14_HTML.jpg

图 6-14。

Visual Studio automatically generates the project-specific preprocessor definition

当评估为__declspec(dllexport)的特定于项目的关键字被添加到函数声明中时,函数链接器符号被导出。否则,省略这样一个特定于项目的关键字肯定会阻止函数符号的导出。图 6-15 有两个功能,其中只有一个声明出口。

A978-1-4302-6668-6_6_Fig15_HTML.jpg

图 6-15。

Visual Studio automatically generates example of using project-specific symbol export control keyword

现在是介绍 Visual Studio dumpbin实用程序的最佳时机,您可以使用它在搜索导出符号时分析 DLL。它是 Visual Studio tools 的一部分,只有运行专门的 Visual Studio Tools 命令提示符才能使用(图 6-16 )。

A978-1-4302-6668-6_6_Fig16_HTML.jpg

图 6-16。

Launching Visual Studio command prompt to access the collection of binary analysis command-line tools

图 6-17 显示了dumpbin工具(用/EXPORT标志调用)关于你的 DLL 输出的符号的报告。

A978-1-4302-6668-6_6_Fig17_HTML.jpg

图 6-17。

Using dumpbin.exe to view the list of DLL exported symbols

显然,用特定于项目的导出符号声明的函数符号最终会被 DLL 导出。然而,链接器根据 C++ 准则处理它,该准则使用名称篡改。客户端可执行文件通常不会有解释这些符号的问题,但是如果有,你可以将函数声明为extern "C",这将导致函数符号遵循 C 风格的约定(图 6-18 )。

A978-1-4302-6668-6_6_Fig18_HTML.jpg

图 6-18。

Declaring the function as extern "C"

使用模块定义文件(。def)

控制 DLL 符号导出的另一种方法是通过使用模块定义(。def)文件。与前面描述的机制(基于__declspec(dllexport)关键字)不同,它可以通过选中“Export symbols”复选框,通过项目创建向导来指定,模块定义文件的使用需要一些更明确的措施。

首先,如果你计划使用.def文件,建议不要勾选“导出符号”复选框。相反,使用文件➡新建菜单创建一个新的定义(。def)文件。如果这一步完成正确,项目设置会显示模块定义文件正式成为项目的一部分,如图 6-19 所示。

A978-1-4302-6668-6_6_Fig19_HTML.jpg

图 6-19。

Module-definition (.def) file is officially part of the project

或者,您可以手动编写.def文件,手动将其添加到项目源文件列表中,最后,手动编辑链接器属性页,如图 6-19 所示。指定用于导出的演示功能的模块定义文件如图 6-20 所示。

A978-1-4302-6668-6_6_Fig20_HTML.jpg

图 6-20。

Module-definition file example

在 EXPORTS 行下,它可能包含与您计划导出其符号的函数一样多的行。

一个有趣的细节是,模块定义文件的使用导致函数符号导出为 C 风格的函数,而不需要将函数声明为extern "C"。这是优点还是缺点取决于个人喜好和设计环境。

使用模块定义的一个特别优点是。def)文件作为导出 DLL 符号的方法是,在某些交叉编译的情况下,非微软编译器倾向于支持这个选项。

一个这样的例子是使用 MinGW 编译器,它编译一个开源项目(例如 ffmpeg)来创建 Windows DLLs 和相关的.def文件。为了在构建时动态链接 DLL,您需要使用它的导入库,不幸的是,它不是由 MinGW 编译器生成的。

幸运的是,Visual Studio 工具提供了lib.exe命令行实用程序,它可以基于.def文件的内容生成导入库文件(图 6-20 )。lib 工具可通过 Visual Studio 工具命令提示符获得。图 6-21 中的例子说明了在交叉编译会话后如何使用该工具,在交叉编译会话中,运行在 Linux 上的 MinGW 编译器产生了 Windows 二进制文件(但没有提供导入库)。

A978-1-4302-6668-6_6_Fig21_HTML.jpg

图 6-21。

Generating import library files for DLLs generated by MingW compiler based on specified module definition (.def) files

处理模块定义文件的缺点(。def)

在试验.def文件时,发现了以下缺点:

  • 无法区分 C++ 类方法和 C 函数:如果在一个 DLL 中有一个类,并且该类有一个与您在.def文件中指定要导出的 C 函数同名的方法,编译器将报告一个冲突,同时试图确定这两个中的哪一个应该被导出。
  • extern "C"怪癖:一般来说,在.def文件中声明为导出的函数不需要声明为extern "C",因为链接器会注意它的符号遵循 C 惯例。然而,如果您仍然决定将函数修饰为extern "C",请确保在头文件和源文件.cpp中都这样做(后者通常不是必需的)。如果不这样做,链接器会不知何故地混乱,客户端应用程序将无法链接您导出的函数符号。对于更难的问题,dumpbin实用程序输出不会显示任何差异,这使得问题更难解决。

链接完成要求

动态库创建过程是一个完整的构建过程,因为它包括编译和链接阶段。一般来说,一旦每个链接器符号都被解析,链接阶段就完成了,不管目标是可执行还是动态库,都应该遵守这个标准。

在 Windows 中,这一规则被严格执行。在每个动态库符号被解析之前,链接过程不会被视为完成,输出二进制文件也不会被创建。搜索相关库的完整列表,直到最后一个符号引用被解析。

然而,在 Linux 中,默认情况下,当构建动态库时,这个规则有点扭曲,因为它允许动态库的链接完成(并创建二进制文件),即使不是所有的符号都已被解析。

允许这种偏离原本严格的规则的原因是,它隐含地假设在链接阶段丢失的符号最终会以某种方式出现在进程内存映射中,这很可能是运行时加载其他动态库的结果。动态库未提供的所需符号被标记为未定义(“U”)。

通常,如果由于某种原因,预期的符号没有出现在进程的内存映射中,操作系统倾向于通过在 stderr 流中打印文本消息,指定丢失的符号来报告原因。

链接动态库的 Linux 规则中的这种灵活性已经在许多场合被证明是一个积极的因素,允许有效地克服某些非常复杂的链接限制。

-no-未定义的链接器标志

尽管默认情况下在 Linux 中链接动态库要宽松得多,但是 GCC 链接器支持建立与 Windows 链接器遵循的标准相匹配的链接严格性标准。

如果在构建时没有解析每个符号,那么将--no-undefined标志传递给 gcc 链接器将导致构建失败。通过这种方式,Linux 默认的容忍未解析符号的存在被有效地转化为类似 Windows 的严格标准。

注意,当通过 gcc 调用链接器时,链接器标志必须以前缀-Wl开头,例如:

$ gcc -fPIC <source files> -l <libraries>``-Wl,--no-undefined

动态链接模式

链接动态库的决定可以在程序生命周期的不同阶段做出。在某些场景中,您预先知道您的客户端二进制文件无论如何都需要加载特定的动态库。在其他场景中,关于加载某个动态库的决定是运行时环境的结果,或者是运行时设置的用户偏好。基于何时实际做出关于动态链接的决定,可以区分以下动态链接模式。

静态感知(加载时)动态链接

在到目前为止的所有讨论中,我已经隐含地假设了这个特定的场景。事实上,从程序启动的那一刻起,一直到程序终止,对特定动态库功能的需求是经常发生的,这一事实是预先知道的。在这种情况下,构建过程需要下列项目。

在编译时:

  • 动态库的导出头文件,指定与库的 ABI 接口相关的所有内容

链接时:

  • 项目所需的动态库列表
  • 客户端二进制文件设置预期库符号列表所需的动态库二进制文件的路径。

关于如何指定路径的更多细节,请查看“构建时库位置规则”一节。

  • 指定链接过程细节的可选链接器标志

运行时动态链接

动态链接特性的全部优点是程序员能够在运行时确定是否真的需要某个动态库和/或需要加载哪个特定的库。

很多时候,设计要求存在许多动态库,每个库都支持相同的 ABI,并且根据用户的选择只加载其中的一个。这种情况的一个典型例子是多语言支持,在这种情况下,应用程序根据用户的偏好加载动态库,该动态库包含以用户选择的语言编写的所有资源(字符串、菜单项、帮助文件)。

在这种情况下,构建过程需要下列项目。

在编译时:

  • 动态库的导出头文件,指定与库的 ABI 接口相关的所有内容

链接时:

  • 至少是要加载的动态库的文件名。动态库文件名的确切路径通常通过依赖于控制路径选择的一组优先级规则来隐式解析,在运行时在该路径中期望找到库二进制文件。

所有主要的操作系统都提供了一组简单的 API 函数,允许程序员充分利用这一宝贵的特性(表 6-1 )。

表 6-1。

API Functions

| 目的 | Linux 版本 | Windows 版本 | | --- | --- | --- | | 库装载 | `dlopen()` | `LoadLibrary()` | | 寻找符号 | `dlsym()` | `GetProcAddress()` | | 库卸载 | `dlclose()` | `FreeLibrary()` | | 错误报告 | `dlerror()` | `GetLastError()` |

不管操作系统和/或编程环境如何,使用这些函数的典型范例可以用下面的伪代码序列来描述:

1) handle = do_load_library("<library path>", optional_flags);

if(NULL == handle)

report_error();

2) pFunction = (function_type)do_find_library_symbol(handle);

if(NULL == pFunction)

{

report_error();

unload_library();

handle = NULL;

return;

}

3) pFunction(function arguments); // execute the function

4) do_unload_library(handle);

handle = NULL;

清单 6-4 和 6-5 提供了运行时动态加载的简单说明。

清单 6-4。Linux 运行时动态加载

#include <stdlib.h>

#include <stdio.h>

#include <dlfcn.h>

#define PI (3.1415926536)

typedef double (*PSINE_FUNC)(double x);

int main(int argc, char **argv)

{

void *pHandle;

pHandle =``dlopen

if(NULL == pHandle) {

fprintf(stderr, "%s\n",``dlerror

return -1;

}

PSINE_FUNC pSineFunc = (PSINE_FUNC)``dlsym

if (NULL == pSineFunc) {

fprintf(stderr, "%s\n",``dlerror

dlclose(pHandle);

pHandle = NULL;

return -1;

}

printf("sin(PI/2) = %f\n", pSineFunc(PI/2));

dlclose (pHandle);

pHandle = NULL;

return 0;

}

清单 6-5 展示了 Windows 运行时动态加载,其中我们试图加载 DLL,定位函数 DllRegisterServer()和/或 DllUnregisterServer()的符号并执行它们。

清单 6-5。Windows 运行时动态加载

#include <stdio.h>

#include <Windows.h>

#ifdef __cplusplus

extern "C"

{

#endif // __cplusplus

typedef HRESULT (*PDLL_REGISTER_SERVER)(void);

typedef HRESULT (*PDLL_UNREGISTER_SERVER)(void);

#ifdef __cplusplus

}

#endif // __cplusplus

enum

{

CMD_LINE_ARG_INDEX_EXECUTABLE_NAME = 0,

CMD_LINE_ARG_INDEX_INPUT_DLL,

CMD_LINE_ARG_INDEX_REGISTER_OR_UNREGISTER,

NUMBER_OF_SUPPORTED_CMD_LINE_ARGUMENTS

} CMD_LINE_ARG_INDEX;

int main(int argc, char* argv[])

{

HINSTANCE dllHandle = ::``LoadLibraryA

if(NULL == dllHandle)

{

printf("Failed loading %s\n", argv[CMD_LINE_ARG_INDEX_INPUT_DLL]);

return -1;

}

if(NUMBER_OF_SUPPORTED_CMD_LINE_ARGUMENTS > argc)

{

PDLL_REGISTER_SERVER pDllRegisterServer =

(PDLL_REGISTER_SERVER)``GetProcAddress

if(NULL == pDllRegisterServer)

{

printf("Failed finding the symbol \"DllRegisterServer\"");

::``FreeLibrary

dllHandle = NULL;

return -1;

}

pDllRegisterServer();

}

else

{

PDLL_UNREGISTER_SERVER pDllUnregisterServer =

(PDLL_UNREGISTER_SERVER)``GetProcAddress

if(NULL == pDllUnregisterServer)

{

printf("Failed finding the symbol \"DllUnregisterServer\"");

::``FreeLibrary

dllHandle = NULL;

return -1;

}

pDllUnregisterServer();

}

::``FreeLibrary

dllHandle = NULL;

return 0;

}

动态链接模式比较

这两种动态链接模式之间几乎没有实质性的区别。尽管动态链接发生的时刻不同,但在两种情况下,动态链接的实际机制是完全相同的。

此外,可以静态加载的动态库也可以在运行时动态加载。动态库设计中没有任何元素可以严格限定库在不同场景中的使用。

唯一的实质性区别是,在静态感知场景中,有一个额外的需求需要满足:您需要提供构建时库的位置。正如将在下一章中展示的,这个任务需要一些技巧,一个好的软件开发人员在 Linux 和 Windows 环境中都需要知道这些技巧。

七、搜索库

Abstract

二进制代码共享的思想是库概念的核心。不太明显的是,这通常意味着库二进制文件的单一副本将驻留在给定机器上的固定位置,而大量不同的客户机二进制文件将需要定位所需的库(在构建时或运行时)。为了解决定位库的问题,已经设计并实现了各种约定。在这一章中,我将讨论这些惯例和准则的细节。

二进制代码共享的思想是库概念的核心。不太明显的是,这通常意味着库二进制文件的单一副本将驻留在给定机器上的固定位置,而大量不同的客户机二进制文件将需要定位所需的库(在构建时或运行时)。为了解决定位库的问题,已经设计并实现了各种约定。在这一章中,我将讨论这些惯例和准则的细节。

典型的库用例场景

库的使用已经被证明是跨软件社区共享代码的一种非常强大的方式。在某些领域积累了专业知识的公司以库的形式交付其知识产权是一种非常常见的做法,第三方可以将其集成到他们的产品中并交付给客户。

使用库的实践通过两个不同的用例场景发生。第一个用例场景发生在开发人员试图在他们的产品中集成第三方库(静态或动态)的时候。另一种情况是,为了让安装在客户机上的应用程序正常运行,需要在运行时定位库(在这种情况下,特别是动态库)。

这两个用例场景都引入了定位库二进制文件的问题。这些问题的结构性解决方法将在本章中描述。

开发用例场景

通常,第三方包包含库、导出头,可能还有一些附加内容(如文档、在线帮助、包图标、实用程序、代码和媒体示例等)。)安装在开发人员机器上的预定路径上。紧接着,开发人员可能会在她的机器上的许多不同路径上创建过多的项目。

显然,每个需要与第三方库链接的项目都需要能够访问库二进制文件。否则,就不可能完成这个项目。

将第三方库复制到开发人员可能创建的每个项目中绝对是一种可能性,尽管这是一个非常糟糕的选择。显然,在每个可能需要的项目的文件夹中保存库的副本,违背了支持库概念的代码重用的最初想法。

可接受的替代方案是只有一个库二进制文件的副本,以及一组帮助客户机二进制项目定位它的规则。这种规则集通常称为构建时库位置规则,通常由开发平台的链接器支持。这些规则基本上规定了如何将完成客户端二进制链接所需的库路径信息传递给链接器。

构建时库位置规则相当复杂,并且有多种选择。每一个主要的开发平台通常都提供了一套非常复杂的选项来决定如何实施这些规则。

理解构建时库位置规则与静态和动态库都相关是非常重要的。不管链接静态库和动态库之间的实际差异,链接器仍然必须知道所需库二进制文件的位置。

最终用户运行时用例场景

一旦开发人员集成了第三方库,他们的产品就可以交付给最终客户了。基于各种各样的设计标准和现实生活中的考虑,所交付产品的结构可能有各种各样的选择:

  • 在最简单的情况下,产品包只包含一个应用程序文件。预期用途是客户端简单地运行应用程序。

这个案子很简单。为了访问和运行应用程序,用户只需将它的路径添加到全局 path 环境变量中。除了完全不懂计算机的人之外,任何人都有能力完成这个简单的任务。

  • 在更复杂的场景中,产品包混合了动态库和一个或多个实用程序。动态库可以是直接转发的第三方库,也可以是由软件包供应商创建的,或者是两者的组合。

预期用途是各种应用程序与所提供的动态库动态链接。多媒体领域中这种情况的典型例子是诸如 DirectX 或 GStreamer 之类的多媒体框架,因为它们中的每一个都提供(或指望在运行时可用)一组精心制作的动态库,每一个都提供某一组明确定义的功能。

与开发用例场景非常相似,解决该问题的有意义的方法假设只有一个所需动态库的副本,位于安装过程部署它们的路径中。另一方面,驻留在大量不同路径上的大量客户端二进制文件(其他动态库或应用程序)可能需要这些库。

为了构建在运行时(或稍早,在加载时)查找动态库二进制文件的过程,需要建立一组运行时库位置规则。运行时库位置规则通常相当复杂。每一个开发平台都提供了自己风格的复杂选项,来决定如何实施这些规则。

最后——冒着重复显而易见的风险——运行时库位置规则只适用于动态库。静态库的集成总是在运行时之前完成(即,在客户端二进制构建过程的链接阶段),并且从来不需要在运行时定位静态库。

构建时间库位置规则

在这一节中,我将讨论为库二进制文件提供构建时路径的技术。除了提供链接器的完整路径这一最简单的可能步骤之外,还有一些额外的技巧值得您注意。

Linux 构建时库位置规则

如何在 Linux 上实现构建时库位置规则的诀窍的重要部分属于 Linux 库命名约定。

Linux 静态库命名约定

Linux 静态库文件名是根据以下模式标准创建的:

static library filename = lib + <library name> +

库文件名的中间部分是库的实际名称,用于将库提交给链接器。

Linux 动态库命名约定

Linux 有一个非常复杂的动态库命名约定方案。即使最初的意图是解决库版本问题,命名约定方案也会影响库位置机制。下面几段将说明要点。

动态库文件名与库名

Linux 动态库文件名是根据以下模式标准创建的:

dynamic library filename =``lib``+``<library name>``+``.so

库文件名的中间部分是库的实际名称,用于将库提交给链接器,然后提交给构建时库搜索以及运行时库搜索过程。

动态库版本信息

库文件名的最后一部分携带的库版本信息遵循以下约定:

dynamic library version information = < M>.<m>.<p>

其中每个助记符可以代表一个或多个数字

  • m:主要版本
  • m:次要版本
  • p:补丁(次要代码更改)版本

动态库版本信息的重要性将在第十一章中详细讨论。

动态库名称

根据定义,动态库的 soname 可以指定为

library``soname``= lib +``<library``name``>``+``.so``+ <library``major version``digit(s)>

例如,libz.so.1.2.3.4 库的 soname 应该是 libz.so.1。

事实上,只有主要版本数字在库的 soname 中起作用,这意味着次要版本不同的库仍将由相同的 soname 值来描述。具体如何使用这一特性将在第十一章的“动态库版本处理”部分讨论。

库 soname 通常由链接器嵌入到库的二进制文件的专用 ELF 字段中。指定库 soname 的字符串通常通过专用的链接器标志传递给链接器,如下所示:

$ gcc -shared <list of object files>``-Wl,-soname,

检查二进制文件内容的实用程序通常提供检索 soname 值的选项(图 7-1 )。

A978-1-4302-6668-6_7_Fig1_HTML.jpg

图 7-1。

Library soname embedded in the library binary’s ELF header

链接器与人类对库名的感知

请注意,这些约定所描述的库名不一定在人类对话中用来表示库。例如,在给定机器上提供压缩功能的库可能驻留在文件名 libz.so.1.2.3.4 中。根据库命名约定,该库的名称简单地为“z”,它将在与链接器和加载器的所有交易中使用。从人类交流的角度来看,库可以被称为“libz”,例如在错误跟踪系统中的以下错误描述中:“问题 3142:缺少 libz 二进制文件的问题”。为了避免混淆,有时库名也被称为库的链接器名。

Linux 构建时库位置规则详细信息

构建时库路径规范在 Linux 上以所谓的-L -l选项的形式实现。使用这两个选项的真正正确方法可以通过以下一组准则来描述:

  • 将完整的库路径分为两部分:文件夹路径和库文件名。
  • 通过将文件夹路径追加到-L链接器标志之后,将文件夹路径传递给链接器。
  • 通过将库名(链接器名)附加在-l标志之后,仅将其传递给链接器。

例如,通过编译文件main.cpp并链接到位于文件夹../sharedLib中的动态库libworkingdemo.so来创建应用程序演示的命令行可能如下所示:

$ gcc main.o``-L``../sharedLib``-l

^              ^

|              |

library folder path    library name only

(not the full library filename !)

在 gcc 行结合了编译和链接的情况下,这些链接器标志应该加上-Wl,标志,就像这样:

$ gcc -Wall -fPIC main.cpp``-Wl,-L``../sharedLib``-Wl,-l

初学者的错误:什么可能出错以及如何避免

在处理动态库的场景中,当下列任一情况发生时,典型的问题会发生在缺乏耐心和经验的程序员身上:

  • 动态库的完整路径被传递给-l选项(不使用-L部分)。
  • 路径的一部分通过-L选项传递,路径的其余部分(包括文件名)通过-l选项传递。

链接器通常正式接受这些指定构建时库路径的变体。如果提供了通向静态库的路径,这些“创造性的自由”不会带来任何问题。

然而,当传递到动态库的路径时,由于偏离传递库路径的真正正确的方式而引入的问题开始在运行时出现。例如,假设一个客户端应用程序演示依赖于库libmilan.so,它驻留在开发人员的机器上的以下文件夹中:

/home/milan/mylibs/case_a/libmilan.so

以下链接器命令行成功构建了客户端应用程序:

$ gcc main.o -l/home/milan/mylibs/case_a/libmilan.so -o demo

并且在同一台机器上运行良好。

现在让我们假设这个项目被部署到另一台机器上,并被授予一个名为“john”的用户当该用户尝试运行该应用程序时,什么也不会发生。仔细的调查(其技术将在第十三章和第十四章中讨论)将揭示应用程序在运行时需要动态库libmilan.so(这是可以的),但它期望在路径/ home/milan/mylibs/case_a/找到它。

不幸的是,这个文件夹在用户“john”的机器上不存在!

指定相对路径而不是绝对路径可能只能部分缓解问题。例如,如果库路径被指定为相对于当前文件夹(即../mylibs/case_a/libmilan.so),则只有在客户机二进制文件和所需的动态库被部署到约翰的机器上的文件夹结构中时,约翰的机器上的应用程序才会运行,该文件夹结构保持可执行文件和动态库之间的确切相对位置。但是,如果 john 敢于将应用程序复制到不同的文件夹,并试图从那里执行它,那么原来的问题就会再次出现。

不仅如此,应用程序可能会停止工作,甚至在开发人员的机器上,它曾经完美的工作。如果您决定将应用程序二进制文件复制到开发人员机器上的不同路径,加载程序将开始在相对于应用程序二进制文件所在位置的路径上搜索库。很可能这样的路径将不存在(除非你费心重新创建它)!

理解问题根本原因的关键是要知道链接器和加载器并不同等重视通过-L-l选项传递的库路径。

事实上,链接器赋予您在-l选项下传递的内容更多的意义。更具体地说,通过-L选项传递的那部分路径只在链接阶段有用,但此后就不起作用了。

然而,在-l选项下指定的部分被印入二进制库,并在运行时继续发挥重要作用。事实上,当试图找到运行时所需的库时,加载程序首先读取客户机二进制文件,试图找到这个特定的信息。

如果您敢于背离严格的规则,通过-l选项传递除了库文件名以外的任何内容,那么在 john 的机器上部署和运行时,在 milan 的机器上构建的应用程序将在硬编码路径中查找动态库,这很可能只存在于开发人员(milan)的机器上,而不存在于用户(john)的机器上。图 7-2 对此概念进行了说明。

A978-1-4302-6668-6_7_Fig2_HTML.jpg

图 7-2。

The -L convention plays a role only during library building. The impact of -l convention, however, remains important at runtime, too

Windows 生成时库位置规则

有几种方法可以将链接时所需的关于动态库的信息传递给项目。不管选择哪种方式来指定构建时位置规则,该机制对静态和动态库都有效。

项目链接器设置

标准选项是提供链接器所需的有关 DLL 的信息,如下所示:

A978-1-4302-6668-6_7_Fig4_HTML.jpg

图 7-4。

Specify the library paths

  • 将导入库的路径添加到库路径目录集中(图 7-4 )。

A978-1-4302-6668-6_7_Fig3_HTML.jpg

图 7-3。

Specify needed libraries in the list of dependencies

  • 指定 DLL 的导入库(。lib)文件(图 7-3 )。
#pragma Comment

可以通过在源文件中添加这样一行来指定库要求:

#pragma comment(lib, "<import library name, full path, or relative path>");

当遇到这个指令时,编译器将在目标文件中插入一个库搜索记录,该记录最终将被链接器获取。如果双引号中仅提供了库文件名,则库搜索将遵循 Windows 库搜索规则。通常,该选项用于在搜索库的过程中提高精确度,因此,与其他方式相比,更常用于指定库的完整路径和版本。

以这种方式指定构建时库需求的一个巨大优势是,通过在源代码中,它使设计人员能够根据预处理器指令定义链接需求。例如,

#ifdef CUSTOMER_XYZ

#pragma comment(lib, "<customerXYZ-specific library>");

#else

#ifdef CUSTOMER_ABC

#pragma comment(lib, "<customerABC-specific library>");

#else

#ifdef CUSTOMER_MPQ

#pragma comment(lib, "<customerMPQ-specific library>");

#endif // CUSTOMER_MPQ

#endif // CUSTOMER_ABC

#endif // CUSTOMER_XYZ

库项目的隐式引用

只有在特殊情况下,当动态库项目及其客户端可执行项目都是同一 Visual Studio 解决方案的组成部分时,才可以使用此选项。如果将 DLL 项目添加到客户端应用程序项目的引用列表中,Visual Studio 环境将自动提供构建和运行应用程序所需的一切(对程序员来说几乎是不可见的)。

首先,它会将 DLL 的完整路径传递给应用程序的链接器命令行。最后,它会将 DLL 复制到应用程序的运行时文件夹(对于调试版本通常是Debug,对于发布版本通常是Release),从而以最简单的方式满足运行时库位置的规则。

图 7-5 到 7-8 使用由两个相关项目组成的解决方案(SystemExamination)的例子说明了如何做到这一点:由 SystemExaminerDemoApp 应用程序静态感知链接的 SystemExaminer DLL。

我不会依靠前面描述的第一种方法(即通过指定 DLL 的导入库(链接器输入列表中的lib)文件)。图 7-5 展示了这个看似奇特又有点反直觉的细节。

A978-1-4302-6668-6_7_Fig5_HTML.jpg

图 7-5。

In this method, you don’t need to specify the library dependency directly

相反,将客户端二进制项目设置为引用依赖库项目就足够了。访问通用属性➤框架和参考选项卡(图 7-6 )。

A978-1-4302-6668-6_7_Fig6_HTML.jpg

图 7-6。

Adding a reference to the dependency library project

图 7-7 显示引用依赖库项目完成。

A978-1-4302-6668-6_7_Fig7_HTML.jpg

图 7-7。

Referencing the dependency library project is completed

最终结果将是所需 DLL 的构建时路径被传入链接器的命令行,如图 7-8 所示。

A978-1-4302-6668-6_7_Fig8_HTML.jpg

图 7-8。

The result of implicit referencing: the exact path to the library is passed to the linker

运行时动态库位置规则

加载程序需要知道动态库的二进制文件的确切位置,以便打开、读取并加载到进程中。程序运行可能需要的动态库种类繁多,从总是需要的系统库,一直到定制的、专有的、特定于项目的库。

从程序员的角度来看,硬编码每个动态库的路径似乎是完全错误的。如果程序员只需提供动态库文件名,操作系统就会知道在哪里寻找这个库,那就更有意义了。

所有主要的操作系统都认识到需要实现这样一种机制,它能够在运行时根据程序提供的库文件名搜索和找到动态库。不仅定义了一组预定的库位置,还定义了搜索顺序,指定了操作系统将首先查找的位置。

最后,不管动态库是静态加载的还是在运行时加载的,知道动态库的运行时位置都同样重要。

Linux 运行时动态库位置规则

运行时搜索动态库的算法由以下一组规则控制,这些规则以较高的优先级顺序列出。

预加载的库

毫无疑问,高于任何库搜索的最高优先级是为指定预加载的库保留的,因为加载程序首先加载这些库,然后开始搜索其他库。有两种方法可以指定预加载的库:

  • 通过设置 LD_PRELOAD 环境变量。

export``LD_PRELOAD``=/home/milan/project/libs/libmilan.so

  • 通过/etc/ld.so.preload 文件。

这个文件包含一个空格分隔的 ELF 共享库列表,在程序运行前加载。

指定预加载的库不是标准的设计规范。相反,它用于特殊场景,如设计压力测试、诊断和原始代码的紧急修补。

在诊断场景中,您可以快速创建一个标准函数的定制版本,用调试输出来修饰它,并构建一个共享库,它的预加载将有效地替换标准地提供这种函数的动态库。

在完成对指示预加载的库的加载之后,开始搜索被列为依赖项的其他库。它遵循一组精心设计的规则,其完整列表(从最高优先级方法向下排列)将在以下章节中解释。

rpath 先生

从很早的时候开始,ELF 格式就有了用于存储 ASCII 字符串的DT_RPATH字段,该字符串携带了与二进制文件相关的搜索路径细节。例如,如果可执行的 XYZ 依赖于动态库 ABC 的运行时存在,那么 XYZ 可以在它的DT_RPATH中携带指定运行时可以找到库 ABC 的路径的字符串。

这个特性很明显代表了一个很好的进步,允许程序员对部署问题建立更紧密的控制,最显著的是避免了预期库和可用库版本之间可能出现的大范围不匹配。

由可执行 XYZ 的DT_RPATH字段携带的信息最终将在运行时由加载程序读出。需要记住的一个重要细节是,加载程序启动的路径在解释DT_RPATH信息时发挥了作用。最值得注意的是,在DT_RPATH带有相对路径的情况下,它将不会被解释为相对于库 XYZ 的位置,而是相对于加载程序(即应用程序)启动的路径。虽然不错,但是rpath的概念经过了一定的修改。

根据网络资源,大约在 1999 年,当 C 运行时库的版本 6 正在取代版本 5 的过程中,rpath 的某些缺点已经被注意到,它大部分被 ELF 二进制文件格式的一个非常相似的字段runpath ( DT_RUNPATH)所取代。

现在,rpathrunpath都可用,但是runpath在运行时搜索优先级列表中被给予更高的关注。只有在其弟弟runpath ( DT_RUNPATH字段)不存在的情况下,rpath ( DT_RPATH字段)才保留 Linux 加载程序的最高优先级的搜索路径信息。然而,如果 ELF 二进制的runpath ( DT_RUNPATH)字段非空,则rpath被忽略。

通常通过向链接器传递紧跟在您想要指定为 runpath 的路径之后的-R-rpath标志来设置rpath。此外,按照惯例,每当链接器被间接调用时(即通过调用gccg++),链接器标志需要由前缀-Wl(即“减去 Wl 逗号”):

$ gcc``-Wl,-R

^   ^       ^

|   |       |

|   |       actual rpath value

|   |

|   run path linker flag

|

-Wl, prefix required when invoking linker

indirectly, through gcc instead of

directly invoking ld

或者,可以通过指定LD_RUN_PATH环境变量来设置 rpath:

$ export``LD_RUN_PATH``=/home/milan/projects

最后,通过运行chrpath实用程序,可以在事后修改二进制文件的 rpath。chrpath的一个显著缺点是它不能修改超过现有字符串长度的rpath。更准确地说,chrpath可以修改和删除/清空DT_RPATH字段,但不能插入或扩展到更长的字符串。

检查二进制文件中DT_RPATH字段值的方法是检查二进制文件的 ELF 头(比如运行readelf -dobjdump -f)。

LD_LIBRARY_PATH 环境变量

在库搜索路径概念发展的早期,开发人员认识到需要一种临时的、快速而有效的机制来试验和测试他们的设计。通过提供特定环境变量(LD_LIBRARY_PATH)将用于满足这些需求的机制来解决需求。

当没有设置rpath ( DT_RPATH)值时,以这种方式提供的该路径被用作最高优先级搜索路径信息。

Note

在这个优先级方案中,嵌入在二进制文件中的值和环境变量之间存在不均衡的竞争。如果事情保持不变,二进制文件中出现的rpath将使得无法用第三方软件产品解决问题。幸运的是,新的优先级方案解决了这个问题,它认识到rpath过于独裁,并提供了一种暂时覆盖其设置的方法。rpath的弟弟runpath被赋予压制流氓和独裁rpath的力量,在这种情况下LD_LIBRARY_PATH有机会暂时获得最高优先级的待遇。

设置LD_LIBRARY_PATH的语法与设置任何类型的路径变量的语法相同。这可以在特定的 shell 实例中通过键入以下内容来完成:

$ export``LD_LIBRARY_PATH``=/home/milan/projects

同样,这种机制的使用应该保留用于实验目的。软件产品的生产版本根本不应该依赖这种机制。

运行路径

runpath概念遵循与rpath相同的原理。它是 ELF 二进制格式的字段(DT_RUNPATH),可以在构建时设置为指向动态库应该查找的路径。相对于权威不容置疑的rpath,runpath被设计成对LD_LIBRARY_PATH机制的紧急需求比较宽容。

runpath的设置方式与rpath的设置方式非常相似。除了传递-R-rpath链接器标志,还需要使用一个额外的--enable-new-dtags链接器标志。正如在rpath的情况下已经解释的,每当通过调用gcc(或g++)而不是直接调用ld来间接调用链接器时,按照惯例,链接器标志需要加上前缀-Wl:

$ gcc``-Wl,-R``/home/milan/projects/``-Wl,--enable-new-dtags

^   ^       ^                             ^

|   |       |                             |

|   |       actual rpath value            both rpath and runpath set

|   |                                     to the same string value

|   run path linker flag

|

-Wl, prefix required when invoking linker

indirectly, through gcc instead of

directly invoking ld

通常,只要指定了 runpath,链接器就会将rpathrunpath设置为相同的值。

检查二进制文件中DT_RUNPATH字段值的方法是检查二进制文件的 ELF 头(比如运行readelf -hobjdump -f)。

从优先级的角度来看,只要DT_RUNPATH包含一个非空字符串,加载程序就会忽略DT_RPATH字段。这样,rpath的独裁权力被压制,而LD_LIBRARY_PATH的意志在真正需要的时候得到尊重。

有用的实用程序patchelf能够修改二进制文件的runpath字段。目前,它还不是官方资料库的一部分,但它的源代码和简单的手册可以在 http://nixos.org/patchelf.html 找到。编译二进制文件相当简单。下面的例子说明了patchelf的用法:

$ patchelf --set-rpath <one or more paths> <executable>

^

|

multiple paths can be defined,

separated by a colon (:)

Note

尽管patchelf文档提到了rpath,但是patchelf实际上作用于runpath字段。

ldconfig 高速缓存

标准代码部署过程之一是基于运行 Linux ldconfig实用程序( http://linux.die.net/man/8/ldconfig )。运行ldconfig实用程序通常是标准包安装过程中的最后一步,通常需要将包含库的文件夹路径作为输入参数。结果是ldconfig将指定的文件夹路径插入到保存在/etc/ld.so.conf文件中的动态库搜索文件夹列表中。同样,对新添加的文件夹路径扫描动态库,结果是找到的库的文件名被添加到保存在/etc/ld.so.cache文件中的库文件名列表中。比如对我开发的 Ubuntu 机器的检查,揭示了图 7-9 中/etc/ld.so.conf文件的内容。

A978-1-4302-6668-6_7_Fig9_HTML.jpg

图 7-9。

The contents of /etc/ld.so.conf file

ldconfig预扫描/etc/ld.so.conf文件中列出的所有目录时,它会找到大量的动态库,这些动态库的文件名保存在/etc/ld.so.cache文件中(图 7-10 中只显示了一小部分)。

A978-1-4302-6668-6_7_Fig10_HTML.jpg

图 7-10。

The contents (small part) of the /etc/ld.so.cache file Note

/etc/ld.so.conf文件引用的一些库可能位于所谓的可信库路径中。如果在构建可执行文件时使用了-z nodeflib链接器标志,那么在库搜索过程中,在操作系统信任的库路径中找到的库将被忽略。

默认库路径(/lib 和/usr/lib)

路径/lib/usr/lib是 Linux 操作系统保存动态库的两个默认位置。设计用于超级用户权限和/或对所有用户可用的第三方程序通常将其动态库部署到这两个位置之一。

请注意,/usr/ local /lib路径不属于此类。当然,没有什么可以阻止您通过使用前面描述的机制之一添加到优先级列表中。

Note

如果可执行文件与-z nodeflib链接器标志链接,则在库搜索期间,在操作系统信任的库路径中找到的所有库都将被忽略。

优先级方案摘要

总之,优先级方案有以下两个操作版本。

当指定了RUNPATH字段时(即DT_RUNPATH非空)

LD_LIBRARY_PATH   runpath (DT_RUNPATH field)   ld.so.cache   default library paths (/lib and /usr/lib)  

在没有RUNPATH的情况下(即DT_RUNPATH是空字符串)

RPATH of the loaded binary, followed by the RPATH of the binary, which loads it all the way up to either the executable or the dynamic library which loads all of them   LD_LIBRARY_PATH   ld.so.cache   default library paths (/lib and /usr/lib)  

有关这个特定主题的更多细节,请查看 Linux loader 手册页( http://linux.die.net/man/1/ld )。

Windows 运行时动态库位置规则

在关于该主题的最简单、最流行、最广泛的知识中,以下两个位置是最常用的部署运行时所需 DLL 的路径:

  • 应用程序二进制文件所在的路径
  • 一个系统 DLL 文件夹(如C:\Windows\SystemC:\Windows\System32)

然而,这并不是故事的结尾。Windows 运行时动态库搜索优先级方案要复杂得多,因为以下因素在优先级方案中起作用:

  • Windows 应用商店应用程序(Windows 8)与 Windows 桌面应用程序具有不同的规则集。
  • 内存中是否已经加载了同名的 DLL。
  • 该 DLL 是否属于给定版本的 Windows OS 的已知 DLL 组。

要获得更精确和最新的信息,查看微软关于这个主题的官方文档是最有意义的,目前位于 http://msdn.microsoft.com/en-us/library/windows/desktop/ms682586(v=vs.85).aspx

构建时和运行时约定的 Linux 演示

下面的例子说明了严格遵循-L-R约定的积极效果。本例中使用的项目由动态库项目及其测试应用程序项目组成。为了展示应用-L惯例的重要性,我们创建了两个演示应用程序。第一个名为testApp_withMinusL的例子展示了使用-L链接器标志的积极效果。另一个(testApp_withoutMinusL)展示了如果不遵守-L约定会发生什么样的故障。

两个应用程序都依赖于rpath选项来指定所需动态库的运行时位置。动态库的项目文件夹和应用程序的项目文件夹的结构如图 7-11 所示。

A978-1-4302-6668-6_7_Fig11_HTML.jpg

图 7-11。

The folder structure of project designed to illustrate the benefits of strictly following the –L –l conventions

不依赖于-L约定的应用程序的 Makefile 如清单 7-1 所示。

清单 7-1。Makefile 不依赖于–L 约定

# Import includes

COMMON_INCLUDES  = -I../sharedLib/exports/

# Sources/objects

SRC_PATH        = ./src

OBJECTS          = $(SRC_PATH)/main.o

# Libraries

SYSLIBRARIES    =          \

-lpthread \

-lm      \

-ldl

DEMOLIB_PATH    = ../deploy

# specifying full or partial path may backfire at runtime !!!

DEMO_LIBRARY    = ../deploy/libdynamiclinkingdemo.so

LIBS            = $(SYSLIBRARIES) $(DEMO_LIBRARY) -Wl,-Bdynamic

# Outputs

EXECUTABLE      = demoNoMinusL

# Compiler

INCLUDES        = $(COMMON_INCLUDES)

DEBUG_CFLAGS    = -Wall -g -O0

RELEASE_CFLAGS  = -Wall -O2

ifeq ($(DEBUG), 1)

CFLAGS          = $(DEBUG_CFLAGS) $(INCLUDES)

else

CFLAGS          = $(RELEASE_CFLAGS) $(INCLUDES)

Endif

COMPILE          = g++ $(CFLAGS)

# Linker

RUNTIME_LIB_PATH = -Wl,-R$(DEMOLIB_PATH)

LINK            = g++

# Build procedures/target descriptions

default: $(EXECUTABLE)

%.o: %.c

$(COMPILE) -c $< -o $@

$(EXECUTABLE): $(OBJECTS)

$(LINK) $(OBJECTS) $(LIBS) $(RUNTIME_LIB_PATH) -o $(EXECUTABLE)

clean:

rm $(OBJECTS) $(EXECUTABLE)

deploy:

make clean; make; patchelf --set-rpath ../deploy:./deploy $(EXECUTABLE);\

cp $(EXECUTABLE) ../;

遵循-L约定的应用程序的 Makefile 如清单 7-2 所示。

清单 7-2。遵循–L 约定的 Makefile

# Import includes

COMMON_INCLUDES  = -I../sharedLib/exports/

# Sources/objects

SRC_PATH        = ./src

OBJECTS          = $(SRC_PATH)/main.o

# Libraries

SYSLIBRARIES    =          \

-lpthread \

-lm      \

-ldl

SHLIB_BUILD_PATH = ../sharedLib

DEMO_LIBRARY    = -L$(SHLIB_BUILD_PATH) -ldynamiclinkingdemo

LIBS            = $(SYSLIBRARIES) $(DEMO_LIBRARY) -Wl,-Bdynamic

# Outputs

EXECUTABLE      = demoMinusL

# Compiler

INCLUDES        = $(COMMON_INCLUDES)

DEBUG_CFLAGS    = -Wall -g -O0

RELEASE_CFLAGS  = -Wall -O2

ifeq ($(DEBUG), 1)

CFLAGS          = $(DEBUG_CFLAGS) $(INCLUDES)

else

CFLAGS          = $(RELEASE_CFLAGS) $(INCLUDES)

endif

COMPILE          = g++ $(CFLAGS)

# Linker

DEMOLIB_PATH    = ../deploy

RUNTIME_LIB_PATH = -Wl,-R$(DEMOLIB_PATH)

LINK            = g++

# Build procedures/target descriptions

default: $(EXECUTABLE)

%.o: %.c

$(COMPILE) -c $< -o $@

$(EXECUTABLE): $(OBJECTS)

$(LINK) $(OBJECTS) $(LIBS) $(RUNTIME_LIB_PATH) -o $(EXECUTABLE)

clean:

rm $(OBJECTS) $(EXECUTABLE)

deploy:

make clean; make; patchelf --set-rpath ../deploy:./deploy $(EXECUTABLE);\

cp $(EXECUTABLE) ../;

当构建动态库的过程完成时,它的二进制文件被部署到deploy文件夹中,该文件夹位于应用程序 Makefile 所在文件夹的两层深度之上。因此,构建时路径需要指定为../deploy/libdynamiclinkingdemo.so

图 7-12 展示了遵守-L约定的优势:程序不受运行时库路径变化的影响。

当使用-L选项指定构建时库路径时,库名称被有效地从路径中分离出来,并被印入客户端二进制文件中。当执行运行时搜索时,印记名称(即,不是路径加名称,而仅仅是库名称!)非常适合运行时搜索算法的实现。

A978-1-4302-6668-6_7_Fig12_HTML.jpg

图 7-12。

The benefit of carefully following the –L –l conventions. Following the convention typically means being worry free at runtime

八、设计动态库:高级主题

Abstract

本章的目的是讨论动态链接过程的细节。这个过程中的一个关键因素是内存映射概念。基本上,它允许将已经加载到正在运行的进程的内存映射中的动态库映射到同时运行的另一个进程的内存映射中。

本章的目的是讨论动态链接过程的细节。这个过程中的一个关键因素是内存映射概念。基本上,它允许将已经加载到正在运行的进程的内存映射中的动态库映射到同时运行的另一个进程的内存映射中。

动态链接的重要规则是不同的进程共享动态库的代码段,但不共享数据段。期望加载动态库的每个进程提供其自己的数据副本,动态库代码在该数据上操作(即,库的数据段)。按照烹饪类比,几家餐馆的几个厨师可以同时使用同一本书的食谱(说明)。然而,很有可能不同的厨师会使用同一本书上的不同食谱。此外,根据同一本烹饪书的食谱准备的菜肴将提供给不同的顾客。显然,尽管厨师们阅读的是同一本食谱,但他们每个人都应该使用自己的一套餐具和厨房用具。否则,将是一个巨大的混乱。

尽管整个故事现在看起来既伟大又简单,但在这个过程中仍有几个技术问题需要解决。让我们仔细看看。

为什么解析内存地址是必须的

在深入研究动态链接实现设计过程中遇到的技术问题的细节之前,有必要重申一些简单的事实,这些事实植根于汇编语言和机器指令领域,最终决定了许多其他细节。

也就是说,某些指令组希望在运行时知道操作数在内存中的地址。一般来说,以下两组指令严格要求精确计算地址:

  • 数据访问指令(mov等)。)需要内存中操作数的地址。例如,为了访问数据变量,X86 体系结构的 mov 汇编指令需要变量的绝对存储器地址,以便在存储器和 CPU 寄存器之间传输数据。

以下汇编指令序列用于递增存储在存储器中的变量:

mov eax, ds:0xBFD10000 ; load the variable from address 0xBFD10000 to register eax

add eax, 0x1           ; increment the loaded value

mov ds:0xBFD10000, eax ; store the result back to the address 0xBFD10000

  • 子程序调用(calljmp等)。)需要代码段中函数的地址。例如,为了调用一个函数,调用指令必须提供函数入口点的代码段内存地址。

下面的汇编指令序列执行实际的函数调用:

call 0x0A120034 ; calling function whose entry point resides at address 0x0A120034

这相当于

push eip + 2    ; return address is current address + size of two instructions

jmp 0x0A120034  ; jumping to the address of my_function

为了使事情变得简单一些,有些情况下仅仅是相对偏移起了作用。静态变量的地址以及局部范围的函数的入口点(在 C 编程语言的意义上,都是通过使用static关键字来声明的)可以通过仅知道相对于引用它们的指令的相对偏移量来解析。数据访问和/或子程序调用汇编指令都需要相对偏移量而不是绝对地址。然而,这并没有解决整个问题;这只是在一定程度上减少了它。

解析引用的一般问题

让我们考虑最简单的可能情况,其中可执行文件(应用程序)是加载单个动态库的客户端二进制文件。以下一组已知事实描述了工作场景:

  • 可执行二进制代码提供了进程存储器映射蓝图的固定的、预定的部分。
  • 一旦动态加载完成,动态库就成为该过程的一个合法部分。
  • 通过可执行程序调用一个或多个由动态库实现并正确导出的函数,可执行程序和动态库之间的连接自然发生。

有趣的部分来了。

将库加载到进程内存映射中的过程从将库段的地址范围转换到新位置开始。一般来说,动态库将被加载的地址范围事先是不知道的。相反,它是由加载器模块的内部算法在加载时确定的。

在这种情况下,不确定性的程度仅仅因为可执行格式规定了动态库可以被加载的地址范围而稍微减少。然而,规定的允许地址范围相当宽,因为它被设计成容纳同时加载的许多动态库。这显然对猜测动态库最终将被加载到哪里没有太大帮助。

动态库加载期间发生的地址转换过程(如图 8-1 所示)是动态链接的关键问题,这使得整个概念相当复杂。

A978-1-4302-6668-6_8_Fig1_HTML.jpg

图 8-1。

Address translation inevitably happens as the loader tries to find a place for the dynamic library in the process memory map

地址转换到底出了什么问题?

地址转换本身不是问题。在前面的章节中,你已经看到,当链接器试图将目标文件拼接到进程内存映射中时,它通常会执行这个简单的操作。但是,由哪个模块执行地址转换非常重要。

更具体地说,链接器执行地址转换的场景与加载器执行相同操作的场景有很大的不同。

  • 当执行地址转换时,链接器通常具有“干净的石板/未开垦的白雪”的情形。链接器在平铺过程中接受的对象文件都没有解析任何引用。这给了链接器很大的自由度,当它试图为目标文件找到正确的位置时,它可以灵活地处理它们。完成目标文件的初始放置后,链接器扫描未解析引用的列表,解析它们,并将正确的地址标记到汇编指令中。
  • 另一方面,装载机在非常不同的环境下工作。它将动态库二进制文件作为输入,这个二进制文件已经通过了完整的构建过程,并且已经解析了所有的引用。换句话说,所有的汇编指令都标有正确的地址。

在链接器将绝对地址印入汇编指令的特殊情况下,由加载器执行的地址转换使得印入的地址完全没有意义。执行这种从根本上被破坏的指令最多只能给出虚假的结果,而且有可能非常危险。显然,在动态加载期间执行的地址转换属于“中国商店中的大象”范例的宽泛范畴。

总之,加载程序的地址转换是不可避免的,因为这是动态加载的固有思想。然而,它立即带来了一个非常严重的问题。幸运的是,尽管它无法避免,一些绕过它的方法已经成功实现。

哪些符号可能会受到地址转换的影响?

声明为 static 的函数和变量(在 C 语言中,只与它们所在的文件相关)没有危险,这几乎是显而易见的。事实上,由于只有附近的指令需要访问这些符号,所以所有的访问都可以通过提供相对地址偏移量来实现。

没有声明为静态的函数和变量是什么情况?

事实证明,没有被声明为 static 仍然不意味着这样的函数或变量将不可避免地遭受地址转换。

事实上,只有其符号由动态库导出的函数和变量肯定会受到地址转换的负面影响。事实上,当链接器知道某个符号被导出时,它通过绝对地址实现所有的访问。然后,地址转换使得这些指令不可用。

附录 A 中分析的代码示例说明了这一点,其中代码中有两个非静态变量,其中只有一个是由动态库导出的。如分析所示,导出变量是受动态加载地址转换影响的变量。

地址转换引起的问题

动态加载期间的地址转换有时会导致问题。幸运的是,这些可以被系统化为两个一般场景。

场景 1:客户端二进制文件需要知道动态库符号的地址

这是最基本的场景,当客户端二进制文件(可执行文件或动态库)指望加载的动态库的符号在运行时可用,但不知道最终地址是什么时,就会发生这种情况,如图 8-2 所示。

A978-1-4302-6668-6_8_Fig2_HTML.jpg

图 8-2。

Scenario 1: The client binary must resolve dynamic library symbols

如果您采用通常的方法,解析符号地址的任务传统上属于链接器(并且只属于链接器),那么您就陷入了麻烦的境地。也就是说,链接器已经完成了构建客户端二进制文件和库的任务,库是动态加载的。

很快就变得非常明显,为了解决这种情况,需要应用某些“跳出框框”的思维。该解决方案将链接器解析符号的部分职责授予加载器。

在新的方案中,加载程序获取一些链接程序能力的新能力通常被实现为一个通常被称为动态链接程序的模块。

场景 2:加载的库不再知道它自己符号的地址

通常,动态库导出的 ABI 函数是库内部功能的封装良好的入口点。运行时发生的典型顺序是,客户端二进制文件通常调用一个 ABI 方法,该方法又调用库的内部函数,这些函数对客户端二进制文件没有特别的兴趣,因此不会被导出。

一种可能的不同场景(尽管不太常见)是动态库 ABI 函数在内部调用另一个 ABI 函数。

例如,让我们假设一个动态库拥有一个导出两个接口函数的模块:

  • Initialize()
  • Uninitialize()

这两个函数的内部执行流很可能采用库内部函数的调用顺序,用静态范围声明。调用内部方法通常由以相对地址为特征的汇编调用系列指令来执行。地址转换不会对调用功能的实现产生负面影响,如图 8-3 所示。

A978-1-4302-6668-6_8_Fig3_HTML.jpg

图 8-3。

Regardless of address translation, the calls to local functions (which may may be implemented as relative jumps) can be easily resolved

然而,库设计者完全有可能决定提供Reinitialize()接口函数。这个函数首先在内部调用Uninitialize()接口函数,紧接着调用Initialize()接口函数,这既不奇怪也不会错。

作为 ABI 接口函数,Reinitialize()函数的入口点必须属于动态库的输出符号集。引用此函数的跳转指令不能作为相对跳转来实现。相反,链接器必须将跳转/调用指令实现为到绝对地址的跳转。

显然,现在你有一个有趣的情况。在这种情况下,受损的一方不再仅仅是客户端二进制文件,还有加载的库。加载程序执行内存转换后,函数地址不再适用。链接器完美地印上绝对地址的汇编调用指令不仅没有意义,而且有潜在的危险,因为它们的跳转目标不再是原来计划的位置,如图 8-4 所示。

A978-1-4302-6668-6_8_Fig4_HTML.jpg

图 8-4。

Scenario 2: One ABI function internally calling another suffers from unresolved references problems. Both function entry points are designated for export, which urges the compiler to implement calls as absolute jumps. Resolving the absolute addresses is not possible until the loader completes the address translation

同样,ABI 函数面临的同样问题也存在于动态库的全局范围变量中。

链接程序-加载程序协调

人们很早就认识到,在动态链接场景中,链接器不能完全解决它通常在构建整体可执行文件时解决的所有问题。

在动态链接的初始阶段,加载程序将动态库的代码段加载到新的地址范围。即使链接器在构建动态库时合法地完成了解析引用的任务,这还远远不够;地址转换过程使得印在汇编调用指令中的绝对地址无效。

“地震”发生在链接程序竭尽所能之后,这一事实暗示着一定有“聪明人”在事后解决问题。“某个聪明人”被选为装卸工。

总体战略

知道了所有先前描述的约束,链接器和加载器之间的协作已经根据以下一组宽泛的准则建立:

  • 链接器认识到自己的局限性。
  • 链接器精确地估计损坏,准备修复损坏的指令,并将指令嵌入二进制文件。
  • 加载程序严格遵循链接程序指令,并在地址转换完成后应用更正。
链接器认识到自己的局限性

当创建一个动态库时,除了机智地找出谜题各部分之间的关系,链接器还必须足够聪明,以识别由于代码段加载到不同的地址范围而将会中断什么。

首先,动态库内存映射的代码地址范围是从零开始的,不像可执行文件,在可执行文件中,链接器处理更具体的非零地址范围。

第二,当认识到某些符号的地址在加载时间之前不能被解析时,链接器停止尝试;相反,它用临时值填充未解析的符号(通常是一些明显错误的值,如全零等)。

然而,这并不意味着链接器已经放弃了完成任务的追求。

链接器精确地估计损坏,并准备修复它的指令

完全有可能对加载程序地址转换将使先前解析的引用无效的所有情况进行分类。每当汇编指令需要绝对地址时,就会发生这种情况。当完成构建动态库的链接阶段时,链接器可以识别这样的事件,并以某种方式让加载器知道它们。

为了提供对链接器-加载器协调的支持,二进制格式规范支持全新的部分,其目的仅仅是为链接器提供位置,以便为加载器留下如何修复由动态加载期间发生的地址转换所引起的损坏的指令。此外,还设计了一种特定的简单语法,以便链接器可以精确地向加载器指定要采取的操作过程。这种段在二进制文件中被称为重定位段,其中的.rel.dyn段是最老的。

一般来说,重定位指令是由链接器写入二进制文件的,以后由加载器读取。他们指定

  • 在布局了整个进程的最终内存映射后,加载程序需要在这些地址应用一些补丁。
  • 为了正确地修补未解析的地址,加载程序到底需要做什么。
加载程序严格遵循链接器指令

最后一个阶段属于加载程序。它读入由链接器创建的动态库,读入加载程序段(每个加载程序段都带有各种链接器部分),并将它们全部放在进程内存映射中,与属于原始可执行文件的代码放在一起。

最后,它找到.rel.dyn部分,读入链接器留下的指令,并根据这些指令执行对原始动态库的修补。当修补完成时,存储器映射准备好启动该过程。

显然,处理动态库加载的任务要求赋予加载程序比其基本任务所需更多的智能。

策略

一般来说,链接器和加载器之间的信息交换是通过链接器插入到二进制代码体中的特定的.rel.dyn部分进行的。唯一的问题是链接器将在哪个二进制文件中插入.rel.dyn部分?

答案很简单:是吱吱响的轮子得到了油。代码段需要修复的二进制文件通常会带有.rel.dyn段。

具体来说,在场景 1 中,链接器将.rel.dyn部分嵌入到客户端二进制文件(可执行文件或动态库,其指令因加载新的动态库而被“损坏”),因为这是加载的库的地址转换导致问题的地方。图 8-5 说明了这个想法。

A978-1-4302-6668-6_8_Fig5_HTML.jpg

图 8-5。

In Scenario 1, the linker directives are embedded into the client binary file

然而,在场景 2 中,链接器将.rel.dyn部分嵌入到加载的库的二进制文件中,因为它需要帮助重建地址和指向它们的指令之间的一致性(图 8-6 )。

A978-1-4302-6668-6_8_Fig6_HTML.jpg

图 8-6。

In Scenario 2, the linker directives are embedded into the dynamic library

在这个特殊的例子中,有一个最简单的场景,其中可执行文件加载一个动态库。更现实的情况是,一个动态库本身可以加载另一个动态库,而另一个动态库又可以加载另一个动态库,等等。位于动态加载链中间的任何动态库都可能扮演双重角色。场景 1 和场景 2 可能恰好适用于同一个二进制文件。

链接器指令概述

二进制格式规范通常详细规定了链接器和加载器之间通信的语法规则。加载器的链接器指令通常非常简单,但是非常精确并且切中要点(图 8-7 )。因此,构建链接器指令所携带的信息并不需要花费大量的精力来实现和理解。

特别是,ELF 文件格式携带了链接器如何为加载器指定指令的详细定义。指令主要存储在.rel.dyn部分以及其他几个特殊部分(rel.pltgotgot.plt)。可以使用readelfobjdump等工具显示指令内容。图 8-7 显示了一些例子。

A978-1-4302-6668-6_8_Fig7_HTML.jpg

图 8-7。

Examples of linker directives

指令语法字段的解释如下:

  • Offset 指定汇编指令操作数的代码段字节偏移量,地址转换使其变得无意义,需要修复。
  • ELF 格式规范将信息描述为

#define ELF32_R_SYM(i)  ((i)>>8)

#define ELF32_R_TYPE(i)  ((unsigned char)(i))

#define ELF32_R_INFO(s,t) (((s)<<8)+(unsigned char)(t))

#define ELF64_R_SYM(i)  ((i)>>32)

#define ELF64_R_TYPE(i)  ((i)&0xffffffffL)

#define ELF64_R_INFO(s,t) (((s)<<32)+((t)&0xffffffffL)

在哪里

  • ELFxx_R_SYM表示必须进行重定位的符号表索引:

二进制文件的一个部分带有符号列表。该值仅表示代表该特定符号的符号表项目的索引。readelfobjdump可以提供包含在二进制符号表中的完整符号列表。

  • Sym.Value指定代码段(函数的情况下)或数据段(变量的情况下)中符号当前驻留在原始二进制文件中的暂定临时偏移量。假设地址转换会影响这些值。
  • Sym.Name指定人类可读的符号名(函数名、变量名)

A978-1-4302-6668-6_8_Fig8_HTML.jpg

图 8-8。

Overview of linker directive types (from ELF format specification)

  • ELFxx_R_TYPE表示要应用的重新定位类型。下面显示了可用重定位类型的详细描述。
  • Type 指定加载程序需要对汇编指令操作数执行的动作类型,以便修复由地址转换引起的问题。图 8-8 所示的 ELF 二进制格式(ELF 规范的图 1-22)规定了以下重定位类型。

链接器-加载器协调实现技术

在动态链接概念的发展过程中,使用了两种实现技术:加载时间重定位(LTR)和位置独立代码(PIC)。

加载时间重定位(LTR)

按时间顺序,动态链接概念的第一次实现是以所谓的加载时间重定位的形式出现的。概括地说,这种技术是第一种真正有效的动态加载技术。它的直接好处是能够将应用程序二进制文件从携带不必要的“行李”(处理特定于操作系统的日常事务的代码)的需要中解放出来。

LTR 概念带来的直接好处是,不仅应用程序二进制文件的字节大小变得非常小,而且某些特定于操作系统的任务的执行方式在各种应用程序之间变得统一。

尽管这个概念带来了明显的好处,但它也有几个主要的缺点。首先,这种技术用变量和函数的地址的文字值修改(修补)动态库代码,只有在首先加载它的应用程序的上下文中才有意义。在任何其他应用程序(很可能以不同进程的内存映射布局为特征)的上下文中,代码修改很可能是无用的、无意义的,甚至是不适用的。

因此,如果几个应用程序同时需要一个动态库的服务,这就意味着您将在内存中拥有同样多的动态库副本。

第二个缺点是需要大量的代码修改。有了这种技术,加载程序需要修改/修补代码中引用某个变量或调用某个函数的地方。在应用程序加载大量动态库的情况下,加载时间会在应用程序启动期间增长到显著的初始延迟。

第三个缺点是可写代码(。text)段构成了潜在的安全威胁。

有了这种技术,只将动态库加载到物理内存一次,并将其映射到大量不同应用程序的内存映射不同地址的梦想就无法实现了。

图 8-9 说明了加载时间重定位概念背后的想法。

A978-1-4302-6668-6_8_Fig9_HTML.jpg

图 8-9。

LTR concept and its limitations

所有的缺点都被较新的、在许多方面更优越的位置独立码(PIC)方法的设计所解决,该方法很快成为链接技术的普遍选择。

位置无关码

加载时间重定位方案的局限性已在动态链接的下一个实现中解决,该技术被称为位置独立代码(图 8-10 )。通过采取额外的间接步骤,避免了对动态库代码段指令的不必要的直接修改。用编程语言的行话来说,这种方法可以描述为使用指针到指针而不是指针。

基本上,符号地址分两步提供给需要的指令。为了得到符号地址,首先一个mov指令访问地址的地址位置,并将其内容(一个需要的符号地址)加载到一个可用的 CPU 寄存器中。紧接着,现在存储在寄存器中的检索到的符号地址可以用作后续指令中的操作数(mov用于数据,call用于函数调用)。

A978-1-4302-6668-6_8_Fig10_HTML.jpg

图 8-10。

PIC concept

该解决方案的特殊之处在于,符号地址保存在一个所谓的全局偏移表(GOT)中,链接器为该表保留了一个专用的.got部分。.text段和.got段之间的距离是恒定的,并且在链接时是已知的。对于需要被解析的每个符号,全局偏移表在从表开始的已知且固定的偏移处维护一个专用槽。

给定固定的 get 距离和固定的 slot 偏移量(两者在链接时都是已知的),编译器就有可能实现代码指令来引用固定的位置。最重要的是,实现的代码不依赖于实际的符号地址,并且可以在不做任何修改的情况下直接映射到大量其他进程中使用。

对特定存储器映射布局特性的最终调整由加载程序完成。然而,在该方案中,加载程序不会不可逆地修改代码(.text部分)。相反,一旦知道了符号地址,加载程序就修补.got部分,它(很像数据部分)总是在每个进程中实现。

Note

为了实现这个方案,需要大量的设计工作,这超出了链接器-加载器的界限。事实上,为了实现 PIC 概念,故事必须从编译器级别开始。特别是,–fPIC标志必须传递给编译器。“fPIC”或简称“PIC”助记符最终成为动态链接的同义词。

惰性绑定

PIC 方法中的符号引用通过额外的间接层,这一事实为在运行时实现额外的性能优势提供了可能性。实现额外性能提升的策略基于这样一个事实,即在程序启动之前,加载程序不会浪费宝贵的时间来设置.got.got.plt部分的内容。

引用这些符号的汇编指令无论如何都被设置为指向中间点,并且代码的整体形状没有什么可怕的错误会阻止程序加载。

事实上,除非绝对必要,否则加载程序通常甚至懒得完成设置.got.got.plt部分的内容。这样的时刻发生在程序已经启动之后,并且只有当执行流程到达引用了其地址保存在.got.got.plt段中的符号的指令时。

加载程序的拖延(通常称为惰性绑定)的明显好处是加载过程完成得更快,这使得应用程序启动得更快。当加载程序快速弥补其最初的(尽管是有预谋的)疏忽时,会出现一个小的一次性性能损失。这种情况只在需要时发生,并且只在第一次出现符号引用时发生一次。运行时实际引用的动态库符号越少,加载程序能够实现的性能节省就越多。

惰性绑定概念是 PIC 方法的一个额外特性,这显然为开发人员选择 PIC 而不是 LTR 实现增加了另一个很好的理由。事实上,当客户机二进制文件是可执行文件(即应用程序)时,PIC 方法是场景 1 类型问题的一种最受欢迎的实现。

动态链接递归链的规则和限制

到目前为止,您详细研究的场景属于最简单的动态链接场景。在仔细研究了原子级别之后,现在让我们稍微后退一步,看看动态链接的分子级别,因为它具有某些规则和限制,这些规则和限制在故事的原子级别上并不明显。

实际上,一个典型程序的结构可以被描述为动态链接的递归链,其中链中的每个动态库加载几个其他的动态库。从视觉上看,加载的递归链可以表示为一个复杂的树形结构,在分支之间有大量的边连接。在某些情况下,单个分支的长度最终可能会非常大。尽管动态链接的递归链可能很复杂,其分支的长度可能令人印象深刻,但这些并不是整个故事中最有力的细节。

更重要的是,在动态加载链中,每个参与的动态库可能会发现自己同时扮演场景 1 和场景 2 的角色。图 8-11 说明了这一点。

换句话说,加载链中的动态库可能既需要解析它加载的库的引用,也需要重新解析它自己的符号的引用。这使得整个故事更有趣了。

A978-1-4302-6668-6_8_Fig11_HTML.jpg

图 8-11。

A branch of typical recursive chain of dynamic linking

驻留在这个分子级别的一组强烈的实现偏好规定了实现细节,我将在下一节简要回顾这些细节。

强烈的实施偏好

不管是哪种情况,总是有两种方法可以实现链接器-加载器的协调:可以应用 LTR 或 PIC 方法。链接器-加载器协调技术的选择不是绝对自由的。除了设计人员根据每种技术的优缺点进行选择之外,还有一些其他限制需要明确指出:

  • 位置独立代码(PIC)是可执行程序解析第一级加载库引用的首选技术(图 8-11 中用圆圈字母 A 标记的场景)。

就在 LTR 或 PIC 之间进行选择而言,加载链中的动态库可能具有多种组合。实现 LTR 的动态库可以依次动态加载下一个实现 PIC 的动态库,后者可以依次动态加载实现…您选择的库—无论您的选择是什么,都是允许的。

  • 单个动态库严格利用链接器-加载器协调技术之一来解决场景 1 和场景 2(如果需要的话)。同一个动态库不可能通过 LRT 方法解决场景 1 的问题,通过 PIC 方法解决场景 2 的问题(反之亦然)。

图 8-12 说明了所描述的规则。

A978-1-4302-6668-6_8_Fig12_HTML.jpg

图 8-12。

Strong implementation preferences (at the molecular level) governing the implementation of the recursive chain of dynamic linking