:::warning 链接是什么?
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。

  1. 链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;
  2. 也可以执行于加载时(load time),也就是在程序被加载器(load­er)加载到内存并执行时;
  3. 甚至执行于运行时(runtime),也就是由应用程序来执行。

在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。 ::: :::info 链接的好处:
我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。 :::

7.1 编译器驱动程序

大多数编译系统提供编译器驱动程序(compiler driver),它代表用户在需要时调用语言预处理器编译器汇编器链接器。比如,要用GNU编译系统构造示例程序,我们就要通过在shell中输入下列命令来调用GCC驱动程序
gcc -0g -o prog main.c sum.c
image.png
具体来讲: :::warning

  1. 运行C预处理器cpp将源文件处理为ASCII码的中间文件main.icpp [other arguments] main.c /tmp/main.i
  2. 运行C编译器ccl,它将main.i翻译成一个ASCII汇编语言文件main.s
  3. 然后,驱动程序运行汇编器(as),它将main.s翻译成一个可重定位目标文件(relo­eatable object file) main.o
  4. 驱动程序经过相同的过程生成sum.o
  5. 最后,运行链接器程序ld,将main.osum.o以及一些必要的系统目标文件组合起来,创建一个可执行目标文件(executable object file)progld -o prog [system object files and args] /tmp/main.o /tmp/sum.o :::

    7.2 静态链接

    像Linux ld程序这样的静态链接器(static linker)以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。输入的可重定位目标文件由各种不同的代码和数据节(section)组成,每一节都是一个连续的字节序列。 :::tips 链接器的两个主要任务:

  6. 符号解析(symbol resolution)。目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即C语言中任何以static属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来

  7. 重定位(relocation)。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。 :::

    7.3 目标文件

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

  • 编译器汇编器生成可重定位目标文件(包括共享目标文件),(即生成obj文件和动态库)

  • 链接器生成可执行目标文件 ::: 目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。如Linux下采用ELF文件,Windows下采用PE文件。

    7.4 可重定位目标文件

    可以参考程序员自我修养里面的:
    3. 目标文件里有什么
    如上节所述,Linux中文件主要采用ELF格式存储。下图给出了ELF文件的结构:
    image.png
    ELF头(ELF header)以一个16字section的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。
    ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。此外ELF头还包含了节头部表/段头部表(section header table)的文件偏移,以及节头部表中条目的大小和数最。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
    夹在ELF头和节头部表之间的都是节(section)。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
    1. typedef struct{
    2. unsigned char e_ident[16];
    3. Elf32_Half e_type;
    4. Elf32_Half e_machine; //ELF文件的CPU平台属性.相关常量以EM_开头
    5. Elf32_Word e_version; //ELF版本号,一般为常数l
    6. Elf32_Addr e_entry;
    7. Elf32_Off e_phoff;
    8. Elf32_Off e_shoff; //段表在文件中的偏移
    9. Elf32_Word e_flags;
    10. Elf32_Half e_ehsize; //ELF文件头本身的大小
    11. Elf32_Half e_phentsize;
    12. Elf32_Half e_phnum;
    13. Elf32_Half e_shentsize;
    14. Elf32_Half e_shnum;
    15. Elf32_Half e_shstrndx;
    16. } Elf32_Ehdr;
    通过这一结构就可以确定每一个section的大小、在文件中的偏移、类型等等信息 ``` readelf -S SimpleSection.o There are 14 section headers, starting at offset 0x4a0:

节头: [号] 名称 类型 地址 偏移量 大小 全体大小 旗标 链接 信息 对齐 [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000000000 00000040 000000000000005f 0000000000000000 AX 0 0 1 [ 2] .rela.text RELA 0000000000000000 00000380 0000000000000078 0000000000000018 I 11 1 8 [ 3] .data PROGBITS 0000000000000000 000000a0 0000000000000008 0000000000000000 WA 0 0 4 [ 4] .bss NOBITS 0000000000000000 000000a8 0000000000000004 0000000000000000 WA 0 0 4 [ 5] .rodata PROGBITS 0000000000000000 000000a8 0000000000000004 0000000000000000 A 0 0 1 [ 6] .comment PROGBITS 0000000000000000 000000ac 000000000000002c 0000000000000001 MS 0 0 1 [ 7] .note.GNU-stack PROGBITS 0000000000000000 000000d8 0000000000000000 0000000000000000 0 0 1 [ 8] .note.gnu.propert NOTE 0000000000000000 000000d8 0000000000000020 0000000000000000 A 0 0 8 [ 9] .eh_frame PROGBITS 0000000000000000 000000f8 0000000000000058 0000000000000000 A 0 0 8 [10] .rela.eh_frame RELA 0000000000000000 000003f8 0000000000000030 0000000000000018 I 11 9 8 [11] .symtab SYMTAB 0000000000000000 00000150 00000000000001b0 0000000000000018 12 12 8 [12] .strtab STRTAB 0000000000000000 00000300 000000000000007c 0000000000000000 0 0 1 [13] .shstrtab STRTAB 0000000000000000 00000428 0000000000000074 0000000000000000 0 0 1 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info), L (link order), O (extra OS processing required), G (group), T (TLS), C (compressed), x (unknown), o (OS specific), E (exclude), l (large), p (processor specific)

  1. 1. `.text section`: 程序源代码编译后的机器指令经常被放在代码段(Code Section)里,代码段常见的名字有`.code``.text`
  2. 1. `.rodata section`:只读数据
  3. 1. `.data section`: 已初始化的**全局和静态C变量**。局部C变量在运行时被保存在栈中
  4. 1. `.bss section`: 未初始化的全局变量和局部静态变量一般放在一个叫`.bss`的段里,因为未初始化,所以并未对`.bss`段分配空间,这是一种节约空间的做法。以及所有被初始化为0的全局或静态变量。
  5. 1. `.symtab`:一个符号表,它存放在程序中定义和引用的**函数**和**全局变量**的信息。很重要,在链接时会很有用。实际上,每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用`STRIP`命令去掉它)。然而,和编译器中的符号表不同,`.symtab`符号表**不包含局部变量的条目**。
  6. 1. `.rel.data `:被模块引用或定义的所有全局变量的重定位信息。即编译时编译器找不到定义的全局变量,会将其加入`.rel.data`,留给链接器来处理
  7. 1. `.rel.text`:类似地调用外部函数时也需要重定位。
  8. 1. `.strtab`:一个字符串表,其内容包括`.symtab``.debug`节中的符号表,以及节头部中的节名字。
  9. <a name="qILY3"></a>
  10. # 7.5 符号和符号表
  11. :::info
  12. 每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息,具体而言,有三种符号:
  13. 1. 由模块m定义并能被其他模块引用的**全局符号**。全局链接器符号对应于非静态的C函数和全局变量。(静态的C函数默认只能在当前文件被使用,在C中,源文件扮演模块的角色。任何带有static属性声明的全局变量或者函数都是模块私有的。)
  14. 1. 由其他模块定义并**被模块m引用**的全局符号。这些符号称为**外部符号**,对应千在其他模块中定义的非静态C函数和全局变量。
  15. 1. **只被模块m定义和引用**的局部符号。它们对应于带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。
  16. `.symtab`中的符号表不包含对应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理.
  17. :::
  18. 符号表是由汇编器构造的,**使用编译器输出到汇编语言.s文件中的符号(对于C++而言可能还经过了符号重整)**。`.symtab`节中包含ELF符号表。这张符号表包是一个结构体数组。<br />结构体的格式如下:
  19. ```c
  20. typedef struct{
  21. int name; //符号在字符串表中的偏移量
  22. char type:4, //是函数还是变量 (4bits)
  23. binding:4; //Local or global(4bits)
  24. char reserved; //保留
  25. short section; //属于哪个section通过序号来描述,序号在section header table中有
  26. long value; //值,一般是地址
  27. long size; //大小 bytes
  28. }Elf64_Symbol;

