动态链接介绍

动态链接

动态链接(Dynamic Linking)本质是指把链接这个过程推迟到了运行时再进行,准确的说这个过程应该放在装载部分。不过动态链接的出现很大一部分原因是为了解决内存浪费问题,因此直接照搬静态链接的方式不合理,需要做一些改变。

另外我们称一个程序为动态链接程序或静态链接程序指的是该程序是否有动态链接过程.

注意动态链接不包括合并代码和数据段的过程,各个模块在内存中独立存在。

装载时重定位

由于需要将多个模块装载到内存中,因此动态链接难免会有地址冲突问题,这就需要我们在加载的时候将模块中的相关地址修改为正确的值,这就是装载时重定位。

Linux和GCC支持这种装载时重定位的方法,在产生共享对象时,使用了两个GCC参数 -shared 和 -fPIC ,如果只使用 -shared ,那么输出的共享对象就是使用装载时重定位的方法。

地址无关代码

如果采用装载时重定位的方法虽然能够做到任意地址装载,但存在弊端。比如模块装载到不同位置会导致模块的代码段内容发生改变,无法实现共享库的复用,造成内存浪费;每次装载重定位会影响性能等。

地址无关代码的出现很好的解决了装载时重定位的缺点。地址无关代码的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC,Position-independent Code)的技术。这也就是 GCC 的 -fPIC 编译参数。
模块中各种类型的地址引用方式有以下 4 种:
模块内部的函数调用、跳转等。
模块内部的数据访问,比如模块中定义的全局变量、静态变量。
模块外部的函数调用、跳转等。
模块外部的数据访问,比如其他模块中定义的全局变量。

对于前两种引用方式由于是在模块内部,相对地址偏移固定,因此可以通过 [rip + xxx] (注意这里的 rip 是当前指令的下一条指令的地址,下一条指令指的是地址相邻的下一条指令)的方式进行引用,从而做到地址无关。因此关键在于后两种怎么解决。

模块间的访问比模块内部稍微麻烦一点,因为模块间的数据访问目标地址要等到装载时才决定,我们前面提到要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,很明显,这些其他模块的全局变量的地址是跟模块装载地址有关的。ELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过 GOT 中相对应的项间接引用。

前面模块内部的解决方法实际上并不严谨,比如一些全局变量以及函数声明没有初始化会被认为是若弱符号,这些弱符号编译器并不知道是否只在本模块定义,因此不能仅使用 [rip + xxx] 的方式访问。

针对这种情况的解决办法是所有的使用这个变量的指令都指向位于可执行文件中的那个副本。ELF 共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作前面的类型四,通过 GOT 来实现变量的访问。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把 GOT 中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本;如果该全局变量在程序主模块中没有副本,那么 GOT 中的相应地址就指向共享模块内部的该
变量副本。这就是为什么 libc 的 GOT 表中会有自身函数。

地址无关代码虽然解决了模块复用的问题,但是本质还是装载时重定位因此没有解决性能问题,实际上ELF 采用了延迟绑定的方法来解决这一问题。

地址无关代码技术除了可以用在共享对象上面,它也可以用于可执行文件,一个以地址无关方式编译的可执行文件被称作地址无关可执行文件(PIE, Position-Independent Executable)。与 GCC 的 -fPIC和 -fpic 参数类似,产生 PIE 的参数为 -fPIE 或 -fpie 。

延时绑定

在动态链接下,程序模块之间包含了大量的函数引用(全局变量往往比较少,因为大量的全局变量会导致模块之间耦合度变大),所以在程序开始执行前,动态链接会耗费不少时间用于解决模块之间的函数引用的符号查找以及重定位。可以想象,在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者是一些用户很少用到的功能模块等,如果一开始就把所有函数都链接好实际上是一种浪费。所以 ELF 采用了一种叫做延迟绑定(Lazy Binding)的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。所以程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定。这样的做法可以大大加快程序的启动速度,特别有利于一些有大量函数引用和大量模块的程序。

注意,延迟绑定一般只出先在未开启 FULL RELRO 的时候,如果开启 FULL RELRO 则 got 表不可写,程序在装载时完成 got 表的重定位。当然特殊情况也有在开启 FULL RELRO 的时候进行重定位,比如ret2dlresolve 。

