美文网首页一些收藏
深入理解计算机系统-第七章(链接)笔记

深入理解计算机系统-第七章(链接)笔记

作者: Cool_Pomelo | 来源:发表于2020-03-19 10:19 被阅读0次

    深入理解计算机系统-第七章(链接)笔记

    背景

    链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程

    这个文件可被加载(拷贝)到存储器中并执行:

    • 链接可以执行于编译时,也就是源代码翻译成机器码时

    • 也可以执行于加载时,也就是程序被加载到存储器并执行时

    • 甚至执行于运行时,由应用程序来执行

    链接是由叫做链接器的程序自动执行的。链接器的出现,使得分离编译成为可能,我们不用将一个大型的应用程序组织为一个巨大的源文件,而是把它分解成更小、更好管理的模块。可以独立的修改和编译这些模块。当我们改变这些模块中的一个时,我们只要单独地编译它,并将它重新链接到应用上,而不用编译其他文件

    编译器驱动程序

    实验例子程序:

    main.c:

    
    int sum(int *a, int n);
    
    int array[2] = {1,2};
    
    int main() 
    {
    
        int val = sum(array,2);
        return val;
    
    }
    
    

    sum.c:

    
    int sum(int *a, int n)
    
    {
    
        int i, s=0;
    
        for(i = 0; i < n; i++) 
        {
            s += a[i];
        }
        return s;
    }
    
    
    

    大多数编译系统提供编译驱动程序,对于上述例子:

    • 它首先运行C预处理器cpp,将C源程序main.c翻译成一个ASCII码的中间文件main.i
    • 接下来,C编译器cc1将main.i翻译成一个ASCII汇编语言文件main.s
    • 最后,汇编器as将main.s翻译成一个可重定位目标文件main.o

    类似地,生成sum.o。最后,运行链接器程序ld,将main.o和sum.o以及一些必要的系统目标文件组合起来,创建一个可执行目标文件prog

    图示:

    图1.png

    静态链接

    像Linux ld程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出。输入的可重定位目标文件由各种不同的代码和数据节组成。指令在一个节中,初始化的全局变量在另一个节中,而未初始化的变量又在另外一个节中。

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

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

    目标文件纯粹是字节块的结合,有些包含代码,有些包含数据。

    目标文件

    目标文件有三种形式:

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

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

    链接器生成可执行目标文件

    各系统之间,目标文件的格式各不相同,早期的UNIX使用的是一般目标文件COFF。Windows NT使用的是COFF的一个变种,叫做可移植性可执行PE格式。现代Unix系统(包括linux,solaris)使用的是Unix可执行和可链接格式ELF。这些格式尽管各不相同,但基本的概念是类似的。

    可重定位目标文件

    典型的ELF可重定位目标文件:

    图2.png

    ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(可重定位、可执行或者共享的)、机器类型(如IA32)、节头部表的文件偏移,以及节头部表中的条目大小和数量。

    夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:

    • .text:已编译程序的机器代码
    • .rodata:只读数据
    • .data:已初始化的全局C变量,局部C变量在运行时保存在栈中
    • .bss:未初始化的全局变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分初始化和未初始化变量是为了空间效率。
    • .symtab:符号表,它存放在程序中定义和引用的函数和全局变量的信息。
    • .rel.text:一个在.text节中位置的列表,当链接器把这个目标文件和其他文件结合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。

    • .rel.data:被模块引用或者定义的任何全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。

    • .debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源程序。
    • .line:原始C源程序中的行号和.text节中机器指令之间的映射。
    • .strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。

    符号和符号表

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

    • 由m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数以及被定义为不带C static属性的全局变量。
    • 由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于定义在其他模块中的C函数和变量。
    • 只被模块m定义和引用的局部符号。它们对应于带static属性的C函数和全局变量,这些符号在模块m中任何位置都可见,但是不能被其他模块引用。

    在.symtab中的符号表不包含对应于本地非静态程序变量的任何符号。这些符号在运行时再栈中管理。链接器对此类符号不感兴趣。

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

    比如,同一个模块中两个函数各自定义了一个静态局部变量x:

    
    int f()
    {
    
        static int x = 0;
        return x;
    }
    
    int g()
    {
        static int x = 1;
        return x;
    }
    
    

    这种情况下,编译器向汇编器输出两个不同名字的局部链接器符号,比如它可以用x.1表示函数f中的定义,而用x.2表示函数g中的定义

    符号解析

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

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

    但如果编译器遇到一个不是在当前模块中定义的符号(变量或者函数)时,它就会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,把它交给链接器处理。如果链接器在它任何输入模块中都找不到这个被引用的符号,它就输出一条符号解析的错误信息并终止。

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

    链接器的输入是一组可重定位目标模块,每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块可见)。如果多个模块定义同名的全局符号,会发生什么?下面是Linux编译系统采用的方法:

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

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

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

    例子:

    假设试图编译和链接下面两个C模块:

    /* foo1.c */
    int main()
    {
        return 0;
    }
    
    
    /* bar1.c */
    int main()
    {
        return 0;
    }
    
    

    这种情况,链接器将生成一条错误信息,因为强符号main被定义了多次(规则1)

    相似的,链接器对于下面的模块也会生成一条错误信息,因为强符号x被定义了两次(规则1):

    
    /* foo2.c */
    
    int x = 123456;
    
    int main()
    {
        return 0;
    }
    
    
    /* bar2.c */
    
    int x = 123456;
    
    void f()
    {
    
    }
    

    如果在一个模块里x未被初始化,那么链接器将安静的选择在另一个模块中定义的强符号(规则2):

    
    
    /* foo3.c */
    
    #include<stdio.h>
    
    void f(void);
    
    
    int x = 123456;
    
    int main()
    {
        f();
    
        printf("x=%d",x);
    
        return 0;
    }
    
    /* bar3.c */
    
    int x;
    
    void f()
    {
        x = 12345;
    }
    
    

    运行时,函数f将x的值由123456改成12345

    如果x有两个弱定义,也会发生一样的事情(规则3):

    
    /* foo4.c */
    
    #include<stdio.h>
    
    void f(void);
    
    int x;
    
    int main()
    {
        x = 123456;
    
        f();
    
        printf("x = %d",x);
    
        return 0;
    }
    
    /* bar4.c */
    
    int x;
    
    void f()
    {
        x = 12345;
    }
    
    
    
    

    规则2和规则3的应用会造成一些不易察觉的运行时错误

    与静态库链接

    所有的编译系统都提供一种机制,将所有相关的目标模块打包成一个单独的文件,叫做静态库

    它可以用做链接器的输入,当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块

    为何要支持库的概念?以ISO C99为例,它定义了一组广泛的标准IO、字符型操作以及整数数学函数,它们在libc.a库里面,对于每个C程序来说都是可用的

    假设不使用静态库,编译器开发人员要用什么方法向用户提供这些函数?

    • 1 让编译器辨认出对标准函数的调用,并直接生成相应的代码。

    这种方法对于C来说是不合适的,因为C标准定义了大量的标准函数,这种方法会增加编译器的复杂性,并且每次添加,删除,修改一个函数时,都需要一个新的编译器版本

    • 2 将所有的标准C函数都放在一个单独的可重定位目标模块中(比如libc.o)应用程序员可以把这个模块链接到它们的可执行文件中

    这种方法的优点:

    • 将编译器的实现与标准函数的实现分离开来

    缺点:

    • 系统中每个可执行文件现在都包含着一份标准函数集合的完全副本,这对于磁盘空间是很大的浪费。

    • 对任何标准函数的任何改变,无论多么小的改变,都要求库的开发人员重新编译整个源文件,这是一个非常耗时的操作

    静态库的概念被提出了,以解决这些不同方法的缺点,相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。之后,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数。

    链接时,链接器值需要复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小。

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

    在符号解析的阶段,链接器从左到右按照它们在编译器驱动程序命令上出现的相同顺序来扫描可重定位目标文件和存档文件。在扫描中,链接器维持一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。初始时,E、U和D都是空的。

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

    重定位

    一旦链接器完成了符号解析这一步,它就把代码中的每个符号引用和确定的一个符号定义联系起来。这样,链接器就知道它输入目标模块中的代码节和数据节的确切大小,并根据这些对目标模块进行重定位,合并输入模块,为每个符号分配运行时地址。重定位由两步组成:

    • 重定位节和符号定义

    在这一步中,链接器将所有相同类型的节合并为同一个类型的新的聚合节

    • 如来自所有输入模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节

    然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。这一步完成之后,程序中的每条指令和全局变量都有唯一的运行时内存地址了

    • 重定位节中的符号引用

    在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。要执行这一步,链接器要依赖于可重定位目标模块中叫做重定位条目的数据结构

    重定位条目

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

    所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。

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

    上面展示了ELF重定位条目的格式

    • offset:需要被修改的引用的节偏移
    • symbol:标识被修改引用应该指向的符号

    • type:告知链接器如何修改新的引用

    • addend:一个有符号常数

    ELF定义了11中不同的重定位类型,在这里只关心其中最基本的两种:

    R_X86_64_PC32:重定位一个使用32位PC相对地址的引用。即当相对于当前距程序计数器PC的当前运行时值的偏移量。例如CALL指令的目标。

    R_X86_64_32:重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。

    可执行目标文件

    一个典型的ELF可执行文件中的各类信息:

    图3.png

    链接器将多个目标文件合并成一个可执行目标文件,一个简单的C程序,开始时是一组ASCII文本文件,到后来转化成一个二进制文件,并且这个二进制文件包含加载程序到内存并运行它所需要的所有信息

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

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

    加载可执行目标文件

    在Linux中运行可执行目标文件prog,可以在Linux shell的命令行运行

    
    linux> ./prog
    

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

    每个Linux程序都有一个运行时内存映像:

    图4.png

    在Linux X86-64系统中,代码总是从地址0x400000开始,后面是数据段,运行时堆在数据段之后,通过调用malloc库往上增长,堆后面的区域是为共享模块保留的。

    动态链接共享库

    为了解决静态库的一些缺点:

    • 需要定期维护和更新

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

    共享库:

    一个目标模块。在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来,这个过程叫做动态链接,由一个叫做动态链接器的程序来执行。

    共享库也叫作共享目标:

    • Linux中通常用.so后缀结尾来表示

    • Windows中用.dll后缀结尾来表示

    共享库以两种不同的方式来“共享”:

    • 任何给定的文件系统中,对于一个库只有一个.so文件,所有引用该库的可执行目标文件共享这个,so文件中的代码与数据,不是像静态库的内容那样被复制和嵌入到引用它们可执行的文件中

    • 在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享

    位置无关代码

    共享库的一个关键目的是为了使多个进程能够共享内存中的同一份代码拷贝,已达到节约内存资源的目的。如何做到呢?一种方法是预先为每一个共享库指定好加载的地址范围,然后要求加载器总是将共享库加载至指定的位置。这种方法尽管很简单,但是会产生一些严重的问题。因为就算一个进程并没有用到某个库,相应的地址范围依然会被保留下来,这是一种效率很低的内存使用方式。另外,这种方法管理起来也很困难。我们必须保证预留的地址块之间没有重叠。每当一个库被修改后,我们还必须要保证它能被放回到修改前的位置,否则,我们还要为它重新找一个新的位置。当我们创建一个新的库时,我们还要为它寻找合适的空间,地址空间碎片化造成的大量无用的内存空洞。更糟糕的是,不同的系统为动态库分配内存的方式不尽相同,这使得管理起来更为困难。

    一个更好的方法是将动态库编译成可以在任意位置加载而无需链接器进行修改。这样的代码被称作位置无关代码(PIC)

    GNU编译系统可以通过指定-fPIC选项来生成PIC代码

    在IA32系统中,对于同一个模块中的符号的引用无需特殊处理使之成为PIC,因为其引用相对于PC地址的偏移量是已知的。但是,对外部过程的调用和对全局变量的引用一般却不是PIC的,因此需要在链接的时候进行重定位

    相关文章

      网友评论

        本文标题:深入理解计算机系统-第七章(链接)笔记

        本文链接:https://www.haomeiwen.com/subject/nrekyhtx.html