动态链接的一点小总结 《程序员的自我修养》·笔记

动态链接的一点小总结

  • 动态链接(相对于静态链接)的优势

    • 动态链接可以节省内存和磁盘空间。动态链接使得内存和磁盘中的编译完成的目标文件只保留一份,这样也可以减少物理页的换入换出,同时也可以增加CPU缓存的命中率。
    • 动态链接便于程序的更新、部署、发布;
    • 动态链接下,程序在运行期间可以动态地加载各种程序模块,也就是我们经常说的插件;
    • 动态链接可以加强程序的兼容性,程序和不同平台之间可以加入一个“中间层”,让程序在不同的平台可以动态地链接到有操作系统提供的动态链接库,从而消除程序对不同平台依赖的差异性;
  • 动态链接的基本实现

    • Linux系统中,ELF动态链接文件被称为动态共享对象,一般是以".so"为扩展名;在win下被称为动态链接库,以".dll"结尾;
    • C语言库的动态链接库文件是“libc.so”,程序与之真正的链接工作是由动态链接器完成的,动态链接是把链接这个过程从本来的程序装载前被推迟到了装载的时候,这样的推迟也造成了动态链接的性能损失,不过我们也有相应的优化策略:延迟绑定。之后会详细说明。
  • 通过一个简单的例子引入