关于字符串表,参考:
3. 目标文件里有什么

7.6 符号解析

链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。
对那些和引用定义在相同模块中的局部符号的引用,符号解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。

不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目(即添加到重定位表中),并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条(通常很难阅读的)错误信息并终止。

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

在编译时,编译器向汇编器输出每个全局符号,或者是强(strong)或者是弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
如果多个模块定义同名的全局符号,会发生什么呢? :::info Linux链接器使用下面的规则来处理多重定义的符号名:

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

实际上规则3常常是引起错误的根源 :::

实际上,采用这个惯例是由于在某些情况中链接器允许多个模块定义同名供全局符号。当编译器在翻译某个模块时,遇到一个弱全局符号,比如说x,它并不知道其他模块是否也定义了x,如果是,它无法预测链接器该使用x的多重定义中的哪一个。所以编译器把x分配成**.COMMON**,把决定权留给链接器

7.6.2 与静态库链接

到目前为止,我们都假设是将一组可重定位目标文件作为输出,实际上,所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库(static library),它可以用做链接器的输入。当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。 :::warning 相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数:
gcc main.c /usr/lib/libm.a /usr/lib/libc.a
在链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小。 :::

存档文件

:::info 在Linux系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀**.a**标识。 ::: 要创建这些函数的一个静态库,我们将使用AR工具

  1. linux> gcc -c addvec.c multvec.c
  2. linux> ar rcs libvector.a addvec.o multvec.o

