Linux下动态链接

Linux系统中,ELF动态链接文件被称为动态共享对象(DSO),简称共享对象,它们一般都是以“.so”为扩展名的一些文件;Windows中,动态链接文件被称为动态链接库(.dll)。下面是一个简单的例子:
[cpp]  view plain copy
  1. #include "lib.h"  
  2. int main()  
  3. {  
  4.     foobar(1);  
  5.     return 0;  
  6. }  

[cpp]  view plain copy
  1. #include <stdio.h>  
  2. void foobar(int i)  
  3. {  
  4.     printf("printing from Lib.so %d\n", i);  
  5.     sleep(-1);  
  6. }  

[cpp]  view plain copy
  1. #ifndef LIB_H  
  2. #define LIB_H  
  3.   
  4. void foobar(int i);  
  5. #endif  

$ gcc -fPIC -shared -o Lib.so Lib.c
$ gcc -o Program1 Program1.c ./Lib.so
    从Program1的角度来看,整个编译链接过程如下:
Lib.c被编译成Lib.so共享对象文件,Program1.c被编译成Program1.o之后,链接称为Program1;但是,在Program1被链接的过程,输入的目标文件只有Program1.o,Lib.so也参与了链接过程。程序Program1.c被编译成目标文件时,编译器还不知道foobar()函数的地址,(静态)链接时,链接器必须确定foobar()的函数性质,如果它是一个定义在其它目标模块中的函数,则链接器会按照静态链接的规则,将Program1.o中的foobar地址引用重定位;如果foobar()是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。
    Lib.so中保存了完整的符号信息,Lib.so也作为链接的输入文件之一,链接器在解析符号时就可以知道,foobar是一个定义在Lib.so中的动态符号。这样链接器就可以对foobar的引用做特殊的处理,使它成为一个对动态符号的引用。这就是链接时用到Lib.so的原因。

    我们查看下动态链接程序运行时地址空间分布:
$ ./Program1 &
$ cat /procmaps
Linux下动态链接_第1张图片

可以看到,进程的虚拟地址空间中,Lib.so和Program1一样,被映射到进程的虚拟地址空间,此外还有C语言运行库libc-2.15.so;另外还有一个共享对象就是ld-2.15.so,这就是Linux下的动态链接器。动态链接器与普通共享对象一样被映射到进程的地址空间,在系统开始运行Program1之前,首先会把控制权交给动态链接器,完成动态链接工作以后再把控制权交给Program1,然后开始执行。
    查看Lib.so的装在属性:
$ readelf -l Lib.so
Linux下动态链接_第2张图片

可以看出,动态链接模块装在地址是从0x0开始的,这是个无效的地址,我们从上面的进程虚拟空间分布看到,Lib.so的装在地址并不是0x0,可以推断共享对象的最终装载地址在编译时是不确定的。或者可以这样表述,共享对象在编译时不能假设自己在进程虚拟地址空间中位置。

    在链接时,对所有绝对地址的引用不做重定位,而把这一步推迟到装载时在完成,一旦模块装载地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。静态链接的重定位,叫做链接时重定位,现在这种情况经常被称为装载时重定位,在Windows中这种装载时重定位又被叫做基址重置(Rebasing)。动态连接模块被映射到虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到统一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然,动态链接库中的可修改数据部分对于不同进程来说有许多副本,所以它们可以采用装载时重定位的方法来解决。我们在产生共享对象时使用了两个参数“-shared”和“-fPIC”,如果只使用“-shared”,那么输出的共享对象就是使用装载时重定位的方法。

    装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。我们的目的很简单,希望程序模块中的指令部分在装载时不需要因为装载地址的改变而改变,基本想法就是把指令中的那些需要被修改的部分分离出来,跟数据部分放到一块,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC, Position-independent Code)的技术。
    我们可以把程序内部的地址引用方式分为四种:
  • 模块内部的函数调用、跳转
  • 模块内部的数据访问,比如,模块中定义的全局变量和静态变量
  • 模块外部的函数调用、跳转
  • 模块外部的数据访问,比如其他模块中定义的全局变量
