Author:onceday date:2022年8月2日
漫漫长路刚刚开始,不要甘于平凡。
本文内容整理于《深入理解计算机系统》,实际操作发现与目前的实际情况有所不一样,仅供参考之用!
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
链接可发生在以下阶段:
编译,源代码被翻译成机器代码时。
加载,程序被加载器加载到内存并执行时。
运行,由应用程序来执行。
现在操作系统上,链接通过链接器(Linker)的程序自动执行。
链接对于软件开发中,扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能,可以将一个大程序分成更小、更好管理的模块,可以独立地修改和编译这些模块。
学习链接器将有益于解决一下问题:
帮助构建大型程序
避免一些危险的编程错误,即全局变量在链接时会发生的一些错误。
理解语言的作用域规则是如何实现的。
虚拟内存,分页、内存映射等是如何同链接配合的。
可以理解并利用共享库。
以下讨论基于运行Linux的x86-64系统,使用标准的ELF-64目标文件文件格式。
一共四步:预处理(CPP),编译(cc1),汇编器(as),链接(ld)。
输入以下指令可查看具体步骤:
gcc -Og -g -v -o xxx xxx.c
诸如Linux LD命令这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,可以生成一个完全链接、可以加载和运行的可执行目标文件作为输出。
链接器具有两个任务:
符号解析(symbol resolution),目标文件定义和引用符号,每个符号对应于一个函数,一个全局变量或一个静态变量,符号解析在于将每个符号的引用和一个符号定义关联起来。
重定位(relocation),编译器和汇编器生成从地址0开始的代码和数据节,链接器通过把每个符号号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得他们指向这个内存位置。
目标文件是纯粹字节块的集合,链接器根据其中某些段的参数,修改各类符号引号的位置。
常有三种形式的目标文件:
可重定位目标文件。包含二进制代码和数据,可和其他可重定位目标文件通过链接合并为一个可执行目标文件。
可执行目标文件。包含二进制代码和数据,其可被直接加载到内存中执行。
共享目标程序。可在加载会运行时被动态地加载进内存并连接。
Windows使用可移植可执行(Portable Executable,PE)格式,Mac-X使用Mach-O格式,现代X86-64Linux和Unix使用可执行可连接格式(Executable and Linkable Format,ELF)。

ELF头描述了生成该文件的系统的字的大小和字节顺序,还包括帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小,目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
.text段是已编译程序的机器代码
.rodata是只读数据
.data已初始化的全局和静态c变量,局部c变量在运行时被保存在栈中,既不出现在.data,也不出现在.bss节。
.bss未初始化的全局和静态c变量,以及所有被初始化为0的全局和静态变量。
symtab一个符号表,存放在程序中定义和引用的函数和全局变量的信息。该表无需-g选项编译,可用STRIP命令去掉。
.rel.text一个.text节中位置的列表,可提供位置信息用于链接器把这个目标文件和其他文件组合。调用本地函数的指令无需修改位置信息,在可执行目标文件通常也不需要定位信息。
.rel.data被模块引用或定义的所有全局变量的重定位信息。
.debug一个调试符号表,其条目是程序中定义的局部变量和类型定义。程序中定义和引用的全局变量,以及原始的C源文件。
.line原始c源代码中的行号和.text节中的机器指令之间的映射
.strtab一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中节的名字。
为什么未初始化的数据称为.bss?
起源于IBM704汇编语言(1957年)中的“块存储开始"(Block Storage Start),现在也可看成是(Better Save Space)更好地节省空间的缩写。
每个可重定位模块M都包含一个符号表,一般有三种符号:
由模块M定义并且能被其他模块引用的全局符号。即非静态存储期的全局变量和函数
由其他模块定义并被模块M引用的全局符号,称为外部符号,即其他模块定义的非静态存储器的全局变量和函数。
只要被模块M定义和引用的局部符号,对应带Static属性的C函数和全局变量。
本地非静态程序变量都由栈管理,链接器对这些符号不感兴趣。
不同作用域的静态存储器变量如果同名,编译器加上额外的标识字符进行区分,并且记录符号在.data或者.bss中。
从某种角度来看,C语言中源文件扮演了C++/Java中类模块的角色。
.symtab节包含ELF符号表,表中包含一个条目的数组。 其每个条目格式如下:

