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

113 阅读40分钟

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

原文:Advanced C and C++ Compiling

协议:CC BY-NC-SA 4.0

一、多任务操作系统基础

Abstract

与构建可执行程序相关的所有技术的最终目标是对程序执行过程建立尽可能多的控制。为了真正理解可执行程序结构某些部分的目的和意义,最重要的是充分理解程序执行过程中发生的事情,因为操作系统内核和嵌入可执行程序内部的信息之间的相互作用起着最重要的作用。在执行的初始阶段尤其如此,此时对运行时影响(如用户设置、各种运行时事件等)来说还为时过早。)这通常会发生。

与构建可执行程序相关的所有技术的最终目标是对程序执行过程建立尽可能多的控制。为了真正理解可执行程序结构某些部分的目的和意义,最重要的是充分理解程序执行过程中发生的事情,因为操作系统内核和嵌入可执行程序内部的信息之间的相互作用起着最重要的作用。在执行的初始阶段尤其如此,此时对运行时影响(如用户设置、各种运行时事件等)来说还为时过早。)这通常会发生。

朝着这个方向必须迈出的第一步是理解程序运行的环境。本章的目的是提供一个现代多任务操作系统功能的最有力的细节。

就如何实现最重要的功能而言,现代多任务操作系统在许多方面非常相似。因此,首先将有意识地努力以独立于平台的方式说明这些概念。此外,还将关注特定平台解决方案的复杂性(无处不在的 Linux 和 ELF 格式与 Windows 的对比),并将对其进行详细分析。

有用的抽象

计算技术领域的变化往往发生得非常快。集成电路技术提供的元件不仅种类丰富(光、磁、半导体),而且功能也在不断升级。根据摩尔定律,集成电路上的晶体管数量大约每两年翻一番。与可用晶体管数量密切相关的处理能力也有类似的趋势。

正如很早就发现的那样,充分适应变化步伐的唯一方法是以抽象/概括的方式,在不断变化的实现细节之上的级别,定义计算机系统的总体目标和体系结构。这项工作的关键部分是以这样一种方式制定抽象,即任何新的实际实现都符合基本定义,而将实际实现的细节放在一边,因为相对来说不重要。整个计算机架构可以表示为一组结构化的抽象,如图 1-1 所示。

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

图 1-1。

Computer Architecture Abstractions

最底层的抽象通过用字节流的本质属性来表示各种 I/O 设备(鼠标、键盘、操纵杆、轨迹球、光笔、扫描仪、条形码阅读器、打印机、绘图仪、数码相机、网络摄像头),来处理这些设备。事实上,不管各种设备的目的、实现和能力之间的差异,从计算机系统设计的角度来看,这些设备产生或接收(或两者)的字节流是最重要的细节。

下一个抽象层次是虚拟内存的概念,它代表了系统中常见的各种内存资源,对于本书的主题来说是非常重要的。这种特定的抽象实际上表示各种物理存储设备的方式不仅影响实际硬件和软件的设计,而且为编译器、链接器和加载器的设计奠定了基础。

抽象物理 CPU 的指令集是下一级的抽象。理解指令集的特性和它所承载的处理能力绝对是编程大师感兴趣的话题。从我们主要话题的角度来看,这个抽象层次不是最重要的,也不会详细讨论。

操作系统的复杂性代表了抽象的最终层次。操作系统设计的某些方面(最明显的是多任务处理)对整个软件架构有决定性的影响。多方试图访问共享资源的场景需要深思熟虑的实现,避免不必要的代码重复,这是直接导致共享库设计的因素。

让我们在分析整个计算机系统的错综复杂的过程中绕一小段路,而是特别注意与内存使用相关的重要问题。

内存层次和缓存策略

有几个与计算机系统内存相关的有趣事实:

  • 对记忆的需求似乎永无止境。人们总是需要远远超过现有水平的东西。在提供更大数量(更快的内存)方面的每一次飞跃都立即满足了人们对技术的长期等待的需求,这些技术在概念上已经准备好了相当长的一段时间,其实现被推迟到物理内存变得足够多的那一天。
  • 这项技术在克服处理器的性能障碍方面似乎比内存更有效。这种现象通常被称为“处理器内存差距”
  • 存储器的存取速度与存储容量成反比。最大容量存储设备的存取时间通常比最小容量存储设备的存取时间大几个数量级。

