动态链接

动态链接

为什么需要动态链接

静态链接使得不同的程序开发者和部门能够相对独立的开发和测试自己的程序模块,从某种意义上来讲大大促进了程序开发的效率,原先现在程序规模也随之扩大。
但静态链接的缺点也暴露出来:浪费内存、磁盘空间、模块更新困难。

内存与磁盘空间

静态链接在计算机早期还是比较流行的,但是到了后面,其缺点也非常明显。比如浪费内存和磁盘空间,更新模块困难等。

举个例子,每个程序内部除了都保留了printf()、scanf()等这样的公共函数库,还有相当一部分的其他函数库及辅助数据结构都会包含在其中。现在Linux中,一个程序用到C语言静态库至少1MB以上,那么100个程序就会浪费掉100MB空间。

图示:

图1.png

Program1 & Program2分别包含Program1.o和Program2.o两个模块,并且还共用了Lib.o这个模块。静态链接下,P1和P2都用到了Lib.o这模块,所以它们同时在链接输出的可执行文件P1和P2有两个副本,当同时运行两个程序,Lib.o在磁盘和内存中都有两个副本,浪费空间。

程序开发与发布

静态链接另一个问题是对程序的更新,部署和发布也会很麻烦,如程序P1使用的Lib.o是由一个第三方A提供的,那么A更新Lib.o时候,P1的厂商需要拿到最新版的Lib.o,然后将P1与其链接后,将新的P1整个发布给用户,这么做有很明显的缺点:

程序有任何模块更新,整个程序就要重新链接,发布给用户,

动态链接

动态链接的出现解决了上面的问题。将程序模块相互独立的分隔开来,形成独立的文件,不再将它们静态地链接到一起。简单而言就是对那些组成程序目标文件的链接,等到程序运行时才进行链接,也就是把链接的过程推迟到运行时才进行,这就是动态链接的基本思想。

如上面的例子,假如现在保留了Program1.o、Program2.o和Lib.o,当运行Program1这个程序的时候,系统首先加载Program1.o,当系统发现Program1.o依赖Lib.o的时候,那么系统再去加载Lib.o,如果还依赖其他目标文件,则同样以类似于懒加载的方式去加载其他目标文件。

当所有的目标文件加载完之后,依赖关系也得到了满足,则系统才开始进行链接,这个链接过程和现在链接非常相似。之前介绍过静态链接的过程,包含符号解析,重定向等。完成这些之后,系统再把控制权交过Program1.o的执行入口,开始执行。如果这个时候Program2需要运行,则会发现系统中已经存在了Lib.o的副本所以就不需要重新加载Lib.o,直接将Lib.o链接起来就可以了。

图示:

图2.png

根据前面介绍的,这样的方式不仅仅减少了内存、磁盘空间的浪费,还减少了物理页面的换入换出,也可以增加CPU缓存的命中率,因为不同进程的数据和指令偶读集中在了一个共享模块上。

至于更新也就更加简单了,只需要简单的将旧的目标文件覆盖掉。无需从先将程序链接一遍,下次程序运行的时候,新的目标文件就会自动装载到内存中。

扩展性及兼容性

动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点被用来制作插件。

动态链接还可以加强程序的兼容性。一个程序在不同的平台运行时可以动态地链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加一个中间层,从而消除了程序对不同平台之间依赖的差异性。

比如操作系统A和B对于printf方法的实现机制不同,那么如果是静态链接,程序需要分别链接成能够运行A和B的两个版本并且分开发布,要是动态链接,需要A和B能够体统一个动态链接库包含printf方法,且这个方法使用一样的接口,那么程序只需要一个版本,就可以在两个OS上跑起来了

动态链接的基本实现

动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起,形成一个完整程序,而不是像静态链接一样把所有的程序模块链接成一个单独的可执行文件。

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

Linux系统中,ELF动态链接文件被称为动态共享对象,简称共享对象,它们一般是”.so”文件。在windows系统中,动态链接被称为动态链接库,它们通常就是我们常见的”.dll”为扩展名的文件。

当程序被转载的时候,系统的动态链接器会将程序所需要的所有动态链接库装载到进程的地址空间,并将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。

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

简单动态链接例子

演示代码文件:

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("Printint from Lib.so %d\n", i);
}


Lib.h


#ifndef LIB_H
#define LIB_H 
void foobar(int i);
#endif


使用GCC将Lib.c编译成一个共享对象文件:


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

参数:

  • -shared表示产生共享对象

可以得到一个Lib.so文件。这就是包含了Lib.c的foobar函数的共享对象文件

接下来再分别编译Program1.c & Program2.c


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


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

从Program1的角度看,整个编译和链接过程图示:

图3.png

Lib.c被编译成Lib.so共享对象文件,Program1.c被编译成Program1.o之后,链接称为可执行文件Program1,但上图中有一步与静态链接不一样,那就是Program1.o被链接成可执行文件这一步,在静态链接中,这一步链接过程会把Program1.o & Lib.o链接到一起,并且产生可执行文件Program1,但是这里Lib.o没有被链接进来,链接的输入目标文件只有Program1.o,但是命令行中可以发现Lib.so参与了链接过程

模块

静态链接中,整个程序最终只有一个可执行文件,它是一个不可以分割的整体,但是在动态链接下,一个程序被分成了若干个文件,有程序的主要部分,即可执行文件(Program1)和
程序所依赖的共享对象(Lib.so),很多时候把这些部分叫做模块,即动态链接下的可执行文件和共享对象都可以看做是程序的一个模块

