本文和https://blog.csdn.net/jinking01/article/details/116757013配合,了解so文件编译和链接以及运行的关联关系。
一个程序链接不同版本的同一个库(同一个so文件,有多个版本,但这多个版本都需要),可能会崩溃
,这是为什么呢?要如何解决呢?
一般来说,动态库的名称中会包含版本控制信息,例如 libg++.so.2.7.1
,这个版本控制一般依赖于体系架构。动态库的版本信息可以在 SONAME
域中编码。
一般来说,动态库的 SONAME
和它的文件名是相同的,例如/usr/lib/libgxx.so.2.1.0
的 SONAME
是 libgxx.so.2.1.0
。但是也有不一样的。
如果在编译阶段人为指定了-soname,那么后面可以使用ldconfig -n . 来自动创建一个与soname名字一致的文件,它一般是具体的so文件的软链接,如soname为libhello.so.0,而生成的so文件为libhello.so.0.0.1那么使用ldconfig -n .可以自动创建libhello.so.0,当然,也可以使用ln -s创建,但这两种机制是不同的。
值得注意的是,如果我们不更改动态库(libhello.so.0.0.1)的 SONAME
,更改动态库的文件名(改成libhello.so.0.0.2),然后指定给链接器更改文件名后的共享库(libhello.so.0),那么在运行时,二进制文件可能会报错:找不到指定的库(因为libhello.so.0链到的是libhello.so.0.0.1)。
静态库不是可执行的文件,它只是一系列 .o
文件的集合。而 .o
文件是 ELF 文件,所以可以说静态库是 .o
文件的集合。所谓的“链接静态库到程序”,并不是指静态库本身链接到程序,而是静态库被传递给链接器后,链接器从静态库中提取出 .o
文件,然后从这些 .o
文件中挑选出自己需要的使用。
相较于静态库 ,动态库的 ELF 信息多出一些 program headers
。这些program headers
提供的信息是程序运行时需要的,这一点和动态库在程序运行时被链接相印证。
$ readelf -a libtest.so
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x5a0
Start of program headers: 64 (bytes into file)
Start of section headers: 6264 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 7
Size of section headers: 64 (bytes)
Number of section headers: 29
Section header string table index: 26
...
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000754 0x0000000000000754 R E 200000
LOAD 0x0000000000000e00 0x0000000000200e00 0x0000000000200e00
0x0000000000000228 0x0000000000000230 RW 200000
DYNAMIC 0x0000000000000e18 0x0000000000200e18 0x0000000000200e18
0x00000000000001c0 0x00000000000001c0 RW 8
NOTE 0x00000000000001c8 0x00000000000001c8 0x00000000000001c8
0x0000000000000024 0x0000000000000024 R 4
GNU_EH_FRAME 0x00000000000006d0 0x00000000000006d0 0x00000000000006d0
0x000000000000001c 0x000000000000001c R 4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 10
GNU_RELRO 0x0000000000000e00 0x0000000000200e00 0x0000000000200e00
0x0000000000000200 0x0000000000000200 R 1
...
unresolved 符号
在链接时,静态库中可以有 unresolved(未解决)的符号,只要程序不引用这些 unresolved 符号——不引用含有 unresolved 符号的 .o
文件里的所有符号。
因为“静态库被传递给链接器后,链接器从静态库中提取出
.o
文件,然后从这些.o
文件中挑选出自己需要的使用。”
而动态库是独立的 ELF 文件,如果程序链接的是动态库,那么我们必须 resolve(解决)库里的所有的 unresolved 符号。假如编译test时要链接 libpigi.so
库,而libpigi.so又依赖Octools库,那么我们必须同时也引用 Octtools,即使程序test没有使用 Octtolls。
动态库不能包含 unresolved 符号,意味着动态库中的所有符号都是可用的。
所以,一旦使用动态链接库编译出现unresolved symbols之类的错误,就需要知道,这个肯定是依赖的so文件本身又依赖其他so文件了,在编译时,要找到直接依赖的so文件所依赖的其他so文件,把它加上去,就能解决编译问题。
符号可见性(symbols visibility)
在 Linux 系统中,所有非静态的全局符号对外默认都是可见的,“默认”一词意味着有手段改变符号的可见性。
编译器的 -fvisibility 选项
编写函数或者类时,指定 __attribute__((visibility("default")))
属性,如此一来,在编译时便可通过 -fvisibility
选项限制相应符号的可见性。详情可参考这里。
链接器的 --version-script 选项
该选项可以为链接器指定版本控制脚本,支持动态库的 ELF 平台都可以使用,通常在创建动态库时使用,以指定所创建库的版本层次结构附加信息。详情可参考这里。下面是一个实例:
/* foo.c */
int foo() { return 42; }
int bar() { return foo() + 1; }
int baz() { return bar() - 1; }
编译上述文件,并且查看符号:
$ gcc -fPIC -shared -o libfoo.so foo.c && nm -D libfoo.so | grep ' T '
0000000000000718 T _fini
00000000000005b8 T _init
00000000000006b7 T bar
00000000000006c9 T baz
00000000000006ac T foo
可见在默认情况下,所有的符号都被导出了。现在我们创建 version 脚本:libfoo.version
,限制一些符号的可见性,内容如下所示:
FOO {
global: bar; baz; # 只导出 bar 和 baz
local: *; # 隐藏其他的符号
};
然后把它传递给链接器,重新编译链接,再查看相应的符号:
$ gcc -fPIC -shared -o libfoo.so foo.c -Wl,--version-script=libfoo.version
$ nm -D libfoo.so | grep ' T '
00000000000005f7 T bar
0000000000000609 T baz
与预期一致。
链接器的 --exclude-libs 选项
链接器的这个选项可以不导出(exclude)指定静态库的符号,该选项可以接收多个参数,各个参数用逗号或者冒号分开:
$ ... --exclude-libs lib,lib,lib
--exclude-libs
在 i386 PE 平台和 ELF 平台可用,对于 ELF 平台来说,该选项将会把指定库里的符号改为本地隐藏状态,具体可参考这里。稍后将看到实例。
几个方法的特点
- 要使用编译器的
-fvisibility
需要从代码层面修改,工作量略大; - 编写链接器的版本脚本需要明确知道每一个符号,处理复杂库比较痛苦;
- 使用链接器的
--exclude-libs
,虽然简单,但是要求传递的参数是静态库。
综合考虑,就解决本文开头提出的问题而言,使用链接器的 --exclude-libs
最方便。
实验
实验现象
现在编写简易代码模拟本文开头遇到的问题。首先编写 lib_v1.0.cpp,表示版本 1.0 的库:
// lib_v1.0.cpp
float foo() {
return 1.0;
}
然后编写 lib_v1.1.cpp,表示版本 1.1 的库:
// lib_v1.1.cpp
float foo() {
return 1.1;
}
接着编写 wrapper.cpp,调用库函数 foo():
// wrapper.cpp
float foo();
float wfoo() {
return foo();
}
我们首先将两个版本的库编译出来:
$ g++ -c lib_v1.0.cpp -o lib_v1.0.o
$ ar cr libv1.0.a lib_v1.0.o
$
$ g++ -c lib_v1.1.cpp -o lib_v1.1.o
$ ar cr libv1.1.a lib_v1.1.o
我们还有封装了版本 1.0 的静态库的 libwrapper.so ,编译之:
$ g++ -fPIC wrapper.cpp -L./ -lv1.0 -shared -o libwrapper.so
此时我们得到了三个库:
- libv1.0.a
- libv1.1.a
- libwrapper.so(封装了 libv1.0.a)
按照文章开头的问题:程序同时链接 libwrapper.so 和 libv1.1.a 运行时崩溃。对应到本小节的试验,我们编写 main() 函数生成可执行程序:
// test.cpp
#include <iostream>
float foo();
float wfoo();
int main() {
float f = foo();
float wf = wfoo();
std::cout << f << ", " << wf << std::endl;
return 0;
}
编译 test.cpp,并同时链接 libv1.1.a 和 libwrapper.so,然后执行之,得到如下输出:
$ g++ test.cpp -L./ -lv1.1 -lwrapper -o test
$ ./test
1.1, 1.1
可以看出,此处的输出与直觉(1.1, 1)并不一致。现在我们交换 libv1.1.a 和 libwrapper.so 的链接顺序:
$ g++ test.cpp -L./ -lwrapper -lv1.1 -o test
$ ./test
1, 1
分析和解决
同样,输出还是与直觉(1, 1.1)不一致,怎么回事呢?结合前文的分析思考下,其实是不难理解的,这个现象背后隐含的原理也可以解释和解决文章开头遇到问题:一个程序链接不同版本的同一个库,可能会崩溃
。请看:
链接时,链接器会从指定的库里查找自己需要的符号,查找过程是有顺序的,从实验来看,查找顺序与我们输入的库的顺序一致。并且,当链接器找到自己需要的符号后,就不再从后面的库里查找了。此时,上述结果便可解释了:
- 1.1,1.1:链接器处理 main() 函数中的 foo() 函数时,优先从 libv1.1.a 中查找,因此此时 f=1.1。同时,wfoo() 需要调用的 foo() 函数也不必再查找了,直接使用已经从 libv1.1.a 找到的 foo(),所以 wf=1.1;
- 1,1:链接器处理 main() 函数中的 foo() 函数时,优先从 libwrapper.so 中查找,而 libwrapper.so 的编译过程链接了 libv1.0.a,其中含有 foo() 函数的符号,根据前文的讨论,动态链接库里的所有符号都是可用的,所以此时链接器先找到的是 libv1.0 的 foo() 函数,f=1.0,wf=1.0,通过 std::cout 的格式输出,就是 1,1 了。
得出上述推论后,应该明白我们甚至可以只链接 libwrapper.so 就能成功编译 test.cpp:
$ g++ test.cpp -L./ -lwrapper -o test
$ ./test
1, 1
回到文章开头的问题:libwrapper.so 链接了 1.0 版本的 libxx.a,某个应用程序 xx_test 同时链接 1.1 版本的 libxx.a 和 libwrapper.so,编译正常,运行时崩溃。
现在便不难解释了:xx 是一个复杂的工程,其从 1.0 版本迭代更新到 1.1 版本,基本上必定有功能改动。假设 xx 某个 1.0 版本特有的校验功能依赖 1.0 版本的 version() 函数输出 1.0,否则就会有某个指针运用错误,导致程序运行时崩溃。但是在 1.1 版本中,version() 函数输出的是 1.1。
xx_test 的编译过程同时链接了 1.1 版本的 libxx.a 和 libwrapper.so(链接了 1.0 版本的 libxx.a),按照链接顺序,libwrapper.so 里关于 xx 的大多数符号都可以在 1.1 版本的 libxx.a 里找到,xx 的 1.0 版本的特有检验功能可能会使用 1.1 版本的 version() 函数,导致 xx_test 在运行时崩溃。
要解决这样的问题,解决程序在链接时的符号冲突问题就可以了。
仔细分析下应用程序 xx_test 实际的需求:它需要 libwrapper.so 里封装的功能,以及 1.1 版本 libxx.a 里的功能,并不关心 libwrapper.so 里的 1.0 版本 libxx.a。因此,我们只要限制 libwrapper.so 使用 1.0 版本的 libxx.a,并且 libwrapper.so 不导出 1.0 版本 libxx.a 的符号就可以了。(对外暴露的是libwrapper.so,它只需要暴露它自己的对外接口就行了,并不需要暴露它所依赖的其他包的接口,也就是1.0版本的libxx.a里面的接口并不需要暴露给xx_test)
为了方便,我们还是以实验为例:限制 libwrapper.so 使用 1.0 版本库,并且不导出 1.0 版本库的符号,根据前面的讨论,做到这一点是简单的,重新生成libwrapper.so:
$ g++ -fPIC wrapper.cpp -L./ -lv1.0 -shared -o libwrapper.so -Wl,--exclude-libs libv1.0.a
我测试发现有时候要用
=
传输指定库才有效,也即-Wl,--exclude-libs=libv1.0.a
。
此时,无论我们如何交换链接顺序,都能得到预期结果:
$ g++ test.cpp -L./ -lwrapper -lv1.1 -o test
$ ./test
1.1, 1
$
$ g++ test.cpp -L./ -lv1.1 -lwrapper -o test
$ ./test
1.1, 1
我们的实验虽然简单,但是原理是通用的。将上述方法应用到 xx 不同版本库的冲突问题解决上,确实解决了问题。
小结
应用程序的编译链接过程,很多时候是处理符号的过程。程序同时链接不同版本的同一个库时,只要解决好符号问题,就能避免冲突崩溃。(这方面的知识需要继续提升啊,不然再遇到类似的问题,就要连猜带蒙了。