我们以调用 puts 函数为例讲解一下延迟绑定的过程。

首先第一次调用 puts 时由于 puts@got 没有进行重定位,因此会调用 _dl_runtime_resolve 函数进行重定位, _dl_runtime_resolve 函数将查找到的 puts 函数地址填写到 puts@got 后会调用puts 函数。

在这里插入图片描述

再次调用 puts 函数时由于 puts@got 已经完成重定位,因此会直接调用 puts 函数
在这里插入图片描述

其中在第一次调用 puts 函数时调用的 _dl_runtime_resolve 函数的具体实现为:

  • 用第一个参数 link_map 访问 .dynamic ,取出 .dynstr , .dynsym , .rel.plt 的指针。
  • .rel.plt + 第二个参数 求出当前函数的重定位表项 Elf32_Rel 的指针,记作 rel 。
  • rel->r_info >> 8 作为 .dynsym 的下标,求出当前函数的符号表项 Elf32_Sym 的指针,记作sym 。
  • .dynstr + sym->st_name 得出符号名字符串指针。
  • 在动态链接库查找这个函数的地址,并且把地址赋值给 *rel->r_offset ,即 GOT 表。
  • 调用这个函数。

动态链接的步骤和实现

动态链接器自举

由于动态链接器本身的作用是重定位,因此自身的重定位也需要自身来完成,完成自身重定位的过程成为自举(Bootstrap)。

动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行。自举代码首先会找到它自己的 GOT 。而 GOT 的第一个入口保存的即是.dynamic 段的偏移地址,由此找到了动态连接器本身的“.dynamic”段。通过 .dynamic 中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。

从这一步开始,动态链接器代码中才可以开始使用自己的全局变量和静态变量。

装载共享对象

完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表(Global Symbol Table)。然后链接器开始寻找可执行文件所依赖的共享对象,我们前面提到过 .dynamic 段中,有一种类型的入口是 DT_NEEDED ,它所指出的是该可执行文件(或共享对象)所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后链接器开始从集合里取一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的 ELF 文件头和 .dynamic 段,然后将它相应的代码段和数据段映射进程空间中。

如果这个 ELF 共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止,当然链接器可以有不同的装载顺序,如果我们把依赖关系看作一个图的话,那么这个装载过程就是一个图的遍历过程,链接器可能会使用深度优先或者广度优先或者其他的顺序来遍历整个图,这取决于链接器,比较常见的算法一般都是广度优先的。

当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接所需要的符号。

重定位和初始化

当上面的步骤完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT 中的每个需要重定位的位置进行修正。因为此时动态链接器已经拥有了进程的全局符号表,所以这个修正过程也显得比较容易,跟我们前面提到的地址重定位的原理基本相同。

动态链接重定位除了前面静态链接重定位类型外还有如下重定位类型:

  • R_386_RELATIVE :针对下面这种代码的重定位,由于加载地址不确定,需要加载后的才能确定。
  • R_386_GLOB_DAT :位于 .got 的重定位入口,只需要填入正确变量地址即可。
  • R_386_JUMP_SLOT :位于 .got.plt 的重定位入口,只需要填入正确的函数地址即可。

定位完成之后,如果某个共享对象有 .init 段,那么动态链接器会执行 .init 段中的代码,用以实现共享对象特有的初始化过程,比如最常见的,共享对象中的 C++ 的全局/静态对象的构造就需要通过.init 来初始化。相应地,共享对象中还可能有 .fini 段,当进程退出时会执行 .fini 段中的代码,可以用来实现类似 C++ 全局对象析构之类的操作。

如果进程的可执行文件也有 .init 段,那么动态链接器不会执行它,因为可执行文件中的 .init 段和.fini 段由程序初始化部分代码负责执行。当完成了重定位和初始化之后,所有的准备工作就宣告完成了,所需要的共享对象也都已经装载并且链接完成了,这时候动态链接器就如释重负,将进程的控制权转交给程序的入口并且开始执行。

  • 24
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值