关于 so 的一些笔记

现在的程序越来越复杂,由多个模块构成,如果把所有的模块和依赖都编译到一个单一的可执行文件中,不仅文件体积很大,而且也不利于模块更新;而且有些基础模块可以被多个程序共用,没必要各个程序都打包一份,因此就有了动态链接库。顾名思义,动态链接库就是可以动态地进行链接,在程序需要的时候才会进行加载,并且这份代码在内存里是共享的,在 Windows 中叫“Dynamic Link Library”,后缀是 dll,Linux 上叫“Shared Object”,后缀一般是“so”。

Hello, world!

下面是经典的打印“Hello, world!”的例子:

#ifndef __HELLO_H__
#define __HELLO_H__

void print(void);

#endif
/* hello.c */

#include <stdio.h>

void print(void)
{
    printf("Hello, world!\n");
}
/* main.c */

#include "hello.h"

int main(void)
{
    print();

    return 0;
}

如果是编译成用于静态链接的 .o 文件,只需使用命令

gcc -c hello.c

即可;如果要把 hello.c 作为一个模块编译成一个 .so,需要使用以下命令:

gcc -fPIC -c hello.c
gcc -shared hello.o -o libhello.so

也可以只用一条命令,这样就不会生成 .o 文件:

gcc -fPIC -shared -o libhello.so hello.c

选项“PIC”是“position-independent code”的缩写,“shared”选项用来生成一个可用于动态链接的 .so。最后编译成可执行的文件 a.out:

gcc main.c -L. -lhello

选项“-L”后跟目录名,告诉 gcc 去哪个目录找需要的包含函数定义的 .so 文件;“-l”后跟需要加载的 .so 名称。一般来说,.so 的命名都是“lib{soname}.so”,其中的“{soname}”就是加载时指定的名称,例如这里生成了 libhello.so,“-l”后跟的名称就是“hello”。

运行时加载.so

如果直接运行上面生成的 a.out 的话,很有可能得到一条报错信息:

./a.out: error while loading shared libraries: libhello.so: cannot open shared object file: No such file or directory

这是因为当前目录并不在 loader 查找 .so 的路径中,就跟运行 a.out 要指定一下当前目录“./”一样。除非把生成的 .so 放到系统预设的查找路径中(一般是“/lib”和“/usr/lib”),否则在运行程序前需要先设置查找 .so 的路径。使用命令“ldd a.out”查看依赖关系可以看到这样的输出:

libhello.so => not found

一般来说设置 .so 查找路径有 3 种方法。

第一种方法是在 /etc/ld.so.conf 里配置,每行一个路径,修改完文件后运行命令“ldconfig”使设置生效,详细的设置可以看一下 ldconfig(8) 里的说明。这种方法是设置全局查找路径的,会对整个系统的环境产生影响,而且对于没有 root 权限的用户来说也行不通。

第二种方法是通过设置环境变量 LD_LIBRARY_PATH。一般来说这个变量的值都是空的,如果需要在运行某个特定的程序时加载特定的 .so,可以写一个启动脚本(参考资料 [1]):

#!/bin/bash

export LD_LIBRARY_PATH=/path/to/so:$LD_LIBRARY_PATH
./myprogram $*

这样就不会对当前的登录环境产生影响。注意这个变量是给 loader 用的,不是给编译器用的,因此编译时还是要加“-L”和“-l”。

第三种是在编译时指定查找路径。在链接时可以使用选项“-Wl”来指定 .so 的路径(参考资料 [2]):

gcc -L. -lhello main.c -Wl,-rpath,\$ORIGIN

选项“-Wl”用来给 linker 传递参数,后面的参数以逗号分割,例如“-Wl,key,value”的意思是把“key=value”传给 linker,详细说明可以看一下 gcc 的文档。上面的命令把 libhello.so 的绝对路径传给了 linker,编译后用“ldd a.out”可以查看到类似的输出:

libhello.so => /path/to/libhello.so

这种情况需要把 .so 放在固定的位置。另外也可以把参数“$ORIGIN”替换成“.”,这样只要把 .so 和可执行文件都放在同一目录下就行了,当然也可以指定别的相对目录路径。

既然有三种不同的设置查找 .so 路径的方法,如果在不同的路径下有同样名字的 .so 会怎样呢?按照 Linux 上离用户最近的设置优先的习惯,优先级从高到低的顺序应该是:编译时指定的路径 > LD_LIBRARY_PATH指定的路径 > /etc/ld.so.conf 指定的路径 > 默认的路径。

与 .so 相关的环境变量

  • LD_LIBRARY_PATH: 用来设置.so查找路径,路径用冒号分隔。
  • LD_DEBUG(参考资料 [2]): 有一系列的字符串取值。如果设置为“files”,则会打印出运行程序时加载 .so 的顺序;设置为“bindings”则会打印出其中哪些函数来自于哪个 .so;设置“libs”则会打印出查找 .so 的路径先后顺序;设置为“versions”会打印 .so 的版本依赖;设置为“help”可以查看所有可用的选项以及解释。
  • LD_PRELOAD(参考资料[3]): 这个变量可以用来指定预先加载的 .so。例如重新实现 puts() 函数(puts.c):
