Index

第 7 章

  • · · · 0 · H A . · P T · .E R 7

    链接

链接Clink ing ) 是将各种代码和数据片段收集并 组合成 为一个单一文件的过程, 这个文件可被加栽(复制)到内存并执行。链接可以 执行于编译 时( com pile time), 也就是在源代码被翻译成 机器代码时; 也可以执行千加 载 时 ( loa d time) , 也就是在程序被加栽 器( lo ad­ er ) 加载到内存并 执行时; 甚至执行 于运行 时( ru n time), 也就是由应用程序来执行。在早期的计算机系统中, 链接是手 动执行的。在现代系统中, 链接是由叫做链接器 Clinker ) 的程序自动 执行的 。

链接器在软 件开发中扮演着一个关键的角色, 因为它们使得分 离 编译( separa te com­ pila t io n ) 成为可能。我们不用将一个 大型的应用程序组织为一个巨大的源文 件, 而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模 块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。

链接通常是由链接器来默默地处理的,对于那些在编程入门课堂上构造小程序的学生而言,链接不是一个重要的议题。那为什么还要这么麻烦地学习关于链接的知识呢?

  • 理解链接器将帮助你构造大型程序。构造大型程序的程序员经常会遇到由千缺少模块、缺少库或者不兼容的库版本引起的链接器错误。除非你理解链接器是如何解析引用、什么是库以及链接器是如何使用库来解析引用的,否则这类错误将令你感到迷惑和挫败。
    • 理解链接器将帮助你避免 一些危 险的编程错误。Lin ux 链接器解析符号引用时所做的决定可以不动声色地影响你程序的正确性。在默认情况下, 错误地定义多个全局变量的 程序将通过链 接器, 而不产生任何警告信息。由此得到的程序会产生令人迷惑的运行时行为,而且非常难以调试。我们将向你展示这是如何发生的,以及该如 何避免它。

    • 理解链接 将帮助你理 解语言的作 用域规则是 如何实现的。例如, 全局和局部 变量之间的 区别 是什么?当你定义一个具有 s 七a t i c 属性的变量或者函数时, 实际到底意味着什么?

    • 理解链接将帮助你理解其他重要的系统概念。链接器产生的可执行目标文件在重要的系 统功能中扮演着关键角色, 比如加载和运行程序、虚拟内存、分页、内存映射。

    • 理解链接 将使你能够利 用共 享库。 多年以来, 链接都被认 为是相当简单和无趣的。然而,随着共享库和动态链接在现代操作系统中重要性的日益加强,链接成为一个 复杂的过 程, 为掌握它的程序员 提供了强大的能力。比如, 许多软件产品在运行时使用共享库来升级压缩包装的 ( s h r ink- w ra pped ) 二进制程序。 还有 , 大多数 Web 服 ` 务器都依赖于共享库的动态链接来提供动态内容。

      这一章提供了关于链接各方面的全面讨论, 从传统静态链接到加载时的共享库的动态链接,以及到运行 时的共享库的动态链接。我们将使用实际示例来描 述基本的 机制, 而且指出链接问题在哪些情况中会影响程序的 性能和正确性。为了使描述具体和便千理解 ,我们的讨论是基千这样的环境: 一个运行 Linux 的 x86-64 系统, 使用标准的 ELF-64 (此后称为 ELF)

目标文件格式。不过,无 论是什么样的操作系统、ISA 或者目标文件格式, 基本的链接概 念是通用的,认识 到这一点是很重要的。细节可能不尽相同 , 但是概念是相同的。

  1. 1 编译器驱动程序

    考虑图 7-1 中的 C 语言程序。它将 作为贯穿本章的一个小的运行示例, 帮助我们说明关千链接是 如何工作的一些重要知识 点。

code/link/main.c int sum(int *a, int n);

int array [2] = {1, 2};

int main()

int val= sum(array, 2);

return val;

code/link/sum.c int sum(int *a, int n)

inti, s = O;

for (i = O; i < n; i++) {

s += a[i]

returns;

code/linmka/in.c

  1. main. c b) sum. c

code/link/sum.c

图 7-1

示例程序 1。这个示 例程序由 两个源文件组成 , ma i n. c 和 s um. c 。 ma i n 函数初始化一个整数数组, 然后调用 s um 函数来对数组元素求 和

大多数编译 系统提供编译 器驱 动程序 ( co mpile r driver), 它代表用户在需 要时调用语

言预处理器、编译器、汇编器和链接器。

ma i n . c

s um . c 源文件

比如 ,要用 G NU 编译系统构造示例程序,

我们就要 通过在 s hell 中输入下列命令来调用 G CC 驱动程序:

l_inux> gee -Og -o prog mai n . e sum. e

图 7-2 概括了驱动程序在 将示例程序从

翻译器

(epp, eel, as)

ma1.n.o

!

翻译器

(epp, eel, as)

可重定位目标文件

ASCII 码源文件翻译成可执行目标文件时的行为。(如果你想看看这些 步骤,用 - v 选项来运行 GC C。)驱动程序首先 运行 C 预处理器 ( c p p )e , 它将 C 的源程序 ma i n . c 翻译成一个 AS CII 码的中间 文件 ma i n . i :

图 7- 2

链接器 ( l d )

prlog 完全链接的

可执行目标文件

静态链接。链接器将可重定位目标文件组合起来, 形成一个可执行目标 文件 pr og

cpp [other arguments] main. c /tmp/main. i

接下来, 驱动程序运行 C 编译器( e el ) ,

件 ma i n . s :

它将 ma i n . 工 翻译成一个 AS C II 汇编语言文

cc1 /tmp/main. i -Dg [other arguments] -o /tmp/main.s

然后, 驱动程序运行 汇编器( a s ) , eatable object file) main. o:

它将 ma i n . s 翻译 成一个可重定位目 标文件( re lo-

as [other arguments] -o /tmp/main.o /tmp/main.s

8 在某些 GCC 版本中,预 处 理 器 被 集 成 到 编译 器驱动程序中。

驱动程序经过相同的过程生成 s um. o 。 最后,它 运行链接器程序 l d , 将 ma i n . a 和s um. o 以及一些 必要的系统目标文件组合起来, 创建一个可执行目标 文件 ( e xec uta ble ob­ ject file)prog:

ld -o prog [system objectfiles and args] / t mp/ ma i n . o /tmp/sum. o

要运行 可执行 文件 pr og , 我们在 Lin ux s hell 的命令行上输入它的名 字:

linux> ./prog

shell调用操作系统中一个叫做加载 器 ( load er ) 的 函数, 它将可执行文件 pr og 中的代码和数据复制到内存 , 然后 将控制转 移到这个程序的开头。

  1. 2 静态链接

    像 L in ux LD 程序这样 的静态链接 器( s tat ic lin ker ) 以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。输入 的可重定位目标 文件由 各种不同的 代码和数据节( s ect ion ) 组成, 每一节都是一个 连续的字节序列。指令在一节中,初始化了的全局变储在另一节中,而未初始化的变量又在另外一 节中。

    为了构造可执行文件,链接器必须完成两个主要任务:

    • 符号解析 ( s ym bol resolut io n ) 。目标文件定义和引用符号, 每个符号对应于一个函数、一个全局变量或一个静态 变量(即 C 语言中任何以 s t a t i c 属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。

    • 重定位( re loca tion ) 。编译器和汇编器生成从 地址 0 开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对 这些符号的引用,使 得它们指向这个内 存位置。链接器使用汇编器产生的重定位条目 ( relocat ion e nt r y) 的详细指令, 不加甄别 地执行这样的重定 位。

      接下来的章节将更加详细地描述这些任务。在你阅读的时候,要记住关千链接器的一些基本事实 :目 标文件纯粹是字节块的集 合。这些块中, 有些包含程序代码, 有些包含程序数据,而其他的则包含引导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。链接器对目标机器了解 甚少。产生目标文件的编译器和汇编器已经完成了大部分工作。

  2. 3 目标文件

    目标文件有三种形式:

    • 可重定位 目标 文件。包含二进制代码 和数据, 其形式可以在编译时与其他可重定位目 标文件合并 起来, 创建一个可执行目标 文件。

    • 可执行目标 文件。包含二进制代码和数据,其形式 可以被直接复制到内存并执行。

    • 共享目标 文件。一种特殊类型的可 重定位目标文件, 可以在加载或者运行时被动态地加载进内存并 链接。

      编译牉和汇编骈生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。从技术上来说, 一个目标 模块 ( object mod ule ) 就是一个字节序列, 而一个目标 文件Cob­ ject file) 就是一个以文件形式存放在磁盘中的目标模块。不过, 我们会互换地使用这些术语。

      目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。

从贝尔实 验室诞生的第一个 U nix 系统使用的是 a . ou t 格式(直到今天, 可执行文件仍然称为 a . o 江 文 件)。Windo ws 使用 可移植 可执行 ( Por table Executable, PE ) 格式。Mac os-x 使 用 Mach-0 格式。现代 x86-64 Lin ux 和 U nix 系统使用可执 行 可链接格式( E xec ut ­ able and Linkable Format, ELF)。尽管我们的讨论集中在 E LF 上,但 是 不 管是 哪 种格式,

基本的概念是相似的。

4 可重定位目标文件 #

图 7-3 展示了一个典型的 ELF 可重定位目标文件的格式。E LF 头 ( E LF header ) 以一个 16 字节的序列开始, 这个序列描述了生成该文件

的系统的字的大小和字节顺序。E L F 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其 中包括 ELF 头的大小、目标文件的类型(如可重定位、可 执行或者共享的)、机器类型(如x86-64 ) 、 节

头部表 ( sect io n header table ) 的 文件 偏 移 ,以 及 节头

部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目 ( en t r y ) 。

夹在 E LF 头和节头部表之间的都是节。一个典型的 E LF 可重定位目标文件包含下 面儿个节:

.text: 巳编译程序的机器代码。

描述目标文件的节{

.rodata: 只读数据, 比 如 p r i n t f 语 句 中 的 格 图 7-3 典型的 ELF 可重定位目标文件

式串和开关语句的跳转表。

.data: 已初始化的全局和静态 C 变量。局部 C 变扯在运行时被保存在栈中,既 不 出现在 . da 七a 节 中 , 也不 出 现 在 . b s s 节中 。

bss: 未初始化的全局和静态 C 变量,以 及 所有被初始化为 0 的全局或静态变量。在目标文件中这个节不占据实际的空间, 它仅仅是一个占位符。目标文件格式区分已初始化 和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁 盘空间 。运行时, 在内存中分配这些变量, 初始值为 0。

.symtab: 一个符号表,它 存 放在程序中定义和引用的函数和全局变批的信息。一些程序员 错误地认为必须通过 - g 选项来编译一个程序, 才能得到符号表信息。实际上, 每个可重定 位目标文件在 . s ymt a b 中 都 有 一 张符号表(除非程序员特意用 ST R IP 命令去掉它)。然而, 和编译器中的符号表不同,. s ymt a b 符 号 表 不包含局部变量的条目。

.rel.text: 一个.te江节中位置的列表, 当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言, 任何调用外部函数或者引用全局变篮的指令都需要修改。另 一方面,调 用 本 地函数的指令则不需要修改。注意, 可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。

.rel.data: 被模块引用或定义的所有全局变最的重定位信息。一般而言, 任何已初始化的 全局变扯, 如果 它 的 初始值是一个全局变量地址或者外部定义函数的地址,都 需 要被修改。

.debug: 一个调试符号表,其 条 目 是 程序中定义的局部变量和类型定义, 程 序 中 定义和引 用的全局变扯,以 及 原 始 的 C 源文件。只有以 - g 选项调用编译器驱动程序时, 才

会得到这张表。

.line: 原始 C 源程序中的行号和 . t e x t 节中机器指令之间的映射。只有以- g 选项调

用编译器驱动程序时,才会得到这张表。

.strtab: 一个字符串表, 其 内 容包括 . s ymt a b 和 . d e b u g 节中的符号表,以 及节头部 中 的 节 名字。字符串表就是以 nul l 结尾的字符串的序列。

囚 日 为什么未初始化的数据称为 . b ss

用术语 . bs s 来表 示 未初 始化的数据是很普遍的。 它起 始于 IB M 704 汇编语言(大约在 1 957 年)中"块存储开始 ( Block Storage Start )" 指令的 首 字母 缩 写 , 并 沿 用 至今。一种记住 . d a t a 和 . b s s 节之间 区 别的 简 单方 法是把 " bss " 看成是“ 更好地节 省空间 , ( Be tt e r S ave S pace ) " 的缩写。

  1. 5 符号和符号表

    每个可重定位目标模块 m 都有一个符号表, 它 包 含 m 定 义 和引用的符号的信息。在链 接器的上下文中,有 三种不同的符号:

    • 由 模 块 m 定义并能被其他模块引用的全局符号。全局链接器符号对应千非静态的 C

      函数和全局变量。

      • 由其他模块定义并被模块 m 引用的全局符号。这些符号称为外部符号, 对应于在其他模块中定义的非静态 C 函数和全局变量。

      • 只被模块 m 定义和引用的局部符号。它们对应于带 s t a t i c 属性的 C 函数和全局变握。这些符号在模块 m 中任何位置都可见,但 是 不 能 被其他模块引用。

        认识到本地链接器符号和本地程序变量不同是很重要的。.s ymt a b 中 的 符号表不包含对 应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,链 接器对此类符号不感兴趣。

        有趣的是,定 义为带有 C s t a t i c 属性的本地过程变量是不在栈中管理的。相反,编译 骈在 . d a t a 或 . bs s 中为每个定义分配空间,并 在 符 号 表中创建一个有唯一名字的本地链 接器符号。比如, 假设在同一模块中的两个函数各自定义了一个静态局部变量 X :

        int f ()

        2 {

        3 static int x = O;

        return x·

1 int gO

static int x = 1;

10 return x;

在这种情况中, 编译器向汇编器输出两个不同名字的局部链接器符号。比如, 它可以用 x . 1 表 示 函 数 f 中 的定 义, 而用 x . 2 表示函数 g 中的定义。

田 注 皿 勾 利用 s t a 七i c 属性隐藏变量 和函数名字

C 程序员使 用 s t a t i c 属性隐藏模块内部的 变量 和函数声明 ,就 像 你在 Java 和 C++,

中使用 p u b 江 e 和 pr i v a t e 声 明一样。在 C 中, 源 文件扮演模块的 角 色。 任何带 有

S 七a t i c 属性声明的 全局 变量 或者函数都是模块私有的。类似地, 任何不 带 s t a t i c 属性声明的 全局变量 和函数都是公共的, 可以被其他模块访问。尽可能 用 s t a 巨 c 属性来保护你的变量和函数是很好的编程习惯。

符号表是由汇编器构造的 ,使用 编译器输出到汇编语言. s 文件中的符号。. s ymt a b 节中包含 ELF 符号表。这张符号表包含一个条目的数组。图 7-4 展示了每个条目 的格式。

code/linklelfstructs.c

typedef struct {

int name; I* String table offset *I

char type:4, I* Function or data (4 bits) *I

binding:4; I* Local or global (4 bits) *I

char reserved; I* Unused *I

short section; I* Section header index *I

long value; I* Section offset or absolute address *I

long size; I* Object size in bytes *I

} Elf64_Symbol;

图 7-4

code/link/elfstructs.c

ELF 符号表条目 。t ype 和 bi ndi ng 字段每个都是 4 位

na me 是字符串表中的字节偏移, 指向符号的以 n u l l 结尾的字符 串名字。v a l ue 是符号的地址。对 于可重定位的 模块来说, v a l u e 是距定义目标的节的起始位置的偏移。对于可执行目标文件来说 ,该 值是一个绝对运行时地址 。s i z e 是目标的大小(以字节为单位)。t ype 通常要 么是数据, 要么是函数。符号表还可以 包含各个节的条目, 以及对应原始源文件的路径名的 条目。所以这些目标的类型也有所不同。b i n d i ng 字段表示符号是本地的还是全局的。

每个符号都被分配到 目标文件的某个节, 由 s e c t i o n 字段 表示 , 该字段也是一个到节头部表的 索引。有三个特殊的伪节( ps e ud os ect io n ) , 它们在节头部表中是没有条目的:

ABS 代表不该被重定位的符号; UNDEF 代表未定义的符号, 也就是在本目标模块中引用,但是却在其他地方定义的符号; COM MON 表示还未被分配位置的未初始化的数据目标。对于 COMMON 符号, v a l u e 字段给出对齐要求, 而 s i z e 给出最小的大小。注意, 只有可重定位目标文件中才有这些伪节,可执行目标文件中是没有的。

COMMON 和. b s s 的区别很细微。现代的 GCC 版本根据以下规则来将可重定位目标

文件中的符号 分配到 CO MMO N 和. b s s 中:

COMMON 未初始化的全局变量

.bss 未初始 化的静 态变篮,以及 初始 化为 0 的全 局或静 态变量

采用这种看上去很绝对的区分方式的原因来自于链接器执行符号解析的方式, 我们会在

7. 6 节中加以 解释。

GNU READELF 程序是一 个查看目标文件内 容的很方便的工具 。比如, 下面是图 7-1 中示例程序的 可重定 位目标文件 ma i n . .o 的符号表中的 最后三个条目。开始的 8 个条目没有显示出来 , 它们是链接器内部使用的局部符号。

Num. :

Value

Size Type

Bind Vis

Ndx Name

8: 0000000000000000

9: 0000000000000000

10: 0000000000000000

24 FUNC

8 OBJECT

0 NOTYPE

GLOBAL DEFAULT GLOBAL DEFAULT GLOBAL DEFAULT

1 main

3 array UND sum

在这个例子中, 我们看到全局 符号 ma i n 定 义的条目, 它是一个位于. t e x t 节中偏移量 为 0 ( 即 va l ue 值)处的24 字节函数。其后跟随着的是全局符号 arr a y 的定义, 它是一个位于. da t a 节中偏移量为 0 处的 8 字节目标。最后一个条目来自对外部符号 s um 的引用. READEL F 用一个整数索引来标识 每个节。 Ndx =l 表示. t e xt 节, 而 Ndx =3 表示. da t a 节。

沁囡 练习题 7. 1 这个题 目针 对图 7-5 中的 m. o 和 s wa p . a 模块。 对于每 个在 s wa p . a 中定义或引 用 的符 号, 请 指 出 它 是否在模块 s wa p . a 中的 . s ym七a b 节 中 有 一个 符号表条目。 如果 是, 请指 出定义该 符号的模 块( s wa p . a 或者 m. o ) 、 符号 类型(局部、 全局或者外部)以及它在模 块中被 分配到的 节( . t e x t 、. d a t a 、. b s s 或 COMMON ) 。

void swap();

code/link/m.c

extern int buf [) ;

code/linklswap.c

int buf[2] = {1, 2};

int main()

swap(); return O;

code/link/m.c

int *bufpO = &buf[O]; int *bufpl;

void swap()

int temp;

bufp1 = &buf[1]; temp= *bufpO;

*bufpO = *bufp1;

*bufp1 = temp;

  1. m.c b) swap. c

图 7-5 练习题 7. 1 的示例程序

code/link/swap.c

7. 6 符号解析

链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的 一个确定的符号定义关联起来。对那些和引用定义在相同模块中的局部符号的引用,符号 解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变 量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。

不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义 的符号(变扯或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并 把它交给链接器处理。如果链接器在它的 任何输入模块中都找不到这个被引用符号的定义,就输出一条(通常很难阅读的)错误信息并终止。比如,如果我们试着在一

台 L in ux 机器上编译和链接下面的源文件 :

void foo(void);

int main() { foo(); return O;

那么编译 器会没有障碍地运行, 但是当链接器无法解析对 f o o 的引用时, 就会终止:

linux> gee -Wall -Og -o linkerror linkerror. e

/tmp/ccSz5uti.o: In function’main':

/tmp/ccSz5uti.o(.text+Ox7): u 卫 defi ned reference t o ’ f oo'

对全局符号的符号解析很棘手,还因为多个目标文件可能会定义相同名字的全局符 号。在这种情况中,链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃 其他定义。 Li nu x 系统采纳的方 法涉及 编译器、汇编器和链接器之间的协作, 这样也可能

给不警觉的程 序员带来一些麻烦。

m 对 C + + 和 J a va 中链接器符号的重整

C++ 和 Java 都允许重栽方法,这 些方法在 源代码中有 相同的名宇,却 有不同的 参数

列表。那么链接器是如何区别这些不同的重栽函数之间的差异呢? C + + 和 J ava 中能使 用重栽函数,是因为编译器将每个唯一的方法和参数列表组合编码成一个对链接器来说唯一的名字。 这种编码过程叫做 重整 ( mangling ) , 而相反的过程叫做 恢复( demangling ) 。

幸运的是 , C ++ 和 Java 使用兼容的 重整 策略。一个被 重整的 类名 字是由名字中宇符的整数数 量, 后面跟原始名 字组成的 。比如,类 Fo o 被编码成 3Foo 。方法被 编码为原始方法名,后 面加上__, 加上被重整的 类名 ,再加 上每个参数的 单宇母 编码。比如, Foo : :bar (int, l ong) 被编码为 b ar 3Foo 斗。 重整全局 变量 和模板名字的 策略是相似的。

  1. 6. 1 链接器如何解析多重定义的全局符号

    链接器的输入是一 组可重定位目标模块。每个模块定义一组符号, 有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块也可见)。如果多个模块定义同名 的全局符号 , 会发生什么呢?下面是 L in ux 编译系统采用的 方法。

    在编译时, 编译器向汇编器输出每个全局符号, 或者是强 ( st ro n g ) 或者是弱 ( w ea k ) , 而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局 变量是强符号,未初始化的全局变量是弱符号。

    根据强弱符号 的定义, L in ux 链接器使用下面的规则来处 理多重定义的符号名:

    • 规则 1 : 不允许有多个同名的强符号。
  • 规则 2 : 如果有一个强符号和多个弱符号同名,那么选择强符号。

  • 规则 3: 如果有多个弱符号同名,那么从这些弱符号中任意选择一个。比如, 假设我们试图编译和链接下面两个 C 模块:

    I* fool.c *I

    int main()

    return O;

I* barl.c *I

2 int main()

3 {

return O;

5 }

在这个情况中,链 接 器将生成一条错误信息,因 为强符号 ma i n 被定义了多次(规则D:

linux> gee foo1.c bar1.c

/tmp/ccq2Uxnd.o: In function’main’: barl.c:(.text+OxO): multiple definition of’main'

相似地,链 接 器 对 于下面的模块也会生成一条错误信息,因 为 强 符号 x 被定义了两次

(规 则 U :

I* foo2.c *I

2 int X = 15213;

4 int main()

5 {

return O;

7 }

I* bar2.c *I

2 int X = 15213;

4 void f()

5 {

6 }

然而,如 果 在 一 个 模 块 里 x 未被初始化,那 么 链 接器将安静地选择在另一个模块中定义 的 强 符 号(规则 2 ) :

I* foo3.c *I

2#include <stdio.h>
3void f(void);
5int X = 15213;
7int main()
8{
10f (); printf(“x = 炽\ n”,x);
return O;
12}

I* bar3.c *I

2 int x·

4 void f ()

5 {

6 X = 15212;

7 }

在运行时,函 数 f 将 x 的 值 由 1 5 21 3 改 为 1 521 2 , 这会 给 ma i n 函 数 的 作 者带来不受欢 迎 的 意 外! 注 意, 链接器通常不会表明它检测到多个 x 的定义:

linux> gee -o foobar3 foo3.e bar3. e linux> ./foobar3

X = 15212

如果 x 有两个弱定义,也 会发生相同的事情(规则3 ) :

I* foo4.c *I

#include <s t d i o . h> void f(void);

int x;

int main()

X = 15213 ;

f O;

printf(“x = %d\n”, x); return O;

I* bar4.c *I

int x;

void f 0

X = 15212;

规则 2 和规则 3 的应用会造成一些不易察觉的运行时错误, 对于不警觉的程序员来说,是很难理解的, 尤其是如果重复的符号定义还有不同的类型时。考虑下面这个例子, 其中 x 不幸地在一个模块中定义为 i n t , 而在另一个模块中定义为 d o u b l e :

I* foo5.c *I

#include <s t di o . h> void f(void);

int int

15212;

15213;

int main()

f O;

pr i nt f (“x = Ox% x y = Ox 妘 \ n “’ x, y);

return O;

I* bar 5 . c *I

double x;

void f 0

X “’ - 0 . Q;

在一台 x86- 64 / L in u x 机器上, d o u b l e 类 型 是 8 个 字节 ,而 i n t 类 型 是 4 个字节。在我们的系统中, x 的 地址是 Ox 601 02 0 , y 的 地址是 Ox 601 0 2 4。因此, b ar 5 . c 的 第 6 行中的赋值 x = -0. 0 将用负零的双精度浮点 表示覆盖内存中 x 和 y 的位置( fo o 5 . c 中的第 5 行和第 6 行)!

linux> gee -Wall -Og -o foobar5 foo5. e bar5. e

/usr/bin/ld: Warning: alignment 4 of symbol’x’in /tmp/cclUFK5g.o is smaller than 8 in /tmp/ccbTLcb9.o

linux> ./toobar5

x = OxO y = Ox80000000

这是一个细微而令人讨厌的错误,尤其是因为它只会触发链接器发出一条警告,而且 通常要在程序执行很久以后才表现出来,且 远离错误发生地。在一个拥有成百上千个模块的大型系统中,这种类型的错误相当难以修正,尤其因为许多程序员根本不知道链接器是 如何工作的。当你怀疑有此类错误时, 用 像 G C C - f n o - c o mmo n 标 志 这样的选项调用链接器,这个选项会告诉链接器,在遇到多重定义的全局符号时,触发一个错误。或者使用

- Wer r or 选 项 ,它 会把所有的警告都变为错误。

在 7. 5 节中, 我们看到了编译器如何按照一个看似绝对的规则来把符号分配为 OM

MON 和. bs s 。实际上, 采用这个惯例是由千在某些情况中链接器允许多个模块定义同名航全局符号。当编译器在翻译某个模块时, 遇到一个弱全局符号,比 如说 x , 它并不知道其他模块是否也定义了 x, 如果是,它 无法 预 测链接器该使用 x 的多重定义中的哪一个。所以编译

器把 x 分配成COMMON, 把决定权留给链接器。另一方面, 如果 x 初始化为o, 那么它是一个

强符 号(因此根据规则 2 必须是唯一的),所以 编译 器可以很自信地将它分配成. bs s 。类似地, 静态符号的构造就必须是唯一的,所以编译 器可以自信地把它们分配成. da t a 或. bs s 。

; 练习题 7. 2 在此题 中, REF (x. i)-DEF (x.k) 表 示链 接器 将把模 块 1 中对符 号 x 的任意引用 与模块 k 中 x 的定 义关联 起来。对于下 面的 每个 示例 ,用 这种表 示 法来 说明链接器将如何解析 每个模块 中对 多 重定义 符 号 的引 用。 如果有 一个链接 时错误(规则 1 )’ 写

“错 误"。 如 果链接 器从 定义中任意选择 一个(规则 3) , 则写“未知”。

  1. I* Module 1 *I

    int main()

    I* Module 2 *I

    int main;

    int p20

  2. REF( ma i n .1) DEF(.)

    (REF(ma i n .2) DEF(.)

BI.

Module 1 *I

I* Module 2 *I

void main()

int main= 1; int p2()

(a) REF(ma i n .1) DEF(.)

(REF(ma i n .2) DEF(.)

CIModule 1 *I

int x;

void main()

I* Module 2 *I double x = 1.0; int p2()

  1. REF(x.1) DEF( . )

    Cb) REF(x.2) DEF(.)

7. 6. 2 #

与静态库链接

迄今为止 , 我们都 是假设链 接器读取一组可重定位目标文件, 并把它们链接起来,形成一个输出的可执行文件。实际上,所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件, 称为静态库 ( s t atic library), 它可以用做链接器的输入。当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。

为什么系统要 支持库的概念呢?以 ISO C99 为例, 它定义了一组广泛的标准 I/ 0 、字符串操作 和整数数学函数, 例如 a t o i 、p r i n t f 、s c a n f 、S 七r c p y 和r a nd 。它们在 l i b c .

a 库中, 对每个 C 程序来说都是可用的。ISO C99 还在 li b m. a 库中定义了一组广泛的浮点数学函数 , 例如 s i n 、c o s 和 s q r t 。

让我们来看看如果不使用静态库,编译器开发人员会使用什么方法来向用户提供这些 函数。一种方法是让编译器辨认出对标准函数的调用 , 并直接生成 相应的代码。Pascal ( 只提供了一小部分标准函数)采用的就是这种方法, 但是这种方法对 C 而言是不合适的, 因为 C 标准定义了大量的标准函数。这种方法将给编译器增加显著的复杂性, 而且每次添加、删除 或修改一个标准函数时,就需 要一个新的编译器版本。然而,对 于应用程序员而言,这 种方法会是 非常方便的 , 因为标准函数将总是可用的 。

另一种方法是 将所有的标准 C 函数都放在一个单独的可重定位目标模块中(比如说

libc.o中)应用程序员 可以把这个模块链接到他们的 可执行文件中 :

linux> gee main.e /usr/lib/libe.o

这种方法的优点是它将编译器的实现与标准函数的实现分离开来,并且仍然对程序员 保持适度的便 利。然而, 一个很大的缺点是系统中每个可执行 文件现在都包含着一份标准函数集合的完 全副本, 这对磁盘空间是很大的浪费。(在一个典型的系统上, 辽 b e . a 大 约是 5MB , 而 让bm. a 大约是 2M B。)更糟的是, 每个正 在运行的程序都将它 自己的 这些函数的副本放在内存中 , 这是对内存的极度浪费。另一个大的缺点是, 对任何标准函数的任何改变,无论多么小的改变,都要求库的开发人员重新编译整个源文件,这是一个非常耗时 的操作,使得标准函数的开发和维护变得很复杂。

我们可以通过为每个标准函数创建一个独立的可重定位文件,把它们存放在一个为大

六仁 家都知道的目 录中来解 决其中的一些间题。然而, 这种方法要求应用程序员显式地链接合

适的目标模块到它们的可执行文件中,这是一个容易出错而且耗时的过程:

linux> gee main. e /usr/lib/printf.o /usr/lib/seanf.o . . .

静态库概念被提出来,以解决这些不同方法的缺点。相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数。比如, 使用 C 标准库和数学库中函数的程序可以用形式如下的命令行来编译和链接:

linux> gee main.e /usr/lib/libm.a /usr/lib/libe.a

在链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内 存中的大小。另一方面,应用 程序员只需要包含较少的 库文件的名字(实际上, C 编译器驱

动程序总是传送 li b c . a 给链接器,所以前 面提到的对 让be . a 的引用是不必要的)。

在 L in u x 系统中, 静态库以一种称为存档( a rc h ive ) 的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文 件的大小和位置。存档文 件名由后 缀 . a 标识。

为了使我们对库的讨论更加形象具体, 考虑图 7- 6 中的两个向量例程。每个例 程, 定义在它自己的目标模块中,对两个输入向量进行一个向量操作,并把结果存放在一个输出 向量中。每个例程有一个副作用,会记录它自已被调用的次数,每次被调用会把一个全局 变量加 1。(当我们在 7. 1 2 节中解释位置无关 代码的思想 时会起作用。)

1 int addcnt = 0;

code/link/addvec.c

int multcnt = O;

code/linklmultvec.c

2 2

  1. void addvec(int *X, int *Y, 3 void multvec(int *X, int *Y,

  2. int *Z, int n) 4 int *z, int n)

    5 { 5 {

    6 inti; 6 inti;

    7 7

    8 addcnt++; 8 multcnt++;

    9 9

    10 for (i = O; i < n; i++) 10 for (i = O; i < n; i++) 11 Z (i] = X [i] + y [i] ; 11 Z [i] = X [i] * y [i] ;

    12 } 12 }

    code/linkladdvec.c codellinklmultvec.c

  3. addvec. o b) multvec.o

    图7-6 l i bvec t or 库中的成员目标 文件

要创建这些函数的一个静态库 , 我们将使用 AR 工具, 如下:

linux> gee -e addvee.e multvee.e

linux> ar res l i bveet or .a addvee.o multvee.o

为了使用这个库 , 我们可以编写一个应用, 比如图 7-7 中的 ma i n 2 . c , 它调用 a ddve c

库例程。包含(或头)文件v e c t o r . h 定义了 巨 bv e c t or . a 中例程的函数原型。

code/link/main2.c

  1. #include <stdio. h>

  2. #include “vector .h”

    3

    4 int X[2] = {1, 2};

    5 int y[2] = {3, 4};

    6 int z[2];

    7

    8 int marnO

    9 {

    1o addvec (x, y, z, 2) ;

    11 printf(“z = [%d %d]\n”, z[O], z[1]);

    12 return O,·

    13 }

code/link/main2.c

图 7-7 示例程序 2。这个程序调用 l i bve c t or 库中的函数

为了创建这个可执行 文件, 我们要 编译和链接输入文件 ma i n . a 和 l i b v e c t o r .a:

linux> gee -e main2.e

linux> gee -statie -o prog2e mai n 2 . o . / l i bve ct ro . a

或者等价地使用:

l i nux> gee - e ma i n2 . e

l i nu x > gee -statie -opr og2e mai n2 . o - L . -lveetor

图7-8 概括了链接器的行为。- s t a t i c 参数告诉编译器驱动程序,链 接 器 应 该 构 建 一个完全链 接的可执行目标文件, 它 可以加载到内存并运行, 在 加 载时无须更进一步的链接。 - l v e c t or 参 数是 l i b v e c t or . a 的 缩写, - L . 参 数 告诉 链接器在当前目录下查找 li b - ve c t o r . a 。

源文件 main2.c vector.h

翻译器

(epp, eel, as) I l i bvee t or . a l i bc . a 静态库

可重定位目标文件 ma1.n 2 . o I addvec. o

链接器 (l d )

pr i nt f. o和其他pr i nt f. o调用的模块

pr og 2_c 完全链接的

可执行目标文件图 7-8 与静态库链接

当链接器运行时 , 它判定 ma i n 2 . o 引 用 了 a d d v e c . o 定 义的 a d d v e c 符号 ,所 以 复 制addve c . o 到可执行文件。因为程序不引用任何由 mu l t v e c . o 定 义 的 符 号 ,所 以 链 接 器 就不会复制这个模块到可执行文件。链接器还会复制 l i b c . a 中的 pr i n t f . o 模块,以 及 许多 C 运行 时系统中的其他模块。

  1. 6. 3 链接器如何使用静态库来解析引用

    虽然静态库很有用,但 是 它 们 同 时 也 是 一个程序员迷惑的源头,原 因 在 于 L in u x 链接器使用它们解析外部引用的方式。在符号解析阶段,链接器从左到右按照它们在编译器驱 动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。(驱动程序自动将命令 行中所有 的 . c 文件翻译为 . o 文件。)在这次扫描中, 链接器维护一个可重定位目标文件的集合 E C这个集合中的文件会被合并起来形成可执行文件), 一 个 未解析的符号(即引用了

    但是尚未定 义的符号)集合 u , 以及一个在前面输入文件中已定义的符号集合 D。初始时,

    E、U 和 D 均为空。

    • 对千命令行上的每个输入文件 f , 链接器会判断 J 是一个目标文件还是一个存档文件。如果 J 是一个目标文件,那 么链 接器把 f 添加到 E , 修改 U 和 D 来反映 f 中的符号定义和引用, 并 继 续下 一 个 输 入 文 件 。

    • 如果 J 是一个存档文件,那 么链接器就尝试匹配 U 中未解析的符号和由存档文件成员定

      义的符号。如果某个存档文件成员 m, 定义了一个符号来解析 U 中的一个引 用,那么就将 m 加到E 中, 并 且链 接 器修改 U 和D 来反映 m 中的符号定义和引用。对存档文件中所有的成员目标文件都依次进行这个过程, 直 到 U 和 D 都不再发生变化。此时,任何不包含在 E 中的成员目标文件都简单地被丢弃,而 链接器将继续处理下一个输入文件。

;,

  • 如果当链接器完成对命令行上输入文件的扫描后, U 是非空的, 那么链接器就会输出一个错误并终止。否则,它 会合并和重定位E 中的目标文件,构 建输出的可执行文件。

    不幸的是,这种算法会导致一些令人困扰的链接时错误,因为命令行上的库和目标文 件的顺序非常重要。在命令行中, 如果定义一个符号的库出现在引用这个符号的目标文件之前, 那么引用就不能被解析, 链接会失败 。比如, 考虑下面的命令行发生了什么?

    linux> gee -static ./libvector.a main2.c

    /tmp/cc9XH6Rp.o: In function’main':

    / t mp / cc9XH6Rp . o ( . t e xt +Ox18 ) : undefined reference to’addvec'

在处理 l i b v e c t o r . a 时, U 是空的,所 以没有 l i b v e c t or . a 中的成员目标文件会添加到 E 中。因此, 对 a d d v e c 的引用是绝不会 被解析的, 所以链接器会产生一条错误信息并终止。

关于库的一般准则是将它们放在命令行的结尾。如果各个库的成员是相互独立的(也就是说没有成员引用另一个成员定义的符号),那么这些库就可以以任何顺序放置在命令 行的结尾处。另一方面,如果库不是相互独立的,那么必须对它们排序,使得对千每个被 存档文件的成员外部引用的符号 s’ 在命令行中 至少有一个 s 的定义是在对 s 的引用之后的。比如, 假设 f o o . c 调用 l i b x . a 和 l i b z . a 中的函数, 而这两个库又调 用 li b y : a 中的 函数。那么,在 命令行中 让b x . a 和 l i b z . a 必须处在 l i b y . a 之前:

linux> gee foo.e l i bx.a libz.a liby.a

如果需要满足依 赖需求, 可以在命令行上重复库。比如, 假设 f o o . c 调用 li bx . a 中的 函数 ,该 库又调用 l i b y . a 中的函数, 而 l i b y . a 又调用 l i b x . a 中的函数。那么 li bx.

a 必须在命令行 上重复出现:

linux> gee foo.e l i bx.a liby.a libx.a

另一种方法是, 我们可以将 l i b x . a 和 li b y . a 合并成一个单独的存档文件。

让 练习题 7. 3 a 和 b 表示当前目录中的目标模 块或者静态库,而 a- b 表示 a 依赖于b, 也就是说 b 定义了一 个被 a 引用的符号。对于下面每种场景,请给出最小的命令行(即 一个含有最少数量的目标文件和库参数的命令), 使得静态链接器能解析所有的符号引用。

A. p.o -libx.a
B. p.o -libx.a - liby.a
C. p . o -巨 b x . a - l i b y . a 且巨 b y . a -libx.a - p.o
  1. 7 重定位

    一旦链接器完成了符号解析这一 步, 就把代码中的 每个符号引 用和正好一个符号定义

    (即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输人目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骇中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:

    • 重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的 新的聚合节。例如 ,来自所有 输入模块的 . d a t a 节被全部合并成一个节, 这个节成为输出的可执行目 标文件的 . d a t a 节。然后, 链接器将运行时内存地址赋给新的聚合节,赋 给输入模块定 义的每个节,以 及赋给输入模 块定义的每个符号。当这一步完成时, 程序中 的每条指令和全局变量都有唯一的运行时内存地址了。
  • 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位 条目 ( r e lo ca t ion e n t r y ) 的数据结构, 我们接下来将会描述这种数据结构。
  1. 7. 1 重定位条目

    当汇编器生成一个目标模块时,它 并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何 时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将 目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在 .r e l . 七e x t 中。已初始 化数据的重定位条目放在 . r e l . d a 七a 中。

    图 7-9 展示了 ELF 重定位条目 的格式。o f f s e t 是需要被修改的引用的节偏移。 s ymbo l 标识被修改引 用应该指向的符号。t y pe 告知链接器如何修改新的引用。a d d e n d 是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。

    codellinklelfstructs.c

    typedef struct {

    long offset; I* Offset of the reference to relocate *I

    long type:32, I* Relocation type *I

    symbol:32; I* Symbol table index *I

    long addend; I* Constant part of relocation expression *I

    } Elf64_Rela;

图 7-9

codellink/elfstructs.c

ELF 重定位条目。每个条目表示一个必须被重定位的引用 , 并指明如何计算被修改的引用

ELF 定义了 3 2 种不同的重定位类型,有 些相当隐秘。我们只关心其中两种最基本的重定位类型:

  • R_ X8 6_ 6 4_ PC32。重定位一个使用 32 位 PC 相对地址的引用。回想一下 3. 6. 3 节,

    一个 PC 相对地址就是距程序计数器( PC ) 的当前运行时值的偏移量。当 CPU 执行一条使用 PC 相对寻址的指令时, 它就将在指令中编码的 32 位值加上 PC 的当前运行时值, 得到有效地址(如c a l l 指令的目标), PC 值通常是下一条指令在内存中的地址。

    • R_ X8 6_ 6 4_ 32 。重定位一个使用 3 2 位绝对地址的引用。通过绝对寻址, CP U 直接使用在指令 中编码的 32 位值作为有效地址, 不需要进一 步修改。

      这两种重定位类型支持 x86- 64 小型 代码模型( small code model) , 该模型假设可执行目标 文件中的代码和数据的总体大小小 于 2G B, 因此在运行时 可以用 32 位 PC 相对地址来访问。GCC 默认使用小 型代码模型。大千 2G B 的程序可以用- mc mod e l =me d i u m( 中型代码模型) 和- mc mo d e l =l ar g e ( 大型代码模型)标志来编译, 不过在此我们 不讨论 这些模型。

  1. 7. 2 重定位符号引用

    图 7-10 展示了链接器的重定位算法的伪代码。第 1 行和第 2 行在每个节 s 以及与每个节相关联 的重定位条目r 上迭代执行。为了 使描述具体化, 假设每个 节 s 是一个字节数组,每个重 定位条目r 是一个类型为 El f 6 4_ Re l a 的结构, 如图 7- 9 中的定义。另外, 还

假设当算法运行时 , 链接器巳经为每个节(用 ADDR (s ) 表示)和每个符号都选择了运行时地址(用 ADDR (r. s ymbo l ) 表示)。第3 行计算的是需要被重定位的 4 字节引用的数组 s 中的地址。如果这个引用使用的是 PC 相对寻址 , 那么它就用第 5~ 9 行来重定位。如果该引用使用 的是 绝对寻址,它 就通过第 11 ~ 1 3 行来重定 位。

foreach sections {

2 foreach relocation entryr {

3refptr = s + r.offset; I* ptr to reference to be relocated *I
4
5I* Relocate a PC-relative reference *I
6if Cr.type== R_X86_64_PC32) {
7refaddr = ADDR(s) + r.offset; I* ref’s run-time address *I
8*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr);
9
10
11/ * Relocate an absolute reference *I
12if (r.type == R_X86_64_32)
13*refptr = (unsigned) (ADDR(r.symbol) + r.addend);
14}
15}

图 7-10 重定位算法

让我们来看看链接器 如何用这个算法来重定位图 7-1 示例程序中的引 用。图 7-11 给出了(用o b j dump-dx main. o 产生的)GNU OBJDU MP 工具产生的 ma i n . o 的反 汇编代码。

codellinklmain-relo.d

1 0000000000000000 <main> :

20:4883ec08sub$0x8,%rsp
34 :be02000000mov$0x2,%esi
49 :bf00000000mov$0xO,%edi¾edi = &array
5a:R_X86_64_32arrayRelocation entry
6e:e800 00 0000callq 13 <main+Ox13>sum()
7f:R_X86_64_PC32 sum-Ox4Rel ocat 工on entry
813: 17:48 c383 c4 08add $0x8,%rsp retq
code/linklmain-relo.d

图 7-11 ma i n. o 的 代码和重定位条目 。原始 C 代码在图 7-1 中

ma i n 函数引用了两个全局符号: ar r a y 和 s u m。 为每个引用, 汇编器产生一个重定位条目,显 示在引用的后面一行上 产 这些重定位条目告诉链接器对 s um 的引用要使用 32 位 PC 相对地址进行重定位, 而对 arr a y 的引用要使用 32 位绝对地址进行重定 位。接下来两节会详细介绍链接器是如何重定位这些引用的。

1 重定位 PC 相对引 用

图 7-11 的第 6 行中, 函数 ma i n 调用 s um 函数, s u m 函数是在模块 s u m. o 中定义的,

e 回想一下, 重定 位条目和指令实际上存 放在目 标 文件的 不同 节中 。 为了 方便, O BJDUMP 工具把它们显示在一起。

ca ll 指令开始于节偏 移 Ox e 的地方, 包括 1 字节的操作码 Ox e 8 , 后面跟着的是对目标

s um 的 32 位 PC 相对引用的占位符。

相应的重定位条目r 由 4 个字段组成:

r.offset = Oxf r.symbol = sum

r.type = R_X86_64_PC32

r.addend = -4

这些字段告 诉链接器修改 开始千偏移最 Ox f 处的 32 位 PC 相对引用, 这样在运行时它会指向 s um 例程。现在, 假设链接器已经确定

ADDR(s) = ADDR( . text) = Ox4004d0

ADDR(r.symbol) = ADDR(sum) = Ox4004e8

使用图 7-10 中的算法, 链接器首先计算出引用的运行时地址(第 7 行):

refaddr = ADDR(s) + r.offset

= Ox4004d0 + Oxf

= Ox4004df

然后, 更新该引用,使 得它在运行时指向 s um 程序(第8 行):

*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)

= (unsigned) (Ox4004e8 + (-4) - Ox4004df)

= (unsigned) (Ox5)

在得到的可执行目 标文件中, c a l l 指令有如下的重定位的形式 :

4004de: e8 05 00 00 00 callq 4004e8 <sum> sum()

在运行时 , c a l l 指 令将存放在地址 Ox 4 00 4d e 处。当 CPU 执行 c a l l 指令时, P C 的值为 Bx 40 0 4e 3 , 即紧随在 c a 荨 指令之后的指令的地址。为了执行这条指令, CPU 执行以下的步骤:

  1. 将 PC 压 入栈中

    1. PC 七 PC + Ox5 = Ox4004e3 +Ox5 = Ox4004e8

    因此, 要执行的下一 条指令就是 s um 例程的第一条指令, 这当然就是 我们想 要的!

    2. 重定位绝对引用

    重定位绝对引用相当简单。例如,图 7-11 的第 4 行中, mo v 指令将 arr a y 的地址(一个32 位立即数值 )复制到寄存器%e d i 中。mo v 指令开始于节偏移量 Ox 9 的 位置, 包括 1 字节操作码 Ox b f , 后 面跟着对 a rr a y 的 32 位绝对引用 的占位符。

    对应的占 位符条目r 包括 4 个字段:

    r.offset = Oxa

  2. symbol = array

    r .t ype = R_X86_64_32 r.addend = 0

    这些字段告诉链接器要 修改从偏移量 Ox a 开始的绝 对引用, 这样在运行时它将会指向

    arr a y 的第一个字 节。现 在, 假设链接器已经确定

    ADDR(r.symbol) = ADDR(array) = Ox601018

链接器使用图 7-1 0 中算法的第 1 3 行修改了引用:

*refptr = (unsigned)

(unsigned) (unsigned)

(ADDR(r.symbol) + r.addend) (Ox601018 + 0) (Ox601018)

在得到的可执行目标文件中, 该 引 用 有 下 面的重定位形式:

4004d9 : bf 18 10 60 00 mov $0x601018,%edi ¼edi = &array

综合到一起,图 7- 1 2 给出了最终可执行目标文件中已重定位的 . t e xt 节和 . da t a 节。在加载的时 候 ,加 载器会把这些节中的字节直接复制到内存,不 再进行 任何修改地执行这些指令。

00000000004004d0 <main>:

4004d0: 48 83 ec 08

sub

$0x8 , %r s p

4004d4:be0200 00 00mov$0x2,%esi
4004d9:bf1810 60 00mov$0x601018,%edi¼edi = &array
4004de:e80500 00 00callq4004e8 <sum>sum()
4004e3:4883c4 08add$0x8,%rsp
4004e7:c3retq

00000000004004e8 <sum>:

4004e8: b8 00 00 00 00

mov

$0x0,%eax

4004ed: ba 00 00 00 00

4004£2: eb 09

4004£4: 48 63 ca

4004£7: 03 04 8f

4004fa: 83 c2 01

4004fd: 39 f2

4004ff: 7c f3 400501: f3 c3

mov $0x0,%edx

jmp 4004f d <sum+Ox15> mo v s l q %edx,%rcx

add (%rdi,%rcx,4),%eax add $0x1,%edx

cmp %esi,%edx

jl 4004f4 <sum+Oxc> repz retq

  1. 已重定位的 . t ext 节

图 7-12

0000000000601018 <arr a y> :

601018: 01 00 00 00 02 00 00 00

  1. 巳重定位的. dat a 节

可执行文件 pr og 的已重定位的 . t e江 节和 . da t a 节。原始的 C 代码在图 7-1 中

; 练习题 7. 4 本题是关 于图 7- 1 2a 中 的 已 重定位程序的。

  1. 第 5 行中对 s um 的 重定 位引用 的 十 六进 制地 址是 多少?

  2. 第 5 行中 对 s u m 的 重定位引用的十 六进制值是多少?

    ; 练习题 7. 5 考虑目标 文件 m . o 中对 s wa p 函数 的调用(图 7- 5 ) 。

    9: e8 00 00 00 00 callq e <ma i n +Ox e > swap()

它的重定位条目如下:

r.offset = Oxa r . s ymbol = swap

r.type = R_X86_64_PC32 r.addend = -4

现在假设链接器 将 m . o 中 的 . t e x t 重 定位到 地 址 Ox 400 4d 0 , 将 s wa p 重定位到地 址

Ox 4 0 0 4e 8 。那么 c a l l q 指令中对 s wa p 的重定 位引用的值是什么?

7. 8 可执行目标文件

我们已经看到链接器如何将多个目标文件合并成一个可执行目标文件。我们的示例 C 程序 , 开始时是一组 ASCII 文本文件,现 在 已经被转化为一个二进制文件, 且这个二进制文件包 含加载程序到内存并运行它所需 的所有信息。图 7-13 概括了一个典 型的 ELF 可 执行文件中的各类信息。

只读内存段(代码段 )

}读/写内存段(数据段)

描述目标文件的节{

不加载到内存的符号表

和调 试信 息

图 7-13 典型的 ELF 可执行目标 文件

‘,,

可执行目标文件的格式类似于可重定位目标文件的格式。ELF 头描述文件的总体格 式。它 还包 括程序的入口点( e n t r y point), 也就是当程序运行时要执行的第一条指令的地址。. t e x t 、.r o d a t a 和 . d a 七a 节 与可重定位目标文件中的节是相似的,除 了 这些节巳经被重定位到它们最终的运行时内存地址以外。. i n it 节定 义了一个小函数,叫 做 _ i n i 七,程 序 的初始 化代码会调用它。因为可执行文件是完全链接的(已被重定位),所以 它 不 再 需 要 . r e l 节。

ELF 可执行文件被设计得很容易加载到内存, 可执行文件的连续的片( c h u n k ) 被映射到连续的内存段。程序头部 表 ( p ro g ra m header table ) 描述了这种映射关系。图 7- 1 4 展示了可执行 文件 pr o g 的程序头部表, 是 由 O BJDU MP 显示的。

code/linklp rog-exe.d

Read-only code s egme nt

1 LOAD off OxOOOOOOOOOOOOOOOO vaddr Ox0000000000400000 paddr Ox0000000000400000 align 2**21

2 filesz Ox000000000000069c memsz Ox000000000000069c flags r-x

Rea d/ wr i t e datas egme nt

3 LOAD off Ox0000000000000df8 vaddr Ox0000000000600df8 paddr Ox0000000000600df8 align 2**21

4 filesz Ox0000000000000228 memsz Ox0000000000000230 flags rw-

codellinklprog-exe.d

图 7-14 示例可执行文件 p r og 的程序头部表

off: 目标文件中的偏移; vaddr/paddr: 内存地址; al i gn : 对齐要求; filesz: mems z: 内存 中的 段 大小; flags : 运 行 时访 问权 限 。

目标文件中的段大小;

从程序头部表, 我们会看到根据可执行目标文件的内容初始化两个内存段。第 1 行和

第 2 行告诉我们第一个段(代码段)有读/执行访问权限, 开始于内存地址 Ox 40 0000 处, 总共的内存大小是 Ox 69c 字节, 并且被初始化为 可执行目标 文件的头 Ox 69c 个字节 , 其中包括 E L F 头、程序头部表以及 . i n it 、. t e x t 和.r o da t a 节。

第 3 行和第 4 行 告 诉我们第二个段(数据段)有读/写访问权限, 开始于内 存地址

Ox 60 0d f 8 处,总 的 内 存大小为 Ox 230 字节, 并用从目标文件中偏移 Ox d f 8 处开始的

. d a t a 节中的 Ox 2 28 个字节初始化。该段中剩下的 8 个字节对应于运行时将被初始化为 0

的 . b s s 数据。

对于任何段 s , 链接器必须 选择一个起始地址 v a d dr , 使得

vaddr mod align= off modalign

这里, o ff 是目标 文件中段的 第一个节的偏移扯, a 止 g n 是程序头部中指定的对齐 c 221 =

Ox 2 0000 0) 。例如,图 7-1 4 中的数据段中

vaddr mod align = Ox600df8 mod Ox200000 = Oxdf8

以及

off mod align= Oxdf8 mod Ox200000 = Oxdf8

这个对齐要求是一种优化,使得当程序执行时,目标文件中的段能够很有效率地传送到内 存中。原因有点儿微妙,在千虚拟内存的组织方式,它被组织成一些很大的、连续的、大 小为 2 的幕的字节片。第 9 章中你会学习到虚拟内存的知识。

7. 9 加载可执行目标文件

要运行 可执行目标文件 pr o g , 我们可以在 Lin ux s hell 的命令行中输入它的名字:

linux> ./prog

因为 pr og 不是一个内置的 s hell 命令, 所以 shell 会认为 pr og 是一个可执行目标文件,通 过洞用 某个驻留在存储器中称为加载器( loade r ) 的操作系统代码来运行它。任何L in ux 程序都 可以通过调用 e xe c ve 函数来调用加载器, 我们将在 8. 4. 6 节中详细描述这个函数。加载器将可执行目标文件中的代码和数据从磁盘复制到内存中 ,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加栽。

每个 L in ux 程序都有一个运行时内存映像, 类似千图 7-1 5 中所示。在 Lin ux x86-64 系统中, 代码段总是从地址 Ox 400000 处开始, 后面是数据段。运行时堆在数据段之后, 通过调用 ma l l o c 库往上增长。(我们将在 9. 9 节中详细描述 ma l l o c 和堆。)堆后面的区域是为共享模块保留的。 用户栈总是从最大的合法用户地址 ( 24 8- 1 ) 开始, 向较小内存地址增长。栈上的区域, 从地址 沪 开始, 是为内核 ( kern el ) 中的代码和数据保留的 , 所谓内核就是操作系统驻留在内存的部分。

为了简洁,我们把堆、数据和代码段画得彼此相邻,并且把栈顶放在了最大的合法用 户地址处。实际上, 由千. d a t a 段有对齐要求(见 7. 8 节), 所以代码段和数据段之间是有间隙的。同时,在分配栈、共享库和堆段运行时地址的时候,链接器还会使用地址空间布 局随机化( AS L R , 参见 3. 10. 4 节)。虽然每次程序运行时 这些区域的地址都会改变, 它们的相对位置是不变的。

当加载 器运行时, 它创建类似于图 7-1 5 所示的内存映像。在程序头部表的引导下, 加载器将可执行文件的片( ch un k ) 复制到代码段和数据段。接下来, 加载器跳转到程序的

入口点 ,也 就 是 _s t ar t 函 数 的 地址。这个函数是在系统目标文件 c tr l . o 中定义的,对 所有的 C 程序都是一样的。_s t ar t 函数调用系统启动函数__让 b c _ s t a r t —ma i n , 该函数定义在 l i b c .s o 中。它初始化执行环境, 调 用 用 户 层 的 ma i n 函 数 ,处 理 ma i n 函 数 的 返 回值,并且在需要的时候把控制返回给内核。

248- 1

内核内存

用户栈

(运行时创建)

了 飞/汇’ • 心气勺_;

共享库的内存映射区域

见的内存

千- %rsp ( 栈指针)

护.、,又

`,令— br k

