动态链接的步骤与实现

December 09, 2023
测试
测试
测试
测试
10 分钟阅读

1. 动态链接器的自举

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

这是一个“鸡生蛋,蛋生鸡”的问题,为了解决这种无休止的循环,动态链接器这个“鸡” 必须有些特殊性。首先是,动态链接器本身不可以依赖于其他任何共享对象;其次是动态链接器本身所需要的全局和静态变量和重定位工作由它本身完成。对于第一个条件我们可以认为的控制。在编写动态链接器时必须保证不使用任何系统库,运行库;对于第二个条件,动态链接器必须在启动时有一段非常精巧的代码可以完成这项艰巨的工作而同时又不能使用全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举(Bootstrap)。

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

实际上在动态链接器的自举代码中,除了不可以使用全局变量和静态变量之外,甚至不能调用函数,即动态链接器本身的函数也不能调用。这是为什么呢?其实我们在前面分析地址无关代码时已经提到过,实际上使用PIC模式编译的共享对象,对于模块内部的函数调用也是采用跟模块外部函数调用一样的方式,即使用 GOT/PLT的方式,所以在 GOT/PLT没有被重定位之前,自举代码不可以使用任何全局变量,也不可以调用函数。下面这段注释来自于 Glibc26.1源代码中的 elf/rtld.c

这段注释写在白举代码的末尾,表示自举代码已经执行结束。“ Now life is sane",可以想象动态链接器的作者在此时大舒一冂气,终于完成白举了,可以自由地调用各种函数并且随意访问全局变量了,

2. 装载共享对象

完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表( Global Symbol Table)。然后链接器开始寻找可执文件所依赖的共享对象,我们前面提到过“.dynamic”段中,有一种类型的入口DT_NEEDED,它所指出的是该可执行文件(或共享对象)所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后链接器开始从集合里取个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和“ .dynamic”段,然后将它相应的代码段和数据段映射到进程空间中。如果这个ELF共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止,当然链接器可以有不同的装载顺序,如果我们把依赖关系看作一个图的话,那么这个装载过程就是一个图的遍历过程,链接器可能会使用深度优先或者广度优先或者其他的顺序来遍历整个图,这取决于链接器,比较常见的算法一般都是广度优先的。

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

符号的优先级

在动态链接器按照各个模块之间的依赖关系,对它们进行装载并且将它们的符号并入到全局符号表时,会不会有这么一种情况发生,那就是有可能不同的模块定义了同一个符号?让我们来看看这样一个例子:共有4个共享对象a1.so,a2.so, b1.so, b2.so,它们的源代码文件分别为a1.c, a2.c, b1.c 和 b2.c

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

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

/*b1.c*/
#include <stdio.h>
void b1() {
    printf("b1.c\n");
}

/*b2.c*/
#include <stdio.h>
void b2() {
    printf("b2.c\n");
}

可以看到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-gate.so.1 ->  (0xffffe000)
    a1.so -> not found
    libc.so.6 -> /lib/tls/i686/cmov/libc.so.6 (0xb7e86000)
    /lib/ld-linux.so.2 (0x80000000)

$ldd b2.so
    linux-gate.so.1 ->   (0xffffe000)
    a2.so   ->  not found
    libc.so.6  -> /lib/tls/i686/cmov/libc.so.6  (0xb7e17000)
    /lib/ld-linux.so.2 (0x80000000)

那么当有程序同时使用b1.c中的函数b1和b2.c中的函数b2会怎么样呢?比如有程序

main.c

#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 ./
./main
a1.c
a1.c

很明显,main依赖于b1.so和b2.so;b1.so依赖于a1.so;b2.so依赖于a2.so,所以当动态链接器对main程序进行动态链接时,b1.so、b2.so、a1.so和a2.so都会被装载到进程的地址空间,并且它们中的符号都会被并入到全局符号表,通过查看进程的地址空间信息可以看到:

这4个共享对象的确都被装载进来了,那a1.so中的函数a和a2.so中的函数a是不是冲突了呢?为什么main的输出结果是两个“al.c”呢?也就是说a2.so中的函数a似乎被忽略了。这种一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象又被称为共享对象全局符号介入(Global symbol interpose)

关于全局符号介入这个问题,实际上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()函数不被其他模块覆盖,就可以使用第一类的方法,即模块内部调用指令,可以加快函数的调用速度。

3. 重定位与初始化

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

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

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

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

4. 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当作可执行文件运行。

Linux的ELF动态链接器是Glbc的一部分,它的源代码位于Glibc的源代码的elf目录下面,它的实际入口地址位于 sysdeps/i386/d1-manchine.h中的__start(普通程序的入口地址start()在 sysdeps/i386/elf/start.S,本书的第4部分还会详细分析)

start调用位于 elf/rtld.c的_dl_start函数。dl start函数首先对ldso(以下简称ld x.y.z.so为ld.so)进行重定位,因为ld.so自己就是动态链接器,没有人帮它做重位工作,所以它只好自己来,美其名曰“自举”。自举的过程需要十分的小心谨慎,因为有很多限制.这个我们在前面已经介绍过了。完成自举之后就可以调用其他函数并访问全局变量了。调用_dl_start_final,收集一些基本的运行数值,进入_ dl_sysdep_start,这个函数进行一些平台相关的处理之后就进入了 _dl_main,这就是真正意义上的动态链接器的主函数了。 _dl_main在一开始会进行一个判断:

很明显,如果指定的用户入口地址是动态链接器本身,那么说明动态链接器是被当可 执行文件在执行。在这种情况下,动态链接器就会解析运行时的参数,并且进行相应的处理_dl_main本身非常的长,主要的工作就是前面提到的对程序所依赖的共享对象进行装载、符号解析和重定位,我们在这里就不再详细展开了,因为它的实现细节又是一个非常大的话题

关于动态链接器本身的细节实现虽然不再展开,但是作为一个非常有特点的,也很特殊的共享对象,关于动态链接器的实现的几个问题还是很值得思考的:

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

动态链接器本身应该是静态链接的,它不能依赖于其他共享对象,动态链接器本身是用来帮助其他ELF文件解决共享对象依赖问题的,如果它也依赖于其他共享对象,那么谁来帮它解决依赖问题?所以它本身必须不依赖于其他共享对象。这一点可以使用ldd来判断: $ ldd /lib/ld-linux so 2 statically linked

  1. 动态链接器本身必须是PC的吗?

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

  1. 动态链接器可以被当作可执行文件运行,那么的装载地址应该是多少?

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

继续阅读

更多来自我们博客的帖子

如何安装 BuddyPress
由 测试 December 17, 2023
经过差不多一年的开发,BuddyPress 这个基于 WordPress Mu 的 SNS 插件正式版终于发布了。BuddyPress...
阅读更多
Filter如何工作
由 测试 December 17, 2023
在 web.xml...
阅读更多
如何理解CGAffineTransform
由 测试 December 17, 2023
CGAffineTransform A structure for holding an affine transformation matrix. ...
阅读更多