序
本文给大家分享一个比较诡异的bug,是关于dlopen的,我大致罗列了我项目中使用代码方式及结构,更好的复现这个问题,也帮助大家进一步理解dlopen.
问题复现
以下是项目代码的文件结构:
# tree
.
├── file1
│ ├── file1.cpp
│ └── file1_sub
│ ├── file1_sub.cpp
│ └── file1_sub.h
├── file2
│ ├── file2.cpp
│ └── file2_sub
│ ├── file2_sub.cpp
│ └── file2_sub.h
├── include
│ ├── factory.h
│ └── factory_register.h
└── main.cpp
首先来说该项目会产生一个可执行程序和4个库:
main.cpp -> main(可执行程序)
file1_sub.cpp -> libfile1_sub.so
file1.cpp -> libfile1.so(依赖libfile1_sub.so)
file2_sub.cpp -> libfile2_sub.so
file2.cpp -> libfile2.so(依赖libfile2_sub.so)
代码比较简单,仅仅是main函数中打开libfile1.so和libfile12.so两个so库,并调用相应的函数runFile1和runFile2,我保证这里是最复杂的代码了:)
c++
// main.cpp
typedef void (*Func)();
int main() {
void *handler1 = dlopen("./libfile1.so", RTLD_LAZY | RTLD_GLOBAL);
if (handler1 == NULL) {
printf("ERROR:%s :dlopen1\n", dlerror());
return -1;
}
Func file1Func = (Func) dlsym(handler1, "_Z8runFile1v");
if (file1Func == NULL) {
printf("ERROR:%s :dlsym1\n", dlerror());
return -1;
}
void *handler2 = dlopen("./libfile2.so", RTLD_LAZY | RTLD_GLOBAL);
if (handler2 == NULL) {
printf("ERROR:%s :dlopen2\n", dlerror());
return -1;
}
Func file2Func = (Func) dlsym(handler2, "_Z8runFile2v");
if (file2Func == NULL) {
printf("ERROR:%s :dlsym2\n", dlerror());
return -1;
}
file1Func();
file2Func();
for (;;) {}
return 0;
}
然后再继续看file1和file2中分别做了什么, 因为file1和file2都会用到factory这个,那就先来看下factory.h
c++
// factory.h
template<typename T>
struct Factory {
static Factory& instance() {
static Factory f;
return f;
}
T t{};
};
一个很简单模板类,就一个T的成员。比较关键的是,这个提供了单例对象。而后我们都会使用这个单例对象。
c++
// file1.cpp
void runFile1() {
File1Sub sub;
sub.run();
std::cout << "addr:" << &(Factory<int>::instance().t)
<< ", value:" << Factory<int>::instance().t << std::endl;
}
file1中首先会去调用File1Sub的run函数,然后打印Factory的成员的值和地址。
其实file2中也是做类似的事情:
c++
// file2.cpp
void runFile2() {
File2Sub sub;
sub.run();
std::cout << "addr:" << &(Factory<int>::instance().t)
<< ", value:" << Factory<int>::instance().t << std::endl;
}
然后我们再来看下file1_sub和file2_sub的run做了什么事情,在这之前还扔需要看下factory_register文件,因为这两个类会用到:
c++
// factory_register.h
struct FactoryRegister
{
FactoryRegister(int val) {
Factory<int>::instance().t = val;
}
};
FactoryRegister仅仅就是在构造函数中调用一下Factory并给其成员赋值。
继续看下file1_sub和file2_sub
c++
// file1_sub.cpp
void File1Sub::run() {
FactoryRegister r(12);
}
// file2_sub.cpp
void File2Sub::run() {
FactoryRegister r(22);
}
最简单的语言来说就是,file_sub来设定单例的值,file来获取单例的值。
这里看到file_sub1,file_sub2,file1,file2使用的是同一个单例对象。不过稍微绕一点的是使用factory_register来赋值,这在实际项目中也是会遇到的,假如你想在main函数之前就将factory注册成功呢,就需要一个static或者全局变量来操作factory,这里就是提供了factory_register这个实现。
我们使用如下指令来编译:
shell
# 编译main,dlopen需要用到dl库
g++ main.cpp -ldl -o main
# 编译file_sub库
g++ file1/file1_sub/file1_sub.cpp -fPIC -shared -o libfile1_sub.so
g++ file2/file2_sub/file2_sub.cpp -fPIC -shared -o libfile2_sub.so
# 编译file库(需要依赖file_sub库)
g++ file1/file1.cpp -fPIC -shared -L. -lfile1_sub -o libfile1.so
g++ file2/file2.cpp -fPIC -shared -L. -lfile2_sub -o libfile2.so
然后我们运行main试试:
c++
# ./main
addr:0x7f04b67cf06c, value:12
addr:0x7f04b67cf06c, value:22
一切完美,都是相同的变量地址,值也设定成功了。
不过我的问题也不是出现在这里,项目中使用qnx,我们编译完运行的结果却是这样的:
c++
# ./main
addr:111cf37048, value:12
addr:111cf5d048, value:0
是不是很意外,地址不一样也就算了,关键的值还没有赋值成功,太诡异了。
问题分析
我们先在linux上分析一波,我们猜想问题肯定出现在factory和factory_register这两个文件,我们在各个库上看下这两个符号:
c++
# nm -C libfile1.so | grep "Factory"
000000000020106c u Factory<int>::instance()::f
# nm -C libfile2.so | grep "Factory"
000000000020106c u Factory<int>::instance()::f
# nm -C libfile1_sub.so | grep "Factory"
0000000000000914 W FactoryRegister::FactoryRegister(int)
000000000020104c u Factory<int>::instance()::f
# nm -C libfile2_sub.so | grep "Factory"
0000000000000914 W FactoryRegister::FactoryRegister(int)
000000000020104c u Factory<int>::instance()::f
我只罗列了关键的信息,可以看到factory的单例对象在四个库中都有,FactoryRegister::FactoryRegister构造函数就只有在file_sub库中有。
然后我观测qnx编译的库也是类似的。
那我们也先不要FactoryRegister,赋值的地方直接调用Factory的instance来设定,看下运行结果:
# qnx
addr:111cf37048, value:12
addr:111cf5d048, value:22
虽然地址不一样,但是值确实是赋值成功了,也可以达到预期。
这里其实到了一个相对盲区的地方,一般来说我们其实是动态库之间不应该出现相同符号的。
到这里我们其实也应该知道了大致的原因了,就是因为qnx和linux上使用dlopen时针对同名符号解析是不同的。
通过在qnx上符号的地址查看,可以得出下图:
libfile1.so对factory的引用都是在自己所在的so中,libfile1_sub.so对factory的引用是在libfile1.so,但是libfile2_sub.so对FactoryRegister引用需要到libfile1_sub.so中,进一步到libfile1.so中对factory设定。
所以在libfile.so中获取的factory的地址是不一样的。而libfile2.so对factory成员值的获取是0。
关于dlopen
我们使用的dlopen的mode是RTLD_LAZY | RTLD_GLOBAL
。
- RTLD_LAZY表示该库函数符号会延迟到使用调用时采取解析重定位等,与之相反的是RTLD_NOW。
- RTLD_GLOBAL表示该库中的符号会加入到全局符号表中,以便于后边使用dlopen的库使用。与之相反的是RTLD_LOCAL表示该库的符号仅给该组中库使用。(这里的组表示该库及随之一起加载的依赖的库)
由上边的排查,我们知道实际上是由于dlopen函数对相同符号解析位置的设定导致这个问题的出现,我们打开qnx的官方文档对于dlopen符号查找位置顺序解释:
- 加载的动态库
- LD_PRELOAD环境变量指定ELF文件(这里我们没有用到)
- 全局列表
- 加载的动态库所依赖的动态库
那我们再回来看下各个符号的查找细节:
- file1中factory的符号在本库是有的,对factory引用就直接到本库中找就行的。
- file1_sub中FactoryRegister放到全局列表中,file1_sub中FactoryRegister对factory的引用就会优先到file1中查找。
- file2的factory也是定位到本库的
- file2_sub对FactoryRegister引用就会先到file2中查找,但是file2中是没有的,然后回去全局列表中查找,就找到了file1_sub中。
所以对file2就不会拿到预期的值,去掉FactoryRegister的引用就可以到预期了。
总结
本文我们从例子中看出来dlopen的解析符号的位置和顺序会影响程序的正确性。
我们大致总结三点:
- dlopen等函数不仅仅是依赖于运行时库还依赖操作系统,不同操作系统上表现可能不一样
- 尽量不要多个不同的ELF文件含有相同的符号,比如这个例子中我们就可以让单独一个so库对factory及factory_register进行封装,大家都使用这个库保证符号的单一性。
- 排查问题时可以使用readelf,nm,pmap等指令查看elf文件中的符号,以及运行时符号所在的位置等