当程序模块Program1.c被编译成Program1.o时,编译器还不不知道foobar函数的地址,当链接器将Program1.o链接成可执行文件时,这时候链接器必须确定Program1.o中所引用的foobar函数的性质。

  • 如果foobar是一个定义与其它静态目标模块中函数,那么链接器将会按照静态链接的规则,将Program1.o中的foobar地址引用重定位

  • 如果foobar是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行

这就引出了一个问题?

链接器如何知道foobar的引用是一个静态符号还是一个动态符号?实际上这就是用到Lib,so的原因,Lib.so中保存了完整的符号信息,把Lib.so也作为链接的输入文件之一,链接器在解析符号时就知道:foobar是一个定义在Lib.so的动态符号,这样链接器就可以对foobar的引用做特殊的处理,使它成为一个对动态符号的引用

动态链接程序运行时地址空间分布

静态链接而言,整个进程只有一个可执行文件被映射,之前介绍过静态的内存分布。动态链接而言除了可执行文件外还有其他共享目标文件。

在Lib.c中的foobar加入sleep函数防止一运行程序就结束了。


#include<stdio.h>
void foobar(int i)
{
    printf("Printing from Lib.so %d\n",i);
    sleep(-1);
}

然后查看进程的虚拟地址空间分布:


$ ./Program1 &

[1] 4471

$ cat /proc/4471/maps

55fc314d9000-55fc314da000 r-xp 00000000 08:01 1179978                    /home/mrlin/桌面/project/p6/Program1
55fc316d9000-55fc316da000 r--p 00000000 08:01 1179978                    /home/mrlin/桌面/project/p6/Program1
55fc316da000-55fc316db000 rw-p 00001000 08:01 1179978                    /home/mrlin/桌面/project/p6/Program1
55fc31df4000-55fc31e15000 rw-p 00000000 00:00 0                          [heap]
7fb5fba9d000-7fb5fbc84000 r-xp 00000000 08:01 1185496                    /lib/x86_64-linux-gnu/libc-2.27.so
7fb5fbc84000-7fb5fbe84000 ---p 001e7000 08:01 1185496                    /lib/x86_64-linux-gnu/libc-2.27.so
7fb5fbe84000-7fb5fbe88000 r--p 001e7000 08:01 1185496                    /lib/x86_64-linux-gnu/libc-2.27.so
7fb5fbe88000-7fb5fbe8a000 rw-p 001eb000 08:01 1185496                    /lib/x86_64-linux-gnu/libc-2.27.so
7fb5fbe8a000-7fb5fbe8e000 rw-p 00000000 00:00 0 
7fb5fbe8e000-7fb5fbe8f000 r-xp 00000000 08:01 1179974                    /home/mrlin/桌面/project/p6/Lib.so
7fb5fbe8f000-7fb5fc08e000 ---p 00001000 08:01 1179974                    /home/mrlin/桌面/project/p6/Lib.so
7fb5fc08e000-7fb5fc08f000 r--p 00000000 08:01 1179974                    /home/mrlin/桌面/project/p6/Lib.so
7fb5fc08f000-7fb5fc090000 rw-p 00001000 08:01 1179974                    /home/mrlin/桌面/project/p6/Lib.so
7fb5fc090000-7fb5fc0b7000 r-xp 00000000 08:01 1185468                    /lib/x86_64-linux-gnu/ld-2.27.so
7fb5fc29f000-7fb5fc2a2000 rw-p 00000000 00:00 0 
7fb5fc2b5000-7fb5fc2b7000 rw-p 00000000 00:00 0 
7fb5fc2b7000-7fb5fc2b8000 r--p 00027000 08:01 1185468                    /lib/x86_64-linux-gnu/ld-2.27.so
7fb5fc2b8000-7fb5fc2b9000 rw-p 00028000 08:01 1185468                    /lib/x86_64-linux-gnu/ld-2.27.so
7fb5fc2b9000-7fb5fc2ba000 rw-p 00000000 00:00 0 
7ffe34baa000-7ffe34bcb000 rw-p 00000000 00:00 0                          [stack]
7ffe34bdc000-7ffe34bdf000 r--p 00000000 00:00 0                          [vvar]
7ffe34bdf000-7ffe34be1000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]


整个进程调度虚拟地址空间多出了几个文件的映射。Lib.so与Program1一样,被操作系统以同样的方式映射到虚拟地址空间,只是占据的虚拟地址范围不同。

Program1除了使用Lib.so移位,其中还用到了动态链接形式的C语言运行库libc-2.27.so,还有一个非常重要的共享对象ld-2.27.so,其实ld-2.27.so就是Linux下的动态链接器。动态链接器和普通的共享对象一样被映射到了进程的地址空间,系统开始运行程序之前,会把控制权给动态链接器,由动态链接器完成链接工作,之后再把控制权给Program1

通过readelf工具查看Lib.so的装载属性:



readelf -l Lib.so



Elf 文件类型为 DYN (共享目标文件)
Entry point 0x580
There are 7 program headers, starting at offset 64

