最近在编写C++工程时,使用了 MNN 库(libMNN.a,1.0 版本)。是这么使用的:我首先把 MNN 封装了下,打包成一个新的库,暂且称之为 wrApper.so 吧,提供给客户使用。不久后客户反馈问题,使用我提供的 wrapper.so 后,程序编译正常,但是运行时崩溃了。。。分析原因后,知道客户除了使用我提供的 wrapper.so 之外,也使用了 MNN 库,只不过版本是 1.1 版本。
为什么运行时崩溃了?
把这个问题抽象概括一下,就是一个程序链接不同版本的同一个库,可能会崩溃,这是为什么呢?要如何解决呢?回答这两个问题之前,正好趁机对静态库和动态库做深入一点的理解。
不想看理解,只想看解决方案的直接翻到最后。
在 linux 系统中,静态库通常以 .a 结尾,例如 libg++.a。使用 ar 命令可以将一系列目标文件(.o 文件)打包成静态库,因此,静态库的本质其实就是 .o 文件的集合,所以它的基础表现与 .o文件没有区别——在链接时(link time),链接器从静态库中搜索所有的可见全局函数/变量符号,并且把这些符号复制到二进制文件(通常是可执行程序)中。
大多数现代操作系统(Linux、windows 等)都支持动态链接库,也即支持在程序运行时(runtime)链接动态库。动态链接库的文件名在 Linux 中通常以 .so 结尾,在 Windows 中则通常以 .dll 结尾。
使用编译器/链接器可以将 .o 文件打包成动态库,通常来说,要制作动态库,编译器需将 .o 文件编译为 PIC(Position Independent Code,位置独立代码),例如使用 gcc/g++ 编译时指定 -fPIC选项。
$ g++ test.cpp -fPIC ...
动态库允许多个应用程序共享同一个库,并不把动态库的代码复制到二进制文件中,因此相比于链接静态库,同等条件下,链接动态库的的程序具备更小的 size。在运行时,动态库允许二进制文件访问库内的所有符号,即使在链接时没有用到这些符号。
因为动态库通常是 PIC,所以就算多个应用程序链接的是一个动态库,在这些程序中,动态库函数也可以是不同的地址。
动态库的名称中还可以包含版本控制信息,例如 libg++.so.2.7.1,这个版本控制一般依赖于体系架构。动态库的版本信息可以在 SONAME 域中编码。一般来说,动态库的 SONAME 和它的文件名是相同的,例如/usr/lib/libgxx.so.2.1.0 的 SONAME 是 libgxx.so.2.1.0。
值得注意的是,如果我们不更改动态库的 SONAME,更改共享库的文件名,然后指定给链接器更改文件名后的动态库,那么在运行时,二进制文件可能会报错:找不到指定的库。
和静态库不同,动态库程序需要在运行时链接,因此必须保证程序能够找到动态库,通常程序会从一些特殊的目录、环境变量里搜索需要链接的动态库,例如在 Linux 中,程序会从 LD_LIBRARY_PATH环境变量中搜索需要的动态库。
二进制文件本身也可以在其内部编码存储要搜索的动态库所在路径列表(RPATH),这样做更好一点,因为不需要用户再手动指定库的搜索环境变量。
正如前文所述,静态库不是可执行的文件,它只是一系列 .o 文件的集合。鉴于 .o 文件是 ELF 文件,我们可以说静态库是 .o 文件的集合。
所谓的“链接静态库到程序”,并不是指静态库本身链接到程序。静态库被传递给链接器后,链接器从静态库中提取出 .o 文件,然后从这些 .o 文件中挑选出自己需要的使用。
这里再强调一下,静态库是 ELF 文件的集合,它本身并不是 ELF 文件。虽说典型的 ELF 解析工具(例如 objdump,readelf,nm)能够解析静态库,但这是因为它们知道静态库的本质,所以解析输出的信息其实是静态库中 .o 文件的信息列表。
例如,我们有目标文件 test.o,执行下面的命令将其打包为静态库 libtest.a:
$ ar cr libtest.a test.o
此时,通过 readelf 命令读取 test.o 和 libtest.a,输出是一致的。
$ readelf -a test.o
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: REL (Relocatable file)
machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 680 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 13
Section header string table index: 10
...
$
$
$ readelf -a libtest.a
File: libtest.a(test.o)
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: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 680 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 13
Section header string table index: 10
...
动态库是 ELF 文件。假设有 test.cpp 文件,执行下面的命令将其编译为动态库 libtest.so 和静态库 libtest.a:
$ g++ -fPIC test.cpp -shared -o libtest.so
$
$ g++ test.cpp -c -o test.o
$ ar cr libtest.a test.o
然后通过 readelf 命令读取其 ELF 信息,不难发现相较于静态库 libtest.a,动态库 libtest.so 的 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
...
就 ELF 文件结构而言,动态库和可执行程序并无太多区别。
有了前文的分析,我们现在应该明白动态库和静态库关于 unresolved 符号的区别了。在链接时,静态库中可以有 unresolved(未解决)的符号,只要程序不引用这些 unresolved 符号——不引用含有 unresolved 符号的 .o 文件里的所有符号。
因为“静态库被传递给链接器后,链接器从静态库中提取出 .o 文件,然后从这些 .o 文件中挑选出自己需要的使用。”
而动态库是独立的 ELF 文件,如果程序链接的是动态库,那么我们必须 resolve(解决)库里的所有的 unresolved 符号。因此就本例而言,要链接 libpigi.so 库,我们必须同时也引用 Octtools,即使程序没有使用 Octtolls。
动态库不能包含 unresolved 符号,意味着动态库中的所有符号都是可用的。
在 Linux 系统中,所有非静态的全局符号对外默认都是可见的,“默认”一词意味着有手段改变符号的可见性。
编写函数或者类时,指定 __attribute__((visibility("default"))) 属性,如此一来,在编译时便可通过 -fvisibility 选项限制相应符号的可见性。详情可参考这里。
该选项可以为链接器指定版本控制脚本,支持动态库的 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)指定静态库的符号,该选项可以接收多个参数,各个参数用逗号或者冒号分开:
$ ... --exclude-libs lib,lib,lib
--exclude-libs 在 i386 PE 平台和 ELF 平台可用,对于 ELF 平台来说,该选项将会把指定库里的符号改为本地隐藏状态,具体可参考这里。稍后将看到实例。
综合考虑,就解决本文开头提出的问题而言,使用链接器的 --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
此时我们得到了三个库:
按照文章开头的问题:程序同时链接 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)不一致,怎么回事呢?结合前文的分析思考下,其实是不难理解的,这个现象背后隐含的原理也可以解释和解决文章开头遇到问题:一个程序链接不同版本的同一个库,可能会崩溃。
隐藏内容,请点击文章末尾的“了解更多”查看。
此时,无论我们如何交换链接顺序,都能得到预期结果:
$ 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
我们的实验虽然简单,但是原理是通用的。将上述方法应用到 MNN 不同版本库的冲突问题解决上,确实解决了问题。
应用程序的编译链接过程,很多时候是处理符号的过程。程序同时链接不同版本的同一个库时,只要解决好符号问题,就能避免冲突崩溃。这方面的知识需要继续提升啊,不然再遇到类似的问题,就要连猜带蒙了。