《程序员的自我修养》学习心得——第七章 动态链接※※※

前言

这一样会有一些在做题当中会用到的点,前面几章一直讲的都是静态链接,静态链接会有一些缺点,比如浪费内存、磁盘空间模块更新困难的问题

一、为什么要动态链接

在说动态链接之前,咱们先回顾一下静态链接,起始静态链接的过程你可以把它看做一个集装打包的过程。举个栗子:比如有两个程序Program1和Program2,分别包含Program1.o和Program2.o两个模块,而且他们还共用Libc.o:
在这里插入图片描述在静态链接的情况下,因为Program1和Program2都用到Lib.o这个模块,所以同时在链接输出的可执行文件有两个副本。在同时运行Program1和Program2时,Lib.o在磁盘中的内存中都有两份副本。Lib.o的重复载入造成了空间的浪费,这才只是两个模块,每个模块1MB,2000个这样的模块呢?

我们再来看动态链接,如果说把静态链接比喻成集装打包,那么动态链接就可以比喻成模块化使用,我需要哪部分,就载入哪部分,多个模块同时需要某个部分,就把这个部分作为一个共享。简单来说不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接,把链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking)的基本思想

还是以Program1和Program2举例:假设保留Program1.o、Program2.o和Lib.o三个目标文件。

  • 当运行Program1时,系统首先加载Program1.o
  • 当系统发现Program1.o中用到了Lib.o,即Program1.o依赖于Lib.o,那么系统接着加载Lib.o
  • 如果Program1.o或Lib.o还依赖于其他目标文件,系统会按照这种方法将他们全部加载至内存。所有需要的目标文件加载完毕之后,如果依赖满足,所有依赖的目标文件都存在于磁盘,系统开始进行链接工作
  • 完成上述之后,系统开始把控制权交给Program1.o的程序入口,程序开始运行
  • 如果需要运行Program2,那么系统只需要加载Program2.o就可以了,因为内存中已经存在了一份Lib.o的副本,系统只需要将Program2.o和Lib.o链接起来就行