程序头:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000754 0x0000000000000754  R E    0x200000
  LOAD           0x0000000000000e10 0x0000000000200e10 0x0000000000200e10
                 0x0000000000000220 0x0000000000000228  RW     0x200000
  DYNAMIC        0x0000000000000e20 0x0000000000200e20 0x0000000000200e20
                 0x00000000000001c0 0x00000000000001c0  RW     0x8
  NOTE           0x00000000000001c8 0x00000000000001c8 0x00000000000001c8
                 0x0000000000000024 0x0000000000000024  R      0x4
  GNU_EH_FRAME   0x00000000000006b4 0x00000000000006b4 0x00000000000006b4
                 0x0000000000000024 0x0000000000000024  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000000e10 0x0000000000200e10 0x0000000000200e10
                 0x00000000000001f0 0x00000000000001f0  R      0x1

 Section to Segment mapping:
  段节...
   00     .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame 
   01     .init_array .fini_array .dynamic .got .got.plt .data .bss 
   02     .dynamic 
   03     .note.gnu.build-id 
   04     .eh_frame_hdr 
   05     
   06     .init_array .fini_array .dynamic .got 

可以看到除了文件类型和可执行文件不同与装载地址从0x0000 0000开始之外,其余基本上都一样。很明显这个装载地址是无效地址。共享对象最终的装载地址在编译时是不确定的,而是在装载的时候,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。

地址无关代码

固定装载地址的困扰

问题?

共享对象被装载时,如何确定它在进程虚拟地址空间中的位置?

为了实现动态链接,第一个问题就是共享对象地址的冲突问题,程序模块的指令和数据中可能会包含一些绝对地址的引用,在链接产生输出文件时候,就要假设模块被装载的目标地址

动态链接下,不同的模块目标装载地址都一样是不行的,对于单个程序,可以手工指定各个模块的地址,如把0x1000到0x2000分配给模块A,把多少到多少分配给B,但是一旦模块多了或者使用的人多起来之后,就很麻烦。

早期系统就采用这种方法,叫做静态共享库

静态共享库和静态库有很明显的区别。静态库是在链接的时候就确定了符号地址,而静态共享库是吧程序各个模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一个地址块,为已知的模块预留足够的空间。

静态共享库有很多问题,比如地址冲突;还有就是升级之后共享库必须保持共享库中的全局函数和变量地址不变,一旦在链接的时候绑定了这些地址,更改之后就需要重新链接整个程序。

装载时重定位

为了让共享对象在任意地址装载,所以对所有绝对地址的引用不做重定位,而是把这步推迟到装载的时候再完成,比如一旦模块的装载地址确定了也就是目标地址确定,那么系统对程序所有的绝对地址引用进行重定位,来实现任意地址装载。

比如前面的例子foorbar相对于代码段的其实位置是0x100,当模块被装载到0x10000000时,假设代码段在模块最开始的位置,则foobar的地址就是0x10000100。这个时候遍历所有模块中的重定位表,把所有对foorbar的地址引用都重定位为0x10000100

类似这种方法很早就就存在了,早先没有虚拟存储概念下,程序是直接装载进入物理内存的

比如一个程序在编译时假设被装载的目标地址为0x1000,但是装载时发现这个地址被别的程序使用了,所从0x4000开始有足够大的空间可以容纳该程序,那么程序就装载到0x4000

前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link Time Relocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation),在Windows中,这种装载时重定位又被叫做基址重置(Rebasing)。

但是装载时重定位的方法并不适合用来解决上面的共享对象中所存在的问题。可以想象,动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然,动态链接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。Linux和GCC支持这种装载时重定位的方法,我们前面在产生共享对象时,使用了两个GCC参数“-shared”和“-FPIC”,如果只使用“-shared”,那么输出的共享对象就是使用装载时重定位的方法。

地址无关代码

什么是"-fPIC"?这个参数有什么效果?

装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。我们还需要有一种更好的方法解决共享对象指令中对绝对地址的重定位问题。其实我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC, Position-independent Code)的技术。

对于现代的机器来说,产生地址无关的代码并不麻烦。我们先来分析模块中各种类型的地址引用方式:这里把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问,这样就得到了四种情况,如下图所示:

  • 1 模块内部的函数调用 跳转等

  • 2 模块内部的数据访问,比如模块中定义的全局变量,静态变量

  • 3 模块外部的函数调用,跳转等

  • 4 模块外部的数据访问,比如其他模块中定义的全局变量


static int a;
extern int b;

extern void ext();

void bar()
{
    a = 1; //对应第2
    b = 2;//对应第4
}

void foo()
{
    bar();//对应第1
    ext();//对应第3
}

编译器编译pic.c时,它实际上不能确定变量b和函数ext()是模块外部的还是模块内部的,因为它们有可能被定义在同一个共享对象的其它目标文件中,所以编译器只能把它们都当做模块外部的函数和变量来处理

类型一 模块内部的函数调用、跳转等

被调用的函数与调用者都处于同一个模块,它们之间的相对位置是固定的,所以这种情况比较简单。对于现代的系统来讲,模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。

类型二 模块内部的数据访问,比如模块中定义的全局变量、静态变量

指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。我们知道,一个个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。现代的体系结构中,数据的相对寻址往往没有相对于当前指令地址(PC)的寻址方式,所以ELF用了一个很巧妙的办法来得到当前的PC值,然后再加上一个偏移量就可以达到访问相应变量的目的了。得到PC值的方法很多。

类型三 模块外部的数据访问,比如其它模块中定义的全局变量

模块间的数据访问目标地址要等到装载时才决定,比如上面例子中的变量b,它被定义在其它模块中,并且该地址在装载时才能确定。要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,很明显,这些其它模块的全局变量的地址是跟模块装载地址有关的。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table, GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。当指令中需要访问变量b时,程序会先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址。每个变量都对应一个4个字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。