运行时堆

(由ma l l o c 创建 )

Ox 400 0 00

乏,

图 7-15 Linux x86-64 运行时内存映像。没有展示出由于段对齐要求和地址空间布局随机化( ASLR) 造成的空隙。区域大小不成比例

日 日-加载器实际是如何工作的? #

我们对于加 栽的描述从概念上来说 是 正确的,但 也 不是 完全准确, 这是有意为 之 。要理解加栽实际是如何工作的,你必须理解进程、虚拟内存和内存映射的概念,这些我 们还 没有加以讨论。在后面 笫 8 章 和 笫 9 章 中遇到这些概念时, 我们将重新回到加栽的问题上, 并逐渐向 你揭开它的 神秘面纱 。

对于不够有耐心的读者,下面是关于加栽实际是如何工作的一个概述: Lin ux 系统中的 每个程序都运行在一个进程上下文中, 有自 己的 虚拟地址空间。 当 s hell 运行一个程序时, 父 s hell 进程生成 一个子进程, 它是 父进 程的一个复 制。子进程通过 e xe cv e 系统调 用启动加栽器。加 栽器删 除子进 程现有的虚拟内存段, 并创 建 一组新的代码、数据、堆 和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的 页映 射 到 可执行文件的 页大小的 片 ( c h unk ) , 新的代码和数据段被初始化为 可执行 文件的 内容。 最后 , 加栽器跳 转到_s t ar t 地址, 它最终会调用应 用程 序的 ma i n 函数。除了一 些头部 信息,在加栽过 程中没有 任何从磁盘到内存 的数据复制。直到 CPU 引 用一 个被 映射的虚拟页时 才会进行复制, 此时,操 作 系统 利用它的 页面 调度机制自动将 页面从磁 盘传送到内存。

  1. 10 动态链接共享库

    我们在 7. 6. 2 节中研究 的静 态库解决了许多关千如何让大量相关函数对应用程序可用的问题。然而,静 态库仍然有一些明显的缺点。静态库和所有的软件一样,需 要 定 期 维 护和更新 。如果应用程序员想要使用一个库的最新版本,他 们 必 须 以 某种方式了解到该库的

