代码改变世界

深入理解计算及系统 Chapter7 学习笔记

2018-11-17 17:41  CeddieCoding  阅读(327)  评论(0)    收藏  举报

Chapter7 链接

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

(1)符号解析:目的是将每个符号引用正好和一个符号定义关联起来

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

2.目标文件(*.o):一个以文件形式存放在磁盘中的字节序列,是按照特定的目标文件格式来组织的,Linux下为ELF,格式如下图所示:

3.ELF可重定位目标文件

如图所示,夹在ELF头和节头部表之间的都是节(section),主要有:

(1).text:已编译程序的机器代码(就是经过预处理->编译->汇编生成的代码的机器码)

(2).data:已初始化的全局和静态变量

(3).bss:未初始化的全局和静态变量。区分已初始化和未初始化主要是为了节省空间

(4).symtab:符号表,存放在程序中定义和引用的函数以及全局变量的信息

(5).rel.text/.rel.data:一个.text/.data中位置的列表,包含重定位信息(因为生成的目标文件是从地址0开始的,需要在运行时根据此列表修改为真实的地址)

3.符号表的类型

(1)全局符号:由模块m定义并被模块m引用的全局符号

(2)外部符号:由其他模块定义并被模块m引用的全局符号

(3)局部符号:只被模块m定义和引用的局部符号(c中为static变量和函数,要与局部变量区分开)

注意,.symtab中不包含对应于本地非静态程序变量的任何符号,这些符号在运行时在中被管理。有个例外,就是本地静态变量不在栈中管理,而是在.data或.bss为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号

4.符号表的条目(entry)

.symtab节目标文件中是如何被组织的,下面用代码来说明其格式:

typedef struct {
    int    name;              /*字节偏移,指向符号的字符串名字*/
    char   type:4;            /*类型(数据或函数)*/
    char   binding:4;         /*本地或者全局*/
    char   reserved;
    short  secotion;          /*被分配到目标文件的哪个节中*/
    long   value;             /*距定义目标的节的起始位置的偏移*/
    long   size;              /*大小*/
}

用GNU READELF程序读取某个目标文件,结果如下:

 

5.符号解析

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

(1)局部符号:对于和引用定义在相同模块中的局部符号的引用,符号解析简单明了,编译器只允许每个模块中每个局部符号有一个定义

(2)全局符号:当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,会假设该符号在其他模块中定义,生成一个符号表条目,并把它交给汇编器处理。汇编器接收到了与符号相关的信息,将这些信息放在生成的目标文件的符号表里(.symtab),但此时它并不知道数据和代码最终放在内存中的什么位置,也不知道生成的目标文件所引用的任何外部定义的函数或者全局变量的位置,对这些最终位置未知的目标引用,会生成一个重定位条目,告诉链接器将在目标文件合并成可执行文件时如何修改这个引用

在符号解析阶段,链接器从左到右按照各个文件(*.o:目标文件,*.a:存档文件)在命令行上出现的顺序来扫描可重定位目标文件和存档文件。在这次扫描中,链接器维护一个可重定位目标文件的集合E,一个未解析的符号集合U,以及一个在前面输入文件中已定义的符号集合D。初始时,E、U、D均为空:

(1)对于命令行的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件(可以看做是目标文件的集合)。如果是目标文件,那么链接器把f添加到E中,修改U和D来反映f中的符号定义和引用(对于上图来说,会根据.symtab文件中的条目,将main和array放入D,将sum放入U),并继续下一个输入文件

(2)如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号由存档文件成员定义的符号。如果某个存档文件成员m(是一个目标文件)定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且修改U和D来反映m中的符号定义和引用。对存档文件中所有的成员目标文件都依次进行这个过程,知道U和D都不在发生变化。此时,任何不包含在E中的成员目标文件都简单地被抛弃,并继续处理下一个输入文件

(3)如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,构建输出的可执行文件

6.重定位

一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和一个符号定义(符号表中的一个条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小,就可以开始重定位步骤了:

(1)重定位节和符号定义:链接器将所有相同类型的节合并为同一类型的新聚合节,然后将运行时内存地址赋给新的聚合节,以及输入模块定义的每个符号(即下面结构体中的symbol字段)。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址

(2)重定位节中的符号引用:链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。这一步的执行依赖于可重定位模块中称为重定位条目的数据结构(这些条目在.rel.text.rel.data中),其格式为:

typedef struct {
    long   offset;             /*需要被修改引用的节偏移(找到要修改的地方),值为相对于该节起始处的偏移量(类型为相对地址引用时)*/
    long   type:32;            /*重定位类型*/
    long   symbol:32;          /*标识被修改引用指向的符号(在(1)中已经获取到地址),值为符号的地址*/
    long   addend;             /*一个偏移调整量,值为offset相对于下一条指令的偏移量(类型为相对地址引用时)*/
}

其中,重定位类型分为多种,最需要关注的只有两种:R_X86_64_PC32(相对地址引用),R_X86_64_32(绝对地址引用)。

7.加载可执行目标文件

以上的过程执行完时,链接器已经将多个目标文件合并成一个可执行文件,当执行该文件时,会调用加载器来运行它。当加载器运行时,在程序头部表的引导下,加载器将可执行文件的片(chunk)复制到代码段和数据段。接下来,加载器跳转到程序的入口点,也就是_start函数的地址,该函数调用系统启动函数__libc_start_main,初始化执行环境并调用用户层的main函数,处理main函数的返回值,并在需要的时候把控制返回给内核。

8.动态链接共享库

共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。在Linux系统中通常用.so后缀来表示。在任何给定的文件系统中,对于一个库只有一个.so文件,所有引用该库的可执行目标文件共享这个.so文件,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行文件中。

9.部分链接的可执行文件

当某个目标文件有对共享库符号的引用时,其在链接阶段产生的可执行文件为“部分链接的”可执行文件,即对于部分链接的可执行文件,在链接结束时,除了确定了静态链接在运行时的内存外,只是能确定引用了哪一个共享库中的哪一个符号,至于运行时的地址,依旧未知!当运行这个部分链接的可执行文件时,加载器会加载和运行一个动态链接器(在可执行文件的.interp节,包含动态链接器的路径名),然后动态链接器通过执行重定位完成链接任务:

(1)重定位共享库的文本和数据段到某个内存段(由动态链接器完成,重定位共享库中的GOT。为什么要重定位?因为你不知道共享库会被加载进哪个内存段内。重定位条目怎么来的?在编译成.so文件时生成的,同时生成的还有GOT)

(2)重定位部分链接的可执行文件中对共享库定义的符号和引用(同样是由动态链接器完成,同样重定位部分可执行文件中的GOT)

虽然部分链接可执行文件并不知道引用共享库的符号的在运行时的真实地址,但链接器会在生成可执行文件时,打下一个桩(PLT,在.text节中,生成一段桩代码),让代码中调用的共享库符号先重定位到这个桩的地址上(图示为调用共享库的printf):

当第一次运行时,利用这个桩对该符号的真实地址进行解析,并再次重定位GOT中的地址(即将这些地址改为真实地址,第二次调用时直接跳转到真实地址),完成动态地址解析过程。