链接(Linking)


链接是集合和结合不同的 代码块数据一个文件中,该文件能被加载(拷贝)到内存中并执行。

链接能发生在

  • 编译期 (compile time),此时源代码被翻译为机器码,被称为静态链接
  • 加载期(load time),此时程序被加载到内存中并由 loader 执行,被称为加载期的共享库动态链接
  • 运行期 (run time),通过应用程序链接,被称为运行期的共享库动态链接

在现代操作系统中,链接由程序链接器 (linker) 自动执行。

不是将大的应用作为一个整体的源文件进行组织,而是将应用分解为更小,更易于管理的模块进行组织,这些模块能分别进行修改和编译,这被称为 separate compilation。当修改这些模块之一时,只需简单地重新编译该模块并对应用重新链接即可,不需要重新编译其他文件。

1. 编译器驱动 (Compiler Drivers)

大部分编译系统提供编译器驱动,例如 gcc,来调用语言预处理器,编译器,汇编器,和链接器。

我们通过一个实例来看看编译器驱动是如何运行的。假设有下述代码,
(a) main.c

/* main.c */
2 void swap();
3
4 int buf[2] = {1, 2};
5
6 int main()
7 {
8 	swap();
9 	return 0;
10 }

(b) swap.c

1 /* swap.c */
2 extern int buf[];
3
4 int *bufp0 = &buf[0];
5 int *bufp1;
6
7 void swap()
8 {
9 	int temp;
10
11 	bufp1 = &buf[1];
12 	temp = *bufp0;
13 	*bufp0 = *bufp1;
14 	*bufp1 = temp;
15 }

图 7.2 总结了驱动器将示例代码从 ASCII 源码翻译为可执行对象文件的活动。
在这里插入图片描述
驱动器首先运行 C 预处理器 (cpp),它将 C 源文件 mianc.c 翻译为 ASCII 中间文件 main.i:

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

随后,驱动器运行 C 编译器(cc1),它将 main.i 翻译为 ASCII 汇编语言文件 main.s:

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

然后,驱动器运行 C 汇编器(as),将 main.s 文件翻译为 relocatable object file main.o:

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

驱动器经历相同的过程以产生 swap.o。最终,它运行链接器程序 ld,以结合 main.o 和 swap.o 以及其他必要的系统文件,创建 executable object file p:

ld -o p [system object files and args] \
	/tmp/main.o /tmp/swap.o

为了运行可执行文件 p,执行下述命令:

unix> ./p

shell 调用操作系统中的一个函数,称为 loader,将可执行文件 p 中的代码和数据拷贝到内存中,随后将控制转移到程序的开始。

2. 静态链接

Static linkers 例如 Unix ld 程序的输入为可重定位对象文件以及命令行参数,输出为完全链接可执行对象文件。输入的可重定位对象文件由多个代码和数据 sections 组成。指令在一段,初始化的全局变量在另一段,未初始化的变量也在另一段。

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

  • Symbol resolution. 对象文件定义和引用 symbols。符号解析的目的是将每个符号引用 关联到具体的某个符号定义上。
  • Relocation. 编译器和汇编器从地址 0 生成代码和数据段。链接器通过关联 内存位置到每个符号定义来重定位这些段,然后修改所有对这些符号的引用,使它们指向该内存位置

下述章节将进一步描述这些任务。当你阅读时,记住这些关于链接器的基础的事实:对象文件仅仅是字节块的集合。这些块中有一些包含程序代码,其他的则包含指导链接器 (linker) 和加载器 (loader) 的数据结构。链接器将这些块拼接在一起,决定被连接的块运行时所处的位置,并修改代码和数据块中的各个位置。链接器对目标机器的了解很少。生成对象文件的编译器和汇编器已经完成了大部分工作。

3. 对象文件 (Object Files)

对象文件有三种格式:

  • Relocatable object file. 包含二进制代码和数据,能与其他可重定位对象文件结合创建一个可执行对象文件。
  • Executable object file. 包含二进制代码和数据,能直接拷贝到内存中并执行。
  • Shared object file. 可重定位对象文件的一种特殊形式,在加载期或运行期能被动态地加载到内存中并被链接。

编译器和汇编器产生可重定位对象文件 (包括共享对象文件)。链接器生成可执行对象文件。技术上,object module 是字节序列,object file 是存储于磁盘文件上的对象模块。然而,我们将互换使用这些术语。

对象文件的格式因系统而异。第一个 Unix 系统使用 a.out 格式 (如今,可执行对象文件仍然使用 a.out 格式)。早期版本的 System V Unix 使用 Common Object File 格式(COFF)。Windows NT 使用 COFF 的变体 Protable Executable (PE) 格式。现代 Unix 系统,例如 Linux,更新版本的 System V Unix,BSD Unix 变体,和 Sun Solaris,使用 Unix Executable and Linkable Format (ELF) 格式。

4. 可重定位对象文件

图 7.3 展示了典型的 ELF 可重定位对象文件的格式。
在这里插入图片描述
ELF header 起始的 16 字节序列描述生成该文件的系统的字长字节序。ElF 部首的剩余部分包含指导链接器解析和解释对象文件的信息,包括:

  • ELF 部首的大小;
  • 对象文件类型 (例如,可重定位,可执行,或共享的);
  • 机器类型 (例如,IA32);
  • section header table 的文件偏移;
  • section header table 中的条目的大小和数量;

各 section 的位置和大小由 section header table 描述,section header table 为对象文件中的每个 section 包含一个固定大小的条目。

