解决Linux C中多版本库共存的问题unresolved 符号

本文和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 不同版本库的冲突问题解决上,确实解决了问题。

小结

应用程序的编译链接过程,很多时候是处理符号的过程。程序同时链接不同版本的同一个库时,只要解决好符号问题,就能避免冲突崩溃。(这方面的知识需要继续提升啊,不然再遇到类似的问题,就要连猜带蒙了。

原文参考:https://blog.popkx.com/difference-of-static-and-shared-libraries-in-linux-what-would-happen-if-a-c-program-links-different-versions-but-same-library/

上一篇:二叉树-层序遍历


下一篇:pycharm无法识别已存在模块,如图,对于已存在的_init_paths.py和multitracker.py显示“unresolved reference xxx”: