一个由动态库对其他动态库的依赖导致的问题

感觉难以解决的编译问题大多是链接的问题,其中大多是动态链接的问题,而这其中又大多是涉及要加 -l 选项的动态库的动态链接问题。

问题场景

编译可执行程序 UnitTest 时报错:

1Scanning dependencies of target UnitTest
2[ 16%] Building CXX object CMakeFiles/UnitTest.dir/test/unit_test.cpp.o
3[ 33%] Linking CXX executable UnitTest
4/home/ubuntu/project/build/libA.so: undefined reference to `dlopen'
5/home/ubuntu/project/build/libA.so: undefined reference to `dlclose'
6/home/ubuntu/project/build/libA.so: undefined reference to `dlsym'
7collect2: error: ld returned 1 exit status
8CMakeFiles/UnitTest.dir/build.make:102: recipe for target 'UnitTest' failed
9make[2]: *** [UnitTest] Error 1

在 CMakeLists.txt 中相应的 target_link_libraries(UnitTest ...) 中添加了 -ldl 也没用。

用 ldd 分析 libA.so 的动态库,可以看到 libdl.so 在其中:

1$ ldd ./libA.so 
2	...
3	libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fe5887cd000)
4	...

问题解析

对 ldd 的分析

发现自己以前对 ldd 的理解有偏差。先来解释一下为什么链接器找不到 dl{open,close,error,sym} 的定义,但 ldd ./libA.so 却有 libdl.so 的现象。

首先,在链接可执行程序时,假如链接有问题,符号的定义没有找全,链接器就会报错;但如果链接的是动态库的话,链接器不会报错。因为 .so 文件本质上是 .o 文件转化而来,两者本身并不要求“可执行”意义上的完整性,符号链接没有全部完成也无所谓。拿它链接生成可执行程序的时候,再把未完成的部分补齐就好。也就是说,一个动态库被生成,并不能说明它的链接是完整的(可执行程序则相反,不然它就不可执行了)。

其次,如 man ldd(1) 所述:

ldd prints the shared objects (shared libraries) required by each program or shared object specified on the command line.

对一个目标程序或动态库而言,ldd 输出的是它所需要的(其他)动态库。当目标是动态库时,这并不等同于“这些动态库已经被正确地链接”。ldd 的原理是利用动态加载器 ld-linux.so 来分析 ELF 文件所依赖的动态库:

In the usual case, ldd invokes the standard dynamic linker (see ld.so(8) ) with the LD_TRACE_LOADED_OBJECTS environment variable set to 1. This causes the dynamic linker to inspect the program’s dynamic dependencies, and find (according to the rules described in ld.so(8) ) and load the objects that satisfy those dependencies.

至于 ld-linux.so 是如何在这种情况下找到 libdl.so 的就不清楚了,要了解的话要去看看源码。(待补充)

要看一个动态库自身链接了哪些其他的动态库,应该使用 readelf -d libA.so | grep NEEDED,即分析库文件本身带的 ELF 相关信息。

我们可以模仿此帖 的代码来编译验证一下,在代码中故意留一个未定义的函数,然后试着编译成动态库看看能否成功。测试代码文件 silly.c 内容如下:

 1#include <stdio.h>
 2#include <dlfcn.h>
 3
 4int foo(const char *filename)
 5{
 6	void *handle;
 7	double (*func)(double);
 8
 9	handle = dlopen(filename, RTLD_LAZY);
10	if (!handle) {
11		perror("dlopen");
12		return 1;
13	}
14	func = (double (*)(double)) dlsym(handle, "func");
15	printf("%f\n", func(2.0));
16	dlclose(handle);
17	return 0;
18}

用下述命令编译验证:

1gcc -shared -fPIC -o libsilly.so silly.c # successful
2
3gcc -shared -fPIC -o libsilly.so silly.c -Wl,-z,defs
4# error: undefined reference to `dl{open,sym,close}'
5
6gcc -shared -fPIC -o libsilly.so silly.c -ldl -Wl,-z,defs # successful

另外,用 readelf 解析上述两个成功的编译命令生成的动态库,结果也是不同的,后者才会带上:

10x0000000000000001 (NEEDED)             Shared library: [libdl.so.2]

解决方法与根因

后来同事不知从哪里找到了解决方法:在链接选项中添加 --no-as-needed,编译就通过了。

man ld(1) 中对该选项有解释。一般情况下,链接器会将编译参数中提到的动态库都记录在目标二进制文件中并打上 DT_NEEDED 标签(即所谓“链接到二进制文件中”),不论这些动态库是否真的有被用到。设置 --as-needed 选项后,链接器就只会链接这些动态库中实际被使用到的库,而且是要被二进制文件直接使用,像“依赖库的依赖库”这种间接使用的不算。加这个选项显然是有好处的,如这篇 Gentoo Wiki 所分析的,不仅可以在运行时避免加载冗余动态库以减少库加载和库初始化带来的额外时间开销,还有利于动态库依赖的解耦,减少由底层动态库变更带来的重新构建1。因此较高版本的编译器在编译时都是默认加上 --as-needed 选项的2。继续用上面的代码编译试验一下:

 1gcc -shared -fPIC -o libsilly.so silly.c -ldl -lm
 2# readelf -d ./libsilly.so | grep NEEDED
 3# 0x0000000000000001 (NEEDED)             Shared library: [libdl.so.2]
 4# 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 5
 6gcc -shared -fPIC -Wl,--no-as-needed -o libsilly.so silly.c -ldl -lm
 7# readelf -d ./libsilly.so | grep NEEDED
 8# 0x0000000000000001 (NEEDED)             Shared library: [libdl.so.2]
 9# 0x0000000000000001 (NEEDED)             Shared library: [libm.so.6]
10# 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

说回到问题场景上,这也解释了为什么在编译 UnitTest 的命令中加 -ldl 无效:libdl.so 并不是 UnitTest 这个程序直接使用的库,因此即便加了 -ldl 也会因默认的 --as-needed 而被排除出链接过程。

随后我检查了 libA.so 中的 NEEDED 标签,发现其中的确缺少了 libdl.so。所以其实最终的锅还是 libA.so 的编译问题,--no-as-needed 只能算是一个解决问题的 trick。

参考资料

后注

在工作过程中看到了这篇 bugzilla ,留意到一种验证动态库是否完整的方法:

1ld /lib64/libbpf.so 

待更多验证和原理探究。


  1. 这里解释一下动态库依赖的解耦。由于 as-needed 只链接目标二进制直接使用的动态库,“目标的依赖”与“目标依赖的依赖”就解耦开来,后者名称的变化对目标文件的影响就会减小。如此处 所举的例子:上层 GUI 应用依赖 GTK,而 GTK 依赖 cairo。使用 --as-needed 链接后,若 cairo 库的名称发生变更,大概率只要重新编译 GTK 即可,GUI 应用需要重新构建的可能性降低了。 ↩︎

  2. 这一点可以在用 GCC 编译时加 -v 来确认。 ↩︎


基于 BusyBox 快速制作内核验证环境
关于 SELinux 的宽泛介绍