在这里插入图片描述
动态链接有很多好处:

  • 首先通过上面的的过程可以看到动态链接不会造成空间的浪费,多个模块运行时公共使用的部分不必重复加载
  • 减少物理页面的换入换出,增加CPU缓存的命中率
  • 使程序的升级变得更容易,理论上在升级程序库或程序共享的某个模块时,可以直接覆盖旧的目标文件
  • 程序开发时可以分开实现,模块更加独立
  • 程序在运行时可以动态选择加载各种程序模块,被用来制作插件(Plug-in
  • 可以加强程序的兼容性,相当于在程序和操作系统之间增加了一个中间层,消除程序对平台之间依赖的差异性

动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个个单独的可执行文件。但是虽然这么说,在理论上可行,但实际上动态链接的实现方案与直接使用目标文件稍有差别

动态链接涉及运行时链接及多个文件的装载,必须要有操作系统的支持,因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接情况下更为复杂,还有一些存储管理、内存共享、进程线程等机制在动态链接下也会有一些微妙的变化

在Linux系统中,ELF动态链接文件被称为动态共享对象(DSO,Dynamic Shared Objects),简称共享对象,一般是以“.so”为扩展名的一些文件。对应着在Windows系统中,动态链接文件被称为动态链接库(Dynamically Linking Libarary),通常就是平时常见的以“.dll”为扩展名的文件

在Linux中,常用的C语言库的运行库glibc,动态链接形式的版本保存在“/lib”目录下,文件名叫“libc.so”。所有C语言编写的、动态链接的程序都可以在运行时使用它。当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作

二、简单的动态链接例子

还是拿之前的Program1和Program2来说,分别需要:Program1.c、Program2.c、Lib.c、Lib.h

扫描二维码关注公众号,回复: 11403484 查看本文章
/* 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", i);
}

/* Lib.h */
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif

使用如下命令将Lib.c编译成一个共享对象文件:

gcc -fPIC -shared -o Lib.so Lib.c

这时候就得到了一个Lib.so文件,就是包含了Lib.c的foobar()函数的共享对象文件,然后分别编译链接Program1.c和Program2.c:

gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so

这样就得到了两个程序Program1和Program2,这两个程序都是用了Lib.so里面的foobar()函数,从Program1的角度看,整个编译过程如下:
在这里插入图片描述
回到动态链接的机重定位制上来:

  • 当程序模块Program1.c被编译成Program1.o时,编译器还不知道foobar()函数的地址
  • 当连接器将Program1.o链接成可执行文件时,这时候链接器必须确定Program1.o中所引用的foobar()函数的性质
    • 如果foobar()是一个定义与其他静态目标模块中的函数,那么链接器将会按照静态链接的规则,将Program1.o中的foobar地址引用重定位
    • 如果foobar()是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时在进行

到这就需要想一个问题,链接器如何知道foobar的引用是一个静态符号还是一个动态符号?Lib.so中保存了完整的符号信息(因为运行时进行动态链接还需使用符号信息),把Lib.so也作为链接的输入文件之一,链接器在解析符号时就可以知道foobar是一个定义在Lib.so的动态符号

三、地址无关代码

1、共享模块的全局变量问题

这是一个什么样的问题呢,比如说一个共享对象lib.so中定义了一个全局变量G,而进程A和进程B都是用了lib.so,那么当进程A改变这个全局变量G的值的时候,进程B中的G会收到影响吗?

对于这类问题,解决的办法就是所有的使用这个变量的指令都指向位于可执行文件中的那个副本。lib.so被两个进程加载时,它的数据段部分在每个进程中都有独立的副本,从这个角度来看,共享对象中的全局变量实际上和定义在程序内部的全局变量没什么区别。任何一个进程访问的只是自己的那个副本,而不会影响其他进程

上面说的是进程,那么线程呢?把这个问题条件改成同一个进程中的线程A和线程B,他们是否能看到对方对lib.so中的全局变量G的修改呢?对于同一进程的两个线程来说,他们访问的是同一个进程地址空间,也就是同一个lib.so的副本,所以对于G的修改,对方都能看到

2、数据段地址无关性

看一段代码:

static int a;
static int* p = &a;

如果某个共享对象里面有这样一段代码,那么指针p的地址就是一个绝对地址,指向变量a,而变量a的地址会随着共享对象的装载地址改变而改变。当然也是有解决办法的

对于数据段来说,它在每个进程都有一份独立的副本,所以不担心被进程改变,所以可以选择装载时重定位的方法来解决数据段中绝对地址引用问题。对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,表中包含了“R_386_RELATIVE”类型的重定位入口,就可以解决上述问题

对于可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么GCC会使用PIC的方法来产生可执行文件的代码段部分,以便于不同的进程能够共享代码段,节省内存。所以在动态链接的可执行文件中存在“.got”这样的段

四、绑定延迟(PLT)

动态链接虽然比静态链接灵活,但是牺牲了一部分性能的代价:

  • 因为动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址,对于模块间的调用也要先定位GOT,然后在进行间接跳转,所以速度会慢
  • 另外一个原因是动态链接的链接工作是在运行时完成的,动态链接器会寻找并装载所欲需要的共享对象,然后进行符号查找地址定位等工作,这些工作会减慢程序的启动速度

延迟绑定实现

为了解决上面动态链接的弊端,ELF采用了一种叫做延迟绑定(Lazy Binding)的做法,基本思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。所以程序开始时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定

ELF使用PLT(Procedure Linkage Table)的方法来实现,在这之前,先从动态链接器的角度设想一下:假设liba.so需要调用liba.so中的bar()函数,你那么当liba.so中第一次调用bar()时,这时候就需要调用动态链接器中的某个函数来完成地址绑定工作,夹着这个函数叫做lookup(),那么lookup()需要知道这个地址绑定发生在哪个模块、哪个函数。假设lookup()的原型为lookup(module,function),两个参数分别是liba.so和bar()。在Glibc中,lookup()函数真正的名字叫做_dl_runtime_resolve() (在高级栈溢出的时候会用到)

当我们调用某个外部模块的函数时,PLT为了实现延迟绑定,在这个过程中有增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫做PLT项的结构来进行跳转,每个外部函数在PLT中都有一个相应的项,比如bar()函数在PLT中的项的地址称之为bar@plt:

bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump __dl_runtime_resolve

bar@plt的第一条指令是一条通过GOT间接跳转的指令,bar@GOT表示GOT中保存bar()这个函数相应的项。若果链接器在初始化阶段已经初始化该项,并且将bar()的地址填入该项,那么这个跳转指令的结果就是我们所期望的,跳转到bar(),实现函数正确调用。

  • 但是为了实现延迟绑定,链接器在初始化并没有将bar()的地址填入到该项,而是将上面代码中第二条指令”push n“的地址填入到bar@GOT中,这个步骤不需要任何符号,所以代价很低。
  • 第一条指令的效果是跳转到提二条指令,相当于没有任何操作。
  • 第二条指令将一个数字n压入栈中,这个数字是bar这个符号引用在重定位表”.rel.plt“中的下标
  • 第三条push指令将模块的ID压入到栈中
  • 第四条跳转到_dl_runtime_resolve

也就是在实现前面提到的lookup(module, function)这个函数的调用:先将所需要决议符号的下标压入栈,在将模块ID压入栈,然后调用动态链接器的_dl_runtime_resolve()函数来完成符号解析和重定位工作。_dl_runtime_resolve()在进行一系列工作以后将bar()的真正地址填入到bar@GOT中

一旦bar()这个函数被解析完毕,再次调用bar@plt时,第一条jmp指令就能够跳转到真正的bar()函数中,bar()函数返回的时候会根据站里面保存的EIP直接返回到调用者,而不会在执行bar@plt中第二条指令开始的那段代码,那段代码只会在符号未被解析时执行一次

ELF将GOT拆分两个表叫做”.got“和”.got.plt“。其中”.got“用来保存全局变量引用地址,”.got.plt“用来保存函数引用地址,所有外部函数的引用全部非分离出来放在”.got.plt“中。另外”.got.plt“还有特殊的提房就是他的前三项:

  • 第一项是“.dynamic”段的地址,这个段描述了本模块动态链接相关的信息
  • 第二项保存的是本模块的ID
  • 第三项保存的是_dl_runtime_resolve()的地址

其中第二项和第三项由动态链接器在装载共享模块的时候将他们初始化”.got.plt“的其余项分别对应每个外部函数的引用。PLT的结构为了减少代码的重复,ELF把上面例子中最后两条指令放到PLT中的第一项。并规定每一项的长度时16个字节,刚好存放3条指令,实际的PLT基本结构如下:

在这里插入图片描述实际的PLT基本结构代码如下:

PLT0:
push *(GOT + 4)
jump *(GOT + 8)
...
bar@plt:
jmp *(bar@GOT)
push n
jump PLT0

PTL在ELF文件中以独立的段存放,段名通常叫做“.plt”,因为它本身是一些地址无关的代码,所以可以跟代码段等一起河滨城同一个可读可执行的“Segment”被装载入内存

五、动态链接相关结构

动态链接情况下,可执行文件的装载与静态链接情况基本一样。首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的“Program Header”中读取每个“Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置。这个时候操作性同还不能在装载完可执行文件后吧控制权交给可执行文件,因为可执行文件依赖于很多共享对象。可执行文件里对于外部符号的引用还处于无效地址的状态,即还没有跟相应的共享对象中的实际位置连接起来,所以在映射完可执行文件之后,操作系统会先启动一个动态链接器(Dynamic Linker

在Linux下,动态链接器ld.so实际上是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态连接器后,就会将控制权交给动态链接器的入口地址。之后动态链接器执行一系列自身的初始化操作,然后根据当前环境参数,开始对可执行文件进行动态链接工作。所有链接工作完成之后,动态链接器会将控制权交到可执行文件入口地址

1、“.interp”段

系统中链接器的位置由ELF可执行文件决定的,在动态链接的ELF可执行文件中,有个段叫做“.interp”段(“interp”是“interpreter”(解释器)的缩写)。“.interp”里只保存一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径,在Linux下,可执行文件所需要的动态链接器的路径几乎都是“/lib/ld-linux.so.2”,其他的*nix系统可能会有不同的路径。在Linux系统中,/lib/ld-linux.so.2通常是一个软连接。操作系统在对可执行文件进行加载的时候,它回去寻找装载该可执行文件所需要相应的动态链接器,即“.interp”段执行的路径的共享对象

2、“.dynamic”段

“.dynamic”段里保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置】动态链接重定位表的位置、共享对象初始化代码地址等。“.dynamic”段结构数组如下:

typedef struct {
  Elf32_Sword d_tag;
  union {
    Elf32_Word d_val;
    Elf32_Addr d_ptr;
  } d_un;
} Elf32_Dyn;

Elf32_Dyn结构由一个类型值加上一个附加的数值或指针,对于不同的类型,后面附加的数值或者指针有着不同含义:

d_tad类型 d_un的含义
DT_SYMTAB 动态链接符号表地址,d_ptr表示“.dynsym”的地址
DT_STRTAB 动态链接字符串表地址,d_ptr表示“.dynstr”的地址
DT_STRSZ 动态链接字符串表大小,d_val表示大小
DT_ 动态链接哈希表地址,d_ptr表示“.hash”的地址
DT_SONAME 动态链接
DT_RPATH 动态链接共享对象搜索路径
DT_INIT 初始化代码地址
DT_FINIT 动态链接
DT_NEED 依赖的共享对象文件,d_ptr表示所依赖的共享对象文件名
DT_REL
DT_RELA
动态链接重定位表地址
DT_RELENT
DT_RELAENT
动态重读位表入口数量

从上面给出的定义来看,“.dynamic”段里面保存的信息有点像ELF文件头,只是前面看到的ELF文件头中保存的是静态链接时相关的内容,比如静态链接时用到的符号表、重定位表,这里换成了动态链接下所使用的相关信息。所以“.dynamic”段可以堪称是动态链接下ELF文件的“文件头”

3、动态符号表

为了动态链接这些模块之间的符号导入导出关系,ELF有一个叫动态符号表(Dynamic Symbol Table)的段来保存这些信息,段名叫“.dynsym”。“.dynsym”只保存与动态链接相关的符号,不保存模块内部符号,比如模块私有变量

动态符号表也需要一些辅助表,比如保存符号名的字符串表——动态符号字符串表“.dynstr”(Dynamic String Table)。由于动态链接下需要在程序运行时查找符号,为了加快符号的查找过程,还需要辅助的符号哈希表“.hash”

4、动态链接重定位表

共享对象需要重定位的主要原因是导入符号的存在。动态链接下,无论是可执行文件或共享对象,一旦它依赖于其他共享对象,也就是说有导入的符号时,它的代码或数据中就会有对于导入符号的引用。在编译时这些导入符号的地址未知,在动态链接中,导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号的引用修正,即需要中定位

动态链接重定位相关结构

动态链接的文件中,也有类似静态链接的重定位表,分别叫做“.rel.dyn”和“.rel.plt”,分别相当于静态链接下的“.rel.text”和“.rel.data”。

  • “.rel.dyn”实际上是对数据引用的修正,他所修正的位置位于“.got”以及数据段
  • “.rel.plt”是对函数引用的修正,他所修正的位置位于“.got.plt”

5、动态链接时进程堆栈初始化信息

当操作系统把控制权交给动态链接器时,他开始做连接工作。操作系统会将可执行文件有几个段(“segment”)、每个段的属性、程序的入口地址这些信息交给动态链接器,这些信息保存在进程的堆栈里面。在进程初始化的时候,栈里保存了关于进程执行环境和命令参数等信息,栈中还保存了动态链接器所需要的一些辅助信息数组(Auxiliarty Vector)。辅助信息的格式也是一个结构数组:

typedef struct{
  uint32_t a_type;
  union{
    uint32_t a_val;
  } a_un;
} Elf32_auxv_t;

这个结构和前面“.dynamic”段里面的结构很相似下面十几个比较重要的类型值,比较常见,并且是动态链接器在启动时所需要的

a_type定义 a_type值 a_val的含义
AT_NULL 0 表示辅助信息数组结束
AT_EXEFD 2 表示可执行文件的文件句柄。动态链接器需要知道一些关于可执行文件的信息,当进程开始执行可执行文件时,操作系统会先将文件打开,这时候就会产生文件句柄。那么操作系统可以将文件句柄传递给动态链接器,动态链接器可以通过操作系统的文件读写来访问可执行文件
AT_PHDR 3 可执行文件中程序头表(Program Header)在进程中的地址。动态链接器可以通过操作系统的文件读写功能来访问可执行文件。但是很多操作系统会把可执行文件映射到进程的虚拟空间里,从而动态链接器不需要通过读写文件,而是可以直接访问内存中的文件映像。当操作系统选择映像的方式时,必须提供后面的AT_PHENT、AT_PHNUM和AT_ENTRY
AT_PHENT 4 可执行文件头中程序头表中每个入口(Entry)的大小
AT_PHNUM 5 可执行文件头中程序头表中入口(Entry)的数量
AT_BASE 7 表示动态链接器本身的装载地址
AT_ENTRY 9 可执行文件入口地址,即启动地址

事实上,辅助信息数组位于环境变量指针的后面,比如假设操作系统传给动态链接器的辅助信息有四个:

  • AT_PHDR,值为0x08048034,程序头表位于0x08048034
  • AT_PHENT,值为20,程序头表中每个项的大小为20字节
  • AT_PHNUM,值为7,程序头表共有7个项
  • AT_ENTRY,0x08048320,程序入口地址为0x08048320

那么进程初始化栈如下:
在这里插入图片描述

六、动态链接的步骤和实现

动态链接的步骤基本上分3步:

  • 启动动态链接器本身
  • 装载所有需要的共享对象
  • 重定位和初始化

1、动态链接器自举

对于普通共享对象来说,它的重定位工作是由动态链接器完成的。但是动态链接器自己本身也是共享对象,那么谁来重定位动态链接器呢?

想要解答这个问题,需要对答案进行限制,也就是说动态链接器必须有些特殊性:

  • 首先是动态链接器本身不可以依赖其他任何共享对象
  • 其次是动态链接器本身所需要的全局变量和静态变量的重定位工作必须由它本身完成

那么一个一个的实现这两个特殊性:

  • 对于第一个条件,人为可以控制,在编写动态链接器时保证不使用任何系统库、运行库
  • 对于第二个条件,动态链接器必须在启动时有一段代码可以完成重定位工作,同时又不能用到全局变量和静态变量。这种具有一定限制条件的启动代码被称为自举(Bootstrap

动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码开始执行:

  • 首先会找到自己的GOT
  • GOT的第一个入口保存的就是“.dynamic”段的偏移地址,由此找到了动态链接器本身的“.dynamic”段
  • 通过“.dynamic”中的信息,自举代码可以获得动态链接器本身的重定位表和符号表
  • 从而得到动态链接器本身的重定位入口,先将他们全部重定位

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

2、装载共享对象

装载过程如下:

  • 在完成基本自举后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表中,可称之为全局符号表(Global Symbol Table)。
  • 然后链接器开始寻找可执行文件所依赖的共享对象,在“.dynamic”段中,有一类入口是DT_NEEDED,它所指处的是该可执行文件(或共享对象)所依赖的共享对象。因此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装在集合中。
  • 然后链接器开始重集合里去一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和“.dynamic”段,然后将它相应的代码段和数据段映射到进程空间中。如果这个ELF共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放到装在集合中
  • 如此循环直到所有以来的共享对象都被装载进来为止
  • 当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装再进来的时候,全局符号表里将包含进程中所有的动态链接所需要的符号

符号的优先级

一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象被称为共享对象全局符号介入(Global Symbol Interpose

在Linux下的动态链接器定义了一个规则,当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。如果两个符号重名又执行不同功能,那么程序运行时可能会将所有该符号名的引用解析到第一个被加入全局符号表的使用该符号名的符号,从而导致错误

3、重定位和初始化

上面的步骤完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将他们的GOT/PLT中每个需要重定位的位置进行修正。因为此时动态链接器已经拥有了进程的全局符号表,所以修正过程比较容易

重定位完成之后,如果某个共享对象有“.init”段,那么动态链接器就会执行“.init”段中的代码,用以实现共享推向特有的初始化过程。共享对象还可能有“.finit”段,当进程退出时会执行“.finit”段中的代码,可以用来实现类似C++全局对象析构之类的操作

如果进程的可执行文件有“.init”段,那么动态链接器不会执行他,因为可执行文件中的“.init”段和“.finit”段是由程序初始化部分代码负责执行的。

完成重定位和初始化之后,准备工作就完成了,动态链接器将进程的控制权转交给程序的入口并开始执行

4、关于动态链接器的几个问题

(1)动态链接器本身是动态链接还是静态链接的?

动态链接器本身是静态链接的,他不能依赖于其他共享对象,动态链接器本身使用来帮助其他ELF文件解决共享对象依赖问题的,如果它也依赖于其他共享对象,那么谁来帮他解决依赖问题?所以它本身必须不依赖其他共享对象

(2)动态链接器本身必须是PIC的吗?

是不是PIC对于动态链接器来说并不关键,冬日太连接器可以使PIC也可以不是,但往往使用PIC会更加简单一些。一方面如果不是PIC的话,会使得代码段无法共享,浪费内存。另一方面也会使ld.so本身初始化更加复杂,因为自举时还需要对代码段进行重定位

(3)动态链接器可以被当做可执行文件运行,那么装载地址应该是多少?

ld.so的装载地址一般和共享对象没区别,即0x00000000.这个装载地址是一个无效的装载地址,作为一个共享库,内核在装载时会为其选择一个合适的装载地址

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_41202237/article/details/106996893