ELF header 和 section header table 之间是 sections。一个典型的 ELF 可重定位对象文件包含下述 sections:

  • .text: 被编译程序的机器代码
  • .rodata: 只读数据,例如格式化字符串,和 switch 语句的 jump tables;
  • .data: 初始化的全局 C 变量。局部 C 变量在运行时栈上维护 ,不会出现在 .data 或 .bss 段;
  • .bss: 未初始化的全局 C 变量。该 section 在对象文件中没有占用实际的空间;它仅仅是一个 place holder。对象文件格式区分因为空间效率问题区分初始化变量和未初始化变量:未初始化变量在对象文件中不占用任何实际的磁盘空间。
  • .symtab: symbol table 包含关于在程序中被(内部)定义和引用(外部)的函数和全局变量的信息。一些程序员错误地认为程序必须使用 -g 选项进行编译以获取符号表信息。事实上,每个可重定位的对象文件在 .symtab 段都有一个符号表。然而,与编译器中的符号表不同,.symtab 符号表不包含任何局部变量条目。
  • .rel.text: .text 段的位置列表,包含在链接器将该对象文件与其他文件结合时,需要修改的位置。通常,任何调用外部函数或引用全局变量的指令都需要被修改。注意重定位信息不需要在可执行对象文件中,该段通常是缺省的,除非用户显式地指示链接器包含它。
  • .rel.data:模块所引用或定义的全局变量的重定位信息。通常来说,任何初始化值是全局变量的地址或外部定义函数的已初始化的全局变量都需要被修改。
  • .debug:包含程序中定义的局部变量和 typedefs,程序中定义和引用的全局变量,以及原始的 C 源文件相关条目的调试符号表。仅当编译器驱动使用 -g 选项时它才存在。
  • .line:原始 C 源程序的行号和 .text 段中的机器码之间的映射。仅当编译器驱动使用 -g 选项时它才存在。
  • .strtab: 用于 .symtab 和 .debug 段中符号表,以及 section header 中的 section name 的字符串表。字符串表是空字符结尾的字符串序列。

5. 符号和符号表

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

  • 该模块定义的 global symbols,能被其他模块引用。全局链接器符号对应于 nonstatic 的 C 函数和未使用 C static 属性定义的全局变量。
  • 由其他模块定义的,该模块引用的 global symbols。这样的符号被称为 externals,且对应于定义在其他模块的 C 函数和变量。
  • 该模块独占定义和引用的 local symbols。局部链接器符号对应于使用 static 属性定义的 C 函数和全局变量。这些符号在模块内可见,但是不能被其他模块引用。对象文件中的 sections 和对应于模块的源文件的名字也会获得局部符号。

局部链接器符号和局部程序变量并不相同。.symtab 中的符号表不包含任何对应于程序的非静态局部变量的符号。它们被运行时的栈管理,链接器对其不感兴趣。

有趣的是,使用 C static 属性定义的局部过程变量并非由栈管理。相反,编译器在 .data 或 .bss 段为其分配空间,并在符号表中使用唯一的名字为其创建局部器链接符号。例如,假设有一对函数在相同的模块中定义一个静态局部变量 x:

1 int f()
2 {
3 	static int x = 0;
4 	return x;
5 }
6
7 int g()
8 {
9 	static int x = 1;
10 	return x;
11 }

在这种情况下,编译器为两个整数在 .data 段分配空间,并向汇编器导入一对唯一的局部链接符号。例如,可能对函数 f 中的定义使用 x.1,对函数 g 中的定义使用 x.2。

符号表由汇编器构建,使用由编译器导入到汇编语言 .s 文件中的符号。一个 ELF 符号表被包含于 .symtab 段,符号表包含条目数组。图 7. 4 展示了每个条目的格式。
在这里插入图片描述

  • name:字符串表的偏移
  • value:section 偏移,或虚拟内存 (VM) 地址
  • size:对象大小,以字节为单位
  • type:data,func,section,或 src file name
  • binding: 全局或局部
  • section:section header index,ABS,UNDEF,或 COMMON

每个符号都关联对象文件的某个 section,由 section 字段表示,它是 section header table 中的索引。存在 3 种伪 section,它们并不存在于 section header table 中:ABS 用于不应该被重新定位的符号UNDEF 用于未被定义的符号,即,在其他地方定义的被该模块引用的符号;COMMON 用于还未被分配的未初始化的数据对象。对于 COMMON 符号,value 字段给出它的对齐要求,size 字段给出它的最小大小。

使用 GNU READELF 工具, main.o 的符号表最后三个条目如下,没有显示出来的前 8 个条目是链接器内部使用的局部符号。
在这里插入图片描述
在本例中,可以看到全局变量 buf 的定义的符号条目,是一个 8 字节的对象,位于 .data 段偏移量为 0 的位置。随后则是全局符号 main 的定义,一个 17 字节的函数,位于 .text 段偏移量为 0 的位置。最后一个条目来自于外部符号 swap 的引用。READELF 使用一个整数 index 标识出每个 section。Ndx = 1表示 .text section,Ndx = 3 表示 .data section。

类似的,swap.o 的符号表条目如下:
在这里插入图片描述
第一个条目是全局符号 bufp0 的定义,一个 4 字节的已初始化对象,起始于 .data 的 0 偏移处。下一个符号来自于外部的 buf 符号的引用,在 bufp0 初始化代码中。随后是全局符号 bufp1,一个 4 字节的未初始化数据对象 (4 字节的对齐要求),最终在模块链接时作为一个 .bss 对象被分配。

6. 符号解析

链接器通过将每个符号引用 关联符号表符号的定义以对符号进行解析,其中符号表包含所有作为链接器输入的可重定位对象文件的符号表。对于局部符号引用来说,符号解析是直观的,因为符号引用和符号定义在同一个模块中。对于每个模块中的每个局部符号,编译器仅允许一个定义存在。编译器也保证静态局部变量,它得到一个局部链接器符号,具有唯一的名字。

对于全局符号的引用解析则比较棘手。当编译器遇到一个没有在当前模块中定义的符号 (变量或函数名) 的引用时,它假设器定义于某个其他的模块,为其生成一个链接器符号表条目,并将对该符号引用的解析留给链接器处理。如果链接器在任何输入的可重定位对象模块中都没有找到该符号的定义,它打印错误信息并终止。例如,如果我们在 Linux 机器上尝试编译和链接下述源文件,

1 void foo(void);
2
3 int main() {
4 	foo();
5 	return 0;
6 }

编译器能正常工作,但是链接器因为不能解析 foo 的引用而终止:

unix> gcc -Wall -O2 -o linkerror linkerror.c
/tmp/ccSz5uti.o: In function ‘main’:
/tmp/ccSz5uti.o(.text+0x7): undefined reference to ‘foo’
collect2: ld returned 1 exit status

对全局符号的解析也很棘手,因为相同的全局符号可能在多个对象文件中多次定义。这种情况下,链接器必须抛出一个错误或者选择其中一个定义并弃置剩下的定义。


在 C++ 和 Java 中链接器符号的识别编码(mangling)
C++ 和 Java 都允许重载具有相同名字的函数,重载的函数具有不同参数列表。链接器如何知晓不同的重载函数间的差异呢?编译器将方法参数列表 (依据参数类型) 结合到一起进行编码,获得一个唯一的名字用于链接器。这个编码过程被称为 mangling,相反的过程则被称为 demangling

