深入理解计算机系统:链接(第二章:符号解析、重定位和可执行目标文件)

1、符号解析

链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来,即使用的符号一定要找到相应的定义。可分为局部符号解析和全局符号解析。

局部符号解析:引用定义在相同模块中的局部符号的引用,符号解析非常的简单明了,就不用介绍了。
全局符号解析:当编译器遇到一个不是当前模块中定义的符号时,会假设该符号时在其他某个模块中定义的,生成一个连接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个符号的定义,就输出一条错误信息并终止。

全局符号解析还因为多个目标文件可能会定义相同的名字的全局符号。在这种情况下,链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃其它定义。

1.1、解析多重定义的全局符号

链接器的输入是一组可重定位目标模块。每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是全局的(对其它模块也可见)。如果多个模块定义同名的全局符号,连接器会进行筛选。

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

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

  1. 不允许有多个同名的强符号
  2. 如果有一个强符号和多个弱符号同名,那么选择强符号
  3. 如果有多个弱符号同名,那么从这些弱符号中任意选择一个

规则2和规则3的应用会造成一些不易察觉的运行时错误,对于不警觉的程序员来说,是很难理解的,尤其是如果重复的符号定义还有不同的类型时。如下,x在一个模块中为int,而在另一个模块中为double:

foo.c 文件中

void f(void);

int y = 15212;
int x = 15213;

int main()
{
	f();
	printf("x=0x%x y = 0x%x \n", x,y);
	return 0;
}

bar.c 文件中

double x;

void f()
{
	x = -0.0;
}

在64位系统上,double类型是8个字节,int类型是4个字节。x的地址是0x601220,y的地址是0x601024。因此,bar.c文件中的 x = -0.0 的赋值会覆盖内存中x和y的位置。

这是一个细微的错误,链接器只会发出一条警告,但是通常要在程序执行很久以后才表现出来,且远离错误发生地,这种错误难以修正。

当你怀疑有此类错误时,用GCC -fno-common标志这样的选项调用链接器,这样链接器就会在遇到多重定义的全局符号时,触发一个错误。

1.2、与静态库链接

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

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

1.3、链接器如何使用静态库来解析引用

虽然静态库很有用,但是它们同时也是一个程序员迷惑的源头,原因在于Linux链接器使用它们解析外部引用的方式。在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。在这次扫描中,链接器维护一个可重定位目标文件的集合E(这个集合中的文件会被合并成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。初始时,E、U和D均为空。

  • 对于命令行上的每个输入文件 f ,链接器会判断f是一个目标文件还是一个存档文件。如果 f 是一个目标文件,那么链接器把 f 添加到 E,修改U和D来反映 f 中的符号定义和引用,并继续下一个输入文件。
  • 如果 f 是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将 m 加到E中,并且链接器修改U和D来反映 m 中的符号定义和引用。对存档文件中所有的成员目标文件都一次进行这个操作,直到U和D都不再发生变化。此时,任何不包含在E中的成员目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件。
  • 如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,构建输出的可执行文件。

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

2、重定位

链接器完成了符号解析这一步,其实就给所有引用的符号找到了相应的定义。链接器就知道它输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:

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

2.1、重定位条目

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

下面的结构体展示了ELF重定位条目的格式。offset是需要被修改的引用的节偏移。symbol标识被修改引用应该指向的符号。type告知链接器如何修改新的引用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。

typedef struct
{
	long offset;
	long type:32,
			symbol:32;
	long addend;
}Elf64_Rela

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

  • R_X86_64_PC32。重定位一个使用32位 PC 相对地址的引用。一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行一条使用PC相对寻址的指令时,他就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址,PC值通常是下一条指令在内存中的地址。
  • R_X86_64_32。重定位一个使用32位绝对地址的引用。通常绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。

3、可执行目标文件

我们已经看到链接器如何将多个目标文件合并成一个可执行目标文件。可执行目标文件是一个二进制文件,这个二进制文件包含加载程序到内存并运行它所需要的所有信息。下图概括了一个典型的ELF可执行文件中的各类信息:
深入理解计算机系统:链接(第二章:符号解析、重定位和可执行目标文件)_第1张图片
可执行目标文件的格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式。它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。.text、.rodata和.data节与可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时内存地址以外。.init节定义了一个小函数,叫做_init,程序初始化代码会调用它。因为可执行文件是完全链接的,所以它不在需要.rel节。

ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片被映射到连续的内存段。程序头部表描述了这种映射关系。

4、加载可执行目标文件到内存

要运行科执行目标文件,需要先调用操作系统中的 加载器加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存中的过程叫做加载。

每个Linux程序都有一个运行时内存映射,如下图所示:
深入理解计算机系统:链接(第二章:符号解析、重定位和可执行目标文件)_第2张图片
在Linux x86-64系统中,代码段总是从地址0x400000处开始,后面是数据段,在之后是运行时堆,通过调用malloc库往上增长。堆之后的区域是为共享模块保留的。用户栈总是从最大的合法用户地址( 2 48 2^{48} 248-1)开始,向较小内存地址增长。栈上的区域是为内核中的代码和数据保留的,所谓内核就是操作系统驻留在内存的部分。

为了简洁,我们把堆、数据和代码段画的彼此相邻,并且把栈顶放在了最大的合法用户处。实际上,由于.data段有对其要求,所以代码段和数据段之间是有间隙的。

当加载器运行时,它创建上图的内存映射。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段。接下来,加载器跳转到程序的入口点,也就是_start函数的地址。这个函数是在系统目标文件ctrl.o中定义的,对所有的C程序都是一样的。_start函数调用系统启动函数__libc_start_main,该函数定义在libc.so中。它初始化执行环境,调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。

感谢大家,我是假装很努力的YoungYangD(小羊)

参考资料:
《深入理解计算机系统》

你可能感兴趣的:(计算机基础)