动态库全局符号覆盖的大坑

今天在调试时发现了一个奇怪的core:double free or corruption (fasttop),从堆栈看是由于 _dl_fini 函数多次重复释放了某些 STL 容器导致的,此时就算在 main 函数中只保留个简单 return 0 也会出错,因此猜想肯定和某些全局变量有关。后面经过各种修改尝试,终于发现这是由于引用的 .so 动态库和主程序中定义了同名的全局 STL 容器导致的,此时的行为简直就是一个神坑,很有必要记录一下……

先说最终结论吧:

  • 多个动态库或者是动态库与主程序间可以有同名全局符号,包括全局变量和函数等,此时链接过程是不会出错的。
  • 这些同名全局符号的地址是相同的
  • 链接过程中会从前往后依次查找符号,对于 .so.a 来说,如果遇到相同的全局变量是不会报错的,此时 GCC 会默默的选择第一个,这种情况连 Warning 都不会有。这与多个 .o 是不同的,在多个 .o 中定义相同的全局变量无法正常链接。
  • 以上行为的问题在于,绝大部分情况下各模块期望的行为都是调用自己的全局变量及函数,而不是调用其他模块的,因此大概率会造成运行时的各种异常。
  • 尤有甚者,若全局变量并不是基本类型而是 class,那虽然此变量只有一个内存地址,然而其构造与析构函数会被调用多次,若其中有动态分配的内存,多次 delete 就会导致 double free 异常。
  • 以上构造与析构行为是通过编译时向 _init()_fini() 中添加 hook 函数实现的,构造顺序是链接顺序,析构顺序是其逆序。前文提到的 _dl_fini() 函数应该是 _fini() 的动态库版本。

上面这些行为看上去已经很坑了吧,然而这并不是全部……以上行为仅适用于编译时直接指定需要链接库的情况,若是在程序运行过程中使用 dlopen 动态加载 .so 时行为不太一样;若通过 LD_PRELOAD 指定动态库那行为又不一样了……

使用 dlopen 加载时的行为可以简要归纳如下:

  • 主程序中的全局符号是永远都不会被加载进来的动态库给覆盖的,无论是变量还是函数。这与很多文章中说的不太一样,然而实际使用 GCC 9.3 测试的结果就是如此,估计是较新的 GCC 版本做了什么修改导致的。
  • 多次调用 dlopen 加载多个动态库,若这些动态库间存在相同的全局符号,则它们之间是有可能相互覆盖的,这取决于 dlopenflag。若使用 RTLD_GLOBAL,则后面加载进来的动态库会使用已有的全局符号;若使用 RTLD_LOCAL,则每个动态库间的符号是独立的。
  • 上述行为中,对应全局变量的构造及析构每次都会进行,也就是后面加载进来的动态库会在之前内存的基础上再来构造一次,退出的时候也会析构多次。

以上很多行为显然应该都不是预期行为的,那如何解决这些问题呢,大概有这些方法:

  • 创建 .so 时加上编译选项 -Wl,-Bsymbolic,这会强制采用本地的全局变量定义。
  • 可以通过 __attribute__ ((visibility("xxx"))) 来控制符号可见性,并通过编译选项 -fvisibility=xxx 来控制默认符号可见性。
  • 将不需要导出的全局变量声明为 static 的。
  • 最根本的做法,通过 namespace 等手段从根本上避免同名变量及函数的存在

最后给出几个简单测试程序,可以对照着理解上面的各种行为。

my_calss.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

void g_fun();

class MyClass {
public:
MyClass(int a) : a_(a) {
std::cout << "Construct! " << a_ << " @ " << this << std::endl;
g_fun();
}

~MyClass() {
std::cout << "Destruct! " << a_ << " @ " << this << std::endl;
}

private:
int a_;
};

my_lib1.cc:

1
2
3
4
5
6
7
8
#include <iostream>
#include "my_class.h"

MyClass g_var(1);

void g_fun() {
std::cout << __FILE__ << ":" << __LINE__ << std::endl;
}