C++ 和 Java 使用兼容的 mangling 方案。一个 mangled 类名由名字的字符数量后跟原始的名字组成。例如,类 Foo 被编码为 3Foo。方法则使用原始方法名进行编码,后跟 __ ,随后是 mangled 类名,最后是每个参数的单个字母编码。例如,Foo::bar(int, long) 被编码为 bar__3Fooil。类似的方案也被用于识别编码全局变量和模板名。


6.1 链接器如何解析多次定义的全局变量

在编译期,编译器向汇编器导入每个全局符号,汇编器显式地将符号信息编码到可重定位对象文件地符号表中。函数和初始化的全局变量获得强符号未初始化的全局变量获得弱符号。例如图 7.1 中的程序,buf,bufp0,main 和 swap 是强符号,bufp1 是弱符号。

考虑强弱符号的概念,Unix 使用下述规则处理多次定义的符号:

  • 规则 1:多个相同的强符号是不允许的
  • 规则 2:一个强符号,多个弱符号,选择强符号
  • 规则 3:多个弱符号,选择任一弱符号

例如,假设尝试编译和链接下述 C 模块:

1 /* foo1.c */
2 int main()
3 {
4 	return 0;
5 }
 /* bar1.c */
2 int main()
3 {
4 	return 0;
5 }

在这种情况下,链接器将生成一个错误信息,因为强符号 main 被定义了多次 (规则 1):

unix> gcc foo1.c bar1.c
/tmp/cca015022.o: In function ‘main’:
/tmp/cca015022.o(.text+0x0): multiple definition of ‘main’
/tmp/cca015021.o(.text+0x0): first defined here

类似的,因为强符号 x 被定义了两次,链接器将为下述信息产生一个错误信息 (规则 1):

1 /* foo2.c */
2 int x = 15213;
3
4 int main()
5 {
6 	return 0;
7 }
1 /* bar2.c */
2 int x = 15213;
3
4 void f()
5 {
6 }

然而,如果 x 在一个模块中未被初始化,链接器将选择定义在其他模块中的强符号 (规则 2):

1 /* foo3.c */
2 #include <stdio.h>
3 void f(void);
4
5 int x = 15213;
6
7 int main()
8 {
9 	f();
10 	printf("x = %d\n", x);
11 	return 0;
12 }
1 /* bar3.c */
2 int x;
3
4 void f()
5 {
6 	x = 15212;
7 }

在运行时,函数 f 将 x 的值从 15213 改为了 15212,这对函数 mian 的作者来说可能是个意外。注意,链接器通常不会提示它检测到 x 有多个定义:

unix> gcc -o foobar3 foo3.c bar3.c
unix> ./foobar3
x = 15212

如果 x 的两个定义都是弱符号,则任意选择一个 x 的定义 (规则 3):

1 /* foo4.c */
2 #include <stdio.h>
3 void f(void);
4
5 int x;
6
7 int main()
8 {
9 	x = 15213;
10 	f();
11 	printf("x = %d\n", x);
12 	return 0;
13 }
1 /* bar4.c */
2 int x;
3
4 void f()
5 {
6 	x = 15212;
7 }

规则 2 和规则 3 可能会引入一些潜在的运行时错误,特别是当重复的符号定义类型不同时。考虑下述例子,一个模块中 x 被定义为 int,而它在另一个模块中的类型是 double:

1 /* foo5.c */
2 #include <stdio.h>
3 void f(void);
4
5 int x = 15213;
6 int y = 15212;
7
8 int main()
9 {
10 	f();
11 	printf("x = 0x%x y = 0x%x \n",
12 		   x, y);
13 	return 0;
14 }
/* bar5.c */
2 double x;
3
4 void f()
5 {
6 	x = -0.0;
7 }

在 IA32/Linux 机器上,doubles 是 8 字节,而 ints 是 4 字节。因此,bar5.c 中的行 6上的赋值语句,x = - 0.0,将使用负 0 的双精度浮点数表达式重写 x 和 y 的内存位置 (foo5.c 的第 5 第 6 行)。

linux> gcc -o foobar5 foo5.c bar5.c
linux> ./foobar5
x = 0x0 y = 0x80000000

这是一个不易察觉且令人讨厌的 bug,特别是它悄悄地产生,没有任何的编译器警告。且因为它通常在程序执行的后面才显现出来,距离错误发生的位置很远。在具有几百个模块的大型系统中,这种类型的 bug 极难修复。在调用链接器时使用 flag 比如 GCC 的 -fno-common flag,在遇到多次定义的全局符号时会触发一个错误。

6.2 链接静态库

到目前为止,我们假设链接器读取所有的可重定位对象文件并将它们链接到一起,输出一个可执行文件。实际上,所有操作系统都提供一种机制用于打包相关的对象模块到一个文件中,该文件被称为 static library,它能作为链接器的输入。当静态库被用于构建可执行对象文件时,链接器仅拷贝库中被应用程序引用的对象模块

为什么系统支持库的概念?ANSI C 定义了一个拓展性的集合,包括标准 IO,字符串操作,和整型数学函数例如 atoi,printf,scanf,strcpy,和 rand。它们在 libc.a 库中对每个 C 程序中可用。

假设编译器开发者不使用静态库的方式,那如何为用户提供这些函数呢?方法之一是让编译器识别对标准函数的调用,并直接生成对应的代码。Pascal 使用这一方法提供一个包含少量标准函数的集合,但这对 C 来说并不可行,因为 C 标准定义了大量的标准函数。这将导致编译器的复杂度大大提高,并且每次添加,删除或修改一个标准函数时,编译器都需要进行版本更新。但对于程序员而言,这一方法十分便利,因为这意味着标准函数总是可用的。

另一种方法是将所有的标准 C 函数放到一个单独的可重定位对象模块中,称之为 libc.o,应用程序员能将其链接到自己的可执行程序中。

unix> gcc main.c /usr/lib/libc.o

这种方式的好处是将标准函数的实现和编译器的实现解耦,且对程序员来说依然相当便利。然而,它有一个巨大的缺点,就是系统中的每个可执行文件现在都包含标准函数集合的完全拷贝,这是对磁盘空间的极大浪费。更糟糕的是,每个运行的程序都在内存中包含自己对这些系统函数的拷贝,同样这是对内存的极大浪费。另一个缺点是当标准函数发生修改时,无论是多么小的修改,都需要库开发者重新编译整个源文件。这是一个耗时操作,会复杂化标准库的开发和维护。

