动态链接库的符号重名问题

分类: 学习笔记

背景

工作时,同事遇到一个编译和链接相关的问题,我跟着一起研究了一下。很有意思的问题,我也做了一些实验,在此记录。

问题描述

问题源自CNTK相关的程序,但在这里,我把问题抽象出来进行描述。

假设,现在有:

  1. 一个静态链接库liba.a,定义了函数int GetInt(),返回3
  2. 一个动态链接库libb.so,定义了函数int GetDoubleInt(),调用静态链接库liba.a,返回GetInt() * 2
  3. 一个静态链接库liba2.a,同样定义了函数int GetInt(),但返回9999
  4. 主程序,调用动态链接库libb.so和静态链接库liba2.a,分别输出GetInt()GetDoubleInt()的返回值。

可以理解成,在实际生产环境中,liba2.a是为静态链接库liba.a开发的新版本,保持了接口,修改了实现。

主程序调用了一个用旧版库(liba.a)写成的库(libb.so),同时自己又调用了新版库(liba2.a)。

实验

依赖关系

首先,整理一下文件、目标文件和库之间的依赖关系。

目标文件 依赖项
a.o a.cpp
a2.o a2.cpp
b.o b.cpp
main.o main.cpp
目标库 依赖项
liba.a a.o
liba2.a a2.o
libb.so b.o liba.a
目标可执行文件 依赖项
main main.o liba2.a libb.so

源程序

liba.aliba2.a 的公共接口 (a.h)

int GetInt();

liba.a 的源程序 (a.cpp)

#include "a.h"

int GetInt()
{
    return 3;
}

liba2.a 的源程序 (a2.cpp)

#include "a.h"

int GetInt()
{
    return 9999;
}

libb.so 的接口 (b.h)

int GetDoubleInt();

libb.so 的源程序 (b.cpp)

#include "a.h"
#include "b.h"

int GetDoubleInt()
{
    return 2 * GetInt();
}

主程序 main 的源程序 (main.cpp)

#include <iostream>
#include "a.h"
#include "b.h"
using namespace std;

int main()
{
        cout << GetInt() << endl;
        cout << GetDoubleInt() << endl;
        return 0;
}

编译和链接

编译

我们直接来看一下我写好的Makefile中,编译(从源文件到目标文件的过程)相关的部分。

注意编译动态链接库的代码时,需要加上-fPIC这个选项。PIC即Position Independent Code,位置无关代码。

这个选项可以让生成的代码中涉及的各种引用地址均使用相对地址,例如跳转的目标地址等。只有使用位置无关代码,才能生成动态链接库。

a.o: a.cpp
        g++ a.cpp -c -o a.o
a2.o: a2.cpp
        g++ a2.cpp -c -o a2.o
b.o: b.cpp
        g++ b.cpp -fPIC -c -o b.o
main.o: main.cpp
        g++ main.cpp -c -o main.o

注:-c表示生成目标文件,-o xxx.o指定目标文件名称为xxx.o

打包生成静态链接库

调用ar这个Linux中的Archive工具来打包静态链接库所包含的目标文件,其实静态链接库就是一个带符号索引的目标文件包。

liba.a: a.o
        ar rcs liba.a a.o
liba2.a: a2.o
        ar rcs liba2.a a2.o

链接生成动态链接库

链接liba库到生成的动态链接库libb.so

libb.so: b.o liba.a
        g++ -shared b.o -L. -la -o libb.so

注:-shared表示生成动态链接库,-L.表示设置链接库查找目录为当前目录.-la表示链接名为aliba的库。

链接生成主程序

这里,主程序分别链接了liba2libb两个库。

main: main.o liba2.a libb.so
        g++ main.o -L. -Wl,-R. -la2 -lb -o main

注:-Wl,-R.设置程序运行时查找引用库的目录为当前目录.,其中-Wl表示后面的选项为传入链接器的选项。

生成全部

最后,在Makefile中加入all这个生成目标:

all: main

执行

root@3b172fa2403e:/home/code# make
g++ main.cpp -c -o main.o
g++ a2.cpp -c -o a2.o
ar rcs liba2.a a2.o
g++ b.cpp -fPIC -c -o b.o
g++ a.cpp -c -o a.o
ar rcs liba.a a.o
g++ -shared b.o -L. -la -o libb.so
g++ main.o -L. -Wl,-R. -la2 -lb -o main

root@3b172fa2403e:/home/code# ./main
9999
19998

诶?结果为什么调用的都是liba2库里定义的GetInt()

如果说,按我们的设定,主程序使用新版的liba2,而主程序调用的libb应该去使用旧版的liba才对,也就是说,我们期待的输出结果应该是:

9999
6

换换顺序?再试一下。

现在,把链接生成主程序的链接库顺序颠倒一下(先链接libb,再链接liba2):

main: main.o liba2.a libb.so
        g++ main.o -L. -Wl,-R. -lb -la2 -o main