现在,让我们从程序员/设计师/工程师的角度快速看一下这个系统。理想情况下,系统需要尽可能快地访问所有可用内存——我们知道这是不可能实现的。接下来的问题就变成了:我们能为此做些什么吗?

让我们松了一口气的细节是,系统并不总是使用所有的内存,而只是在某些时候使用一些内存。在这种情况下,我们真正需要做的是为运行立即执行保留最快的内存,并为不立即执行的代码/数据使用较慢的内存设备。当 CPU 从快速存储器中取出安排立即执行的指令时,硬件试图猜测下一步将执行程序的哪一部分,并将该部分代码提供给较慢的存储器等待执行。在执行存储在较慢的存储器中的指令的时间到来之前不久,它们被转移到较快的存储器中。这一原理被称为缓存。

现实生活中的贮藏类似于普通家庭对食物供应的处理。除非我们住在非常偏僻的地方,否则我们通常不会购买并带回家一整年所需的所有食物。相反,我们通常会在家里(冰箱、餐具室、货架)保留相当大的储存空间,以备一两周的食物供应。当我们注意到这些小储备即将耗尽时,我们会去杂货店买足够的食物来填满当地的储备。

程序的执行通常会受到许多外部因素的影响(用户设置只是其中之一),这一事实使得缓存机制成为一种猜测或漫无目的的游戏。程序执行流程越可预测(通过缺少跳转、中断等来衡量)。)缓存机制工作得越顺畅。相反,每当程序遇到流改变时,先前累积的指令由于不再需要而被丢弃,并且需要从较慢的存储器提供新的、更合适的程序部分。

缓存原理的实现无处不在,并且跨越了几个级别的内存,如图 1-2 所示。

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

图 1-2。

Memory caching hierarchy principle

虚拟内存

内存缓存的一般方法在下一个体系结构级别上获得实际的实现,其中运行的程序由称为进程的抽象表示。

现代多任务操作系统的设计意图是允许一个或多个用户同时运行几个程序。对于普通用户来说,同时运行多个应用程序(例如网络浏览器、编辑器、音乐播放器、日历)并不罕见。

虚拟内存的概念解决了内存需求和有限的内存可用性之间的不均衡,虚拟内存的概念可以通过以下一组指导原则来概括:

  • 程序内存余量是固定的,对所有程序都是一样的,本质上是声明性的。

操作系统通常允许程序(进程)使用 2 N 字节的内存,现在 N 是 32 或 64。该值是固定的,与系统中物理内存的可用性无关

  • 物理内存的数量可能会有所不同。通常,可用内存的数量比声明的进程地址空间小几倍。运行程序可用的物理内存量是一个奇数是很正常的。
  • 运行时的物理内存被分成小片段(页面),每个页面用于同时运行的程序。
  • 正在运行的程序的完整内存布局保存在慢速内存(硬盘)中。只有当前将要执行的内存部分(代码和数据)被加载到物理内存页面中。

虚拟内存概念的实际实现需要大量系统资源的交互,例如硬件(硬件异常、硬件地址转换)、硬盘(交换文件)以及最低级别的操作系统软件(内核)。虚拟内存的概念如图 1-3 所示。

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

图 1-3。

Virtual memory concept implementation

虚编址

虚拟寻址的概念是虚拟内存实现的基础,并且在许多方面极大地影响了编译器和连接器的设计。

作为一般规则,程序设计者完全不用担心他的程序在运行时将占用的寻址范围(至少对大多数用户空间应用程序来说是这样;内核模块在这个意义上有些例外)。相反,编程模型假设地址范围在 0 和 2 N (虚拟地址范围)之间,并且对所有程序都是相同的。

为所有程序授予简单统一的寻址方案的决定对代码开发过程有着巨大的积极影响。以下是一些好处:

  • 链接被简化。
  • 装载被简化。
  • 运行时进程共享变得可用。
  • 简化了内存分配。

程序存储器在具体地址范围内的实际运行时位置由操作系统通过地址转换机制来执行。它的实现是由称为内存管理单元(MMU)的硬件模块执行的,它不需要程序本身的任何参与。

图 1-4 比较了虚拟寻址机制和简单明了的物理寻址方案(至今仍用于简单微控制器系统领域)。

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

