CXD Linux Engineer

动态链接的应用

2018-09-27

显示运行时链接

支持动态链接的系统一般都支持一种更加灵活的模块加载方式,叫做显示运行时链接,有时也叫做运行时加载
这种运行时加载使得程序的模块组织变得很灵活,可以用来实现一些诸如插件、驱动等功能。 当程序需要用到某个插件或者驱动的时候,才将相应的模块装载进来,而不需要从一开始就将他们全部装载进来,从而减少了程序的启动时间和内存使用。 并且程序可以在运行的时候重新加载某个模块,这样使得程序本身不必重新启动而实现模块的增加、删除、更新等。

动态库的装载是通过一系列由动态链接器提供的API来完成的:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)以及关闭动态库(dlclose), 程序可以通过这几个API对动态库进行操作。这几个API的实现是在/lib/libdl.so.2里面,他们的声明和相关常量被定义在系统标准头文件<dlfcn.h>中。

dlopen()

dlopen()函数用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程,他的C原型定义为: void *dlopen(const char *filename, int flag); 第一个参数是被加载动态库的路径,如果这个路径是绝对路径则该函数将会尝试直接打开该动态库;如果我们将filename这个参数设置为0, 那么dlopen返回的将是全局符号表的句柄,也就是说我们可以在运行时找到全局符号表里面的任何一个符号,并且可以执行他们,这有些类似高级语言反射的特性。 全局符号表包括了程序的可执行文件本身、被动态链接器加载到进程中的所有共享模块以及在运行时通过dlopen打开并且使用了RTLD_GLOBAL方式的模块中的符号。

第二个参数flag表示函数符号的解析方式,常量RTLD_LAZY表示使用延迟绑定,当函数第一次被用到时才进行绑定(绑定的意思是在动态库加载时进行符号重定位), 即PLT机制;而RTLD_NOW表示当模块被加载时即完成所有的函数绑定工作,如果有任何未定义的符号引用的绑定工作没法完成,那么dloopen()就会返回错误。 另外还有一个常量RTLD_GLOBAL可以跟上面的两者中任意一个一起使用(通过常量的“或”操作)。他表示将被加载的模块的全局符号合并到进程的全局符号表中, 使得以后加载的模块可以使用这些符号。

dlopen的返回值是被加载的模块的句柄,这个句柄在后面使用dlsym和dlclose时会用到。在完成装载、映射和重定位以后,dlopen会执行.init段的代码来初始化模块然后返回。

dlsym()

dlsym函数基本上是运行时装载的核心部分,我们可以通过这个函数找到所需要的符号。他的定义如下: void *dlsym(void *handle, char *symbol); 第一个参数是有dlopen()返回的动态库的句柄;第二个参数即所要查找的符号的名字,一个以\0结尾的C字符串。 如果dlsym()找到了相应的符号则返回该符号的值;没有找到则返回NULL。dlsym()返回的值对于不同类型的符号,意义是不同的。 如果查找的符号是函数则返回的是函数地址;如果是变量则返回的是变量的地址;如果这个符号是常量则返回的是该常量的值。 这里有一个问题:如果常量的值刚好是NULL或者0呢?我们怎么判断dlsym()是否找到了改符号了?这就要用到dlerror()函数了, 如果符号找到了,那么dlerror()返回NULL,如果没有找到,dlerror()返回相应的错误信息。

符号优先级

当多个共享模块中有符号名冲突时,先装入的符号优先,我们把这种优先级方式称为装载序列。那么当我们的进程中有模块是通过dlopen()装入的共享对象是, 这些后装入的模块中的符号可能会跟先前已经装入的模块之间的符号重复。这种情况下动态连接器在进行符号解析以及重定位时,都是采用装载序列。

dlsym()对符号的查找优先级分两种类型。第一种情况是,如果我们是在全局符号表中进行符号查找,即dlopen()的filename参数为NULL, 那么由于全局符号表使用的装载序列,所以dlsym()使用的也是装载序列。第二中情况是如果我们是对某个通过dlopen()打开的共享对象进行符号查找的话,那么采用的是一种叫做依赖序列的优先级。 什么叫依赖序列呢?它是以被dlopen()打开的那个共享对象为根节点,对它所有依赖的共享对象进行广度优先遍历,直到找到符号为止。

dlclose()

dlclose()的作用跟dlopen()刚好相反,用于卸载已经加载的模块。系统会维持一个加载引用计数器,没次使用dlopen()加载某模块时,相应的计数器加一; 每次使用dlclose()卸载某模块时,相应的计数器减一。只有当计数器值减为0时,模块才被真正的卸载掉。卸载过程是先执行.finit段的代码,然后将相应的符号从符号表中去除, 取消进程空间跟模块的映射关系,然后关闭模块文件。

示例程序

这段程序将数学库模块用运行时加载的方法加载到进程中,然后获取sin()函数符号地址,调用sin()并且返回结果:

#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);
        dlclose(handle);
        return -1;
    }

    printf(" %f\n", func(3.1415926/2));
    dlclose(handle);
    return 0;
}

环境:Ubuntu
编译:gcc test2.c -o test2 -ldl
执行:./test2 /lib/x86_64-linux-gnu/libm-2.23.so
输出:1.000000

参考

这篇文章主要是看《程序员的自我修养-链接、装载与库》这本书的笔记,经常在一些大型项目中看到使用这种方式给程序写插件, 而且可以通过这种方式来hook系统中的库函数来实现一些比较牛逼的特性,比较著名的是微信libco网络库 或者之前分析过SNG的SPP微线程框架
至于hook的原理网上已经有很多介绍:libco hook原理简析


上一篇 端口复用

Comments

Content