更新情况, 然后显 式地将他们的程序与更 新了的库重新链 接。

另一个问题是几乎每个 C 程序都使用标准 I/ 0 函数, 比如 pr i n t f 和 s c a n f 。在运行时,这些函数的代码会被复制到每个运行进程的文本段中。在一个运行上百个进程的典型 系统上, 这将是对稀缺的内 存系统资源的 极大浪费。(内存的一个有趣属性就是不论 系统的内存 有多大,它 总 是一种稀缺资源。磁 盘空间和厨房的垃圾桶同样有这种 属性。)

共享库 ( s ha red lib ra r y) 是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块, 在运行或加 载时, 可以加载到任意的 内存地址 ,并和一个在内存中的程序链接起来。这个过程称为动 态链接( dynamic linking) , 是由一个叫做动 态链 接器 ( dyn amic linker) 的程序来执行的。共享库也称为共享目标 ( s ha red object), 在 Linu x 系统中通常用 . s o后缀来表示。微软的操作系统大量地使用了 共享库, 它们称为 DLLC动态链接库)。

共享库是以两种不同的方式来“共 main2. c vec 七ro . h

享" 的。首先, 在任何给定的文件系统中, 对于一 个库只有一个 . s o 文件。所有引用该库的可执行目标 文件共享这个.