#include <unistd.h>
#include <string.h>

int puts(const char* format)
{
    const char* str = "Hello, ouonline.net.\n";

    return write(1, str, strlen(str));
}

编译后得到 .so,然后测试一下:

#include <stdio.h>

int main(void)
{
    puts("oops.\n");

    return 0;
}

按照上面的方法先把 puts.c 编译成 libputs.so,对于 main.c 可以正常编译。当还没设置 LD_PRELOAD 时,打印的内容是正常的;当设置了

export LD_PRELOAD="./libputs.so"

后会发现打印的内容变成了我们自定义的字符串。利用这个环境变量可以换掉一些库函数,从而达到窃取程序内容的目的。

当然一件工具可以用来干坏事,也可以用来干好事。例如每个 C 程序员都会头疼的内存泄漏问题,可以在调试时重新实现 malloc() 函数添加必要的调试信息,然后设置 LD_PRELOAD,这样既可以达到调试的目的,也不需要重新编译源代码。也可以用 LD_PRELOAD 把默认的 malloc() 替换成 jemalloc 等。

.so 编程接口

前面介绍的加载 .so 的路径方法都是在程序启动时就把 .so 加载进来,既然 .so 是能够动态加载的,Linux 提供了几个与 .so 操作相关的 API,能让程序决定什么时候加载 .so。这里使用了上面的 libhello.so:

#include <stdio.h>
#include <dlfcn.h>

int main(void)
{
    void* handle;
    void (*func)(void);
    const char* errmsg;

    handle = dlopen("./libhello.so", RTLD_NOW);
    if (!handle) {
        fprintf(stderr, "%s\n", dlerror());
        return -1;
    }

    func = dlsym(handle, "print");
    errmsg = dlerror();
    if (errmsg) {
        fprintf(stderr, "%s\n", errmsg);
        goto end;
    }

    func();

end:
    dlclose(handle);
    return 0;
}

这里用到的函数都在头文件 dlfcn.h 中定义,编译的时候要加上链接选项“-ldl”。程序首先调用了

void *dlopen(const char *filename, int flag);

加载需要的 .so。其中第一个参数 filename 是 .so 的文件名,flag 是一些可以用或操作连接起来的选项。

当 filename 为 NULL 的时候,返回的 handle 指向的就是主程序本身。

flag 必须包含 RTLD_LAZY 或 RTLD_NOW 中的其中一个。RTLD_LAZY 表示加载 .so 时不对其中的函数的有效性进行检查,也就是说如果某个函数一直没有被使用,尽管这个没有实现也不会报错;RTLD_NOW 表示在加载 .so 时就对所有的函数进行有效性检查,如果某个函数没有定义或找不到定义就报错。这两个选项都是对于函数符号而言的,对于一般的变量符号总是会进行有效性检查。

另外 flag 还有一些可选项。RTLD_GLOBAL 表示导出当前 .so 中的符号为全局可见,这样后续加载的 .so 都能看到当前加载的 .so 中的符号;RTLD_LOCAL 和 RTLD_GLOBAL 正好相反,当前 .so 中的符号只在本 .so 中可见,这也是默认值。另外还有几个 glibc 提供的选项,但是 POSIX 中没有规定,详细可以看 dlopen(3)。

如果 dlopen() 出错,函数

char *dlerror(void);

返回上一次出错时的出错信息。dlopen() 成功后,使用函数

void *dlsym(void *handle, const char *symbol);

获取需要的符号。第一个参数 handle 就是 dlopen() 的返回值,第二个参数是要获取的符号名称,返回值是指向该符号的指针。在上面的例子中,dlsym() 查找名为“print”的符号(是一个函数),然后调用了这个函数。

如果要查找的符号的值可能就是一个 NULL,可以通过 dlerror() 的返回值来判断符号是否存在,如果能确定要查找的符号不可能为 NULL,可以直接通过返回值来判断。

当不需要某个 .so 后可以使用函数

int dlclose(void *handle);

来卸载该 .so(就是引用计数减1,如果为0才会真正从内存中释放掉)。

构造函数和析构函数

这里主要是针对 gcc 的扩展而言的。当 .so 被 dlopen() 加载时会执行被 __attribute__((constructor)) 修饰的函数,而 dlclose() 时会执行被 __attribute__((destructor)) 修饰的函数。

其它

由于 .so 可以动态加载更换,在某种程度上可以实现代码的“热修改”,例如把数据都放在主程序中,.so 中只有处理逻辑,这样当修改了处理逻辑后只需重新加载 .so 即可;也可以使用 inotify(看看 这里)来监控 .so 的变化从而达到自动加载的效果,不过要小心误操作。

参考资料

[1] Shared Libraries
[2] Linux Shared Object Tutorial
[3] 警惕UNIX下的LD_PRELOAD环境变量
[4] Dynamically Loaded (DL) Libraries

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注