图 1-4。

Physical vs. virtual addressing

进程内存划分方案

上一节解释了为什么可以为(几乎)任何程序的设计者提供相同的内存映射。本节的主题是讨论过程记忆图内部组织的细节。假设程序地址(从程序员的角度来看)位于 0 和 2 N 之间的地址范围内,N 为 32 或 64。

各种多任务/多用户操作系统指定不同的内存映射布局。特别是,Linux 进程虚拟内存映射遵循图 1-5 所示的映射方案。

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

图 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 说明了编译的概念。

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

图 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的二进制内容。

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

图 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&gt:

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 所示。

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

图 2-3。

Tiling the individual sections into the final program memory map

剩下的任务是将存储在单个目标文件中的单个部分拼接到程序内存映射体中。正如前面提到的,这个任务需要留给程序构建过程的另一个阶段,叫做链接。

细心的观察者不禁会问(在进入链接过程的细节之前),为什么我们需要构建过程的全新阶段,或者更准确地说,为什么到目前为止描述的编译过程不能完成任务的平铺部分?

拆分构建过程有几个非常充分的理由,本节的其余部分将试图阐明导致这种决定的环境。

简而言之,答案可以用几句简单的话来提供。首先,将这些部分组合在一起(尤其是代码部分)并不总是简单的。这一因素肯定起一定的作用,但并不充分;有许多编程语言的程序构建过程可以一步完成(换句话说,它们不需要将过程分成两个阶段)。

第二,应用于程序构建过程的代码重用原则(以及将来自不同项目的二进制部分组合在一起的能力)明确肯定了将 C/C++ 构建实现为两步(编译和链接)过程的决定。

是什么让组合变得如此复杂?

在很大程度上,将源代码翻译成二进制目标文件是一个相当简单的过程。代码行被翻译成特定于处理器的机器代码指令;为初始化变量保留空间,并向其中写入初始值;未初始化变量的空间被保留并用零填充,等等。

然而,整个故事中有一部分注定会引起一些问题:即使源代码被分组到专用的源文件中,成为同一个程序的一部分意味着一定存在某些相互的联系。事实上,代码不同部分之间的连接通常是通过以下两种方式建立的:

  • 功能独立的代码体之间的函数调用:

例如,聊天应用的 GUI 相关源文件中的函数可以调用 TCP/IP 网络源文件中的函数,该函数又可以调用位于加密源文件中的函数。

  • 外部变量:

在 C 编程语言领域(在 C++ 领域要少得多),通常的做法是保留全局可见的变量,以维护代码各个部分的状态。更广泛使用的变量通常在一个源文件中声明为全局变量,并作为外部变量从所有其他源文件中引用。

一个典型的例子是标准 C 库中使用的 errno 变量,用于保存最后遇到的错误的值。

为了访问这两个(通常称为符号)中的任何一个,必须知道它们的地址(更准确地说,是函数在程序存储器中的地址和/或全局变量在数据存储器中的地址)。

但是,在将各个段合并到相应的程序段之前(即,在段平铺完成之前),无法知道实际地址!!!).在此之前,函数和它的调用者之间的有意义的连接和/或对外部变量的访问是不可能建立的,这两者都被适当地报告为未解析的引用。请注意,如果函数或全局变量是从定义它的同一个源文件中引用的,就不会出现这种问题。在这种特殊的情况下,函数/变量和它们的调用者/用户最终都是同一个部分的一部分,并且它们相对于彼此的位置在“大拼图完成”之前就已经知道了在这种情况下,一旦切片完成,相对内存地址就变得具体和可用。

正如本节前面提到的,解决这种问题仍然不要求构建过程必须分为两个不同的阶段。事实上,许多不同的语言都成功地实现了一遍构建过程。然而,应用于构建程序领域的重用概念(在本例中是二进制重用)最终确定了将程序构建分为两个阶段(编译和链接)的决策。

连接

程序构建过程的第二阶段是链接。链接过程的输入是由先前完成的编译阶段创建的目标文件的集合。每个目标文件可以被看作是对所有类型的程序存储器映射部分(代码、初始化数据、未初始化数据、调试信息等)的单独源文件贡献的二进制存储。).链接器的最终任务是从单个贡献中形成结果程序内存映射部分,并解析所有引用。提醒一下,虚拟存储器的概念简化了链接器的任务,因为它允许假设链接器需要填充的程序存储器映射是对于每个程序的相同大小的从零开始的地址范围,而不管操作系统在运行时给进程什么地址范围。