l i bc . so

l i bvect or . so

s o 文件中的代码和数 据, 而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。其次,在内存中, 一个共享库的 . t e x t 节的一个副本可以被不同的正在运行的进程共享。在第 9 章我们学习虚拟内存时将更加详细地讨论这个问题。

可重定 位目标文件 ma i n2 . o

! #

链接器 ( l d )

部分链接的可 rp og21

执行目标文件

L

加载器

(e x ecve ) I l i bc . so

动态链接过程。为了构造图 7-6 中示例向蜇例程的共享库 l i bve c t or . s o , 我

内存中完全链 接

代码和数据

们调用编译器驱动程序,给编译器和链接器如下特殊指令:

的可执行文件 1 动态链接骈 ( l d- li nux. so ) I

图 7-16 动态链接共享库

linux> gee -shared -fpie -o libveetor. so addvee.c multvec. c

- f pi c 选项指示 编译 器生成与位置无 关的代码(下一节将详细讨论 这个问题)。

- s ha r e d 选项指示链接器创建一个 共享的目标文件。一旦创建了这个库,随 后就要将它链接到图 7-7 的示例程序中 :

linux> gee -o prog21 main2.e ./libveetor.so

这样就创建了一个可执行目标文件 pr og 21, 而此文件的形式使得它在运行时可以和l i b v e c t or . s o 链接。基本的思路是当创建可执行文件时, 静态执行一些链接, 然后在程序加载时, 动态完成链接过程。认识到这一点 是很重要的: 此时, 没有任何 l i b ve c t o r . so 的代码和数据节真的被复制到可执行 文件 pr o g 21 中。反之, 链接器复制了一些重定位和符号表信息 ,它 们使得运行 时可以解 析对 l i b ve c t or . s o 中代码和数据的引用。