执行结果:

3
6

是不是很神奇?结果又都变成调用liba了。

不链接liba2可以吗?

之所以会出现这种情况,多半是因为主程序使用的liba2,和libb使用的liba发生了冲突。

因为libaliba2是静态链接库,所以在编译动态链接库libb的时候,liba的代码实际上是也被链接进了libb中。

那么,我们直接让主程序链接libb会怎么样呢?是不是也可以成功链接,并且直接调用liba

main: main.o liba2.a libb.so
        g++ main.o -L. -Wl,-R. -lb -o main

执行结果:

3
6

果然不出所料。

解释

事实上,因为libb链接了静态链接库liba,默认地,也就导出了所有liba导出的符号。

我们可以借助nm工具,查看库导出的符号都有什么:

root@3b172fa2403e:/home/code# nm liba.a

a.o:
0000000000000000 T _Z6GetIntv

root@3b172fa2403e:/home/code# nm liba2.a

a2.o:
0000000000000000 T _Z6GetIntv

root@3b172fa2403e:/home/code# nm -D libb.so
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
00000000000005c5 T _Z12GetDoubleIntv
00000000000005d2 T _Z6GetIntv
0000000000201028 B __bss_start
                 w __cxa_finalize
                 w __gmon_start__
0000000000201028 D _edata
0000000000201030 B _end
00000000000005e0 T _fini
00000000000004c8 T _init

可以看到,动态链接库libb.so是包括了GetIntGetDoubleInt两个符号的。

当我们将liba2libb同时链接到主程序中时,自然出现了重名的冲突。

而根据前面的实验结果,可以看到,最终被链接的程序,来自首先被链接的库。

那么,重名符号冲突,到底有哪些情况呢?

重名符号优先级

符号的定义域,一般有三种:

  1. 目标文件或静态链接库
  2. 动态链接库导出符号
  3. 动态链接库本地符号(static function,文件作用域函数)

其中,对于优先级最高的第1类符号,重名符号冲突会直接导致报错,不被允许。

跨优先级的重名情况,高优先级一般总会覆盖低优先级的符号。

而同优先级的动态链接库导出符号重名,则根据链接顺序有所不同。

不过,两个不同动态链接库的本地符号重名,是毫无干扰的,因为它们的作用域仅限于所在文件。

一个疑惑

可是,本文前面的例子,明明就是一个静态链接库和一个动态链接库,并不是静态链接库优先级高就覆盖了动态链接库的导出符号,而是与链接顺序有关啊!

其实并非如此。

本文的例子,按理来说,确实应该优先使用静态链接库的程序。但是由于我们只调用了GetDoubleInt()GetInt(),而这两个名称在动态链接库中均能够解析并链接到,静态链接库就被链接器认为没有存在的必要了。

如果此时,我们在静态链接库liba2中再定义一个独一无二的函数,并在main中调用,会发现无论如何修改链接库的链接顺序,最终的结果都是调用liba2的程序。

在这里,具体原因我没有找到明确的参考依据,我的推断大概是:

链接器根据链接顺序解决符号链接,如果已经解决了全部符号,那么后续链接库就不予考虑;而静态链接库一旦有链接的必要,那么全部符号的优先级就会高于动态链接库的导出符号。

我查阅资料了解到--no-as-needed--as-needed这一对链接选项(使用gcc/g++设置时应在前面加上-Wl,)可以控制链接器是否按需链接库,不过应该只是对动态链接库进行按需链接。

所以,这个行为的具体原因我仍然没有确定,但大概情况应该差不多是我描述的这样。

解决方案:隐藏符号

那么,有什么办法可以实现我们的预期结果,就是如何让动态链接库的GetDoubleInt继续调用它原本调用的旧版liba,而主程序调用的GetInt则调用新版的liba2呢?

其实,只要把动态链接库导出的GetInt()符号隐藏,就不存在符号重名冲突的问题了。

方法有两种。

方法一:链接选项--exclude-libs

在链接生成动态链接库的时候,加入-Wl,--exclude-libs liba.a即可避免导出静态链接库liba的符号,也就不会导出GetInt()这个函数。

这样,libb动态链接库中的GetInt()就相当于本地符号。 主程序调用动态链接库libbGetDoubleInt()时,动态链接库内部还是会调用libaGetInt();而主程序调用GetInt(),因为无法在动态链接库中找到这个符号,自然就会调用liba2的了。

libb.so: b.o liba.a
        g++ -shared b.o -L. -Wl,--exclude-libs liba.a -la -o libb.so

执行结果:

9999
6

是我们想要的!

方法二:Version Script

可以使用链接器支持的Version Script控制导出的符号名称,即提供一个文件来指定具体导出哪些符号。

具体可以参考: GNU ld 文档 - Version Scripts

参考文献

https://www.safaribooksonline.com/library/view/advanced-c-and/9781430266679/9781430266679_Ch09.xhtml