我们可以通过为每个标准函数创建一个可重定位的对象文件,并将它们放在公开的目录中,以解决上述问题中的某些问题。然而,这一方法需要应用程序员显式地链接合适的对象模块到可执行的对象文件中,这一过程是耗时且易于出错的:

unix> gcc main.c /usr/lib/printf.o /usr/lib/scanf.o ...

为了解决这些方法中存在的问题,静态库被开发出来。静态库减少了可执行文件在磁盘和内存中占用的空间大小。另一方面,应用程序员仅需包含几个库文件的名字。(事实上,C 编译器驱动总是传递 libc.a 给链接器,所以之前提到的对 libc.a 的引用是不必要的。)

在 Unix 系统中,静态库以特定的文件格式 archive 存储在磁盘中。Archive 是连接的可重定位的对象文件的集合,它有一个 header 用于描述每个成员对象文件的大小和位置。Archive 文件名由 .a 后缀表示。为了使我们对库的讨论更加具体,假设我们想在静态库 libvector.a 中提供一个的 vector 例程。
(a) addvec.o

1 void addvec(int *x, int *y,
2 			  int *z, int n)
3 {
4 	int i;
5
6 	for (i = 0; i < n; i++)
7 		z[i] = x[i] + y[i];
8 }

(b) multvec.o

1 void multvec(int *x, int *y,
2 int *z, int n)
3 {
4 	int i;
5
6 	for (i = 0; i < n; i++)
7 		z[i] = x[i] * y[i];
8 }

为了使用该库,我们可能会写一个类似 main2.c 的应用 (包含(头)文件 vector.h 定义了 libvector.a 中例程的函数原型)。

1 /* main2.c */
2 #include <stdio.h>
3 #include "vector.h"
4
5 int x[2] = {1, 2};
6 int y[2] = {3, 4};
7 int z[2];
8
9 int main()
10 {
11 	addvec(x, y, z, 2);
12 	printf("z = [%d %d]\n", z[0], z[1]);
13 	return 0;
14 }

为了构建可执行文件,我们将编译和链接输入文件 main.o 和 libvector.a:

unix> gcc -O2 -c main2.c
unix> gcc -static -o p2 main2.o ./libvector.a

在这里插入图片描述
图 7.7 总结了链接器的活动。-static 参数告诉编译器驱动链接器应该构建完全链接的可执行对象文件,能被加载到内存运行,且不需要再在加载期或运行期进行其他链接行为。当链接器运行时,它确定定义在 addvec.o 中的 addvec 符号被 main.o 引用,所以它拷贝 addvec.o 进入可执行文件中。因为程序没有引用任何 multvec.o 中定义的符号,链接器不拷贝该模块进行可执行文件中。链接器也从 libc.a 中拷贝 prinf.o 模块,以及其他几个来自 C 运行时系统的模块。

6.3 链接器如何使用静态库解析引用

在符号解析阶段,链接器从左到右的扫描可重定位对象文件和 archives,以这些文件出现在编译器驱动的命令行上相同的顺序。(驱动自动地将命令行中出现的 .c 文件翻译为 .o 文件。) 在扫描期间,链接器维护可重定位的对象文件的集合 E,这些可重定位对象文件将组合形成可执行文件未解析的符号集合 U (i.e.,所引用的未定义的符号);输入文件中已定义的符号的集合 D。开始时,E,U,D 都为空。

  • 对命令行中每个输入文件 f,链接器确认 f 是对象文件还是 archive。如果 f 是对象文件,链接器添加 f 到 E 中,依据 f 中的符号定义和引用更新 U 和 D,并处理下一个输入文件。
  • 如果 f 是 archive,链接器尝试使用 archive 成员中定义的符号匹配 U 中的未解析符号。如果某个 archive 成员,m,中定义的某个符号能解析 U 中的某个引用,m 就会被添加到 E 中,链接器依据 m 中的符号定义和引用更新 U 和 D。这一过程在 archive 中的成员对象文件中迭代,直到 U 和 D 不再改变。此时,任何不再 E 中的成员对象文件会被丢弃,链接器继续处理下一个输入文件。
  • 当链接器完成对所有输入文件的扫描,而 U 非空时,它打印出错误并终止。否则,它合并并重定位 E 中的对象文件以构建输出的可执行文件。

不幸的是,该算法可能导致一些令人困惑的链接时错误,因为命令行中库文件和对象文件的顺序意义重大。如果定义某符号的库文件先于引用该符号的对象文件出现,该引用不会被解析,链接将失败。例如,考虑下述:

unix> gcc -static ./libvector.a main2.c
/tmp/cc9XH6Rp.o: In function ‘main’:
/tmp/cc9XH6Rp.o(.text+0x18): undefined reference to ‘addvec’

在处理 libvector.a 时,U 是空的,所有不会添加任何 libvector.a 中的成员对象到 E 中。因此,对 addvec 的引用不会被解析,链接器发出错误信息并终止。

对于库的通用规则是将它们放在命令行的末尾。如果不同库的成员间相互独立,没有任何成员引用另一个成员的符号,那么库可以以任意顺序放在命令行的末尾。

另一方面,如果库不是相互独立的,它们必须被排序,对于每个被外部引用的符号 s,其定义所在的 archive 需要在引用所在的 archive 之后。例如,假设 foo.c 调用位于 libx.a 和 libz.a 中的函数,libz.a 又调用 liby.a 中的函数。libx.a 和 libz.a 在命令行中的位置必须先于 liby.a:

unix> gcc foo.c libx.a libz.a liby.a

为了满足依赖性的需求,库可以在命令行上反复出现。例如,假设 foo.c 调用 libx.a 中的函数,libx.a 调用 liby.a 中的函数,liby.a 调用 libx.a 中函数。libx.a 必须在命令行上重复:

unix> gcc foo.c libx.a liby.a libx.a

当然,我们还可以将 libx.a 和 liby.a 结合为一个 archive。

7. 重定位(Relocation)