GOT如何做到指令的地址无关性?从第二种类型的数据访问我们了解到,模块在编译时可以确定模块内部变量相对于当前指令的偏移,那么我们也可以在编译时确定GOT相对于当前指令的偏移。确定GOT的位置跟上面的访问变量a的方法基本一样,通过得到PC值然后加上一个偏移量,就可以得到GOT的位置。然后我们根据变量地址在GOT中的偏移就可以得到变量的地址,当然GOT中每个地址对应于哪个变量是由编译器决定的。

类型四 模块外部的函数调用、跳转等

也可以采用上面类型三的方法来解决,与上面的类型有所不同的是,GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转。

各种地址引用方式:

| |指令跳转 & 调用 |数据访问 |
|-|-|-|-
|模块内部|1 相对跳转和调用|2 相对地址访问|
|模块外部|3间接跳转&调用(GOT)|4 间接访问(GOT)

-fpic和-fPIC

使用GCC产生地址无关代码很简单,我们只需要使用“-fPIC”参数接口。实际上GCC还提供了另外一个类似的参数叫做“-fpic”,即”PIC”3个字母小写,这两个参数从功能上来讲完全一样,都是指示GCC产生地址无关代码。唯一的区别是,“-fPIC”产生的代码要大,而“-fpic”产生的代码相对较小,而且较快。那么我们为什么不使用“-fpic”而要使用“-fPIC”呢?原因是,由于地址无关代码都是跟硬件平台相关的,不同的平台有着不同的实现,“-fpic”在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而“-fpic”则没有这样的限制。所以为了方便起见,绝大部分情况下,我们都使用“-fPIC”参数来产生地址无关代码。

$ readelf -d Lib.so | grep TEXTREL

上面的命令可以用来区分一个DSO是否为PIC。如果上面的命令有任何输出,那么Lib.so就不是PIC的,否则就是PIC的。PIC的DSO是不会包含任何代码段重定位表的,TEXTREL表示代码段重定位表地址。

PIC与PIE

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

共享模块的全局变量问题

定义在模块内的全局变量?当一个模块引用了一个定义在全局变量的时候,编译器无法判断这个变量在定义同一模块还是定义在另一个共享对象之中。

比如:

一个共享对象定义了一个全局变量global,而模块module.c中这么引用:


extern int global;

int foo()
{
    golbal = 1;
}

当编译器编译module.c时,它无法根据上下文判断global是定义在同一个模块的其它目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用

数据段地址无关性

数据部分是否也有绝对地址引用问题?

代码:


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

上面代码,指针p的地址就是一个绝对地址,它指向变量a,a的地址会随着共享对象的装载地址改变而改变

对于数据段来说,它在每个进程都有一份独立的副本,所以并不担心被进程改变。从这点来看,我们可以选择装载时重定位的方法来解决数据段中绝对地址引用问题。

对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个重定位表里面包含了“R_386_RELATIVE”类型的重定位入口,用于解决上述问题。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。

实际上,我们甚至可以让代码段也使用这种装载时重定位的方法,而不使用地址无关代码。但是,如果代码不是地址无关的,它就不能被多个进程之间共享,于是也就失去了节省内存的优点。但是装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程。

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

延迟绑定(PLT)

动态链接的确有很多优势,比静态链接要灵活得多,但它是以牺牲一部分性能为代价的。据统计ELF程序在静态链接下要比动态库稍微快点,当然这取决于程序本身的特性及运行环境等。

我们知道动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先定位GOT,然后再进行间接跳转,如此一来,程序的运行速度必定会减慢。

另外一个减慢运行速度的原因是动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都需要进行一次链接工作,动态链接器会寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作,这些工作势必减慢程序的启动速度。这是影响动态链接性能的两个主要问题。

延迟绑定实现

在动态链接下,程序模块之间包含了大量的函数引用(全局变量往往比较少,因为大量的全局变量会导致模块之间耦合度变大),所以在程序开始执行前,动态链接会耗费不少时间用于解决模块之间的函数引用的符号查找以及重定位。不过可以想象,在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者是一些用户很少用到的功能模块等,如果一开始就把所有函数都链接好实际上是一种浪费。

所以ELF采用了一种延迟绑定(Lazy Bingding)的做法,基本的思想

就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。所以程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定。这样的做法可以大大加快程序的启动速度,特别有利于一些有大量函数引用和大量模块的程序。

ELF使用PLT(Procedure Linkage Table)的方法来实现延迟绑定,这种方法使用了一些很精巧的指令序列来完成。

假设liba.so需要调用libc.so中的bar函数,那么当liba.so第一次调用bar时,这时候就需要调用动态链接器中的某个函数来完成地址绑定工作,假设这个函数即lookup(),那么lookup()需要知道哪些必要的信息才能完成这个函数地址绑定工作?

  • lookup()至少需要知道这个地址绑定发生在哪个模块?哪个函数?

假设lookup的原型为lookup(module,function),这两个参数的值在例子中分别为liba.so & bar(),

当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过GOT中相应的项进行间接跳转。PLT为了实现延迟绑定,在这个过程中间又增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫做PLT项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项。

ELF将GOT拆分成了两个表叫做”.got”和”.got.plt”。其中”.got”用来保存全局变量引用的地址,”.got.plt”用来保存函数引用的地址