测试代码:

[cpp]  view plain copy
  1. #include <stdio.h>  
  2. #include "ext.h"  
  3.   
  4. static int a;  
  5. extern int b;  
  6. extern void ext();  
  7.   
  8. void bar()  
  9. {  
  10.     a = 1;  
  11.     b = 2;  
  12. }  
  13.   
  14. void foo()  
  15. {  
  16.     bar();  
  17.     ext();  
  18. }  
  19.   
  20. int main()  
  21. {  
  22.     foo();  
  23.     printf("\n");  
  24.     printf("\na address is %x.\n", &a);  
  25.     printf("b address is %x.\n", &b);  
  26.     printf("foo address is %x.\n", foo);  
  27.     printf("bar address is %x.\n", bar);  
  28.     printf("ext address is %x.\n", ext);  
  29.     sleep(-1);  
  30.     return 0;  
  31. }  

[cpp]  view plain copy
  1. #ifndef EXT_H  
  2. #define EXT_H  
  3.   
  4. int b;  
  5. void ext();  
  6.   
  7. #endif  

[cpp]  view plain copy
  1. #include <stdio.h>  
  2. #include "ext.h"  
  3.   
  4. void ext()  
  5. {  
  6.     printf("ext() in ext.so.\n");  
  7. }  

$ gcc -fPIC -shared -o ext.so ext.c
$ gcc -o jump Program1.c ./ext.so
    使用objdump -d jump查看代码段的反汇编:
Linux下动态链接_第3张图片

<foo>中,8048573 反汇编为e8跳转指令,跳转到地址8048577 -23(ffffffdc)的位置为08048454<bar>
<bar>中,8048557 访问地址 0x804a028的数据,我们查看符号表:
Linux下动态链接_第4张图片

得知访问的是本地变量 。当然,由于是模块内部访问,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,这里也可以使用数据的相对寻址。
<bar>中,80484f4 访问地址 0804a02c的数据,通过上面的符号表得知, 访问的是外部模块ext.so中的数据  b,感觉好奇怪,它不是应该呆在ext.so所映射的虚拟地址空间吗?这是由于,可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来,为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的“.bss”段创建一个 b 的副本,ELF共享库在装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把GOT(稍后解释)中的相应地址指向该副本,这样该变量在实际运行时实际上最终只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本;如果该全局变量在程序主模块中没有副本,那么GOT中的相应地址就指向模块内部的该变量副本。通常情况下,编译器会按照跨模块模式产生代码,因为编译器无法确定对该变量的引用是跨模块的还是模块内部的,即便是模块内部的全局变量的引用,还是会产生跨模块的代码,因为,共享模块可能会引用该变量,最终还是要引用可执行文件中的副本。
    现在,介绍下通常情况下的模块间数据访问:
ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table, GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用,每个变量都对应一个4个字节的地址。链接器在装载模块时会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。
    通过模块内部的数据访问我们了解到,模块在编译时可以确定模块内部变量相对于当前指令的偏移,那么我们也可以在编译期间确定GOT相对于当前指令的偏移,这样我们就可以确定当前指令相对于该变量的偏移。

    <foo> 0x8048578跳转指令,跳转地址为804857c -fc(ffffff03)即8048480<ext@plt>(GOT的一种,got.plt)该地址内容为:

根据.plt项跳转到ext函数地址,具体跳转方式稍后叙述。

    ELF采用了一种叫做延迟绑定的做法,其基本思想就是当函数第一次被用到时才进行绑定。它使用PLT(Procedure Linkage Table)的方法来实现。当我们调用外部模块的函数时,按照通常的做法是通过GOT中相应的项进行间接跳转,PLT为了实现延迟绑定,在这个过程中间又增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫做PLT的项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项,比如ext()函数在PLT中的项的地址我们称之为ext@plt,见上图。
    其中,第一条指令为跳转指令,跳转到0x804a010地址所保存的函数地址执行;第二条将一个数字压入堆栈;第三条执行跳转指令。跟踪指令:
