深入理解计算机系统 --- 链接

本章目的: 提供了关于链接各方面的全面讨论,从传统静态链接到加载时的共享库的动态链接,以及到运行时的共享库的动态链接

链接(linking)是将各种代码和数据片段收集并组合成一个单一文件的过程

这个文件可被加载(复制)到内存被并执行
链接可以执行于编译时,也就是在源代码被翻译成机器代码时
也可以执行于运行时,也就是应用程序执行

在早期,链接是手动执行的,在现代系统中,链接是由叫做链接器(linker)的程序自动执行的
在这里插入图片描述
不过,无论是什么样的操作系统、ISA或者目标文件格式、基本链接概念是通用的
细节可能不尽相同,但是概念是相同的

7 链接

7.1 编译器驱动程序

在这里插入图片描述
大多数编译系统提供 编译器驱动程序 , 它代表用户在需要时调用语言预处理器、编译器、汇编器和连接器

在shell使用命令:
在这里插入图片描述
在这里插入图片描述
上图概括了驱动程序在将示例从ASCLL码源文件翻译器成 可执行目标文件时的行为
在这里插入图片描述
如果想看详细步骤,可以用 –v 选项

它将C的源程序main.c 翻译成一个ASCLL码的中间文件 main.i
Cpp main.c main.i

然后,驱动程序运行C编译器(ccl),它将main.i翻译成一个ASCLL汇编语言文件main.s
Ccl main.i –Og –o main.s

然后,驱动程序运行汇编器(as),它将main.s翻译成一个可重定位目标文件main.o
As –o main.o main.s

驱动程序经过相同的过程生产sum.o ,最后,它运行连接器ld,将main.o和sum.o以及一些必要的系统目标文件组合起来,创建一个可执行目标文件 prog
Ld –o prog main.o sum.o

运行prog
./prog
Shell调用操作系统中一个叫做加载器的函数,它将可执行文件prog的代码和数据复制到内存,然后将控制转移到这个程序的开头

在这里插入图片描述
上图展示了 GCC 编译成不同的中间文件

7.2 静态链接
像Linux LD程序这样的静态链接器 (static linker) 以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出

输入的可重定位目标文件由各种不同的代码和数据节(section)组成
每一节都是一个连续的字节序列,指令在一节中,初始化了的全局变量在另一节中,而未初始化的变量由在另外一节中

为了构造可执行文件,链接器必须完成两个主要任务:
在这里插入图片描述

7.3 目标文件

目标文件有三种形式:
在这里插入图片描述

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

在技术上来说,一个目标模板(Object module)就是一个字节序列,而一个目标文件(Object file)就是一个以文件形式存放在磁盘中的目标模板

目标文件就是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同
Windows使用可移植可执行(Protable Executable, PE)格式
现代x86-64 Linux和Unix系统使用可执行可链接格式(Executable and Linkable Format, ELF)
但不管哪种格式,基本概念是相似的

7.4 可重定位目标文件

在这里插入图片描述
ELF头(ELF header) 以一个16字节的序列开始,这个序列描述了生产该文件的系统的字的大小和字节顺序

ELF头剩下的部分包括帮助连接器语法分析和解释目标文件的信息
其中包括ELF头的大小、目标文件的类型、机器类型、节头部表中条目的大小和数量
不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目

包含在EFL头和节头部表之间的都是节,一个典型的ELF可重定位目标文件包含下面几个节:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

7.5 符号和符号表

每个可重定位目标模板m都有一个符号表,它包含m定义和引用的符号的信息,在连接器的上下文中,有三种不同的符号:
在这里插入图片描述
本地链接器符号和本地程序变量不同的, .symtab中的符号表不包含对应于本地非静态程序变量的任何符号,这些符号在运行时在栈中被管理

定义带有C static属性的本地过程变量是不在栈中被管理的,编译器在.data 或 .bss 中为每个定义分配空间,并在符号表中创建一个唯一名字的本地链接符号

符号表是由编译器构造的,使用编译器输出到汇编语言 .s 文件中的符号, .symtab节中包含ELF符号表,这张符号表包含一个条目的数据
在这里插入图片描述
在这里插入图片描述
每个符号都被分配到目标文件的某个节,由section字段表示,该字段也是到一个节头部表的索引

有三个特殊的伪节(pseudosection),它们在节头部表中是没有条目的:
ABS 代表不被重定位的符号
UNDEF 代表未定义的符号,也就是在本目标模板中引用
COMMON 表示还未被分配位置的未初始化的数据目标
对于COMMON符号,value字段给出对齐要求,而size给出最小的大小

注意,只有可重定位目标文件才有这些伪节,可执行目标文件中是没有的

7.6 符号解析

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