为了简单起见,我将在这个例子中涵盖最简单的可能情况,其中对程序存储器映射部分的贡献仅仅来自属于同一个项目的文件。实际上,由于二进制重用概念的发展,这可能不是真的。

链接阶段

链接过程通过一系列阶段(重定位、引用解析)进行,这将在下面详细讨论。

重新安置

链接程序的第一阶段就是平铺,在这个过程中,包含在单个目标文件中的各种类型的段被组合在一起,以创建程序存储器映射段(见图 2-4 )。为了完成这个任务,先前中立的、从零开始的起作用的段的地址范围被转换成结果程序存储器映射的更具体的地址范围。

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

图 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 中用图形描述。

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

图 2-5。

The problem of unresolved references in its essential form

为了解决这类问题,必须出现解析引用的链接阶段。在这种情况下,链接器需要做的是

  • 检查程序存储器映射中已经平铺在一起的部分。
  • 找出代码的哪个部分调用了其原始部分之外的部分。
  • 找出代码引用部分的确切位置(在内存映射中的哪个地址)。
  • 最后,通过用程序存储器映射的实际地址替换机器指令中的虚拟地址来解析引用。

一旦链接器完成了它的魔法,情况可能看起来如图 2-6 所示。

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

图 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 所示。

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

图 2-7。

bss disassembled

链接者的观点

“当你手里拿着一把锤子时,所有东西看起来都像钉子”——手用锤子综合症

但是说真的,伙计们。。。。

现在您已经知道了链接任务的复杂性,这有助于缩小一点范围,并尝试总结在运行其常规任务时指导链接器的原理。事实上,链接器是一种特殊的工具,不像它的哥哥编译器,它对编写代码的微小细节不感兴趣。相反,它将世界视为一组目标文件,这些文件(很像拼图)将被组合在一起,形成一个更大的程序内存映射图,如图 2-8 所示。

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

图 2-8。

The linker’s view of the world

发现图 2-8 与图 2-9 的左边部分有很多相似之处并不需要太多的想象力,而链接者的最终任务可以由同一图的右边部分来表示。

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

图 2-9。

Linker’s view of the world as seen by humans

可执行文件属性

链接过程的最终结果是二进制可执行文件,其布局遵循适合于目标平台的可执行格式的规则。不管实际的格式差异如何,可执行文件通常包含结果节(。文本,。数据,。bss,以及许多更狭义的专门化的文件)。最值得注意的是,代码(。text)部分不仅包含来自目标文件的各个图块,而且链接器对其进行了修改,以确保各个图块之间的所有引用都已被解析,从而代码不同部分之间的函数调用以及变量访问都是准确且有意义的。

在可执行文件包含的所有符号中,有一个非常独特的地方属于main函数,因为从 C/C++ 程序的角度来看,它是整个程序执行开始的函数。然而,这并不是程序启动时执行的第一部分代码。

需要指出的一个非常重要的细节是,可执行文件并不完全由从项目源文件编译的代码组成。事实上,负责启动程序执行的一段具有重要战略意义的代码是在链接阶段添加到程序存储器映射中的。链接器通常将此目标代码存储在程序内存映射的开头,它有两种变体:

  • crt0 是“普通的”入口点,是在内核控制下执行的程序代码的第一部分。
  • crt1 是更现代的启动例程,支持在执行main函数之前和程序终止之后完成的任务。

考虑到这些细节,可执行程序的整体结构可以用图 2-10 来象征性地表示。

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

图 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 说明了这个想法。

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

图 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 所示,加载程序段通常包含几个具有共同访问属性的部分(读或读写,或者最重要的,是否打补丁)。

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

图 3-2。

Linker vs. loader

如图 3-3 所示,使用readelf实用程序来检查段说明了将许多不同的链接程序段分组到加载程序段中。

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

图 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 )。

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