当加载器加载和运行可执行 文件 pr og21 时,它 利用 7. 9 节中讨论过的技术 , 加载部分链接的可执行文件 p ro g 21。接着,它 注意到 pro g21 包含一个. i nt er p 节, 这一节包含动态链接器的路径名, 动态链接器本身就是一个共享目标(如在 Linux 系统上的 l d - linux.so)。加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器。然

后,动态链接器通过执行下面的重定位完成链接任务:

  • 重定位 l i b c . s o 的文本和数据到某个内存段。
    • 重定位 l i b v e c 七or . s o 的文本和数据到另一个内存段。

    • 重定位 pr o g 21 中所有对由 l i b c . s o 和 l i b v e c t or . s o 定义的符号的引用。

      最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就固定 了,并且在程序执行的过程中都不会改变。

11 从应用程序中加载和链接共享库 #

到目前为止,我们巳经讨论了在应用程序被加载后执行前时,动态链接器加载和链接共享库的情景。然而,应用程序还可能在它运行时要求动态链接器加载和链接某个共享 库,而无需在编译时将那些库链接到应用中。

动态链接是一项强大有用的技术。下面是一些现实世界中的例子:

  • 分发软件 。微软 W in do w s 应用的开发者常常利用共享库来分发软件更新。他们生成一个共享库的新版本,然后用户可以下载,并用它替代当前的版本。下一次他们运行应用程序时,应用将自动链接和加载新的共享库。
    • 构建 高性能 W e b 服务器。 许多 W e b 服务器生成动 态内 容, 比如个性化的 W e b 页面、账户余额和广告标 语。早期的 W eb 服务器通过 使用 f or k 和 e x e c v e 创建一个子进程 , 并在该子进程的上下 文中运行 CGI 程序来生成 动态内容。然而, 现代高性能的 W e b 服务器可以使用基于动态链接的更 有效和完善的方法来生成动态内容。

      其思路是将 每个生成动态内容的函数打包在共享库 中。当一个来自 W e b 浏览器的请求到达时 , 服务器动态地加 载和链接适当的函数, 然后直接调用它, 而不是使用 f or k 和e xe c v e 在子进程的上下 文中运行 函数。函数会一直缓存在服务器的地址 空间 中, 所以只要一个简单 的函数调用的开销就可以 处理随后的请求了。这对一个繁忙的网站来说是有很大影响的。更进一步地说,在运行时无需停止服务器,就可以更新已存在的函数,以及添 加新的函数。

      L in u x 系统为动态链接器提供 了一个简单的接口,允 许应用程序在运行时加载和链接共享库。

