链接1——CSAPP总结

define 链接 (linking)

是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这 个文件可被加载(或被拷贝)到存储器并执行。链接可以执行于编译时 (compile time), 也就 是在源代码被翻译成机器代码时;也可以执行于加载时 (load time) , 也就是在程序被加载器 (loader) 加载到存储器并执行时;甚至执行于运行时 (run time), 由应用程序来执行。在早期 的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器 (linker) 的程序自 动执行的。
链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译 (separate compilation) 成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更 小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需 简单地重新编译它,并重新链接应用,而不必重新编译其他文件。

  • 学习链接之必要
  • ·理解链接器将帮助你构造大型程序。
  • 理解链接器将帮助你避免一些危险的编程错误。
  • 理解链接将帮助你理解语言的作用域规则是如何实现的。
  • 理解链接将帮助你理解其他重要的系统概念。
  • 理解链接将使你能够利用共享库。

(美名曰总结,实为抄书,各位看官请勿责怪)

编译过程

C 预处理器预处理 cpp
C 编译器ccl
汇编器 as
链接器程序ld
c文件
ASCII 码的中间文件 main.i
汇编文件main.s
可重定位目标文件main. o
可执行目标文件exe..o
  • 举例:
  • 两段程序:
/*main.c*/
void swap();
int buf[2] = {1, 2};
int main() {
	swap(); 
	return 0;
}
/*swap.c*/
extern int buf [] ;
int *bufp0 = &buf[0] ; 
int *bufp1;
void swap() {
	int temp;
	bufp1 = &buf[1]; 
	temp =*bufp0; 
	*bufp0 = *bufp1; 
	*bufp1 = temp;
}

大多数编译系统提供编译驱动程序 (compiler driver), 它代表用户在需要时调用语言预处理 器、编译器、汇编器和链接器。比如,要用 GNU 编译系统构造示例程序,我们就要通过在外壳 中输入下列命令行来调用 GCC 驱动程序:

unix> gee -02 -g -op main.e swap.e