my_lib2.cc:

1
2
3
4
5
6
7
8
#include <iostream>
#include "my_class.h"

MyClass g_var(2);

void g_fun() {
std::cout << __FILE__ << ":" << __LINE__ << std::endl;
}

app1.cc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "my_class.h"

MyClass g_var(10);

void g_fun() {
std::cout << __FILE__ << ":" << __LINE__ << std::endl;
}

int main() {
std::cout << "----------" << std::endl;
g_fun();
std::cout << "----------" << std::endl;
return 0;
}

app2.cc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <unistd.h>
#include <dlfcn.h>
#include "my_class.h"

MyClass g_var(12);

void g_fun() {
std::cout << __FILE__ << ":" << __LINE__ << std::endl;
}

int main() {
dlopen("./libmylib1.so", RTLD_NOW);
dlopen("./libmylib2.so", RTLD_NOW);
std::cout << "----------" << std::endl;
g_fun();
std::cout << "----------" << std::endl;
return 0;
}

app3.cc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <unistd.h>
#include <dlfcn.h>
#include "my_class.h"

MyClass g_var(12);

void g_fun() {
std::cout << __FILE__ << ":" << __LINE__ << std::endl;
}

int main() {
dlopen("./libmylib1.so", RTLD_NOW | RTLD_GLOBAL);
dlopen("./libmylib2.so", RTLD_NOW | RTLD_GLOBAL);
std::cout << "----------" << std::endl;
g_fun();
std::cout << "----------" << std::endl;
return 0;
}

Makefile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mylib1: my_class.h my_lib1.cc
g++ -fPIC -shared -o libmylib1.so my_lib1.cc

mylib2: my_class.h my_lib2.cc
g++ -fPIC -shared -o libmylib2.so my_lib2.cc

app1: app1.cc my_class.h mylib1 mylib2
g++ -L./ -lmylib1 -lmylib2 -o app1 app1.cc

app2: app2.cc my_class.h
g++ -ldl -o app2 app2.cc

app3: app3.cc my_class.h
g++ -ldl -o app3 app3.cc

all: app1 app2 app3

测试程序运行结果为:

app1

1
2
3
4
5
6
7
8
9
10
11
12
13
$./app1
Construct! 2 @ 0x404194
app1.cc:6
Construct! 1 @ 0x404194
app1.cc:6
Construct! 10 @ 0x404194
app1.cc:6
----------
app1.cc:6
----------
Destruct! 10 @ 0x404194
Destruct! 10 @ 0x404194
Destruct! 10 @ 0x404194

app2

1
2
3
4
5
6
7
8
9
10
11
12
13
$./app2
Construct! 12 @ 0x404194
app2.cc:9
Construct! 1 @ 0x7fd81b9f106c
my_lib1.cc:7
Construct! 2 @ 0x7fd81b9ec06c
my_lib2.cc:7
----------
app2.cc:9
----------
Destruct! 2 @ 0x7fd81b9ec06c
Destruct! 1 @ 0x7fd81b9f106c
Destruct! 12 @ 0x404194

app3

1
2
3
4
5
6
7
8
9
10
11
12
13
$./app3
Construct! 12 @ 0x404194
app3.cc:9
Construct! 1 @ 0x7efd3799d06c
my_lib1.cc:7
Construct! 2 @ 0x7efd3799d06c
my_lib1.cc:7
----------
app3.cc:9
----------
Destruct! 2 @ 0x7efd3799d06c
Destruct! 2 @ 0x7efd3799d06c
Destruct! 12 @ 0x404194

本文只是一个简单的总结,关于此问题的更多深入讨论可以参考以下文章:

控制共享库的符号可见性 第 1 部分 - 符号可见性简介
浅谈动态库符号的私有化与全局化
linux动态库的种种要点
Linux动态链接库so版本兼容
浅析静态库链接原理
全局符号
Linux下全局符号覆盖问题
What exactly does -Bsymblic do?