图 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 架构开发了许多不同的调用约定,例如cdeclstdcallfastcallthiscall等等。它们中的每一个都是从各种设计角度为特定场景定制的。由内曼贾·特里弗诺维奇( 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

代码重用的概念无处不在,并且已经找到了令人印象深刻的多种表现方式。它对构建程序过程的影响发生在众所周知的从过程编程语言向面向对象语言的转变之前。

代码重用的概念无处不在,并且已经找到了令人印象深刻的多种表现方式。它对构建程序过程的影响发生在众所周知的从过程编程语言向面向对象语言的转变之前。

在前几章中已经描述了在编译器和链接器之间划分任务的最初原因。简而言之,这一切都始于将代码保存在单独的源文件中的有用习惯;然后,在编译时,很明显编译器不能简单地完成解析引用的任务,因为必须首先将代码段拼接到程序内存映射的最终拼图中。

代码重用的想法为拆分编译和链接阶段的决策增加了额外的参数。目标文件带来的不确定性(所有部分都有从零开始的地址范围和未解析的引用),从代码共享的观点来看,这最初看起来肯定是一个缺点,实际上开始看起来像一个宝贵的新品质。

应用于构建程序可执行文件领域的代码重用概念最初是以静态库的形式实现的,静态库是目标文件的捆绑集合。后来,随着多任务操作系统的出现,另一种被称为动态库的重用形式变得突出起来。如今,这两个概念(静态库和动态库)都在使用,各有利弊,因此需要更深入地理解其功能的内部细节。本章非常详细地描述了这两个有些相似,但又有本质区别的概念。

静态库

静态库概念背后的思想非常简单:一旦编译器将一组翻译单元(即源文件)翻译成二进制目标文件,您可能希望保留这些目标文件供以后在其他项目中使用,在链接时它们可以很容易地与其他项目固有的目标文件组合在一起。

为了能够将二进制目标文件集成到某个其他项目中,至少需要满足一个额外的要求:二进制文件附带有导出头包含文件,该文件将提供至少这些函数的各种定义和函数声明,这些函数可以用作入口点。标题为“结论:二进制重用概念的影响”的部分解释了为什么有些功能比其他功能更重要。

有几种方法可以将一组目标文件用于各种项目中:

  • 在这种情况下,显而易见的要求是链接器理解静态库文件格式,并且能够提取其内容(即捆绑在一起的目标文件)以便将它们链接进来。幸运的是,从微处理器编程的早期开始,这个要求就已经被每一个链接器满足了。
  • 还要注意的是,创建静态库的过程决不是不可逆的。更确切地说,静态库仅仅是一个目标文件的档案,可以用多种方式操作。通过方便地使用适当的工具,静态库可以被分解成原始对象文件的集合;可以从库中丢弃一个或多个目标文件,可以添加新的目标文件,最后,现有的目标文件可以被较新的版本替换。

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

图 4-2。

The static library as form of binary code reuse

  • 更好的方法是将目标文件捆绑成一个单一的二进制文件,一个静态库。将单个二进制文件交付给另一个项目比单独交付每个目标文件要简单得多,也优雅得多(图 4-2 )。

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

图 4-1。

A trivial method of binary code reuse, the precursor to static libraries

  • 简单的解决方法是保存编译器生成的目标文件,并以任何可能的方式复制(剪切-粘贴)或传输到需要它们的项目中(它们将与其他目标文件一起链接到可执行文件中),如图 4-1 所示。

无论您决定采用这两种方法中的哪一种,是简单的方法还是更复杂的静态库方法,您都将经历二进制代码重用的过程,因为在一个项目中生成的二进制文件会在其他项目中使用。二进制代码重用对软件设计前景的整体影响将在后面详细讨论。

动态库

与静态库的概念不同,静态库的概念在汇编编程的早期就已经存在,而动态库的概念在很久以后才被完全接受。导致其产生和采用的环境与多任务操作系统的出现密切相关。

在对多任务操作系统功能的任何分析中,有一个特殊的概念很快变得突出:不管并发任务的种类,某些系统资源是独特的,必须由每个人共享。桌面系统上共享资源的典型例子有键盘、鼠标、视频图形适配器、声卡、网卡等等。

如果每个打算访问公共资源的应用程序都必须包含提供资源控制的代码(作为源代码或静态库),这将会适得其反,甚至是灾难性的。这将非常低效、笨拙,并且大量存储(硬盘和内存)将被浪费在存储相同代码的副本上。

对更好、更高效的操作系统的白日梦产生了一种共享机制的想法,这种机制假定既不编译重复的源文件,也不链接重复的目标文件。相反,它将作为某种运行时共享来实现。换句话说,正在运行的应用程序将能够在其程序存储器映射中集成一些其他可执行程序的编译和链接部分,其中集成将在运行时按需发生。这个概念被称为动态链接/动态加载,这将在下一节中更详细地说明。

从最初的设计阶段开始,一个重要的事实变得显而易见:在动态库的所有部分中,只有共享它的代码才有意义。文本)部分,而不是其他进程的数据。在烹饪类比中,一群不同的厨师可以共享同一本食谱(代码)。然而,考虑到不同的厨师可能同时从同一本食谱中准备完全不同的菜肴,如果他们共享相同的厨房用具,那将是灾难性的(数据)。