此时生成了libvector.a静态库
为了创建这个可执行文件,我们要编译和链接输入文件main.o和libvector.a

  1. linux> gcc -c main2.c
  2. linux> gcc -statie -o prog2c main2.o./libvector.a
  3. # 或者使用
  4. linux> gcc -c main2.c
  5. linux> gcc -statie -o prog2c main2.o -L. -lvector
  • -static参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,它可以加载到内存并运行,在加载时无须更进一步的链接。
  • -L.参数告诉链接器在当前目录下查找libvector.a

7c1efa4efe766755e760702ce9d7cf26.png :::warning 当链接器运行时,它判定main2.o引用了addvec.o定义的addvec符号,所以复制addvec.o到可执行文件。因为程序不引用任何由multvec.o定义的符号,所以链接器就不会复制这个模块到可执行文件。链接器还会复制江libc.a中的printf.o模块,以及许多C运行时系统中的其他模块。 :::

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

在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。(驱动程序自动将命令行中所有的.c文件翻译为.o文件。) :::info 在这次扫描中,链接器维护:

  • 一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件)
  • 一个未解析的符号(即引用了但是尚未定义的符号)集合U
  • 以及一个在前面输入文件中已定义的符号集合D :::
  1. 一开始,E、U、D都是空的;
  2. 对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器把f添加到E,修改U和D来反映f中的符号定义和引用,并继续下一个输入文件。

image.png

  1. 如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。

image.png

  1. 对于静态库中所有目标文件都要进行上述操作,没有被加入集合E中的所有静态库中的目标文件都会被删除
  2. 如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。

这种算法会导致一些令人困扰的链接时错误,因为命令行上的库和目标文件的顺序非常重要。在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。 :::info 关于库的一般准则是将它们放在命令行的结尾。如果各个库的成员是相互独立的(也就是说没有成员引用另一个成员定义的符号),那么这些库就可以以任何顺序放置在命令行的结尾处。另一方面,如果库不是相互独立的,那么必须对它们排序, :::

7.7 重定位

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

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