一旦符号链接器完成符号解析的步骤,关联代码中的每个符号引用到对应的符号定义 (i.e.,输入对象模块之一中的符号表条目)。此时,链接器知道输入对象模块中代码和数据的具体大小,它已经准备好开始重定位步骤了,即将输入模块合并并为每个符号分配运行时地址。重定位由两步组成:

  • Relocating sections and symbol definitions. 在此步骤中,链接器合并所有相同类型的 sections 组成一个同类型的聚合的 sections。例如,输入模块中的 .data sections 被合并为一个 .data section,位于输出的可执行对象文件中。链接器随后为新的聚合得到的 section输入模块中定义的每个 section输入模块中定义的每个符号分配运行时的内存地址。当此步骤完成,程序中的每个指令和全局变量都有唯一的运行时内存地址。
  • Relocating symbol references within sections. 在本步骤中,链接器修改代码和数据 section 中的每个符号引用,使它们指向正确的运行时地址。为了执行这一步骤,链接器依赖可重定位对象模块中的数据结构,被称为重定位条目,如下所述。

7.1 重定位条目

当汇编器生成一个对象模块时,它并不知道代码和数据最终会被存储到内存中的何处。也不知道该模块引用的外部定义的函数和全局变量的位置。所以无论何时汇编器遇到一个对象引用,它都不知道该对象最终的位置,因此它生成一个 relocation entry 以告知链接器在合并对象文件为可执行文件时,如何修改该引用代码重定位条目被放在 .rel.text 条目中。已初始化数据重定位条目被放在 .rel.data 中。
在这里插入图片描述
图 7.8 展示了 ELF 重定位条目的格式。offset 是需要修改的引用的 section 偏移。sympol 标识被修改的引用应当指向的符号。type 告诉链接器如何修改新的引用。

ELF 定义了 11 个不同的重定位类型,有些十分难懂。我们仅考虑两种最基础的重定位类型:

  • R_386_PC32:重定位使用 32 位相对于 PC 的地址的引用。相对于 PC 的地址是相对于 program counter(PC) 当前实时值的偏移。当 CPU 执行的指令使用 PC-relative 寻址时,它通过将指令中编码的 32 位值加到 PC 中的当前运行时的值,形成一个 effective address (e.g.,call 指令的目标),它总是内存中下一个指令的地址。
  • R_386_32:使用 32 位绝对地址重定位引用。CPU 直接使用指令中编码的 32 位值作为 effective address,不需要进行修改。

7.2 重定位符号引用

在这里插入图片描述
图 7.9 展示了链接器重定位算法的伪代码。第一行和第二行迭代每个 section s 和关联每个 section 的每个重定位条目 r。具体来说,假设每个 section s 都是一个字节数组,每个重定位条目都是类型为 Elf32_Rel 的结构体,如图 7.8 所定义的那样。同样,假设算法运行时,链接器已经为每个 section 和每个符号选择好了运行时的地址 (由 ADDR(s) 标识)。第三行计算 s 数组中的地址,数组中为需要被重新定位的 4 字节引用。如果该引用使用 PC-relative 寻址,行 5-9 对其进行重定位。如果引用使用绝对寻址,11-13对其进行重定位。

7.1 重定位 PC-Relative 引用

回顾图 7.1(a) 中的示例,main.o 的 .text section 中的 main 例程调用 swap 例程,swap 定义在 swap.o 中。下述为 call 指令的反汇编列表,由 GNU OBJDUMP 工具生成:
在这里插入图片描述
从该列表可以看出,call 指令起始于 section 偏移 0x6 处,由一字节的操作码 0xe8 组成,然后是 32 位引用 0xfffffffc (10 进制为 -4),以小端字节序存储。该引用的重定位条目如下 (重定位条目和指令指令其实存储在对象文件的不同 section 中。OBJDUMP 工具为了便利性将它们在一起展示)。重定位条目 r 包含三个字段:

r.offset = 0x7
r.symbol = swap
r.type = R_386_PC32

这些字段告诉链接器修改起始于偏移 0x7 处的 32 位 PC-relative 引用,这样它将在运行时指向 swap 例程。现在,假设链接器确定

ADDR(s) = ADDR(.text) = 0x80483b4
ADDR(r.symbol) = ADDR(swap) = 0x80483c8

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

refaddr = ADDR(s) + r.offset = 0x80483b4 + 0x7 = 0x80483bb

随后依据当前值(-4)更新引用为 0x9,这样它将在运行时指向 swap (第 8 行):

*refptr = (unsigned) (ADDR(r.symbol) + *refptr - refaddr)
		= (unsigned) (0x80483c8 	 + (-4)	   - 0x80483bb)
		= (unsigned) (0x9)

在最终的可执行文件中,call 指令的重定位如下:
在这里插入图片描述
运行时,call 指令将被存储在 0x80483ba 处。当 CPU 指向 call 指令时,PC 的值为 0x80483bf,为紧随 call 指令的指令的地址。为了执行该指令,CPU 执行下述步骤:

  1. 将 PC 推到栈上
  2. PC <- PC + 0x9 = 0x80483bf + 0x9 = 0x80483c8

因此,下个执行的指令就是 swap 例程的第一个指令。

你可能会考虑为什么汇编器在 call 指令中会使用初始值 -4 创建一个引用。汇编器把该值作为偏差来说明 PC 总是指向当前指令的下一条指令这一事实。不同的机器有不同的指令大小和编码,不同的系统上的汇编器将使用不同的偏差。这是一个强大的技巧,允许链接器在不知道特定机器上的指令编码方式仍能够重定位引用。

7.2 重定位绝对引用

回顾 7.1 图中的示例程序,swap.o 模块初始化全局指针 bufp0 为全局 buf 数组的第一个元素的地址。

int *bufp0 = &buf[0];

因为 bufp0 是一个初始化数据对象,将会存储在 swap.o 可重定位对象模块的 .data section。因为它被初始化为全局数组的地址,它需要被重定位。下述为 swap.o 的 .data section 的反汇编列表:
在这里插入图片描述
我们看到 .data section 包含一个 32 位的引用,bufp0 指针,其值为 0x0。重定位条目告诉链接器这是一个 32 位绝对引用,起始于偏移 0 处,必须被重定位以使其指向符号 buf。现在,假设链接器确定

ADDR(r.symbol) = ADDR(buf) = 0x8049454

链接器使用图 7.9 所示的算法的行 13 更新引用:

*refptr = (unsigned) (ADDR(r.symbol) + *refptr)
		= (unsigned) (0x8049454 	 + 0)
		= (unsigned) (0x8049454)