图 7-2 概括了驱动程序在将示例程序从 ASCII 码源文件翻译成可执行目标文件时的行为。 (如果你想看看这些步骤,用 -v 选项来运行 GCC。)

  • 驱动程序首先运行 C 预处理器 (cpp), 它 将 C 源程序 main.c 翻译成一个 ASCII 码的中间文件 main.i:
  • 接下来,驱动程序运行 C 编译器 (ccl), 它将 main.i 翻译成一个 ASCII 汇编语言文件main.s 。
  • 然后,驱动程序运行汇编器 (as), 它将 main.s 翻译成一个可重定位目标文件 (relocatable
    object file) main. o
  • 驱动程序经过相同的过程生成 swap.o。最后,它运行链接器程序 ld, 将 main.o 和
    swap.o 以及一些必要的系统目标文件组合起来,创建一个可执行目标文件 (executable object
    在这里插入图片描述
  • 要运行可执行文件 p, 我们在 Unix 外壳 的命令行上输入它的名字: unix> ./p main.c. 翻译器
    外壳调用操作系统中一个叫做加载器的函 数,它拷贝可执行文件 p 中的代码和数据到存 储器,然后将控制转移到这个程序的开头。
    在这里插入图片描述

链接器功能

  • 像 Unix ld 程序这样的静态链接器 (staticlinker) 以一组可重定位目标文件和命令行参数作为输人,生成一个完全链接的可以加载和运行的可执行目标文件作为输出。
  • 输入的可重定位目标文件由各种不同的代码和数据节 (section) 组成:指令在一个节中,初始化的全局变量 在另一个节中,而未初始化的变量又在另外一个节中。
  • 注意:目标文件纯粹是字节块的集合。这些块中,有些包含程序代码,有些则包含程序数据,而 其他的则包含指导链接器和加载器的数据结构。
  • 链接器将这些块连接起来,确定被连接块的运行 时位置,并且修改代码和数据块中的各种位置。链接器对目标机器了解甚少。产生目标文件的编 译器和汇编器已经完成了大部分工作。

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

  • 符号解析 (symbol resolution):目标文件定义和引用符号。符号解析的目的是将每个符号 引用刚好和一个符号定义联系起来。
  • 重定位 (relocation):编译器和汇编器生成从地址 0 开始的代码和数据节。链接器通过把 每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指 向这个存储器位置,从而重定位这些节。
链接器链接目标文件的块
符号解析
重定向

目标文件

目标文件有三种形式:

  • 可重定位目标文件.o file:包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文 件合并起来,创建一个可执行目标文件。
  • 可执行目标文件a.out file:包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行。
  • 共享目标文件.so file:一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载 到存储器并链接。

编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。

可执行可连接的目标文件ELF(统一名称)

现代 Unix 系统(如 Linux, 还有 System V Unix 后来的版本,各种 BSD Unix, 以及 Sun Solaris) 使用的是 Unix 可执行和可链接 格式 (Executable and Linkable Format, ELF)。不管格式是哪种,都是类似。

  • 一个典型的 ELF 可重定位目标文件如下:
    在这里插入图片描述
  • ELF 头 (ELF header):以一个 16 字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序
  • ELF 头剩下的部 分包含帮助链接器语法分析和解释目标文件的信息。其中包括 ELF 头的大小、目标文件的类型 (如可重定位、可执行或者是共享的)、机器类型(如 IA32)、节头部表 (section header table) 的 文件偏移,以及节头部表中的条目大小和数量。不同节的位置和大小是由节头部表描述的,其中 目标文件中每个节都有一个固定大小的条目 (entry) 。
  • 夹在 ELF 头和节头部表之间的都是
  • text: 已编译程序的机器代码
  • rodata: 只读数据,比如 printf 语句中的格式串 和开关语句的跳转表
  • data: 已初始化的全局 C 变量。局部 C 变量在运 行时保存在栈中,既不出现在 .data 节中,也不出现在 .bss 节中。
  • bss: 未初始化的全局 C 变量。在目标文件中这个节 不占据实际的空间,它仅仅是一个占位符。 目标文件格式 区分初始化和未初始化变量是为了空间效率:在目标文件 中,未初始化变量不需要占据任何实际的磁盘空间。
  • . symtab: 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通 过 -g 选项来编译程序才能得到符号表信息。实际上,每个可重定位目标文件在 . symtab 中都 有一张符号表。然而,和编译器中的符号表不同, symtab 符号表不包含局部变量的条目。 .
  • rel . text: 一个 .text 节中位置的列表,当链接器把这个目标文件和其他文件结合时, 需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方 面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息, 因此通 常省略,除非用户显式地指示链接器包含这些信息。
  • .rel.data: 被模块引用或定义的任何全局变量的重定位信息。一般而言,任何已初始化 的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。 .debug: 一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引 用的全局变量,以及原始的 C 源文件。只有以 -g 选项调用编译驱动程序时才会得到这张表。 .line: 原始 C 源程序中的行号和 .text 节中机器指令之间的映射。只有以 -g 选项调用 编译驱动程序时才会得到这张表。 .strtab: 一个字符串表,其内容包括 .symtab 和 .debug 节中的符号表,以及节头部中 的节名字。字符串表就是以 null 结尾的字符串序列。

符号和符号表

符号表是由汇编器构造的,使用编译器输出到汇编语言 .s 文件中的符号。. symtab 节中包含 ELF 符号表。这张符号表包含一个条目格式形如:

typedef struct { 
int name;//是字符串表中的字节偏移,指向符号的以 null 结尾的字符串名字
int value;//符号的地址,对于可重定位模块value是距定义目标的节的起始位置的偏移,对于可执行目标文件来说,该值是一个绝对运行时地址
int size;//size 是目标的大小,字节为单位
char type:4,//type 通常要么 是数据,要么是函数
binding:4;//binding 字段表示符号是本地的还是全局的
char reserved; 
char section;//每个符号都和目标文件的某个节相关联,由 section 字段表示
} Elf_Symbol;
//符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目。 所以这些目标的类型也有所不同。

用GNUREADELF工具分析ELF文件

  • 参数
参数 属性
-a all
-h 显示a.out的ELF Header的文件头信息。
-l 显示a.out的Program Header Table中的每个Prgram Header Entry的信息(如果有)头表信息
-S 显示a.out的Section Header Table中的每个Section Header Entry的信息(如果有)节信息
  • 使用案例:
linux> readelf -h test.o
linux> readelf -l test.o
linux> readelf -S swap.o

在这里插入图片描述

  • 具体使用请移步:here

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

  • 由 m 定义并能被其他模块引用的全局符号:全局链接器符号对应于非静态的 C 函数以及被 定义为不带 C static 属性的全局变量。

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

  • 只被模块 m 定义和引用的本地符号:有的本地链接器符号对应于带 static属性的 C 函 数和全局变量。这些符号在模块 m 中随处可见,但是不能被其他模块引用。目标文件中对 应于模块 m 的节和相应的源文件的名字也能获得本地符号。

  • 本地链接器符号和本地程序变量不同, .symtab 中的符号表不包含对应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,链接器对此类符号不感 兴趣。定义为带有 C static 属性的本地过程变量是不在栈中管理的。相反,编译器在 .data 和 .bss 中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符

链接器任务一,符号解析

链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确 定的符号定义联系起来。

1.定义在相同模块中的本地符号的引用

符号解析是非常简单明了,编译器只允许每个模块中每个本地符号只有一个定义。编译器还确保静态本地变量, 它们也会有本地链接器符号,拥有唯一的名字。

2.对全局符号的引用解析

当编译器遇到一个不是在当前模块中定义的符号 (变量或函数名)时,它会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目, 并把它交给链接器处理。

(1)如果链接器在它的任何输入模块中都找不到这个被引用的符号,它就输 出一条错误信息并终止。
(2)如果多个目标文件可能会定义相同的符号。在这种情况中, 链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃其他定义。 对于多重定义的处理规则:

  • Rule 1: 不允许有多个强符号:强符号只能被定义一次否则链接器产生错误信息
  • Rule 2: 若有一个强符号和多个弱符号,则选择强符号 (此时引用弱符号会导致解析强符号)
  • Rule 3: 若有多个弱符号,则从这些弱符号中任意选择一个(使用gcc –fno-common 选项调用链接器,在遇到多重定义的全局符号时,会有警告信息)举例如下:
    在这里插入图片描述

编程习惯

C 程序员使用 static 属性在模块内部隐藏变量和函数声明。任何声明带有 static 属性的全局变 量或者函数都是模块私有的。类似地,任何声明为不带 static 属性的全局变量和函数都是公 共的,可以被其他模块访问。尽可能用 static 属性来保护你的变量和函数是很好的编程习惯

  • 若可以要尽量避免使用全局变量
  • 否则
  • 尽可能使用static
  • 定义全局变量时要初始化
  • 如果要使用外部全局变量需使用extern

链接器任务二,重定位

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

在这里插入图片描述
在这里插入图片描述

重定位
重定位节和符号定义
重定位节中的符号引用
  • 重定位节和符号定义。链接器将所有相同类型的节合并为同一类型的新的聚合节。这一步完成,程序中的每个指 令和全局变量都有唯一的运行时存储器地址。
  • 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。为了执行这一步,链接器依赖于称为重定位条目 (relocation entry) 的可重定位目标模块中的数据结构。以下我们将重点介绍重定位符号引用:

1.可重定位目标模块中的数据结构——重定位条目

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

  • 代码的重定位条目放在 .rel.text 中。已初始化数据的重定位条目放在 .rel.data中。

  • 数据结构的格式如下:

typedef struct {
int offset; 
int symbol:24, 
type:8; ·
}Elf32_Rel;
  • 同样可以通过READELF工具查看:

在这里插入图片描述
ELF 定义了 11 种不同的重定位类型,有些相当隐秘。我们只关心其中两种最基本的重定位
类型:

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

2.重定位符号引用算法伪码

假设链接器已经为每个节(用 ADDR (s) 表示)和每个符号都选择了运行时地址(用 ADDR(r. symbol) 表示)

foreach sections { 
	foreach relocation entry r { 
		refptr = s + r. offset; /* ptr to reference to be relocated */
		/* Relocate a PC-relative reference */ 
		if (r. type ==R386_PC32) {
			refaddr = ADDR(s) + r. offset; /* ref's run-time address */ 
			*refptr = (unsigned) (ADDR(r.symbol) + *refptr - refaddr);
	}	
	/* Relocate an absolute reference */ 
	if (r. type ==R_386_32)
		*refptr = (unsigned) (ADDR(r.symbol) + *refptr);
	}
}

3.重定位 PC 相对引用

  • 重定位前:
    在这里插入图片描述
    (1)在这个列表中看到指令开始于节偏移0x12处,由 1 个字节的操作码 0xe8 和随后的 32 位引用 0xfffffffc (十进制 -4) 组成,它是以小端法字节顺序存储的。下一行显示的是这个引用的重定位条目。(重定位条目和指令实际上是存放在目标文 件的不同节中的)重定位条目 r 由 3 个字段组成:
r.offset = 0x12
r.symbol = swap 
r.type =R386_PC32

(2)这些字段告诉链接器修改开始于偏移量 0x12处的 32 位 PC 相对引用,使得在运行时它指向swap 程序。现在,假设链接器已经确定:

ADDR(s) = ADDR(. text) = Ox8048380			//节起始地址
ADDR(r.symbol) = ADDR(swap) = Ox80483b0		//运行时地址

(3)链接器首先计算出引用的运行时地址:
refaddr = ADDR(s)+ r. offset
= 0x8048380 + 0x12 = Ox8048392
(3)按照算法可以计算重定位call的偏移量:

*refptr = (unsigned) (ADDR(r.symbol) +*refptr - refaddr) = (unsigned) (Ox80483b0+ (-4) - Ox8048392) = (unsigned) (Ox9)
  • 重定位后:
    在这里插入图片描述
    *运行时: PC+ refptr = 0x8048396 + 0x1a = 0x80483b0

4.重定位绝对引用

  • 使用objdump -D查看swap.o可重定向文件,使用readelf -a swap.o 查看重定位条目得到:
    在这里插入图片描述
    重定位条目告诉链接器
    这是一个 32 位绝对引用,开始于偏移 0 处,必须重定位使得它指向符号 buf。现在,假设链接 器已经确定:
  • ADDR(r.symbol) = ADDR(buf) = 0x8049620
  • *refptr = (unsigned) (ADDR(r.symbol) + *refptr) = (unsigned) (0x8049620+0) = (unsigned) (0x8049620)
  • 使用objdump -D run查看反汇编:
    在这里插入图片描述

结果:可执行目标文件

在这里插入图片描述
可执行目标文件的格式类似于可重定位目标文件的格式。

  • ELF 头部描述文件的总体格式。它 还包括程序的入口点 (entry point), 也就是当程序运行时要执行的第一条指令的地址。
  • .tex七、 .rod扛a 和 .data 节和可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最 终的运行时存储器地址以外。
  • .init 节定义了一个小函数,叫做 _init, 程序的初始化代码会 调用它。
  • 因为可执行文件是完全链接的(已被重定位了),所以它不再需要 .rel 节。

加载可执行目标文件

在这里插入图片描述

  • 运行文件:
unix> . /p
  • 因为 p 不是一个内置的外壳命令,所以外壳会认为 p 是一个可执行目标文件
  • 通过调用某个驻 留在存储器中称为加载器 (loader) 的操作系统代码来运行它。
  • 任何 Unix 程序都可以通过调 用 execve 函数来调用加载器。
  • 加载器将可执行目标 文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一条指令或入口点 (entry point) 来运行该程序。这个将程序拷贝到存储器并运行的过程叫做加载 (loading)

加载过程

  • 加载器将可执行文件的相关内容拷贝到代码和数据段。
  • 接下来,加载 器跳转到程序的入口点,也就是符号 _start 的地址。在 _start 地 址处的启动代码 (startup code) 是在 目标文件 ctrl . o 中定义的,对所 有的 C 程序都是一样的。
  • 图展 示了启动代码中具体的调用序列。在 从.text 和 .init 节中调用了初 始化例程后,启动代码调用 atexit 例程
  • 这个程序附加了一系列在应 用程序正常中止时应该调用的程序。
  • exit 函数运行 atexit 注册的函 数,然后通过调用 _exit 将控制返 回给操作系统。
  • 接着,启动代码调用 应用程序的 main 程序,它会开始执 行我们的 C 代码。在应用程序返回之后启动代码调用 _exit 程序,它将控制返回给操作系统。
原创文章 236 获赞 430 访问量 7万+

猜你喜欢

转载自blog.csdn.net/weixin_44307065/article/details/106153802