value是符号的地址,对于可重定位的模块来说,value是距离定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址。
每个符号都被分配到目标文件的某个节,由section字段表示,该字段也是一个到节头部表的索引。
在可重定位目标文件中存在一些可执行目标文件中所没有的伪节(pseudosection):
ABS代表不该被重定位的符号
UNDEF代表未定义的符号,但是本目标模块中引用,但是却在其他地方定义的符号
COMMON表示还未被分配位置的未初始化的数据目标,value给出对齐要求,size给出最小的大小。
COMMON和.bss的区别很细微:
COMMON 未初始化的全局变量
.bss,未初始化的静态变量,以及初始化为0的全局或静态变量
符号解析是把每个引用与它的输出的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对于局部变量,该步骤比较简单,确保在其作用域范围内拥有唯一的名字即可。
全局符号的引用解析会棘手很多,当编译器遇到一个不是在当前模块中定义的符号,会假设该符号是在其他某个模块中定义的。
因此,链接器将会处理这个被引用的符号定义,如果最终找不到,将会报出未定义的错误。
对于C++和Java之类的面对对象编程语言,将会对重载函数的名称进行重命名,以确保其在链接时具有唯一的命名。
当链接器碰到多个模块定义的同名全局符号时:
编译器向汇编器输出每个全局符号,具有强(strong)或者弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。
函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号
以下是处理规则:
规则1:不允许有多个同名的强符号
规则2:如果有一个强符号和多个弱符号同名,那么选择强符号
规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个
规则2和3会造成异常隐晦的错误,可通过开启GCC以下的指令来避免:
gcc -fno-common #遇到多重定义的全局符号,触发一个错误
gcc -Werror #把所有警告编程错误
可以将所有相关的目标模块打包成为一个单独文件,称为静态库(static libreray)
静态库可以作为链接器的输入,链接器只复制静态库里被应用程序引用的目标模块。
在符号解析阶段,链接器按从左到右的顺序扫描可重定位目标文件和存档文件(静态链接库文件),对象就是gcc 编译命令里的源文件。
在扫描过程中,链接器维护一个可重定位目标文件的集合E,一个未解析的符号集合U,一个在前面输入文件中已定义的符号集合D。 初始时,皆为空:
对于命令行上的每个输入文件f,如果是目标文件,那么添加到E中,并且根据f中的符号引用情况修改U和D的情况。
如果f是存档文件,那么链接器尝试匹配U中未解析的符号,以及由存档文件成员所定义的符号。如果某个存档成员m,定义了一个符号来解析U中的一个引用,那么将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。遍历重复此过程,直至U和E中不存在改变。此时任何不包含在E`中的成员目标文件都将被丢弃。
如果链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,就构建可执行文件。
因此,命令行输入的库文件和目标文件的顺序就比较重要,一般库文件放在最后面。
当链接器通过符号解析之后,就能确定代码节和数据节的确切大小,但也因此需要对代码字段和数据字段进行重定位。
重定位节和符号定义,将所有相同类型的节合成一块,并赋予新的运行地址,此外再给每个符号赋予新的地址。因此,每个指令和全局变量都有唯一的运行时地址。
重定位节中的符号引用,链接器修改代码节和数据节对每个符号的引用,使得他们指向正确的运行时地址。
汇编器遇到对最终位置未知的目标引用时,会生成一个重定位条目。
重定位条目会告诉链接器如何在合并可执行程序时修改该引用。
代码的重引用条目放在.rel.text,数据的重引用条目放在.rel.data。

ELF定义了32种不同的重定位类型,其中两种比较典型:
R_X86_64_PC32,重定位一个使用32位PC相对地址的引用。
R_X86_64_32,重定位一个使用32位绝对地址的引用。
这两种支持X86_64小型代码模型(small code model),即可执行目标文件中的代码和数据的总体大小小于2GB。
此外有(-mcmodel1=medium)中型代码模型和(-mcmodel1=medium)大型代码模型。
ELF可执行目标文件包含加载程序到内存并运行它所需的所有信息。

可执行目标程序包括程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址。
可通过调用execve函数来调用加载器。加载器将可执行目标程序中的代码和数据从磁盘复制到内存中,然后跳转到程序的第一条指令或入口点来运行代码。
入口点就是函数_start函数,然后又会调用__libc_start_main函数,它最后会调用户主函数接口main()。

实际加载时由于对齐要求和地址空间布局随机化ASLR的影响,会有很多空隙区域,但大体上的逻辑如上所示。
内存有一个有趣的属性,那就是不管空间有多大,它总是一种稀缺资源。
共享库(share library)致力于解决该问题。
共享库所代表的目标模块在运行或者加载时,可以加载到任意的内存地址,并且和一个在内存中的程序链接起来,这个过程称为动态链接(dynamic linking),是一个叫动态链接器(dynamic linker)的程序来执行的。
Linux系统中,共享库用.so后缀表示,Windows系统中,使用DLL后缀表示。
共享库的代码和数据被所有其他可执行目标程序使用,但不会嵌入它们的文件里面。因此在内存中,一个共享库的.text节的一个副本会被不同的正在运行的进程共享。
创建共享库的时候,要指示编译器生成与位置无关的代码。
共享库在链接时,只会复制一些重定位和符号表信息,实际链接在程序加载时完成。
此时动态链接器需要执行以下的任务:
重定位xx.so的文本和数据段到某个内存段。
重定位目标程序所有对xxx.so定义符号的引用。
动态链接器还可在应用程序运行时加载共享库:
使用linux系统接口,函数头dlfcn.h
编译指令gcc -rdynamic -o xxx xxx.c -ldl
在x86-64系统中,可通过PC相对寻址来实现。
下面是一个有趣的事实:无论在何处加载一个目标模块(包括目标模块),数据段与代码段的距离总是保持不变。
因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量。
编译器在数据段开始的地方创建一个表,叫做全局偏移量表(GOT,Global Offset Table)。
在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。
每个引用全局目标的目标模块都有自己的GOT。
具体示例如下:
[0x200111]:(GOT[4]) &extern_var1 //数据段
//%rip 代表当前指令地址
mov 0x200111(%rip),%rax #间接取地址,然后赋值给%rax
add $0x1,(%rax) #间接赋值给extern_var1
这样只用修改GOT表内的地址即可实现数据引用。
GNU编译系统使用延迟绑定(lazy binding)的技术来解决这个问题,将过程地址的绑定推迟到第一次调用该过程时。
第一次调用过程的开销很大,但后面的调用消耗都比较小。
需要借助两个数据结构来实现:GOT和过程链接表(Procedure Linkage Table,PLT)

首先如左边所示,此时GOT[4]中不知道外部函数addvec()的地址,因此会跳回到地址4005c6。
此时会压入几个参数,用于动态链接器去查找addvec()的地址。
查找完全后,会将实际的地址填入GOT[4]中。
LInux链接器支持一个很强大的技术,称为库打桩(Library interpositioning)。
允许截获对共享库函数的调用,取而代之执行自己的代码。
基本思想:
可以依靠以下三种方法进行打桩:
在预编译时,用宏定义进行替换,这要求在查找头文件时,先查找到自己的头文件。
在静态链接时,使用--wrap f 指令编译,可将对符号f的引用解析成__wrap_f,并且把对符号__real_f的引用解析成f。
gcc -wl,--wrap,malloc -wl,--wrap,free -o init int.o mymalloc.o
在运行时也可以打桩:
动态链接器会先搜索该库的文件,因此可以让自己的动态链接文件先被找到。
此时动态链接库里面源代码不能使用欲打桩的库代码,需要手动通过动态链接器进行加载。
LInux上的GNU的binutils包很有帮助:
AR:创建静态库,插入、删除、列出和提取成员
STRINGS:列出一个目标文件中所有可打印的字符串
STRIP:从目标文件中删除符号表信息
NM:列出一个目标文件的符号表中定义的符号
SIZE:列出目标文件中节的名字和大小
READELF:显示一个目标文件的完整结构
OBJDUMP:二进制文件之母,能显示一个目标文件中所有的信息,能反汇编.text中的二进制指令。
LDD:列出一个可执行文件在运行时所需要的共享库。