Linux下动态链接库版本管理及查找加载方式
作为一名经常在Linux下从事开发的工程师来说,应该很多人都遇到过找不到so库的问题,特别是在一些涉及到C/C++依赖的项目中。在本公众号之前文章中介绍的使用Cython加速Python程序的例子中,Python这类解释型语言也会调用一些C/C++编译出来的so库。为了让大家能够面对例如下面这种错误时不再手足无措,我整理了这篇文章。
error while loading shared libraries: libxxx.so.2: cannot open shared object file: No such file or directory
Linux下so的版本机制介绍¶
如果大家在自己的linux系统上执行 ls -l /usr/lib64
这条命令,则会看到很多具有下列特征的软连接,其中x、y、z为数字, 那么这些软连接和他们后面的数字有什么用途呢?
这里的x,y,z分别代表的是这个so的主版本号(MAJOR),次版本号(MINOR),以及发行版本号(RELEASE),对于这三个数字各自的含义,以及什么时候会进行增长,不同的文献上有不同的解释,不同的组织遵循的规定可能也有细微的差别,但有一个可以肯定的事情是:主版本号(MAJOR)不同的两个so库,所暴露出的API接口是不兼容的。
而对于次版本号,和发行版本号,则有着不同定义,其中一种定义是:次要版本号表示API接口的定义发生了改变(比如参数的含义发生了变化),但是保持向前兼容;而发行版本号则是函数内部的一些功能调整、优化、BUG修复,不涉及API接口定义的修改。
几个so库有关名字的介绍¶
在开始这一节之前,我们先来做一个小的测试,屏幕不要往下滑动太多啊,要不你就提前看到答案了:)
问题:有如下几个so库的名字,你认为对于一个程序的运行,那个名字是最重要的呢?
- libfoo.so
- libfoo.so.1
- libfoo.so.1.1
- libfoo.so.1.1.1
从直觉上,我猜你选择了libfoo.so
这个不带任何数字后缀的答案,但实际上,这个不带任何数字后缀的文件名可能是用处最少的一个文件,真正在一个程序的运行过程中起到定位到某一个so功能的,实际上是带有一个数字后缀的libfoo.so.1
这种形式的文件名。为了证明我说的有道理,我们可以在linux下执行ldd
命令来查看一个可执行文件到底都依赖了哪些so库,例如我们可以执行ldd /bin/bash
来查看一下bash这个可执行文件运行时依赖的so库,在我的PC上输出结果如下:
$ ldd /bin/bash
linux-vdso.so.1 => (0x00007ffffd0cc000)
libtinfo.so.5 => /lib64/libtinfo.so.5 (0x00007fa2b3a49000)
libdl.so.2 => /lib64/libdl.so.2 (0x00007fa2b3845000)
libc.so.6 => /lib64/libc.so.6 (0x00007fa2b3482000)
/lib64/ld-linux-x86-64.so.2 (0x00007fa2b3c73000)
可以看到,这里显示的都是形如libfoo.so.x
这样带有一个数字后缀的文件
好了,下面我们来介绍在so查找过程中的几个名字:SONAME
、real name
、linker name
,其中SONAME
是业界通用名称,而real name
和linker name
这两个叫法是我从参考文献[1]中借鉴的。
-
SONAME
是一组具有兼容API的SO库所共有的名字,其命名特征是lib
+<库名>
+.so.
+<数字>
这种形式的 -
real name
是真实具有SO库可执行代码的那个文件,之所以叫
real
SONAME
linker name
real name
real name
real name
libdns.so.100.1.1
libdns.so.100
libdns.so.100.1.1
real name
- ```
lrwxrwxrwx. 1 root root 17 Feb 7 2018 libdns.so.100 -> libdns.so.100.1.1
-rwxr-xr-x. 1 root root 1.9M Aug 4 2017 libdns.so.100.1.1
linker name
这个名字只是给编译工具链中的连接器使用的名字,和程序运行并没有什么关系,仅仅在链接得到可执行文件的过程中才会用到。它的命名特征是以lib
开头,以.so
结尾,不带任何数字后缀的格式
SONAME
的作用¶
假设在你的Linux系统中有3个不同版本的bar
共享库,他们在磁盘上保存的文件名如下:
- /usr/lib64/libbar.so.1.3
- /usr/lib64/libbar.so.1.5
- /usr/lib64/libbar.so.2.1
假设以上三个文件,都是真实的so库文件,而不是软连接,也就是说,上面列出的名字都是real name
。
根据我们之前对版本号的定义,我们可以知道:
libbar.so.1.3
和libbar.so.1.5
之间是互相兼容的libbar.so.2.1
和上述两个库之间互相不兼容
我们再假设你有两个不同的程序A
和B
,其中A程序依赖libbar.so.1.5
这个库文件,而B程序依赖libbar.so.2.1
这个库文件。但实际上,在A
和B
两个程序中,并没有写明自己所依赖的是libbar.so.1.5
和libbar.so.2.1
,真正保存在A
和B
中的是两个库的SONAME
,也即libbar.so.1
和libbar.so.2
。然后,再通过软链接的形式,将libbar.so.1
链接到libbar.so.1.5
,将libbar.so.2
链接到libbar.so.2.1
。
那么引入软连接的好处是什么呢?假设有一天,libbar.so.2.1
库进行了升级,但API接口仍然保持兼容,升级后的库文件为libbar.so.2.2
,这时候,我们只要将之前的软连接重新指向升级后的文件,然后重新启动B
程序,B
程序就可以使用全新版本的so库了,我们并不需要去重新编译链接来更新B
程序。
总结一下上面的逻辑:
- 通常
SONAME
是一个指向real name
的软连接 - 应用程序中存储自己所依赖的SO库的
SONAME
,也就是仅保证主版本号能匹配就行 - 通过修改软连接的指向,可以让应用程序在互相兼容的SO库中方便切换使用哪一个
- 通常情况下,大家使用最新版本即可,除非是为了在特定版本下做一些调试、开发工作
linker name
的作用¶
上一节中我们提到,可执行文件里会存储精确到主版本号的SONAME
,但是在编译生成可执行文件的过程中,编译器怎么知道应该用哪个主版本号呢?为了回答这个问题,我们从编译链接的过程来梳理一下。
假设我们使用gcc编译生成一个依赖foo
库的可执行文件A
:
熟悉gcc编译的读者们肯定知道,上述的-l
标记后跟随了foo
参数,表示我们告诉gcc在编译的过程中需要用到一个外部的名为foo的库,但这里有一个问题,我们并没有说使用哪一个主版本,我们只给出了一个名字。为了解决这个问题,软链接再次发挥作用,具体流程如下:
根据linux下动态链接库的命名规范,gcc会根据-lfoo
这个标识拼接出libfoo.so
这个文件名,这个文件名就是linker name
,然后去尝试读取这个文件,并将这个库链接到生成的可执行文件A
中。在执行编译前,我们可以通过软链接的形式,将libfoo.so
指向一个具体so库,也就是指向一个real name
,在编译过程中,gcc会从这个真实的库中读取出SONAME
并将它写入到生成的可执行文件A
中。例如,若libfoo.so
指向libfoo.so.1.5
,则生成的可执行文件A
使用主版本号为1的SONAME
,即libfoo.so.1
。
在上述编译过程完成之后,SONAME
已经被写入可执行文件A
中了,因此可以看到linker name
仅仅在编译的过程中,可以起到指定连接那个库版本的作用,除此之外,再无其他作用。
总结一下上面的逻辑:
- 通常
linker name
是一个指向real name
的软连接 - 通过修改软连接的指向,可以指定编译生成的可执行文件使用那个主版本号so库
- 编译器从软链接指向的文件里找到其
SONAME
,并将SONAME
写入到生成的可执行文件中 - 通过改变
linker name
软连接的指向,可以将不同主版本号的SONAME
写入到生成的可执行文件中
探索可执行程序运行时的so加载过程¶
上一节我们详细讨论了编译过程中,编译器是如何将依赖信息写入到可执行文件的。在接下来的部分,我们讨论当应用被运行时,Linux操作系统是如何读取并使用这些依赖信息并最终加载依赖的so库的。
加载so的搜索路径及干预方式¶
当在linux系统中启动一个可执行文件时,首先发挥作用的是程序加载器(program loader),这个加载器也是一个so文件,通常具有ld-linux.so.X
这样的文件名,其中的X是版本号。大家可以回顾一下,在上文中我们用ldd /bin/bash
查看了bash所依赖的so库有哪些,其中就有/lib64/ld-linux-x86-64.so.2
这个文件。其实,你可以尝试用ldd
去检查任何一个可执行文件,你都会看到这个加载器的影子。linux下的elf格式的可执行文件在运行时,首先加载ld-linux.so
,再由这个加载器去加载其他的so文件,当其他so文件都已经加载完成之后,我们自己编写的main函数才会被执行。
加载器会在以下几个地方进行so库的搜索,搜索顺序为从上至下,如果这些信息不存在,或者在对应的路径下找不到能够加载的文件,那么就尝试下一项,如果所有的都找不到,那就会报出文章开头展示出的找不到so库的错误信息:
rpath
信息,编译链接时写入到可执行文件内部的数据LD_LIBRARY_PATH
环境变量runpath
信息,编译链接时写入到可执行文件内部的数据/etc/ld.so.conf
文件中列出的路径/lib
、/usr/lib64
等系统默认路径
我们首先明确一点:绝大多数靠谱的应用程序都不会用到前面3项,仅依靠最后两项就可以运行,还有一些程序会用到前面3项,但是开发者已经提供好了对应的工具,使得用户不必去手工配置这些内容。而对于一些自己开发、内部使用、处于调试阶段的程序等,由于做的不够到位,可能导致需要配置前3项才可以让程序正常运行,而这种情况也往往是在工作中困扰我们最多的情况。
对于上述的rpath
和runpath
两项,都是在编译可执行文件时,由链接器写入到可执行文件中的信息,唯一的区别是这两项相对于LD_LIBRARY_PATH
环境变量的位置,也就是说,rpath
中指定的搜索路径不可以被LD_LIBRARY_PATH
环境变量中指定的路径覆盖,而runpath
中指定的内容却可以被覆盖。
rpath
和runpath
内可以记录一个绝对路径,也可以记录一个相对路径。其绝对路径的表达方式和linux操作系统一致,使用一个以/
开头的路径,就可以表示这是一个绝对路径。但相对路径有两种表达形式,一种是以./
开头,表示相对于当前的工作目录,另一种是使用$ORIGIN
这个特殊的记号来开头,表示相对于可执行文件所在的位置。
那么,rpath
是如何写入到可执行文件中的呢?以gcc为例,在使用gcc编译的过程中,可以通过-Wl
开关向链接器传递-rpath
参数来指定,例如下面的这一个命令,把$ORIGIN
作为rpath
写入到可执行文件中,即表示优先搜索可执行文件所在目录下有没有可以加载的so库, 其中的\$
转义是为了避免shell将其理解为shell环境变量:
我们再来结合这个编译命令,来回顾一下上一节提到的几个名字:
-
-lfoo
告诉编译器,我需要一个叫做foo
的库,于是gcc根据命名规则,拼接出libfoo.so
这个linker name
,但是去哪里找这个linker name
呢? -
-L.
告诉gcc,优先在当前工作目录下去找
libfoo.so
/usr/lib64
等默认路径去查找了。
- 前文说过,`linker name`通常是一个软连接,指向一个`real name`,这种情况在`/usr/lib64`等路径下很常见。但假设这里的`libfoo.so`也是一个我们刚刚编译生成的so文件,仅仅在开发阶段,我们也不关心什么版本管理问题,那么此时,可能软连接并不存在。这时`libfoo.so`本身既是`linker name`也是`real name`
- 编译器根据`-L.`的指示,在当前工作目录下找到了`libfoo.so`,并从中读取出了`SONAME`,假设为`libfoo.so.1`
- 编译器将`SONAME` `libfoo.so.1` 写入到生成的目标文件中
- 接下来,gcc调用链接器将目标文件和so库做链接,并生成最终的可执行文件。
- 由于`-Wl,-rpath,"\$ORIGIN"`命令的存在,gcc在调用链接器的时候,会把`-rpath $ORIGIN`这个参数传递给链接器,链接器将`$ORIGIN`作为`rpath`写入到最终生成的可执行文件中。
- 下面运行程序,程序开始运行,首先是加载器`ld-linux.so`被加载,加载器检查程序依赖的所有`SONAME`,发现程序依赖`libfoo.so.1`,但是去哪找这个so文件呢?
- 加载器发现可执行文件里有
rpath
$ORIGIN
SONAME
SONAME
是否匹配,如果匹配,则成功加载,如果不匹配,则尝试下一个
- 注意,这一步其实是有缓存的,从而加速程序的启动速度。缓存文件是`/etc/ld.so.cache`,有兴趣的同学可以`man ld.so`来了解详情
相比于`rpath`和`runpath`这两个被烙印到可执行文件中的配置而言,环境变量`LD_LIBRARY_PATH`就是一个非常易于修改的配置,因此,通过提供`LD_LIBRARY_PATH`环境变量,其实是我们解决找不到依赖库最常用的一个手段。
- 当然,`rpath`和`runpath`也是可以被修改的,有专用的工具如`chrpath`
### 例外情况
上述所介绍的搜索顺序,在绝大多数场景下都是适用的,但有一个场景不使用,即在使用`setuid`、`setgid`、`chmod +s`等手段,使得一个程序可以以root身份去执行的时候:
- `LD_LIBRARY_PATH`环境变量会被忽略
- `rpath`和`runpath`中包含`$ORIGIN`的会被忽略
以上原因是出于安全性考虑的,避免一个特权程序会因为环境变量的改变,或者文件被复制到其他路径,而加载了被恶意替换的so库。详细内容大家可以参考CVE-2010-3847,或者`man ld.so`。另外需要提示的是,`ldd`命令在执行时并不会受到这个安全策略的影响,所以,有两点需要注意:
- 有可能出现`ldd`报告显示依赖的so都可以找到,但实际执行这个文件就是报找不到的情况
- `ldd`在检测依赖的时候,相当于以一种特殊的方式执行了那个可执行文件,因此存在安全隐患,建议不要对存在风险的可执行文件使用`ldd`查看其依赖
## 实际情况下的使用过程
对于绝大多数通过软件包管理器安装的程序,apt-get、yum这些工具,都会帮你把需要的so放到系统默认路径下,而且大部分应用也不需要将`rpath`烙印到可执行文件中,所以绝大多数情况下,仅使用上述介绍的最后两个搜索位置就可以找到需要的文件。
有一些奇特的开发者,比如知名的mozilla,传说他们的应用程序加载so库的路径不寻常,但mozilla提供了一个包装,在启动浏览器之前会帮我们临时设置好环境变量,所以作为用户来说,我们感知不到什么。
最有可能出现问题的,就是在我们自己的项目中引入了隔壁项目组开发的so库,或者从网上直接下载了一些tar包后解压缩直接使用,这时,如果出现问题,需要排查这几点:
- so库的版本是否正确
- `LD_LIBRARY_PATH`环境变量设置是否正确
- 在链接时,是否加入了`rpath`限定只能从特定位置加载so
- 是否在特权模式下运行
## 写在最后
上文中一直在提到`SONAME`,并且说一个库,可以有不同的版本,不同的主版本对应不同的`SONAME`,那么如何修改生成的so文件中的`SONAME`呢?答案是依然使用`-Wl`参数,指示链接器写入,具体示例如下:
gcc -shared -Wl,-soname,libfoo.so.233