在可执行对象文件中,引用有下述重定位:
在这里插入图片描述
链接器决定运行时的变量 bufp0 将被重定位到内存地址 0x804945c 且将被初始化为 0x8049454, 为 buf 数组的运行时地址。

swap.o 的模块中的 .text section 包含 5 个绝对引用,它们都以相似的方式被重定位。图 7.10 展示了最终的可执行对象文件中重定位的 .text 和 .data section。
在这里插入图片描述

8. 可执行对象文件

我们已经知道了链接器如何将多个对象模块合并为一个可执行对象文件。我们的 C 程序,其生命周期起始时为 ASCII 文本文件的集合,已经被转换为一个二进制文件,该文件包含所有加载该程序进入内存并运行所需要的信息。图 7.11 总结了典型的 ELF 可执行文件中的信息种类。
在这里插入图片描述
可执行对象文件的格式类似于可重定位对象文件的格式。ELF header 描述整个文件格式。它也包含程序的 entry point,它是程序运行时第一个被执行的指令的地址。.text,.rodata,和 .data sections 被重定位到它们最终的运行时内存地址。.init section 定义了一个小的函数,称为 _init,它将被程序的初始化代码调用。因为可执行文件是完全链接的 (重定位的),不需要 .rel sections。

ELF 可执行对象文件被设计为很容易就能加载到内存中,可执行文件中连续的块被映射到连续的内存段中。这一映射由 segment header table 描述。图 7.12 展示了示例中的可执行文件 p 的 segment header table,如同 OBJDUMP 所展示的那样。
在这里插入图片描述
从 segment header table 中,我们看到两个内存段在可执行对象文件上下文中被初始化。第一行和第二行告诉我们第一个 segment (code segment) 在 4KB 边界上对齐,有读写权限,起始内存地址为 0x08048000,总内存大小为 0x448 字节,他被可执行对象文件的第一个 0x448 字节初始化,包含 ELF header,segment header table,以及 .init,.text,和 .rodata sections。

第三行和第四行告诉我们第二个 segment (data segment) 对齐于 4KB 边界,和读写权限,起始于内存地址 0x08049448,总内存大小为 0x104 字节,从文件 0x448 字节偏移处起始,使用 0xe8字节进行初始化,本例中为 .data section 的起始点。segment 中的剩余字节对应于.bss 数据,它们将在运行时被 0 初始化。

9. 加载可执行对象文件

为了运行可执行对象文件 p,我们可以向 Unix shell 命令行键入它的名字:

unix> ./p

因为 p 不对应内置 shell 命令,shell 假设 p 是一个可执行对象文件,通过调用一些操作系统内置的代码,被称为 loader,以运行 p。通过调用 execve 函数,UNIX 程序能调用 loader。loader 硬盘上拷贝可执行对象中的代码和数据到内存中,然后跳到程序的第一个指令,或 entry point,开始运行程序。拷贝程序到内存中然后运行它的过程被称为 loading

每个 Unix 程序都有一个运行时内存印象,类似于图 7.13。在 32 位 Linux 系统上,代码段起始于 0x8048000。data 段是紧随其后的下一个 4KB 对齐的地址。运行时 heap 超过读写段,紧随第一个 4 KB 对齐地址,调用 malloc 时 heap 增长。栈之上的 segment 被保留给操作系统存放其数据和代码。
在这里插入图片描述
当 loader 运行时,它创建如图 7.13 所示的内存印象。在可执行对象文件中的 segment header table 的指导下,它拷贝可执行对象中的块到代码和数据段中。然后,loader 跳到程序的 entry point,即 _start 符号的地址。位于_start 地址的 startup code 定义在对象文件 crt1.o 中,所有的 C 程序都是这样。图 7.14 展现 startup 代码的调用序列。在调用 .text 和 .init 的初始化例程后,起始代码调用 atexit 例程,它追加一个例程列表,该列表中的例程在应用正常终止时被调用。exit 函数运行 atexit 注册的函数,然后调用 _exit 将控制返回给操作系统。然后,startup 代码调用应用的 main 例程,开始执行我们的 C 代码。在应用返回后,startup 代码调用 _exit 例程,将控制返回给操作系统。
在这里插入图片描述

10. 动态链接共享库

6.2 节描述的静态库集合了大量相关的函数,供应用程序使用。然而,静态库仍然有一些严重缺陷,比如所有软件都需要周期性的维护和更新。如果应用程序员想要使用最新版本的库,他们必须使用新的库显式地重新链接他们的程序。

另一个问题是几乎所有的 C 程序都使用标准的 I/O 函数例如 printf 和 scanf。在运行期,函数的代码被复制到每个运行时进程的 text segment。一个典型的系统运行 50-100 个进程,这可能会严重浪费稀缺的内存资源。

Shared libraries 解决了静态库的缺陷。共享库是运行期的内存模块,能被加载到任意的内存地址链接到内存中的程序上。这一过程被称为 dynamic linkingdynamic linker 负责执行这一过程。

共享库也被称为 shared objects,UNIX 系统通常由 .so 后缀指明。Microsoft 操作系统大量使用共享库,即 DLLs(动态链接库)。

共享库以不同的方式 “共享”。首先,在所给的任何文件系统中,一个库对应一个 .so 文件。.so 文件中的代码和数据由所有引用该库的可执行对象文件共享,而静态库被拷贝和内嵌到引用它们的可执行对象文件中。其次,内存中共享库的 .text section 的单个拷贝能在不同的运行时进程中共享
在这里插入图片描述
图 7.15 总结了图 7.6 中的示例程序的动态链接过程。为了构建共享库 libvector.so,对链接器使用下述指定的指令调用编译器驱动:

unix> gcc -shared -fPIC -o libvector.so addvec.c multvec.c

-fPIC 指示编译器生成位置无关的代码。-shared 指示链接器生成共享对象文件。

一旦我们创建了库,就可以链接它到示例程序中:

unix> gcc -o p2 main2.c ./libvector.so

这创建了一个可执行对象 p2,能在运行时链接 libvector.so。基本概念是在创建可执行对象文件时做一些静态链接,然后在程序加载时动态地完成程序链接

此时,libvector.so 中没有代码和数据 section 被实际地拷贝到可执行对象文件 p2。链接器拷贝一些重定向和符号表信息以允许对 libvector.so 中代码和数据地引用在运行时能被解析。