显然,如果一堆不同的进程可以访问动态库数据段,变量覆盖将在任意时刻发生,动态库的执行将是不可预测的,这将使整个想法变得毫无意义。这样,通过只映射代码段,多个应用程序可以自由地在各自独立的区间中运行共享代码。

动态库与共享库

操作系统设计者早期的目标是避免在每个可能需要它们的应用程序的二进制文件中不必要的多次出现相同的操作系统代码。例如,需要打印文档的每个应用程序都必须包含完整的打印堆栈,以打印机驱动程序结束,以便提供打印功能。如果打印机驱动程序改变了,整个应用程序设计团队都需要重新编译他们的应用程序;否则,由于运行时存在过多的不同打印机驱动程序版本,将会出现混乱。

显然,正确的解决方案应该是以如下方式实现操作系统:

  • 通常需要的功能以动态库的形式提供。
  • 需要访问公共功能的应用程序只需要在运行时加载动态库。

图 4-3 说明了动态库概念背后的基本思想。

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

图 4-3。

The dynamic libraries concept

这个问题的第一个解决方案(即动态链接实现的第一个版本,称为加载时重定位(LTR))部分成功地实现了目标。好消息是应用程序不再需要在二进制文件中携带不必要的操作系统代码;相反,它们只部署了特定于应用程序的代码,而所有与系统相关的需求都通过动态链接操作系统提供的模块来满足。

然而,坏消息是,如果多个应用程序在运行时需要某些系统功能,每个应用程序都必须加载自己的动态库副本。这种限制的根本原因是加载时重定位技术修改了。文本部分,以适应给定应用程序的特定地址映射。对于将动态库加载到可能不同的地址范围中的另一个应用,修改后的库代码根本不适合不同的存储器布局。

因此,在运行时,动态库的多个副本驻留在进程的内存映射中。这是我们可以忍受一段时间的事情,但设计的长期目标要远大得多:提供一种更有效的机制,允许动态库只被加载一次(由首先加载它的任何应用程序加载),并可供任何其他试图接下来加载它的应用程序使用。

这个目标是通过称为位置无关码(PIC)的概念实现的。通过改变动态库代码访问符号的方式,只有加载到任何进程的内存映射中的动态库的一个副本通过内存映射到任何应用程序的进程内存映射而变得可共享(图 4-4 )。

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

图 4-4。

The advances brought by the PIC technique of dynamic linking

此外,操作系统将某些公共系统资源(例如,顶级驱动程序)加载到物理内存中并不罕见,因为它知道大量运行的进程很可能需要这些资源。动态链接的效果是,每个进程都有一个完美的错觉,认为它们是驱动程序的唯一所有者。

自从 PIC 概念发明以来,为支持它而设计的动态库被称为共享库。现在,PIC 概念很流行,在 64 位系统上,它受到编译器的强烈青睐,所以动态库和共享库这两个术语之间的命名区别正在消失,这两个名称或多或少可以互换使用。

虚拟内存的概念为运行时共享思想的成功奠定了基础(集中体现在位置无关代码的概念中)。最初的想法相当简单:如果真实的进程内存映射(具有真实的、具体的地址)只不过是从零开始的进程内存映射的 1:1 映射的结果,那么是什么真正阻止我们创建一个怪物,一个通过映射多个不同进程的部分而获得的真实的进程内存映射呢?事实上,这正是动态库的运行时共享机制的工作方式。

PIC 概念的成功实现代表了现代多任务操作系统的基石。