#include <dlfcn.h>

void *dlopen(const char *filename, int flag);

返回: 若 成 功 则 为指 向 句 柄 的 指 针 , 若 出错 则 为 NULL。

d l op e n 函数加载和链接共享库 f i l e na me 。 用 已用带 RTL D_ GLOBAL 选项打开了的库解析 f i l e n a me 中的外部符号。如果当前可执行 文件是带-r d yn a mi c 选项编译的, 那么对符号解析 而言,它的 全局符号也是可用的 。fl a g 参数必须要么包括 RTL D_ NOW, 该标志告诉链接器立即解 析对外部符号的引用, 要么包括 RTLD_ LAZY 标志,该 标志指示链接器推迟符号解 析直到执行来自库中 的代码。这两个值中的任意一个都可以 和 RTL D_ GLOBAL 标志取或 。

#include <dlfcn.h>

void *dlsym(void *handle, char *symbol);

返回: 若 成 功 则 为指 向 符 号 的 指 针 , 若 出错 则 为 NULL 。

dl s yrn 函数 的输 入是一个指向前 面巳 经打开了的共享库的句柄和一个 s ym bol 名字, 如 果 该 符 号 存 在 ,就 返回符号的地址,否 则 返回 NU LL 。

#include <dlfcn.h>

int dlclose (void *handle);

返回: 若 成 功 则 为 o, 若 出错 则为 一1。

如果没有其他共享库还在使用这个共享库, d l c l o s e 函 数 就 卸 载该共享库。

#include <dlfcn.h>

const char *dlerror(void);

返回: 如 果 前 面对 dl open、 dl s ym 或 dl cl so e 的 调 用失败 , 则 为铸 误 消息 ,如 果 前 面的调 用 成 功 , 则 为 NUL,L

d l er r or 函 数 返回一个字符串,它 描 述 的 是 调 用 d l o p e n 、 d l s ym 或者 d l c l o s e 函数

时 发 生的最近的错误,如 果没有错误发生,就 返回 NU LL 。

图 7-17 展示了如何利用这个接口动态链接我们的 l i b v e c 七or . s o 共 享 库 , 然后调用它的 a d d v e c 例程。要 编译 这个程序, 我们将以下面的方式调用 GCC:

linux> gee -rdynamic -o prog2r dll.e -ldl

code/link/dll.c