当 loader 加载和运行可执行对象文件 p2 时,它加载部分链接地可执行对象 p2。然后,它注意到 p2 包含一个 .interp section,它包含动态链接器地路径名,动态链接器本身是一个共享对象 (e.g.,Linux 系统上为 LD-LINUX)。loader 加载并运行动态链接器,而非像寻常那样将控制转交给应用。

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

  • 重定位 libc.so 的 textdata 到某个内存 segment 中。
  • 重定位 libvector.so 的 text 和 data 中到另一个内存 segment 中。
  • 重定位任何 p2 中对定义于 libc.so 和 libvector.so 中的符号的引用

11. 从应用加载和链接共享库

到目前为止,我们讨论了动态链接器在应用被加载时加载和链接共享库。然而,对于应用来说在运行时请求动态链接器加载和链接任一共享库也是有可能的。

动态链接是十分强大和有用的技术。下述是一些现实世界的真实案例:

  • Distributing software. Microsoft Windows 开发者频繁使用动态库来发布软件更新。他们生成共享库的新的拷贝,用户可以下装并使用它们以替换当前版本。下一次它们运行应用时,会自动加载和更新新的共享库。
  • Building high-performance Web servers. 很多的 Web 服务器生成 dynamic content, 例如个人化的 Web 页面,账户余额和广告。早期的 Web 服务器通过使用 fork 和 execve 来创建子进程并在子进程的上下文中运行 “CGI 程序”,来生成动态内容。然而,现代高性能 Web 服务器使用基于动态链接的更高性能更复杂的方法来生成动态内容。
    理念是将所有生成动态内容的函数打包到一个动态库中。当请求从 Web 浏览器到达时,服务器动态加载和链接合适的函数,并直接调用它。函数在服务器的地址空间中维持缓存状态,所以随后请求能在支付一个简单的函数调用的代价下被处理。这对繁忙站点的吞吐量有很大的影响。进一步而言,能在运行时更新已存在的函数或添加新的函数,不需要停止服务器。

Linux 系统提供一个简单的对动态链接器的接口,允许应用程序在运行期加载和链接动态库。

#include <dlfcn.h>
void* dlopen(const char* filename, int flag);
// Returns: ptr to handle if OK, NULL on error

dlopen 函数加载和链接共享库 filename。使用先前使用 RTLD_GLOBAL flag 打开的库解析 filename 中的外部符号。如果当前可执行对象文件使用 --rdynamic flag 进行编译,对于符号解析来说它的全局符号也可用。flag 参数必须包含 RTLD_NOW,告诉链接器立即解析外部符号引用,或 RTLD_LAZY flag,指示链接器推迟符号解析直至库中的代码被执行。这些值都能和 RTLD_GLOBAL flag 一起使用。

#include <dlfcn.h>
void* dlsym(void* handle, char* symbol);
// Returns: ptr to symbol if OK, NULL on error

dlsym 函数使用早先打开的共享库的 handle 和一个符号名,如果存在,返回符号地址,否则返回 NULL。

#inlcude <dlfcn.h>
int dlclose(void* handle);
// Returns: 0 if OK, −1 on error

如果没有其他共享库仍在使用 handle 指向的共享库的话,dlclose 函数卸载该共享库。

#include<dlfcn.h>
const char* dlerror(void);
// Returns: error msg if previous call to dlopen, dlsym,
// or dlclose failed, NULL if previous call was OK

dlerror 函数返回一个字符串,用于描述最近调用 dlopendlsym,或 dlclose 函数时发生的错误,没有错误发生时返回 NULL。

图 7.16 展现我们使用这些接口动态链接 libvector.so 共享库,然后调用 addvec 例程。为了编译这一程序,我们以以下方式调用 GCC:

unix> gcc -rdynamic -O2 -o p3 dll.c -ldl

code/link/dll.c

1 #include <stdio.h>
2 #include <stdlib.h>
3 #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 {
11  void *handle;
12  void (*addvec)(int *, int *, int *, int);
13  char *error;
14
15  /* Dynamically load shared library that contains addvec() */
16  handle = dlopen("./libvector.so", RTLD_LAZY);
17  if (!handle) {
18    fprintf(stderr, "%s\n", dlerror());
19    exit(1);
20  }
21
22  /* Get a pointer to the addvec() function we just loaded */
23  addvec = dlsym(handle, "addvec");
24  if ((error = dlerror()) != NULL) {
25    fprintf(stderr, "%s\n", error);
26    exit(1);
27  }
28
29  /* Now we can call addvec() just like any other function */
30  addvec(x, y, z, 2);
31  printf("z = [%d %d]\n", z[0], z[1]);
32
33  /* Unload the shared library */
34  if (dlclose(handle) < 0) {
35    fprintf(stderr, "%s\n", dlerror());
36    exit(1);
37  }
38  return 0;
39 }

12. 位置无关的代码 (PIC)

共享库的主要目的是允许多个运行时的进程在内存中共享相同的库代码,以节省珍贵的内存资源。那么多个进程如何共享一个单独的程序拷贝呢?方法之一是提前为每个共享库分配一个专门的地址空间,然后要求加载器总是在该地址加载共享库。该方法虽然很直观,但是有一些严重的问题。这是对地址空间的一种低效使用,因为即使进程没有使用该库,还是为库分配了部分空间。其次,管理上十分困难。必须保证没有一块地址相互重叠。每次库被修改,都必须保证依然满足它被分配的块,如果不满足,则必须找一个满足条件的新的块。如果我们创建一个新的块,我们必须为它找到一个空间。随着时间的推移,系统中存在几百个库和库的版本,很难保证地址空间不产生很多小的未被使用也不可用的内存空洞。甚至更糟,每个系统对库的内存分配都不一样,这带来了更多的管理难题。

一个更好的方法是编译代码,这样它就可以在任意地址加载和运行不需要被链接器修改。这样的方法被称为 position-independent code (PIC)。用户对 gcc 使用 -fPIC 选项指示 GNU 编译系统生成 PIC 代码。

在 IA32 系统,在相同的对象模块中调用过程不需要特殊的照顾,因为引用是 PC-relative,也知道偏移,所以已经是 PIC 的了。然而,调用外部定义的过程和全局变量的引用通常不是 PIC 的,因为它们需要在链接期进行重定位

PIC 数据引用