更详细的动态链接

动态链接的概念是动态库概念的核心。如果不理解动态库、客户端可执行文件和操作系统之间复杂的相互作用,几乎不可能完全理解动态库是如何工作的。本节的重点是提供对动态链接过程的必要的广泛理解。一旦理解了它的本质,本文档的后续部分将对细节给予应有的关注。

那么,让我们看看在动态链接的过程中到底发生了什么。

第一部分:构建动态库

正如前面的图所示,构建动态库的过程是一个完整的构建,因为它包括编译(将源代码转换为二进制目标文件)和解析引用。动态库构建过程的产品是二进制文件,其性质与可执行文件的性质相同,唯一的区别是动态库缺少启动例程,该例程允许它作为独立程序启动(图 4-5 )。

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

图 4-5。

Building the dynamic library

以下是一些需要考虑的注意事项:

  • 在 Windows 中,构建动态库严格要求必须解析所有引用。如果动态库代码调用某个其他动态库中的函数,那么该其他库及其包含的引用符号在构建时必须是已知的。
  • 然而,在 Linux 中,缺省选项允许更大的灵活性,允许某些符号不被解析,并期望在链接其他动态库之后,它们最终会出现在最终的二进制文件中。此外,Linux 链接器提供了与 Windows 链接器的严格性完全匹配的选项。
  • 在 Linux 中,可以修改动态库,使其可以自己运行(仍在研究 Windows 上是否存在这样的选项)。事实上,libc (C 运行时库)本身是可执行的;当通过在 shell 窗口中键入文件名来调用时,它会在屏幕上显示一条消息并终止。有关如何实现此功能的更多详细信息,请查看第十四章。
第二部分:在构建客户端可执行文件时信任游戏(只寻找符号)

使用动态库场景的下一个阶段发生在您尝试构建打算在运行时使用动态库的可执行文件时。与链接器根据自己的意愿创建可执行文件的静态库场景不同,链接动态库的场景是特殊的,因为链接器试图将其当前工作与创建动态库二进制文件的先前完成的链接过程的现有结果相结合。

故事这一部分的关键细节是,链接器几乎把所有的注意力都放在了动态库的符号上。在这个阶段,链接器似乎对任何部分都不感兴趣,对代码也不感兴趣。文本),也不是数据(。数据/。bss)。

更具体地说,这个操作阶段的链接器“通过信任来玩它”

它没有彻底检查动态库的二进制文件;它既不试图找到这些部分或它们的大小,也不试图将它们集成到生成的二进制文件中。相反,它只是试图验证动态库是否包含生成的二进制文件所需的符号。一旦找到它,它就完成任务并创建可执行的二进制文件(见图 4-6 )。

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

图 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 所示。

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

图 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 符号列表,不包含任何链接器部分,其唯一目的是将动态库的导出符号集呈现给客户端二进制文件。

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

图 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 所示。

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

图 4-9。

Import selectiveness criteria for static libraries

客户机二进制文件的字节长度增加了,尽管只是增加了从静态库中获取的相关代码的数量。

Note

尽管链接算法在选择链接哪些目标文件时是有选择性的,但这种选择性不会超出单个目标文件的粒度。除了真正需要的符号之外,选择的目标文件还可能包含一些不需要的符号。

动态库的导入选择性标准

当客户端二进制链接动态库时,它仅在符号表级别具有选择性,其中只有真正需要的动态库符号才会在符号表中提及。

在所有其他方面,这种选择性实际上是不存在的。无论动态库功能的具体需求有多小,整个动态库都会被动态链接进来(图 4-10 )。

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

图 4-10。

Import selectiveness criteria for dynamic libraries

增加的代码量只发生在运行时。客户端二进制文件的字节长度不会显著增加。新符号的簿记所需的额外字节往往相当于小字节计数。然而,链接动态库要求动态库二进制文件在运行时在目标机器上可用。

整个归档导入方案

当静态库的功能需要通过中间的动态库呈现给二进制客户端时,一个有趣的转折就发生了(图 4-11 )。

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

图 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。

  • 知识产权受控交换的潜力。

通过交付二进制文件而不是源代码,软件公司可能会交付他们的技术而不披露其背后的想法。反汇编程序的出现使这个故事变得更加复杂,但是从长远来看,基本思想仍然适用。