对于那些和引用定义在相同模板中的局部符号的引用,符号解析式是非常简单明了的
编译器只允许每个模板中每个局部符号有一个定义,静态变量也会有本地链接器符号
编译器还要确保他们拥有唯一的名字

对于全局变量的引用解析就棘手得多,当编译器遇到一个不是在当前模板中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理
如果链接器在他任何输入模块中找不到这个被引用符号的定义,就输出一条错误信息
在这里插入图片描述

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

链接器的输入时一组可重定位目标模块
每个模块定义一组符号,有些是局部(只对定义该符号的模块可见)
有些是全局(对其他模块也可见)

如果多个模块定义同名的全局符号,下面是Linux编译系统采用的方法:
在编译时,编译器向汇编器输出每个全局符号,或者是强(strong),或者是弱(weak)
而汇编器把这个信息隐含在可重定位目标文件的符号表里
函数和已初始化的全局变量是强符号
未初始化的全局变量是弱符号
在这里插入图片描述
假设试图编译和链接下面两个C模块:

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

7.6.2 与静态库链接

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

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

为了对库的讨论更加形象,参考下面例程:

在这里插入图片描述
要创建这些函数的一个静态库,使用ar工具:
在这里插入图片描述
为了使用这个库,我们可以编写一个应用程序:
在这里插入图片描述
在这里插入图片描述
//vector.h 是自己创建的一个头文件,里边包括那两个函数的声明
-static 参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件
它可以加载到内存并运行,在加载时无须更进一步的链接
-lvector 参数是 libvector.a 的缩写, -L. 参数告诉编译器在当前目录下查找libvector.a

在这里插入图片描述
上图概括了链接器的行为
当链接器运行时,它判定main2.o引用了addvec.o定义的addvec符号,所以复制addcec.o到可执行文件,因为程序不引用任何由 multvec.o 定义的符号,所以链接器就不会复制这个模块到可执行文件,链接器还会复制liba.a 中的printf.o 模块,以及许多C运行时系统中的其他模块

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