编译器为全局变量生成 PIC 引用,通过:无论我们在内存中哪里加载对象模块 (包括共享对象模块),data segment 总是被立即分配在 code segment 之后。因此,code segment 中的指令和 data segment 中的变量间的 distance 是一个运行期常量,与 code 和 data segments 的绝对内存位置无关。

为了利用这一事实,编译器在 data segment 的起始处创建一个称为 global offset table(GOT) 的表。GOT 为对象模块引用的每个全局数据对象包含一个条目。编译器也为每个 GOT 中的条目生成一个重定位记录。在加载期,动态链接器重定位 GOT 中的每个条目,所以它将包含合适的绝对地址。每个引用全局数据的对象模块都有自己的 GOT。

在运行期,每个全局变量通过 GOT直接引用,使用下述代码形式:
在这里插入图片描述
在这段代码中,对 L1 的调用将返回地址 (恰巧时 popl 指令的地址) 放到栈上。popl 指令指令然后将该地址弹出到 %ebx。这两条指令的效果是将 PC 的值移动到 %ebx 中。

addl 指令添加一个常量偏移到 %ebx 上,这样它就指向 GOT 中合适的条目了,该条目包含数据项的绝对地址。此时,全局变量能通过 %ebx 中包含的 GOT 条目直接引用。在这个例子中,两个 movl 指令 (通过 GOT 间接地) 加载全局变量的内容到 %eax 寄存器中。

PIC 代码有性能缺陷。每个全局变量引用现在需要 5 个指令而不是一个指令,以及对 GOT 的额外的内存引用。虽然,PIC 代码使用额外的寄存器保存 GOT 条目的地址。在具有大量寄存器文件的机器上,这不是一个大的问题。但是在缺少寄存器的 IA32 系统中,缺少一个寄存器也会导致寄存器被放到栈上。

PIC 函数调用

对于 PIC 代码来说,使用完全相同的方法来解析外部的过程调用是完全有可能的:
在这里插入图片描述
然而对于每一次的运行期过程调用来说,这一方法需要三个额外的指令。相反,ELF 编译系统使用了一个有趣的技术,称为 lazy binding,它推迟了过程地址的绑定直到该过程第一次被调用。该例程第一次调用时,会有一个不小的开销,但是在那之后的每次调用都只需要一个指令和一个间接的内存引用。

Lazy binding 是通过两个数据结构之间紧凑但有些复杂的交互实现的:GOT 和 procedure linkable table (PLT)。如果一个对象模块调用任何定义在共享库中的函数,它就有自己的 GOT 和 PLT。GOT 是 .data section 的一部分,PLT 则是 .text section 的一部分。

图 7.17 展示了图 7.6 中 main2.o 的示例程序的 GOT 的格式。前三个 GOT 条目是特殊的:GOT[0] 包含 .dynamic segment 的地址,包含用于绑定过程地址 (例如符号表的位置和重定位的信息) 的动态链接器的信息。GOT[1] 包含一些定义该模块的信息。GOT[2] 包含进入动态链接器的 lazy binding 代码的 entry point。
在这里插入图片描述
定义在共享对象中并被 mian2.o 调用的每个例程在 GOT 中都有一个条目,从 GOT[3] 开始。对于示例程序,可以看到 printf 的 GOT 条目,它定义在 libc.so 中,addvec 的 GOT 条目,它定义在 libvector.so 中。

图 7.18 展示了示例程序 p2 的 PLT。PLT 是16 字节条目的数组。第一个条目 PLT[0],是跳入动态链接器的特殊条目。每个被调用的过程在 PLT 中都有一个条目,起始于 PLT[1]。本图中,PLT[1] 对应于 printf,PLT[2] 对应于 addvec。
在这里插入图片描述
最初,在程序被动态链接开始执行后,过程 printf 和 addvec 被绑定到各自的 PLT 条目中的第一个指令。例如,调用 addvec 有如下形式:

80485bb: e8 a4 fe ff ff call 8048464 <addvec>

当 addvec 被第一次调用时,控制被传递给 PLT[2] 的第一个指令,间接的跳到 GOT[4]。最初,每个 GOT 地址包含对应 PLT 条目的 pushl 条目的地址,所以 PLT 的间接跳跃简单地将控制返回给 PLT[2] 的下一条指令。该指令将 addvec 符号的 ID 推到栈上。最后一条指令跳到 PLT[0],PLT[0] 首先将标识信息从 GOT[1] 中推到栈上,然后通过 GOT[2] 间接的跳到动态链接器上。动态链接器使用两个栈条目来确定 addvec 的位置,使用该地址重写 GOT[4],并将控制转移给 addvec。

下一次程序调用 addvec 时,控制如同之前一样传递到 PLT[2]。然而,这次间接跳到 GOT[4] 把控制转移给了 addvec。此时唯一的额外开销是间接跳跃的内存引用。

13. 总结

链接能在编译期由静态链接器执行,也能在加载期运行期由动态链接器执行。链接器管理的二进制文件被称为对象文件,有三种不同的形式:可重定位可执行,和共享对象文件。可重定位对象文件由静态链接器结合到可执行对象文件中,可执行对象文件能被加载到内存中执行。共享对象文件 (共享库) 由动态链接器在运行期加载和链接,要么是在调用程序被加载并开始执行时,要么是依据需要在程序调用 dlopen 所打开的库中的函数时。

链接器的两个主要任务是符号解析,对象文件中的全局符号被绑定到唯一的定义上,以及重定位,确定每个符号最终的内存地址,并修改对这些符号的引用的值。

因为静态链接由编译器,例如 GCC 触发。它们结合多个可重定位对象文件为一个可执行对象文件。多个对象文件能定义相同的符号,链接器用于隐式地解析多次定义所使用地规则可能会导致一些不易察觉地 bug。

多个对象文件能被包含到同一个静态库中。链接器使用库来解析在其他对象模块中地符号引用。很多链接用来解析符号引用地从左到右地顺序扫描是另一个造成奇怪的链接期错误的源头。

loader 加载可执行对象文件的内容到内存中,并运行该程序。链接器能产生部分链接的可执行对象文件,其中包含定义在共享库中的未解析的例程引用和数据。在加载期,loader 映射部分链接的可执行对象到内存中,然后调用动态链接器,通过加载共享库重定位程序中的引用来完成链接任务。

作为位置无关的代码编译的共享库能在任意地方加载,且在运行期被多个进程共享。应用能在运行期使用动态链接器加载链接访问共享库中的函数和数据。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值