也就是说,所有对于外部函数的引用全部被分离出来放到了”.got.plt”中。另外”.got.plt”还有一个特殊的地方是它的前三项是有特殊意义的,分别含义如下:第一项保存的是”.dynamic”段的地址,这个段描述了本模块动态链接相关的信息;第二项保存的是本模块的ID;第三项保存的是_dl_runtime_resolve()的地址。其中第二项和第三项由动态链接器在装载共享模块的时候负责将它们初始化。”.got.plt”的其余项分别对应每个外部函数的引用。PLT在ELF文件中以独立的段存放,

段名通常叫做”.plt”,因为它本身是一些地址无关的代码,所以可以跟代码段等一起合并成同一个可读可执行的”Segment”被装载入内存。

动态链接相关结构

动态链接下,可执行文件的装载与静态链接基本一样

  • 操作系统读取可执行文件头部,检查文件合法性

  • 从头部中“Program Header”读取每个“Segment”的虚拟地址,文件地址,属性,将它们映射到进程虚拟空间的相应位置

这些操作跟静态链接下的装载基本一致,在静态链接下,操作系统接着就可以吧控制权转交给可执行文件的入口地址,然后程序开始执行

但在动态链接下,操作系统还不能在装载完可执行文件之后就把控制权交给可执行文件,因为可执行文件依赖于很多共享对象,此时,可执行里面对于很多外部符号的引用还处于无效地址的状态,即还没有跟相应的共享对象中的实际位置链接起来,所以在映射完可执行文件之后,操作系统会启动一个动态链接器

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

".interp"段

系统中哪个才是动态链接器?位置由谁决定?

实际上,动态链接器的位置既不是由系统配置指定,也不是由环境参数决定,而是由ELF可执行文件决定,在动态链接的ELF可执行文件中,有一个专门的段叫做“.interp”

可以使用odjdump工具查看:


$ objdump -s a.out

a.out:     文件格式 elf64-x86-64

Contents of section .interp:
 0238 2f6c6962 36342f6c 642d6c69 6e75782d  /lib64/ld-linux-
 0248 7838362d 36342e73 6f2e3200           x86-64.so.2.    



内容就是一个字符串,这个字符串是可执行文件所需要的动态链接器的路径,在Linux下,可执行文件所需要的动态链接器的路径几乎都是"/lib/ld-linux.so.2"

Linux中,操作系统在对可执行文件的进行加载时候,它会去寻找装载该可执行文件所需要相应的动态链接器,即“.interp”段指定的路径的共享对象

Linux可以通过命令行查看一个可执行文件所需要的动态链接器的路径

$ readelf -l a.out | grep interpreter
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

“.dynamic”段

动态链接ELF中最重要的结构应该是“.dynamic”段,这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象,动态链接符号表的位置,动态链接重定位表的位置,共享对象初始化代码的地址等等,

使用readelf工具查看:


$ readelf -d Lib.so

Dynamic section at offset 0xe20 contains 24 entries:
  标记        类型                         名称/值
 0x0000000000000001 (NEEDED)             共享库:[libc.so.6]
 0x000000000000000c (INIT)               0x520
 0x000000000000000d (FINI)               0x690
 0x0000000000000019 (INIT_ARRAY)         0x200e10
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x200e18
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x1f0
 0x0000000000000005 (STRTAB)             0x368
 0x0000000000000006 (SYMTAB)             0x230
 0x000000000000000a (STRSZ)              163 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000003 (PLTGOT)             0x201000
 0x0000000000000002 (PLTRELSZ)           48 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x4f0
 0x0000000000000007 (RELA)               0x448
 0x0000000000000008 (RELASZ)             168 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000006ffffffe (VERNEED)            0x428
 0x000000006fffffff (VERNEEDNUM)         1
 0x000000006ffffff0 (VERSYM)             0x40c
 0x000000006ffffff9 (RELACOUNT)          3
 0x0000000000000000 (NULL)               0x0


此外,Linux还提供了一个命令用来查看一个程序主模块或者一个共享库依赖于哪些共享库

             
$ ldd Program1
    linux-vdso.so.1 (0x00007ffcaabe0000)
    ./Lib.so (0x00007fe1e974c000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fe1e935b000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fe1e9b50000)

动态符号表

完成动态链接,最关键的还是所依赖的符号和相关文件的信息

静态链接中,有一个专门的段叫做符号表“.symtab”,里面保存了所有关于该目标文件的符号的定义以及引用,动态链接的符号表实际上与静态链接类似

如前面例子Program1程序依赖于Lib.so,引用到了里面的foobar函数,那么对于Program1来说,往往叫Program1导入了foobar函数,foobar函数是Program1的导入函数

从Lib.so角度,它实际上定义了foobar函数,并且提供给其他模块使用,叫Lib.so导出了foobar函数,foobar是Lib.so的导出函数

为了表示动态链接这些模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表的段用来保存这些信息,这个段叫做“.dynsym”

“.dynsym”只保存了与动态链接有关的符号,对于那些模块内部的符号,比如模块私有变量则不保存

readelf工具来查看




$ readelf -sD Lib.so