7.7.1 重定位条目

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

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

    1. typedef struct{
    2. long offset; //引用在section中的偏移
    3. long type:32, //引用的类型:相对地址或者绝对地址
    4. symbol:32; //被修改的引用应该指向的符号
    5. long addend; //某些情况需要用它做地址偏移的修改
    6. }Elf64_Rela;

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

  • R_X86_64_PC32: 重定位一个使用32位PC相对地址的引用。

  • R_X86_64_32: 重定位一个使用32位绝对地址的引用。

    7.7.2 重定位符号引用

    重定位符号引用的算法如下:
    假设在执行算法时,链接器巳经为每个节(用ADDR(s)表示)和每个符号都选择了运行时地址(用ADDR(r.symbol)表示)

    1. foreach section s{
    2. foreach reloaction entry r{
    3. refptr=s+r.offset; //需要重定位的引用的位置
    4. if(r.type==R_X86_64_PC32){//如果时使用32位PC相对地址引用
    5. refaddr=ADDR(s)+r.offset;
    6. *refptr=(unsigned)(ADDR(r.symbol)+r.addend-refaddr);
    7. //相对地址:求对于本地址的偏移量
    8. }
    9. if(r.type==R_X86_64_32){//如果使用绝对地址引用
    10. *refptr=(unsigned)(ADDR(r.symbol)+r.addend);
    11. }
    12. }
    13. }

    7.8 可执行目标文件

    7.8.1 典型ELF可执行目标文件

    b78454d871303ad142ae04a0d8a7b365.png :::warning 可执行目标文件的格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式。它还包括程序的入口点(entry point),也就是当程序运行时要执 行的第一条指令的地址。.text.rodata.data节与可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时内存地址以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位),所以它不再需要.relsection。 ::: ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片(chunk)被映射到连续的内存段。程序头部表(program header table)描述了这种映射关系。
    image.png
    第一行给出只读文件的起始地址,对于Linux而言一般从0x400000开始

    7.9 加载可执行目标文件

    要运行可执行目标文件,可以在shell中输入其名字:

    1. linux> ./prog

    因为prog不是一个内置的shell命令,所以shell会认为prog是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器(loader)的操作系统代码来运行它。任何Linux程序都可以通过调用execve函数来调用加载器。
    加载器将可执行目标文件的代码和数据从磁盘复制到内存中,然后跳转到程序的第一条指令或者入口点来执行该程序,此过程称作加载
    image.png
    每个Linux程序都有一个运行时内存映像。在Linux x86-64 系统中,代码段总是从地址0x400000处开始,后面是数据段。运行时堆在数据段之后,通过调用malloc库往上增长。堆后面的区域是为共享模块(动态库)保留的。用户栈总是从最大的合法用户地址(2^48-1)开始,向较小内存地址增长。 :::info

  • 堆从小地址向上增长(大小不固定)

  • 栈从大地址向小地址增长(大小固定) ::: 栈上的区域,从地址2^48开始,是为内核(kernel)中的代码和数据保留的,所谓内核就是操作系统驻留在内存的部分。当加载器运行时,它创建内存映像。在程序头部表的引导下,加载器将可执行文件的片(chunk)复制到代码段和数据段。

要理解加载实际是如何工作的,你必须理解进程、虚拟内存和内存映射的概念,这些我们还没有加以讨论。 :::warning 当shell运行一个程序时,父shell进程生成一个子进程,它是父进程的一个复制。子进程通过execve系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。
后,加载器跳转到_start地址,它最终会调用应用程序的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制直到CPU引用一个被映射的虚拟页时才会进行复制,(缺页中断)此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。 :::

7.10 动态链接共享库

:::info 共享库(shared Iibrary)是致力千解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker) 的程序来执行的。 ::: 在任何给定的文件系统中,对于一个库只有一个**.so**文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。
在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。7b46699bf0709b89b3024e200bd486c0.png

7.10.1 生成动态库

生成动态库使用-shared创建共享的目标文件

  1. linux> gcc -shared -fpic -o libvector.so addvec.c multvec.c
  • -shared指示生成一个共享的目标文件
  • -fpic指示生成于位置无关的代码

调用动态库可以使用如下命令:

  1. linux> gcc -o prog21 main2.c ./libvector.so

:::warning 基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。认识到这一点是很重要的:此时,没有任何让libvector.so的代码和数据节真的被复制到可执行文件prog21中。反之,链接器复制了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so中代码和数据的引用。 ::: 注意到prog21包含一个.interp节,这一节包含动态链接器的路径名动态链接器本身就是一个共享目标(如在Linux系统上的ld-linux.so), 加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器。动态链接器会执行下面的重定位完成链接任务:

  1. 重定位libc.so的文本和数据到某个内存段
  2. 重定位libvector.so的文本和数据到另一个内存段
  3. 重定位prog21中所有对由libc.solibvector.so定义的符号的引用。 :::danger 是之前说的加载时重定位 :::