/* Program1.c */
#include "Lib.h"
int main()
{
    foobar(1);
    return 0;
}
/* Program2.c*/
#include "Lib.h"
int main()
{
    foobar(2);
    return 0;
}
/* Lib.c */
#include <stdio.h>
void foobar(int i)
{
    printf("Printing from Lib.so %d\n",i);
}
/* Lib.h */
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif
- 程序很简单,两个程序的主模块Program1.c和Program2.c分调用Lib.c里面的foobar()函数执行相应的操作。
- 接下来我们使用gcc将Lib.c编译成为一个共享对象文件(即动态链接文件)如下:  
`gcc -fPIC -shared -o Lib.so Lib.c`  
其中,`-shared`是表示产生共享对象的参数,这样我们就得到了一个Lib.so文件。
- 接着,我们编译链接Program1.c和Program2.c文件:  
`gcc -o Program1 Program1.c ./Lib.so`  
`gcc -o Program2 Program2.c ./Lib.so`  
这样我们就得到了两个程序**Program1**和**Program2**,整个编译以及链接过程大致如下:
![](http://7xl3j2.com1.z0.glb.clouddn.com/cxy-9.jpg)
【引入模块module的概念】在动态链接下,一个程序被分成若干个文件,有程序的主要部分,即可执行文件(Program1),也有程序所依赖的共享对象(Lib.so),很多时候,我们也把这些部分称为模块,即动态链接下可执行文件和共享对象都可以看作是程序的一个模块。
- 当链接器将Program1.o链接成可执行文件的时候,这个时候链接器必须确定Program1.o所引用的foobar()函数的性质,如果该函数是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对他进行地址重定位,把这个过程留到装载的时候再进行。判断函数是静态符号还是动态符号也简单,Lib.so保存了完整的符号信息。

【需要注意的是】共享对象的最终装载地址在编译的时候是不确定的,而是在装载的时候,装载器根据当前地址空间的空闲状况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。这样的话,关于共享对象的地址就会出现问题,下面会给出问题以及对应的解决方案。

  • 地址无关代码的引入(确定共享对象加载的时候在虚拟地址空间的位置)

    • 固定装载地址的困扰
        实现共享对象在任意地址加载,也就是说共享对象在编译的时候不能假设自己在进程虚拟地址空间的位置。但是可执行文件基本可以确定自己在虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件。
    • 装载时重定位(暂时的一个解决方法)
        实现共享对象在任意地址位置的装载的大致基本思路:在链接的时候,对所有的地址引用不作重定位,而把这一步推迟到装载的过程。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中的所有绝对地址引用进行重定位。比如,上面的例子,假设foobar相对于代码段的的起始地址是0x100,当模块被装载到0x10000000时,我们假设代码段位于模块的最开始,即代码段的装载地址也是0x10000000,那么我们就可以确定foobar的装载地址为0x10000100。这时系统遍历模块中的重定位表,把所有的foobar的地址引用重定位为0x10000100即可。
    • Linux和GCC支持这种装载时重定位的方法,我们前面在产生共享对像的时候,使用了两个GCC参数"-shared"和"-fPIC",如果只使用"-shared",那么输出的共享文件对象就是使用装载时重定位的方法。
    • 但是上述的装载时重定位的方法有一个问题:指令部分无法在多个进程之间共享。这样就会失去动态链接节省内存的优势。其实需要解决的问题就是,希望程序模块中共享的指令部分在装载的时候不需要因为装载地址的改变而改变
    • 改进的基本思想:把指令中那些需要修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变了,而数据部分可以在每个进程中保留一个副本。这种方案就是目前的地址无关代码,即PIC技术
    • PIC技术
      将对共享对象模块的地址引用分为指令引用和数据引用,如下图,后面会详细介绍:

    1.模块内部函数调用、跳转等。
    - 位置相对固定,可以采用相对地址调用或基于寄存器相对调用,这种指令不需要重定位;

    2.模块内部数据访问。
    - 相对地址,一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说任一条指令与模块内部数据之间的相对位置也是固定的。PC + 偏移值即相对地址;

    3.模块外部函数调用、跳转等。
    - 因为模块间的数据访问目标地址要等到装载的时候才能确定。要想让代码地址无关,就要将与地址相关的部分放至数据段。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(GOT),当代码需要引用改全局变量耳朵时候,可以通过GOT中相对应的项简介引用。其机制如下:

    4.模块外部数据访问。模块间的数据访问目标地址要等装载时才决定,这时就用到了代码地址无关技术,把跟地址相关的部分放到数据段。ELF建立一个指向其他模块全局变量的指针数组(GOT),采用它间接调用。
    - 与3的解决方法类似,如下:

  • 延迟绑定(PLT

    • 据统计,ELF程序的静态链接比动态链接稍微快一些,大约为1%~5%。主要原因:
      • 动态链接对全局的和静态的数据的访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先进行GOT定位,然后再进行间接跳转。
      • 程序开始执行时,,动态链接器都要执行一次链接工作。
    • 优化
        可以想象,有些函数在程序执行过程中很少用到,比如错误处理函数或者是用户很少用到的程序功能模块,所以一开始就将所有的函数都链接好是一种浪费。优化的思路:当程序第一次被用到的时候才进行绑定(符号查找、重定位等),如果没有用到就不进行绑定。
    • PLT的真正实现
      • ELF将GOT拆分成两个表,".got"和".got.plt",前者保存全局变量的引用的地址,后者对应函数引用的地址。对于".got.plt",其前三项有特殊的含义:
        1.".dynamic"段的地址;
        2.本模块的ID;
        3._dl_runtime_resolve()(完成地址绑定的函数)的地址。
        其中的第二项和第三项由动态链接器在装载共享变量的时候进行初始化,".got.plt"的其余项分别对应每个外部函数的引用。如下:

通过上面原理的了解,接下来我们考虑一下动态链接的基本实现过程

  • 动态链接的相关结构

    • 基本过程(简述)
      1.动态链接的装载。首先操作系统会读取可执行文件的头部,检查文件的合法性,之后从头部中的"Program Header"中读取每个"Segment"的虚拟地址、文件地址和属性,并将其映射到进程虚拟空间的相应位置。
      2.如果是静态链接,上述过程之后,操作系统就会把控制权交给可执行文件的入口地址,但是在动态链接中,操作系统接下来会启动一个动态链接器。
      3.在Linux下,动态链接器ld.so实际上是一个共享对象,操作系统同样通过映射的方式将其加载到进程的地址空间。当动态链接器得到控制权之后,它就开始执行一系列的自身的初始化操作,然后根据当前的环境参数对可执行文件进行动态链接工作。
      4.当所有的动态链接工作完成之后,动态链接器将控制权交给可执行文件,程序开始正式执行。
    • 动态链接相关的段
      1.".interp"段(是ELF可执行文件中的一个段,下面段的也一样)
        动态链接器需要被加载到进程空间,所以事先要知道动态链接器的位置,".interp"段保存的就是动态链接器的地址路径。
      2.".dynamic"段
        里面包含了动态链接所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等等。
        ".dynamic"段保存的信息(类型、格式)有点像ELF文件头,也可以将其看作是动态链接情况下的ELF文件的“文件头”
      3.动态符号表
        类似于静态链接的".symtab"符号表,动态链接有一个动态符号表".dynsym",该符号表仅仅保存了与动态链接相关的符号。
        很多时候动态链接的模块同时拥有".symtab"和".dynsym"两个表,".symtab"往往保存了多有的符号,包括".dynsym"中的符号(相当于".dynsym"将动态链接的相关符号单独取出来了)。
        动态链接也需要一些辅助的表,用于保存符号名的字符串等,即动态符号字符串表".dynstr"。
      4.动态链接重定位表
        上述的".got.plt"从第四项开始就是外部函数的引用。动态链接器到动态全局符号表里面找到这些引用进行重定位,下面会提到。
    • 动态链接的步骤和实现
      1.动态链接器“自举”
        动态链接器本身就是一个共享对象,但是又有一些特殊性。动态链接器本身不可以依赖其他任何的共享对象;其次是动态链接本身的所需要的全局和静态变量的重定位工作由自己完成。
        动态链接器的入口地址即自举代码的入口。自举代码会首先找到他自己的GOT。而GOT的第一个入口保存的即是".dynamic"段的偏移地址,由此就可以找到动态链接器自身的".dynamic"段,也就可以得到重定位表以及符号表,从而得到动态链接器本身的重定位入口。
      2.装载共享对象
        完成基本的自举之后,动态链接器可以将可执行文件和链接器本身的符号表合并进而得到全局符号表。然后链接器开始寻找可执行文件所依赖的共享文件,我们前面提到过".dynamic"段中,有一类的入口叫做DT_NEEDED,对应的就是该可执行文件(或者共享文件)所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载器集合中。
        然后链接器开始从集合中取出一个所需的共享对象的名字,找到相应的文件之后打开该文件,读取相应的ELF文件头和".dynamic"段,将相应的代码段和数据段映射到进程空间。(如果该共享对象还依赖于其他的共享对象,就将依赖的共享对象装入装载集合里面)如此循环直到所有的共享对象都装载进来。如果我们把依赖关系看作一个图的话,那么装载的整个过程就类似于图的遍历过程(深度遍历和广度遍历,多广度遍历)。
        当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以,当所有的共享都被装载进来的时候,全局符号表里面讲包含进程中所有的动态链接需要的符号。
      3.重定位和初始化
        链接器重新遍历可执行文件和每个共享对象的重定位表,将他们的GOT/PLT中的每个需要重定位的位置进行修正。
        重定位完成之后,如果某个共享对象有".init"段,那么动态链接就会执行该段中的代码,用于实现共享对象特有的初始化过程。比如,共享对象中的C++的全局/静态对象的构造就需要通过".init"段来初始化。相应的,共享对象中还可能有".finit"段,当进程退出的时候会执行该段的代码,可以实现类似C++全局对象析构之类的操作。
        当完成重定位和初始化之后,所有的准备工作就完成了,就将进程的控制权交还给程序入口开始执行。
  • 问题

    • 动态链接器本身是静态链接还是动态链接?
      • 静态链接。执行过程不依赖于其他的共享变量。可以通过ldd命令查看。

推荐阅读更多精彩内容