程序员的自我修养——动态链接
动态链接的步骤和实现
动态链接基本上分为3步:先是启动动态链接器本身,然后装载所需要的共享对象,最后是重定位和初始化。
自举
动态链接器本身也是一个共享对象,但实际上对于普通共享对象文件来说,它的重定位工作有动态链接器来完成;它也可以依赖别的共享对象,其中被依赖的共享对象又由动态链接器来负责装载和链接。那对于动态链接器本身的重定位工作由谁来完成?它是否可以依赖于别的共享对象。
很明显,动态链接器由于特殊性,它本身不可以依赖任何其他共享对象,其次是动态链接器本身所需要的全局和静态变量的重定位工作由它自己来完成,这就使得动态链接器在启动的时候必须要有一段非常精巧的代码来完成这项工作,同时不使用全局变量和静态变量,这种具有一定的限制条件的启动代码往往被称为自举(Bootstrap)
动态链接器入口地址即是自举代码的入口,当操作系统将进程交给动态链接器的时候,动态链接器的自举代码开始执行。自举代码首先找到自己的GOT,而GOT的第一个入口保存的即是.dynamic
段的偏移地址,因此找到动态链接器本身的.dynamic
段,通过其中的信息自举代码便可以获得动态链接器本身的重定位表和符号表,从而得到动态链接器本身的重定位入口,先将它们全部重定位,从这一步开始,动态链接器中的代码才可以开始使用自己的全局变量和静态变量。
自举代码也不能使用自己的函数,因为使用PIC(地址无关代码)模式编译的时候,对于模块内部的函数调用也是采用模块外部函数调用一样的处理方式(GOT/PLT),所以在GOT/PLT没有被重定位之前,自举代码不能使用任何全局变量,也不能调用任何函数。
装载共享对象
完成自举后,动态链接器将可执行文件和链接器本身的符号全部合并到一个符号表中,我们可以称它为全局符号表(Global Symbol Table),然后链接器开始寻找可执行文件所依赖的共享对象(通过DT_NEEDED),链接器将这些共享对象的名字放到一个装载集合当中,一个一个打开文件从中读取相应的 ELF 文件头和 .dynamic
段,然后将它相应的代码段和数据段映射到进程空间中。如果这个ELF文件也依赖于其他的共享对象,那么往往采用广度优先的装载顺序。当所有的共享对象都被装载进来的时候,全局符号表里面就包含了进程的所有的动态链接所需要的符号。
符号优先级
1 | /* a1.c */ |
然后我们在编译时指定依赖关系:
1 | $ gcc -fPIC -shared a1.c -o a1.so |
当有程序同时使用 b1.c 和 b2.c 中的函数时会怎么样?
注:这里也需要-Xlinker -rpath ./
否则 a1.so
那里显示的是未找到(not found),这会导致后面的链接无法执行。
1 | /* main.c */ |
指定共享对象进行编译链接: gcc main.c b1.so b2.so -o main -Xlinker -rpath ./
运行发现竟然打印了两个 a1.c,这四个共享对象应该都被装载进来了,但为什么 a2.so 中的 a 函数被忽略了。这种一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象又被称为共享对象全局符号介入(Global Symbol Interpose)。
在 Linux 下动态链接器针对全局符号介入的处理方式是:如果符号名相同并且已经存在,后入的符号被忽略。安照广度优先的顺序进行装载,首先是 main,然后是 b1.so b2.so a1.so 最后是a2.so,所以后面的被忽略了。当程序使用大量的共享对象的时候应当非常小心符号的重名问题。
重定位和初始化
当上面的步骤都完成的时候,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的 GOT/PLT 中每个需要重定位的位置进行修正。因为此时动态链接器已经有了进程的全局符号表,所以修正过程相对容易。重定位完成后,如果某个共享对象有 .init
段,那么动态链接器会执行 .init
段的代码,用以实现共享对象特有的初始化过程。比如最常见的,共享对象中的 C++ 的全局/静态对象的构造就需要通过 .init
来初始化。相应的,共享对象中还可能有 .finit
段,当程序退出时会执行 .finit
段中的代码,可以用来实现 C++ 全局对象析构之类的操作。
Linux 动态链接器的实现
内核在装载完 ELF 可执行文件之后就返回用户空间,将控制权交给程序的入口。对于不同链接形式的 ELF 可执行文件来说,程序的入口存在区别。对于静态可执行文件来说,程序的入口就是 ELF 文件头中 e_entry 指定的入口,对于动态链接,这个入口是不行的,因为可执行文件所依赖的共享库还没有被装载,也没有进行动态链接。对于动态链接的可执行文件,内核会分析它的动态链接器地址(在 .interp
段),将动态链接器映射至进程地址空间,然后把控制权交给动态链接器。
Linux 的动态链接器本身是一个共享对象,它的路径 /lib/ld-linux.so.2
指向 /lib/ld-x.y.z.so
这个才是真正的动态连接器文件。共享对象也是一个 ELF 文件,它也有跟可执行文件一样的 ELF 文件头。动态链接器不仅是一个共享对象,也可以执行。
$ /lib/ld-linux.so.2
Usage: ld.so [OPTION]… EXECUTABLE-FILE [ARGS-FOR-PROGRAM…]
You have invokedld.so', the helper program for shared library executables. This program usually lives in the file
/lib/ld.so’, and special directives
in executable files using ELF shared libraries tell the system’s program
loader to load the helper program from this file.
…
Linux 的内核在执行 execve() 时不关心目标 ELF 文件是否可执行(文件头 e_type 是 ET_EXEC 还是 ET_DNY),它只是简单按照程序头表里面的描述对文件进行装载然后把控制权转交给 ELF 入口地址,这点也能看出共享库和可执行文件实际上没有什么区别,除了文件头的标志位和扩展名有所不同。
几点问题:
- 动态链接器本身是动态链接还是静态链接?
- 动态链接本身是静态链接,它不能依赖于其他共享对象,动态链接器本身是用于帮助其他 ELF 文件解决共享对象的依赖问题。
$ ldd /lib/ld-linux.so.2
statically linked
-
动态链接器本身必须是 PIC 的吗?
- 是不是 PIC 对于动态链接器本身来说并不关键,动态链接器可以是也可以不是 PIC,但往往使用 PIC 会更加简单一些。如果不是 PIC 的话,会使得代码段无法共享,浪费内存,另一方面也使得 ld.so 本身初始化更加复杂。因为自举时要对代码进行重定位。实际上 ld-linux.so.2 是 PIC 的。
-
动态链接器本身可以被当做可执行文件运行,那么它的装载地址应该是多少?
- ld.so 的装载地址跟一般的共享对象没区别,即为 0x00000000。这个装载地址是一个无效的装载地址,作为一个共享库,内核在装载它时会为其选择一个合适的装载地址。
显示运行时链接
支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显示运行时链接(Explicit Run-time Linking),有时也叫做运行时加载。也就是让程序自己在运行时控制加载指定的模块,并且在不需要该模块的时候将其卸载。这种共享对象往往被叫做动态装载库(Dynamic Loading Library)。
最常见的例子是 WEB 服务器程序。对于 WEB 服务器程序来说,它需要根据配置来选择不同的脚本解释器,数据库链接驱动等,对于不同的脚本解释器分别做成一个独立的模块,当 WEB 服务器需要某种脚本解释器的时候就将其加载进来,这对于数据库连接的驱动程序也是一样的原理。
动态装载库通过一系列动态链接器提供的 API 来实现装载,这些 API 的实现在 libdl.so.2 里面。
dlopen()
void *dlopen(const char *filename, int flag)
- 第一个参数是被加载的动态库路径,如果这个路径是绝对路径,该函数会尝试直接打开该库,否则会尝试以一定的顺序查找该动态库文件。
- flag 表示函数符号的解析方式,有RTLD_LAZY(延迟绑定), RTLD_NOW(模块被加载时立即完成绑定)
dlopen() 返回被加载模块的句柄,如果被加载过了会返回同一个句柄,加载失败返回 NULL,若存在依赖关系则需要手动加载被依赖模块。
dlsym()
void *dlsym(void *handle, char *symbol)
- 第一个参数 handle 就是 dlopen() 返回的动态库的句柄,第二个参数即要查找的符号的名称,一个以
'\0'
结尾的 C 字符串。如果没找到,返回 NULL,查找函数,返回函数的地址,查找变量,返回变量的地址,查找常量,返回常量的值。需要注意的是如果 dlerror() 返回NULL,说明找到符号了,否则返回响应的信息。
dlerror()
- 每次调用后都可以调用它来看看上一次调用是否成功,返回NULL表示成功,返回字符串表示上次调用的错误信息
dlclose()
- 卸载一个已经装载的模块。系统会维护一个加载引用计数器,每次加载一个新模块的时候相应的计数器 +1,卸载时 -1。只有当计数器的值减到0时模块才会真正的被卸载。卸载过程与加载相反,先执行
.finit
代码,然后将符号从符号表中去除,取消进程空间跟模块的映射关系,然后关闭该模块。
小例子
1 |
|
#include<stdio.h>
#include<dlfcn.h>
int main(int argc, char* argv[]){
void *handle;
double (*func)(double);
char *error;
handle = dlopen(argv[1], RTLD_NOW);
if (handle == NULL){
printf("Open library %s error: %s\n", argv[1], dlerror());
return -1;
}
func = dlsym(handle, "sin");
if ((error = dlerror()) != NULL){
printf("Symbol sin not found: %s\n", error);
}
else{
printf("%f\n", func(3.1415926/2));
}
dlclose(handle);
}
使用数学库模块用运行时加载的方式加载到进程中,然后获取 sin() 函数符号地址,调用 sin() 返回结果
gcc -o simple_run simpe_run.c -ldl
./simple_run /lib/x86_64-linux-gnu/libm-2.27.so
ps:
- -ldl 表示使用 DL(Dynamic Loading) 库
- 原书中适用于32位系统,当对于64位系统的时候,要正确的找到自己 libm 库的位置,若出现错误
wrong ELF class: ELFCLASS32
说明你使用的动态库是32位的。
程序员的自我修养——动态链接