Linux 链接器使用它们解析外部引用的方式
在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件
(驱动程序自动将命令行中所有的.c文件翻译成.o文件)在这次扫描中,链接器维护一个可重定位目标文件的集合E(在这个集合中的文件会被合并起来形成可执行文件)
一个未解析的符号(即引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已
定义的符号集合D,初始时,E、U、D均为空
在这里插入图片描述
在这里插入图片描述

7.7 重定位

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

7.7.1 重定位条目

当汇编器生成一个目标模块时,它并不知道数据和代码最终将要放在内存的什么位置
也不知道这个模块引用的任何外部定义的函数或者全局变量的位置

所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生产一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用

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

在这里插入图片描述
上图展示了ELF重定位条目的格式

Offset 是需要被修改的引用的节偏移
Symbol 标识被修改引用应该指向的符号
Type 告诉链接器如何修改新的引用
Addend 是一个有符号常数,一些类型的重定位要被使用它对被修改引用的值做偏移调整

在这里插入图片描述
这两种重定位类型支持x86-64小型代码模型(small code model),该模型假设可执行目标文件中的代码和数据总体大小小于2GB,因此在运行时可以用32位PC相对地址来访问
GCC默认使用小型代码模型,大于2GB的程序可以用 –mcmodel = medium(中型代码模型)
和 –mcmodel = large(大型代码模型)标志来编译

7.7.2 重定位符号引用

在这里插入图片描述
上图展示了链接器的重定位算法的伪代码

1,2行在每个节s以及与每个节关联起来的重定位条目r上迭代执行
假设每个节s是一个字节数组,每个重定位条目r是一个类型为ELF64_Rela的结构(图7-9)
还假设当算法运行时,链接器已经为每个节 (ADDR(S)表示) 和每个符号都选择了运行时地址 (ADDR(r.symbol表示)

3行计算的是需要被重定位的4字节引用的数组s中的地址
如果这个引用是PC相对寻址,那就5-9行来重定位
如果该引用使用的是绝对寻址,那就通过11-13行来重定位

以本章最开始的实例程序看下链接器如何用这个算法重定位程序的引用
用objdump –d –x main.o 产生main.o 的反汇编代码
在这里插入图片描述
Main引用了两个全局符号: array和sum,为每个引用,汇编器产生一个重定位条目
显示在引用的后面一行

这些重定位条目告诉链接器对sum的引用要使用32位PC相对地址进行重定位,而对array的引用要使用32位绝对地址进行重定位

1.重定位PC相对引用
上图第6行中,函数main调用sum函数,sum函数是在模块sum.o中定义的
CALL指令开始于 节偏移0xe 的地方,包括1字节的操作码 0xe8
后面跟着是对目标sum的32位PC相对引用的占位符
相应的重定位条目r由4个字段组成:
r.offset = 0xf
r.symbol = sum
r.type = R_x86-64_PC32
r.addend = -4
这些字段告诉链接器修改开始于偏移量 0xf 处的32位PC相对引用,这样在运行时它会指向sum例程

假设链接器已经确定
ADDR(s) = ADDR(.text) = 0x4004e8

ADDR(r.symbol) = ADDR(sum) = 0x4004e8
使用图7-10中的算法,连接器首先计算出引用的运行时地址(7行):
Refaddr = ADDR(s) + r.offset = 0x4004d0 + 0xf = 0x4004df
然后更新该引用,使得它在运行时只想sum程序(8行):
*refptr = (unsigned)(ADDR(r.symbol) + r.addend – refaddr)
= (unsigned)(0x4004e8 + (-4) – 0x4004df)
= (unsigned)(0x5)
在得到的可执行目标文件中,call指令有如下重定位的形式:
4004de: e8 05 00 00 00 callq 4004e8
在这里插入图片描述

2.重定位绝对引用
相对简单,如图7-11的第4行,mov指令将array的地址(一个32位立即数值)复制到寄存器%edi中,mov指令开始于节偏移量0x9的位置,包括1字节操作码0xbf,后面紧跟着对array的32位绝对地址引用的占位符
在这里插入图片描述
这些字段告诉链接器要修改从偏移量0xa开始的绝对引用,这样在运行时它将会指向array的第一个字节,假设链接器已经确定
ADDR(r.symbol) = ADDR(array) = 0x601018
在这里插入图片描述
在这里插入图片描述

7.8 可执行目标文件

我们的示例C程序,开始时是一组ASCLL文本文件,现在已经被转化为一个二进制文件,且这个二进制文件包含加载程序到内存并运行它所需的所有信息
在这里插入图片描述
上图概括了一个典型的ELF可执行文件中的各类信息
可执行目标文件格式类似于可重定位目标文件格式,ELF头描述文件的总体格式
它还包括程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址

.text .rodata .data 节与重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行内存地址以外

.init 节定义了一个小函数,叫做 _init ,程序的初始化代码会调用它
因为可执行文件是完全链接的(已被重定位), 所以它不再需要 .rel 节

ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片(chunk)被映射到连续的内存段
程序头部表(program header table)描述了这种映射关系
在这里插入图片描述
上图为可执行文件prog的程序头部表,本章开始的示例程序 (OBJDUMP显示)

从程序头部,我们会看到根据可执行目标文件的内容初始化两个内存段,第1,2行
第2行告诉我们第一个段(代码段)有 读/执行访问权限,开始于内存0x400000处,总共内存大小是0x69c字节,并且被初始化为可执行目标文件的头0x69c个字节,其中包括ELF头、程序头部表以及 .init .text .rodata节

在这里插入图片描述

7.9 加载可执行目标文件
要运行可执行目标文件prog,可以在Linux Shell 的命令行输入:
./prog

因为prog不是内置Linux命令,所以会被认为是一个可执行文件
通过调用某个驻留在存储器中称为加载器(loader)的操作系统代码来运行它
任何Linux程序都可以通过调用 Execve 函数来调用加载器
加载器将可执行目标文件中的代码和数据从磁盘复制到内存中
然后通过跳转到程序的第一条指令或入口点来运行该程序,将程序复制到内存并运行的过程叫做加载
在这里插入图片描述
每个Linux程序都有一个运行时内存映像,类似上图
在Linux x86-64系统中,代码段总是从地址0x400000处开始,后面是数据段

运行时堆在数据段之后,通过调用malloc库往上增长
堆后面的区域是为共享模块保留的
用户栈总是从最大的合法用户地址开始,向较小内存地址增长
栈上的区域,从地址2^48开始,是为内核中代码和数据保留的,所谓内核就是操作系统驻留在内存的部分

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

在这里插入图片描述

7.10 动态链接共享库

静态库和所有的软件都一样,需要定期维护和更新
如果应用程序员想要使用一个库的最新版本,必须以某种方式了解到该库的更新情况
然后显式地将他们的程序与更新了的库重新链接

几乎每个C程序都使用标准I/O函数,比如printf 和 scanf
在运行时,这些函数的代码会被复制到每个运行进程的文本段中
在一个运行上百个进程的典型系统上,这将是对稀缺的内存系统资源极大浪费

共享库( shared library )是致力于解决静态库缺陷的一个现代创新产物
共享库是一个目标模板,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来,这个过程称为动态链接( dynamic linking ),是由一个叫做动态链接器的程序来执行的

共享库也称为共享目标( shared object ),在Linux系统中常用 .so 后缀来表示
Windows中,他们称为DLL(动态链接库)

共享库是以两种不同的方式来“共享”的
首先,在任何给定的文件系统中,对于一个库只有一个 .so 文件
所有引用该库的可执行目标文件共享这个 .so 文件中的代码和数据
而不是像静态库的内容那样被复制和嵌入到引用他们的可执行文件中

其次,在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享
在这里插入图片描述
示例:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
-fpic 选项指示编译器生成与位置无关的代码
-shared 选项指示链接器创建一个共享的目标文件

一旦创建了这个库,随后就要将它链接到示例程序中,于上图shell程序中第二行

这样就创建了一个可执行目标文件 prog ,而此文件形式使得它在运行时可以和libdemo.so链接
基本思路就是当创建可执行文件时,静态执行一些链接,然后再程序加载时,动态完成链接过程
此时,没有任何libdemo.so的代码和数据节被复制到可执行文件prog中
反之,链接器复制了一些重定位和符号表信息,它们使得运行可执行文件prog时,可以解析对libdemo.so中代码和数据的引用

当加载器加载和运行可执行文件prog时,加载部分链接的可执行文件prog
接着,它注意到prog包含一个 .interp节,这一节包含动态链接器的路径名
动态链接器本身就是一个共享目标,加载器不会像它通常所做地那样将控制传递给应用
而是加载和运行这个动态链接器,然后动态链接器通过执行下面这些重定位完成链接任务:
在这里插入图片描述

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

应用程序还可能在它运行时要求动态链接器加载和链接某个共享库
而无需在编译时将那些库链接到应用中
在这里插入图片描述
Linux系统为动态链接器提供了一个简单的接口,允许应用程序在运行时加载和链接共享库:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
示例:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

7.12 位置无关代码

在这里插入图片描述
1.PIC数据引用
编译器通过运用以下这个有趣的事实来生成对全局变量的PIC引用:
无论我们在内存中的何处加载一个目标模板,数据段与代码段的距离总是保持不变
因此,数据段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的

想要生成对全局变量PIC引用的编译器利用了这个事实,它在数据段开始的地方创建了一个表,叫做 全局偏移表(Global Offset Tab, GOT)
在GOT中,每个被这个目标模块引用的全局数据目标都有一个8字节条目
编译器还为每个条目生成一个重定位记录,在加载时动态链接器会重定位GOT中dem
在加载时,动态链接器会重定位GOT中的每个条目,使它包含目标的正确的绝对地址
在这里插入图片描述
上图展示的是 编译于 7.6.2小节的代码
这里的关键思想是对GOT[3]的PC相对引用中的偏移量是一个运行时变量

因为addcnt是由libvector.so模块定义的,编译器可以利用代码段和数据段之间不变的距离,产生对addcnt的直接PC相对引用,并增加一个重定位,让链接器在构造这个共享模块时解析它

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

延迟绑定是通过两个数据结构之间简洁但又有些复杂的交互实现的
GOT和过程链接表(PLT)
如果有一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT
GOT是数据段的一部分,PLT是代码段的一部分
在这里插入图片描述
上图展示了PLT和GOT如何协作在运行时解析函数的地址
在这里插入图片描述

7.13 库打桩机制

库打桩,它允许你截获对共享库函数的调用,取而代之执行自己的代码
在这里插入图片描述
打桩可以发生在编译时、链接时、当前程序被加载和执行的运行时

7.13.1 编译时打桩

在这里插入图片描述
上图展示了如何使用C预处理器在编译时打桩

使用下面这样编译和链接这个程序:
在这里插入图片描述
由于使用 -I 参数,所以会进行打桩,他告诉C预处理器在搜索通常的系统目录之前,先在当前目录中查找malloc.h,注意mymalloc.c中的包装函数是使用标准 malloc.h头文件编译的

运行结果:
在这里插入图片描述

7.13.2 链接时打桩

在这里插入图片描述
Linux静态链接器支持用 –wrap f标志进行链接时打桩
这个标志告诉编译器 把对符号f的引用解析成 __wrap_f
还要把符号 __real_f的引用解析成 f
在这里插入图片描述
在这里插入图片描述
-Wl, option标志把 option 传递给链接器
Option中的每个逗号都要替换为一个空格
–wrap,malloc 就把 –wrap malloc 传递给链接器,以类似的方式传递
-Wl,–wrap,free

结果:
在这里插入图片描述

7.13.3 运行时打桩

运行时打桩,它只需要能够访问可执行目标文件
这个很厉害的机制基于动态链接器的LD_PRELOAD环境变量
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

7.14 处理目标文件工具

在这里插入图片描述

小结

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章