1.0x8048480,跳转到0x804a010地址保存的函数地址,

该地址属于.got.plt段,该段保存函数引用的地址,所有对于外部函数的引用全部放到该段。 每一项占4个字节, 另外该段的前三项分别表示:“.dynamic”段的地址、本模块的ID、_dl_runtime_resolve()<动态链接器中完成符号解析和重定位工作的函数>的地址;后面保存的都是外部函数的引用引用地址。0x8048a010保存的是ext的地址;可以从.rel.plt段查看需要重定位外部函数的偏移地址:
Linux下动态链接_第5张图片

    当前,0x084a010保存的ext函数的引用地址为0x08048486。
2.我们发现 0x08048486 在.plt段<ext@plt>中:
    
    压入0x20到堆栈,.rel.plt段每一项占8字节,即.rel.plt[4],第五项为ext

3.继续执行,跳转到 0x08048430, 该地址内容页属于.plt段
  1. 第一条指令是将 0x08049ff8内容压入堆栈,它是.got.plt的第二项,即模块ID;第二条指令是 执行0x08049ffc保存的函数引用地址,它是.got.plt的第三项,即动态链接器中负责符号解析和重定位的函数;该函数将解决ext函数的重定位,即将.got.plt中外部函数ext的引用地址修正为真正的引用地址;原地址是指向<ext@plt>的第二条指令,现修正为ext的真正地址,以后再执行该函数时,就不会再一次跳转到<ext@plt>的第二条指令...进行重定位工作了。
    系统中动态链接器是由ELF可执行文件决定,它被保存在.interp段
    

    动态链接器如何完成链接过程的呢?我们先从相关的段着手,其中一个最重要的段是.dynamic段
Linux下动态链接_第6张图片

该段保存了了动态链接器所需要的基本信息:依赖对象、符号表位置、重定位表位置、共享对象初始化代码地址等等。

    动态符号表 .dynsym段保存了与动态链接相关的符号,对于模块内部的符号则不保存

Linux下动态链接_第7张图片

    
    当动态链接器需要进行重定位时,它首先查找 ext 的地址, ext位于ext.so中,链接器在全局符号表里面找到 ext 的地址,那么链接器就会将这个地址填入到 .got.plt 中特定偏移的位置中去,从而实现了地址的重定位。
    
    动态链接器本身也是个共享对象嘛!它的重定位工作哪个来做的呢? 自举!
    自举完成后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表中,称之为全局符号表。然后链接器在 .dynamic段寻找可执行文件所依赖的共享对象,并将这些共享对象的名字放入到一个装在集合中,然后将一个个处理;取出一个共享对象的名字,找到文件并打开,读取ELF文件头和 .dynamic段,将相应的代码段和数据段映射到进程空间;如果这个共享对象还依赖其它共享对象,那么僵所依赖的共享对象名放到装载集合中,如此循环...当所有共享对象都被装载进来时,全局符号表里面将包含进程中所有动态链接所需要的符号。
    共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖,这种现象被称为全局符号介入。
    重定位完成后,如果某个共享对象有 .init段,那么动态链接器会执行 .init段的代码,用以实现共享对象特有的初始化过程,相应地,共享对象中还可能有 .finit段,当进程退出时会执行 .finit的代码。当重定位和初始化完成之后,动态链接器便将进程的控制权转交给程序的入口并且开始执行。
    其实,Linux内核在执行execve()时并不关心目标ELF文件是否可执行,它只是简单的按照程序头表里面的描述对文件进行装载然后把控制权交给ELF文件入口地址(ELF文件没有 .interp就是ELF文件的e_entry,如果有,就是动态链接器的e_entry)。
    支持动态链接的系统,往往都支持一种更加灵活的模块加载方式,叫做显示运行时链接,有时候也叫运行时加载。就是让程序自己在运行时控制价在指定的模块,并且可以在不需要时将其卸载

你可能感兴趣的:(动态链接)