42 共享库高级特性
动态加载库:dlopen API
#include <dlfcn.h>
void *dlopen(const char *filename, int flags);
int dlclose(void *handle);
使用 dlopen API 要给 gcc 传递 -ldl
参数使其链接到 libdl.so。
dlopen()
每个被加载的库会有一个引用计数,一个库被加载时,它依赖的其他库(被称为依赖树)会被自动加载,它们的引用计数增加;卸载时引用计数又会减少,归零时才真正意义上卸载共享库。
filename 参数
- 如果 filename 包含 /,那么按照文件路径查找;否则在标准的路径中按照 41 章描述的顺序查找。
- 如果 filename 为
NULL
,那么返回主程序的句柄,也就是“全局符号对象”(global symbol object)。
在全局符号对象中符号的搜索顺序
如果用全局符号对象的句柄作为参数调用 dlsym()
,那么会:
- 先在主程序中搜索符号
- 然后在程序启动时加载的共享库中按顺序搜索
- 最后在所有使用了
RTLD_GLOBAL
标志加载的共享库中按顺序搜索。
解析共享对象中的符号时的搜索顺序
加载时没 RTLD_LAZY
立即解析,有则延后函数解析、立即变量解析。
Symbol references in the shared object are resolved using (in order):(这一段话来自 man 手册)
- symbols in the link map of objects loaded for the main program(不包括主程序本身!) and its dependencies;
- symbols in shared objects (and their dependencies) that were previously opened with
dlopen()
using theRTLD_GLOBAL
flag;- and definitions in the shared object itself (and any dependencies that were loaded for that object).
有了这样的解析顺序,想要在不重新链接程序的情况下替换 malloc 的实现为 jemalloc,可以使用 LD_PRELOAD
环境变量(格式和 PATH
、LD_LIBRARY_PATH
差不多)指定 jemalloc 共享库的路径(比如 LD_PRELOAD=/path/to/jemalloc.so /bin/ls
),这样就能把它作为额外的依赖注入在依赖列表的最前面,找 malloc 的时候会首先找到 jemalloc 中的实现。
如果不使用环境变量,且要让程序使用 jemalloc 的共享库版本,可以将可执行程序连接到 jemalloc 库。如果命令行中没有显式出现 libc,那么 libc 就是最后被链接的;如果出现了 libc 则要写在对 jemalloc 链接的后面!
flags 参数
flags 参数可以取以下标志:
RTLD_LAZY
:对于函数符号,在代码执行的时候才解析。(变量仍然是加载时就解析。)RTLD_NOW
:在dlopen()
结束之前就加载库中的符号。如果环境变量LD_BIND_NOW
是非空字符串,那么会忽略RTLD_LAZY
标志,始终采用RTLD_NOW
标志,即环境变量LD_BIND_NOW
有更高的优先级。RTLD_GLOBAL
:将加载到的符号共享给进程加载的其他库的符号解析过程。RTLD_LOCAL
:和RTLD_GLOBAL
相反。RTLD_NODELETE
:在引用计数归零的时候不要卸载库。这样卸载库之后再重新加载就不会反复初始化静态变量。(gcc -Wl,-znodelete
对于动态链接器自动加载的库而言有相似的效果。这里用-Wl,-z,nodelete
是一样的。)RTLD_NOLOAD
:不加载库。作用有两个:1. 检查是否库是否已经加载(没有加载会返回NULL
);2. 修改已经加载的库的 flags。RTLD_DEEPBIND
:在解析这个库中的符号引用时先搜索库中的定义,然后再搜索已加载的库中的定义,有点类似于创建共享库的时候就把-Bsymbolic
参数传递给了ld
。
dlerror()
如果上一次用 dlopen API 出现了错误,那么 dlerror()
会返回一个描述错误的字符串。如果没有错误 dlerror()
返回 NULL
。
有意调用 dlerror()
并忽略返回值,则可以将已经记录的错误删除(有点类似于将 errno
置 0)。
dlsym()
#include <dlfcn.h>
void *dlsym(void *restrict handle, const char *restrict symbol);
#define _GNU_SOURCE
#include <dlfcn.h>
void *dlvsym(void *restrict handle, const char *restrict symbol,
const char *restrict version);
dlsym()
返回的结果是无类型的,需要强制转换才能使用。
在一些没有错误的情况下,dlsym()
也可能返回 NULL
,这和运行出现错误无法通过返回值区分,因此我们在调用 dlsym()
之后要用 dlerror()
来检查错误。
一个符号地址为 NULL
的正常情况包括:
- symbol 真的就是被放在 0 地址的(这需要专门的链接脚本或
--defsym
命令行选项)。 - 未定义的 weak symbol 地址为 NULL。
- Finally, the symbol value may be the result of a GNU indirect function (IFUNC) resolver function that returns
NULL
as the resolved value.
不过,dlopen()
在前两种情况下会认为出错从而设置 dlerror()
的错误。第三种情况是无错但是返回 NULL 的。
C99 中使用 dlsym()
的问题
在 C99 中,void *
类型不能和函数指针类型互相转换,开启 -pedantic
选项就会给出警告信息:
warning: ISO C forbids conversion of object pointer to function pointer type [-Wpedantic]
C++ 就不会有这种警告。书上给出的解决方案是不转换赋值号右边的类型,而是把赋值号左边的类型转换了:
#include <dlfcn.h>
int
main(int argc, char *argv[])
{
...
void (*funcp)(void); /* Pointer to function with no arguments */
...
*(void **) (&funcp) = dlsym(libHandle, argv[2]);
}
https://stackoverflow.com/a/19487645/ 有相关讨论。
虽然说也可以用 memcpy()
来复制数据,但是会麻烦一点,因为 memcpy()
有 dest
和 src
,其中 dest
是上面的 funcp
,而 src
是目标数据的地址。也就是说我们要先把 dlsym()
的返回值存储到一个变量中,再取其地址作为 src
用于复制数据。
伪句柄 RTLD_DEFAULT
和 RTLD_NEXT
除了使用 dlopen()
返回的指针作为 dlsym()
的句柄参数之外,还可以使用以下伪句柄:
RTLD_DEFAULT
:对应于动态链接库采用的默认搜索类型,也相当于先给dlopen()
传入NULL
作为 filename 参数得到全局符号对象,然后再拿着这个句柄去搜索。RTLD_NEXT
:找到下一个 symbol。
实际上 RTLD_DEFAULT
可能和 dlopen(NULL, 0)
相等(在 debian 虚拟机中测试,结果仅代表特定环境):
#include <stdio.h>
#include <dlfcn.h>
int main() {
void *handle = dlopen(NULL, 0);
if (handle == RTLD_DEFAULT) { printf("equal\n"); }
else { printf("not equal\n"); }
}
对 RTLD_NEXT
的理解:
- Man 手册中说的是:Find the next occurrence of the desired symbol in the search order after the current object.
- TLPI 说的是:Search for symbol in shared libraries loaded after the one invoking
dlsym()
.
这个伪句柄限制了从它之后加载的共享库中搜索 symbol(这一段调用 dlsym(RTLD_NEXT, ...)
的代码在于某个 object 中,要按照搜索顺序,从下一个 object 开始搜索同名 symbol),因此可以用来实现包装函数,TLPI 举的例子是 func = dlsym(RTLD_NEXT, "malloc")
,GNU ld 的 --wrap
选项应该也是通过类似的原理实现的。
RTLD_NEXT
并不能用来迭代目前已知的所有同名 symbols(如果有该多好啊!),只能获得当前共享对象位置后面的第一个符号。需要强制使用特定库中的函数时,则需要先用 dlopen()
打开特定库,然后再将其句柄传给 dlsym()
。相关文章: https://optumsoft.com/dangers-of-using-dlsym-with-rtld_next/ 。
用 dladdr()
获取加载的符号的更多信息
使用 dlsym()
找到符号之后,可以用 dladdr()
获取它的更多信息。有些 UNIX 实现没有提供这个函数。
在静态链接时导出符号到全局作用域
有的时候我们希望通过 dlopen()
加载的共享库中的代码反而能够使用主程序中的符号,这个时候我们就需要在链接主程序的时候将符号导出到全局作用域中。这样 dlsym()
才能找到对应的符号。
gcc -Wl,--export-dynamic main.c
# 或者
gcc -export-dynamic main.c
参考 https://stackoverflow.com/a/36700270/ ,用其中的代码分别在无 -rdynamic
和有 -rdynamic
选项的情况下编译,得到两个文件 prog 和 prog.dyn(为了区分我在名字上加了后缀)。用 readelf -s
去查看两个可执行文件,会发现它们都有 .dynsym 和 .symtab 两张符号表,其中 .symtab 一样长,但是 prog.dyn 的 .dynsym 更长了、增加了 foo / main 等符号(在主函数中定义)。
在代码中控制符号的可见性
共享库符号默认对外可见,主程序中符号默认对外不可见。
如果想要控制共享库中符号对外的可见性,可以使用以下语法:
- 单个文件可见:
static
。 - 库中可见,库外不可见:
__attribute__ ((visibility("hidden")))
,这个是 GNU 扩展语法。
只要库外不可见,就不用担心暴露过多细节在 ABI 中,既防止了用户依赖实现细节,又避免了不重要的符号和后加载的共享库中的公开符号重名、导致其无法被正确绑定(会绑定到第一个)。
此外,如果有以上的可见性说明,在编译共享库的时候见到这样的符号就会立即绑定,就好像只对特定的符号开启了链接器的 -Bsymbolic
选项一样。
补充测试
先创建 foo.c,其中只定义一个变量 int foo = 42;
。然后编译成目标文件,发现 foo 这个符号并不存在于 .dynsym 当中。(不仅如此,整个 .dynsym section 都不存在!)但是将目标文件链接成共享库之后 foo 这个符号就出现在 .dynsym 当中了。
eric@debian:~/attr$ echo "int foo = 42;" > foo.c
eric@debian:~/attr$ gcc -fPIC -c -o foo.o foo.c
eric@debian:~/attr$ readelf -sD foo.o
Dynamic symbol information is not available for displaying symbols.
eric@debian:~/attr$ gcc -fPIC -shared -o libfoo.so foo.o
eric@debian:~/attr$ readelf -sD libfoo.so
Symbol table for image contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __cxa_finalize
2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMC[...]
3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterT[...]
4: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
5: 0000000000004008 4 OBJECT GLOBAL DEFAULT 17 foo
在生成目标文件时指定 -Wl,--export-dynamic
也是没有效果的,因为 -Wl,--export-dynamic
只在链接的时候起作用:
eric@debian:~/attr$ gcc -fPIC -Wl,--export-dynamic -c -o foo.o foo.c
eric@debian:~/attr$ readelf -sD foo.o
Dynamic symbol information is not available for displaying symbols.
观察:将符号从 .symtab 导出到 .dynsym 的过程是在链接过程中做的,在生成目标文件的时候不会处理 .dynsym。生成共享库的时候符号默认会被导出,生成可执行文件时符号默认不被导出。静态库只是对目标文件的打包而已,应该也不涉及 .dynsym。
链接器版本脚本(version script)
用途一:指定符号可见性
在链接成共享库的时候使用版本脚本:
gcc -Wl,--version-script,myscriptfile.map ...
在生成共享库的目标文件(没有链接过程)、或者链接成可执行程序时不需要使用版本脚本。
版本脚本一般用 .map 结尾。举例:
VER_1 {
global:
vis_f1;
vis_f2;
local:
*;
};
这个版本脚本规定了 vis_f1 和 vis_f2 这两个符号对库外可见,而其他符号对库外不可见。这是链接器版本脚本的第一个用途:指定符号的可见性。
用途二:符号版本化
旧的版本脚本为:
VER_1 {
global: xyz;
local: *; # Hide all other symbols
};
旧的共享库代码为:
#include <stdio.h>
void xyz(void) { printf("v1 xyz\n"); }
假设已经有可执行程序 A 链接到了这个(特定版本的)库。
维护多版本的库可以使用 soname:接下来如果想要更新库,且库的 ABI 不兼容旧库,那么按照前一章的内容,可以创建一个具有不同主要版本号的共享库并安装,这样系统中会同时存在新库和老库。还要保证可执行程序 A 之前就是用 soname 链接的,以便它仍然能找到旧版本的共享库进行加载,新程序也能开始使用新版本的库。
另外一种维护共享库多版本的做法是使用版本脚本和符号版本化技术:在同一个库中同时提供新老代码,用汇编代码指定符号版本,然后用版本脚本来控制新增部分的可见性。
新的库代码:
#include <stdio.h>
__asm__(".symver xyz_old,xyz@VER_1");
__asm__(".symver xyz_new,xyz@@VER_2");
void xyz_old(void) { printf("v1 xyz\n"); }
void xyz_new(void) { printf("v2 xyz\n"); }
void pqr(void) { printf("v2 pqr\n"); }
现在新增了函数 pqr()
。xyz()
函数被分成了 xyz_old()
和 xyz_new()
两个版本,并通过汇编指定到了不同的版本。@
后面就跟着一个版本标签,这个版本标签和版本脚本对应,其关联符号的可见性受到对应版本的规则约束。@@
表示默认版本,同一个符号只能有一个带 @@
标签的版本。
新的版本脚本:
VER_1 {
global: xyz;
local: *; # Hide all other symbols
};
VER_2 {
global: pqr;
} VER_1;
VER_2 { global: pqr; } VER_1;
表示 VER_2 依赖 VER_1,其中的符号可见性规则也会继承过来。版本并不是一定要用 VER_ 开头命名,只要易读即可,例如 glibc 会使用 GLIBC_2.0、GLIBC_2.1 的版本名。
如果可执行文件 A 之前就是使用版本脚本进行链接的,那么它需要的符号就会被做上 VER_1 的标记(很显然,旧的版本脚本只有 VER_1 这一个版本,所以无需特别指定就能确定版本)。现在更新共享库之后,共享库同时带有新老代码,A 依然能够找到正确的符号,因为它就是根据 VER_1 来找的。新的程序自然能够链接到 VER_2 版本的 xyz
。
因此,符号版本化也可以替代 soname 作为共享库迭代升级的管理方式。(此时要固定使用同一个 soname,因为动态链接器还是按照 soname 来找库的。)Glibc 从 2.1 版本开始就使用了符号版本化技术,glibc 2.0 以及之后的版本都通过单个主要库版本(libc.so.6)来支持(← soname 也固定了),因而 glibc 有着非常好的兼容性(我看未必,经常遇到依赖问题)。
在我最近编译出来的程序中,可以看到标准库函数有不同的符号版本:
eric@debian:~/rdynamic$ readelf -s prog | grep @GLIBC | awk '/[a-z]+@/ { print $8 }' | sort -u
dlerror@GLIBC_2.34
dlopen@GLIBC_2.34
dlsym@GLIBC_2.34
exit@GLIBC_2.2.5
fprintf@GLIBC_2.2.5
puts@GLIBC_2.2.5
stderr@GLIBC_2.2.5
Note
C++ 有 name mangling,如果做符号版本化就得使用名字重整之后的符号名,可能会有比较棘手的问题。我记得 PyTorch 源码中有个文件就包含了大量的重整后符号名。
验证书上例子
eric@debian:~/symver$ gcc -fPIC -shared -o libxyz.so xyz.c -Wl,--version-script,v1.map
eric@debian:~/symver$ gcc -fPIC -o prog1 main.c libxyz.so
eric@debian:~/symver$ LD_LIBRARY_PATH=. ./prog1
v1 xyz
eric@debian:~/symver$ gcc -fPIC -shared -o libxyz.so xyz2.c -Wl,--version-script,v2.map # 覆盖更新旧的共享库
eric@debian:~/symver$ gcc -fPIC -o prog2 main.c libxyz2.so
eric@debian:~/symver$ LD_LIBRARY_PATH=. ./prog2
v2 xyz
eric@debian:~/symver$ LD_LIBRARY_PATH=. ./prog1 # 旧的可执行文件依然能运行
v1 xyz
eric@debian:~/symver$ objdump -t prog1 | grep xyz
0000000000000000 F *UND* 0000000000000000 xyz@VER_1
eric@debian:~/symver$ objdump -t prog2 | grep xyz
0000000000000000 F *UND* 0000000000000000 xyz@VER_2
可以发现两个可执行文件中使用的符号是带有版本号标记的。
其他补充
在代码中实现符号版本化除了用 .symver 汇编之外,还可以用 GNU 的 函数属性:
__attribute__ ((__symver__ ("foo@VERS_1")))
int foo_v1 (void)
{
}
/* 需要 binutils 2.35 以上 */
__attribute__ ((__symver__ ("foo@VERS_2"), __symver__ ("foo@VERS_3")))
int symver_foo_v1 (void)
{
}
构造函数和析构函数
参考 GCC 文档:
constructor
destructor
constructor (priority)
destructor (priority)
其中 priority 的 0-100 范围是被预留的,我们能用的范围是 101 到 65535。构造函数能够在共享库载入时完成初始化工作、析构函数在共享库卸载时完成清理工作。这和 C++ 静态变量的初始化和析构是同样的过程:在主程序中静态变量先于 main()
函数被初始化,在 main()
函数退出之后才析构。
用来实现共享库初始化和析构的较早技术是 void _init(void)
和 void _fini(void)
函数,如果要使用自己创建的这两个函数,需要使用 gcc -nostartfiles
选项以防止链接器加入这些函数的默认实现。也可以使用 –Wl,–init
和 –Wl,–fini
选项来指定函数的名称。
有了 constructor 和 destructor 特性之后应该避免使用老的 void _init(void)
和 void _fini(void)
函数。
预加载共享库
环境变量 LD_PRELOAD
可以用来指定空格或冒号分割的、共享库的列表。这些共享库不仅是额外加载对象,而且在程序进入时先于其他共享库被加载。
和 LD_LIBRARY_PATH
的区别:LD_PRELOAD
用来强制加载具体的库(添加依赖),LD_LIBRARY_PATH
则是提供了搜索库的额外路径。
在系统层面上控制预加载共享库可以使用 /etc/ld.so.preload 文件来完成。在我的测试环境中这个文件不存在。(应该是表示不会预加载任何共享库?)
处于安全原因,set-user/group-ID 程序禁用了环境变量 LD_PRELOAD
。
用环境变量调试动态链接器
用 LD_DEBUG=help date
就会输出帮助信息,并不会执行程序!
eric@debian:~/attr$ LD_DEBUG=help date
Valid options for the LD_DEBUG environment variable are:
libs display library search paths
reloc display relocation processing
files display progress for input file
symbols display symbol table processing
bindings display information about symbol binding
versions display version dependencies
scopes display scope information
all all previous options combined
statistics display relocation statistics
unused determined unused DSOs
help display this help message and exit
To direct the debugging output into a file instead of standard output
a filename can be specified using the LD_DEBUG_OUTPUT environment variable.
有了帮助信息之后我们可以选一些来执行,比如:
eric@debian:~/attr$ LD_DEBUG=libs date
8403: find library=libc.so.6 [0]; searching
8403: search cache=/etc/ld.so.cache
8403: trying file=/lib/x86_64-linux-gnu/libc.so.6
8403:
8403:
8403: calling init: /lib64/ld-linux-x86-64.so.2
8403:
8403:
8403: calling init: /lib/x86_64-linux-gnu/libc.so.6
8403:
8403:
8403: initialize program: date
8403:
8403:
8403: transferring control: date
8403:
Monday, July 22, 2024 PM03:57:52 HKT
可以同时使用多个选项:
eric@debian:~/attr$ LD_DEBUG=statistics,libs date
8420: find library=libc.so.6 [0]; searching
8420: search cache=/etc/ld.so.cache
8420: trying file=/lib/x86_64-linux-gnu/libc.so.6
8420:
8420:
8420: runtime linker statistics:
8420: total startup time in dynamic loader: 382413 cycles
8420: time needed for relocation: 27469 cycles (7.1%)
8420: number of relocations: 93
8420: number of relocations from cache: 7
8420: number of relative relocations: 171
8420: time needed to load objects: 67369 cycles (17.6%)
8420:
8420: calling init: /lib64/ld-linux-x86-64.so.2
8420:
8420:
8420: calling init: /lib/x86_64-linux-gnu/libc.so.6
8420:
8420:
8420: initialize program: date
8420:
8420:
8420: transferring control: date
8420:
Monday, July 22, 2024 PM04:00:26 HKT
这个环境变量不仅对可执行文件中链接的共享库有效,对 dlopen()
打开的共享库也有效。出于安全考虑,set-user/group-ID 程序(在 glibc 2.2.5 起)会忽略环境变量 LD_DEBUG
。