动态库与单例
背景
好久没有写博客了,有两个原因。一是工作强度有点大,一时间难以适应,一有空闲时间就像休息睡觉 : ( ; 二是确实不知道写什么,工作内容基本以业务为主,也属于保密内容,没啥好写的。现在稍微有点时间来回顾一下了,个人对动态链接相关的认知还比较浅显,所以写一篇博客回顾并加深映像,也方便后续的深入学习。
这篇博客内容与工作中一次DEBUG有关,探讨的话题是 动静态库混用的情况下,单例还能不能保证是“单例”,及其延申出的一系列其他话题,特别是动态库符号查找的相关内容
本文组织结构如下:
- 用一个简化的例子引出动态库与单例的问题
- 简单回顾动态链接相关知识,其中与本文强相关以及本人之前不怎么熟悉的内容是动态链接时的符号解析规则
- 简单叙述一下各种编译链接方式为什么可行\不可行
- 回顾(工作)项目代码,总结如何避免这类错误
注:所有实验在linux平台完成,编译器为 Glang++
一个简化的栗子
C++11以上可以使用函数局部static变量的方式实现单例模式,并在头文件中(singleton.h)声明单例获取函数,在源文件(singleton.cpp)中实现该函数;plugin1.cpp 和 plugin2.cpp 各自引用singleton.h并打印单例对象地址:
main函数使用dlopen打开plugin1和plugin2库,分别调用其中的函数打印单例对象地址。如果“单例”真的是“单例”,这两个变量的地址应该相同。最后,选择使用CMake编译这个例子,自己手动编写编译命令的话过于痛苦了,后续可以使用 VERBOSE=1 make
观察cmake自动生成的编译命令。注意这里singleton编译成了静态库,plugin1和plugin2编译成动态库。
编译并执行main程序,“单例对象”执行两次构造函数,且打印出的地址不相同,说明在动静态库混用的情况下,并以上述方法进行编译、链接和执行,单例模式不能保证对象的单一实例化:
copy
- 1
- 2
- 3
- 4
- 5
❯ ./main
Singleton ctor
f: 0x7f5983187070
Singleton ctor
g: 0x7f5983182070
通过查阅资料,发现有两种方法解决上述问题:
-
main.cpp中dlopen函数传入参数或上RTLD_GLOBAL,singleton可保持静态链接的方式:
copy- 1
- 2
- 3
- 4
- 5
- 6
// mian.cpp // ... void* fd = dlopen("./libplugin1.so", RTLD_GLOBAL|RTLD_LAZY); // ... fd = dlopen("./libplugin2.so",RTLD_GLOBAL|RTLD_LAZY); // ...
-
singleton编译成共享库:
copy- 1
- 2
- 3
- 4
// CmakeLists.txt // ...... add_library(singleton SHARED singleton.cpp) // ...
下面,首先理一下动态链接相关内容,然后阐述为什么一开始的编译链接方式会导致单例对象初始化两次,为什么上述的两个改动能正确运行单例模式。
方便起见,把一开始出错的编译链接方法记作方式一,后面两种改进方法分别记作方式二、方式三和方法四
当然,可行方法不止两种,下文也会引出另外两种可行的方法,但重要的是理清动态库的一些运行机制。
动态链接简单回顾
PIC与GOT
PIC,position independent code,位置无关代码。能够生成位置无关代码是因为:代码段与数据段的相对距离是一个常量。PIC使得动态库可以被加载到地址空间的任意内存地址,代码段对数据的引用使用间接地址即可,GOT就是为了实现PIC引入的一个数据结构。
GOT,Global Position Table,全局偏移表。为了实现PIC,在数据段头部添加GOT,代码段引用数据时,获取到GOT的绝对地址以及GOT对应的索引即可对数据进行间接引用。
GOT的每个表项存储符号的绝对地址,符号包括变量名和函数地址,加载某个动态库时,由动态链接器修改GOT表项使之成为运行时的实际符号地址。
现在假设动态链接器已经在GOT[3]中填写好了var变量的真实地址,下图展示了执行“var++”时的变量寻址过程:
PLT
对于函数名的解析和动态链接,使用lazy binding的技巧,使得函数名的解析延迟到第一次调用该函数时才发生。因此,又引入了PLT,ProcedureLinkageTable,该表存放于代码段。可参考这篇CSAPP的笔记。
一图概括其流程(图源《深入理解计算机系统》):
对照一下GDB感受一下这个过程(TUDO):
符号解析
未在静态链接过程中确认的符号需要在加载时动态解析,可用 readelf -d <soname> 查看,那些Ndx Name 为UND的,就是需要动态解析的符号。
dlopen的[man page](dlopen(3) - Linux manual page)对其符号解析规则有简单介绍:
copy
- 1
- 2
- 3
- 4
- 5
- 6
Symbol references in the shared object are resolved using (in order): symbols in the link map of objects loaded for the main program and its dependencies; symbols in shared objects (and their dependencies) that were previously opened with dlopen() using the RTLD_GLOBAL flag; and definitions in the shared object itself (and any dependencies that were loaded for that object).
再结合这篇文章的1.5.4节,总结动态链接的符号解析规则如下:
- 未定义的符号主要在两个scope中查找:
- Global:executable程序(个人理解是main程序)本身及其依赖的动态库(包括动态链接其的so库)组成的符号集合,依赖的动态库可用ldd <soname>查看
- Local:在执行过程中使用dlopen打开的动态库及其依赖库组成的符号集合;每个dlopen一个动态库,就引入一个Local scope。
- 动态库符号查找的默认顺序是:现在Global中查找,再去Local Scope查找
- 一般情况下,Global符号集与Local符号集互不干扰,但是如果使用RTLD_GLOBAL属性dlopen一个动态库,那么这个动态库及其所依赖的符号集将被“合并”到Global符号集中,但文章不推荐这样的做法
方式一为什么出错
方法一: 静态编译singleton库,动态编译plugin1、plugin2 并且都静态链接singleton库
动态库libplugin*.cpp以静态链接的方式链接singleton库,main函数运行时的虚拟地址空间示意图如下图所示。
程序执行前,动态链接器将程序依赖的所有动态库加载并映射至进程的地址空间中。
程序在调用dlopen时,会将相应的动态库加载至进程虚拟空间,如图的libplugin1.so和libplugin2.so。
因为singleton.cpp的编译方式是静态库,“嵌”在了libplugin*.cpp中,在这段程序中,存在两个LocalScope,各自定义了一个Singleton::Instance符号。
根据之前的查找规则,plugin1和plugin2解析符号时先在Global中查找,但GlobalScope没有Singleton::Instance这个符号,转向各自的Localscope中找到对应的定义。所以动态连接器分别在两个local scope中各找到一个Singleton::Instance函数,这两个函数使用的GOT不相同。
最终,plugin1和plugin2调用的Singleton::Instance函数不相同,分别使用不同的GOT表项间接引用Singleton实例,最终返回了不同的Singleton实例。
方式二为什么可行
方法二: 编译方式与方式一相同,但主程序main在dlopen是传入RTLD_GLOBAL参数
方式二在dlopen时传入了RTLD_GLOBAL参数,所以LocalScope的符号“合并”在GlobalScope中。plugin2在解析符号时,辉县找到plugin1定义的Singleton::Instance函数,因此使用plugin1中的定义。在这种情况下,plugin1和plugin2调用的Singleton::Instance函数是同一个,只用了一个GOT,间接引用的Singleton对象也是唯一的。
方法三为什么可行
方法三:将singleton编译成动态库;动态编译plugin1和plugin2,并都动态链接single库
在dlopen liblugin1.so时,会将libplugin1.so本身及其依赖(其中也包括libsingleton.so)打开,并映射至虚拟地址空间。
dlopen libplugin2.so时,会将libplugin2.so本身及其依赖库加载至内存,但其依赖的libsingleton.so已经被加载,因此动态链接器不再重复执行加载步骤——这是使用动态库的初衷之一:节省系统内存资源
最后形成的搜索范围中,同样有两个LocalScope,但是只有一个libsingleton.so,所以Singleton::Instance函数是唯一的,Singleton对象也是唯一的。
回顾项目代码(方法四)及总结
再回顾项目代码,发现它没有使用方法二、三、四中的任何一个。捋了一遍其中的编译链接关系,得到了方法五——Singleton编译成静态库libsingleton.a,plugin1静态链接libsingleton.a,plugin2动态链接libplugin1.so:
copy
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
// CmakeLists.txt
cmake_minimum_required(VERSION 3.16)
PROJECT(test_singleton)
set(CMAKE_CXX_COMPILER clang++)
add_complie_option(-fPIC)
add_library(plugin1 SHARED plugin1.cpp)
add_library(plugin2 SHARED plugin2.cpp)
add_library(singleton STATIC singleton.cpp)
target_link_libraries(plugin1 singleton)
target_link_libraries(plugin2 plugin1) # plugin 动态链接plugin1
add_executable(main main.cpp)
target_link_libraries(main dl)
编译运行,单例农事运行正确。因为libplugin2.so调用的Singleton::Instance被路由到了libplugin1.so中的实际函数地址。所以这个动态库使用同一个函数。这里的图也不画了,都是类似的(懒得画了)。
总结:
- 单例编译成静态库时,一定要注意它与动态库的链接关系。若编译链接的方式不正确,会导致单例模式失效。
- 最简单的可行方法就是将单例编译成动态库,由于动态链接器和操作系统能够保证所有程序在运行时只会存在一个动态库的唯一实例,所以这种方法在大部分场景下都能够保证单例模式的正确性。
- 但是为什么项目中把单例编译成静态库呢?可能是时间与空间的tradeoff 吧,下面仅仅是一些猜测
- 动态库节省了空间,但是增大了程序执行的时间开销。静态库则相反。
- 项目中的单例库规模很小,所以编译成动态库不会获得太多空间上的好处,反而会降低了执行效率。使用动态库的劣势比优势大,所以选择使用静态库的方式编译
参考
dlopen(3) - Linux manual page)
PIC GOT PLT OMG: how does the procedure linkage table work in linux?
本文作者:别杀那头猪
本文链接:https://www.cnblogs.com/HeyLUMouMou/p/18639578
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
2023-02-09 C++11新特性总结