C-C---高级编译教程-全-
C/C++ 高级编译教程(全)
一、多任务操作系统基础
Abstract
与构建可执行程序相关的所有技术的最终目标是对程序执行过程建立尽可能多的控制。为了真正理解可执行程序结构某些部分的目的和意义,最重要的是充分理解程序执行过程中发生的事情,因为操作系统内核和嵌入可执行程序内部的信息之间的相互作用起着最重要的作用。在执行的初始阶段尤其如此,此时对运行时影响(如用户设置、各种运行时事件等)来说还为时过早。)这通常会发生。
与构建可执行程序相关的所有技术的最终目标是对程序执行过程建立尽可能多的控制。为了真正理解可执行程序结构某些部分的目的和意义,最重要的是充分理解程序执行过程中发生的事情,因为操作系统内核和嵌入可执行程序内部的信息之间的相互作用起着最重要的作用。在执行的初始阶段尤其如此,此时对运行时影响(如用户设置、各种运行时事件等)来说还为时过早。)这通常会发生。
朝着这个方向必须迈出的第一步是理解程序运行的环境。本章的目的是提供一个现代多任务操作系统功能的最有力的细节。
就如何实现最重要的功能而言,现代多任务操作系统在许多方面非常相似。因此,首先将有意识地努力以独立于平台的方式说明这些概念。此外,还将关注特定平台解决方案的复杂性(无处不在的 Linux 和 ELF 格式与 Windows 的对比),并将对其进行详细分析。
有用的抽象
计算技术领域的变化往往发生得非常快。集成电路技术提供的元件不仅种类丰富(光、磁、半导体),而且功能也在不断升级。根据摩尔定律,集成电路上的晶体管数量大约每两年翻一番。与可用晶体管数量密切相关的处理能力也有类似的趋势。
正如很早就发现的那样,充分适应变化步伐的唯一方法是以抽象/概括的方式,在不断变化的实现细节之上的级别,定义计算机系统的总体目标和体系结构。这项工作的关键部分是以这样一种方式制定抽象,即任何新的实际实现都符合基本定义,而将实际实现的细节放在一边,因为相对来说不重要。整个计算机架构可以表示为一组结构化的抽象,如图 1-1 所示。
图 1-1。
Computer Architecture Abstractions
最底层的抽象通过用字节流的本质属性来表示各种 I/O 设备(鼠标、键盘、操纵杆、轨迹球、光笔、扫描仪、条形码阅读器、打印机、绘图仪、数码相机、网络摄像头),来处理这些设备。事实上,不管各种设备的目的、实现和能力之间的差异,从计算机系统设计的角度来看,这些设备产生或接收(或两者)的字节流是最重要的细节。
下一个抽象层次是虚拟内存的概念,它代表了系统中常见的各种内存资源,对于本书的主题来说是非常重要的。这种特定的抽象实际上表示各种物理存储设备的方式不仅影响实际硬件和软件的设计,而且为编译器、链接器和加载器的设计奠定了基础。
抽象物理 CPU 的指令集是下一级的抽象。理解指令集的特性和它所承载的处理能力绝对是编程大师感兴趣的话题。从我们主要话题的角度来看,这个抽象层次不是最重要的,也不会详细讨论。
操作系统的复杂性代表了抽象的最终层次。操作系统设计的某些方面(最明显的是多任务处理)对整个软件架构有决定性的影响。多方试图访问共享资源的场景需要深思熟虑的实现,避免不必要的代码重复,这是直接导致共享库设计的因素。
让我们在分析整个计算机系统的错综复杂的过程中绕一小段路,而是特别注意与内存使用相关的重要问题。
内存层次和缓存策略
有几个与计算机系统内存相关的有趣事实:
- 对记忆的需求似乎永无止境。人们总是需要远远超过现有水平的东西。在提供更大数量(更快的内存)方面的每一次飞跃都立即满足了人们对技术的长期等待的需求,这些技术在概念上已经准备好了相当长的一段时间,其实现被推迟到物理内存变得足够多的那一天。
- 这项技术在克服处理器的性能障碍方面似乎比内存更有效。这种现象通常被称为“处理器内存差距”
- 存储器的存取速度与存储容量成反比。最大容量存储设备的存取时间通常比最小容量存储设备的存取时间大几个数量级。
现在,让我们从程序员/设计师/工程师的角度快速看一下这个系统。理想情况下,系统需要尽可能快地访问所有可用内存——我们知道这是不可能实现的。接下来的问题就变成了:我们能为此做些什么吗?
让我们松了一口气的细节是,系统并不总是使用所有的内存,而只是在某些时候使用一些内存。在这种情况下,我们真正需要做的是为运行立即执行保留最快的内存,并为不立即执行的代码/数据使用较慢的内存设备。当 CPU 从快速存储器中取出安排立即执行的指令时,硬件试图猜测下一步将执行程序的哪一部分,并将该部分代码提供给较慢的存储器等待执行。在执行存储在较慢的存储器中的指令的时间到来之前不久,它们被转移到较快的存储器中。这一原理被称为缓存。
现实生活中的贮藏类似于普通家庭对食物供应的处理。除非我们住在非常偏僻的地方,否则我们通常不会购买并带回家一整年所需的所有食物。相反,我们通常会在家里(冰箱、餐具室、货架)保留相当大的储存空间,以备一两周的食物供应。当我们注意到这些小储备即将耗尽时,我们会去杂货店买足够的食物来填满当地的储备。
程序的执行通常会受到许多外部因素的影响(用户设置只是其中之一),这一事实使得缓存机制成为一种猜测或漫无目的的游戏。程序执行流程越可预测(通过缺少跳转、中断等来衡量)。)缓存机制工作得越顺畅。相反,每当程序遇到流改变时,先前累积的指令由于不再需要而被丢弃,并且需要从较慢的存储器提供新的、更合适的程序部分。
缓存原理的实现无处不在,并且跨越了几个级别的内存,如图 1-2 所示。
图 1-2。
Memory caching hierarchy principle
虚拟内存
内存缓存的一般方法在下一个体系结构级别上获得实际的实现,其中运行的程序由称为进程的抽象表示。
现代多任务操作系统的设计意图是允许一个或多个用户同时运行几个程序。对于普通用户来说,同时运行多个应用程序(例如网络浏览器、编辑器、音乐播放器、日历)并不罕见。
虚拟内存的概念解决了内存需求和有限的内存可用性之间的不均衡,虚拟内存的概念可以通过以下一组指导原则来概括:
- 程序内存余量是固定的,对所有程序都是一样的,本质上是声明性的。
操作系统通常允许程序(进程)使用 2 N 字节的内存,现在 N 是 32 或 64。该值是固定的,与系统中物理内存的可用性无关
- 物理内存的数量可能会有所不同。通常,可用内存的数量比声明的进程地址空间小几倍。运行程序可用的物理内存量是一个奇数是很正常的。
- 运行时的物理内存被分成小片段(页面),每个页面用于同时运行的程序。
- 正在运行的程序的完整内存布局保存在慢速内存(硬盘)中。只有当前将要执行的内存部分(代码和数据)被加载到物理内存页面中。
虚拟内存概念的实际实现需要大量系统资源的交互,例如硬件(硬件异常、硬件地址转换)、硬盘(交换文件)以及最低级别的操作系统软件(内核)。虚拟内存的概念如图 1-3 所示。
图 1-3。
Virtual memory concept implementation
虚编址
虚拟寻址的概念是虚拟内存实现的基础,并且在许多方面极大地影响了编译器和连接器的设计。
作为一般规则,程序设计者完全不用担心他的程序在运行时将占用的寻址范围(至少对大多数用户空间应用程序来说是这样;内核模块在这个意义上有些例外)。相反,编程模型假设地址范围在 0 和 2 N (虚拟地址范围)之间,并且对所有程序都是相同的。
为所有程序授予简单统一的寻址方案的决定对代码开发过程有着巨大的积极影响。以下是一些好处:
- 链接被简化。
- 装载被简化。
- 运行时进程共享变得可用。
- 简化了内存分配。
程序存储器在具体地址范围内的实际运行时位置由操作系统通过地址转换机制来执行。它的实现是由称为内存管理单元(MMU)的硬件模块执行的,它不需要程序本身的任何参与。
图 1-4 比较了虚拟寻址机制和简单明了的物理寻址方案(至今仍用于简单微控制器系统领域)。
图 1-4。
Physical vs. virtual addressing
进程内存划分方案
上一节解释了为什么可以为(几乎)任何程序的设计者提供相同的内存映射。本节的主题是讨论过程记忆图内部组织的细节。假设程序地址(从程序员的角度来看)位于 0 和 2 N 之间的地址范围内,N 为 32 或 64。
各种多任务/多用户操作系统指定不同的内存映射布局。特别是,Linux 进程虚拟内存映射遵循图 1-5 所示的映射方案。
图 1-5。
Linux process memory map layout
无论给定平台的进程内存划分方案有何特点,都必须始终支持内存映射的以下部分:
- 携带机器代码指令供 CPU 执行的代码段。文本部分)
- 携带 CPU 将操作的数据的数据段。通常,为初始化数据保留单独的部分。数据段),对于未初始化的数据(。bss 部分),也适用于常量数据(。rdata 部分)
- 运行动态内存分配的堆
- 堆栈,用于为函数提供独立的空间
- 属于内核的最顶层部分,其中存储了特定于进程的环境变量
Gustavo Duarte 对这一特定主题的详细讨论可以在
duartes.org/gustavo/blog/post/anatomy-of-a-program-in-memory
二进制文件、编译器、链接器和加载器的角色
上一节揭示了运行进程的内存映射。接下来的重要问题是如何在运行时创建正在运行的进程的内存映射。这一部分将对故事的这一特定方面提供初步的见解。
在草图中,
- 程序二进制文件携带了运行进程内存映射蓝图的细节。
- 二进制文件的框架是由链接器创建的。为了完成其任务,链接器组合由编译器创建的二进制文件,以便填充各种存储器映射部分(代码、数据等)。).
- 进程存储器映射的初始创建任务由称为程序加载器的系统实用程序执行。在最简单的意义上,加载程序打开二进制可执行文件,读取与节相关的信息,并填充进程内存映射结构。
这种角色划分适用于所有现代操作系统。
请注意,这种最简单的描述远远不能提供完整的情况。它应该被看作是对后续讨论的一个温和的介绍,随着我们进一步深入这个主题,将会传达关于二进制文件和进程加载主题的更多细节。
摘要
本章概述了对现代多任务操作系统的设计影响最大的概念。虚拟内存和虚拟寻址的基础概念不仅影响程序的执行(将在下一章详细讨论),而且直接影响程序可执行文件的构建方式(将在本书后面详细解释)。
二、简单的程序生命周期阶段
Abstract
在前一章中,您已经广泛了解了现代多任务操作系统在程序执行过程中扮演的角色。程序员自然会想到的下一个问题是,为了安排程序执行,要做什么、如何做以及为什么要做。
在前一章中,您已经广泛了解了现代多任务操作系统在程序执行过程中扮演的角色。程序员自然会想到的下一个问题是,为了安排程序执行,要做什么、如何做以及为什么要做。
就像蝴蝶的生命周期由它的毛虫阶段决定一样,程序的生命周期在很大程度上由二进制文件的内部结构决定,操作系统加载程序加载、解包并将其内容放入执行中。我们随后的大部分讨论将致力于准备蓝图并将其恰当地嵌入到二进制可执行文件的主体中,这并不奇怪。我们将假设程序是用 C/C++ 编写的。
为了完全理解整个故事,将非常详细地分析程序生命周期的其余部分,即加载和执行阶段。进一步的讨论将集中在计划生命周期的以下阶段:
Creating the source code Compiling Linking Loading Executing
说实话,这一章将会包含更多关于编译阶段的细节。后续阶段(尤其是链接阶段)的覆盖范围仅从本章开始,在这一章中,您将仅看到众所周知的“冰山一角”在链接阶段后面的最基本的思想介绍之后,本书的剩余部分将处理复杂的链接以及程序加载和执行。
初始假设
尽管很可能有很大比例的读者属于高级到专家程序员的范畴,但我将从相当简单的初始示例开始。这一章的讨论将与这个非常简单但很能说明问题的例子有关。演示项目由两个简单的源文件组成,它们将首先被编译,然后被链接在一起。编写这段代码的目的是将编译和链接的复杂性保持在尽可能简单的水平。
特别是,在这个演示例子中,没有外部库的链接,特别是动态链接。唯一的例外是与 C 运行时库的链接(这是绝大多数用 C 编写的程序所必需的)。作为 C 程序执行生命周期中的一个常见元素,为了简单起见,我将故意对与 C 运行时库链接的具体细节视而不见,并假设程序是以这样一种方式创建的,即所有来自 C 运行时库的代码都“自动地”插入到程序内存映射的主体中。
通过遵循这种方法,我将以简单明了的形式详细说明程序构建的本质问题。
代码编写
鉴于本书的主要主题是程序构建的过程(即,源代码编写后会发生什么),我不会在源代码创建过程上花太多时间。
除了在少数情况下源代码是由脚本生成的,我们假设用户通过在他选择的编辑器中键入 ASCII 字符来生成满足所选编程语言(在我们的例子中是 C/C++)语法规则的书面语句。选择的编辑器可能各不相同,从最简单的 ASCII 文本编辑器一直到最先进的 IDE 工具。假设这本书的普通读者是一个相当有经验的程序员,那么对于程序生命周期的这个阶段,真的没有什么特别要说的。
然而,有一种特殊的编程实践会显著地影响故事从这一点开始的走向,值得特别关注。为了更好地组织源代码,程序员通常遵循将代码的各种功能部分保存在单独的文件中的做法,导致项目通常由许多不同的源文件和头文件组成。
这种编程实践很早就被采用了,因为开发环境是为早期的微处理器设计的。作为一个非常可靠的设计决策,它一直被实践到现在,因为它被证明提供了可靠的代码组织,并使代码维护任务变得非常容易。
这无疑是有用的编程实践,具有深远的影响。正如你将很快看到的,实践它会在构建过程的后续阶段导致一定量的不确定性,解决这个问题需要一些仔细的思考。
概念图:演示项目
为了更好地说明编译过程的复杂性,也为了给读者提供一点动手热身的体验,我们提供了一个简单的演示项目。代码非常简单;它只包含一个头文件和两个源文件。然而,它是经过精心设计的,旨在说明对于理解更广泛的图景极其重要的几点。
以下文件是项目的一部分:
- 源文件 main.c,其中包含了
main()
函数。 - 头文件 function.h,声明被调用的函数和由
main()
函数访问的数据。 - 源文件 function.c,包含函数的源代码实现和由
main()
函数引用的数据的实例化。
用于构建这个简单项目的开发环境将基于运行在 Linux 上的 gcc 编译器。清单 2-1 到 2-3 包含了演示项目中使用的代码。
清单 2-1。function.h
#pragma once
#define FIRST_OPTION
#ifdef FIRST_OPTION
#define MULTIPLIER (3.0)
#else
#define MULTIPLIER (2.0)#endif
float add_and_multiply(float x, float y);
清单 2-2。function.c
int nCompletionStatus = 0;
float add(float x, float y)
{
float z = x + y;
return z;
}
float add_and_multiply(float x, float y)
{
float z = add(x,y);
z *= MULTIPLIER;
return z;
}
清单 2-3。main.c
#include "function.h"
extern int nCompletionStatus = 0;
int main(int argc, char* argv[])
{
float x = 1.0;
float y = 5.0;
float z;
z = add_and_multiply(x,y);
nCompletionStatus = 1;
return 0;
}
收集
一旦你写好了你的源代码,是时候让你自己沉浸在代码构建的过程中了,其强制性的第一步是编译阶段。在深入复杂的编译之前,先介绍几个简单的介绍性术语。
介绍性定义
广义的编译可以定义为将一种编程语言编写的源代码转换成另一种编程语言的过程。以下一组介绍性事实对于您全面理解编译过程非常重要:
- 编译的过程是由称为编译器的程序执行的。
- 编译器的输入是一个翻译单元。典型的翻译单元是包含源代码的文本文件。
- 一个程序通常由许多翻译单元组成。尽管将项目的所有源代码保存在一个文件中是完全可能和合法的,但是有充分的理由(在前一节中解释过)说明为什么通常不是这样。
- 编译的输出是一组二进制目标文件,每个输入翻译单元一个。
- 为了变得适合执行,目标文件需要通过另一个称为链接的程序构建阶段进行处理。
图 2-1 说明了编译的概念。
图 2-1。
The compiling stage
相关定义
通常会遇到以下各种编译器用例:
- 严格意义上的编译是指将高级语言的代码翻译成低级语言的代码(通常是汇编程序甚至是机器码)的过程。
- 如果编译是在一个平台(CPU/OS)上执行,以产生要在另一个平台(CPU/OS)上运行的代码,这就叫做交叉编译。通常的做法是使用一些桌面操作系统(Linux、Windows)来为嵌入式或移动设备生成代码。
- 反编译(反汇编)是将低级语言的源代码转换成高级语言的过程。
- 语言翻译是将一种编程语言的源代码转换成另一种相同级别和复杂度的编程语言的过程。
- 语言重写是将语言表达式重写为更适合某些任务(如优化)的形式的过程。
编译的各个阶段
汇编过程在本质上不是单一的。事实上,它可以大致分为几个阶段(预处理,语言分析,汇编,优化,代码发射),其细节将在下面讨论。
预处理
处理源文件的标准第一步是通过称为预处理器的特殊文本处理程序运行它们,该程序执行以下一个或多个操作:
- 将包含定义的文件(包含/头文件)包含到源文件中,如关键字
#include
所指定的。 - 将使用
#define
语句指定的值转换成常量。 - 在调用宏的不同位置将宏定义转换为代码。
- 根据
#if
、#elif
和#endif
指令的位置,有条件地包含或排除代码的某些部分。
预处理器的输出是最终形状的 C/C++ 代码,它将被传递到下一个阶段,语法分析。
演示项目预处理示例
gcc 编译器提供了一种模式,在这种模式下,仅对输入源文件执行预处理阶段:
gcc -i <input file> -o <output preprocessed file>.i
除非另有说明,预处理器的输出是与输入文件同名的文件,其文件扩展名为. I。对文件function.c
运行预处理器的结果如清单 2-4 所示。
清单 2-4。function.i
# 1 "function.c"
# 1 "
# 1 "
# 1 "function.h" 1
# 11 "function.h"
float add_and_multiply(float x, float y);
# 2 "function.c" 2
int nCompletionStatus = 0;
float add(float x, float y)
{
float z = x + y;
return z;
}
float add_and_multiply(float x, float y)
{
float z = add(x,y);
z *= MULTIPLIER;
return z;
}
如果传递给 gcc 的额外标志很少,可能会获得更紧凑、更有意义的预处理器输出,比如
gcc -E -P -i <input file> -o <output preprocessed file>.i
这产生了清单 2-5 中所示的预处理文件。
清单 2-5。function.i (Trimmed Down Version)
float add_and_multiply(float x, float y);
int nCompletionStatus = 0;
float add(float x, float y)
{
float z = x + y;
return z;
}
float add_and_multiply(float x, float y)
{
float z = add(x,y);
z *=``3.0
return z;
}
显然,预处理器替换了符号MULTIPLIER
,基于定义了USE_FIRST_OPTION
变量的事实,它的实际值最终是 3.0。
语言分析
在这个阶段,编译器首先将 C/C++ 代码转换成更适合处理的形式(删除注释和不必要的空格,从文本中提取标记等)。).对源代码的这种优化和压缩形式进行词汇分析,目的是检查程序是否满足编写它的编程语言的语法规则。如果检测到与语法规则的偏差,则会报告错误或警告。这些错误足以导致编译终止,而警告可能足够,也可能不够,这取决于用户的设置。
对编译过程的这个阶段的更精确的观察揭示了三个不同的阶段:
- 词法分析,将源代码分成不可分割的标记。下一阶段,
- 解析/语法分析将提取的标记连接成标记链,并验证它们的排序从编程语言规则的角度来看是有意义的。最后,
- 语义分析的目的是发现语法正确的语句实际上是否有意义。例如,将两个整数相加并将结果赋给一个对象的语句将通过语法规则,但可能无法通过语义检查(除非该对象覆盖了赋值运算符)。
在语言分析阶段,编译器可能更应该被称为“抱怨者”,因为它往往更多地抱怨打字错误或遇到的其他错误,而不是实际编译代码。
装配
只有在验证源代码不包含语法错误之后,编译器才会到达这个阶段。在这个阶段,编译器试图将标准语言结构转换成特定于实际 CPU 指令集的结构。不同的 CPU 有不同的功能处理,一般来说,可用的指令、寄存器和中断也不同,这就解释了为什么会有各种各样的编译器适用于更多种类的处理器。
演示项目组装示例
gcc 编译器提供了一种操作模式,在这种模式下,输入文件的源代码被转换成包含特定于芯片和/或操作系统的汇编指令行的 ASCII 文本文件。
$ gcc
-S
<input file> -o <output assembler file>.s
除非另外指定,否则预处理器的输出是与输入文件同名的文件,其文件扩展名为. s。
生成的文件不适合执行;它仅仅是一个文本文件,带有人类可读的汇编指令助记符,开发人员可以使用它来更好地了解编译过程的内部工作细节。
在 X86 处理器体系结构的特定情况下,汇编代码可以符合两种支持的指令打印格式之一,其选择可以通过向 gcc 汇编程序传递额外的命令行参数来指定。格式的选择大多是开发者个人口味的问题。
- 美国电话电报公司格式
- 英特尔格式
the choice of which may be specified by passing an extra command-line argument to the gcc assembler. The choice of format is mostly the matter of the developer’s personal taste.
美国电话电报公司组件格式示例
当通过运行以下命令将文件function.c
汇编成 AT & T 格式时
$ gcc -S -masm=att function.c -o function.s
它创建了输出汇编文件,如清单 2-6 所示。
清单 2-6。function.s (AT&T Assembler Format)
.file "function.c"
.globl nCompletionStatus
.bss
.align 4
.type nCompletionStatus, @object
.size nCompletionStatus, 4
nCompletionStatus:
.zero 4
.text
.globl add
.type add, @function
add:
.LFB0:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $20, %esp
flds 8(%ebp)
fadds 12(%ebp)
fstps -4(%ebp)
movl -4(%ebp), %eax
movl %eax, -20(%ebp)
flds -20(%ebp)
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE0:
.size add, .-add
.globl add_and_multiply
.type add_and_multiply, @function
add_and_multiply:
.LFB1:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $28, %esp
movl 12(%ebp), %eax
movl %eax, 4(%esp)
movl 8(%ebp), %eax
movl %eax, (%esp)
call add
fstps -4(%ebp)
flds -4(%ebp)
flds .LC1
fmulp %st, %st(1)
fstps -4(%ebp)
movl -4(%ebp), %eax
movl %eax, -20(%ebp)
flds -20(%ebp)
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE1:
.size add_and_multiply, .-add_and_multiply
.section .rodata
.align 4
.LC1:
.long 1077936128
.ident "GCC: (Ubuntu/Linaro 4.6.3-1ubuntu5) 4.6.3"
.section .note.GNU-stack,"",@progbits
英特尔组件格式示例
通过运行以下命令,可以将同一个文件(function.c
)汇编成英特尔汇编程序格式。
$ gcc -S -masm=intel function.c -o function.s
这产生了清单 2-7 所示的汇编文件。
清单 2-7。function.s (Intel Assembler Format)
.file "function.c"
.intel_syntax noprefix
.globl nCompletionStatus
.bss
.align 4
.type nCompletionStatus, @object
.size nCompletionStatus, 4
nCompletionStatus:
.zero 4
.text
.globl add
.type add, @function
add:
.LFB0:
.cfi_startproc
push ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
mov ebp, esp
.cfi_def_cfa_register 5
sub esp, 20
fld DWORD PTR [ebp+8]
fadd DWORD PTR [ebp+12]
fstp DWORD PTR [ebp-4]
mov eax, DWORD PTR [ebp-4]
mov DWORD PTR [ebp-20], eax
fld DWORD PTR [ebp-20]
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE0:
.size add, .-add
.globl add_and_multiply
.type add_and_multiply, @function
add_and_multiply:
.LFB1:
.cfi_startproc
push ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
mov ebp, esp
.cfi_def_cfa_register 5
sub esp, 28
mov eax, DWORD PTR [ebp+12]
mov DWORD PTR [esp+4], eax
mov eax, DWORD PTR [ebp+8]
mov DWORD PTR [esp], eax
call add
fstp DWORD PTR [ebp-4]
fld DWORD PTR [ebp-4]
fld DWORD PTR .LC1
fmulp st(1), st
fstp DWORD PTR [ebp-4]
mov eax, DWORD PTR [ebp-4]
mov DWORD PTR [ebp-20], eax
fld DWORD PTR [ebp-20]
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE1:
.size add_and_multiply, .-add_and_multiply
.section .rodata
.align 4
.LC1:
.long 1077936128
.ident "GCC: (Ubuntu/Linaro 4.6.3-1ubuntu5) 4.6.3"
.section .note.GNU-stack,"",@progbits
最佳化
一旦创建了对应于原始源代码的第一个汇编版本,优化工作就开始了,其中寄存器的使用被最小化。此外,该分析可以指示代码的某些部分实际上不需要被执行,并且代码的这些部分被消除。
代码发射
最后,创建编译输出的时候到了:目标文件,每个翻译单元一个。汇编指令(以人类可读的 ASCII 码编写)在这个阶段被转换成相应机器指令(操作码)的二进制值,并写入目标文件中的特定位置。
目标文件仍然没有准备好作为食物提供给饥饿的处理器。其中的原因是这本书的主题。此时有趣的话题是对一个目标文件的分析。
作为二进制文件,目标文件与预处理和汇编过程的输出有本质的不同,它们都是 ASCII 文件,人天生可读。当我们人类试图更仔细地观察内容时,差异变得最明显。
除了明显选择使用十六进制编辑器(除非你以编写编译器为生,否则没有太大帮助)之外,为了深入了解目标文件的内容,还采用了一个叫做反汇编的特定过程。
在从 ASCII 文件到适合在具体机器上执行的二进制文件的整个路径上,反汇编可以被视为一个小的 U 形转弯,即将就绪的二进制文件被转换成 ASCII 文件,以满足软件开发人员好奇的目光。幸运的是,这个小小的迂回只是为了给开发人员提供更好的方向,没有真正的原因通常不会执行。
演示项目编译示例
gcc 编译器可以被设置为执行完整的编译(预处理、汇编和编译),这是一个生成二进制目标文件(标准扩展名)的过程。o)其结构遵循 ELF 格式指南。除了通常开销(标题、表格等)。),它包含所有相关的部分(。文本,。代码,。bss 等。).为了只指定编译(还没有链接),可以使用下面的命令行:
$ gcc``-c
除非另外指定,否则预处理器的输出是与输入文件同名的文件,其文件扩展名为. o。
生成的目标文件的内容不适合在文本编辑器中查看。十六进制编辑器/查看器更合适一些,因为它不会被不可打印的字符和缺少换行符所混淆。图 2-2 显示了编译该演示项目文件function.c
生成的目标文件function.o
的二进制内容。
图 2-2。
Binary contents of an object file
显然,仅仅看一下目标文件的十六进制值并不能告诉我们太多。分解过程有可能告诉我们更多的信息。
名为 objdump(流行的 binutils 包的一部分)的 Linux 工具专门负责反汇编二进制文件,以及其他许多事情。除了转换特定于给定平台的二进制机器指令序列之外,它还指定指令驻留的地址。
它支持打印汇编代码的美国电话电报公司(默认)和英特尔风格,这并不奇怪。
通过运行简单形式的 objdump 命令,
$ objdump``-D
您会在终端屏幕上看到以下内容:
disassembled output of function.o (AT&
T assembler format)
function.o: file format elf32-i386
Disassembly of section .text:
00000000 <add>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 ec 14 sub $0x14,%esp
6: d9 45 08 flds 0x8(%ebp)
9: d8 45 0c fadds 0xc(%ebp)
c: d9 5d fc fstps -0x4(%ebp)
f: 8b 45 fc mov -0x4(%ebp),%eax
12: 89 45 ec mov %eax,-0x14(%ebp)
15: d9 45 ec flds -0x14(%ebp)
18: c9 leave
19: c3 ret
0000001a <add_and_multiply>:
1a: 55 push %ebp
1b: 89 e5 mov %esp,%ebp
1d: 83 ec 1c sub $0x1c,%esp
20: 8b 45 0c mov 0xc(%ebp),%eax
23: 89 44 24 04 mov %eax,0x4(%esp)
27: 8b 45 08 mov 0x8(%ebp),%eax
2a: 89 04 24 mov %eax,(%esp)
2d: e8 fc ff ff ff call 2e <add_and_multiply+0x14>
32: d9 5d fc fstps -0x4(%ebp)
35: d9 45 fc flds -0x4(%ebp)
38: d9 05 00 00 00 00 flds 0x0
3e: de c9 fmulp %st,%st(1)
40: d9 5d fc fstps -0x4(%ebp)
43: 8b 45 fc mov -0x4(%ebp),%eax
46: 89 45 ec mov %eax,-0x14(%ebp)
49: d9 45 ec flds -0x14(%ebp)
4c: c9 leave
4d: c3 ret
Disassembly of section .bss:
00000000 <nCompletionStatus>:
0: 00 00 add %al,(%eax)
...
Disassembly of section .rodata:
00000000 <.rodata>:
0: 00 00 add %al,(%eax)
2: 40 inc %eax
3: 40 inc %eax
Disassembly of section .comment:
00000000 <.comment>:
0: 00 47 43 add %al,0x43(%edi)
3: 43 inc %ebx
4: 3a 20 cmp (%eax),%ah
6: 28 55 62 sub %dl,0x62(%ebp)
9: 75 6e jne 79 <add_and_multiply+0x5f>
b: 74 75 je 82 <add_and_multiply+0x68>
d: 2f das
e: 4c dec %esp
f: 69 6e 61 72 6f 20 34 imul $0x34206f72,0x61(%esi),%ebp
16: 2e 36 2e 33 2d 31 75 cs ss xor %cs:%ss:0x75627531,%ebp
1d: 62 75
1f: 6e outsb %ds:(%esi),(%dx)
20: 74 75 je 97 <add_and_multiply+0x7d>
22: 35 29 20 34 2e xor $0x2e342029,%eax
27: 36 2e 33 00 ss xor %cs:%ss:(%eax),%eax
Disassembly of section .eh_frame:
00000000 <.eh_frame>:
0: 14 00 adc $0x0,%al
2: 00 00 add %al,(%eax)
4: 00 00 add %al,(%eax)
6: 00 00 add %al,(%eax)
8: 01 7a 52 add %edi,0x52(%edx)
b: 00 01 add %al,(%ecx)
d: 7c 08 jl 17 <.eh_frame+0x17>
f: 01 1b add %ebx,(%ebx)
11: 0c 04 or $0x4,%al
13: 04 88 add $0x88,%al
15: 01 00 add %eax,(%eax)
17: 00 1c 00 add %bl,(%eax,%eax,1)
1a: 00 00 add %al,(%eax)
1c: 1c 00 sbb $0x0,%al
1e: 00 00 add %al,(%eax)
20: 00 00 add %al,(%eax)
22: 00 00 add %al,(%eax)
24: 1a 00 sbb (%eax),%al
26: 00 00 add %al,(%eax)
28: 00 41 0e add %al,0xe(%ecx)
2b: 08 85 02 42 0d 05 or %al,0x50d4202(%ebp)
31: 56 push %esi
32: c5 0c 04 lds (%esp,%eax,1),%ecx
35: 04 00 add $0x0,%al
37: 00 1c 00 add %bl,(%eax,%eax,1)
3a: 00 00 add %al,(%eax)
3c: 3c 00 cmp $0x0,%al
3e: 00 00 add %al,(%eax)
40: 1a 00 sbb (%eax),%al
42: 00 00 add %al,(%eax)
44: 34 00 xor $0x0,%al
46: 00 00 add %al,(%eax)
48: 00 41 0e add %al,0xe(%ecx)
4b: 08 85 02 42 0d 05 or %al,0x50d4202(%ebp)
51: 70 c5 jo 18 <.eh_frame+0x18>
53: 0c 04 or $0x4,%al
55: 04 00 add $0x0,%al
...
类似地,通过指定英特尔风格,
$ objdump -D -M intel <input file>.o
您会在终端屏幕上看到以下内容:
disassembled output of function.o (Intel assembler format)
function.o: file format elf32-i386
Disassembly of section .text:
00000000 <add>:
0: 55 push ebp
1: 89 e5 mov ebp,esp
3: 83 ec 14 sub esp,0x14
6: d9 45 08 fld DWORD PTR [ebp+0x8]
9: d8 45 0c fadd DWORD PTR [ebp+0xc]
c: d9 5d fc fstp DWORD PTR [ebp-0x4]
f: 8b 45 fc mov eax,DWORD PTR [ebp-0x4]
12: 89 45 ec mov DWORD PTR [ebp-0x14],eax
15: d9 45 ec fld DWORD PTR [ebp-0x14]
18: c9 leave
19: c3 ret
0000001a <add_and_multiply>:
1a: 55 push ebp
1b: 89 e5 mov ebp,esp
1d: 83 ec 1c sub esp,0x1c
20: 8b 45 0c mov eax,DWORD PTR [ebp+0xc]
23: 89 44 24 04 mov DWORD PTR [esp+0x4],eax
27: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
2a: 89 04 24 mov DWORD PTR [esp],eax
2d: e8 fc ff ff ff call 2e <add_and_multiply+0x14>
32: d9 5d fc fstp DWORD PTR [ebp-0x4]
35: d9 45 fc fld DWORD PTR [ebp-0x4]
38: d9 05 00 00 00 00 fld DWORD PTR ds:0x0
3e: de c9 fmulp st(1),st
40: d9 5d fc fstp DWORD PTR [ebp-0x4]
43: 8b 45 fc mov eax,DWORD PTR [ebp-0x4]
46: 89 45 ec mov DWORD PTR [ebp-0x14],eax
49: d9 45 ec fld DWORD PTR [ebp-0x14]
4c: c9 leave
4d: c3 ret
Disassembly of section .bss:
00000000 <nCompletionStatus>:
0: 00 00 add BYTE PTR [eax],al
...
Disassembly of section .rodata:
00000000 <.rodata>:
0: 00 00 add BYTE PTR [eax],al
2: 40 inc eax
3: 40 inc eax
Disassembly of section .comment:
00000000 <.comment>:
0: 00 47 43 add BYTE PTR [edi+0x43],al
3: 43 inc ebx
4: 3a 20 cmp ah,BYTE PTR [eax]
6: 28 55 62 sub BYTE PTR [ebp+0x62],dl
9: 75 6e jne 79 <add_and_multiply+0x5f>
b: 74 75 je 82 <add_and_multiply+0x68>
d: 2f das
e: 4c dec esp
f: 69 6e 61 72 6f 20 34 imul ebp,DWORD PTR [esi+0x61],0x34206f72
16: 2e 36 2e 33 2d 31 75 cs ss xor ebp,DWORD PTR cs:ss:0x75627531
1d: 62 75
1f: 6e outs dx,BYTE PTR ds:[esi]
20: 74 75 je 97 <add_and_multiply+0x7d>
22: 35 29 20 34 2e xor eax,0x2e342029
27: 36 2e 33 00 ss xor eax,DWORD PTR cs:ss:[eax]
Disassembly of section .eh_frame:
00000000 <.eh_frame>:
0: 14 00 adc al,0x0
2: 00 00 add BYTE PTR [eax],al
4: 00 00 add BYTE PTR [eax],al
6: 00 00 add BYTE PTR [eax],al
8: 01 7a 52 add DWORD PTR [edx+0x52],edi
b: 00 01 add BYTE PTR [ecx],al
d: 7c 08 jl 17 <.eh_frame+0x17>
f: 01 1b add DWORD PTR [ebx],ebx
11: 0c 04 or al,0x4
13: 04 88 add al,0x88
15: 01 00 add DWORD PTR [eax],eax
17: 00 1c 00 add BYTE PTR [eax+eax*1],bl
1a: 00 00 add BYTE PTR [eax],al
1c: 1c 00 sbb al,0x0
1e: 00 00 add BYTE PTR [eax],al
20: 00 00 add BYTE PTR [eax],al
22: 00 00 add BYTE PTR [eax],al
24: 1a 00 sbb al,BYTE PTR [eax]
26: 00 00 add BYTE PTR [eax],al
28: 00 41 0e add BYTE PTR [ecx+0xe],al
2b: 08 85 02 42 0d 05 or BYTE PTR [ebp+0x50d4202],al
31: 56 push esi
32: c5 0c 04 lds ecx,FWORD PTR [esp+eax*1]
35: 04 00 add al,0x0
37: 00 1c 00 add BYTE PTR [eax+eax*1],bl
3a: 00 00 add BYTE PTR [eax],al
3c: 3c 00 cmp al,0x0
3e: 00 00 add BYTE PTR [eax],al
40: 1a 00 sbb al,BYTE PTR [eax]
42: 00 00 add BYTE PTR [eax],al
44: 34 00 xor al,0x0
46: 00 00 add BYTE PTR [eax],al
48: 00 41 0e add BYTE PTR [ecx+0xe],al
4b: 08 85 02 42 0d 05 or BYTE PTR [ebp+0x50d4202],al
51: 70 c5 jo 18 <.eh_frame+0x18>
53: 0c 04 or al,0x4
55: 04 00 add al,0x0
...
目标文件属性
编译过程的输出是一个或多个二进制目标文件,其结构自然是下一个感兴趣的主题。正如您将很快看到的,目标文件的结构包含许多在真正理解更广阔的图景的道路上重要的细节。
在草图中,
- 目标文件是翻译其原始对应源文件的结果。编译的结果是项目中有多少源文件就有多少目标文件的集合。
编译完成后,目标文件在程序构建过程的后续阶段继续表示其原始源文件。
- 目标文件的基本成分是符号(对程序或数据存储器中存储地址的引用)以及段。
在目标文件中最常见的部分是代码(。文本)、初始化数据(。数据)、未初始化的数据(。bss),以及一些更专业的部分(调试信息等)。).
- 构建程序的最终目的是将编译单个源文件获得的部分组合(平铺)成一个二进制可执行文件。
这样的二进制文件将包含相同类型的部分(。文本,。数据,。bss,。。。)是通过将各个文件的部分平铺在一起而获得的。形象地说,一个对象文件可以被看作是一个简单的瓷砖,等待在进程内存映射的巨大马赛克中找到自己的位置。
- 然而,目标文件的内部结构并没有暗示各个部分最终将驻留在程序存储器映射中的什么位置。由于这个原因,每个目标文件中每个部分的地址范围被暂时设置为从零值开始。
目标文件中某一段最终驻留在程序映象中的实际地址范围将在程序建立过程的后续阶段(链接)中确定。
- 在将目标文件的段平铺到结果程序内存映射中的过程中,唯一真正重要的参数是其段的长度,或者更准确地说,是其地址范围。
- 目标文件不包含对堆栈和/或堆有贡献的部分。存储器映射的这两个部分的内容完全是在运行时确定的,除了默认的字节长度之外,不需要特定于程序的初始设置。
- 目标文件对程序的贡献。bss(未初始化数据)部分非常初级;那个。bss 部分仅通过其字节长度来描述。这些信息正是加载程序建立。bss 段作为内存的一部分,其中将存储一些数据。
一般来说,信息是根据以二进制格式规范的形式概括的一组规则存储在目标文件中的,这些规则的细节在不同的平台上有所不同(Windows 与 Linux、32 位与 64 位、x86 与 ARM 处理器系列)。
通常,二进制格式规范旨在支持 C/C++ 语言结构和相关的实现问题。二进制格式规范经常涵盖各种二进制文件模式,如可执行文件、静态库和动态库。
在 Linux 上,可执行可链接格式(ELF)已经得到了普及。在 Windows 上,二进制文件通常符合 PE/COFF 格式规范。
编译过程限制
一步一步,程序构建过程的巨大拼图的碎片开始就位,整个故事的广阔而清晰的画面慢慢浮现。到目前为止,您已经了解了编译过程将 ASCII 源文件翻译成相应的二进制目标文件集合。每个目标文件都包含段,每个段的命运都是最终成为程序内存映射的巨大拼图的一部分,如图 2-3 所示。
图 2-3。
Tiling the individual sections into the final program memory map
剩下的任务是将存储在单个目标文件中的单个部分拼接到程序内存映射体中。正如前面提到的,这个任务需要留给程序构建过程的另一个阶段,叫做链接。
细心的观察者不禁会问(在进入链接过程的细节之前),为什么我们需要构建过程的全新阶段,或者更准确地说,为什么到目前为止描述的编译过程不能完成任务的平铺部分?
拆分构建过程有几个非常充分的理由,本节的其余部分将试图阐明导致这种决定的环境。
简而言之,答案可以用几句简单的话来提供。首先,将这些部分组合在一起(尤其是代码部分)并不总是简单的。这一因素肯定起一定的作用,但并不充分;有许多编程语言的程序构建过程可以一步完成(换句话说,它们不需要将过程分成两个阶段)。
第二,应用于程序构建过程的代码重用原则(以及将来自不同项目的二进制部分组合在一起的能力)明确肯定了将 C/C++ 构建实现为两步(编译和链接)过程的决定。
是什么让组合变得如此复杂?
在很大程度上,将源代码翻译成二进制目标文件是一个相当简单的过程。代码行被翻译成特定于处理器的机器代码指令;为初始化变量保留空间,并向其中写入初始值;未初始化变量的空间被保留并用零填充,等等。
然而,整个故事中有一部分注定会引起一些问题:即使源代码被分组到专用的源文件中,成为同一个程序的一部分意味着一定存在某些相互的联系。事实上,代码不同部分之间的连接通常是通过以下两种方式建立的:
- 功能独立的代码体之间的函数调用:
例如,聊天应用的 GUI 相关源文件中的函数可以调用 TCP/IP 网络源文件中的函数,该函数又可以调用位于加密源文件中的函数。
- 外部变量:
在 C 编程语言领域(在 C++ 领域要少得多),通常的做法是保留全局可见的变量,以维护代码各个部分的状态。更广泛使用的变量通常在一个源文件中声明为全局变量,并作为外部变量从所有其他源文件中引用。
一个典型的例子是标准 C 库中使用的 errno 变量,用于保存最后遇到的错误的值。
为了访问这两个(通常称为符号)中的任何一个,必须知道它们的地址(更准确地说,是函数在程序存储器中的地址和/或全局变量在数据存储器中的地址)。
但是,在将各个段合并到相应的程序段之前(即,在段平铺完成之前),无法知道实际地址!!!).在此之前,函数和它的调用者之间的有意义的连接和/或对外部变量的访问是不可能建立的,这两者都被适当地报告为未解析的引用。请注意,如果函数或全局变量是从定义它的同一个源文件中引用的,就不会出现这种问题。在这种特殊的情况下,函数/变量和它们的调用者/用户最终都是同一个部分的一部分,并且它们相对于彼此的位置在“大拼图完成”之前就已经知道了在这种情况下,一旦切片完成,相对内存地址就变得具体和可用。
正如本节前面提到的,解决这种问题仍然不要求构建过程必须分为两个不同的阶段。事实上,许多不同的语言都成功地实现了一遍构建过程。然而,应用于构建程序领域的重用概念(在本例中是二进制重用)最终确定了将程序构建分为两个阶段(编译和链接)的决策。
连接
程序构建过程的第二阶段是链接。链接过程的输入是由先前完成的编译阶段创建的目标文件的集合。每个目标文件可以被看作是对所有类型的程序存储器映射部分(代码、初始化数据、未初始化数据、调试信息等)的单独源文件贡献的二进制存储。).链接器的最终任务是从单个贡献中形成结果程序内存映射部分,并解析所有引用。提醒一下,虚拟存储器的概念简化了链接器的任务,因为它允许假设链接器需要填充的程序存储器映射是对于每个程序的相同大小的从零开始的地址范围,而不管操作系统在运行时给进程什么地址范围。
为了简单起见,我将在这个例子中涵盖最简单的可能情况,其中对程序存储器映射部分的贡献仅仅来自属于同一个项目的文件。实际上,由于二进制重用概念的发展,这可能不是真的。
链接阶段
链接过程通过一系列阶段(重定位、引用解析)进行,这将在下面详细讨论。
重新安置
链接程序的第一阶段就是平铺,在这个过程中,包含在单个目标文件中的各种类型的段被组合在一起,以创建程序存储器映射段(见图 2-4 )。为了完成这个任务,先前中立的、从零开始的起作用的段的地址范围被转换成结果程序存储器映射的更具体的地址范围。
图 2-4。
Relocation, the first phase of the linking stage
措辞“更具体”用于强调由链接器创建的结果程序映像本身仍然是中性的。请记住,虚拟寻址机制使每个程序都有可能拥有相同的、相同的、简单的程序地址空间视图(驻留在 0 到 2N 之间),而程序执行的实际物理地址在运行时由操作系统确定,对程序和程序员是不可见的。
一旦重新定位阶段完成,大多数(但不是全部!)的程序存储器映射已经被创建。
解析引用
现在是困难的部分。以段为例,将它们的地址范围线性转换成程序存储器映射地址范围是相当容易的任务。更困难的任务是在代码的各个部分之间建立所需的连接,从而使程序同质。
让我们假设(考虑到这个演示程序的简单性,这是正确的)前面的所有构建阶段(完整的编译以及节重定位)都已经成功完成。现在是指出哪种问题留到最后的链接阶段解决的时候了。
如前所述,链接问题的根本原因相当简单:源自不同翻译单元(即源文件)的代码片段试图相互引用,但不可能知道这些项目将驻留在内存中的什么位置,直到目标文件被平铺到程序内存映射的主体中。导致问题最多的代码组件是那些与程序内存(函数入口点)或数据内存(全局/静态/外部)变量中的地址紧密绑定的组件。
在这个特定的代码示例中,您有以下情况:
- 函数
add_and_multiply
调用函数add
,该函数驻留在同一个源文件中(即同一个目标文件中的同一个翻译单元)。在这种情况下,函数add(
在程序存储器中的地址在某种程度上是一个已知的量,可以用它相对于目标文件function.o
代码段的偏移量来表示。 - 现在函数
main
调用函数add_and_multiply
并且引用外部变量nCompletionStatus
,并且在计算它们所在的实际程序内存地址时遇到了巨大的问题。事实上,它只能假设这两个符号将在未来的某个时刻驻留在进程内存映射中的某个位置。但是,在内存映射形成之前,有两个条目只能被认为是未解决的引用。
这种情况在图 2-5 中用图形描述。
图 2-5。
The problem of unresolved references in its essential form
为了解决这类问题,必须出现解析引用的链接阶段。在这种情况下,链接器需要做的是
- 检查程序存储器映射中已经平铺在一起的部分。
- 找出代码的哪个部分调用了其原始部分之外的部分。
- 找出代码引用部分的确切位置(在内存映射中的哪个地址)。
- 最后,通过用程序存储器映射的实际地址替换机器指令中的虚拟地址来解析引用。
一旦链接器完成了它的魔法,情况可能看起来如图 2-6 所示。
图 2-6。
Resolved references
演示项目链接示例
有两种方法可以编译和链接完整的演示项目来创建可执行文件,以便它可以运行。
在分步方法中,您将首先在两个源文件上调用编译器来生成目标文件。在接下来的步骤中,您将把两个目标文件链接到输出可执行文件中。
$ gcc -c function.c main.c
$ gcc function.o main.o -o demoApp
在一次性全部完成的方法中,只需一个命令就可以通过调用编译器和链接器来完成相同的操作。
$ gcc function.c main.c -o demoApp
为了这个演示的目的,让我们采取一步一步的方法,因为它将生成main.o
对象文件,其中包含我想在这里演示的非常重要的细节。
文件的反汇编main.o,
$ objdump -D -M intel main.o
显示它包含未解析的引用。
disassembled output of main.o (Intel assembler format)
main.o: file format elf32-i386
Disassembly of section .text:
00000000 <main>:
0: 55 push ebp
1: 89 e5 mov ebp,esp
3: 83 e4 f0 and esp,0xfffffff0
6: 83 ec 20 sub esp,0x20
9: b8 00 00 80 3f mov eax,0x3f800000
e: 89 44 24 14 mov DWORD PTR [esp+0x14],eax
12: b8 00 00 a0 40 mov eax,0x40a00000
17: 89 44 24 18 mov DWORD PTR [esp+0x18],eax
1b: 8b 44 24 18 mov eax,DWORD PTR [esp+0x18]
1f: 89 44 24 04 mov DWORD PTR [esp+0x4],eax
23: 8b 44 24 14 mov eax,DWORD PTR [esp+0x14]
27: 89 04 24 mov DWORD PTR [esp],eax
2a: e8 fc ff ff ff call
2b <main + 0x2b>
2f: d9 5c 24 1c fstp DWORD PTR [esp+0x1c]
33: c7 05 00 00 00 00 01 mov DWORD PTR``ds:0x0
3a: 00 00 00
3d: b8 00 00 00 00 mov eax,0x0
42: c9 leave
43: c3 ret :
2a 行有一个跳转到自身的调用指令(很奇怪吧?)而第 33 行显示了对驻留在地址 0x0 的变量的访问(更奇怪)。显然,这两个明显奇怪的值是链接器有目的地插入的。
然而,输出可执行文件的反汇编输出表明,不仅main.o
目标文件的内容已经被重新定位到从地址 0x08048404 开始的地址范围,而且这两个问题点已经被链接器解决。
$ objdump -D -M intel demoApp
disassembled output of demoApp (Intel assembler format)
080483ce <add_and_multiply>:
80483ce: 55 push ebp
80483cf: 89 e5 mov ebp,esp
80483d1: 83 ec 1c sub esp,0x1c
80483d4: 8b 45 0c mov eax,DWORD PTR [ebp+0xc]
80483d7: 89 44 24 04 mov DWORD PTR [esp+0x4],eax
80483db: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
80483de: 89 04 24 mov DWORD PTR [esp],eax
80483e1: e8 ce ff ff ff call 80483b4 <add>
80483e6: d9 5d fc fstp DWORD PTR [ebp-0x4]
80483e9: d9 45 fc fld DWORD PTR [ebp-0x4]
80483ec: d9 05 20 85 04 08 fld DWORD PTR ds:0x8048520
80483f2: de c9 fmulp st(1),st
80483f4: d9 5d fc fstp DWORD PTR [ebp-0x4]
80483f7: 8b 45 fc mov eax,DWORD PTR [ebp-0x4]
80483fa: 89 45 ec mov DWORD PTR [ebp-0x14],eax
80483fd: d9 45 ec fld DWORD PTR [ebp-0x14]
8048400: c9 leave
8048401: c3 ret
8048402: 90 nop
8048403: 90 nop
08048404 <main>:
8048404: 55 push ebp
8048405: 89 e5 mov ebp,esp
8048407: 83 e4 f0 and esp,0xfffffff0
804840a: 83 ec 20 sub esp,0x20
804840d: b8 00 00 80 3f mov eax,0x3f800000
8048412: 89 44 24 14 mov DWORD PTR [esp+0x14],eax
8048416: b8 00 00 a0 40 mov eax,0x40a00000
804841b: 89 44 24 18 mov DWORD PTR [esp+0x18],eax
804841f: 8b 44 24 18 mov eax,DWORD PTR [esp+0x18]
8048423: 89 44 24 04 mov DWORD PTR [esp+0x4],eax
8048427: 8b 44 24 14 mov eax,DWORD PTR [esp+0x14]
804842b: 89 04 24 mov DWORD PTR [esp],eax
804842e: e8 9b ff ff ff call 80483ce
<add_and_multiply>
8048433: d9 5c 24 1c fstp DWORD PTR [esp+0x1c]
8048437: c7 05 18 a0 04 08 01 mov DWORD PTR``ds:0x804a018
804843e: 00 00 00
8048441: b8 00 00 00 00 mov eax,0x0
8048446: c9 leave t:
存储器映射地址 0x8048437 处的行引用地址 0x804a018 处的变量。现在唯一悬而未决的问题是在那个特定的地址上驻留了什么?
多功能的 objdump 工具可能会帮助您找到这个问题的答案(后续章节的相当一部分专门讨论这个非常有用的工具)。
通过运行以下命令
$ objdump -x -j .bss demoApp
你可以拆开。携带未初始化数据的 bss 部分,它揭示了您的变量nCompletionStatus
正好位于地址 0x804a018,如图 2-7 所示。
图 2-7。
bss disassembled
链接者的观点
“当你手里拿着一把锤子时,所有东西看起来都像钉子”——手用锤子综合症
但是说真的,伙计们。。。。
现在您已经知道了链接任务的复杂性,这有助于缩小一点范围,并尝试总结在运行其常规任务时指导链接器的原理。事实上,链接器是一种特殊的工具,不像它的哥哥编译器,它对编写代码的微小细节不感兴趣。相反,它将世界视为一组目标文件,这些文件(很像拼图)将被组合在一起,形成一个更大的程序内存映射图,如图 2-8 所示。
图 2-8。
The linker’s view of the world
发现图 2-8 与图 2-9 的左边部分有很多相似之处并不需要太多的想象力,而链接者的最终任务可以由同一图的右边部分来表示。
图 2-9。
Linker’s view of the world as seen by humans
可执行文件属性
链接过程的最终结果是二进制可执行文件,其布局遵循适合于目标平台的可执行格式的规则。不管实际的格式差异如何,可执行文件通常包含结果节(。文本,。数据,。bss,以及许多更狭义的专门化的文件)。最值得注意的是,代码(。text)部分不仅包含来自目标文件的各个图块,而且链接器对其进行了修改,以确保各个图块之间的所有引用都已被解析,从而代码不同部分之间的函数调用以及变量访问都是准确且有意义的。
在可执行文件包含的所有符号中,有一个非常独特的地方属于main
函数,因为从 C/C++ 程序的角度来看,它是整个程序执行开始的函数。然而,这并不是程序启动时执行的第一部分代码。
需要指出的一个非常重要的细节是,可执行文件并不完全由从项目源文件编译的代码组成。事实上,负责启动程序执行的一段具有重要战略意义的代码是在链接阶段添加到程序存储器映射中的。链接器通常将此目标代码存储在程序内存映射的开头,它有两种变体:
- crt0 是“普通的”入口点,是在内核控制下执行的程序代码的第一部分。
- crt1 是更现代的启动例程,支持在执行
main
函数之前和程序终止之后完成的任务。
考虑到这些细节,可执行程序的整体结构可以用图 2-10 来象征性地表示。
图 2-10。
Overall structure of an executable file
正如您将在后面专门讨论动态库和动态链接的章节中看到的那样,由操作系统提供的这段额外的代码几乎决定了可执行程序和动态库之间的所有区别;后者没有这部分代码。
下一章将讨论程序开始执行时发生的一系列步骤的更多细节。
各种截面类型
就像没有马达和一组四个轮子就无法想象汽车的行驶一样,没有代码就无法想象程序的执行。文本)和数据(。数据和/或。bss)部分。这些成分自然是最基本的程序功能的精华部分。
然而,就像汽车不只是马达和四个轮子,二进制文件包含更多的部分。为了很好地同步各种操作任务,链接器创建更多不同的节类型并插入到二进制文件中。
按照惯例,节名以点(.)性格。最重要的节类型的名称是独立于平台的;不管平台和它所属的二进制格式是什么,它们的名称都是一样的。
在本书的整个过程中,将详细讨论某些部分类型在整个事物中的意义和作用。希望在通读这本书的时候,读者将对二进制文件部分有更广泛和更集中的理解。
在表 2-1 中,Linux 的流行 ELF 二进制格式的规范带来了以下( http://man7.org/linux/man-pages/man5/elf.5.html
)按字母顺序提供的各种节类型的列表。尽管对单个部分的描述有点贫乏,但在这一点上看一眼各种部分可能会让读者对各种可用的部分有相当好的了解。
表 2-1。
Linker Section Types
| 部分名称 | 描述 | | --- | --- | | 。英国标准规格 | 这个部分保存了未初始化的数据,这些数据构成了程序的内存映像。根据定义,当程序开始运行时,系统用零初始化数据。该部分的类型为`SHT_NOBITS`。属性类型为`SHF_ALLOC`和`SHF_WRITE`。 | | 。评论 | 此部分包含版本控制信息。该部分的类型为`SHT_PROGBITS`。不使用任何属性类型。 | | 。科特斯 | 这个部分保存指向 C++ 构造函数的初始化指针。该部分的类型为`SHT_PROGBITS`。属性类型为`SHF_ALLOC`和`SHF_WRITE`。 | | 。数据 | 这个部分保存初始化的数据,这些数据有助于程序的内存映像。该部分的类型为`SHT_PROGBITS`。属性类型为`SHF_ALLOC`和`SHF_WRITE`。 | | .数据 1 | 这个部分保存初始化的数据,这些数据有助于程序的内存映像。该部分的类型为`SHT_PROGBITS`。属性类型为`SHF_ALLOC`和`SHF_WRITE`。 | | 。调试 | 本节包含符号调试的信息。内容不明。该部分的类型为`SHT_PROGBITS`。不使用任何属性类型。 | | 。析构函数表 | 这个部分保存指向 C++ 析构函数的初始化指针。该部分的类型为`SHT_PROGBITS`。属性类型为`SHF_ALLOC`和`SHF_WRITE`。 | | 。动态的 | 这个部分包含动态链接信息。该部分的属性包括`SHF_ALLOC`位。`SHF_WRITE`位是否置位取决于处理器。该部分属于`SHT_DYNAMIC`类型。请参见上面的属性 | | 。dynstr | 此部分包含动态链接所需的字符串,最常见的是表示与符号表条目相关联的名称的字符串。该部分的类型为`SHT_STRTAB`。使用的属性类型是`SHF_ALLOC`。 | | 。dynsym | 此部分保存动态链接符号表。该部分的类型为`SHT_DYNSYM`。使用的属性是`SHF_ALLOC`。 | | 。菲尼 | 这个部分保存了构成进程终止代码的可执行指令。当一个程序正常退出时,系统安排执行该段代码。该部分的类型为`SHT_PROGBITS`。使用的属性是`SHF_ALLOC`和`SHF_EXECINSTR`。 | | . gnu 版本 | 这个部分保存版本符号表,这是一个 ElfN_Half 元素的数组。该部分的类型为`SHT_GNU_versym`。使用的属性类型是`SHF_ALLOC`。 | | . gnu .版本 _d | 这个部分包含版本符号定义,一个 ElfN_Verdef 结构表。该部分的类型为`SHT_GNU_verdef`。使用的属性类型是`SHF_ALLOC`。 | | 。gnu_version.r 版 | 这个部分包含版本符号所需的元素,一个 ElfN_Verneed 结构表。该部分的类型为`SHT_GNU_versym`。使用的属性类型是`SHF_ALLOC`。 | | 。得到 | 此部分保存全局偏移表。该部分的类型为`SHT_PROGBITS`。这些属性是特定于处理器的。 | | . got.plt | 这个部分保存了过程链接表。该部分的类型为`SHT_PROGBITS`。这些属性是特定于处理器的。 | | 。混杂 | 这个部分包含一个符号哈希表。该部分的类型为`SHT_HASH`。使用的属性是`SHF_ALLOC`。 | | 。初始化 | 这一部分包含有助于进程初始化代码的可执行指令。当一个程序开始运行时,系统安排在调用主程序入口点之前执行本节中的代码。该部分的类型为`SHT_PROGBITS`。使用的属性是`SHF_ALLOC`和`SHF_EXECINSTR`。 | | 。插值函数 | 这个部分保存了程序解释器的路径名。如果文件有一个包含该段的可加载段,则该段的属性将包含`SHF_ALLOC`位。否则,该位将关闭。该部分属于`SHT_PROGBITS`类型。 | | 。线条 | 这个部分保存了符号调试的行号信息,它描述了程序源和机器码之间的对应关系。内容不明。该部分的类型为`SHT_PROGBITS`。不使用任何属性类型。 | | 。注意 | 该部分以“注释部分”的格式保存信息。该部分的类型为`SHT_NOTE`。不使用任何属性类型。OpenBSD 本地可执行文件通常包含一个. note.openbsd.ident 部分来标识自己,以便内核在加载文件时绕过任何兼容性 ELF 二进制仿真测试。 | | . note.GNU-stack | 这个部分在 Linux 目标文件中用于声明堆栈属性。该部分的类型为`SHT_PROGBITS`。唯一使用的属性是`SHF_EXECINSTR`。这向 GNU 链接器表明目标文件需要一个可执行堆栈。 | | 。血小板计数 | 这个部分保存了过程链接表。该部分的类型为`SHT_PROGBITS`。这些属性是特定于处理器的。 | | 。relNAME | 如下所述,该部分保存重定位信息。如果文件有一个包含重定位的可加载段,该段的属性将包含`SHF_ALLOC`位。否则,该位将被关闭。按照惯例,“名称”由重定位应用到的部分提供。因此形成了一个重新定位部分。文本通常名为. rel.text。该部分的类型为`SHT_REL`。 | | 。重新命名 | 如下所述,该部分保存重定位信息。如果文件有一个包含重定位的可加载段,该段的属性将包含`SHF_ALLOC`位。否则,该位将被关闭。按照惯例,“名称”由重定位应用到的部分提供。因此,重新定位部分。文本的名称通常为. rela.text。该部分的类型为`SHT_RELA`。 | | .滚动 | 该部分保存只读数据,这些数据通常是进程映像中不可写的部分。该部分的类型为`SHT_PROGBITS`。使用的属性是`SHF_ALLOC`。 | | .rodata1 | 该部分保存只读数据,这些数据通常是进程映像中不可写的部分。该部分的类型为`SHT_PROGBITS`。使用的属性是`SHF_ALLOC`。 | | 。shrstrtab | 该部分包含部分名称。该部分的类型为`SHT_STRTAB`。不使用任何属性类型。 | | 。strtab | 此部分保存字符串,最常见的是表示与符号表条目相关的名称的字符串。如果文件有一个包含符号字符串表的可加载段,该段的属性将包含`SHF_ALLOC`位。否则,钻头将会脱落。该部分属于`SHT_STRTAB`类型。 | | 。西蒙 tab | 这个部分包含一个符号表。如果文件有一个包含符号表的可加载段,该段的属性将包含`SHF_ALLOC`位。否则,该位将被关闭。该部分属于`SHT_SYMTAB`类型。 | | 。文本 | 这个部分包含程序的“文本”或可执行指令。该部分的类型为`SHT_PROGBITS`。使用的属性是`SHF_ALLOC`和`SHF_EXECINSTR`。 |各种符号类型
ELF 格式提供了种类繁多的链接器符号类型,远远超出了您在理解错综复杂的链接过程的早期阶段的想象。目前,您可以清楚地分辨出符号可以是局部范围的,也可以是其他模块通常需要的更广的可见性。在随后的整本书材料中,我们将更详细地讨论各种符号类型。
表 2-2 显示了各种符号类型,如有用的 nm 符号检查实用程序的手册页( http://linux.die.net/man/1/nm
)所示。一般来说,除非明确指出(如“U”与“U”的情况),小写字母表示局部符号,而大写字母表示更好的符号可视性(外部、全局)。
表 2-2。
Linker Symbol Types
| 符号类型 | 描述 | | --- | --- | | “一个” | 符号的值是绝对的,不会因进一步链接而改变。 | | " B "还是" B " | 该符号在未初始化的(。bss)数据段。 | | “C” | 这个符号很常见。通用符号是未初始化的数据。链接时,多个通用符号可能以相同的名称出现。如果在任何地方定义了符号,公共符号将被视为未定义的引用。 | | " D "还是" D " | 该符号位于初始化数据部分。 | | " G "还是" G " | 该符号位于小对象的初始化数据段中。一些对象文件格式允许更有效地访问小数据对象,例如与大型全局数组相反的全局`int`变量。 | | “我” | 对于 PE 格式文件,这表明该符号位于特定于 dll 实现的节中。对于 ELF 格式文件,这表明该符号是一个间接函数。这是对 ELF 符号类型标准集的 GNU 扩展。它表示一个符号,如果被重定位引用,则不计算其地址,而是必须在运行时调用。运行时执行将返回在重定位中使用的值。 | | “N” | 该符号是调试符号。 | | “p” | 符号位于堆栈展开节中。 | | " R "还是" R " | 该符号位于只读数据段中。 | | " S "还是" S " | 该符号位于小对象的未初始化数据段中。 | | “T”还是“T” | 该符号位于文本(代码)部分。 | | " U " | 该符号未定义。事实上,这个二进制文件并没有定义这个符号,而是期望它最终作为加载动态库的结果出现。 | | " u " | 该符号是一个独特的全球符号。这是对 ELF 符号绑定标准集的 GNU 扩展。对于这样的符号,动态链接器将确保在整个进程中只有一个符号使用这个名称和类型。 | | " V "还是" V " | 这个符号是一个弱物体。当弱定义符号与正常定义符号链接时,正常定义符号被正确使用。当弱未定义符号被链接且该符号未被定义时,该弱符号的值变为零而没有错误。在某些系统上,大写字母表示已经指定了默认值。 | | “W”还是“W” | 该符号是一个弱符号,没有被专门标记为弱对象符号。当弱定义符号与正常定义符号链接时,正常定义符号被正确使用。当弱未定义符号被链接且该符号未被定义时,该符号的值以特定于系统的方式被确定而没有错误。在某些系统上,大写字母表示已经指定了默认值。 | | "-" | 该符号是一个`a.out`目标文件中的 stabs 符号。在这种情况下,打印的下一个值是 stabs other 字段、stabs desc 字段和 stab type。Stabs 符号用于保存调试信息。 | | "?" | 符号类型未知,或者目标文件格式特定。 |三、程序执行阶段
Abstract
本章的目的是描述当用户启动一个程序时发生的一系列事件。分析主要集中在指出操作系统和可执行二进制文件的布局之间相互作用的细节,这与进程内存映射紧密相关。不用说,本讨论的主要焦点是通过在 C/C++ 中构建代码而创建的可执行二进制文件的执行顺序。
本章的目的是描述当用户启动一个程序时发生的一系列事件。分析主要集中在指出操作系统和可执行二进制文件的布局之间相互作用的细节,这与进程内存映射紧密相关。不用说,本讨论的主要焦点是通过在 C/C++ 中构建代码而创建的可执行二进制文件的执行顺序。
外壳的重要性
在用户控制下的程序执行通常通过外壳发生,外壳是监视用户在键盘和鼠标上的动作的程序。Linux 有许多不同的 shells,最流行的是 sh、bash 和 tcsh。
一旦用户键入命令名并按下 Enter 键,shell 首先尝试将键入的命令名与它自己的内置命令进行比较。如果确认程序名不是 shell 支持的任何命令,shell 将尝试查找名称与命令字符串匹配的二进制文件。如果用户只键入程序名(即不是可执行二进制文件的完整路径),shell 会尝试在 path 环境变量指定的每个文件夹中查找可执行文件。一旦知道了可执行二进制文件的完整路径,shell 就会激活加载和执行二进制文件的过程。
shell 的第一个强制性动作是通过派生相同的子进程来创建自己的克隆。通过复制 shell 的现有内存映射来创建新的进程内存映射似乎是一个奇怪的举动,因为新的进程内存映射很可能与 shell 的内存映射没有任何共同之处。这个奇怪的操作有一个很好的理由:这样 shell 就可以有效地将其所有的环境变量传递给新进程。事实上,在新进程内存映射创建后不久,其原始内容的大部分被擦除/清零(除了携带继承的环境变量的部分),并被新进程的内存映射覆盖,从而为执行阶段做好准备。图 3-1 说明了这个想法。
图 3-1。
The shell starts creating the new process memory map by copying its own process memory map, with the intention to pass its own environment variables to the new process
从这一点开始,shell 可能会遵循两种可能的场景之一。默认情况下,外壳等待其分叉克隆进程完成命令(即启动的程序完成执行)。或者,如果用户在程序名后面键入一个&符号,子进程将被推到后台,shell 将继续监视用户随后键入的命令。完全相同的模式可以通过用户不在可执行文件名称后附加&符号来实现;相反,在程序启动后,用户可以按 Ctrl-Z(向子进程发出 SIGSTOP 信号)并在 shell 窗口中键入“bg”(向子进程发出 SIGCONT 信号),这将导致相同的效果(将 shell 子进程推到后台)。
当用户在应用程序图标上点击鼠标时,会出现一个非常相似的启动程序的场景。提供图标的程序(比如 Linux 上的 gnome-session 和/或 Nautilus 文件浏览器)负责将鼠标点击转换成system( )
调用,这导致一系列非常相似的事件发生,就好像通过在 shell 窗口中键入来调用应用程序一样。
核心角色
一旦 shell 委派了运行程序的任务,内核就通过调用exec
系列函数中的一个函数来做出反应,所有这些函数都提供了几乎相同的功能,但是在如何指定执行参数的细节上有所不同。不管选择哪一个特定的exec
类型的函数,它们中的每一个最终都会调用sys_execve
函数,该函数开始执行程序的实际工作。
下一步(发生在函数search_binary_handler
(文件fs/exec.c
)是识别可执行格式。除了支持最新的 ELF 二进制可执行格式之外,Linux 还通过支持其他几种二进制格式来提供向后兼容性。如果 ELF 格式被识别,动作的焦点移到load_elf_binary
功能(文件fs/binfmt_elf.c
)。
在可执行格式被识别为支持的格式之一之后,准备用于执行的进程存储器映射的工作开始。特别是,由外壳创建的子进程(外壳本身的克隆)从外壳传递到内核,目的如下:
- 内核获得了沙箱(进程环境),更重要的是,获得了相关的内存,可以用来启动新程序。
内核要做的第一件事是彻底清除大部分内存映射。紧接着,它将把用从新程序的二进制可执行文件 harePoint 中读取的数据填充擦除的内存映射的过程委托给加载程序。
- 通过克隆 shell 进程(通过
fork( )
调用),shell 中定义的环境变量被传递到子进程,这有助于环境变量的继承链不会被破坏。
加载者角色
在详细介绍加载器功能之前,必须指出加载器和链接器对二进制文件的内容有不同的观点。
二进制文件的特定于加载程序的视图(段与段)
链接器可以被认为是一个高度复杂的模块,能够精确地区分各种性质的各种各样的部分(代码、未初始化的数据、初始化的数据、构造函数、调试信息等)。).为了解析引用,它必须非常了解其内部结构的细节。
另一方面,加载程序的职责要简单得多。在大多数情况下,它的任务是将链接器创建的部分复制到进程内存映射中。为了完成它的任务,它不需要知道很多关于部分的内部结构。相反,它所关心的是这些部分的属性是否是只读的、可读写的,以及(后面将讨论)在可执行文件准备好启动之前是否需要应用一些补丁。
Note
正如后面关于动态链接过程的讨论中所显示的,加载器的功能比简单的复制数据块要复杂一些。
因此,加载器倾向于根据它们共同的加载需求将链接器创建的部分分组为段也就不足为奇了。如图 3-2 所示,加载程序段通常包含几个具有共同访问属性的部分(读或读写,或者最重要的,是否打补丁)。
图 3-2。
Linker vs. loader
如图 3-3 所示,使用readelf
实用程序来检查段说明了将许多不同的链接程序段分组到加载程序段中。
图 3-3。
Sections grouped into segments
程序装入阶段
一旦确定了二进制格式,内核的加载器模块就开始发挥作用了。加载程序首先尝试在可执行二进制文件中定位 PT_INTERP 段,这将有助于它执行动态加载任务。
为了避免众所周知的“本末倒置”的情况——因为动态加载还有待解释——让我们假设一个最简单的场景,其中程序是静态链接的,不需要任何类型的动态加载。
STATIC BUILD EXAMPLE
术语静态构建用于表示可执行文件,它不具有任何动态链接依赖关系。创建这种可执行文件所需的所有外部库都是静态链接的。因此,获得的二进制文件是完全可移植的,因为它不需要任何系统共享库(甚至不需要libc
)就可以执行。完全可移植性的好处(很少需要如此激烈的措施)是以可执行文件的字节大小大大增加为代价的。
除了完全的可移植性之外,静态构建可执行文件的原因可能纯粹是教育性的,因为它非常适合解释加载程序最初的、最简单的可能角色的过程。
静态建筑的效果可以用简单明了的“Hello World”的例子来说明。让我们使用同一个源文件来构建两个应用程序,其中一个是用-static
链接器标志构建的;请参见清单 3-1 和 3-2。
清单 3-1。主页面
#include <stdio.h>
int main(int argc, char* argv[])
{
printf("Hello, world\n");
return 0;
}
清单 3-2。build.sh
gcc main.cpp -o regularBuild
gcc``-static
比较这两个可执行文件的字节大小可以看出,静态构建的可执行文件的字节大小要大得多(在这个特定的例子中大约大 100 倍)。
加载程序继续读入程序的二进制文件段的头,以确定每个段的地址和字节长度。需要指出的一个重要细节是,在这个阶段,加载程序仍然没有向程序存储器映射写入任何内容。加载程序在这一阶段所做的就是建立和维护一组结构(例如vm_are_struct
),携带可执行文件段(实际上是每个段的页宽部分)和程序内存映射之间的映射。
从可执行文件中实际复制段发生在程序执行开始之后。当授予进程物理存储器页面和程序存储器映射之间的虚拟存储器映射已经建立时;第一个分页请求开始从内核到达,请求页面范围的程序段可用于执行。这种策略的直接结果是,只有运行时真正需要的程序部分会被加载(图 3-4 )。
图 3-4。
Program loading stage
执行程序入口点
从通常的 C/C++ 编程角度来看,程序入口点是main()
函数。然而,从程序执行的角度来看,并非如此。在执行流程到达main()
函数之前,会执行一些其他函数,为程序的运行铺平道路。
让我们仔细看看在 Linux 中程序加载和执行main()
函数的第一行代码之间通常会发生什么。
加载程序找到入口点
加载程序后(即准备程序蓝图并将必要的部分复制到内存中以便执行),加载程序快速查看 ELF 头中e_entry
字段的值。该值包含程序存储器地址,执行将从该地址开始。
反汇编可执行二进制文件通常会显示出,e_entry
值只携带代码的第一个地址(。文本)部分。巧合的是,这个程序存储器地址通常表示_start
函数的来源。
以下是.text
部分的拆卸:
08048320 <_start>:
8048320: 31 ed xor ebp,ebp
8048322: 5e pop esi
8048323: 89 e1 mov ecx,esp
8048325: 83 e4 f0 and esp,0xfffffff0
8048328: 50 push eax
8048329: 54 push esp
804832a: 52 push edx
804832b: 68 60 84 04 08 push 0x8048460
8048330: 68 f0 83 04 08 push 0x80483f0
8048335: 51 push ecx
8048336: 56 push esi
8048337: 68 d4 83 04 08 push 0x80483d4
804833c: e8 cf ff ff ff call 8048310 <``__libc_start_main
8048341: f4 hlt
_start()函数的作用
_start
函数的作用是为接下来要调用的__libc_start_main
函数准备输入参数。其原型被定义为
int __libc_start_main(int (*main) (int, char * *, char * *), /* address of main function */
int argc, /* number of command line args */
char * * ubp_av, /* command line arg array */
void (*init) (void), /* address of init function */
void (*fini) (void), /* address of fini function */
void (*rtld_fini) (void), /* address of dynamic linker fini function */
void (* stack_end) /* end of the stack address */
);
事实上,call 指令之前的所有指令都是按照预期的顺序堆叠调用所需的参数。
为了理解这些指令到底是做什么的,为什么,请看下一节,这一节将专门解释堆栈机制。但是在去那里之前,我们先完成关于开始程序执行的故事。
__libc_start_main()函数的作用
在为程序运行准备环境的过程中,这个函数是关键角色。它不仅在程序执行期间为程序设置环境变量,而且还执行以下操作:
- 启动程序的线程。
- 调用
_init()
函数,该函数执行需要在main()
函数开始之前完成的初始化。
GCC 编译器通过the __attribute__ ((constructor))
关键字支持自定义设计您可能希望在程序启动前完成的例程。
- 注册程序终止后要调用来清理的
_fini()
和_rtld_fini()
函数。通常,_fini()
的动作与_init()
函数的动作相反。
GCC 编译器,通过__attribute__ ((destructor))
关键字,支持在程序启动前您可能想要完成的例程的定制设计。
最后,在所有的先决条件动作完成后,_ libc_start_main()
调用main()
函数,从而使您的程序运行。
堆栈和调用约定
任何具有绝对初学者水平以上编程经验的人都知道,典型的程序流实际上是一系列函数调用。通常,主函数至少调用一个函数,而这个函数又可能调用大量的其他函数。
堆栈的概念是函数调用机制的基石。程序执行的这个特殊方面对于本书的整个主题来说并不是最重要的,我们也不会花太多的时间来讨论堆栈如何工作的细节。这个话题早已是老生常谈,众所周知的事实无需赘述。
相反,将只指出与堆栈和函数相关的几个要点。
- 进程内存映射为堆栈的需要保留了一定的区域。
- 运行时使用的堆栈内存量实际上是变化的;函数调用序列越长,使用的堆栈内存就越多。
- 堆栈内存不是无限的。相反,可用堆栈内存量与可用于分配的内存量(进程内存中称为堆的部分)绑定在一起。
函数调用约定
函数如何将参数传递给它调用的函数是一个非常有趣的话题。已经设计了各种非常复杂的将变量传递给函数的机制,产生了特定的汇编语言例程。这种堆栈实现机制通常被称为调用约定。
事实上,已经为 X86 架构开发了许多不同的调用约定,例如cdecl
、stdcall
、fastcall
、thiscall
等等。它们中的每一个都是从各种设计角度为特定场景定制的。由内曼贾·特里弗诺维奇( www.codeproject.com/Articles/1388/Calling-Conventions-Demystified
)撰写的标题为“召唤惯例去神秘化”的文章提供了一个有趣的视角,深入了解各种召唤惯例之间的差异。几年后,传奇人物 Raymond Chen 发表了一系列名为“呼叫约定的历史”的博客文章( http://blogs.msdn.com/b/oldnewthing/archive/2004/01/02/47184.aspx
),这可能是关于该主题的最完整的单一信息来源。
在这个特定的主题上不要花费太多时间,一个特别重要的细节是,在所有可用的调用约定中,有一个特别的,cdecl
调用约定是实现导出到另一个世界的动态库的接口的首选。请继续关注更多细节,因为在第六章中关于库 ABI 功能的讨论将对这个主题提供更好的见解。
四、重用概念的影响
Abstract
代码重用的概念无处不在,并且已经找到了令人印象深刻的多种表现方式。它对构建程序过程的影响发生在众所周知的从过程编程语言向面向对象语言的转变之前。
代码重用的概念无处不在,并且已经找到了令人印象深刻的多种表现方式。它对构建程序过程的影响发生在众所周知的从过程编程语言向面向对象语言的转变之前。
在前几章中已经描述了在编译器和链接器之间划分任务的最初原因。简而言之,这一切都始于将代码保存在单独的源文件中的有用习惯;然后,在编译时,很明显编译器不能简单地完成解析引用的任务,因为必须首先将代码段拼接到程序内存映射的最终拼图中。
代码重用的想法为拆分编译和链接阶段的决策增加了额外的参数。目标文件带来的不确定性(所有部分都有从零开始的地址范围和未解析的引用),从代码共享的观点来看,这最初看起来肯定是一个缺点,实际上开始看起来像一个宝贵的新品质。
应用于构建程序可执行文件领域的代码重用概念最初是以静态库的形式实现的,静态库是目标文件的捆绑集合。后来,随着多任务操作系统的出现,另一种被称为动态库的重用形式变得突出起来。如今,这两个概念(静态库和动态库)都在使用,各有利弊,因此需要更深入地理解其功能的内部细节。本章非常详细地描述了这两个有些相似,但又有本质区别的概念。
静态库
静态库概念背后的思想非常简单:一旦编译器将一组翻译单元(即源文件)翻译成二进制目标文件,您可能希望保留这些目标文件供以后在其他项目中使用,在链接时它们可以很容易地与其他项目固有的目标文件组合在一起。
为了能够将二进制目标文件集成到某个其他项目中,至少需要满足一个额外的要求:二进制文件附带有导出头包含文件,该文件将提供至少这些函数的各种定义和函数声明,这些函数可以用作入口点。标题为“结论:二进制重用概念的影响”的部分解释了为什么有些功能比其他功能更重要。
有几种方法可以将一组目标文件用于各种项目中:
- 在这种情况下,显而易见的要求是链接器理解静态库文件格式,并且能够提取其内容(即捆绑在一起的目标文件)以便将它们链接进来。幸运的是,从微处理器编程的早期开始,这个要求就已经被每一个链接器满足了。
- 还要注意的是,创建静态库的过程决不是不可逆的。更确切地说,静态库仅仅是一个目标文件的档案,可以用多种方式操作。通过方便地使用适当的工具,静态库可以被分解成原始对象文件的集合;可以从库中丢弃一个或多个目标文件,可以添加新的目标文件,最后,现有的目标文件可以被较新的版本替换。
图 4-2。
The static library as form of binary code reuse
- 更好的方法是将目标文件捆绑成一个单一的二进制文件,一个静态库。将单个二进制文件交付给另一个项目比单独交付每个目标文件要简单得多,也优雅得多(图 4-2 )。
图 4-1。
A trivial method of binary code reuse, the precursor to static libraries
- 简单的解决方法是保存编译器生成的目标文件,并以任何可能的方式复制(剪切-粘贴)或传输到需要它们的项目中(它们将与其他目标文件一起链接到可执行文件中),如图 4-1 所示。
无论您决定采用这两种方法中的哪一种,是简单的方法还是更复杂的静态库方法,您都将经历二进制代码重用的过程,因为在一个项目中生成的二进制文件会在其他项目中使用。二进制代码重用对软件设计前景的整体影响将在后面详细讨论。
动态库
与静态库的概念不同,静态库的概念在汇编编程的早期就已经存在,而动态库的概念在很久以后才被完全接受。导致其产生和采用的环境与多任务操作系统的出现密切相关。
在对多任务操作系统功能的任何分析中,有一个特殊的概念很快变得突出:不管并发任务的种类,某些系统资源是独特的,必须由每个人共享。桌面系统上共享资源的典型例子有键盘、鼠标、视频图形适配器、声卡、网卡等等。
如果每个打算访问公共资源的应用程序都必须包含提供资源控制的代码(作为源代码或静态库),这将会适得其反,甚至是灾难性的。这将非常低效、笨拙,并且大量存储(硬盘和内存)将被浪费在存储相同代码的副本上。
对更好、更高效的操作系统的白日梦产生了一种共享机制的想法,这种机制假定既不编译重复的源文件,也不链接重复的目标文件。相反,它将作为某种运行时共享来实现。换句话说,正在运行的应用程序将能够在其程序存储器映射中集成一些其他可执行程序的编译和链接部分,其中集成将在运行时按需发生。这个概念被称为动态链接/动态加载,这将在下一节中更详细地说明。
从最初的设计阶段开始,一个重要的事实变得显而易见:在动态库的所有部分中,只有共享它的代码才有意义。文本)部分,而不是其他进程的数据。在烹饪类比中,一群不同的厨师可以共享同一本食谱(代码)。然而,考虑到不同的厨师可能同时从同一本食谱中准备完全不同的菜肴,如果他们共享相同的厨房用具,那将是灾难性的(数据)。
显然,如果一堆不同的进程可以访问动态库数据段,变量覆盖将在任意时刻发生,动态库的执行将是不可预测的,这将使整个想法变得毫无意义。这样,通过只映射代码段,多个应用程序可以自由地在各自独立的区间中运行共享代码。
动态库与共享库
操作系统设计者早期的目标是避免在每个可能需要它们的应用程序的二进制文件中不必要的多次出现相同的操作系统代码。例如,需要打印文档的每个应用程序都必须包含完整的打印堆栈,以打印机驱动程序结束,以便提供打印功能。如果打印机驱动程序改变了,整个应用程序设计团队都需要重新编译他们的应用程序;否则,由于运行时存在过多的不同打印机驱动程序版本,将会出现混乱。
显然,正确的解决方案应该是以如下方式实现操作系统:
- 通常需要的功能以动态库的形式提供。
- 需要访问公共功能的应用程序只需要在运行时加载动态库。
图 4-3 说明了动态库概念背后的基本思想。
图 4-3。
The dynamic libraries concept
这个问题的第一个解决方案(即动态链接实现的第一个版本,称为加载时重定位(LTR))部分成功地实现了目标。好消息是应用程序不再需要在二进制文件中携带不必要的操作系统代码;相反,它们只部署了特定于应用程序的代码,而所有与系统相关的需求都通过动态链接操作系统提供的模块来满足。
然而,坏消息是,如果多个应用程序在运行时需要某些系统功能,每个应用程序都必须加载自己的动态库副本。这种限制的根本原因是加载时重定位技术修改了。文本部分,以适应给定应用程序的特定地址映射。对于将动态库加载到可能不同的地址范围中的另一个应用,修改后的库代码根本不适合不同的存储器布局。
因此,在运行时,动态库的多个副本驻留在进程的内存映射中。这是我们可以忍受一段时间的事情,但设计的长期目标要远大得多:提供一种更有效的机制,允许动态库只被加载一次(由首先加载它的任何应用程序加载),并可供任何其他试图接下来加载它的应用程序使用。
这个目标是通过称为位置无关码(PIC)的概念实现的。通过改变动态库代码访问符号的方式,只有加载到任何进程的内存映射中的动态库的一个副本通过内存映射到任何应用程序的进程内存映射而变得可共享(图 4-4 )。
图 4-4。
The advances brought by the PIC technique of dynamic linking
此外,操作系统将某些公共系统资源(例如,顶级驱动程序)加载到物理内存中并不罕见,因为它知道大量运行的进程很可能需要这些资源。动态链接的效果是,每个进程都有一个完美的错觉,认为它们是驱动程序的唯一所有者。
自从 PIC 概念发明以来,为支持它而设计的动态库被称为共享库。现在,PIC 概念很流行,在 64 位系统上,它受到编译器的强烈青睐,所以动态库和共享库这两个术语之间的命名区别正在消失,这两个名称或多或少可以互换使用。
虚拟内存的概念为运行时共享思想的成功奠定了基础(集中体现在位置无关代码的概念中)。最初的想法相当简单:如果真实的进程内存映射(具有真实的、具体的地址)只不过是从零开始的进程内存映射的 1:1 映射的结果,那么是什么真正阻止我们创建一个怪物,一个通过映射多个不同进程的部分而获得的真实的进程内存映射呢?事实上,这正是动态库的运行时共享机制的工作方式。
PIC 概念的成功实现代表了现代多任务操作系统的基石。
更详细的动态链接
动态链接的概念是动态库概念的核心。如果不理解动态库、客户端可执行文件和操作系统之间复杂的相互作用,几乎不可能完全理解动态库是如何工作的。本节的重点是提供对动态链接过程的必要的广泛理解。一旦理解了它的本质,本文档的后续部分将对细节给予应有的关注。
那么,让我们看看在动态链接的过程中到底发生了什么。
第一部分:构建动态库
正如前面的图所示,构建动态库的过程是一个完整的构建,因为它包括编译(将源代码转换为二进制目标文件)和解析引用。动态库构建过程的产品是二进制文件,其性质与可执行文件的性质相同,唯一的区别是动态库缺少启动例程,该例程允许它作为独立程序启动(图 4-5 )。
图 4-5。
Building the dynamic library
以下是一些需要考虑的注意事项:
- 在 Windows 中,构建动态库严格要求必须解析所有引用。如果动态库代码调用某个其他动态库中的函数,那么该其他库及其包含的引用符号在构建时必须是已知的。
- 然而,在 Linux 中,缺省选项允许更大的灵活性,允许某些符号不被解析,并期望在链接其他动态库之后,它们最终会出现在最终的二进制文件中。此外,Linux 链接器提供了与 Windows 链接器的严格性完全匹配的选项。
- 在 Linux 中,可以修改动态库,使其可以自己运行(仍在研究 Windows 上是否存在这样的选项)。事实上,libc (C 运行时库)本身是可执行的;当通过在 shell 窗口中键入文件名来调用时,它会在屏幕上显示一条消息并终止。有关如何实现此功能的更多详细信息,请查看第十四章。
第二部分:在构建客户端可执行文件时信任游戏(只寻找符号)
使用动态库场景的下一个阶段发生在您尝试构建打算在运行时使用动态库的可执行文件时。与链接器根据自己的意愿创建可执行文件的静态库场景不同,链接动态库的场景是特殊的,因为链接器试图将其当前工作与创建动态库二进制文件的先前完成的链接过程的现有结果相结合。
故事这一部分的关键细节是,链接器几乎把所有的注意力都放在了动态库的符号上。在这个阶段,链接器似乎对任何部分都不感兴趣,对代码也不感兴趣。文本),也不是数据(。数据/。bss)。
更具体地说,这个操作阶段的链接器“通过信任来玩它”
它没有彻底检查动态库的二进制文件;它既不试图找到这些部分或它们的大小,也不试图将它们集成到生成的二进制文件中。相反,它只是试图验证动态库是否包含生成的二进制文件所需的符号。一旦找到它,它就完成任务并创建可执行的二进制文件(见图 4-6 )。
图 4-6。
Build time linking with a dynamic library
“信任游戏”的方法并不是完全不直观的。让我们考虑一个现实生活中的例子:如果你告诉某人,为了寄信,他需要去附近广场的信息亭买一张邮票,你基本上是将你的建议建立在合理的信任程度上。你知道广场上应该有一个卖邮票的亭子。事实上,你不知道 kiosk 操作的具体细节(工作时间,谁在那里工作,邮票的价格)并不减少你的建议的有效性,因为在运行时所有这些不太重要的细节都会得到解决。动态链接的想法是基于完全类似的假设。
但是,请注意,这种信任程度为许多有趣的场景打开了大门,所有这些场景都属于“用一个构建,加载另一个”的范例。实际的含义各不相同,从奇特的软件设计技巧一直到全新的范例(插件),这两者都将在本书后面讨论。
第三部分:运行时加载和符号解析
加载时发生的事件至关重要,因为此时需要确认链接器对动态库承诺的信心。以前,构建过程(可能在构建机器“A”上完成)在搜索可执行文件所需的符号时检查动态库二进制文件的副本。现在,运行时需要发生的事情(可能在不同的运行时机器“B”上)如下:
The dynamic library binary file needs to be found.
每个操作系统都有一套规则,规定加载程序应该在哪个目录中寻找动态库的二进制文件。
The dynamic library needs to be successfully loaded into the process.
此时,构建时链接的承诺必须在运行时实现。
事实上,在运行时加载的动态库必须携带承诺在构建时可用的相同符号集。更具体地说,在函数符号的情况下,术语“相同”意味着运行时在动态库中找到的函数符号必须与构建时承诺的完整函数签名(从属关系、名称、参数列表、链接/调用约定)完全匹配。
有趣的是,并不要求在运行时找到的动态库的实际汇编代码(即部分内容)与在构建时使用的动态库二进制文件中找到的代码相匹配。这开启了许多有趣的场景,稍后将详细讨论。
The executable symbols need to be resolved to point to the right address in the part of process of memory map where the dynamic library is mapped into.
正是在这个阶段,将动态库集成到进程内存映射中才真正称得上是动态链接,因为与传统的链接不同,它发生在加载时。
如果这个阶段的所有步骤都成功完成,你就可以让你的应用程序执行动态库中包含的代码,如图 4-7 所示。
图 4-7。
Load time linking of a dynamic library
Windows 动态链接的特点
因为动态链接确实发生在两个阶段(构建时与运行时),其中链接器关注动态库二进制文件的不同细节,所以没有充分的理由说明为什么动态库二进制文件的相同副本不能在两个阶段都使用。
即使在构建时动态链接阶段只有库符号起作用,如果在运行时阶段也使用二进制文件的完全相同的副本,也没有什么错。
包括 Linux 在内的各种操作系统都遵循这一原则。然而,在 Windows 中,为了使动态链接阶段之间的区分更加清晰,事情变得稍微复杂了一些,这可能会让初学者有点困惑。
Windows 中与动态链接相关的特殊二进制文件类型
在 Windows 中,动态链接的不同阶段之间的区别通过在每个阶段使用稍微不同的二进制文件类型来强调。也就是说,当创建和构建 Windows DLL 项目时,编译器会生成几个不同的文件。
动态链接库(。dll)
这种文件类型实际上是动态库,一个在运行时由进程通过动态链接机制使用的共享对象。更具体地说,到目前为止,关于动态库函数完全适用于 DLL 文件的原理的大部分事实。
导入库文件(。lib)
专用的导入库(。lib)二进制文件在 Windows 上专门用于动态链接的“Part2”阶段(图 4-8 )。它只包含 DLL 符号列表,不包含任何链接器部分,其唯一目的是将动态库的导出符号集呈现给客户端二进制文件。
图 4-8。
Windows import library
导入库文件的文件扩展名(。lib)是混淆的潜在来源,因为相同的文件扩展名也被用来表示静态库。
另一个值得讨论的细节是,这个文件被称为导入库,但实际上在导出 DLL 符号的过程中起作用。的确,命名的选择取决于我们从哪个方面来看待动态链接的过程,同样,这个文件属于 DLL 项目,通过构建 DLL 项目来创建,并且可以传播到无数的应用程序。出于所有这些原因,采用“从 DLL 向外”的方向,并因此使用导出库的名称应该是正确的。
在讨论使用__declspec
关键字的部分中可以找到明显的证据,证明微软的其他人至少在一定程度上同意这一观点,其中命名(__declspec(dllexport)
)用于表示从 DLL 向客户端应用程序的导出(即,向外的方向)。
微软公司的人决定坚持这种特殊命名惯例的原因之一是 DLL 项目产生了另一种类型的库文件,可以在循环依赖的情况下代替这种类型的库文件。另一种文件类型称为导出文件(。exp)(见下文),并且为了区分两者,保留了现有的命名。
导出文件(。exp)
导出文件与导入库文件具有相同的性质。但是,它通常用于两个可执行文件具有循环依赖关系,从而无法完成其中任何一个的构建的情况。在这种情况下,提供 exp 文件的目的是使至少一个二进制文件能够成功编译,这又可以被其他依赖的二进制文件用来完成它们的构建。
Note
Windows DLLs 严格要求在生成时解析所有符号。然而,在 Linux 上,可能会留下一些未解析的动态库符号,并期望丢失的符号最终会作为动态链接到其他动态库中的结果出现在进程内存映射中。
动态库的独特性质
在二进制类型的集合中,动态库具有相当独特的性质,在处理通常的相关设计问题时,记住其细节是很重要的,这一点很重要。
当查看其他二进制类型时,可执行文件和静态库的相反性质几乎立即变得显而易见。静态库的创建不涉及链接阶段,而对于可执行文件来说,这是必须的最后一步。因此,可执行文件的性质更加完整,因为它包含已解析的引用,并且由于嵌入了额外的 start 例程,它可以执行了。
在这方面,尽管“库”这个词暗示了静态库和动态库之间的相似之处,但事实是动态库的本质更接近可执行程序的本质。
属性 1:动态库创建需要完整的构建过程
创建动态库的过程不仅包括编译,还包括链接阶段。不管命名的相似性意味着什么,动态库构建过程的完整性(即,除了编译之外的链接)使得动态库与可执行文件的相似性远远大于与静态库的相似性。唯一的区别是可执行文件包含允许内核启动进程的启动代码。向动态库添加几行代码是完全可能的(在 Linux 中肯定是这样),这使得从命令行执行库成为可能,就好像它是一个可执行的二进制类型。更多详情请查看第十四章。
属性 2:动态库可以链接到其他库中
这是一个非常有趣的事实:不仅是可执行文件可以加载和链接动态库,它也可以是另一个动态库。因此,我们不能再用“可执行”来表示动态库中链接的二进制文件;我们必须使用其他更恰当的术语。
Note
我决定此后使用术语“客户机二进制文件”来表示可执行文件或加载动态库的动态库。
应用程序二进制接口(ABI)
当接口概念应用于编程语言领域时,它通常用于表示函数指针的结构。C++ 通过将它定义为一类函数指针增加了一些额外的含义;此外,通过将函数指针声明为等于 NULL,接口获得了额外的抽象,因为它变得不适合实例化,但可以用作其他类实现它的理想模型。
由软件模块输出到客户端的接口通常被称为应用编程接口(API)。当应用于二进制领域时,接口的概念获得了一种额外的特定于领域的味道,称为应用程序二进制接口(ABI)。将 ABI 看作是在源代码接口编译/链接过程中创建的一组符号(主要是一组函数入口点)并没有错。
当更精确地解释动态链接期间发生的事情时,ABI 概念就派上了用场。
- 在动态链接的第一个(构建时)阶段,客户机二进制文件实际上链接到库的导出 ABI。
正如我所指出的,在构建时,客户机二进制文件实际上只检查动态库是否导出了符号(函数指针,如 ABI),而根本不关心段(函数体)。
- 为了成功完成动态链接的第二个(运行时)阶段,运行时可用的动态库的二进制样本必须导出未改变的 ABI,与构建时找到的相同。
第二个语句被认为是动态链接的基本要求。
静态库与动态库的比较点
尽管我只是略微谈到了静态库和动态库背后的概念,但是已经可以对两者进行一些比较了。
进口选择性标准的差异
静态库和动态库之间最有趣的区别是试图链接它们的客户端二进制文件所应用的选择性标准的不同。
静态库的导入选择性标准
当客户端二进制链接静态库时,它不会链接完整的静态库内容。相反,它严格地只链接包含真正需要的符号的目标文件,如图 4-9 所示。
图 4-9。
Import selectiveness criteria for static libraries
客户机二进制文件的字节长度增加了,尽管只是增加了从静态库中获取的相关代码的数量。
Note
尽管链接算法在选择链接哪些目标文件时是有选择性的,但这种选择性不会超出单个目标文件的粒度。除了真正需要的符号之外,选择的目标文件还可能包含一些不需要的符号。
动态库的导入选择性标准
当客户端二进制链接动态库时,它仅在符号表级别具有选择性,其中只有真正需要的动态库符号才会在符号表中提及。
在所有其他方面,这种选择性实际上是不存在的。无论动态库功能的具体需求有多小,整个动态库都会被动态链接进来(图 4-10 )。
图 4-10。
Import selectiveness criteria for dynamic libraries
增加的代码量只发生在运行时。客户端二进制文件的字节长度不会显著增加。新符号的簿记所需的额外字节往往相当于小字节计数。然而,链接动态库要求动态库二进制文件在运行时在目标机器上可用。
整个归档导入方案
当静态库的功能需要通过中间的动态库呈现给二进制客户端时,一个有趣的转折就发生了(图 4-11 )。
图 4-11。
“Whole archive” scenario of importing static library
中间动态库本身不需要任何静态库的功能。因此,根据制定的导入选择性规则,它不会从静态库中链接任何东西。然而,设计动态库的唯一原因是吸收静态库的功能并导出其符号供世界其他地方使用。
如何缓解这些相反的需求?
幸运的是,这个场景很早就被发现了,并且通过--whole-archive
链接器标志提供了足够的链接器支持。当指定时,该链接器标志指示其后列出的一个或多个库将被无条件地完全链接,而不管链接它们的客户端二进制文件是否需要它们的符号。
考虑到这种情况,Android 原生开发系统除了支持LOCAL_STATIC_LIBRARIES
构建变量之外,还支持LOCAL_WHOLE_STATIC_LIBRARIES
构建变量,如下所示:
$ gcc -fPIC <source files>``-Wl,--whole-archive
有趣的是,有一个反作用链接器标志(--no-whole-archive
)。它的作用是抵消--whole-archive
对所有后续库的影响,这些库被指定在完全相同的链接器命令行上链接。
$ gcc -fPIC <source files> -o <executable-output-file> \
-Wl,--whole-archive
-l<libraries-to-be-entirely-linked-in> \
-Wl,--no-whole-archive
-l<all-other-libraries>
与--whole-archive
标志本质上有些相似的是-rdynamic
链接器标志。通过传递这个链接器标志,你基本上是在请求链接器将所有的符号(出现在.symtab
部分)导出到动态(.dynsym
部分,这基本上使它们可用于动态链接的目的。有趣的是,这面旗帜似乎不需要-Wl
前缀。
部署困境场景
当设计软件部署包时,构建工程师通常面临最小化部署包的字节大小的需求。在一个最简单的可能场景中,需要部署的软件产品由一个可执行文件组成,该可执行文件将向库提供其特定部分功能的任务委托给库。假设这个库有两种风格,静态库和动态库。构建工程师面临的基本问题是使用哪种链接场景来最小化已部署软件包的字节大小。
选择 1:与静态库链接
构建工程师面临的选择之一是将可执行文件与库的静态版本链接起来。这个决定有利有弊。
- 优点:可执行文件是完全独立的,因为它包含了所有需要的代码。
- 缺点:可执行字节的大小会随着从静态库获取的代码量而增加。
选择 2:与动态库链接
当然,另一种可能性是将可执行文件与库的动态版本相链接。这个决定也有利弊。
- 优点:可执行字节大小不会改变(除了小符号簿记费用)。
- 缺点:无论出于什么原因,所需的动态库总是有可能在目标机器上不可用。如果采取预防措施,将所需的动态库与可执行文件一起部署,可能会出现几个潜在的问题。
- 首先,随着您现在部署一个可执行文件和一个动态库,部署包的总字节大小肯定会变大。
- 其次,部署的动态库版本可能与依赖它的其他应用程序的需求不匹配。
- 第三、第四等等,在处理动态库时可能会发生一系列问题,称为“DLL 地狱”
定论
当应用程序链接相对较少数量的静态库的相对较小的部分时,与静态库的链接是一个好的选择。
当应用程序依赖于在运行时存在于目标机器上的动态库时,与动态库的链接是一个很好的选择。
可能的候选者是特定于操作系统的动态库,例如 C 运行时库、图形子系统、用户空间顶级设备驱动程序和/或来自非常流行的软件包的库。表 4-1 总结了静态库和动态库的区别。
表 4-1。
Comparison Points Summary
| 比较类别 | 静态库 | 动态库 | | --- | --- | --- | | 构建过程 | 未完成:编译:是链接:否 | 完成:编译:是链接:是 | | 二进制本质 | 目标文件的存档所有部分都存在,但大多数引用未被解析(本地引用除外)。不能独立存在;clientbinary 的环境决定了大量的细节。它的所有符号只有在客户端可执行文件中才有意义。 | 没有启动例程的可执行文件。包含已解析的引用(除非另有说明),其中一些引用旨在全局可见。非常独立(在 Linux 中,通过一些简单的添加,可以有效地添加缺少的启动例程)。高度专业化于某些战略任务;一旦加载到流程中,在提供专门的服务时通常非常可靠。 | | 与可执行文件集成 | 在可执行文件构建过程中发生,在链接阶段完成。高效:只有归档文件中需要的目标文件被链接到可执行文件中。但是,客户端二进制文件的字节大小会增加。 | 通过动态链接的两个独立阶段发生:1)针对可用符号进行链接 2)在加载时集成符号和部分效率低下:整个库被加载到进程中,而不管真正需要库的哪一部分。客户端二进制文件的字节大小几乎不变。然而,动态库二进制文件在运行时的可用性是一个额外需要担心的问题。 | | 对可执行文件大小的影响 | 随着部分被添加到可执行部分,增加可执行文件的大小。 | 减少可执行文件的大小,因为只有特定于应用的代码驻留在应用可执行文件中,而可共享的部分被提取到动态库中。 | | 轻便 | 很好,因为应用程序需要的一切都在它的二进制文件中。没有外部依赖性使得移植变得容易。 | 各不相同。适用于操作系统标准的动态库(libc、设备驱动程序等)。),因为它们保证存在于运行时机器上。对于特定于应用程序或特定于供应商的场景,效果不太好。存在大量潜在问题的场景(版本、缺少库、搜索路径等。) | | 易于组合 | 非常有限。无法通过使用其他库(既不是静态库也不是动态库)来创建静态库。只能将它们链接到同一个可执行文件中。 | 太好了。动态库可以链接一个或多个静态库,和/或一个或多个动态库。事实上,Linux 可以被看作是“乐高乐园”,一组由动态库与其他动态库链接而成的结构。源代码的可用性极大地促进了集成的规模。 | | 易于转换 | 相当容易。归档器实用程序的标准功能是提取配料对象文件。一旦提取出来,它们就可以被消除、替换或重新组合成一个新的静态或动态库。只有在非常特殊的情况下(在“提示和技巧”一节中,请参阅关于在 64 位 Linux 上将静态库链接到动态库的主题),这可能还不够好,您可能需要重新编译原始源代码。 | 对大多数人来说几乎是不可能的。已经看到了一些商业解决方案,它们试图实现从动态库到静态库的转换,并取得了不同程度的成功。 | | 适合发展 | 繁琐。即使代码中最小的变化也需要重新编译所有链接库的可执行文件。 | 太好了。处理孤立特征的最佳方式是将其提取到动态库中。只要导出的符号(函数签名和/或数据结构布局)没有改变,重新编译库就不需要重新编译其余的代码。 | | 杂项/其他 | 甚至在最简单的微控制器开发环境中也应用了更简单、更古老、更普遍的二进制共享形式。 | 二进制代码重用的新方法。现代的多任务系统没有它们甚至无法想象。对插件的概念至关重要。 |有用的对比类似物
表格 4-2 到 4-4 列出了几个非常有用的和说明性的类比,可以帮助你更好地理解编译过程的作用。
表 4-2。
Legal Analogy
| 二元类型 | 合法等价物 | | --- | --- | | 静态库 | 总的来说,法律段落是以一种不确定的方式写成的。比如:如果一个人(哪个人?)被判犯有 A 级轻罪(哪种特定的轻罪?这个人到底做了什么?),他或她将被判处支付不超过 2000 美元的罚款(具体是多少?),或者服不超过 6 个月的刑期(到底多长?)或者两者都有(三种可能组合中的哪一种?). | | 动态库 | 具体指控约翰·史密斯因拒捕和不服从警官而被判有罪。控方要求他支付 1500 美元的罚款,并入狱 30 天。 | | 可执行的 | 服刑所有参考资料(何人、何事、何时以及可能的原因)都已解决:违法行为已在法庭上得到证实,法官根据法律条文对约翰·史密斯进行了判决,一切准备就绪,他将在附近的州矫正机构服刑。 |Note
在烹饪的类比中,你(软件设计者)正在经营一家餐馆,在那里(通过构建可执行程序的过程)你为饥饿的 CPU 准备一顿饭,他几乎等不及开始大嚼这顿饭。
表 4-4。
Tropical Jungle Expedition Analogy
| 二元类型 | 探险角色等同 | | --- | --- | | 可执行的 | 英国勋爵,探险队的领队,授勋战斗老兵,以其出色的生存技能和本能而闻名。受英国地理学会指派,调查在热带丛林深处存在着失落已久的先进文明神庙的传闻,那里隐藏着无数的物质和科学宝藏。他有权获得当地英国领事部门的后勤支持,该部门负责协调与当地政府的努力,并提供物资、资金、后勤和运输方面的各种帮助。 | | 动态库 | 当地猎人,探险向导这家伙在探险目标地理区域出生长大。他会说所有当地语言,了解所有部落的宗教和文化;在这个地区有很多人脉;知道所有危险的地方以及如何避开它们;拥有非凡的生存技能;是一个很好的猎人,优秀的开拓者,并能预测天气变化。高度专业化于与丛林相关的一切,完全可以自理。他成年后的大部分时间都是作为这样的探险队的雇佣向导度过的。在探险的间隙,除了和家人在一起、去钓鱼和打猎等,他几乎什么都不做。他既没有野心也没有财力自己创业。 | | 静态库 | 年轻的私人助理来自贵族家庭的年轻英国小伙子。很少或没有实际生活经验,但牛津大学的考古学学位和古代语言知识,以及速记、电报和莫尔斯电码的操作知识为他在团队中赢得了一席之地。尽管他的技能可能适用于许多角色和许多场景,但他从未到过热带地区,不会说当地语言,并且在很大程度上依赖于更高的权威和/或各种更高的专业知识。最有可能的是,他在探险过程中没有正式的权力,除了在他直接的专业领域内,他没有权力做任何决定,而且只有在被要求这样做的时候。 |表 4-3。
Culinary Analogy
| 二元类型 | 烹饪等价物 | | --- | --- | | 静态库 | 生食配料(如生肉或生蔬菜)肯定适合食用,但不能立即食用,因为它们需要一定量的加工(腌制、添加香料、与其他配料混合,最重要的是高温加工),这些必须首先完成。 | | 动态库 | 预先煮好的或现成的可以食用的菜,但照原样端上来就没什么意义了。然而,如果午餐的剩余部分准备好了,它将会是一顿丰盛的大餐。 | | 可执行的 | 完整的午餐包括当天的新鲜面包、沙拉和准备好的主菜,可以通过几天前做的热菜来丰富。 |结论:二进制重用概念的影响
一旦二进制重用的概念被证明是可行的,它就对软件设计的前景产生了以下直接后果:
- 专用项目的出现,其目的不是构建可执行代码,而是构建可重用代码的二进制包。
- 一旦构建供他人使用的代码的实践开始获得动力,遵循封装原则的必要性就凸显出来了。
封装思想的本质是,如果我们正在构建一些东西供其他人使用,那么这种出口产品将基本功能与不太重要的内部功能细节明确分开总是好的。实现它的一个强制方法是声明接口,这是用户最感兴趣的一组典型功能。
- 接口(一组精华/最重要的函数)通常在导出头文件(一个包含文件,提供可重用二进制代码和潜在用户之间的顶级接口)中声明。
简而言之,将代码分发给其他人使用的方法是交付带有二进制文件集和导出头文件集的软件包。二进制文件导出接口,大部分是使用软件包所必需的功能集。
下一波后果紧随其后:
- SDK(软件开发工具包)的出现,在最基本的版本中,SDK 是一组导出头文件和二进制文件(静态和/或动态库),旨在与编译客户端项目本地源文件时创建的二进制文件集成。
- “一个引擎,多种图形用户界面”范例的出现。
有很多这样的例子,不同的应用程序使用流行的引擎,向用户呈现不同的 GUI,但是在后台运行相同的引擎(从相同的动态库中加载)。多媒体领域的典型例子是 ffmpeg 和 avisynth。
- 知识产权受控交换的潜力。
通过交付二进制文件而不是源代码,软件公司可能会交付他们的技术而不披露其背后的想法。反汇编程序的出现使这个故事变得更加复杂,但是从长远来看,基本思想仍然适用。
五、使用静态库
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 版本)。
图 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 所示的链接器错误。
图 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 命令。
图 6-6。
Created DLL linker flags
图 6-5。
Created DLL compiler flags
图 6-4。
Available Win32 DLL Settings
图 6-3。
Click the Next button to specify DLL choice
图 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++ 对象的指针强制转换为接口指针。
图 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.so
和libcontrolledvisibility.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 所示。
图 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 )。
图 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");
}
图 6-10。
Visibility control applied to function printMessage
使用去废工具
控制符号可见性的另一种机制是可用的。它没有那么复杂,也不可编程。相反,它是通过运行strip
命令行实用程序来实现的(图 6-11 )。这种方法要残酷得多,因为它有能力完全擦除关于任何库符号的任何信息,以至于任何通常的符号检查实用程序都无法看到任何符号,无论它是否在.dynamic
部分。
图 6-11。
Using the strip utility to eliminate certain symbols Note
关于strip
工具的更多信息可以在第十三章中找到。
导出 Windows 动态库符号
在 Linux 中,默认情况下,客户端可执行文件可以访问动态库中的所有链接器符号。然而,在 Windows 中,情况并非如此。相反,只有正确导出的符号对客户端可执行文件可见。强制实施这一限制的重要部分是在构建阶段使用单独的二进制文件(导入库),它只包含计划导出的符号。
幸运的是,导出 DLL 符号的机制完全在程序员的控制之下。事实上,有两种受支持的机制可以声明 DLL 符号用于导出。
使用 __declspec(dllexport)关键字
这种机制是 Visual Studio 标准提供的。在新建项目对话框中勾选“导出符号”复选框,如图 6-12 所示。
图 6-12。
Selecting the “Export symbols” option in Win32 DLL Wizzard dialog
在这里,您指定希望项目向导生成包含代码片段的库导出头,看起来有点像图 6-13 。
图 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 )。
图 6-14。
Visual Studio automatically generates the project-specific preprocessor definition
当评估为__declspec(dllexport)
的特定于项目的关键字被添加到函数声明中时,函数链接器符号被导出。否则,省略这样一个特定于项目的关键字肯定会阻止函数符号的导出。图 6-15 有两个功能,其中只有一个声明出口。
图 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 )。
图 6-16。
Launching Visual Studio command prompt to access the collection of binary analysis command-line tools
图 6-17 显示了dumpbin
工具(用/EXPORT
标志调用)关于你的 DLL 输出的符号的报告。
图 6-17。
Using dumpbin.exe to view the list of DLL exported symbols
显然,用特定于项目的导出符号声明的函数符号最终会被 DLL 导出。然而,链接器根据 C++ 准则处理它,该准则使用名称篡改。客户端可执行文件通常不会有解释这些符号的问题,但是如果有,你可以将函数声明为extern "C"
,这将导致函数符号遵循 C 风格的约定(图 6-18 )。
图 6-18。
Declaring the function as extern "C"
使用模块定义文件(。def)
控制 DLL 符号导出的另一种方法是通过使用模块定义(。def
)文件。与前面描述的机制(基于__declspec(dllexport)
关键字)不同,它可以通过选中“Export symbols”复选框,通过项目创建向导来指定,模块定义文件的使用需要一些更明确的措施。
首先,如果你计划使用.def
文件,建议不要勾选“导出符号”复选框。相反,使用文件➡新建菜单创建一个新的定义(。def
)文件。如果这一步完成正确,项目设置会显示模块定义文件正式成为项目的一部分,如图 6-19 所示。
图 6-19。
Module-definition (.def) file is officially part of the project
或者,您可以手动编写.def
文件,手动将其添加到项目源文件列表中,最后,手动编辑链接器属性页,如图 6-19 所示。指定用于导出的演示功能的模块定义文件如图 6-20 所示。
图 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 二进制文件(但没有提供导入库)。
图 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 )。
图 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 对此概念进行了说明。
图 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 的信息,如下所示:
图 7-4。
Specify the library paths
- 将导入库的路径添加到库路径目录集中(图 7-4 )。
图 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 展示了这个看似奇特又有点反直觉的细节。
图 7-5。
In this method, you don’t need to specify the library dependency directly
相反,将客户端二进制项目设置为引用依赖库项目就足够了。访问通用属性➤框架和参考选项卡(图 7-6 )。
图 7-6。
Adding a reference to the dependency library project
图 7-7 显示引用依赖库项目完成。
图 7-7。
Referencing the dependency library project is completed
最终结果将是所需 DLL 的构建时路径被传入链接器的命令行,如图 7-8 所示。
图 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
)所取代。
现在,rpath
和runpath
都可用,但是runpath
在运行时搜索优先级列表中被给予更高的关注。只有在其弟弟runpath
( DT_RUNPATH
字段)不存在的情况下,rpath
( DT_RPATH
字段)才保留 Linux 加载程序的最高优先级的搜索路径信息。然而,如果 ELF 二进制的runpath
( DT_RUNPATH
)字段非空,则rpath
被忽略。
通常通过向链接器传递紧跟在您想要指定为 runpath 的路径之后的-R
或-rpath
标志来设置rpath
。此外,按照惯例,每当链接器被间接调用时(即通过调用gcc
或g++
),链接器标志需要由前缀-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 -d
或objdump -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,链接器就会将rpath
和runpath
设置为相同的值。
检查二进制文件中DT_RUNPATH
字段值的方法是检查二进制文件的 ELF 头(比如运行readelf -h
或objdump -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
文件的内容。
图 7-9。
The contents of /etc/ld.so.conf file
当ldconfig
预扫描/etc/ld.so.conf
文件中列出的所有目录时,它会找到大量的动态库,这些动态库的文件名保存在/etc/ld.so.cache
文件中(图 7-10 中只显示了一小部分)。
图 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\System
或C:\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 所示。
图 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
选项指定构建时库路径时,库名称被有效地从路径中分离出来,并被印入客户端二进制文件中。当执行运行时搜索时,印记名称(即,不是路径加名称,而仅仅是库名称!)非常适合运行时搜索算法的实现。
图 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
- 子程序调用(
call
、jmp
等)。)需要代码段中函数的地址。例如,为了调用一个函数,调用指令必须提供函数入口点的代码段内存地址。
下面的汇编指令序列执行实际的函数调用:
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 所示)是动态链接的关键问题,这使得整个概念相当复杂。
图 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 所示。
图 8-2。
Scenario 1: The client binary must resolve dynamic library symbols
如果您采用通常的方法,解析符号地址的任务传统上属于链接器(并且只属于链接器),那么您就陷入了麻烦的境地。也就是说,链接器已经完成了构建客户端二进制文件和库的任务,库是动态加载的。
很快就变得非常明显,为了解决这种情况,需要应用某些“跳出框框”的思维。该解决方案将链接器解析符号的部分职责授予加载器。
在新的方案中,加载程序获取一些链接程序能力的新能力通常被实现为一个通常被称为动态链接程序的模块。
场景 2:加载的库不再知道它自己符号的地址
通常,动态库导出的 ABI 函数是库内部功能的封装良好的入口点。运行时发生的典型顺序是,客户端二进制文件通常调用一个 ABI 方法,该方法又调用库的内部函数,这些函数对客户端二进制文件没有特别的兴趣,因此不会被导出。
一种可能的不同场景(尽管不太常见)是动态库 ABI 函数在内部调用另一个 ABI 函数。
例如,让我们假设一个动态库拥有一个导出两个接口函数的模块:
Initialize()
Uninitialize()
这两个函数的内部执行流很可能采用库内部函数的调用顺序,用静态范围声明。调用内部方法通常由以相对地址为特征的汇编调用系列指令来执行。地址转换不会对调用功能的实现产生负面影响,如图 8-3 所示。
图 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 所示。
图 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 说明了这个想法。
图 8-5。
In Scenario 1, the linker directives are embedded into the client binary file
然而,在场景 2 中,链接器将.rel.dyn
部分嵌入到加载的库的二进制文件中,因为它需要帮助重建地址和指向它们的指令之间的一致性(图 8-6 )。
图 8-6。
In Scenario 2, the linker directives are embedded into the dynamic library
在这个特殊的例子中,有一个最简单的场景,其中可执行文件加载一个动态库。更现实的情况是,一个动态库本身可以加载另一个动态库,而另一个动态库又可以加载另一个动态库,等等。位于动态加载链中间的任何动态库都可能扮演双重角色。场景 1 和场景 2 可能恰好适用于同一个二进制文件。
链接器指令概述
二进制格式规范通常详细规定了链接器和加载器之间通信的语法规则。加载器的链接器指令通常非常简单,但是非常精确并且切中要点(图 8-7 )。因此,构建链接器指令所携带的信息并不需要花费大量的精力来实现和理解。
特别是,ELF 文件格式携带了链接器如何为加载器指定指令的详细定义。指令主要存储在.rel.dyn
部分以及其他几个特殊部分(rel.plt
、got
、got.plt
)。可以使用readelf
或objdump
等工具显示指令内容。图 8-7 显示了一些例子。
图 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
表示必须进行重定位的符号表索引:
二进制文件的一个部分带有符号列表。该值仅表示代表该特定符号的符号表项目的索引。readelf
和objdump
可以提供包含在二进制符号表中的完整符号列表。
Sym.Value
指定代码段(函数的情况下)或数据段(变量的情况下)中符号当前驻留在原始二进制文件中的暂定临时偏移量。假设地址转换会影响这些值。Sym.Name
指定人类可读的符号名(函数名、变量名)
图 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 说明了加载时间重定位概念背后的想法。
图 8-9。
LTR concept and its limitations
所有的缺点都被较新的、在许多方面更优越的位置独立码(PIC)方法的设计所解决,该方法很快成为链接技术的普遍选择。
位置无关码
加载时间重定位方案的局限性已在动态链接的下一个实现中解决,该技术被称为位置独立代码(图 8-10 )。通过采取额外的间接步骤,避免了对动态库代码段指令的不必要的直接修改。用编程语言的行话来说,这种方法可以描述为使用指针到指针而不是指针。
基本上,符号地址分两步提供给需要的指令。为了得到符号地址,首先一个mov
指令访问地址的地址位置,并将其内容(一个需要的符号地址)加载到一个可用的 CPU 寄存器中。紧接着,现在存储在寄存器中的检索到的符号地址可以用作后续指令中的操作数(mov
用于数据,call
用于函数调用)。
图 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 说明了这一点。
换句话说,加载链中的动态库可能既需要解析它加载的库的引用,也需要重新解析它自己的符号的引用。这使得整个故事更有趣了。
图 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 说明了所描述的规则。
图 8-12。
Strong implementation preferences (at the molecular level) governing the implementation of the recursive chain of dynamic linking
九、在动态库中链接时处理重复符号
Abstract
动态链接的概念显然代表了软件设计领域的一大进步。它带来的前所未有的灵活性为技术进步开辟了许多途径,也为全新的概念打开了许多新的大门。
动态链接的概念显然代表了软件设计领域的一大进步。它带来的前所未有的灵活性为技术进步开辟了许多途径,也为全新的概念打开了许多新的大门。
出于同样的原因,动态库内部如何工作的复杂性给软件工具链(编译器、连接器、加载器)领域带来了几个不同的挑战。早期认识到需要链接器和加载器更紧密地合作,实现这一点的技术在前一章已经讨论过了。
然而,这并不是故事的结尾。
与动态库领域密切相关的另一个有趣的范例是处理重复符号的问题。更具体地说,当动态库是链接过程中的输入成分时,链接器偏离了通常的、常识性的方法,该方法通常在单个目标文件和/或静态库被组合成二进制文件的情况下遵循。
重复符号定义
在解析引用的过程中可能发生的最常见问题是出现重复符号,在链接的最后阶段,当所有可用符号的列表包含两个或更多同名符号时,就会出现这种情况。
顺便提一下,出于内部目的,链接器算法通常会修改原始符号名。作为一个直接的结果,链接器打印出来的报告的重复问题可能指的是与原始名称有些不同的名称。符号名称修改的范围可以从简单的名称修饰(例如,在下划线前加前缀)一直到 C++ 函数关联问题的系统处理。幸运的是,修改通常以严格统一和可预测的方式执行。
典型的重复符号场景
重复符号的原因可能各不相同。在最简单的情况下,不同的设计者为他们的模块类、函数、结构选择了最明显的名字(例如,类Timer
、函数getLength()
或变量lastError
或libVersion
)。试图组合这些设计者的模块不可避免地导致发现重复符号的存在。
其他可能性包括在头文件中定义数据类型实例(类、结构或简单数据类型)的典型情况。不止一次包含头文件不可避免地会产生重复符号的情况。
重复的 C 符号
C 语言对于两个或更多的符号被认为是彼此的副本强加了相当简单的标准。只要函数、结构或数据类型的名称相同,符号就被认为是相同的。
例如,构建以下代码将会失败:
file: main.c
#include <stdio.h>
int``function_with_duplicated_name
{
printf("%s\n", __FUNCTION__);
return 0;
}
int``function_with_duplicated_name
{
printf("%s\n", __FUNCTION__);
return 0;
}
int main(int argc, char* argv[])
{
function_with_duplicated_name(1);
function_with_duplicated_name(1,2);
return 0;
}
它将产生以下错误消息:
main.c:9:5: error: conflicting types for 'function_with_duplicated_name'
main.c:3:5: note: previous definition of 'function_with_duplicated_name' was here
main.c: In function 'main':
main.c:17:5: error: too few arguments to function 'function_with_duplicated_name'
main.c:9:5: note: declared here
gcc: error: main.o: No such file or directory
gcc: fatal error: no input files
compilation terminated.
重复的 C++ 符号
作为一种面向对象的编程语言,C++ 规定了更宽松的重复符号标准。就名称空间、类/结构和简单数据类型而言,使用相同的名称仍然是重复符号的唯一标准。然而,在函数领域中,重复符号标准不再仅限于函数名,还考虑了参数列表。
函数(方法)重载的原则允许对具有不同输入参数列表的同一类的不同方法使用相同的名称,只要返回值类型相同。
同样的原则也适用于属于同一个名称空间的两个或多个函数不属于任何类的情况。即使这样的函数不附属于任何类,也适用更灵活的 C++ 重复标准——只有当它们的名称相同并且它们的输入参数列表相同时,它们才被认为是重复的。
将成功完成以下代码的构建:
file: main.cpp
#include <iostream>
using namespace std;
class CTest
{
public:
CTest(){ x = 0;};
∼CTest(){};
public:
int runTest(void){ return x;};
private:
int x;
};
int``function_with_duplicated_name
{
cout << __FUNCTION__ << "(x)" << endl;
return 0;
}
int``function_with_duplicated_name
{
cout << __FUNCTION__ << "(x,y)" << endl;
return 0;
}
int main(int argc, char* argv[]){
CTest test;
int x = test.runTest();
function_with_duplicated_name(x);
function_with_duplicated_name(x,1);
return 0;
}
file: build.sh
g++ -Wall -g -O0 -c main.cpp
g++ main.o -o clientApp
运行生成的二进制文件将创建以下输出:
function_with_duplicated_name(x)
function_with_duplicated_name(x,y)
然而,试图将下面方法的声明添加到main.cpp
float function_with_duplicated_name(int x)
{
cout << __FUNCTION__ << "(x)" << endl;
return 0.0f;
}
将违反 C++ 函数重载的基本规则,这将导致以下构建失败:
main.cpp: In function 'float function_with_duplicated_name(int)':
main.cpp:23:42: error: new declaration 'float function_with_duplicated_name(int)'
main.cpp:17:5: error: ambiguates old declaration 'int function_with_duplicated_name(int)'
g++: error: main.o: No such file or directory
g++: fatal error: no input files
compilation terminated.
重复符号默认处理
当单个目标文件或静态库被链接在一起成为结果二进制文件时,链接器严格遵循对重复符号的零容忍策略。
当链接器检测到重复符号时,它会打印出一条错误消息,指明出现重复符号的文件/代码行,并声明链接失败。这基本上意味着开发人员需要回到绘图板并尝试解决问题,这很可能意味着代码需要重新编译。
以下示例说明了当您试图将两个具有重复符号的静态库链接到同一个客户端二进制文件时会发生什么。该项目由两个非常简单的静态库组成,具有重复的符号以及试图链接它们的客户端应用程序:
Static Library libfirst.a:
file: staticlibfirstexports.h
#pragma once
int staticlibfirst_function(int x);
int staticlib_duplicate_function(int x);
file: staticlib.c
#include <stdio.h>
int staticlibfirst_function(int x)
{
printf("%s\n", __FUNCTION__);
return (x+1);
}
int staticlib_duplicate_function(int x)
{
printf("%s\n", __FUNCTION__);
return (x+2);
}
file: build.sh
gcc -Wall -g -O0 -c staticlib.c
ar -rcs libfirst.a staticlib.o
Static Library libsecond.a:
file: staticlibsecondexports.h
#pragma once
int staticlibsecond_function(int x);
int staticlib_duplicate_function(int x);
file: staticlib.c
#include <stdio.h>
int staticlibsecond_function(int x)
{
printf("%s\n", __FUNCTION__);
return (x+1);
}
int staticlib_duplicate_function(int x)
{
printf("%s\n", __FUNCTION__);
return (x+2);
}
file: build.sh
gcc -Wall -g -O0 -c staticlib.c
ar -rcs libsecond.a staticlib.o
客户端应用程序:
file: main.c
#include <stdio.h>
#include "staticlibfirstexports.h"
#include "staticlibsecondexports.h"
int main(int argc, char* argv[])
{
int nRetValue = 0;
nRetValue += staticlibfirst_function(1);
nRetValue += staticlibsecond_function(2);
nRetValue += staticlib_duplicate_function(3);
printf("nRetValue = %d\n", nRetValue);
return nRetValue;
}
文件:build.sh
gcc -Wall -g -O0 -I../libFirst -I../libSecond -c main.c
gcc main.o -L../libFirst -lfirst -L../libSecond -lsecond -o clientApp
由于两个静态库中存在重复的符号,尝试构建客户端应用程序会导致链接器错误:
/home/milan/Desktop/duplicateSymbolsHandlingResearch/01_duplicateSymbolsCriteria/02_duplicatesInTwoStaticLibs/01_plainAndSimple/libSecond/staticlib.c:10: multiple definition of 'staticlib_duplicate_function'
../libFirst/libfirst.a(staticlib.o):/home/milan/Desktop/duplicateSymbolsHandlingResearch/01_duplicateSymbolsCriteria/02_duplicatesInTwoStaticLibs/01_plainAndSimple/libFirst/staticlib.c:10: first defined here
collect2: ld returned 1 exit status
注释掉对 duplicate 函数的调用无助于避免链接器失败。显然,链接器首先试图把来自输入静态库和单个目标文件的所有东西拼在一起(main.c
)。如果重复的符号在链接游戏的早期出现,链接器会声明失败,不管没有人试图引用重复的符号。
允许重复的本地符号
有趣的是,用 C 语言中的关键字static
声明的局部函数(即,可见性范围仅限于驻留在同一源文件中的函数)不会被注册为重复的。使用以下代码修改示例中静态库的源文件:
Static Library libfirst.a:
file: staticlib.c
static
int local_staticlib_duplicate_function(int x)
{
printf("libfirst: %s\n", __FUNCTION__);
return 0;
}
int staticlibfirst_function(int x)
{
printf("%s\n", __FUNCTION__);
local_staticlib_duplicate_function(x);
return (x+1);
}
Static Library libsecond.a:
file: staticlib.c
static
int local_staticlib_duplicate_function(int x)
{
printf("libsecond: %s\n", __FUNCTION__);
return 0;
}
int staticlibsecond_function(int x)
{
printf("%s\n", __FUNCTION__);
local_staticlib_duplicate_function(x);
return (x+1);
}
客户端应用程序:
file: main.c
#include <stdio.h>
#include "staticlibfirstexports.h"
#include "staticlibsecondexports.h"
int main(int argc, char* argv[])
{
staticlibfirst_function(1);
staticlibsecond_function(2);
return 0;
}
客户端应用程序现在将成功构建并产生以下输出:
staticlibfirst_function
libfirst: local_staticlib_duplicate_function
staticlibsecond_function
libsecond: local_staticlib_duplicate_function
显然,链接器为本地函数保留了单独的区间。即使它们的符号名称完全相同,也不会发生冲突。
动态库中链接时的重复符号处理
当动态库在链接阶段被添加到输入成分的混合中时,链接器处理重复符号的方式变得更加有趣和复杂。首先也是最重要的,链接器放弃了对重复符号的零容忍政策,并且不立即声明链接失败。相反,它应用近似的、不太理想的方法来解决符号命名冲突。
为了说明链接器对这个特定场景的完全不同的方法,创建了一个简单的演示项目。它由两个动态库组成,这两个动态库以重复符号和链接它们的客户端应用程序为特色:
Shared Library libfirst.so:
file: shlibfirstexports.h
#pragma once
int shlibfirst_function(int x);
int shlib_duplicate_function(int x);
file: shlib.c
#include <stdio.h>
static int local_shlib_duplicate_function(int x)
{
printf("shlibFirst: %s\n", __FUNCTION__);
return 0;
}
int shlibfirst_function(int x)
{
printf("shlibFirst: %s\n", __FUNCTION__);
local_shlib_duplicate_function(x);
return (x+1);
}
int shlib_duplicate_function(int x)
{
printf("shlibFirst: %s\n", __FUNCTION__);
local_shlib_duplicate_function(x);
return (x+2);
}
file: build.sh
gcc -Wall -g -O0 -fPIC -c shlib.c
gcc -shared shlib.o -Wl,-soname,libfirst.so.1 -o libfirst.so.1.0.0
ldconfig -n .
ln -s libfirst.so.1 libfirst.so
共享库 libsecond.so:
file: shlibsecondexports.h
#pragma once
int shlibsecond_function(int x);
int shlib_duplicate_function(int x);
file: shlib.c
#include <stdio.h>
static int local_shlib_duplicate_function
(int x)
{
printf("shlibSecond: %s\n", __FUNCTION__);
return 0;
}
int shlibsecond_function(int x)
{
printf("shlibSecond: %s\n", __FUNCTION__);
local_shlib_duplicate_function(x);
return (x+1);
}
int shlib_duplicate_function(int x)
{
printf("shlibSecond: %s\n", __FUNCTION__);
local_shlib_duplicate_function(x);
return (x+2);
}
file: build.sh
gcc -Wall -g -O0 -fPIC -c shlib.c
gcc -shared shlib.o -Wl,-soname,libsecond.so.1 -o libsecond.so.1.0.0
ldconfig -n .
ln -s libsecond.so.1 libsecond.so
客户端应用程序:
file: main.c
#include <stdio.h>
#include "shlibfirstexports.h"
#include "shlibsecondexports.h"
int main(int argc, char* argv[])
{
int nRetValue = 0;
nRetValue += shlibfirst_function(1);
nRetValue += shlibsecond_function(2);
nRetValue += shlib_duplicate_function(3);
return nRetValue;
}
文件:build.sh
gcc -Wall -g -O0 -I../libFirst -I../libSecond -c main.c
gcc main.o -Wl,-L../libFirst -Wl,-lfirst \
-Wl,-L../libSecond -Wl,-lsecond \
-Wl,-R../libFirst \
-Wl,-R../libSecond \
-o clientApp
即使两个共享库都有副本,甚至其中一个副本(shlib_duplicate_function
)不是本地函数,构建客户端应用程序也能成功完成。
然而,运行客户端应用程序会带来一些惊喜:
shlibFirst: shlibfirst_function
shlibFirst: local_shlib_duplicate_function
shlibSecond: shlibsecond_function
shlibSecond: local_shlib_duplicate_function
shlibFirst: shlib_duplicate_function
shlibFirst: local_shlib_duplicate_function
显然,链接器找到了一些解决重复符号的方法。它通过选取一个符号出现来解决这个问题(在shlibfirst.so
中的那个),并将所有对shlib_duplicate_function
的引用指向那个特定的符号出现。
这个链接器的决定显然是一个非常有争议的步骤。在现实世界的场景中,不同动态库的同名函数可能具有完全不同的功能。例如,假设每个动态库libcryptography.so
、libnetworkaccess.so
和libaudioport.so
都有Initialize()
方法。现在想象一下,链接器决定对Initialize()
的调用总是意味着只初始化其中一个库(从不初始化另外两个库)。
显然,应该小心避免这种情况。要想做对,首先要彻底了解连接者“思考”的方式。
链接器处理动态库重复符号的内部算法的细节将在本章后面讨论。
消除重复符号问题的一般策略
一般来说,解决重复符号的最佳方法是加强符号与其特定模块的关联,因为这通常可以消除绝大多数潜在的重复符号问题。
特别是,诉诸于名称空间的使用是最推荐的技术,因为它已经被证明可以在许多不同的场景中工作,而不管代码以何种形式提供给软件社区(静态库还是共享库)。这个特性仅限于 C++ 语言领域,并且需要使用 C++ 编译器。
或者,如果出于某种原因,强烈建议使用严格的 C 编译器,那么在函数名前面加上惟一的前缀也是一种可行的方法,但这种方法功能稍弱,灵活性也较差。
重复符号和动态链接模式
在详细介绍链接器处理重复符号的新方法之前,有必要指出几个重要的事实。
动态库的运行时动态加载(通过dlopen()
或LoadLibrary()
调用)实际上没有重复符号的风险。检索到的动态库符号通常被分配(通过dlsym()
或GetProcAddress()
调用)给变量,该变量的名称很可能已经被选择为不复制客户端二进制文件中的任何现有符号。
相反,动态库的静态感知链接代表了重复符号出现的典型场景。
决定链接到动态库中的真正原因是对动态库的 ABI 符号集或其子集感兴趣。然而,动态库经常会携带更多与客户端二进制项目无关或不重要的符号,不知道它们的存在可能会导致无意中选择来自不同动态库的重复命名的函数或数据。
为了让事情变得更好,动态库开发人员只能采取这么多预防措施。将动态库符号的导出减少到仅基本符号集无疑是一种可以显著降低符号名称冲突概率的措施。然而,这种强烈推荐的设计实践并不能直接解决问题的根源。无论您在导出动态库符号时多么节俭,不同的开发人员仍然有可能为符号选择最简单的名称,这会导致两个或更多的二进制文件争夺使用符号名称的权利。
最后,需要指出的是,您不是在处理特定平台上特定链接器的特性;Windows 链接器(当然是 Visual Studio 2010)在确定如何处理动态链接过程中遇到的重复符号时,几乎完全遵循同一组规则。
解决动态库重复符号近似算法中的链接器准则
在搜索表示重复符号名称的最佳候选时,链接器基于以下情况做出决定:
- 重复符号的位置:链接器为位于进程内存映射不同部分的符号分配不同的重要性级别。接下来是更详细的解释。
- 动态库的指定链接顺序:如果两个或多个符号驻留在具有相同优先级的代码部分中,则驻留在动态库中的、在指定动态库列表中较早传递给链接器的符号将在表示复制符号的回合中获胜,而不是驻留在列表中稍后声明的动态库中的符号。
位置,位置,位置:代码优先级分区规则
参与构建客户机二进制文件的各种链接器符号可驻留在各种位置。链接器应用于解决符号间名称冲突的第一个标准是基于以下符号优先级方案之间的比较。
第一级优先级符号:客户端二进制符号
构建二进制文件的最初要素是它的目标文件的集合,这些文件要么是项目固有的,要么以静态库的形式出现。在 Linux 的情况下,来自这些组件的部分通常占据进程内存映射的较低部分。
二级优先符号:动态库可见符号
链接器将动态库导出的符号(位于动态库的动态部分)作为优先级方案中的下一个优先级。
第三级优先(非优先、非竞争性)符号
声明为静态的符号通常不会成为重复符号名称冲突的对象,无论它们是属于客户端二进制文件还是静态感知链接动态库。
属于同一组的是动态库的剥离符号,它们显然不参与链接客户机二进制文件的阶段。图 9-1 说明了符号优先级分区方法。
图 9-1。
Linker’s priority zoning
特定重名案例分析
以下部分涵盖了几个用例。
案例 1:客户端二进制符号与动态库 ABI 函数冲突
这种情况基本上可以描述为属于优先区 1 的符号与属于优先区 2 的符号发生冲突(图 9-2 )。
图 9-2。
Case 1: The client binary symbol collides with the dynamic library ABI symbol
作为一般规则,与较高优先级代码区域相关的符号总是胜出;换句话说,它被链接器选择作为对重复命名符号的所有引用的目标。
创建以下项目来演示这一特定场景。它由一个静态库、一个动态库和链接它们的客户端应用程序组成(动态库是静态链接的)。这些库具有重复的名称符号:
Static Library libstaticlib.a:
file: staticlibexports.h
#pragma once
int staticlib_first_function(int x);
int staticlib_second_function(int x);
int shared_static_duplicate_function(int x);
file: staticlib.c
#include <stdio.h>
#include "staticlibexports.h"
int staticlib_first_function(int x)
{
printf("%s\n", __FUNCTION__);
return (x+1);
}
int staticlib_second_function(int x)
{
printf("%s\n", __FUNCTION__);
return (x+2);
}
int shared_static_duplicate_function(int x)
{
printf("staticlib: %s\n", __FUNCTION__);
return 0;
}
file: build.sh
gcc -Wall -g -O0 -c staticlib.c
ar -rcs libstaticlib.a staticlib.o
Shared Library libshlib.so:
file: shlibexports.h
#pragma once
int shlib_function(void);
int shared_static_duplicate_function(int x);
file: shlib.c
#include <stdio.h>
#include "staticlibexports.h"
int shlib_function(void)
{
printf("sharedLib: %s\n", __FUNCTION__);
return 0;
}
int shared_static_duplicate_function(int x)
{
printf("sharedLib: %s\n", __FUNCTION__);
return 0;
}
file: build.sh
gcc -Wall -g -O0 -I../staticLib -c shlib.c
gcc -shared shlib.o -Wl,-soname,libshlib.so.1 -o libshlib.so.1.0.0
ldconfig -n .
ln -s libshlib.so.1 libshlib.so
客户端应用程序:
file: main.c
#include <stdio.h>
#include "staticlibexports.h"
#include "shlibexports.h"
int main(int argc, char* argv[])
{
int nRetValue = 0;
nRetValue += staticlib_first_function(1);
nRetValue += staticlib_second_function(2);
shlib_function();
shared_static_duplicate_function(1);
printf("nRetValue = %d\n", nRetValue);
return nRetValue;
}
file: build.sh
gcc -Wall -g -O0 -I../staticLib -I../sharedLib -c main.c
gcc main.o -Wl,-L../staticLib -lstaticlib \
-Wl,-L../sharedLib -lshlib \
-Wl,-R../sharedLib \
-o clientApp
客户端应用程序成功构建并产生以下输出:
staticlib_first_function
staticlib_second_function
sharedLib: shlib_function
staticlib: shared_static_duplicate_function
nRetValue = 6
显然,链接器选择静态库符号,因为它属于更高优先级的代码区域。
更改构件顺序,如下所示:
file: buildDifferentLinkingOrder.sh
gcc -Wall -g -O0 -I../staticLib -I../sharedLib -c main.c
gcc main.o -Wl,-L../sharedLib -lshlib \
-Wl,-L../staticLib -lstaticlib \
-Wl,-R../sharedLib \
-o clientAppDifferentLinkingOrder
请注意,代码的更改不会改变最终结果:
$ ./clientAppDifferentLinkingOrder
staticlib_first_function
staticlib_second_function
sharedLib: shlib_function
staticlib: shared_static_duplicate_function
nRetValue = 6
特定于 Windows 的扭曲
在这种特殊情况下(即,当静态库使用与动态库 ABI 符号同名的符号时),Visual Studio 链接器实现这一规则的方式略有不同。
当静态库出现在库列表的第一个位置时,DLL 的符号会被忽略,这完全符合预期。
但是,如果 DLL 被指定为库列表中的第一个库,所发生的事情可能不是您所期望的(即静态库符号总是占优势)。相反,链接会失败,并显示如下消息
StaticLib (staticlib.obj): error LNK2005: function_xyz already defined \
in SharedLib.lib (SharedLib.dll)
ClientApp.exe: fatal error LNK1169: one or more multiply defined symbols found
BUILD FAILED.
案例二:不同动态库的 ABI 符号碰撞
这种情况基本上可以描述为两个都属于优先区域 2 的符号相互碰撞(图 9-3 )。
图 9-3。
Case 2: ABI symbols of different dynamic libraries collide
显然,由于没有一个符号具有分区优势,所以在这种情况下,决定性因素将是链接顺序。
为了演示这一特定场景,我们创建了以下演示项目,该项目由两个共享库和客户端应用程序组成,共享库包含重复的 ABI 符号,客户端应用程序以静态方式链接两个动态库。为了提供一些更重要的细节,共享库 ABI 函数之一在内部调用复制的 ABI 函数:
Shared Library libfirst.so:
file: shlibfirstexports.h
#pragma once
int shlib_function(void); // duplicate ABI function
int shlibfirst_function(void);
file: shlib.c
#include <stdio.h>
int shlib_function(void)
{
printf("shlibFirst: %s\n", __FUNCTION__);
return 0;
}
int shlibfirst_function(void)
{
printf("%s\n", __FUNCTION__);
return 0;
}
file: build.sh
gcc -Wall -g -O0 -c shlib.c
gcc -shared shlib.o -Wl,-soname,libfirst.so.1 -o libfirst.so.1.0.0
ldconfig -n .
ln -s libfirst.so.1 libfirst.so
Shared Library libsecond.so:
file: shlibsecondexports.h
#pragma once
int shlib_function(void);
int shlibsecond_function(void);
int shlibsecond_another_function(void);
file: shlib.c
#include <stdio.h>
int shlib_function(void)
{
printf("shlibSecond: %s\n", __FUNCTION__);
return 0;
}
int shlibsecond_function(void)
{
printf("%s\n", __FUNCTION__);
return 0;
}
int shlibsecond_another_function(void)
{
printf("%s\n", __FUNCTION__);
shlib_function()
; // internal call to the duplicate ABI function
return 0;
}
file: build.sh
gcc -Wall -g -O0 -fPIC -c shlib.c
gcc -shared shlib.o -Wl,-soname,libsecond.so.1 -o libsecond.so.1.0.0
ldconfig -n .
ln -s libsecond.so.1 libsecond.so
客户端应用程序:
file: main.c
#include <stdio.h>
#include "shlibfirstexports.h"
#include "shlibsecondexports.h"
int main(int argc, char* argv[])
{
shlib_function(); // duplicate ABI function
shlibfirst_function();
shlibsecond_function();
shlibsecond_another_function(); // this one internally calls shlib_function()
return 0;
}
file: build.sh
gcc -Wall -g -O0 -I../libFirst -I../libSecond -c main.c
gcc main.o -Wl,-L../libFirst -Wl,-lfirst \
-Wl,-L../libSecond -Wl,-lsecond \
-Wl,-R../libFirst \
-Wl,-R../libSecond \
-o clientApp
即使两个共享库都有副本,甚至其中一个副本(shlib_duplicate_function
)不是本地函数,构建客户端应用程序也能成功完成。
运行客户端应用程序会产生以下输出:
$ ./clientApp
shlibFirst: shlib_function
shlibfirst_function
shlibsecond_function
shlibsecond_another_function
shlibFirst: shlib_function
显然,链接器选择了shlibFirst
版本的重复符号来唯一地表示重复符号的名称。此外,即使shlibsecond_another_function()
在内部调用复制的shlib_function()
,也不会影响链接阶段的最终结果。
作为 ABI 符号(.dynsym
部分的一部分),重复的函数符号总是以同样的方式被解析,尽管它与其余的 ABI 函数驻留在同一个源文件中。
不同的函数调用顺序没有影响
作为调查的一部分,颠倒的函数调用顺序的影响被检查(见清单 9-1)。
清单 9-1。main_differentOrderOfCalls.c
#include <stdio.h>
#include "shlibfirstexports.h"
#include "shlibsecondexports.h"
int main(int argc, char* argv[])
{
// Reverse order of calls - first shlibsecond methods
// get called, followed by the shlibfirst methods
shlibsecond_function();
shlibsecond_another_function();
shlib_function(); // duplicate ABI function
shlibfirst_function();
return 0;
}
这一特殊的变化并没有以任何方式影响最终的结果。显然,对重复符号解析过程产生关键影响的链接阶段的重要时刻发生在链接的早期阶段。
不同链接顺序的影响
然而,使用不同的链接顺序构建应用程序会产生不同的结果:
file: buildDifferentLinkingOrder.sh
gcc -Wall -g -O0 -I../shlibFirst -I../shlibSecond -c main.c
gcc main.o -Wl,-L../shlibSecond -lsecond \
-Wl,-L../shlibFirst -lfirst \
-Wl,-R../shlibFirst \
-Wl,-R../shlibSecond \
-o clientAppDifferentLinkingOrder
$ ./clientAppDifferentLinkingOrder
shlibSecond: shlib_function
shlibfirst_function
shlibsecond_function
shlibsecond_another_function
shlibSecond: shlib_function
显然,指定的反向链接顺序影响了链接器的决定。复制的shlib_function
的shlibSecond
版本现在被选择来表示复制的符号。
案例 3:动态库 ABI 符号与另一个动态库本地符号冲突
这种情况基本上可以描述为属于优先区域 2 的符号与属于优先区域 3 的符号发生冲突(图 9-4 )。
图 9-4。
Case 3: Dynamic library ABI symbol collides with another dynamic library local symbol
作为一般规则,很像情况 1 的例子,与较高优先级代码区域相关的符号总是赢得回合;换句话说,它被链接器选择作为对重复命名符号的所有引用的目标。
为了说明这个特定的场景,创建了下面的演示项目;它由两个共享库(以重复符号为特征)和静态感知链接这两个库的客户端应用程序组成:
Shared Library libfirst.so:
file: shlibfirstexports.h
#pragma once
int shlib_function(void);
int shlibfirst_function(void);
file: shlib.c
#include <stdio.h>
int shlib_function(void)
{
printf("shlibFirst: %s\n", __FUNCTION__);
return 0;
}
int shlibfirst_function(void)
{
printf("%s\n", __FUNCTION__);
return 0;
}
file: build.sh
gcc -Wall -g -O0 -c shlib.c
gcc -shared shlib.o -Wl,-soname,libfirst.so.1 -o libfirst.so.1.0.0
ldconfig -n .
ln -s libfirst.so.1 libfirst.so
Shared Library libsecond.so:
file: shlibsecondexports.h
#pragma once
int shlibsecond_function(void);
file: shlib.c
#include <stdio.h>
static
int shlib_function(void)
{
printf("shlibSecond: %s\n", __FUNCTION__);
return 0;
}
int shlibsecond_function(void)
{
printf("%s\n", __FUNCTION__);
shlib_function();
return 0;
}
file: build.sh
gcc -Wall -g -O0 -c shlib.c
gcc -shared shlib.o -Wl,-soname,libsecond.so.1 -o libsecond.so.1.0.0
ldconfig -n .
ln -s libsecond.so.1 libsecond.so
客户端应用程序:
file: main.c
#include <stdio.h>
#include "shlibfirstexports.h"
#include "shlibsecondexports.h"
int main(int argc, char* argv[])
{
shlibfirst_function();
shlibsecond_function();
return 0;
}
file: build.sh
gcc -Wall -g -O0 -I../shlibFirst -I../shlibSecond -c main.c
gcc main.o -Wl,-L../shlibFirst -lfirst \
-Wl,-L../shlibSecond -lsecond \
-Wl,-R../shlibFirst \
-Wl,-R../shlibSecond \
-o clientApp
构建客户端应用程序成功完成。运行客户端应用程序会产生以下输出:
$ ./clientApp
shlibFirst: shlib_function
shlibsecond_function
shlibSecond: shlib_function
这里我们有一点有趣的情况。
首先,当客户端二进制程序调用名为shlib_function
的副本时,链接器毫不怀疑这个符号应该由shlibFirst
库方法来表示,因为它位于更高优先级的代码区域。客户端应用程序输出的第一行证明了这一事实。
然而,早在链接器考虑发生之前,在动态库本身的构建过程中,shlibsecond_function()
对其局部shlib_function()
的内部调用就已经解决了,原因很简单,因为这两个符号彼此都是局部的。这就是为什么一个shlibSecond
函数对另一个shlibSecond
函数的内部调用不会受到构建客户端二进制文件过程的影响。
正如所料,当链接器的决定由代码区优先级的差异决定时,颠倒链接顺序对最终结果没有影响。
情况 4:动态库非导出符号与另一个动态库非导出符号冲突
这种情况基本上可以描述为两个都属于优先区 3 的符号相互碰撞(图 9-5 )。
图 9-5。
Case 4: Dynamic library’s non-exported symbol collides with another dynamic library’s non-exported symbol
属于代码区域 3 的符号对于构建客户端二进制文件的过程来说几乎是不可见的。这些符号要么被声明为局部范围的(并且对链接器完全不感兴趣),要么被剥离(对链接器不可见)。
即使符号名可能重复,这些符号也不会出现在链接器的符号列表中,也不会导致任何冲突。它们的重要性严格限制在它们所属的动态库的领域内。
为了说明这个特定的场景,创建了下面的演示项目;它由一个静态库、一个共享库和链接这两个库的客户端应用程序组成。动态库是静态链接的。
每个二进制文件都有一些局部函数,它们的名字与其余模块中的局部函数的名字相同。此外,客户端应用程序具有与共享库的函数同名的本地函数,共享库的符号被有意剥离。
Static Library libstaticlib.a:
file: staticlibexports.h
#pragma once
int staticlib_function(int x);
file: staticlib.c
#include <stdio.h>
#include "staticlibexports.h"
static int local_function(int x)
{
printf("staticLib: %s\n", __FUNCTION__);
return 0;
}
int staticlib_function(int x)
{
printf("%s\n", __FUNCTION__);
local_function(x);
return (x+1);
}
file: build.sh
gcc -Wall -g -O0 -c staticlib.c
ar -rcs libstaticlib.a staticlib.o
Shared Library libshlib.so:
file: shlibexports.h
#pragma once
int shlib_function(void);
file: shlib.c
#include <stdio.h>
#include "staticlibexports.h"
static int local_function(int x)
{
printf("sharedLib: %s\n", __FUNCTION__);
return 0;
}
static int local_function_strippedoff(int x)
{
printf("sharedLib: %s\n", __FUNCTION__);
return 0;
}
int shlib_function(void)
{
printf("sharedLib: %s\n", __FUNCTION__);
local_function(1);
local_function_strippedoff(1);
return 0;
}
file: build.sh
gcc -Wall -g -O0 -I../staticLib -c shlib.c
gcc -shared shlib.o -Wl,-soname,libshlib.so.1 -o libshlib.so.1.0.0
strip -N local_function_strippedoff libshlib.so.1.0.0
ldconfig -n .
ln -s libshlib.so.1 libshlib.so
Client Application:
file: main.c
#include <stdio.h>
#include "staticlibexports.h"
#include "shlibexports.h"
static int local_function(int x)
{
printf("clientApp: %s\n", __FUNCTION__);
return 0;
}
static int local_function_strippedoff(int x)
{
printf("clientApp: %s\n", __FUNCTION__);
return 0;
}
int main(int argc, char* argv[])
{
shlib_function();
staticlib_function(1);
local_function(1);
local_function_strippedoff(1);
return 0;
}
file: build.sh
gcc -Wall -g -O0 -I../staticLib -I../sharedLib -c main.c
gcc main.o -Wl,-L../staticLib -lstaticlib \
-Wl,-L../sharedLib -lshlib \
-Wl,-R../sharedLib \
-o clientApp
正如预期的那样,客户端应用程序成功构建并产生了以下输出:
sharedLib: shlib_function
sharedLib: local_function
sharedLib: local_function_strippedoff
staticlib_function
staticLib: local_function
clientApp: local_function
clientApp: local_function_strippedoff
显然,链接器没有发现任何重复的符号问题。所有局部/剥离符号都已在其特定模块中得到解析,并且不与其他模块中任何同名的局部/剥离符号冲突。
有趣的场景:静态库中的单例
既然你已经知道了链接器是如何处理动态库的本地/剥离符号的非优先/非竞争域的,那么就更容易理解在第六章中描述的有趣场景“单一类的多个实例”问题(使用静态库的一个相反的场景)。
想象一下下面的真实场景:假设您需要设计一个独特的进程级日志记录实用程序类。它应该存在于一个实例中,并且应该对所有不同的功能模块可见。
实现范例通常基于单例设计模式。让我们假设你的单例类的家是一个专用的静态库。
为了访问日志记录实用程序,托管您的功能模块的几个动态库链接到那个特定的静态库中。由于只是动态库内部功能的一部分(即,不是动态库的 ABI 接口的一部分),单例类符号没有被导出。单例类符号自动开始属于非优先/非竞争代码区域。
一旦进程开始并且所有的动态库都被加载,你最终会遇到这样的情况:几个动态库存在于同一个进程中,每个动态库在它们自己的“私有后院”中都有一个单独的类你瞧,由于动态库的本地符号区域的非竞争性质,突然之间你就有了多个(很好地共存的)单例日志记录实用程序类的实例。
唯一的问题是您想要一个单一的、唯一的 singleton 类实例,而不是很多!!!
为了说明这个特定的场景,我们使用以下组件创建了下一个演示项目:
- 承载单例类的静态库
- 两个共享库,每个都链接到静态库中。每个共享库只导出一个符号:一个在内部调用单例对象方法的函数。来自链接静态库的单例类符号不会被导出。
- 一个客户端应用程序,它链接到静态库中,以便访问单例类本身。它还静态感知两个共享库中的链接。
客户端应用程序和两个共享库自己调用 singleton 类。很快你就会看到,这个应用程序将包含三个不同的 singleton 类实例:
Static Library libsingleton.a:
file: singleton.h
#pragma once
class Singleton
{
public:
static Singleton& GetInstance(void);
public:
∼Singleton(){};
int DoSomething(void);
private:
Singleton(){};
Singleton(Singleton const &); // purposefully not implemented
void operator=(Singleton const&); // purposefully not implemented
private:
static Singleton* m_pInstance;
};
file: singleton.cpp
#include <iostream>
#include "singleton.h"
using namespace std;
Singleton* Singleton::m_pInstance = NULL;
Singleton& Singleton::GetInstance(void)
{
if(NULL == m_pInstance)
m_pInstance = new Singleton();
return *m_pInstance;
}
int Singleton::DoSomething(void)
{
cout << "singleton instance address = " << this << endl;
return 0;
}
file: build.sh
# for 64-bit OS must also pass -mcmodel=large compiler flag
g++ -Wall -g -O0 -c singleton.cpp
ar -rcs libsingleton.a singleton.o
Shared Library libfirst.so:
file: shlibfirstexports.h
#pragma once
#ifdef __cplusplus
extern "C"
{
#endif // __cplusplus
int shlibfirst_function(void);
#ifdef __cplusplus
}
#endif // __cplusplus
file: shlib.c
#include <iostream>
#include "singleton.h"
using namespace std;
#ifdef __cplusplus
extern "C"
{
#endif // __cplusplus
int shlibfirst_function(void)
{
cout << __FUNCTION__ << ":" << endl;
Singleton& singleton = Singleton::GetInstance();
singleton.DoSomething();
return 0;
}
#ifdef __cplusplus
}
#endif // __cplusplus
file: build.sh
rm -rf *.o lib*
g++ -Wall -g -O0 -fPIC -I../staticLib -c shlib.cpp
g++ -shared shlib.o -L../staticLib -lsingleton \
-Wl,--version-script=versionScript \
-Wl,-soname,libfirst.so.1 -o libfirst.so.1.0.0
ldconfig -n .
ln -s libfirst.so.1 libfirst.so
file: versionScript
{
global:
shlibfirst_function;
local:
*;
};
Shared Library libsecond.so:
file: shlibfirstexports.h
#pragma once
#ifdef __cplusplus
extern "C"
{
#endif // __cplusplus
int shlibsecond_function(void);
#ifdef __cplusplus
}
#endif // __cplusplus
file: shlib.c
#include <iostream>
#include "singleton.h"
using namespace std;
#ifdef __cplusplus
extern "C"
{
#endif // __cplusplus
int shlibsecond_function(void)
{
cout << __FUNCTION__ << ":" << endl;
Singleton& singleton = Singleton::GetInstance();
singleton.DoSomething();
return 0;
}
#ifdef __cplusplus
}
#endif // __cplusplus
file: build.sh
rm -rf *.o lib*
g++ -Wall -g -O0 -fPIC -I../shlibFirst -I../staticLib -c shlib.cpp
g++ -shared shlib.o -L../staticLib -lsingleton \
-Wl,--version-script=versionScript \
-Wl,-soname,libsecond.so.1 -o libsecond.so.1.0.0
ldconfig -n .
ln -s libsecond.so.1 libsecond.so
file: versionScript
{
global:
shlibsecond_function;
local:
*;
};
客户端应用程序:
file: main.c
#include <iostream>
#include "shlibfirstexports.h"
#include "shlibsecondexports.h"
#include "singleton.h"
using namespace std;
int main(int argc, char* argv[])
{
shlibfirst_function();
shlibsecond_function();
cout << "Accesing singleton directly from the client app" << endl;
Singleton& singleton = Singleton::GetInstance();
singleton.DoSomething();
return 0;
}
file: build.sh
g++ -Wall -g -O0 -I../staticLib -I../shlibFirst -I../shlibSecond -c main.cpp
g++ main.o -L../staticLib -lsingleton \
-L../shlibFirst -lfirst \
-L../shlibSecond -lsecond \
-Wl,-R../shlibFirst \
-Wl,-R../shlibSecond \
-o clientApp
客户端应用程序产生以下输出:
shlibfirst_function:
singleton instance address =
0x9a01008
shlibsecond_function:
singleton instance address =
0x9a01018
Accesing singleton directly from the client app
singleton instance address =
0x9a01028
Note
勤奋的读者会发现,运行时动态加载(dlopen)在这方面不会有任何改变。
关于这个主题的最后一点,我们尝试了一个线程安全的 singleton 版本,其中 singleton 实例是一个函数静态变量,而不是一个类静态变量:
Singleton& Singleton::GetInstance(void)
{
Static Singleton uniqueInstance;
return uniqueInstance;
}
这种方法只产生了稍微好一点的结果,其中两个共享库打印出了相同的单例实例地址值,尽管客户端应用程序打印出了基本不同的单例实例地址值。
解决问题
为了不完全悲观,有几种方法可以解决这类问题。
一种可能性是通过允许动态库额外导出单例类符号来稍微放宽符号导出标准。导出后,单例符号将不再属于允许存在于大量实例中的非优先/非竞争类别的符号。相反,他们将被提升到“竞争 ABI 符号”类别。根据详细的规则,链接器将只选择其中一个符号,并将所有引用指向该特定的单例类符号。
这个问题的最终解决方案是在动态库中托管 singleton 类。这样,绝大多数可能不想要的场景将被完全消除。没有一个 ABI 设计规则会被违反,新模块的设计将不会面临可笑的额外设计要求。
最后一点:链接不提供任何类型的名称空间继承
名称空间的使用无疑是最强大的工具,可以完全避免由于在处理重复符号时过于依赖链接器的内部推理而带来的令人不快的意外。
不管一个共享库可能链接另一个共享库,而另一个共享库又可能链接另一个共享库,最后又可能链接静态库,保护位于链接链中间某个位置的库所携带的符号的唯一性都需要将特定库的代码完全封装在它自己的专有名称空间中。
期望最顶层的库的名称空间会在与其他动态库的可能冲突之间保护库的符号的唯一性是完全错误的。
唯一可靠的计划,也是真正有效的计划,是每个库,不管是静态的还是动态的,都应该有自己专用的名称空间。
十、动态库版本控制
Abstract
大多数时候,代码开发是在进行中的工作。由于努力提供越来越多的功能,以及巩固现有的代码体,代码不可避免地会发生变化。通常情况下,设计的巨大飞跃会破坏软件组件之间的兼容性。实现向后兼容的理想通常需要专注的努力。在这些努力中,一个非常重要的角色属于版本化概念。
大多数时候,代码开发是在进行中的工作。由于努力提供越来越多的功能,以及巩固现有的代码体,代码不可避免地会发生变化。通常情况下,设计的巨大飞跃会破坏软件组件之间的兼容性。实现向后兼容的理想通常需要专注的努力。在这些努力中,一个非常重要的角色属于版本化概念。
考虑到动态库提供的功能通常由不止一个客户端二进制文件使用,跟踪库版本的精确性和遵守指定版本信息的原则需要额外的严格性。未能注意到由动态库的不同版本提供的功能之间的差异并对其做出反应可能不仅意味着单个应用程序的故障,有时还意味着操作系统的更广泛功能(文件系统、网络、窗口系统等)的混乱。
版本的分级及其对向后兼容性的影响
并非所有的代码更改对模块的功能都有相同的影响。一些变化本质上是装饰性的,其他的代表了错误修复,还有一些带来了脱离以前存在的范例的实质性变化。变更的整体重要性的分级在复杂的版本化方案中表现出来,其细节值得专门讨论。
主要版本代码变更
通常,动态库代码中破坏以前支持的功能的更改应该导致主版本号的增加。先前功能中断的症状包括多种可能性,包括以下几种:
- 所提供的运行时功能的实质性变化,例如完全取消以前支持的功能,对要支持的功能的要求的实质性变化,等等。
- 客户端二进制文件无法链接到动态库,这是由于 ABI 发生了变化,例如函数或整个接口被移除、导出函数签名发生了变化、重新排序的结构或类布局等。
- 维护运行过程中的完全改变的范例或需要主要基础结构改变的改变(例如切换到完全不同类型的数据库,开始依赖不同形式的加密,开始需要不同类型的硬件,等等。).
次要版本代码更改
在不破坏现有功能的情况下引入新功能的动态库代码更改通常会导致次版本号增加。符合动态库次要版本号递增条件的代码更改通常不会强制重新编译/重新链接客户端二进制文件,也不会导致运行时行为的实质性改变。添加的功能通常并不代表激进的转变,而是对现有的各种可用选择的温和增强。
在较小版本增量代码变更的情况下,ABI 接口的修改不会被自动排除。然而,在较小版本变化的情况下,ABI 修改主要意味着新函数、常数和结构或类的添加——换句话说,不影响先前存在的接口的定义和使用的变化。最重要的是,依赖于以前版本的客户机二进制文件不需要重新构建就可以使用新的次要版本的动态库。
补丁版本
大部分属于内部范围的代码更改,既不会导致 ABI 接口的任何更改,也不会带来实质性的功能更改,通常符合“修补”状态。
Linux 动态库版本控制方案
将详细讨论版本化概念的特定于 Linux 的实现,因为它解决与动态库版本化问题相关的一些最重要问题的复杂性绝对值得关注。目前有两种不同的版本控制方案正在使用:基于库的 soname 的版本控制方案和单个符号的版本控制方案。
基于 Linux Soname 的版本控制方案
Linux 库文件名携带版本信息
正如在第七章中提到的,Linux 动态库文件名的最后一部分代表了库的版本信息:
library filename =
lib
+
<library name>
+
.so
+
<library version information>
库版本信息通常使用以下格式
dynamic library version information = <
M>.<m>.<p>
其中 M 代表指示库主版本的一个或多个数字,M 代表指示库次版本的一个或多个数字,而 p 代表指示库补丁(即,非常小的变化)号的一个或多个数字。
通常的动态库升级实践
在典型的现实场景中,动态库的新的次要版本往往会相当频繁地出现。引起任何问题的次要版本升级的期望值通常很低,尤其是如果供应商在发布新代码之前遵循可靠的测试程序。
大多数情况下,动态库的新的次要版本的安装应该是一个相当简单和顺利的过程,例如一个新文件的简单文件复制。
然而,不管一个新的次要版本破坏现有功能的可能性有多小,它仍然有可能发生。为了能够优雅地后退并恢复以前版本的工作完美的动态库,简单的文件复制需要被一种更微妙的方法所取代。
前言:软链接的灵活性
根据定义,软链接是文件系统中携带包含另一个文件路径的字符串的元素。事实上,我们可以说软链接指向一个现有的文件。在大多数方面,操作系统将软链接视为它所指向的文件。对软链接的访问和对它所代表的文件的重定向对性能的影响可以忽略不计。
可以容易地创建软链接。
$ ln -s <file path> <softlink path>
也可以重定向到另一个文件。
$ ln -s -f <another file> <existing softlink>
最后,软链接可能在不再需要时被销毁。
$ rm -rf <softlink path>
前言:库 Soname 与库文件名
正如在第七章关于 Linux 库命名约定的讨论中提到的,库文件名应该遵循以下方案:
library filename =``lib``+``<library name>``+``.so
Linux 动态库的 soname 被定义为
library``soname``= lib +``<library``name``>``+``.so``+ <(only the)library``major version``digit(s)>
显然,soname 几乎与库文件名相同,唯一的区别是它不携带完整的版本信息,而只携带动态库主版本。正如您将看到的,这一事实在动态库版本控制方案中扮演着特别重要的角色。
在库升级方案中结合 Softlink 和 Soname
软链接的灵活性非常适合升级动态库的情况。以下指南描述了该过程:
- 在实际动态库文件名所在的同一文件夹中,维护着一个指向实际库文件的软链接。
- 它的名称与它所指向的库的 soname 完全匹配。这样,软链接实际上携带了库名,其中版本信息稍微宽松(即,仅携带主要版本信息)。
- 通常,客户机二进制文件从不(即,只是极少数情况下)链接到带有完整详细版本信息的动态库文件名。相反,正如您将很快看到的细节,客户机二进制文件构建过程被有目的地设置为客户机二进制文件链接到库 soname。
- 这一决定背后的原因相当简单:指定动态库的完整和准确的版本信息会强加太多不必要的限制,因为它会直接阻止链接到同一库的任何新版本。
图 10-1 说明了这个概念。
图 10-1。
The role of the softlink whose name matches the library’s soname
需要额外的软链接以方便开发场景
在构建客户机二进制文件时,您需要确定动态库的构建时位置,在此期间,您需要遵循“-L -l”约定的规则。尽管通过在“-l”和文件名(-l:
$ gcc -shared <inputs>``-l:
只传递任何版本信息中没有的库名,这是一种非正式但很好的约定。例如,
$ gcc -shared <inputs> -l``m``-l``dl``-l``pthread``-l``xml2``-l``xyz``-o <clientBinary>
指示客户端二进制文件需要链接到名称分别为 libm、libdl、libpthread、libxml2 和 libxyz 的库。
出于这个原因,除了携带库的 soname 的软链接之外,通常只提供携带库名和.so
文件扩展名的软链接,如图 10-2 所示。
图 10-2。
The use of softlinks during build time vs. during runtime
有几种方法可以提供额外的软链接。最结构化的方法是通过包部署配置(pkg-config)。一种不太结构化的方法是在控制动态库构建的 makefile 的部署目标中完成。最后,总是可以从命令行手动创建软链接,或者通过设置一个简单的脚本来创建。
基于 Soname 的版本控制方案分析
所描述的方案显然结合了两种灵活性:软链接的固有灵活性和 soname 的版本灵活性。下面是这两种灵活性如何在整体方案中共同发挥作用。
软链接的作用
由于操作系统将软链接视为它所指向的文件,并且固有地提供了高效的解引用机制,所以加载器通过软链接将客户端二进制文件与运行时可用的实际库文件连接起来没有特别的问题。
当新版本的动态库到达时,只需花费很少的精力和时间就可以将其文件复制到旧版本已经驻留的同一文件夹中,并修改软链接以指向较新版本的文件。
$ ln -s -f <new version of dynamic library file> <existing soname>
这个方案的好处是显而易见的:
- 无需重新构建客户端二进制文件。
- 无需擦除或覆盖动态库文件的当前版本。两个文件可以共存于同一个文件夹中。
- 轻松、优雅、即时地设置客户端二进制文件以使用动态库的新版本。
- 在升级导致意外功能的情况下,能够优雅地恢复客户端二进制文件与旧动态库版本的连接。
Soname 的版本保护作用
如前一节所述,并非动态库代码中的所有更改都会对客户端二进制功能产生破坏性影响。次要版本增量应该不会导致任何重大问题(例如无法动态链接或运行,或者严重的不必要和意外的运行时更改)。升级需要主要版本增量;另一方面,它们是极其危险的主张,应该极其谨慎地对待。
不需要太多思考就可以得出结论,soname 实际上被设计成一种相当有弹性的保护措施。
- 通过在构建客户端二进制文件的过程中将其用作动态库标识符,您基本上对动态库的主要版本施加了限制。
加载器被设计得足够智能,能够识别将动态库升级到不同于 soname 建议的主要版本的尝试,并阻止这种情况发生。
- 通过有目的地省略关于次要版本和补丁号的细节,您隐含地允许次要版本的更改没有太多麻烦地发生。
尽管这听起来很棒,但是只有在您有充分的理由预期新库版本带来的更改不会破坏整体功能的情况下,这种方案才是相当安全的,这种情况下最多只有次要版本发生更改。图 10-3 说明了 soname 的版本保护作用。
图 10-3。
Soname safeguards against linking with incompatible major versions of shared library, but does not interfere with minor version upgrades
在新的动态库具有升级的主版本的情况下,该方案被设计为阻止运行。解释这种情况下限制措施的确切工作方式需要我们更深入地研究实现细节。
Soname 实现的技术细节
尽管听起来很可靠,但是基于使用 soname 的方案并不强大,除非它的实现有一个非常重要的方面。更具体地说,soname 被嵌入到二进制文件中。ELF 格式保留了动态部分的专用字段,用于(根据目的)携带 soname 信息。在链接阶段,链接器获取指定的 soname 字符串,并将其插入到选择的 ELF 格式字段中。
soname 的“卧底生活”始于链接器将其印入动态库,目的是声明库的主要版本。然而,它并没有就此结束。每当客户端二进制文件链接到动态库时,链接器都会提取动态库的 soname,并将其插入到客户端二进制文件中,尽管这次的目的略有不同——表示客户端二进制文件的版本要求。
Soname 嵌入到动态库文件中
构建动态库时,可以使用专用的链接器标志来指定库 soname。
$ gcc -shared <list of linker inputs>``-Wl,-soname,
链接器将指定的 soname 字符串嵌入到二进制文件的DT_SONAME
字段中,如图 10-4 所示。
图 10-4。
Soname gets embedded into the DT_SONAME field of the binary file
Soname 传播到客户端二进制文件中
当客户端二进制文件与动态库链接(直接或通过软链接)时,链接器获取动态库 soname 并将其插入客户端二进制文件的DT_NEEDED
字段,如图 10-5 所示。
图 10-5。
Linked library soname gets propagated into the client binary
这样,soname 携带的版本信息会进一步传播,在所有相关方(链接器、动态库文件、客户机二进制文件和加载器)之间建立可靠的版本规则。
与库文件名不同,库文件名很容易被任何人修改(从每个脑细胞有太多手指和太多时间的弟弟妹妹到恶意的黑客),改变 soname 值既不简单也不实际,因为它不仅需要修改二进制文件,还需要完全熟悉 ELF 格式。
其他实用程序的支持(ldconfig)
除了受动态链接场景中所有必要参与者(即链接器、二进制文件、加载器)的支持之外,其他工具也支持 soname 概念。在这方面,ldconfig 实用程序是一个显著的例子。除了其最初的职责范围之外,该工具还有一个额外的“瑞士刀”功能。
当传递了-n <directory>
命令行参数时,ldconfig 打开所有动态库文件(其名称符合库命名约定!),提取它们的 soname,并为它们中的每一个创建一个名称与提取的 soname 相同的软链接。
-l <specific library file>
选项更加灵活,因为在这种情况下,动态库文件名可以是任何合法的文件名。无论文件名看起来像什么(是具有完整版本信息的完全成熟的原始库名,还是经过重大修改的文件名),嵌入到指定文件中的 soname 都会被提取出来,并且会创建正确的软链接,明确指向库文件。
为了证明这一点,进行了一个小实验,其中原始库名被有目的地改变。然而,ldconfig 设法创建了正确的软链接,如图 10-6 所示。
图 10-6。
Regardless of the library name, ldconfig extracts its soname
Linux 符号版本控制方案
除了控制整个动态库的版本信息之外,GNU 链接器还支持对版本的额外控制,其中版本信息可以归属于单独的符号。在该方案中,在链接阶段,称为版本脚本的文本文件以相当简单的语法为特征被传递给链接器,链接器将该文本文件插入专门用于携带符号版本信息的 ELF 部分(.gnu.version
和类似部分)。
符号版本控制机制的优势
符号版本控制方案在许多方面比基于 soname 的版本控制更复杂。符号版本控制方法的一个特别有趣的细节是,它允许单个动态库二进制文件同时携带同一符号的几个不同版本。需要相同动态库的不同版本的不同客户端二进制文件将加载相同的且唯一的二进制文件,并且将能够针对指定版本的符号进行链接。
相比之下,当使用基于 soname 的版本控制方法时,为了支持同一个库的几个主要版本,您需要在目标机器上实际存在那么多不同的二进制文件(每个都带有不同的 soname 值)。图 10-7 说明了版本化方案之间的区别。
图 10-7。
Comparison of soname-based and symbol-based versioning schemes
额外的好处是,由于脚本文件语法支持丰富的功能,还可以控制符号可见性(即,哪些符号由库导出,哪些保持隐藏),其优雅和简单性超过了迄今为止描述的所有符号可见性方法。
符号版本控制机制分析模型
为了充分理解符号版本控制机制,定义使用它的常见用例场景是很重要的。
阶段 1:初始版本
一开始,假设动态库的第一个已发布版本与客户机二进制文件“A”愉快地链接在一起,一切运行良好。图 10-8 描述了开发周期的早期阶段。
图 10-8。
Chronologically earliest client binary “A” links in library version 1.0.0
然而,这仅仅是故事的开始。
阶段 2:次要版本变更
随着时间的推移,动态的库发展进程不可避免地带来变化。更重要的是,不仅动态库发生了变化,还有一系列新的客户端二进制文件(“B”、“C”等)。)出现,这在动态库与第一个客户机二进制文件“A”链接发生时还不存在。该阶段如图 10-9 所示。
某些动态库更改可能对现有的客户端二进制文件的功能没有影响。此类更改被视为次要版本更改是理所当然的。
图 10-9。
Somewhat newer client binary “B” links in newer library version (1.1.0)
阶段 3:主要版本变更
偶尔,动态库代码的变化会带来一些过于激进的差异,这意味着与以前的库版本完全决裂。在这些新的变化发生时创建的新的客户机二进制文件(“C”)在适应新的范例方面通常没有问题。
然而,较老的客户端二进制(“A”和“B”)可能会以图 10-10 所示的情况结束,这类似于摇滚婚宴上的一对老年夫妇永远等待乐队演奏他们最喜欢的格伦·米勒曲子。
图 10-10。
The latest and greatest client binary “C” links in the newest dynamic library version (2.0.0), which is incompatible for use by the older client binaries “A” and “B”
软件开发人员的任务是使功能升级的过渡尽可能平稳。打破与现有基础设施的兼容性很少是明智之举。该库在开发人员中越受欢迎,就越不建议脱离该库的预期功能。这个问题的真正解决方案是新的动态库至少在一段时间内同时提供旧版本和新版本的功能。这个想法在图 10-11 中进行了说明。
图 10-11。
Symbol versioning resolves incompatibility issues
基本实现要素
符号版本化方案是通过结合链接器版本脚本和.symver
汇编指令来实现的,这两者都将在接下来详细阐述。
链接器版本脚本
符号可见性控制机制的最基本实现是基于 GNU 链接器读取以版本脚本文本文件形式提供的符号版本信息。
让我们从一个简单的动态库(libsimple.so
)的例子开始一个简单的演示,它具有清单 10-1 所示的三个函数。
清单 10-1。简单. c
int first_function(int x)
{
return (x+1);
}
int second_function(int x)
{
return (x+2);
}
int third_function(int x)
{
return (x+3);
}
假设现在你想要前两个库函数(但不是第三个!)来携带版本信息。指定符号版本的方法是创建一个相当简单的版本脚本文件,看起来有点像清单 10-2 中的代码。
清单 10-2 .简单版脚本
LIBSIMPLE_1.0 {
global:
first_function; second_function;
local:
*;
};
最后,让我们现在构建动态库。通过使用专用的链接器标志,可以方便地将版本脚本文件名传递给链接器,如下所示:
$ gcc -fPIC -c simple.c
$ gcc -shared simple.o``-Wl,--version-script,``simpleVersionScript
链接器从脚本文件中提取信息,并将其嵌入专用于版本控制的 ELF 格式部分。关于符号版本信息如何嵌入到 ELF 二进制文件中的更多信息将很快出现。
。symver 装配指令
不同于代表符号版本化概念的“面包和黄油”的版本脚本文件,在所有阶段和所有场景中使用,符号版本化范例依赖于另一个成分——.symver
汇编指令——来解决棘手的情况。
让我们假设一个主要版本变化的场景,其中函数签名在版本之间没有变化,但是底层功能发生了相当大的变化。此外,有一个函数最初用于返回许多链接元素,但在最新版本中被重新设计为返回链表所占用的总字节数(反之亦然)。参见清单 10-3。
清单 10-3。适用于不同主要版本的相同功能的不同实现的示例
// VERSION 1.0:
unsigned long list_occupancy(struct List* pStart)
{
// here we scan the list, and return the number of elements
return nElements;
}
// VERSION 2.0:
unsigned long list_occupancy(struct List* pStart)
{
// here we scan the list, but now return the total number of bytes
return nElements*sizeof(struct List);
}
显然,库的第一个版本的客户端将面临问题,因为函数返回的值将不再符合预期。
如前所述,这种版本控制技术的信条是在同一二进制文件中提供同一符号的不同版本。说得好听,但是怎么做呢?试图构建两个函数版本将导致链接器报告重复的符号。幸运的是,GCC 编译器支持自定义的.symver
汇编指令,这有助于缓解这个问题(见清单 10-4)。
清单 10-4。清单 10-3 中的同一对不同版本的函数,这次正确地应用了符号版本控制
__asm__(".symver list_occupancy_1_0, list_occupancy@MYLIBVERSION_1.0");
unsigned long``list_occupancy_1_0
{
// here we scan the list, and return the number of elements
return nElements;
}
// default symbol version indicated by the additional "@"
// |
// v
__asm__(".symver list_occupancy_2_0, list_occupancy@@MYLIBVERSION_2.0");
unsigned long``list_occupancy_2_0
{
// here we scan the list, but now return the total number of bytes
return nElements*sizeof(struct List);
}
这个方案是如何运作的?
为了消除链接器面临的重复符号问题,您可以为同一函数的不同版本创建不同的名称,这些名称将仅用于内部目的(即不会被导出)。这两个函数是list_occupancy_1_0
和list_occupancy_2_0
。
然而,从外部世界的角度来看,链接器将创建以期望的函数名(即list_occupancy()
)为特征的符号,尽管用适当的符号版本信息进行了修饰,出现在两个不同的版本中:list _ occupancy @ my libversion _ 1.0 和list_occupancy@MYLIBVERSION_2.0.
因此,旧的和新的客户端二进制文件都能够识别它们期望的符号。旧的客户端二进制文件会很高兴看到符号 list _ occupancy @ my libversion _ 1.0 的存在。它对这个中间函数符号的调用将在内部被路由到正确的位置——到动态库函数list_occupancy_1_0()
,这是真正的符号。
最后,全新的客户端二进制文件并不特别关心以前的版本历史,将选择默认符号,由名称中的额外@字符表示(在本例中为list_occupancy@@MYLIBVERSION_2.0
)。
样本项目分析:阶段 1(初始版本)
既然您已经理解了基本的实现成分(版本脚本和/或.symver
汇编指令)是如何工作的,那么是时候仔细看看一个真实的例子了。为了说明要点,让我们回到用来说明链接器版本脚本的原始示例(即具有三个函数的库libsimple.so
,其中前两个将受符号版本控制)。为了让演示更有说服力,会在原代码中加入一些 printf 的;参见清单 10-5 到清单 10-8。
清单 10-5。简单. h
#pragma once
int first_function(int x);
int second_function(int x);
int third_function(int x);
清单 10-6。简单. c
#include <stdio.h>
#include "simple.h"
int first_function(int x)
{
printf(" lib: %s\n", __FUNCTION__);
return (x+1);
}
int second_function(int x)
{
printf(" lib: %s\n", __FUNCTION__);
return (x+2);
}
int third_function(int x)
{
printf(" lib: %s\n", __FUNCTION__);
return (x+3);
}
清单 10-7 .简单版脚本
LIBSIMPLE_1.0 {
global:
first_function; second_function;
local:
*;
};
清单 10-8。build.sh
gcc -Wall -g -O0 -fPIC -c simple.c
gcc -shared simple.o``-Wl,--version-script,``simpleVersionScript
现在库已经构建好了,让我们仔细看看 ELF 格式是如何支持符号版本化概念的。
ELF 格式支持
对库文件的段分析表明,有三个名称非常相似的段用于携带版本信息,如图 10-12 所示。
图 10-12。
ELF format support for versioning information
使用-V
命令行参数调用readelf
实用程序以一种特别简洁的方式提供了关于这些部分内容的报告,如图 10-13 所示。
图 10-13。
Using readelf to list contents of version-related sections
很明显
- 章节描述了在这个特定的库中定义的版本信息(因此章节名中有附录“_d”)。
.gnu.version_r
小节描述了其他库的版本信息,该信息被该库引用(因此小节名称中有附录“_r”)。.gnu_version
部分提供了与该库相关的所有版本信息的汇总列表。
此时验证版本信息是否与版本脚本中指定的符号相关联是很有趣的。
在所有可用的检查二进制文件符号的方法(nm
、objdump
、readelf
)中,再次是readelf
实用程序以最好的形式提供答案,其中符号与指定版本信息的关联变得明显,如图 10-14 所示。
图 10-14。
Using readelf to print symbol versioning information
显然,在版本脚本中指定并传递给链接器的版本信息找到了进入二进制文件的途径,并且肯定成为了用于版本控制的符号的属性。
一个有趣的旁注是,二进制文件的反汇编表明没有first_function@@LIBVERSIONDEMO_1.0
这样的东西。你能找到的只有真正的first_function
的符号。运行时的反汇编(通过运行gdb
)显示了同样的事情。
显然,用符号版本信息修饰的导出符号是一种虚构(有用,但仍然是虚构),而最终唯一有价值的是真实的、现有函数的符号。
将版本符号信息传播到客户端二进制文件
当您检查链接到您的符号版本化动态库的客户端二进制文件时,会出现另一轮有趣的发现。为了在这个特定的方向上探索符号版本化,让我们创建一个简单的引用版本化符号的演示应用程序;参见清单 10-9。
清单 10-9。主网站
#include <stdio.h>
#include "simple.h"
int main(int argc, char* argv[])
{
int nFirst = first_function(1);
int nSecond = second_function(2);
int nRetValue = nFirst + nSecond;
printf("first(1) + second(2) = %d\n", nRetValue);
return nRetValue;
}
让我们现在建立它。
$ gcc -g -O0 -c -I../sharedLib main.c
$ gcc main.o -Wl,-L../sharedLib -lsimple \
-Wl,-R../sharedLib -o firstDemoApp
请注意,为了单独使用符号版本控制机制,特意省略了对库 soname 的指定。
作为一个 ELF 二进制文件,演示应用程序也包含与版本相关的部分(如图 10-15 中的部分检查所示),这并不奇怪。
图 10-15。
Demo application also features versioning-related sections
更重要的是,演示动态库的符号版本信息是由客户端二进制程序通过链接过程获取的,如图 10-16 所示。
图 10-16。
Client binary “ingests” the symbol versioning info from library it linked in
与前面描述的基于 soname 的版本控制场景完全一样,符号版本控制机制也从动态库传递到其客户端二进制文件。这样,客户机二进制文件和动态库版本之间就建立了一种契约形式。
为什么这很重要?从客户机二进制代码与动态库发生链接的那一刻起,动态库代码可能经历大量的改变,并相应地经历大量的次要和主要版本。
不管动态库如何变化,它的客户机二进制文件将继续使用链接时存在的版本信息。如果恰好那个版本(当然还有与那个特定版本相关联的功能)丢失了,将会强烈地表明向后兼容性的破坏。
在继续之前,让我们确保您的版本控制方案不会阻止应用程序运行。简单的实验如图 10-17 所示。
图 10-17。
Versioning scheme working correctly
样本项目分析:阶段 2(次要版本变更)
一旦您理解了符号版本化方案如何操作的基础,就该模拟动态库开发导致非破坏性变更(即,次要版本)的场景了。在尝试模拟真实场景时,将采取以下步骤:
- 您将通过添加更多的函数来修改动态库。只有一个新添加的函数会被导出。宣布 LIBSIMPLE_1.1 次要版本升级的额外项目将丰富版本控制脚本。
- 原始的客户机二进制文件(即最初的简单演示应用程序)将有目的地保持不变。通过不重新构建它,它将完美地模仿遗留应用程序,该应用程序是在动态库以初始版本 1.0 为特色的时候构建的。
- 新的客户机二进制文件(另一个简单的演示应用程序)将被创建并链接到更新的动态库。这样,它将作为一个全新的客户机二进制文件的例子,创建于最新和最好的动态库版本 1.1,不知道任何以前的库版本。
- 为了简化演示,它的代码与最初的简单演示应用程序不会有太大的不同。最显著的区别是它将调用新的 ABI 函数,这在最新的 1.1 版本之前是不存在的。
清单 10-10 和 10-11 显示了修改后的动态库的源文件现在的样子。
清单 10-10。简单. h
#pragma once
int first_function(int x);
int second_function(int x);
int third_function(int x);
int fourth_function(int x);
int fifth_function(int x);
清单 10-11。简单. c
#include <stdio.h>
#include "simple.h"
int first_function(int x)
{
printf(" lib: %s\n", __FUNCTION__);
return (x+1);
}
int second_function(int x)
{
printf(" lib: %s\n", __FUNCTION__);
return (x+2);
}
int third_function(int x)
{
printf(" lib: %s\n", __FUNCTION__);
return (x+3);
}
int fourth_function(int x) // exported in version 1.1
{
printf(" lib: %s\n", __FUNCTION__);
return (x+4);
}
int fifth_function(int x)
{
printf(" lib: %s\n", __FUNCTION__);
return (x+5);
}
清单 10-12 显示了修改后的版本脚本。
清单 10-12 .简单版脚本
LIBSIMPLE_1.0 {
global:
first_function; second_function;
local:
*;
};
LIBSIMPLE_1.1
{
global:
fourth_function
;
local:
*;
};
新的演示应用程序源文件将如清单 10-13 所示。
清单 10-13。主网站
#include <stdio.h>
#include "simple.h"
int main(int argc, char* argv[])
{
int nFirst = first_function(1);
int nSecond = second_function(2);
int nFourth = fourth_function(4);
int nRetValue = nFirst + nSecond + nFourth;
printf("first(1) + second(2) + fourth(4) = %d\n", nRetValue);
return nRetValue;
}
让我们现在建立它。
$ gcc -g -O0 -c -I../sharedLib main.c
$ gcc main.o -Wl,-L../sharedLib -lsimple \
-Wl,-R../sharedLib -o newerApp
现在让我们仔细看看这个小版本化冒险的效果,它完美地模拟了动态库次要版本升级时发生的真实场景。
首先,如图 10-18 所示,版本信息现在不仅包含原始版本(1.0),还包含最新版本(1.1)
图 10-18。
Complete versioning information ingested by the client binary
导出的符号集现在由 1.0 版和 1.1 版符号组成,如图 10-19 所示。
图 10-19。
Symbols of different versions present in the shared library
现在让我们看看在 1.1 版本发布后首次构建的更新、更现代的客户端二进制文件(newerApp)是什么样子的。如图 10-20 所示,链接器读出动态库支持的所有版本的信息,并将其插入到新应用的客户端二进制文件中。
图 10-20。
Newer client binary ingested complete versioning info (both old and newer symbol versions)
客户端二进制文件在运行时依赖的动态库符号列表包含两个版本的符号。图 10-21 说明了这一点。
图 10-21。
Symbols of all versions ingested from the shared library
现在,为了验证添加的新功能和修改的版本信息是否如预期的那样工作,您可以尝试运行旧的和新的应用程序。如图 10-22 所示,运行老 app 会证明新的小版本动态库并没有带来什么不愉快的惊喜。
图 10-22。
Both older and newer app link the same library, but use the symbols of different versions
样本项目分析:阶段 3(主要版本变更)
在前面分析的例子中,我已经介绍了新代码变更通常不会影响客户使用现有代码库的情况。这种代码变更被认为是次要版本的增加。
我不会试图涵盖更戏剧性的情况,在这种情况下,代码更改严重破坏了客户使用代码的方式,因此显然属于主要版本增量类别。
改变 ABI 函数行为的情况
当动态库符号看起来什么都没发生时(即,函数的原型没有改变,和/或结构的布局没有改变),可能会发生最令人不快的代码更改,但是函数处理数据的潜在意义(最重要的是,它们返回的值)确实发生了变化。
想象一下,你有一个函数用来返回以毫秒为单位的时间值。在一个晴朗的日子里,开发人员发现毫秒作为度量不够精确,于是决定返回纳秒值(纳秒值大 1000 倍)。
这个场景是我们下一个例子的主题;我将展示如何通过巧妙使用符号版本控制机制来解决这种性质的问题。(我同意这个例子有点幼稚/荒诞/幼稚。事实上,有一百万种方法可以避免这种变化带来的混乱。例如,您可以引入一个名称中带有单词“纳秒”的新 ABI 函数,它将以纳秒为单位返回时间。即使这样,像这样的例子对于演示来说已经足够好了。)
回到正题,我们假设演示动态库导出头根本没变,所以函数原型没变。然而,最新的设计要求规定从现在起first_function()
需要返回一个不同于它过去返回的值。
int first_function(int x)
{
printf(" lib: %s\n", __FUNCTION__);
return``1000*
}
不用说,这种变化肯定会对现有的客户机二进制文件造成严重破坏。他们现有的代码基础设施根本不期望有那么大的价值。跳出数组边界可能会导致异常。在绘制图表的场景中,该值可能会超出界限,等等。
因此,现在您需要一种方法来确保老客户得到通常的待遇(即,现有的客户端二进制文件对first_function()
的调用返回它以前的值),而新客户得到新设计的好处。
唯一的问题是,你必须解决冲突;相同的函数名必须在两个完全不同的场景中使用。幸运的是,符号版本控制机制证明它能够处理这类问题。
作为第一步,您将修改版本脚本以表明对新的主要版本的支持;请参见清单 10-14。
清单 10-14 .简单版脚本
LIBSIMPLE_1.0 {
global:
first_function; second_function;
local:
*;
};
LIBSIMPLE_1.1 {
global:
fourth_function;
local:
*;
};
LIBSIMPLE_2.0
{
global:
first_function
;
local:
*;
};
接下来,您将基于使用。symver 汇编指令,如清单 10-15 所示。
清单 10-15。simple.c(仅显示此处的更改)
...
__asm__(".symver first_function_1_0,first_function@LIBSIMPLE_1.0");
int``first_function_1_0
{
printf(" lib: %s\n", __FUNCTION__);
return (x+1);
}
__asm__(".symver first_function_2_0,first_function@@LIBSIMPLE_2.0");
int``first_function_2_0
{
printf(" lib: %s\n", __FUNCTION__);
return 1000*(x+1);
}
...
如图 10-23 所示,动态库现在多了一条版本信息。
图 10-23。
The latest and greatest library version contains all the symbol versions
有趣的是,如图 10-24 所示,看起来.symver
指令确实发挥了它的魔力。
图 10-24。
Both versions of first_function() exist
整个.symver
方案的最终效果是导出两个版本的first_function()
符号的魔力,尽管这个名称的函数不再存在,因为它被first_function_1_0()
和first_function_2_0()
所取代。
为了清楚地显示实现的不同,您将创建一个新的应用程序,它的源代码与以前的版本没有什么不同(见清单 10-16)。
清单 10-16。主网站
#include <stdio.h>
#include "simple.h"
int main(int argc, char* argv[])
{
int nFirst = first_function(1);
// seeing 1000 times larger return value will be fun!
int nSecond = second_function(2);
int nFourth = fourth_function(4);
int nRetValue = nFirst + nSecond + nFourth;
printf("first(1) + second(2) + fourth(4) = %d\n", nRetValue);
return nRetValue;
}
将相应地选择新的应用程序名称:
$ gcc -g -O0 -c -I../sharedLib main.c
$ gcc main.o -Wl,-L../sharedLib -lsimple \
-Wl,-R../sharedLib -o
ver2PeerApp
运行时比较将清楚地表明,旧客户端的功能不会受到主要版本变化的影响。然而,当代应用程序将依靠 2.0 版本带来的新功能。图 10-25 总结了这一点。
图 10-25。
Three apps (each of which rely on different symbol versions of the same dynamic library) run as intended
改变 ABI 函数原型的案例
之前描述的案例有点离奇。由于有许多方法可以避免,它在现实生活中发生的几率相当低。然而,从教育的角度来看,它是珍贵的,因为解决这种问题的过程是最简单的。
属于主要版本代码更改的一个更常见的情况是当函数的签名需要更改时。例如,让我们假设对于新的用例场景,first_function()
需要接受一个额外的输入参数。
int first_function(int x, int normfactor);
显然,您现在需要支持同名但不同签名的函数。为了演示这个问题,让我们创建另一个版本,如清单 10-17 所示。
清单 10-17 .简单版脚本
LIBSIMPLE_1.0 {
global:
first_function; second_function;
local:
*;
};
LIBSIMPLE_1.1 {
global:
fourth_function;
local:
*;
};
LIBSIMPLE_2.0 {
global:
first_function;
local:
*;
};
LIBSIMPLE_3.0
{
global:
first_function;
local:
*;
};
一般来说,这个问题的解决方案与前一个案例没有实质性的不同,因为基于.symver
汇编指令的配方将会以与前一个例子相同的方式使用(见清单 10-18)。
清单 10-18。simple.c(仅显示此处的更改)
__asm__(".symver first_function_1_0,first_function@LIBSIMPLE_1.0");
int first_function_1_0(int x)
{
printf(" lib: %s\n", __FUNCTION__);
return (x+1);
}
__asm__(".symver first_function_2_0,first_function@LIBSIMPLE_2.0");
int first_function_2_0(int x)
{
printf(" lib: %s\n", __FUNCTION__);
return 1000*(x+1);
}
__asm__(".symver first_function_3_0,first_function@@LIBSIMPLE_3.0");
int first_function_3_0
(int x, int normfactor)
{
printf(" lib: %s\n", __FUNCTION__);
return normfactor*(x+1);
}
然而,最大的区别是导出头必须被修改,如清单 10-19 所示。
清单 10-19。简单. h
#pragma once
// defined when building the latest client binary
#ifdef SIMPLELIB_VERSION_3_0
int first_function(int x, int normfactor);
#else
int first_function(int x);
#endif // SIMPLELIB_VERSION_3_0
int second_function(int x);
int third_function(int x);
int fourth_function(int x);
int fifth_function(int x);
只有用传递给编译器的 SIMPLELIB_VERSION_3_0 预处理器常量构建的客户端二进制文件才会包含新的first_function()
原型。
$ gcc -g -O0 -c``-DSIMPLELIB_VERSION_3_0
$ gcc main.o -Wl,-L../sharedLib -lsimple \
-Wl,-R../sharedLib -o ver3PeerApp
对于读者来说,验证这个例子在所有其他方面(版本信息、符号存在、运行时结果)满足他/她的期望是一个很好的小练习。
版本脚本语法概述
到目前为止,代码示例中显示的版本脚本仅提供了广泛支持的语法功能的一个子集。本节的目的是提供受支持选项的简要概述。
版本节点
版本脚本的基本实体是版本节点,封装在描述某个版本的花括号中的命名结构,例如
LIBXYZ_1.0.6 {
... <some descriptors reside here>
};
版本节点通常封装了几个控制版本化过程不同方面的关键字,稍后将更详细地讨论它们的多样性。
版本节点命名规则
通常选择节点名称来精确描述该节点所描述的完整版本。通常,名称以点或下划线分隔的数字结尾。代表较新版本的节点出现在代表较早版本的节点之后,这是一种常识性的做法。
然而,这只是一种让人类的生活更轻松的做法。链接器并不特别关心如何命名版本节点,也不关心它们在文件中出现的顺序。所有这些都需要名字不同。
动态库及其客户端二进制文件也有类似的情况。对他们来说,真正重要的是版本节点被添加到版本文件中的时间顺序——在构建它们的时候存在哪个特定的版本。
符号导出控制
版本节点的global
和local
修饰符直接控制符号导出。与在局部标签下声明的符号相反,在全局标签下声明的分号分隔的符号列表将被导出。
LIBXYZ_1.0.6 {
global:
first_function; second_function;
local:
*;
};
尽管这不是版本化方案的主要主题,但是这种导出符号的机制实际上是指定导出符号列表的完全合法的(并且在许多方面是最优雅的)方式。这种机制如何工作的例子将在随后的章节中提供。
通配符支持
版本脚本支持与 shells 支持的表达式匹配操作相同的通配符集。例如,以下版本脚本将名称以“first”或“second”开头的所有函数声明为全局函数
LIBXYZ_1.0.6 {
global:
first``*``; second``*
local:
*;
};
此外,本地标签下的星号指定所有其他函数都属于本地范围(不导出的函数)。双引号中指定的文件名将被逐字采用,不管它们可能包含任何通配符。
链接说明符支持
版本脚本可用于指定extern "C"
(无名称篡改)或extern "C++"
链接说明符。
LIBXYZ_1.0.6 {
global:
extern "C" {
first_function;
}
local:
*;
};
命名空间支持
版本脚本还支持使用命名空间来指定版本化和/或导出符号的从属关系。
LIBXYZ_1.0.6 {
global:
extern "C++" {
libxyz_namespace::*
}
local:
*;
};
未命名节点
未命名的节点可用于指定未版本化的符号。此外,它的目的可能是托管符号导出说明符(全局和/或局部)。
事实上,当对符号导出的控制是您使用版本控制脚本机制的唯一动机时,版本脚本通常只包含一个未命名的节点。
版本脚本附带功能:符号可见性控制
版本脚本机制的另一个特性是它还提供了对符号可见性的控制。“全局”选项卡下的“版本脚本”节点中列出的符号最终会被导出,而“本地”选项卡下列出的符号不会被导出。
仅仅为了指定要导出的符号而使用版本脚本机制是完全合法的。然而,强烈建议在这种情况下使用未命名的脚本版本节点,如图 10-26 所示的简单演示所示。
图 10-26。
Version script can be used as the most elegant way of controlling the symbol visibility, as it does not require any modifications of the source code
Windows 动态库版本控制
Windows 中的版本控制实现遵循与 Linux 中相同的原则。显著脱离现有运行时功能或需要重新构建客户端二进制文件的代码更改会导致主要的版本更改。不中断现有客户端二进制文件功能的所提供功能的添加/扩展符合次要版本更改的条件。
主要影响内部功能细节的代码更改在 Linux 中称为补丁,在 Windows 中称为构建版本。除了明显的命名差异,这两个概念之间没有实质性的区别。
DLL 版本信息
与 Linux 动态库一样,Windows 动态库(DLL)的版本信息是可选的。除非进行有意识的设计来指定此类信息,否则它不会作为 DLL 的一部分出现。然而,作为一个好的设计规则,所有主要的 DLL 供应商(当然从微软开始)都确保他们提供的动态库携带版本信息。当可用时,DLL 版本信息作为文件属性页上的专用选项卡提供,可以通过右键单击文件资源管理器窗格中的文件图标来检索,如图 10-27 所示。
图 10-27。
Example of DLL version information
指定 DLL 版本信息
为了说明 Windows DLL 版本控制的最重要方面,创建了一个演示 Visual Studio 解决方案,其中包含两个项目:
- VersionedDLL 项目,它生成提供了版本信息的 DLL
- VersionedDLLClientApp 项目,该项目构建加载版本化 DLL 并尝试检索其版本化信息的客户端应用程序
向 DLL 项目提供版本信息的通常方式是将专用版本资源元素添加到库资源文件中,如图 10-28 所示。
图 10-28。
Adding the version field to the project resource file
一旦将版本资源添加到 DLL 项目资源文件中,就可以通过 Visual Studio 资源编辑器查看和修改它。
如图 10-29 所示,版本信息提供了两个不同的版本组件,文件版本和产品版本。尽管在绝大多数真实生活场景中,这两个组件具有相同的值,但是在如何设置这些组件的值方面存在某些差异。如果一个 DLL 在多个项目中使用,它的文件版本号可能会明显大于它的产品版本号。
通常,当 DLL 刚刚创建时,版本(主要版本、次要版本、内部版本号)通常被设置为相当小且相当接近的值,例如 1.0.0。然而,在本例中,为了进行令人信服的演示,我特意选择不仅将版本信息设置为相当大的数值,而且使 FILEVERSION 值不同于 PRODUCTVERSION 值。
图 10-29。
Using the Visual Studio editor to set file version and product version information
当库建立时,通过编辑版本资源文件指定的版本信息可以通过右击文件浏览器窗格上的文件图标,并选择属性菜单项来查看(图 10-30 )。
图 10-30。
Set values appearing in the properties of built DLL binary file
查询和检索 DLL 版本信息
DLL 版本信息在许多情况下对几个相关方可能特别重要。其功能严重依赖于 DLL 版本的客户端二进制文件可能希望以编程方式检查 DLL 版本详细信息,以便采取适当的进一步操作。安装/部署包可以首先检索现有 dll 的版本信息,以便决定是否用同一文件的较新版本替换/覆盖现有 dll。最后,执行系统管理维护或故障排除任务的人员可能希望仔细查看 DLL 版本。
在这一节中,我将主要关注可以检索 DLL 版本信息的编程方式。
版本信息结构
在<shlwapi.h>
头文件中声明的 DLLVERSIONINFO 结构通常用于传递版本信息。图 10-31 显示了其布局细节。
图 10-31。
DLLVERSIONINFO structure
链接要求
需要访问版本相关功能的软件模块必须与version.dll
链接(即其导入库version.lib
必须在链接器输入列表中指定),如图 10-32 所示。
图 10-32。
Linking against version.lib (version.dll) is required
接下来将讨论检索 DLL 版本信息的方法。
优雅的方式:调用 DLL 的 DllGetVersion 函数
设计良好的 dll 通常导出DllGetVersion()
函数的实现,其签名遵循以下规范:
HRESULT CALLBACK DllGetVersion( DLLVERSIONINFO *pdvi);
这就是在 MSDN 文献中提到的 http://msdn.microsoft.com/enus/library/windows/desktop/bb776404(v=vs.85).aspx
。Microsoft 提供的 dll 通常提供预期的功能。
对于自定义设计的 dll 来说,实现它也并不复杂。这是配方的概要:函数原型必须被正确地声明和导出,如清单 10-20 以及图 10-33 所示。
清单 10-20。VersionedDll.h
// The following ifdef block is the standard way of creating macros which make exporting
// from a DLL simpler. All files within this DLL are compiled with the VERSIONEDDLL_EXPORTS
// symbol defined on the command line. This symbol should not be defined on any project
// that uses this DLL. This way any other project whose source files include this file see
// VERSIONEDDLL_API functions as being imported from a DLL, whereas this DLL sees symbols
// defined with this macro as being exported.
#ifdef VERSIONEDDLL_EXPORTS
#define VERSIONEDDLL_API __declspec(dllexport)
#else
#define VERSIONEDDLL_API __declspec(dllimport)
#endif
#include <Shlwapi.h>
VERSIONEDDLL_API HRESULT CALLBACK DllGetVersion(DLLVERSIONINFO* pdvi);
图 10-33。
Properly exporting DllGetVersion() function from DLL
有几种方法可以实现该功能。
DLLVERSIONINFO
结构成员可以被设置为预定的一组值。最好以参数化常量(而不是文字常量)的形式保存版本值。- 可以通过加载 DLL 资源、提取版本信息字符串以及解析出关于主要版本、次要版本、内部版本的细节来填充
DLLVERSIONINFO
结构。
清单 10-21 说明了这两种方法的结合。如果版本资源检索失败,则可以返回预定值。(为了简单起见,清单中使用了文字常量。我们都知道可以用更结构化的方式来完成)。
清单 10-21。VersionedDLL.cpp
#define SERVICE_PACK_HOTFIX_NUMBER (16385)
VERSIONEDDLL_API HRESULT CALLBACK DllGetVersion(DLLVERSIONINFO* pdvi)
{
if(pdvi->cbSize != sizeof(DLLVERSIONINFO) &&
pdvi->cbSize != sizeof(DLLVERSIONINFO2))
{
return E_INVALIDARG;
}
if(FALSE == extractVersionInfoFromThisDLLResources(pdvi))
{
// should not happen that we end up here,
// but just in case - try to save the day
// by sticking in the actual version numbers
// TBD: use parametrized value instead of literals
pdvi->dwMajorVersion = 4;
pdvi->dwMinorVersion = 1;
pdvi->dwBuildNumber = 7;
pdvi->dwPlatformID = DLLVER_PLATFORM_WINDOWS;
}
if(pdvi->cbSize == sizeof(DLLVERSIONINFO2))
{
DLLVERSIONINFO2 *pdvi2 = (DLLVERSIONINFO2*)pdvi;
pdvi2->dwFlags = 0;
pdvi2->ullVersion = MAKEDLLVERULL(pdvi->dwMajorVersion,
pdvi->dwMinorVersion,
pdvi->dwBuildNumber,
SERVICE_PACK_HOTFIX_NUMBER);
}
return S_OK;
}
清单 10-22 给出了从 DLL 资源中提取版本信息的函数的细节。
清单 10-22。VersionedDLL.cpp(上半部分)
extern HMODULE g_hModule;
BOOL extractVersionInfoFromThisDLLResources(DLLVERSIONINFO* pDLLVersionInfo)
{
static WCHAR fileVersion[256];
LPWSTR lpwstrVersion = NULL;
UINT nVersionLen = 0;
DWORD dwLanguageID = 0;
BOOL retVal;
if(NULL == pDLLVersionInfo)
return FALSE;
HRSRC hVersion = FindResource(g_hModule,
MAKEINTRESOURCE(VS_VERSION_INFO),
RT_VERSION );
if(NULL == hVersion)
return FALSE;
HGLOBAL hGlobal = LoadResource( g_hModule, hVersion );
if(NULL == hGlobal)
return FALSE;
LPVOID lpstrFileVersionInfo = LockResource(hGlobal);
if(NULL == lpstrFileVersionInfo)
return FALSE;
wsprintf(fileVersion, L"\\VarFileInfo\\Translation");
retVal = VerQueryValue ( lpstrFileVersionInfo,
fileVersion, (LPVOID*)&lpwstrVersion, (UINT *)&nVersionLen);
if(retVal && (4 == nVersionLen))
{
memcpy(&dwLanguageID, lpwstrVersion, nVersionLen);
wsprintf(fileVersion, L"\\StringFileInfo\\%02X%02X%02X%02X\\ProductVersion",
(dwLanguageID & 0xff00)>>8,
dwLanguageID & 0xff,
(dwLanguageID & 0xff000000)>>24,
(dwLanguageID & 0xff0000)>>16);
}
else
wsprintf(fileVersion,L"\\StringFileInfo\\%04X04B0\\ProductVersion",GetUserDefaultLangID());
if(FALSE == VerQueryValue (lpstrFileVersionInfo,
fileVersion,
(LPVOID*)&lpwstrVersion,
(UINT *)&nVersionLen))
{
return FALSE;
}
LPWSTR pwstrSubstring = NULL;
WCHAR* pContext = NULL;
pwstrSubstring = wcstok_s(lpwstrVersion, L".", &pContext);
pDLLVersionInfo->dwMajorVersion = _wtoi(pwstrSubstring);
pwstrSubstring = wcstok_s(NULL, L".", &pContext);
pDLLVersionInfo->dwMinorVersion = _wtoi(pwstrSubstring);
pwstrSubstring = wcstok_s(NULL, L".", &pContext);
pDLLVersionInfo->dwBuildNumber = _wtoi(pwstrSubstring);
pwstrSubstring = wcstok_s(NULL, L".", &pContext);
pDLLVersionInfo->dwPlatformID = _wtoi(pwstrSubstring);
pDLLVersionInfo->cbSize = 5*sizeof(DWORD);
UnlockResource( hGlobal );
FreeResource( hGlobal );
return TRUE;
}
菜谱的重要部分是捕获 DLL 的模块句柄值的最佳时刻是调用DllMain()
函数的时候,如清单 10-23 所示。
清单 10-23。dllmain.cpp
// dllmain.cpp : Defines the entry point for the DLL application.
#include "stdafx.h"
HMODULE g_hModule = NULL;
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_DETACH:
g_hModule = NULL;
break;
case DLL_PROCESS_ATTACH:
g_hModule = hModule;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
break;
}
return TRUE;
}
最后,清单 10-24 显示了客户机二进制程序如何检索版本信息。
清单 10-24。main.cpp(客户端应用)
BOOL extractDLLProductVersion(HMODULE hDll, DLLVERSIONINFO* pDLLVersionInfo)
{
if(NULL == pDLLVersionInfo)
return FALSE;
DLLGETVERSIONPROC pDllGetVersion;
pDllGetVersion = (DLLGETVERSIONPROC) GetProcAddress(hDll, "DllGetVersion");
if(NULL == pDllGetVersion)
return FALSE;
ZeroMemory(pDLLVersionInfo, sizeof(DLLVERSIONINFO));
pDLLVersionInfo->cbSize = sizeof(DLLVERSIONINFO);
HRESULT hr = (*pDllGetVersion)(pDLLVersionInfo);
if(FAILED(hr))
return FALSE;
return TRUE;
}
残酷的选择:直接检查文件版本
如果碰巧 DLL 没有导出DllGetVersion()
函数,您可能会求助于提取嵌入在文件资源中的版本信息的更残酷的措施。实现这种方法的全部工作都在客户端二进制文件上。通过将下面的代码与前面的方法描述中的代码进行比较,可以很容易地得出结论,基于从文件中加载资源、提取版本字符串以及提取其版本号,应用了相同的方法(参见清单 10-25)。
清单 10-25。main.cpp(客户端应用)
BOOL versionInfoFromFileVersionInfoString(LPSTR lpstrFileVersionInfo,
DLLVERSIONINFO* pDLLVersionInfo)
{
static WCHAR fileVersion[256];
LPWSTR lpwstrVersion = NULL;
UINT nVersionLen = 0;
DWORD dwLanguageID = 0;
BOOL retVal;
if(NULL == pDLLVersionInfo)
return FALSE;
wsprintf(fileVersion, L"\\VarFileInfo\\Translation");
retVal = VerQueryValue ( lpstrFileVersionInfo,
fileVersion, (LPVOID*)&lpwstrVersion, (UINT *)&nVersionLen);
if(retVal && (4 == nVersionLen))
{
memcpy(&dwLanguageID, lpwstrVersion, nVersionLen);
wsprintf(fileVersion, L"\\StringFileInfo\\%02X%02X%02X%02X\\FileVersion",
(dwLanguageID & 0xff00)>>8,
dwLanguageID & 0xff,
(dwLanguageID & 0xff000000)>>24,
(dwLanguageID & 0xff0000)>>16);
}
else
wsprintf(fileVersion,L"\\StringFileInfo\\%04X04B0\\FileVersion",GetUserDefaultLangID());
if(FALSE == VerQueryValue (lpstrFileVersionInfo,
fileVersion,
(LPVOID*)&lpwstrVersion,
(UINT *)&nVersionLen))
{
return FALSE;
}
LPWSTR pwstrSubstring = NULL;
WCHAR* pContext = NULL;
pwstrSubstring = wcstok_s(lpwstrVersion, L".", &pContext);
pDLLVersionInfo->dwMajorVersion = _wtoi(pwstrSubstring);
pwstrSubstring = wcstok_s(NULL, L".", &pContext);
pDLLVersionInfo->dwMinorVersion = _wtoi(pwstrSubstring);
pwstrSubstring = wcstok_s(NULL, L".", &pContext);
pDLLVersionInfo->dwBuildNumber = _wtoi(pwstrSubstring);
pwstrSubstring = wcstok_s(NULL, L".", &pContext);
pDLLVersionInfo->dwPlatformID = _wtoi(pwstrSubstring);
pDLLVersionInfo->cbSize = 5*sizeof(DWORD);
return TRUE;
}
BOOL extractDLLFileVersion(DLLVERSIONINFO* pDLLVersionInfo)
{
DWORD dwVersionHandle = 0;
DWORD dwVersionInfoSize = GetFileVersionInfoSize (DLL_FILENAME, &dwVersionHandle);
if(0 == dwVersionInfoSize)
return FALSE;
LPSTR lpstrFileVersionInfo = (LPSTR) malloc (dwVersionInfoSize);
if (lpstrFileVersionInfo == NULL)
return FALSE;
BOOL bRetValue = GetFileVersionInfo(DLL_FILENAME,
dwVersionHandle,
dwVersionInfoSize,
lpstrFileVersionInfo);
if(bRetValue)
{
bRetValue = versionInfoFromFileVersionInfoString(lpstrFileVersionInfo, pDLLVersionInfo);
}
free (lpstrFileVersionInfo);
return bRetValue;
}
int main(int argc, char* argv[])
{
//
// Examining the DLL file ourselves
//
memset(&dvi, 0, sizeof(DLLVERSIONINFO));
if(extractDLLFileVersion(&dvi))
{
printf("DLL File Version (major, minor, build, platformID) = %d.%d.%d.%d\n",
dvi.dwMajorVersion, dvi.dwMinorVersion,
dvi.dwBuildNumber, dvi.dwPlatformID);
}
else
printf("DLL File Version extraction failed\n");
FreeLibrary(hDll);
return 0;
}
最后,图 10-34 显示了运行演示应用程序的结果,演示了两种方法(DLL 查询和“暴力破解”)。
图 10-34。
Programmatically extracting DLL product version as well as the file version
十一、动态库:杂项主题
Abstract
在理解了动态库概念背后最深刻的思想之后,在深入了解软件专业人员日常处理库的工具箱的细节之前,现在是仔细研究几个遗留问题的好时机。首先,让我们仔细看看插件的概念,它是无缝扩展框架基本功能的无所不在的机制。然后,我将指出源于动态库概念的一些实际含义。最后,我将仔细看看开发人员在日常工作中可能遇到的一些杂七杂八的话题。
在理解了动态库概念背后最深刻的思想之后,在深入了解软件专业人员日常处理库的工具箱的细节之前,现在是仔细研究几个遗留问题的好时机。首先,让我们仔细看看插件的概念,它是无缝扩展框架基本功能的无所不在的机制。然后,我将指出源于动态库概念的一些实际含义。最后,我将仔细看看开发人员在日常工作中可能遇到的一些杂七杂八的话题。
插件概念
动态链接的进步可能带来的最重要的概念是插件的概念。这个概念本身没有什么难以理解的,因为我们在日常生活中会遇到很多这样的场景,其中大多数不需要任何技术背景。插入式概念的一个很好的例子是钻头和各种钻头,它们可以根据特定情况的需要和最终用户的决定而改变(图 11-1 )。
图 11-1。
Drill and bits, an everyday example of the plug-in concept
插件的软件概念遵循同样的原则。基本上,有一个主要应用程序(或执行环境)对某个处理主题执行某个动作(例如,修改图片属性的照片处理应用程序),还有一组模块专门对处理主题执行非常具体的动作(例如,模糊滤镜、锐化滤镜、棕褐色滤镜、颜色对比度滤镜、高通滤镜、平均滤镜等)。),这是一个非常容易理解的概念。
但这还不是全部。
并不是所有包含旗舰应用程序和相关模块的系统都应该被称为“插件架构”为了让架构支持插件模型,还需要满足以下要求:
- 添加或删除插件不应要求重新编译应用程序;相反,应用程序应该能够在运行时确定插件的可用性。
- 模块应该通过某种运行时可加载机制来导出它们的功能。
- 无论最终用户在运行时可以使用哪些插件,系统都应该是正常运行的。
实际上,上述要求通常通过以下设计决策来支持:
- 插件被实现为动态库。不管内部功能如何,所有插件动态库都导出标准化接口(一组允许应用程序控制插件执行的函数)。
- 应用程序通过动态库加载过程加载插件。通常支持以下两个选项:
- 应用程序查看预定义的文件夹,并尝试加载它在运行时找到的所有动态库。在加载时,它试图找到与插件期望导出的接口相对应的符号。如果没有找到这些符号(或者只找到了其中的一部分),插件库就会被卸载。
- 用户通过运行时的专用 GUI 选项指定插件位置,并告诉应用程序加载插件并开始提供其功能。
出口规则
并不存在针对每一个插件架构的严格规则。然而,确实存在一套常识性的指导原则。根据解释 C++ 语言对链接器问题的影响的段落,大多数插件体系结构倾向于遵循最简单的可能方案,其中插件导出指向由 C 链接函数组成的接口的指针。
尽管插件的内部功能可以实现为 C++ 类,但这样的类通常实现由其动态库容器导出的接口,并且将指向类实例的指针(转换为指向接口的指针)传递给应用程序是惯例。
流行的插件架构
支持插件架构的流行程序种类繁多,例如(但不限于):
- 图像处理应用程序(Adobe Photoshop 等。)
- 视频处理应用(索尼维加斯等。)
- 声音处理(斯坦伯格 VST 插件架构,在所有主流音频编辑器中得到普遍支持)
- 多媒体框架(GStreamer,avisynth)和流行的应用程序(Winamp,mplayer)
- 文本编辑器(其中大多数都有提供特定功能的插件)
- 软件开发集成开发环境(ide)通过插件支持多种功能
- 版本控制系统的前端 GUI 应用程序
- Web 浏览器(NPAPI 插件架构)
- 等等。
对于这些插件体系结构中的每一个,通常都有一个公开的插件接口文档,详细规定了应用程序和插件之间的交互。
提示和技巧
要完全理解动态库的概念,最后一步需要您后退一步,将您到目前为止所学的一切整理成另一组简单的事实。在日常设计实践中,以不同的方式表述事物有时可能意味着很大的不同。
使用动态库的实际意义
在检查了关于动态库的所有细节之后,关于它们最有力的事实是,针对动态库的链接是一种基于承诺的链接。事实上,在构建阶段,客户端可执行程序所担心的只是动态库符号。只有在运行时加载阶段,动态库部分的内容(代码、数据等)才会被加载。)来玩。从所描述的一系列情况中,有几个现实生活中的含义。
条块分割,更快发展
动态库的概念给了程序员很大的自由。只要对客户机可执行文件重要的符号集不变,程序员就可以自由地修改实际的动态库代码,只要需要就可以。
这个简单的事实对编程的日常程序有巨大的影响,因为它会大大减少不必要的编译时间。通过使用动态库,程序员可以减少对动态库本身重新构建代码的需要,而不必在代码发生微小变化时重新编译整个代码。难怪程序员经常决定将正在开发的代码放在动态库中,至少直到开发完成。
运行时快速替换能力
在构建时,客户机二进制文件不需要完全成熟的动态库,所有的功能都已经就绪。相反,客户端二进制文件在构建时真正需要的是动态库的符号集——仅此而已。
这真的很有趣。请深吸一口气,让我们看看这个说法到底是什么意思。
您在构建时使用的动态库二进制文件和在运行时加载的动态库文件可能在每个方面都有很大的不同,只有一点除外:符号必须匹配。
换句话说(是的,这是真的,也正是它的本意),为了静态感知的构建目的,你可以使用动态库,它的代码(flash 和 blood)还没有实现,但是它的符号(骨架)已经处于它们的最终形状。
或者,您可以使用一个您知道其代码会改变的库,只要您确信导出的符号集不会改变。
或者,您可以在构建时使用适合一种特定风格(如语言包)的动态库,但在运行时与另一个动态库链接—只要两个动态库二进制文件导出相同的符号集。
这真的非常非常有趣。我们如何从这一重要发现中受益的一个极端例子发生在 Android 原生编程领域。在开发一个模块(动态库或原生应用程序)的过程中,整个开发团队不必要且不明智地采取耗时的方式将他们的源代码添加到巨大的 Android 源代码树中,这种情况并不少见。
或者,更有效的方法是开发一个模块作为独立的 Android 项目,与 Android 源代码树无关。在几分钟内,完成构建阶段所需的 Android 本地动态库可以从任何工作的 Android 设备/手机中复制(在 Android 行话中称为“adb pulled ”),并添加到项目构建结构中。以前需要几个小时,现在构建过程最多只需要几分钟。
即使从最近的可用 Android 手机中提取的动态库的代码(?? 部分)可能与在 Android 源代码树中找到的代码明显不同,但符号列表在两个动态库中很可能是相同的。显然,从 Android 设备上拉取的快速替换库可能满足构建需求,而在运行时,将加载动态库二进制文件中“正确的那个”。
杂项提示
在本章的剩余部分,我将讲述以下有趣的知识:
- 将动态库转换为可执行文件
- Windows 库的冲突运行时内存处理方案
- 链接器弱符号
将动态库转换为可执行文件
正如在前面关于动态库的介绍性讨论中所指出的,动态库和可执行文件之间的区别在于,后者有启动例程,允许内核实际开始执行。在所有其他方面,特别是如果与静态库相比,似乎动态库和可执行文件具有相同的性质,例如二进制文件,其中所有的引用都已被解析。
鉴于如此多的相似之处和如此少的差异,有可能将动态库转换为可执行文件吗?
这个问题的答案是肯定的。这在 Linux 上肯定是可能的(我还在寻找在 Windows 上证实这一说法)。事实上,实现 C 运行时库(libc.so
)的库实际上是真正可执行的。当通过在 shell 窗口中键入文件名来调用时,您会得到如图 11-2 所示的响应。
图 11-2。
Running libc.so as executable file
接下来自然出现的问题是如何实现这个库以使其可执行?
以下食谱使之成为可能:
- 实现动态库中的主函数——这个函数的原型是
int main(int argc, char* argv[];
- 声明标准的
main()
函数作为库入口点。传递-e
链接器标志是完成这项任务的方法。
gcc -shared
-Wl,-e,main -o<libname>
- 将
main()
功能变为不返回功能。这可以通过在main()
函数的最后一行插入_exit(0)
调用来实现。 - 将解释器指定为动态链接器。下面一行代码可以做到这一点:
#ifdef __LP64__
const char service_interp[] __attribute__((section(".interp"))) =
"/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2";
#else
const char service_interp[] __attribute__((section(".interp"))) =
"/lib/ld-linux.so.2";
#endif
- 构建了没有优化的库(带有
-O0
编译器标志)。
我们制作了一个简单的演示项目来说明这一思想。为了证明动态库的真正双重性(即,即使它现在可以作为可执行文件运行,它仍然能够作为常规动态库运行),演示项目不仅包含演示动态库,还包含动态加载它并调用其printMessage()
函数的可执行文件。清单 11-1 说明了可执行共享库项目的细节:
清单 11-1。
文件:executableSharedLib.c
#include "sharedLibExports.h"
#include <unistd.h> // needed for the _exit() function
// Must define the interpretor to be the dynamic linker
#ifdef __LP64__
const char service_interp[] __attribute__((section(".interp"))) =
"/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2";
#else
const char service_interp[] __attribute__((section(".interp"))) =
"/lib/ld-linux.so.2";
#endif
void printMessage(void)
{
printf("Running the function exported from the shared library\n");
}
int main(int argc, char* argv[])
{
printf("Shared library %s() function\n", __FUNCTION__);
// must make the entry point function to be a 'no-return' function type
_exit(0);
}
文件:build.sh
g++ -Wall -O0 -fPIC -I./exports/ -c src/executableSharedLib.c -o src/executableSharedLib.o
g++ -shared``-Wl,-e,main
清单 11-2 展示了演示应用程序的细节,其目的是证明我们的共享库在变得可执行的同时并没有失去它原来的功能:
清单 11-2。
文件:main.c
#include <stdio.h>
#include "sharedLibExports.h"
int main(int argc, char* argv[])
{
printMessage();
return 0;
}
文件:build.sh
g++ -Wall -O2 -I../sharedLib/exports/ -c src/main.c -o src/main.o
g++ ./src/main.o -lpthread -lm -ldl -L../deploy -lexecutablesharedlib -Wl,-Bdynamic -Wl,-R../deploy -o demoApp
当您尝试使用它时,会出现图 11-3 所示的结果。
图 11-3。
Illustrating dual nature (dynamic lib, executable) of the demo library
项目源代码 tarball 提供了更多的细节。
Windows 库的运行时内存处理冲突
一般来说,一旦动态库被加载到进程中,它就成为进程的一个合法部分,并且几乎继承了进程的所有特权,包括对堆(运行动态内存分配的内存池)的访问。由于这些原因,动态库函数分配内存缓冲区,并将其传递给属于另一个动态库(或可执行代码)的函数是完全正常的,当不再需要内存时,可以在那里释放内存。
然而,整个故事有一个特殊的转折,需要仔细研究。
通常,不管有多少动态库被加载到进程中,它们都链接到 C runtime library 的同一个实例,该实例提供内存分配基础结构 malloc 和 free(或者对于 C++,new 和 delete ),以及跟踪分配的内存缓冲区的列表实现。如果这个基础设施对每个进程都是唯一的,那么我们就没有理由认为所描述的任何人都可以释放由其他人分配的内存的方案不可行。
然而,有趣的情况可能发生在 Windows 编程领域。Visual Studio 提供了(至少)两个基本 dll,所有可执行文件(应用程序/动态库)都是在这两个 dll 的基础上构建的——常见的 C 运行时库(msvcrt.dll
)以及微软基础类(MFC)库(mfx42.dll
)。有时,项目需求可能会要求混合和匹配构建在不同基 dll 上的 dll,这可能会立即导致与预期规则非常不愉快的偏差。
为了清楚起见,我们假设在同一个项目中,您在运行时加载了以下两个 DLL:DLL“A”,构建在msvcrt.dll
上,以及 DLL“B”,构建在MFC DLL
上。现在让我们假设 DLL“A”分配内存缓冲区并将其传递给 DLL“B”,DLL“B”使用它们,然后释放它们。在这种情况下,试图释放内存将导致崩溃(异常如图 11-4 所示)。
图 11-4。
Error message dialog typical for between-DLLs-conflict memory issues
问题的原因是围绕堆内存的可用池有两个簿记机构;C 运行时 DLL 和 MFC DLL 都维护它们自己的、单独的已分配缓冲区列表(参见图 11-5 )。
图 11-5。
The mechanism of runtime problems caused by unrelated memory allocation bookkeepings maintained by different DLLs
通常,当发送缓冲区进行解除分配时,内存分配基础结构会搜索已分配内存地址的列表,如果在列表中找到了为解除分配而传递的缓冲区,则可以成功完成解除分配。但是,如果分配的缓冲区保存在一个列表中(例如,由 C 运行库 DLL 保存)并被传递给另一个列表(例如,由 MFC DLL 保存),则在列表中找不到缓冲区的内存地址,并且解除分配调用将引发异常。即使您以静默方式处理异常,应用程序是否能够将缓冲区发送到正确的 DLL 进行释放也是有疑问的,从而导致内存泄漏。
更糟糕的是,几乎没有一个通常的内存受限的检查工具能够检测和报告任何错误。在为工具辩护时,您可以注意到,在这种特殊情况下,实际上没有发生任何典型的内存违规(比如写超过缓冲区边界、覆盖缓冲区地址等)。所有这些都使得问题难以处理,除非您事先对潜在的问题有所了解,否则很难确定原因,更不用说问题的解决方案了。
这个问题的解决方案非常简单:在一个 DLL 中分配的内存缓冲区最终应该传递回同一个 DLL 进行释放。唯一的问题是,为了应用这个简单的解决方案,您需要访问两个 dll 的源代码,这可能并不总是可行的。
链接器弱符号解释
链接器弱符号的思想本质上类似于面向对象语言的首要特征(这是多态原理的表现之一)。当应用于链接领域时,弱符号的概念实际上意味着以下内容:
- 编译器(最明显的是,
gcc
)支持语言构造,允许你声明一个符号(一个函数和/或一个全局或函数静态变量)为弱。
下面的示例演示如何将 C 函数声明为弱符号:
int``__attribute__((weak))
- 链接器获取这些信息,以一种非常独特的方式处理这样的符号。
- 如果在链接过程中出现了另一个同名符号,并且没有声明为弱符号,则该符号将替换弱符号。
- 如果在链接过程中出现了另一个同名的符号,并且被声明为弱符号,链接器可以自由决定实际实现这两个符号中的哪一个。
- 两个同名的非弱(即强)符号的出现被认为是错误的(该符号已经定义)。
- 如果在链接过程中没有出现其他同名的符号,链接器可能不会实现这样的符号。如果符号是一个函数指针,保护代码是必须的(事实上,强烈建议总是这样做)。
在 Winfred C.H. Lu 在 http://winfred-lu.blogspot.com/2009/11/understand-weak-symbols-by-examples.html
发表的博客文章中可以找到弱符号概念的一个很好的说明。安迪·穆雷在 www.embedded-bits.co.uk/2008/gcc-weak-symbols/
的博客中描述了这些功能何时会派上用场的真实场景。
十二、Linux 工具箱
Abstract
本章的目的是向读者介绍一套用于分析 Linux 二进制文件内容的工具(实用程序和其他方法)。
本章的目的是向读者介绍一套用于分析 Linux 二进制文件内容的工具(实用程序和其他方法)。
快速洞察工具
使用file
和/或size
实用程序可以最简单、最直接地了解二进制文件的本质。
文件实用程序
简单地命名为file
( http://linux.die.net/man/1/file
)的命令行实用程序用于查找任何文件类型的详细信息。它可以很快派上用场,因为它确定了关于二进制文件的最基本的信息(图 12-1 )。
图 12-1。
Using the file utility
尺寸实用程序
名为size
( http://linux.die.net/man/1/size
)的命令行实用程序可用于即时了解 ELF 部分的字节长度(图 12-2 )。
图 12-2。
Using the size utility
详细的分析工具
通过依赖于统称为binutils
( www.gnu.org/software/binutils/
)的实用程序集合,可以获得对二进制文件属性的详细了解。我将举例说明ldd
、nm
、objdump
和readelf
实用程序的使用。尽管它在形式上不属于 binutils,但名为 ldd 的 shell 脚本(由 Roland McGrath 和 Ulrich Drepper 编写)非常适合放在工具箱的同一个隔间中,因此也将说明它的用法。
掺杂漏极
命令ldd
( http://linux.die.net/man/1/ldd
)是一个非常有用的工具,因为它显示了动态库的完整列表,客户端二进制程序将尝试静态感知这些动态库的负载(即,负载时间依赖性)。
当分析加载时依赖关系时,ldd
首先检查二进制文件,试图定位 ELF 格式字段,其中最直接的依赖关系列表已经被链接器打上印记(如构建过程中链接器命令行所建议的)。
对于每个名字嵌入在客户端二进制文件中的动态库,ldd
试图根据运行时库位置搜索规则定位它们实际的二进制文件(详见第七章中的)。一旦定位了最直接的依赖关系的二进制文件,ldd
运行递归过程的下一级,试图找到它们的依赖关系。对每一个“第二代”依赖者,ldd
进行另一轮调查,等等。
一旦所述的递归搜索完成,ldd
收集报告的依赖项列表,删除重复项,并打印出结果(如图 12-3 )。
图 12-3。
Using the ldd utility
在使用ldd
之前,了解它的局限性很重要:
ldd
无法通过调用dlopen()
函数来识别运行时动态加载的库。为了获得这类信息,必须采用不同的方法。更多详情请访问第十三章。- 根据其手册页,运行某些
ldd
版本实际上可能代表一种安全威胁。
更安全的 ldd 替代品
如手册页中所述:
但是,请注意,在某些情况下,某些版本的 ldd 可能试图通过直接执行程序来获取依赖信息。因此,您不应该在不可信的可执行文件上使用 ldd,因为这可能导致任意代码的执行。在处理不受信任的可执行文件时,一个更安全的替代方法如下(也如图 12-4 所示):
$ objdump -p /path/to/program | grep NEEDED
图 12-4。
Using objdump to (only partially) substitute the ldd utility
使用readelf
实用程序可以获得相同的结果(图 12-5 ):
$ readelf -d /path/to/program | grep NEEDED
图 12-5。
Using readelf to (only partially) substitute the ldd utility
显然,在依赖关系的分析中,这两种工具都不会比仅仅从二进制文件中读出最直接的依赖关系列表更深入。从安全的角度来看,这绝对是一种更安全的寻找答案的方法。
然而,所提供的列表远没有ldd
通常所提供的那样详尽。为了匹配它,您可能需要自己进行递归搜索。
纳米
nm
实用程序( http://linux.die.net/man/1/nm
)用于列出一个二进制文件的符号(图 12-6 )。打印出符号的输出行还指示了符号类型。如果二进制文件包含 C++ 代码,则默认情况下会以损坏的形式打印符号。以下是一些最常用的输入参数组合:
图 12-7。
Using the nm utility to list mangled symbols
$ nm -D --no-demangle <path-to-binary>
打印共享库的动态符号,并严格要求符号不混乱(图 12-7 )。
图 12-6。
Using the nm utility to list unmangled symbols
$ nm <path-to-binary>
列出一个二进制文件的所有符号。在共享库的情况下,它不仅意味着导出(的.dynamic
部分),还意味着所有其他符号。如果库已经被剥离(通过使用strip
命令),没有参数的nm
将报告没有找到符号。$ nm -D <path-to-binary>
仅列出动态部分中的符号(即共享库的导出/可见符号)。$ nm -C <path-to-binary
以分解的格式列出符号(图 12-6 )。
该选项对于检测设计共享库时最常见的错误非常有用——当设计者忘记了 ABI 函数声明/定义中的extern
“C”说明符时(这恰好是客户端二进制文件期望找到的)。
-
当您想要列出库的未定义符号时,
$ nm -u <path-to-binary>
是有用的(即,库本身不包含的符号,但指望在运行时提供,可能由一些其他加载的动态库提供)。 -
$ nm -A <library-folder-path>/* | grep symbol-name
is useful when you search for a symbol in multitude of binaries located in the same folder, as-A
option prints the name of each library in which a symbols is found (Figure 12-8) .图 12-8。
Using nm to recursively search for the presence of a symbol in the set of libraries .
位于 www.thegeekstuff.com/2012/03/linux-nm-command/
的网页文章列出了 10 个最有用的nm
命令。
objdump(对象转储)
objdump
( http://linux.die.net/man/1/objdump
)实用程序可能是最通用的二元分析工具。按时间顺序,它比readelf
更古老,这在很多情况下与其能力相当。objdump
的优势在于除了 ELF,还支持大约 50 种其他二进制格式。而且,它的拆卸能力比readelf
更好。
以下章节涵盖了最常使用objdump
的任务。
解析 ELF 标头
objdump
-f
命令行选项用于深入了解目标文件的文件头。标题提供了大量有用的信息。特别是,可以快速获得二进制类型(目标文件/静态库对动态库对可执行文件)以及关于入口点的信息(?? 段的开始)(图 12-9 )。
图 12-9。
Using objdump to parse the ELF header of various binary file types
当检查静态库时,objdump -f
打印出在库中找到的每个目标文件的文件头。
列出和检查部分
objdump -h
选项用于列出可用的截面(图 12-10 )。
图 12-10。
Using objdump to list the binary file sections
当涉及到部分检查时,objdump
为程序员最感兴趣的部分提供了专用的命令开关。在接下来的几节中,我将介绍一些著名的例子。
列出所有符号
运行objdump -t <path-to-binary>
提供完全等同于运行nm <path-to-binary>
的输出(图 12-11 )。
图 12-11。
Using objdump to list all symbols
仅列出动态符号
运行objdump -T <path-to-binary>
提供完全等同于运行nm -D <path-to-binary>
的输出(图 12-12 )。
图 12-12。
Using objdump to list only dynamic symbols
检查动态部分
运行objdump -p <path-to-binary>
检查动态部分(用于查找DT_RPATH
和/或DT_RUNPATH
设置)。请注意,在这种情况下,您关心显示输出的最后部分(图 12-13 )。
图 12-13。
Using objdump to examine the library dynamic section
检查重新安置部分
运行objdump -R <path-to-binary>
检查搬迁段(图 12-14 )。
图 12-14。
Using objdump to list the relocation section
检查数据部分
运行objdump -s -j <section name> <path-to-binary>
提供截面所携带值的十六进制转储。在图 12-15 中为.got
段。
图 12-15。
Using objdump to examine the data section
列出和检查细分市场
运行objdump -p <path-to-binary>
显示关于 ELF 二进制段的信息。请注意,只有显示输出的第一部分与该特定任务相关(图 12-16 )。
图 12-16。
Using objdump to list segments
反汇编代码
下面是一些如何使用objdump
反汇编代码的例子:
-
Disassembling and Intel style and interspersing the original source code (Figure 12-18) .
图 12-18。
Using objdump to disassemble the binary file (Intel syntax) .
-
Disassembling and specifying assembler notation flavor (Intel style in this case), as shown in Figure 12-17 .
图 12-17。
Using objdump to disassemble the binary file .
仅当二进制文件是为调试而构建时,该选项才有效(即,使用-g
选项)。
- 分解特定部分。
除了携带代码的.text
部分,二进制文件可能包含其他部分(。plt
为例),其中也包含代码。默认情况下,objdump
反汇编所有带有代码的部分。然而,在某些情况下,您可能对检查某个给定部分严格执行的代码感兴趣(图 12-19 )。
图 12-19。
Using objdump to disassemble a specific section
objdump nm 当量
objdump
可用于提供nm
命令的完全等效:
$ nm <path-to-binary>
等同于
$ objdump -t <path-to-binary>
$ nm -D <path-to-binary>
等同于
$ objdump -T <path-to-binary>
$ nm -C <path-to-binary>
等同于
$ objdump -C <path-to-binary>
readelf(读取 11)
readelf
( http://linux.die.net/man/1/readelf
)命令行实用程序提供了与objdump
实用程序几乎完全相同的功能。readelf 和 objdump 之间最显著的区别是
readelf
仅支持 ELF 二进制格式。另一方面,objdump
可以分析大约 50 种不同的二进制格式,包括 Windows PE/COFF 格式。readelf
不依赖于所有 GNU 目标文件解析工具所依赖的二进制文件描述符库(http://en.wikipedia.org/wiki/Binary_File_Descriptor_library
),从而提供了对 ELF 格式内容的独立洞察
接下来的两节提供了使用objdump
的最常见任务的概述。
解析 ELF 标头
readelf -h
命令行选项用于深入了解目标文件的文件头。标题提供了大量有用的信息。特别是,可以快速获得二进制类型(目标文件/静态库对动态库对可执行文件)以及关于入口点的信息(?? 段的开始)(图 12-20 )。
图 12-20。
Examples of using readelf to examine the ELF header of executable, shared library, and object file/static library
当检查静态库时,readelf -h
打印出在库中找到的每个目标文件的文件头。
列出和检查部分
readelf -S
选项用于列出可用的截面(图 12-21 )。
图 12-21。
Using readelf to list sections
当涉及到部分检查时,readelf
为程序员最感兴趣的部分提供了专用的命令开关,例如.symtab
、.dynsym
和.dynamic
部分。
列出所有符号
运行readelf --symbols
提供完全等同于运行nm <path-to-binary>
的输出(图 12-22 )。
图 12-22。
Using readelf to list all symbols
仅列出动态符号
运行readelf --dyn-syms
提供完全等同于运行nm -D <path-to-binary>
的输出(图 12-23 )。
图 12-23。
Using readelf to list dynamic symbols
检查动态部分
运行readelf -d
检查动态部分(用于查找DT_RPATH
和/或DT_RUNPATH
设置),如图 12-24 所示。
图 12-24。
Using readelf to display the dynamic section
检查搬迁部分
运行readelf -r
检查搬迁段,如图 12-25 所示。
图 12-25。
Using readelf to list relocation (.rel.dyn) section
检查数据部分
运行readelf -x
提供截面所携带值的十六进制转储。在图 12-26 中为.got
段。
图 12-26。
Using readelf to provide a hex dump of a section (the .got section in this example)
列出和检查细分市场
运行readelf --segments
显示关于 ELF 二进制段的信息(图 12-27 )。
图 12-27。
Using readelf to examine segments
检测调试版本
readelf
命令很好地支持显示二进制文件中包含的各种调试特定信息(图 12-28 )。
图 12-28。
Readelf provides the option to examine binary file debug information
为了快速确定二进制文件是否是为调试而构建的,在调试构建的情况下,使用任何可用选项运行readelf --debug-dump
的输出将由打印在stdout
上的许多行组成。相反,如果二进制文件不是为调试而构建的,输出将是一个空行。在二进制文件包含调试信息的情况下,限制输出喷涌的一个快速而实用的方法是将 readelf 输出通过管道传输到wc
命令:
$``readelf --debug-dump=line``<binary file path>
或者,可以使用下面的简单脚本以简单明了的文本形式显示readelf
的调查结果。它要求将二进制文件的路径作为输入参数传递。
file: isDebugVersion.sh
if``readelf --debug-dump=line
部署阶段工具
在成功构建二进制文件并开始考虑部署阶段的细节后,诸如chrpath
、patchelf
、strip
和ldconfig
等实用程序可能会派上用场。
chrpath
chrpath
命令行实用程序( http://linux.die.net/man/1/chrpath
)用于修改 ELF 二进制文件的rpath
( DT_RPATH
字段)。在“Linux 运行时库位置规则”一节的第七章中描述了runpath
字段背后的基本概念。
以下细节说明了chrpath
的使用(图 12-29 )以及一些限制(图 12-30 ):
- 它可用于在其原始字符串长度内修改
DT_RPATH
。 - 它可以用来删除现有的
DT_RPATH
字段。
但是,一定要谨慎!
图 12-30。
Limitations of the chrpath utility
图 12-29。
Using the chrpath utility to modify RPATH
- 如果
DT_RPATH
字符串最初为空,则不能用新的非空字符串替换。 - 可用于将
DT_RPATH
转换为DT_RUNPATH
。 - 它不能用更长的字符串替换现有的
DT_RPATH
字符串。
补丁精灵
有用的patchelf
( http://nixos.org/patchelf.html
)命令行实用程序目前不是标准存储库的一部分,但是可以从源代码 tarball 构建它。简单、基本的文档也是可用的。
该实用程序可用于设置和修改 ELF 二进制文件的runpath
( DT_RUNPATH
字段)。在第七章的“Linux 运行时库位置规则”一节中描述了runpath
字段背后的基本概念。
设置runpath
最简单的方法是发出如下命令:
$``patchelf --set-rpath
^
|
multiple paths can be defined,
separated by a colon (:)
patchelf
修改DT_RUNPATH
字段的能力远远超过了chrpath
修改DT_RPATH
字段的能力,因为它可以以任何想象得到的方式修改DT_RUNPATH
的字符串值(替换为更短或更长的字符串、插入多条路径、擦除等)。).
剥夺
strip
命令行实用程序( http://linux.die.net/man/1/strip
)可用于删除动态加载过程中不需要的所有库符号。在第七章的“导出 Linux 动态库符号”一节中演示了条带效果。
ldconfig
在第七章(专门讨论 Linux 运行时库位置规则)中,我指出了指定加载程序在运行时应该寻找库的路径的方法之一(尽管不是最高优先级)是通过使用ldconfig
缓存。
ldconfig
命令行实用程序( http://linux.die.net/man/8/ldconfig
)通常作为软件包安装过程的最后一步执行。当包含共享库的路径作为输入参数传递给ldconfig
时,它搜索共享库的路径,并更新它用于簿记的文件集:
- 包含标准扫描的文件夹列表的文件
/etc/ld.so.conf
- 文件
/etc/ld.so.cache
文件,包含在扫描作为输入参数传递的各种路径时发现的所有库的 ASCII 文本列表
运行时分析工具
通过使用诸如strace
、addr2line
,尤其是 GNU 调试器(gdb
)这样的工具,运行时问题的分析可能会受益。
失去了
strace
( http://linux.die.net/man/1/strace
)命令行实用程序跟踪进程发出的系统调用以及进程收到的信号。它有助于找出运行时依赖关系(即,不仅仅是ldd
命令适用的加载时依赖关系)。图 12-31 显示了典型的 strace 输出。
图 12-31。
Using the strace utility
addr2line
可以使用addr2line
( http://linux.die.net/man/1/addr2line
)命令行实用程序将运行时地址转换成源文件的信息和地址对应的行号。
如果(且仅当)二进制文件是为调试而构建的(通过传递-g -O0
编译器标志),当分析崩溃信息时,使用该命令可能非常有用,其中崩溃发生的程序计数器地址打印在终端屏幕上,如下所示:
#00 pc 0000d8cc6 /usr/mylibs/libxyz.so
在这样的控制台输出上运行addr2line
$ addr2line``-C -f -e
将产生如下所示的输出:
/projects/mylib/src/mylib.c: 45
gdb (GNU 调试器)
传说中的 GNU 调试器工具 gdb 可以用来执行运行时代码反汇编。在运行时反汇编代码的好处是所有的地址都已经被加载器解析过了,而且大部分地址都是最终的。
以下gdb
命令在运行时代码反汇编期间会很有用:
- 设置反汇编风味
- 拆卸
调用反汇编命令时,以下两个标志可能会派上用场:
图 12-33。
Interspersed (assembly and source code) disassembly flavor
-
/m
标志将汇编指令散布在 C/C++ 代码行中(如果有的话),如图 12-33 所示。 -
The
/r
flag requires that the assembler instructions be additionally shown in hexadecimal notation (Figure 12-32) .图 12-32。
Using gdb to show the disassembled code combined with hex values of instructions .
要组合这两个标志,请将它们组合在一起键入(即/rm
),而不是分开键入(即/r /m
),如图 12-34 所示。
图 12-34。
Combining /r and /m disassembly flags
静态库工具
与静态库相关的绝大多数任务都可以由归档器ar
实用程序来执行。通过使用ar
,您不仅可以将目标文件合并到静态库中,还可以列出其内容,删除单个目标文件,或者用新版本替换它们。
阿肯色州
以下简单的例子说明了使用ar
工具的通常阶段。演示项目由四个源文件(first.c
、second.c
、third.c
和fourth.c
)和一个可以被客户端二进制文件使用的导出头文件组成(如下面五个例子所示)。
first.c
#include "mystaticlibexports.h"
int first_function(int x)
{
return (x+1);
}
秒. c
#include "mystaticlibexports.h"
int fourth_function(int x)
{
return (x+4);
}
三. c
#include "mystaticlibexports.h"
int second_function(int x)
{
return (x+2);
}
第四. c
#include "mystaticlibexports.h"
int third_function(int x)
{
return (x+3);
}
mystaticlibexports . h .神秘主义者的出口
#pragma once
int first_function(int x);
int second_function(int x);
int third_function(int x);
int fourth_function(int x);
假设您已经通过编译每个源文件创建了目标文件:
$ gcc -Wall -c first.c second.c third.c fourth.c
下面的屏幕快照说明了处理静态库的各个阶段。
创建静态库
运行ar -rcs <library name> <list of object files>
将指定的目标文件合并到静态库中(图 12-35 )。
图 12-35。
Using ar to combine object files to static library
列出静态库对象文件
运行ar -t <library name>
打印出静态库携带的目标文件列表(图 12-36 )。
图 12-36。
Using ar to print out the list of static library’s object files
从静态库中删除目标文件
假设您想要修改文件first.c
(修复一个 bug,或者简单地添加额外的特性),并且暂时不希望您的静态库携带first.o object file
。从静态库中删除目标文件的方法是运行ar -d <library name> <object file to remove>
(图 12-37 )。
图 12-37。
Using ar to delete an object file from static library
将新的目标文件添加到静态库中
假设您对文件first.c
中所做的更改感到满意,并且已经重新编译了它。现在您想把新创建的目标文件first.o
放回静态库中。运行ar -r <library name> <object file to append>
基本上是将新的目标文件添加到静态库中(图 12-38 )。
图 12-38。
Using ar to add new object file to static library
请注意,目标文件在静态库中的顺序已经改变。新文件已被有效地添加到档案中。
恢复目标文件的顺序
如果您坚持让您的目标文件以代码更改前的原始顺序出现,您可以纠正它。运行ar -m -b <object file before> <library name> <object file to move>
完成任务(图 12-39 )。
图 12-39。
Using ar to restore the order of object files within the static library
十三、Linux 操作指南
Abstract
前一章回顾了 Linux 中可用的有用的分析工具,所以现在是提供同一主题的另一种观点的好时机。这一次的重点不是实用程序本身,而是展示如何完成一些最常执行的任务。
前一章回顾了 Linux 中可用的有用的分析工具,所以现在是提供同一主题的另一种观点的好时机。这一次的重点不是实用程序本身,而是展示如何完成一些最常执行的任务。
通常有多种方法来完成分析任务。对于本章中描述的每项任务,将提供完成任务的替代方法。
调试链接
调试链接阶段最有力的帮助可能是使用LD_DEBUG
环境变量(图 13-1 )。它不仅适用于测试构建过程,也适用于测试运行时的动态库加载。
操作系统支持一组预先确定的值,在运行所需的操作(构建或执行)之前,可以将LD_DEBUG
设置为这些值。列出它们的方法是键入
$ LD_DEBUG=help cat
图 13-1。
Using the LD_DEBUG environment variable to debug linking
与任何其他环境变量一样,有几种方法可以设置LD_DEBUG
的值:
- 立即,在调用链接器的同一行
- 终端外壳寿命期内一次
$ export LD_DEBUG=<chosen_option>
这可以通过以下方式逆转
$ unset LD_DEBUG
- 从外壳轮廓内(例如
bashrc
)文件,为每个终端会话设置它。除非您的日常工作是测试链接过程,否则这个选项可能不是最佳选项。
确定二进制文件类型
有几种简单的方法可以确定二进制类型:
file
实用程序(在它能处理的各种各样的文件类型中)提供了可能是最简单、最快和最优雅的方法来确定二进制文件的性质。- ELF 文件头分析提供了关于二进制文件类型的信息。运转
$ readelf -h <path-of-binary> | grep Type
将显示以下选项之一:
EXEC
(可执行文件)DYN
(共享对象文件)REL
(可重定位文件)
在静态库的情况下,REL
输出将为库携带的每个目标文件出现一次。
- EFL 标题分析可提供类似的分析,但报告不太详细。该命令的输出
$ objdump -f <path-of-binary>
将有一条包含下列值之一的线:
EXEC_P
(可执行文件)DYNAMIC
(共享对象文件)- 在目标文件的情况下,没有指明类型
在静态库的情况下,一个目标文件将为库携带的每个目标文件出现一次。
确定二进制文件入口点
确定二进制文件入口点是一项复杂的任务,从非常简单(对于可执行文件)到稍微复杂一些(在运行时确定动态库的入口点),这两者都将在本节中进行说明。
确定可执行文件入口点
可执行文件的入口点(即程序存储器映射中第一条指令的地址)可以通过以下任一方法确定
- ELF 文件头分析,它提供了二进制文件类型的详细信息。运转
$ readelf -h <path-of-binary> | grep Entry
将显示如下所示的一行:
Entry point address: 0x<address>
objdump
EFL 标题分析,可提供类似分析,但不太详细的报告。该命令的输出
$ objdump -f <path-of-binary> | grep start
看起来会像这样:
start address 0x<address>
确定动态库入口点
当寻找动态库的入口点时,调查并不简单。即使可以使用前面描述的方法之一,所提供的信息(通常是低值的十六进制数,如 0x390)也不是特别有用。假设动态库被映射到客户端二进制进程存储器映射中,则库的真正入口点可能仅在运行时被确定。
最简单的方法可能是在 gnu 调试器中运行加载动态库的可执行文件。如果设置了LD_DEBUG
环境变量,将会打印出关于加载的库的信息。您需要做的就是在main()
函数上设置断点。无论可执行文件是否是为调试而生成的,此符号都很可能存在。
在动态库以静态方式链接的情况下,当程序执行到达断点时,加载过程已经完成。
在运行时动态加载的情况下,最简单的方法可能是将大量的屏幕打印输出重定向到文件,以便以后进行可视化检查。
图 13-2 展示了依赖于LD_DEBUG
变量的方法。
图 13-2。
Determining the dynamic library entry point at runtimeList Symbols
列出符号
尝试列出可执行文件和库的符号时,可以遵循以下方法:
nm
效用readelf
效用
特别是,
- 运行以下命令可以获得所有可见符号的列表
$ readelf --symbols <path-to-binary>
- 可以通过运行以下命令来获得一个列表,其中只列出了出于动态链接目的而导出的符号
$ readelf --dyn-syms <path-to-binary>
objdump
效用
特别是,
- 运行以下命令可以获得所有可见符号的列表
$ objdump -t <path-to-binary>
- 可以通过运行以下命令来获得一个列表,其中只列出了出于动态链接目的而导出的符号
$ objdump -T <path-to-binary>
列出并检查部分
有几种方法可以获得关于二进制部分的信息。运行size
命令可以获得最快速和最基本的洞察。对于更结构化和更详细的洞察,您通常可以依赖像objdump
和/或readelf
这样的工具,后者是严格按照 ELF 二进制格式专门化的。通常,强制性的第一步是列出二进制文件中存在的所有部分。一旦获得这样的洞察力,就详细检查特定片段的内容。
列出可用的部分
ELF 二进制文件的节列表可以通过以下方法之一获得:
readelf
效用
$ readelf -S <path-to-binary>
objdump
效用
$ objdump -t <path-to-binary>
检查特定部分
到目前为止,最常检查的部分是包含链接器符号的部分。因此,已经开发了各种各样的工具来满足这一特定需求。出于同样的原因,尽管描述符号提取的段落属于检查各部分的大类,但是它已经作为单独的主题被首先提出。
检查动态部分
二进制文件的动态部分(特别是动态库)包含大量有趣的信息。列出此特定部分的内容可以通过以下方式之一来完成:
readelf
效用
$ readelf -d <path-to-binary>
objdump
效用
$ objdump -p <path-to-binary>
在可从动态部分提取的有用信息中,以下是极有价值的信息:
DT_RPATH
或DT_RUNPATH
字段的值- 动态库
SONAME
字段的值 - 所需动态库的列表(
DT_NEEDED
字段)
确定动态库是 PIC 还是 LTR
如果动态库是在没有-fPIC
编译器标志的情况下构建的,那么它的动态部分将包含TEXTREL
字段,否则该字段将不会出现。以下简单的脚本(pic_or_ltr.sh
)可以帮助您确定动态库是否是用-fPIC
标志构建的:
if readelf -d $1 | grep TEXTREL > /dev/null; \
then echo "library is LTR, built without the -fPIC flag"; \
else echo "library was built with -fPIC flag"; fi
检查搬迁部分
这项任务可以通过以下方式完成:
readelf
效用
$ readelf -r <path-to-binary>
objdump
效用
$ objdump -R <path-to-binary>
检查数据部分
这项任务可以通过以下方式完成:
readelf
效用
$ readelf -x <section name> <path-to-binary>
objdump
效用
$ objdump -s -j <section name> <path-to-binary>
列出并检查细分市场
这项任务可以通过以下方式完成:
readelf
效用
$ readelf --segments <path-to-binary>
objdump
效用
$ objdump -p <path-to-binary>
反汇编代码
在本节中,您将研究反汇编代码的不同方法。
反汇编二进制文件
这个特殊任务的最佳工具是objdump
命令。事实上,这可能是唯一一种readelf
不提供并行解决方案的情况。特别是,.text
部分可以通过运行来拆卸
$ objdump``-d
此外,您可以指定打印输出的风格(美国电话电报公司与英特尔)。
$ objdump -d``-M intel
如果您想查看散布在汇编指令中的源代码(如果有的话),您可以运行以下命令:
$ objdump -d -M intel``-S
最后,您可能希望分析给定部分中的代码。除了。以携带代码而臭名昭著的 section,其他一些 section(。例如plt
)可以包含源代码。
默认情况下,objdump
反汇编所有代码段。要指定要拆卸的单个部件,使用-j
选项:
$ objdump -d -S -M intel``-j .plt
分解正在运行的进程
最好的方法是依靠 gdb 调试器。请参考前一章专门介绍这个奇妙工具的部分。
标识调试版本
看起来,识别二进制文件是否是为调试而构建的(即,使用-g
选项)的最可靠的方法是依靠readelf
工具。尤其是跑步
$ readelf --debug-dump=line <path-to-binary>
在二进制文件的调试版本的情况下,将提供非空输出。
列出加载时相关性
要列出可执行文件(应用程序和/或共享库)在加载时所依赖的共享库集合,请仔细阅读关于ldd
命令的讨论(其中提到了ldd
方法和基于objdump
的更安全的方法)。
简而言之,运行 ldd
$ ldd <path-to-binary>
将提供依赖项的完整列表。
或者,依靠objdump
或readelf
来检查二进制文件的动态部分是一个更安全的提议,其代价是只提供第一级依赖关系。
$ objdump -p /path/to/program | grep NEEDED
$ readelf -d /path/to/program | grep NEEDED
列出加载程序已知的库
要列出所有运行时路径已知且对加载器可用的库,您可以依赖于ldconfig
实用程序。运转
$ ldconfig -p
将打印加载程序已知的库的完整列表(即当前存在于/etc/ld.so.cache
文件中)及其各自的路径。
因此,在加载程序可用的整个库列表中搜索特定的库可以通过运行
$ ldconfig -p | grep <library-of-interest>
列出动态链接库
与本章到目前为止列出的任务相反,这个特定的任务在二进制分析工具的上下文中没有被提及。原因很简单:当运行时动态库加载发生时,二进制文件分析工具在运行时用处不大。像ldd
这样的工具不包含运行时通过调用dlopen()
函数加载的动态库。
以下方法将提供加载的动态库的完整列表。该列表包括静态感知时动态链接的库以及运行时动态链接的库。
strace 实用程序
调用strace <program command line>
是一种列出系统调用序列的有用方法,其中open()
和mmap()
是我们最感兴趣的。该方法显示加载的共享库的完整列表。每当提到共享库时,通常在mmap()
调用下面的几行输出会显示加载地址。
LD_DEBUG 环境变量
鉴于它的灵活性和广泛的选择,这个选项总是出现在跟踪与链接/加载过程相关的一切的工具列表中。对于这个特殊的问题,LD_DEBUG=files
选项可能会提供大量的打印输出,携带运行时动态加载的库的过多信息(它们的名称、运行时路径、入口点地址等)。).
/proc/ /maps 文件
每当一个进程运行时,Linux 操作系统在/proc
文件夹下维护一组文件,跟踪与该进程相关的重要细节。特别是,对于 PID 为 NNNN 的进程,位置/proc/<NNNN>/maps
的文件包含库列表和它们各自的加载地址。例如,图 13-3 显示了这个方法为 Firefox 浏览器报告的内容。
图 13-3。
Examining /proc/
备注 1:
一个潜在的小问题可能是某些应用程序完成得很快,没有留下足够的时间来检查进程内存映射。在这种情况下,最简单快捷的解决方案是通过 gdb 调试器启动进程,并在主函数上设置一个断点。当程序执行在断点处保持阻塞时,您将有无限的时间来检查进程内存映射。
备注 2:
如果您确定当前只有一个程序实例正在执行,那么您可以依靠pgrep
(process grep)命令来消除查找进程 PID 的需要。对于 Firefox 浏览器,您应该键入
$ cat /proc/
pgrep firefox/maps
lsof 实用程序
lsof
实用程序分析正在运行的进程,并在标准输出流中打印出进程打开的所有文件的列表。如其手册页( http://linux.die.net/man/8/lsof
)所述,打开的文件可以是常规文件、目录、块专用文件、字符专用文件、执行文本引用、库、流或网络文件(互联网套接字、NFS 文件或 UNIX 域套接字)。
在它报告打开的文件类型的广泛选择中,它还报告由进程加载的动态库的列表,不管加载是静态感知的还是动态执行的(通过在运行时运行dlopen
)。
下面的截图展示了如何获取 Firefox 浏览器打开的所有共享库的列表,如图 13-4 所示:
$``lsof -p
图 13-4。
Using the lsof utility to examine process memory map
注意lsof
提供了定期运行过程检查的命令行选项。通过指定检查周期,您可以捕捉到运行时动态加载和卸载发生的时刻。
使用-r
选项运行lsof
时,周期性过程检查会无限循环下去,要求用户按 Ctrl-C 终止。用+r
选项运行lsof
具有当不再检测到打开的文件时lsof
终止的效果。
程序化方式
也可以编写代码,打印出进程正在加载的库。当应用程序代码包含对dl_iterate_phdr()
函数的调用时,它在运行时的打印输出可以帮助您确定它加载的共享库的完整列表,以及与每个库相关联的额外数据(例如加载的库起始地址)。
为了说明这个概念,我们创建了由一个驱动程序和两个简单的动态库组成的演示代码。应用程序的源文件显示在以下示例中。其中一个动态库是静态感知的动态链接,而另一个库是通过调用dlopen()
函数动态加载的:
#define _GNU_SOURCE
#include <link.h>
#include <stdio.h>
#include <dlfcn.h>
#include "sharedLib1Functions.h"
#include "sharedLib2Functions.h"
static const char* segment_type_to_string(uint32_t type)
{
switch(type)
{
case PT_NULL: // 0
return "Unused";
break;
case PT_LOAD: // 1
return "Loadable Program Segment";
break;
case PT_DYNAMIC: //2
return "Dynamic linking information";
break;
case PT_INTERP: // 3
return "Program interpreter";
break;
case PT_NOTE: // 4
return "Auxiliary information";
break;
case PT_SHLIB: // 5
return "Reserved";
break;
case PT_PHDR: // 6
return "Entry for header table itself";
break;
case PT_TLS: // 7
return "Thread-local storage segment";
break;
// case PT_NUM: // 8 /* Number of defined types */
case PT_LOOS: // 0x60000000
return "Start of OS-specific";
break;
case PT_GNU_EH_FRAME: // 0x6474e550
return "GCC .eh_frame_hdr segment";
break;
case PT_GNU_STACK: // 0x6474e551
return "Indicates stack executability";
break;
case PT_GNU_RELRO: // 0x6474e552
return "Read-only after relocation";
break;
// case PT_LOSUNW: // 0x6ffffffa
case PT_SUNWBSS: // 0x6ffffffa
return "Sun Specific segment";
break;
case PT_SUNWSTACK: // 0x6ffffffb
return "Sun Stack segment";
break;
// case PT_HISUNW: // 0x6fffffff
// case PT_HIOS: // 0x6fffffff /* End of OS-specific */
// case PT_LOPROC: // 0x70000000 /* Start of processor-specific */
// case PT_HIPROC: // 0x7fffffff /* End of processor-specific */
default:
return "???";
}
}
static const char* flags_to_string(uint32_t flags)
{
switch(flags)
{
case 1:
return "--x";
break;
case 2:
return "-w-";
break;
case 3:
return "-wx";
break;
case 4:
return "r--";
break;
case 5:
return "r-x";
break;
case 6:
return "rw-";
break;
case 7:
return "rwx";
break;
default:
return "???";
break;
}
}
static int header_handler(struct dl_phdr_info* info, size_t size, void* data)
{
int j;
printf("name=%s (%d segments) address=%p\n",
info->dlpi_name, info->dlpi_phnum, (void*)info->dlpi_addr);
for (j = 0; j < info->dlpi_phnum; j++) {
printf("\t\t header %2d: address=%10p\n", j,
(void*) (info->dlpi_addr + info->dlpi_phdr[j].p_vaddr));
printf("\t\t\t type=0x%X (%s),\n\t\t\t flags=0x%X (%s)\n",
info->dlpi_phdr[j].p_type,
segment_type_to_string(info->dlpi_phdr[j].p_type),
info->dlpi_phdr[j].p_flags,
flags_to_string(info->dlpi_phdr[j].p_flags));
}
printf("\n");
return 0;
}
int main(int argc, char* argv[])
{
// function from statically aware loaded library
sharedLib1Function(argc);
// function from run-time dynamically loaded library
void* pLibHandle = dlopen("libdemo2.so", RTLD_GLOBAL | RTLD_NOW);
if(NULL == pLibHandle)
{
printf("Failed loading libdemo2.so, error = %s\n", dlerror());
return -1;
}
PFUNC pFunc = (PFUNC)dlsym(pLibHandle, "sharedLib2Function");
if(NULL == pFunc)
{
printf("Failed identifying the symbol \"sharedLib2Function\"\n");
dlclose(pLibHandle);
pLibHandle = NULL;
return -1;
}
pFunc(argc);
if(2 == argc)
getchar();
if(3 == argc)
dl_iterate_phdr
(header_handler, NULL);
return 0;
}
这个代码示例的核心部分属于对dl_iterate_phdr()
函数的调用。这个函数本质上是在运行时提取相关的流程映射信息,并将其传递给调用者。调用者负责提供回调函数的定制实现(本例中为header_handler()
)。图 13-5 显示了生成的屏幕打印输出的样子。
图 13-5。
The programmatic way (relying on dl_iterate_phdr() call) of examining the dynamic library loading locations in the process memory map
创建和维护静态库
大多数与处理静态库相关的任务都可以通过使用 Linux ar archiver 来完成。完成诸如反汇编静态库代码或检查其符号之类的任务与在应用程序或动态库上执行这些任务没有什么不同。
十四、Windows 工具箱
Abstract
本章的目的是向读者介绍一套用于分析 Windows 二进制文件内容的工具(实用程序以及其他方法)。尽管 Linux objdump
实用程序有一些分析 PE/COFF 格式的能力,但本章的重点将是本地的 Windows 工具,这些工具更有可能适应 PE/COFF 格式的任何变化。
本章的目的是向读者介绍一套用于分析 Windows 二进制文件内容的工具(实用程序以及其他方法)。尽管 Linux objdump
实用程序有一些分析 PE/COFF 格式的能力,但本章的重点将是本地的 Windows 工具,这些工具更有可能适应 PE/COFF 格式的任何变化。
库经理(lib.exe)
Windows 32 位库管理器lib.exe
是 Visual Studio 开发工具的标准部分(图 14-1 )。
图 14-1。
Using the lib.exe utility
这个实用程序不仅以与它的 Linux 对应物(archiver ar)相同的方式处理静态库,而且还在动态库领域扮演一个角色,作为可以创建导入库(DLL 符号的集合,文件扩展名.lib
)以及导出文件(能够解决循环依赖,文件扩展名.exp
)的工具。关于lib.exe
的详细文档可以在 MSDN 网站( http://msdn.microsoft.com/en-us/library/7ykb2k5f.aspx
) ).
)找到
作为静态库工具的 lib.exe
在这一节中,我将举例说明lib.exe
工具可能真正有用的典型角色。
lib.exe 作为默认归档工具
使用 Visual Studio 创建 C/C++ 静态库项目时,lib.exe
被设置为默认的归档器/库管理器工具,项目设置的‘库管理器’选项卡用于为其指定命令行选项(图 14-2 )。
图 14-2。
Using lib.exe as default archiver
默认情况下,构建静态库项目会在编译阶段后调用lib.exe
,这无需开发人员采取任何行动。然而,这不一定是使用lib.exe
必须结束的地方。可以从 Visual Studio 命令提示符下运行lib.exe
,就像使用 Linux ar archiver 执行相同类型的任务一样。
作为命令行工具的 lib.exe
为了说明lib.exe
的用法,您将创建一个与 Linux 静态库功能完全匹配的 Windows 静态库,用于演示第十章中 ar 的用法。演示项目由四个源文件(first.c
、second.c
、third.c
和fourth.c
)和一个导出头文件组成,客户端二进制文件可以使用这个文件。这些文件显示在以下五个示例中。
file: first.c
#include "mystaticlibexports.h"
int first_function(int x)
{
return (x+1);
}
file: second.c
#include "mystaticlibexports.h"
int fourth_function(int x)
{
return (x+4);
}
file: third.c
#include "mystaticlibexports.h"
int second_function(int x)
{
return (x+2);
}
file: fourth.c
#include "mystaticlibexports.h"
int third_function(int x)
{
return (x+3);
}
file: mystaticlibexports.h
#pragma once
int first_function(int x);
int second_function(int x);
int third_function(int x);
int fourth_function(int x);
创建静态库
让我们假设您编译了所有四个源文件,并且您有四个可用的目标文件(first.obj
、second.obj
、third.obj
和fourth.obj
)。将所需的库名传递给lib.exe
(在/OUT
标志之后),后跟参与的目标文件列表,这将产生创建静态库的效果,如图 14-3 所示。
图 14-3。
Using lib.exe to combine object files into a static library
为了完全模仿 Visual Studio 在创建静态库项目时提供的默认设置,我添加了/NOLOGO
参数。
列出静态库内容
当/LIST
标志传递给lib.exe
时,打印出静态库当前包含的目标文件列表,如图 14-4 所示。
图 14-4。
Using lib.exe to list the object files of static library
从静态库中移除单个目标文件
通过将/REMOVE
标志传递给lib.exe
,可以从静态库中移除单个目标文件(图 14-5 )。
图 14-5。
Using lib.exe to remove individual object file from static library
将目标文件插入到静态库中
通过传递库文件名,然后传递要添加的目标文件列表,可以将新的目标文件添加到现有的静态库中。这个语法非常类似于创建静态库的场景,除了可以省略/OUT
标志(图 14-6 )。
图 14-6。
Using lib.exe to insert object file to static library
从静态库中提取单个目标文件
最后,可以从静态库中提取各个目标文件。为了演示它,我首先有目的地删除了计划从静态库中提取的原始目标文件(first.obj
)(图 14-7 )。
图 14-7。
Using lib.exe to extract an individual object file from the static library
动态库领域中的 lib.exe(导入库工具)
lib.exe
也用于创建 DLL 导入库(。lib
)文件和导出文件(。exp
)基于可用的导出定义文件(。def
)。当严格在 Visual Studio 环境中工作时,这个任务通常会自动分配给lib.exe
。当 DLL 是由第三方编译器创建的,而第三方编译器没有创建相应的导入库和导出文件时,会出现更有趣的情况。在这种情况下,lib.exe
必须从命令行运行(即 Visual Studio 命令提示符)。
以下示例说明了如何在交叉编译会话后使用lib.exe
创建缺失的导入库,在交叉编译会话中,运行在 Linux 上的 MinGW 编译器生成了 Windows 二进制文件,但没有提供所需的导入库(图 14-8 )。
图 14-8。
Using lib.exe to create an import library based on DLL and its definition (.DEF)_ file
垃圾箱实用程序
Visual Studio dumpbin
实用程序( http://support.microsoft.com/kb/177429
)在很大程度上是 Linux objdump
实用程序的 Windows 等价物,因为它执行可执行文件的重要细节的检查和分析,例如导出的符号、部分、反汇编代码(。text
)节,静态库中的目标文件列表等。
该工具也是 Visual Studio 包的标准部分。类似于之前描述的lib
工具,它通常从 Visual Studio 命令提示符下运行(图 14-9 )。
图 14-9。
Using the dumpbin utility
运行dumpbin
可以完成以下章节中描述的典型任务。
识别二进制文件类型
在没有额外标志的情况下运行时,dumpbin
报告二进制文件类型(图 14-10 )。
图 14-10。
Using the dumpbin utility to identify binary file types
列出 DLL 导出的符号
运行dumpbin /EXPORTS <dll path>
提供导出符号列表(图 14-11 )。
图 14-11。
Using dumpbin utility to list exported symbols of DLL file
列出并检查各部分
运行dumpbin /HEADERS <binary file path>
打印出文件中出现的完整章节列表(图 14-12 )。
图 14-12。
Using dumpbin to list the sections
一旦列出了截面名称,就可以通过运行dumpbin /SECTION:<section name> <binary file path>
获得各个截面的信息(图 14-13 )。
图 14-13。
Using dumpbin to get detailed insight into a specific section
反汇编代码
运行dumpbin /DISASM <binary file path>
提供完整二进制文件的反汇编列表(图 14-14 )。
图 14-14。
Using dumpbin to disassemble the code
标识调试版本
dumpbin
实用程序用于识别二进制文件的调试版本。调试版本的指示器根据实际的二进制文件类型而有所不同。
目标文件
在目标文件(*.obj
上运行dumpbin /SYMBOLS <binary file path>
会将为调试而构建的目标文件报告为COFF OBJECT
类型的文件(图 14-15 )。
图 14-15。
Using dumpbin to detect the debug version of the object file
同一文件的发布版本将被报告为文件类型匿名对象(图 14-16 )。
图 14-16。
Indication of the release built of the object file
dll 和可执行文件
DLL 或可执行文件是为调试而构建的,这一点可以从运行dumpbin /HEADERS
选项的输出中的.idata
部分看出。本节的目的是支持仅在调试模式下可用的“编辑并继续”功能。更具体地说,要启用该选项,需要/INCREMENTAL
链接器标志,通常为调试设置,为发布配置禁用(图 14-17 )。
图 14-17。
Using dumpbin to detect the debug version of DLL
列出加载时间相关性
通过运行dumpbin /IMPORTS <binary file path>
(图 14-18 )可以获得依赖库的完整列表以及从其中导入的符号。
图 14-18。
Using dumpbin to list loading dependencies
依赖行者
Dependency Walker(又名depends.exe
,参见 www.dependencywalker.com/
)是一个能够跟踪已加载动态库的依赖链的实用程序(图 14-19 )。它不仅能够分析二进制文件(在这种情况下,它类似于 Linux ldd
实用程序),而且还可以执行运行时分析,在运行时分析中,它可以检测和报告运行时动态加载。它最初是由史蒂夫·米勒开发的,在 VS2005 版本之前是 Visual Studio 工具套件的一部分。
图 14-19。
Using the Dependency Walker utility