#include <stdio.h>

  1. #include <stdlib.h>

  2. #include <dlfcn.h>

    4

    5 int x[2] = {1, 2};

    6 int y[2] = {3, 4};

    7 int z[2];

    8

    9 int main()

    10 {

  3. void *handle;

  4. void (*addvec)(int *, int*, int*, int);

  5. char *error;

    14

  6. I* Dynamically load the shared library containing addvec() *I

  7. handle= dlopen(”. / l i bvect or.so”, RTLD_LAZY);

  8. if (!handle) {

  9. fprintf(stderr, “%s\n”, dlerrorO);

  10. exit (1);

    20 }

    21

    22 I* Get a pointer to the addvec() function we just loaded *I

    23 addvec = dlsym(handle, “addvec”);

  11. if ((error = dlerror O) != NULL) {

  12. fprintf (stderr, “%s\n”, error);

图7-17 示例 程序 3。在运行时 动态加载 和链接共享库 l i bvec t or . so

佐(

笫 7 章 链 接 489

  1. exit (1);

    27 }

    28

  2. I* Now we can call addvec() just like anyother function *I

  3. addvec(x, y, z, 2);

    31 printf(“z = [%d 儿 d] \ n " , z[O], z[l]);

    32

  4. I*Unload the shared library *I

  5. if (dlclose (handle) < 0) {

  6. fprintf (stderr, “%s\n” , dlerror ()) ;

  7. exit (1);

    37 }

    38 return O;

    39 }

code/link/dll.c

m一共 享库和 J a va 本地接口

图 7-17 (续)

Java 定义了一 个标准调 用规 则,叫做 J ava 本地接口(J ava Native Interface, JNI), 它允许 Java 程序调 用“本 地的” C 和 C+ + 函数。J NI 的基本思想是将本地 C 函数(如fo o ) 编译到一 个共 享库 中(如f oe . s o ) 。当一个正在运行的 Java 程序试图调用函数 f oo 时, J ava 解释器利 用 d l op e n 接口(或者与其类似的 接口)动态链接 和加 栽 f o o . s o , 然后 再调用 f oo 。

  1. 12 位置无关代码

    共享库的一个主要目的就是允许多个正在运行的进程共享内存中相同的库代码,因 而节约宝贵的内存资源。那么,多 个 进程是如何共享程序的一个副本的呢? 一种方法是给每个共享库分配一个事先预备的专用的地址空间片,然后要求加载器总是在这个地址加载共 享库。虽然这种方法很简单,但 是 它 也 造成了一些严重的问题。它对地址空间的使用效率不高,因 为即使一个进程不使用这个库,那 部 分 空 间 还是会被分配出来。它也难以管理。我们必须保证没有片会重叠。每次当一个库修改了之后,我们必须确认已分配给它的片还 适合它的大小。如果不适合了,必 须 找一个新的片。并且, 如果创建了一个新的库, 我们还必须为它寻找空间。随着时间的进展,假设在一个系统中有了成百个库和库的各个版本 库,就很 难避免地址空间分裂成大量小的、未使用而又不再能使用的小洞。更糟的是,对每个系统而言,库 在内 存 中的 分 配 都是 不同的, 这就引起了更多令人头痛的管理问题。

    要避免这些问题,现代系统以这样一种方式编译共享模块的代码段,使得可以把它们加载到内存的任何位置而无需链接器修改。使用这种方法,无限多个进程可以共享一个共享模块的代码段的单一副本。(当然, 每个进程仍然会有它自己的读/写 数据块。)

    可以加载而无需重定位的代码称为位置无关代码( P os it io n- I n d e pe nd e n t Code, PIC) 。用户对 GCC 使用- f p i c 选项指示 G N U 编译系统生成 P IC 代码。共享库的编译必须总是使用该选项。

    在一个 x86-64系统中,对 同 一 个 目 标 模 块 中 符 号 的 引 用 是 不 需 要 特 殊 处 理 使 之成为

    PIC。可以用 PC 相对寻址来编译这些引用,构 造目标文件时由静态链接器重定位。然而,对共享模块定义的外部过程和对全局变量的引用需要一些特殊的技巧, 接下来我们会谈到。

  2. PIC 数据引用

    编译器通过运用以下这个有趣的事实来生成对全局变量的 PIC 引用: 无论我们在内存 中的何处加载一个目标模块(包括共享目标模块),数 据段与代码段的距离总是保持不变。因此, 代码段中任何 指令和数据段 中任何变量之间的距 离都是一个运行时常撮, 与代码段和数据段的绝对内存位置是无关的。

    想要生成对全 局变量 PIC 引用的 编译器利用了这个事实,它 在数据段开始的地方创建了一个表, 叫做全局偏移量表 ( G lo b a l Offset Table, GOT ) 。在 G O T 中,每 个被这个目标模块引用的全局数据目标(过程或全局变量)都有一个 8 字节条目。编译器还为 G O T 中每个条目生成一个 重定位记录。在加 载时, 动态链接器会蜇定位 G O T 中的每个条目,使得它包含目标的正确的绝对地址。每个引用 全局目标的目标模块都有自己的 G O T 。

    图 7-18 展示了示例 l i b v e c t or . s o 共享模块的 G O T 。a d d v e c 例程通过 G O T [ 3] 间接

    地加载全局变量 a d d c 泣 的地址,然 后把 a d d c n t 在内 存中加 1。这里的关键思想是对

    G O T [ 3 ] 的 PC 相对引用中的偏移批是一 个运行时常量。

数据段

全局偏移量表 (GOT) GOT [ O ): …

GOT[l) … .

GOT [ 2 ) : …

GOT [ 3 J: &add,cnt

运行时GOT [3 ] 和

a dd l 指令之间的固定距离是

Ox 2008b9 add vec :

、 认

mov Ox 2008b 9 { 毛r i p ) , 毛 r a x # 毛r a x =*GOT [ 3 ] =&ad dc n t

addl $0xl, { %rax) # addcnt++

图 7-18 用 GOT 引 用 全局 变 量 。 l i bve c 七or . s o 中的 addve c 例程通过 l i bve c 七or . s o 的

GOT 间 接引用 了 a ddc n七

因为 a d d c n 七是由 l i b v e c 七o r . s o 模块定义的, 编译器可以利用代码段和数据段之间不变的距离, 产生对 a d d c n t 的直接 PC 相对引用,并 增加一个重定位, 让链接器在构造这个共享模块时解析它。不 过, 如果 a d d c n 七是由另一个共享模块定义的, 那么就需要通过 G O T 进行间接访问。在这里, 编译器选择采用最通用的解决方案, 为所有的引用使用 G O T 。

  1. . P IC 函数调用

    假设程序调用一个由共享库定义的函数。编译器没有办法预测这个函数的运行时地址, 因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定 位记录, 然后动态链接器在程序加载的时候再 解析它。不过, 这种方法并不是 PIC, 因为它需 要链接器修改调用模块的代码段 , GNU 编译系统使用了一种很有趣的技术来 解决这个间题 , 称为延迟绑定 Clazy binding), 将过程地址的绑定推迟到第 一次调用该过程时。

    使用延迟绑定的动机是对于一个像l i b c . s o 这样的共享库输出的成百上 千个函数中, 一个典型的应用程序只会使用其中很少的一部分。把函数地址的解析推迟到它实际被调用的地 方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大 , 但是其后的每次调用都只会花费 一条指令和一个间接的内存引用。

    延迟绑定是通过两个数据结构之间简洁但又有些 复杂的交互来实现的, 这两个数据结

笫 7 章 链 接 491

构是: G O T 和过程链接表( P r o ce d u r e L in k a g e T a b le , PL T ) 。如果一个目标模 块调用定义在共享库中的 任何 函数, 那么它就有自己 的 G O T 和 P L T 。G O T 是数据段的一部分, 而P L T 是代码段的一部分。

图 7-1 9 展示的是 P L T 和 G O T 如何协作在运行时解析函数的地址。首先,让 我们检查一下这两个表的内容。

  • 过程链接表 ( P L T ) 。P L T 是一个数组, 其中每个条目是 1 6 字节代码。P LT [ O] 是一个特殊条目, 它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的 P L T 条目。每个条目 都负责询用一个具体的函数。 PLT [ l ] ( 图中未显示)调用系统启动函数(__ 让 b c _ s 七a 江 _ m a in ) , 它初始化执行环境, 调用 ma i n 函数并处理其

    返回值。从 P LT [ 2 ] 开始 的条目调用用户代码调用的函数。在我们的例子中, P LT [ 2 ] 调用 a d d v e c , PLT [ 3 ] ( 图中未显示e)调用pr i n t f 。

  • 全局偏移量表 ( G O T ) 。正如我们看到的, G O T 是一个数组, 其中每个条目是 8 字节地址。和 P L T 联合使用时 , GOT [ OJ 和 GOT [1 ] 包含动态链接器 在斛析函数地址时会使用的信息。GOT [ 2 ] 是动态链接器在 l d - l i n u x . s o 模块中的入口点。其余的每个条目 对应千一个被调用的函数, 其地址需要在运行时被解析。每个条目都有一个相匹配的 P L T 条目。例如, GOT [ 4 ] 和 P L T [ 2 ] 对应于 a d d v e c 。初始时, 每个 G O T 条目都指向对应 P L T 条目的第二条指令。

    数据段

    全局偏移量表 (GOT)

    GOT [ O] : addr of .yd na mi c GOT[l]: addr of reloc entries GOT [ 2 ] : addr of dynamic linker GOT [ 3 ] : Ox 40 05 b 6 # sys startup

    GOT[4]: Ox 40 0 5c 6 # addvec() GOT[S]: Ox 400 Sd 6 # printf()

I

(Z) C

a ) 第一 次调用 a dd v e c b ) 后续再调用a ddv e c

图 7-19 用 P LT 和 GO T 调用外部 函数。在第一次调用 a ddve c 时 , 动 态 链 接 器 解 析 它 的 地 址

图 7 - 1 9 a 展示了 G O T 和 P L T 如何协同工作, 在 a d d v e c 被第一次调用时, 延迟解析它的运行时地址:

  • 第 1 步。不直 接调用 a d d v e c , 程序调用进入 P LT [2], 这是 a d d v e c 的 P L T 条目。
  • 笫 2 步。第一 条 P L T 指令通过 GOT [ 4 ] 进行间接跳转。因为每个 G O T 条目初始时都指向它对应的 P L T 条目的第二条指令, 这个间接跳转只 是简单地把控制传送回PLT [ 2 ] 中的下一条指令。

. 第 3 步。 在把 a dd v e c 的 ID ( Ox l ) 压入栈中之后, P LT [ 2 ] 跳转到 PLT [ OJ 。

  • 第 4 步。 PLT [ O] 通过 GOT [ l ] 间接地把动态链接器的一个参数压入栈中, 然后通过GOT [2 ] 间接跳转进动态链接器中。动态链 接器使用两个栈条目来确定 a d d v e c 的运行时位置,用 这个地址重写 GOT [ 4 ] , 再把控制传递给 a d d v e c 。

    图 7- 1 9b 给出的是后续再调用 a d d v e c 时的控制流 :

    . 第 1 步。 和前面一样, 控制传递到 PLT [ 2 ] 。

    • 第 2 步。不过 这次通过 GOT [ 4 ] 的间接跳转 会将控制直接转移到 a d d v e c 。

7. 13 库打桩机制

Lin u x 链接器支持一个很强大的技术, 称为库打桩(l ib ra r y interpositioning), 它允许你截获对共享库函数的调用,取而代之执行自己的代码。使用打桩机制,你可以追踪对某 个特殊库函数的调用次数,验证和追踪它的输入和输出值,或者甚至把它替换成一个完全不同的实现。

下面是它的基本思想:给定一个需要打桩的目标函数,创建一个包装函数,它的原型

与目标函数完全一样。使用某种特殊的打桩机制,你就可以欺骗系统调用包装函数而不是 目标函数了。包装函数通常会执行它自己的逻辑,然后调用目标函数,再将目标函数的返 回值传递给调用者。

打桩可以发生在编译时、链接时或当程序被加载和执行的运行时。要研究这些不同的 机制,我 们以图 7- 20a 中的示例程序作为运行 例子。它调用 C 标准库 ( li b c . s o ) 中的 ma l ­ l a c 和 fr e e 函数。对 ma l l o c 的调用从堆中分配一个 32 字节的块, 并返回指向该块的指针。对 fr e e 的调用把块还回到 堆, 供后续的 ma l l o c 调用使用。我们的目标是用打桩来追踪程序运 行时对 ma l l o c 和 f r e e 的调用。

7. 13. 1 编译时打桩

图7-20 展示了如何使用 C 预处理器在编译时打桩。myma l l o c . c 中的包装函数(图7-20c) 调用目标函数 , 打印追踪记录, 并返回。本地的ma l l o c . h 头文件(图7- 20 b) 指示预处理器用对相应包装函数的调用替换掉对目标函数的调用。像下面这样编译和链接这个程序:

linux> gee -DCOMPILETIME -e mymalloe. e linux> gee -I. -o inte int.e mymalloe.o

由于有- I. 参数, 所以会进行打桩,它 告 诉 C 预处理器在搜索通常的系统 目录之前, 先 在当前目录中查 找 ma l l o c . h 。注意 , myma l l o c . c 中的包装函数是使用标准 ma ll oc . h 头文件编译的。

运行这个程序会得到如下的追踪信息:

linux> ./intc malloc(32)=0x9ee010 free(Ox9ee010)

7. 13 . 2 链接时打桩

Lin ux 静态链接器支持用 - -wrap f 标志进行链接时打桩。这个标志告诉链接器, 把对符号 f 的引用解析成_ _wr a p_ f ( 前缀是两个下划线),还 要把对符号——r e a l _ f ( 前缀是两个下划线)的引用解析为 f 。图 7- 21 给出我们示 例程序的包装函数。

#include <stdio.h>

#include <malloc.h>

int main()

code/link/interpose/int.c

int *P = malloc(32); free(p);

return(O);

  1. 示例程序 i nt . c

codellinklinterposelint.c cod e/lin k/interp osel m a l/oc .h

#define malloc(size) mymalloc(size)

#define free(ptr) myfree(ptr)

void *mymalloc(size_t size); void myfree(void *ptr);

code/linklinterpose/malloch.

#if def COMPILETIME

#include <s t di o . h>

#include <mal l oc . h >

  1. 本地 mall oc . h 文件

    code/link/interpose/mymalloc.c

I* malloc wrapper function *I voi d *mymalloc(size_t si ze)

void *ptr = mal l oc (s i z e ) ;

rp i nt f ( “m all oc (%d ) =%p\ n” , (int)size, ptr);

return ptr;

I* free 口rapperfunct ion *I void myfree(void *ptr)

free(ptr);

printf (“free(%p) \ n” , ptr);

#enidf

code/ lin k/int erp ose/mymalloc.c

  1. myma l l oc . c 中的 包 装函数

图7- 20

用 C 预处理器进行编译时打桩

用下述方法把这些源文件 编译成可重定位目标文件:

l i n ux > gee - DLI NKTIME -e myma l l oe . e l i nu x > gee -e int.e

然后把目标文件链接成可执行文件:

linux> gee - Wl , - - wr pa , ma l l o e - Wl , - - wr ap , fr e e -o int;l i nt;.o myma l l oe . o

- Wl , o p t i o n 标志把 o p t i o n 传递给链接器。o p 巨 o n 中的 每个逗号都要替 换为一个空

纵ii

494 笫二部分 在系统上运行程序

格。所以- Wl , - -wrap, ma l l o c 就 把 - -wrap ma l l o c 传 递给链接器,以 类 似的方式传递

-Wl, - -wrap, fr ee。

code/linklinterpose/mymalloc.c

#ifdef LINKTIME

2 #include <stdio.h>

3

4 void * real_malloc(size_t size);

5 void real_free(void *ptr);

6

7 I* malloc -wrapper function *I

8 void * -wrap_malloc(size_t size)

9 {

10 void *ptr = real_malloc(size); I* Call libc malloc *I

11 printf(“malloc(%d) = %p\n”, (int)size, ptr);

12 return ptr;

13 }

14

  1. I* free -wrapper function *I

  2. void -wrap_free(void *ptr)

    17 {

  3. real_free(ptr); I* Call libc free *I

  4. printf(“free(%p)\n”, ptr);

    20 }

    21 #endif

code/linklinterposelmymalloc.c

图 7-21 用 一 wr ap 标志进行链 接时打桩

运行该程序会得到如下追踪信息:

linux> ./intl malloc(32) = Ox18cf010 free(Ox18cf010)

7. 13 . 3 运行时打桩

编译时打桩需要能够访问程序的源代码,链接时打桩需要能够访问程序的可重定位对 象文件。不过, 有一种机制能够在运行时打桩.它只需要能够访间可执行目标文件。这个很 厉 害 的 机 制基于动态链接器的 LD_ P RE LOAD 环境变量。

如果 LD—PRE LO AD 环境变量被设置为一个共享库路径名的列表(以空格或分号分隔),

那 么 当 你 加 载和执行一个程序,需 要 解析未定义的引用时, 动 态 链 接器 ( L D- 荨 NUX. SO) 会先 搜 索 L D—P RE LOAD 库,然 后 才 搜索任何其他的库。有了这个机制, 当你加载和执行任意可 执 行 文 件 时 ,可 以 对任何共享库中的任何函数打桩,包 括 让b e . s o。

图 7- 22 展示了 ma l l o c 和 fr e e 的包装函数。每个包装函数中,对 d l s ym 的 调用返回指向目标 l i b c 函数的 指针 。然后包装函数调用目标函数, 打印 追踪记录,再 返回。

下面是如何构建包含这些包装函数的共享库的方法:

linux> gee -DRUNTIME -shared -tpie -o mymall oe. so mymall oe. e - ldl

这是如何编译主程序:

linux> gee -o intr int.e

codellinklinterpose/mymalloc.c

oc.c

图 7- 22 用 LD_ PRELOAD 进行运行时打桩

下面是如何从 b a s h s h e ll 中运行这个程序 气

linux> LD_PRELOAD=” ./mymalloc. so" ./intr malloc(32) = Ox1bf7010

free(Ox1bf7010)

e 如果你不知道运行的 shell 是哪一种,在命 令行上输人 pr i nt en v SHE LL.

下面是如何 在 c s h 或 t c s h 中运行这个程序:

linux> (setenv LD_PRELOAD 11 ./mymalloc. so"; ./ i ntr ; unsetenv LD_PRELOAD) malloc(32) = Ox2157010

free(Ox2157010)

请注意 , 你可以用 LD_ PRELOAD 对任何可执行 程序的库函数调用打桩!

linux> LD_PRELOAD=" ./mymalloc. so" /usr/bin/uptime malloc(568) = Ox21bb010

free(Ox21bb010) malloc(15) = Ox21bb010 malloc(568) = Ox21bb030 malloc(2255) = Ox21bb270 free(Ox21bb030) malloc(20) = Ox21bb030 malloc(20) = Ox21bb050 malloc(20) = Ox21bb070 malloc(20) = Ox 21 bb090

ma ll oc( 20 ) = Ox 21bb0b0 malloc(384) = Ox2 1 bb0d0

20:47:36 up 85 days , 6:04, 1 user, load average: 0.10, 0.04, 0.05

  1. 14 处理目标文件的工具

    在 L in u x 系统中有大量可用 的工具 可以帮助你理解和处理目标文件。特别地, GNU

    b in ut ils 包尤其有帮助, 而且可以 运行在每个 L in ux 平台上。

    • A R : 创建静态库 , 插入、删除、列出和提取成员。

    • ST RINGS : 列出一个目标文件中所 有可打印 的字符串。

    • ST RIP : 从目标文件中 删除符号表信息。

    • NM : 列出一个目标文件的符号表中定义的符号。

    • SIZE : 列出目标文件中节的名字和大小。

    • R E A D E L F : 显示一个目标文件的完整结构, 包括 E L F 头中编码的所有信息。包含

      S I ZE 和 NM 的功能。

    • OBJDUMP: 所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大的 作用是反汇编 . t e x t 节中的二进制指令 。

      Lin u x 系统为操作 共享库还提供了 L DD 程序 :

    • LDD: 列出一个可执行文件在运 行时所需要的共享库。

7. 15 小结

链接可以在编译时由 静态编译 器来 完成 ,也可以在加载时 和运行时由动态链 接器 来完成。链接器处理称为目标 文件的 二进制文件 ,它有 3 种不同的形式: 可重定 位的、可执 行的和共享的。可重定位的目标文件由静态链接器合并 成一个可执行的目标文件, 它可以 加载到内存中并执行。共 享目标 文件(共享库)是在运行时由动态链接器链接 和加载的 , 或者隐含地在凋用程序被加 载和开始执行时 , 或者根据需要在程序调用 dl ope n 库的函数时。

链接器的 两个主要任务是符号解析 和重定位 , 符号解析将目标 文件中的 每个全局符号都绑定到一个唯一的定义, 而重定位确定 每个符号的最终内存地址,并修改对那些目标的引 用。

静态链接器是由像 GCC 这样的 编译驱动程序调用的 。它们将多个可重定位目标 文件合并成一个单独的可执行目标文件。多个目标文件可以定 义相同的符号, 而链接器用来 悄悄地解析这些 多重定义的规则可能在用户程 序中引人微妙的错误 。

多个目标文件可以 被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器通 过从左到右的顺序扫描来解析符号引用, 这是另一个引起令人迷惑的链接时错误的来源。

加载器将可执行文件的内 容映射到内存,并 运行这个程序。链接器还可能生成部分链接的可执行目标文件 , 这样的文件中有对定 义在共享库 中的例程 和数据的 未解析的引用。在加载时,加 载器将部分链接的可执行文件映 射到内存 , 然后调用动态链接器 , 它通过 加载共享库和重定位程序中的引用来完成链接任务 。

被编译 为位 置无关代码的共享库可以加载到任何地方 , 也可以在运行时被多个进程共享。为了加载、链接和访问 共享库的函数和数据 , 应用程序也可以在运行时 使用动态链接器。

参考文献说明 #

在计算机系统 文献中并 没有很好地 记录链接。因为链 接是处在编译器、计算机体系结构和操作系统的交叉点上, 它要求理解代码生成、机器语 言编程、程序实例化和虚拟内存。它没有恰好落在某个通常 的计算机系统领域中 , 因此这些领域的经典文献并 没有很 好地描述它。然而,Le vin e 的专著提供了有关 这个主题的很好的一般性参考资 料[ 69] 。[ 54] 描述了 ELF 和 DW AR F ( 对. de b ug 和 . l i ne 节内容的规范) 的原始 IA32 规范。[ 36] 描述了对 E LF 文件格式的 x86-64 扩展。x8 6-64 应用二进制接口( ABD 描述了编译、链接和运行 x86-64 程序的惯 例, 其中包括重定位和位置无关代码的规则[ 77] 。

家庭作业

  • 7. 6 这道题是关于图 7-5 的 rn. o 模块和下面的 s wa p . c 函数版本的 , 该函数计算 自已被调用的次数 :

    extern int buf[];

int *bufpO = &buf[O]; static int *bufp1;

static void incrO

static int count=O; count++;

void swap()

int temp;

incr();

bufp1 = &buf[1]; temp= *bufpO;

*bufpO = *bufp1;

*bufp1 = temp;

对于每个 s wa p . o 中定 义和引用的 符号,请指出 它是否在模块 s wa p . o 的 . s ymt a b 节中有符 号表条目。如果是这样 ,请 指出定义该符号的模块( s wa p . o 或 m. o ) 、符号类型(局部、全局或外部)以及它在模块中所处的节( . t e x t 、. da t a 或 . b s s ) 。

  • 7. 7

  • 7. 8

    不改变任何 变量名字 , 修改 7. 6. 1 节中的 b ar S . c , 使得 f o o S . c 输出 x 和 y 的正确值(也就是整数

    15 213 和 1 521 2 的十六进制表示)。

    在此题中, REF (x , i ) —+ DEF (x , k ) 表示链 接器将任意对模 块 1 中符号 x 的引用与模块 k 中符号 x 的定义相关联。在下面每个例子中,用这种符号来说明链接器是如何解析在每个模块中有多重定义的 引用的。如果出现链接时 错误(规则1 ) • 写“错误"。如果 链接器从定义中任意选择一个(规则3)’ 那么写“未知”。

  1. I• Module 1•/

    int main()

    I* Module 2 *I

    static int main=l [ int p20

  2. REF(main.1) - DEF(.) ( REF(main.2) -+ DEF( . )

  3. I* Module 1 *I I* Module 2 *I

    int x; double x;

    void main() int p20

    { {

    } }

    (a) REF(x.1)-+ DEF(_ .)

    (b) REF(x.2)- DEF(._ — ._ _ )

  4. I* Module 1 *I I* Module 2 *I

    int x=l; double x=l.0;

    void main() int p2()

    { {

    } }

    (a) REF(x.1) - DEF( - ·- )

  5. REF(x.2) - DEF_ (_

— — - ·— ·-—一 .)

  • 7. 9

    考虑下面的程序,它由两个目标模块组成:

I* foo6.c *I

void p2(void);

int main()

p20;

return O;

I* bar 6 . c *I

#include <stdio.h> char main;

void p20

printf(“Ox%x\n”, main);

•• 7. 10

当在 x8 6- 64 L in ux 系统中编译和执行这个 程序时 ,即 使函数 p 2 不初始化变 量 ma i n , 它也能打印字符串 " Ox 48 \ n" 并正常终止。你能 解释这一点 吗?

a 和 b 表示当前 路径中的目标模 块或静 态库 , 而 a -+-b 表示 a 依赖于 b , 也就是说 a 引用了一个 b

定义的符办 。对千下 面的每个场 景, 给出使得静态链 接器能够解析所有符号引用的最小的命令行

(即含有最少数量的目标文件和库参数的命令)。

  1. p.o-libx.a-p.o

  2. p. o-+ libx.a- 让by.a 和l i by.a- 江bx.a

    C.p.o- 巨bx.a- 且by.a- l ibz.a 和l i by.a-libx.a-+libz.a

    .. 7. 11 图 7-14 中的程序头部表明 数据段占用了内存中 Ox230 个字节。然而 , 其中只有开始的 Ox228 字节来自可执行文件的节。是什么引起了这种差异?

    •• 7. 12 考虑目标 文件 m. o 中 对函数 s wa p 的调用(作业题7. 6 ) 。

    9: e8 00 00 00 00 callq e <main+Oxe> swap()

    具有如下重定位条目:

    r.offset = Oxa r.symbol = swap

    r.type = R_X86_64_pc32 r.addend = -4

  3. 假设链接器将 m . o 中的. t e xt 重定位到地址 Ox 4004e 0 , 把 s wa p 重定位到地址 Ox 4004f 8。那么c a ll q 指令中对 s wa p 的 重定位引用的值应该是什么?

  4. 假设链 接器将 m. o 中的 . t e x t 重定位到地址 Ox 4004d 0 , 把 s wa p 重定位到地址 Ox 400500 。那么

    c a l l q 指令中对 s wa p 的 重定位引用的值应该是什么?

    •• 7. 13 完成下面的任务将帮助你更熟悉处理目标文件的各种工具。

  5. 在你的系统上,巨 b . c 和 li bm. a 的版本中包含多少目标文件?

  6. g c c - Og 产生的 可执行 代码与 g c c - Og - g 产生的不同吗?

  7. 在你的系统上 , GCC 驱动程序使用的是 什么共享库?

练习题答案 #

7. 1 这道练习题的目 的是帮助你理解链接器符号 和 C 变最及函数之间的关系。注意 C 的局部 变最 t e mp

没有符号表条目。

符号. s ymt a b 条目?符号类型在哪个模块中定义廿Tl
buf定曰外部ma1.n.o.data
bufpO全局swap.o. dat a
bufpl定巨全局swap.oCOMMON
swap全局swap.o.text
temp
  1. 2 这是一个简单的 练习,检查你对 Unix 链接器解析 在一个以上模块中有定 义的 全局符号时所使用规则的理解。理解 这些规则可以 帮助你避免一些 讨厌的编程错误。

    1. 链接器选择定 义在模块 1 中的强符号, 而不是定义在模块 2 中的弱符号(规则 2) :
      1. REF(ma i n.1) DEF(main.1)
      2. REF( ma i n .2) DEF(main.1)
    2. 这是一 个错误 , 因为每个模块 都定义了一个强符号 ma i n( 规则 1 ) 。
    3. 链接器选择定义 在模块 2 中的强符号, 而不是定 义在模块 l 中的弱符号(规则2) :
      1. REF(x.1) DEF(x.2)

      2. REF(x.2) DEF(x.2)

        7 3 在命令行中以错误的顺序放置静态库是造成令许多程序员迷惑的链接器错误的常见原因。然而,

        旦你理解了链 接器是如何使用 静态库来 解析引用的 , 它就相当简单易 懂了。这个小 练习检查了 你对这个概念的理解:

  2. linux> gee p.o li bx. a

  3. linux> gee p.o libx.a liby.a

  4. l inux> gee p.o l i b x. a liby.a libx.a

  5. 4 这道题 涉及的是 图 7-1 2a 中的反汇编列表。目的是让 你练习阅读反汇编列 表, 并检查你 对 PC 相对

寻址的理解。

  1. 第 5 行被重定位引用的十六进制地址为 Ox 4004d f 。
    1. 第 5 行被重定位引用的 十六进制值为 Ox5 。 记住, 反汇编列 表给出的引 用值是用小端法字节顺序表示的。

      7. 5 这道题 是测试你对链 接器重定 位 PC 相对引用的理解的。给定

      ADDR(s) = ADDR(. text) = Ox4004d0

      ADDR(r.symbol) = ADDR(swap) = Ox4004e8

      使用图 7-10 中的算法, 链接器首先 计算引用的运行时 地址:

      refaddr = ADDR(s) + r.offset

      = Ox4004d0 + Oxa

      = Ox4004da

      然后修改 此引用:

      *refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)

      = (unsigned) (Ox4004e8 + (-4) - Ox4004da)

      - (un s i gned ) (Oxa)

因此, 得到的 可执行目标文件中 , 对 s wa p 的 P C 相对引用的值为 Oxa : 4004d9: e8 Oa 00 00 00 callq 4004e8 <swap>