Symbol table of `.gnu.hash' for image:
  Num Buc:    Value          Size   Type   Bind Vis      Ndx Name
    7   0: 0000000000201030     0 NOTYPE  GLOBAL DEFAULT  22 _edata
    8   0: 0000000000201038     0 NOTYPE  GLOBAL DEFAULT  23 _end
    9   1: 0000000000201030     0 NOTYPE  GLOBAL DEFAULT  23 __bss_start
   10   1: 0000000000000520     0 FUNC    GLOBAL DEFAULT   9 _init
   11   2: 0000000000000690     0 FUNC    GLOBAL DEFAULT  13 _fini
   12   2: 000000000000065a    51 FUNC    GLOBAL DEFAULT  12 foobar

动态链接重定位表

动态链接的可执行文件使用PIC方法,虽然其代码段不需要重定位(因为地址无关),但是数据端还是包含了绝对地址的引用,因为代码段中绝对地址相关部分被分离了出来,编程了GOT(全局偏移表),而GOT实际上是数据端的一部分,除了GOT,数据端还可以能包含绝对地址引用。

重定位相关数据结构

和静态链接类似,动态链接重定位表分为.rel.dyn和.rel.plt他们分别相当于.rel.text和.rel.data。.rel.dyn是对数据的修真,位于.got段,.rel.plt是对函数的修正位于.got.plt段。

readelf查看一个动态链接的文件的重定位表



$ readelf -r Lib.so

重定位节 '.rela.dyn' at offset 0x448 contains 7 entries:
  偏移量          信息           类型           符号值        符号名称 + 加数
000000200e10  000000000008 R_X86_64_RELATIVE                    650
000000200e18  000000000008 R_X86_64_RELATIVE                    610
000000201028  000000000008 R_X86_64_RELATIVE                    201028
000000200fe0  000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000200fe8  000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000200ff0  000400000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000200ff8  000600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

重定位节 '.rela.plt' at offset 0x4f0 contains 2 entries:
  偏移量          信息           类型           符号值        符号名称 + 加数
000000201018  000200000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
000000201020  000500000007 R_X86_64_JUMP_SLO 0000000000000000 sleep@GLIBC_2.2.5 + 0

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

从动态链接器角度看,当操作系统把控制权交给它的时候,它将开始做链接的工作,那么它需要知道关于可执行文件和本进程的一些信息,比如可执行文件有几个段,每个段的属性,程序的入口地址等等。这些信息往往由操作系统传递给动态链接器,保存在进程的堆栈里面

可以写一个程序把堆栈中的初始化信息全部打印出来:


#include <stdio.h>
#include <elf.h>

int main(int argc, char* argv[])
{
    void** p = (void**)argv;
    printf("%p\n", p);

    printf("Argument count: %d\n", *((int*)p - 1));

    int i;
    for (i = 0; i < argc; ++i)
    {
        printf("Argument %d: %s\n", i, (char*)*p);
        p++;
    }

    // skip 0
    p++;

    printf("Environment:\n");
    while (*p) {
        printf("%s\n", (char*)*p);
        p++;
    }

    // skip 0
    p++;

    printf("Auxiliary Vectors:\n");
    Elf64_auxv_t* aux = (Elf64_auxv_t*)p;
    while (aux->a_type != AT_NULL) {
        printf("Type: %02ld Value: %#lx\n", aux->a_type, aux->a_un.a_val);
        aux++;
    }

    return 0;
}


输出;


0x7ffe605a00c8
Argument count: 0
Argument 0: ./pr
Environment:
CLUTTER_IM_MODULE=xim
LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=30;41:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.Z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.zst=01;31:*.tzst=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.wim=01;31:*.swm=01;31:*.dwm=01;31:*.esd=01;31:*.jpg=01;35:*.jpeg=01;35:*.mjpg=01;35:*.mjpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=00;36:*.au=00;36:*.flac=00;36:*.m4a=00;36:*.mid=00;36:*.midi=00;36:*.mka=00;36:*.mp3=00;36:*.mpc=00;36:*.ogg=00;36:*.ra=00;36:*.wav=00;36:*.oga=00;36:*.opus=00;36:*.spx=00;36:*.xspf=00;36:
LESSCLOSE=/usr/bin/lesspipe %s %s
XDG_MENU_PREFIX=gnome-
LANG=zh_CN.UTF-8
MANAGERPID=1882
DISPLAY=:0
INVOCATION_ID=acc39dd9b8c647aeb29dd7db54b97631
GNOME_SHELL_SESSION_MODE=ubuntu
COLORTERM=truecolor
USERNAME=mrlin
XDG_VTNR=2
SSH_AUTH_SOCK=/run/user/1000/keyring/ssh
XDG_SESSION_ID=2
USER=mrlin
DESKTOP_SESSION=ubuntu
QT4_IM_MODULE=xim
TEXTDOMAINDIR=/usr/share/locale/
GNOME_TERMINAL_SCREEN=/org/gnome/Terminal/screen/d4ed0b15_155b_4b4f_89d0_07c180b08c8c
PWD=/home/mrlin/桌面/project/p6
HOME=/home/mrlin
JOURNAL_STREAM=9:36807
TEXTDOMAIN=im-config
SSH_AGENT_PID=2004
QT_ACCESSIBILITY=1
XDG_SESSION_TYPE=x11
XDG_DATA_DIRS=/usr/share/ubuntu:/usr/local/share:/usr/share:/var/lib/snapd/desktop
XDG_SESSION_DESKTOP=ubuntu
DBUS_STARTER_ADDRESS=unix:path=/run/user/1000/bus,guid=1acbc5cacc124a89e1f576115e888be9
GTK_MODULES=gail:atk-bridge
WINDOWPATH=2
TERM=xterm-256color
SHELL=/bin/bash
VTE_VERSION=5202
QT_IM_MODULE=xim
XMODIFIERS=@im=ibus
IM_CONFIG_PHASE=2
DBUS_STARTER_BUS_TYPE=session
XDG_CURRENT_DESKTOP=ubuntu:GNOME
GPG_AGENT_INFO=/run/user/1000/gnupg/S.gpg-agent:0:1
GNOME_TERMINAL_SERVICE=:1.86
XDG_SEAT=seat0
SHLVL=1
LANGUAGE=zh_CN:zh
GDMSESSION=ubuntu
GNOME_DESKTOP_SESSION_ID=this-is-deprecated
LOGNAME=mrlin
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus,guid=1acbc5cacc124a89e1f576115e888be9
XDG_RUNTIME_DIR=/run/user/1000
XAUTHORITY=/run/user/1000/gdm/Xauthority
XDG_CONFIG_DIRS=/etc/xdg/xdg-ubuntu:/etc/xdg
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
SESSION_MANAGER=local/mrlin-virtual-machine:@/tmp/.ICE-unix/1911,unix/mrlin-virtual-machine:/tmp/.ICE-unix/1911
LESSOPEN=| /usr/bin/lesspipe %s
GTK_IM_MODULE=ibus
_=./pr
Auxiliary Vectors:
Type: 33 Value: 0x7ffe605b7000
Type: 16 Value: 0xf8bfbff
Type: 06 Value: 0x1000
Type: 17 Value: 0x64
Type: 03 Value: 0x55cfc4890040
Type: 04 Value: 0x38
Type: 05 Value: 0x9
Type: 07 Value: 0x7f7c4b6ea000
Type: 08 Value: 0
Type: 09 Value: 0x55cfc4890580
Type: 11 Value: 0x3e8
Type: 12 Value: 0x3e8
Type: 13 Value: 0x3e8
Type: 14 Value: 0x3e8
Type: 23 Value: 0
Type: 25 Value: 0x7ffe605a03e9
Type: 26 Value: 0
Type: 31 Value: 0x7ffe605a2ff3
Type: 15 Value: 0x7ffe605a03f9


动态链接的步骤 & 实现

动态链接器的自举

我们知道动态链接器本身也是一个共享对象,但是事实上它有一些特殊性。对于普通共享对象文件来说,它的重定位工作由动态链接器来完成。他也可以依赖其他共享对象,其中的被依赖共享对象由动态链接器负责链接和装载。可是对于动态链接器来说,它的重定位工作由谁来完成?它是否可以依赖于其他共享对象?

这是一个“鸡生蛋,蛋生鸡”的问题,为了解决这种无休止的循环,动态链接器这个“鸡” 必须有些特殊性。

  • 首先是,动态链接器本身不可以依赖于其他任何共享对象;

  • 其次是动态链接器本身所需要的全局和静态变量和重定位工作由它本身完成。

对于第一个条件我们可以人为的控制。在编写动态链接器时必须保证不使用任何系统库,运行库;

对于第二个条件,动态链接器必须在启动时有一段非常精巧的代码可以完成这项艰巨的工作而同时又不能使用全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举(Bootstrap)。

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

实际上在动态链接器的自举代码中,除了不可以使用全局变量和静态变量之外,甚至不能调用函数,即动态链接器本身的函数也不能调用。

这是为什么呢?

其实我们在前面分析地址无关代码时已经提到过,实际上使用PIC模式编译的共享对象,对于模块内部的函数调用也是采用跟模块外部函数调用一样的方式,即使用 GOT/PLT的方式,所以在 GOT/PLT没有被重定位之前,自举代码不可以使用任何全局变量,也不可以调用函数。

装载共享对象

完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表( Global Symbol Table)。

然后链接器开始寻找可执文件所依赖的共享对象,我们前面提到过“.dynamic”段中,有一种类型的入口DT_NEEDED,它所指出的是该可执行文件(或共享对象)所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后链接器开始从集合里取个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和“ .dynamic”段,然后将它相应的代码段和数据段映射到进程空间中。

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

符号优先级

a1.c

#include <stdio.h>
void a() {
    printf("a1.c\n");
}

a2.c

#include <stdio.h>
void a() {
    printf("a2.c\n");
}

b1.c

#include <stdio.h>
void a();
void b1() {
    a();
}

b2.c


#include <stdio.h>
void a();
void b2() {
    a();
}

a1.c & a2.c都定义了名字为a的函数,那么b1.c & b2.c 都使用了外部函数a,但是源代码中没有指定依赖于哪个共享对象中的函数a,所以在编译时指定依赖关系,

假设b1.so依赖于a1.so,b2.so依赖于a2.so,将b1.so与a1.so进行链接,b2.so与a2.so进行链接



$ gcc -fPIC -shared a1.c -o a1.so

$ gcc -fPIC -shared a2.c -o a2.so


$ gcc -fPIC -shared b1.c a1.so -o b1.so

$ gcc -fPIC -shared b2.c a2.so -o b2.so



$ ldd b1.so
    linux-vdso.so.1 (0x00007ffc06ff4000)
    a1.so => not found

$ ldd b2.so
    linux-vdso.so.1 (0x00007ffd56bc7000)
    a2.so => not found


当有程序同时使用b1.c的函数b1和b2.c中的函数b2是会怎样?


#include<stdio.h>

void b1();

void b2();

int main()
{
    b1();
    b2();
    return 0;
}

然后我们将main.c编译成可执行文件并且运行:


$ gcc main.c b1.so b2.so -o main -Xlinker -rpath ./

关于全局符号介入这个问题,实际上Linux下的动态链接器是这样处理的:

它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略从动态链接器的装载顺序可以看到,它是按照广度优先的顺序进行装载的,首先是main,然后是b1.so、b2.so、a1.so,最后是a2.so。当a2.so中的函数a要被加入全局符号表时,先前装载a1.so时,al.o中的函数a已经存在于全局符号表,那么a2.so中的函数a只能被忽略。所以整个进程中,所有对于符合“a”的引用都会被解析到a1.so中的函数a,这也是为什么main打印出的结果是两个“a1.c”而不是理想中的“alc”和“a2.c”。

由于存在这种重名符号被直接忽略的问题,当程序使用大量共享对象时应该非常小心符号的重名问题,如果两个符号重名又执行不同的功能,那么程序运行时可能会将所有该符号名的引用解析到第-个被加入全局符号表的使用该符号名的符号,从而导致程序莫名其妙的错误。

全局符号介入与地址无关代码

地址无关代码,对于第一类模块内部调用或跳转的处理时,我们简单地将其当作是相对地址调用/跳转。但实际上这个问题比想象中要复杂,结合全局符号介入,关于调用方式的分类的解释会更加清楚。还是拿前面“pic.c”的例子来看,由于可能存在全局符号介入的问题,foo函数对于bar的调用不能够采用第一类模块内部调用的方法,因为一旦bar函数由于全局符号介入被其他模块中的同名函数覆盖,那么foo如果采用相对地址调用的话,那个相对地址部分就需要重定位,这又与共享对象的地址无关性矛盾。所以对于bar()函数的调用,编译器只能采用第三种,即当作模块外部符号处理,bar()函数被覆盖,动态链接器只需要重定位“.got .plt”,不影响共享对象的代码段

为了提高模块内部函数调用的效率,有一个办法是把bar()函数变成编译单元私有函数,即使用“ statIc”关键字定义bar()函数,这种情况下,编译器要确定bar()函数不被其他模块覆盖,就可以使用第一类的方法,即模块内部调用指令,可以加快函数的调用速度。

重定位与初始化

当上面的步骤完成之后,链接器开始重新遍历可执行的文件和每个共享对象的重定位表,将它们的GOT/PLT的每个需要重定位的位置进行修正。

因为此时动态链接器已经拥有了进程的全局符号表,所以这个修正过程也显得比较容易,跟我们前面提到的地址重定位的原理基本相同。在前面介绍动态链接的重定位表时,我们已经碰到了几种重定位类型,每种重定位入口地址的计算方法我们在这里就不再重复介绍了。

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

如果进程的可执行文件也有“init”段,那么动态链接器不会执行它,因为可执行文件中的“init”段和“ finit”段由程序初始化部分代码负责执行,我们将在后面的“库”这部分详细介绍程序初始化部分。

当完成了重定位和初始化之后,所有的准备工作就宣告完成了,所需要的共享对象都已经装载并且链接完成了,这时候动态链接器就如释重负,将进程的控制权转交给程序的入口并且开始执行。

Linux动态链接器的实现

在前面分析 Linux下程序的装载时,己经介绍了一个通过 execve()系统调用被装载到进程的地址空间的程序,以及内核如何处理可执行文件。

内核在装载完ELF可执行文件以后就返回到用户空间,将控制权交给程序的入口。对于不同链接形式的ELF可执行文件,这个程序的入口是有区别的

  • 对于静态链接的可执行文件来说,程序的入口就是ELF文件头里面的 e_entry指定的入口;

  • 对于动态链接的可执行文件来说,如果这时候把控制权交给e_entry指定的入口地址,那么肯定是不行的,因为可执行文件所依赖的共享库还没有被装载,也没有进行动态链接。所以对于动态链接的可执行文件,内核会分析它的动态链接器地址(在“.interp”段),将动态链接器映射至进程地址空间,然后把控制权交给动态链接器。

Linux动态链接器是个很有意思的东西,它本身是一个共享对象,它的路径是lib/ld-linux.so.2,这实际上是个软链接,它指向lib/ld-x.y.z.so,这个才是真正的动态连接器文件。共享对象其实也是ELF文件,它也有跟可执行文件一样的EF文件头(包括 e_entry、段表等)。动态链接器是个非常特殊的共享对象,它不仅是个共享对象,还是个可执行的程序,可以直接在命令行下面运行:

其实 Linux的内核在执行 execve()时不关心目标ELF文件是否可执行(文件头 e_type是 ET_EXEC还是 ET_DYN),它只是简单按照程序头表里面的描述对文件进行装载然后把控制权转交给ELF入口地址(没有“.interp”就是ELF文件的 e_entry;如果有“.interp”的话就是动态链接器的 e_entry)。

这样我们就很好理解为什么动态链接器本身可以作为可执行程序运行,这也从一个侧面证明了共享库和可执行文件实际上没什么区别,除了文件头的标志位和扩展名有所不同之外,其他都是一样的。 Windows系统中的EXE和DLL也是类似的区别,DLL也可以被当作程序来运行, Windows提供了一个叫做rund32exe的工具可以把一个DLL当作可执行文件运行。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,056评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,842评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,938评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,296评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,292评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,413评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,824评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,493评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,686评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,502评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,553评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,281评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,820评论 3 305
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,873评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,109评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,699评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,257评论 2 341

推荐阅读更多精彩内容