一文读懂Linux下动态链接库版本管理及查找加载方式

原文地址

作为一名经常在Linux下从事开发的工程师来说,应该很多人都遇到过找不到so库的问题,特别是在一些涉及到C/C++依赖的项目中。Python这类解释性语言也会调用一些C/C++编译出来的so库来弥补自身性能的不足。为了让大家能够面对例如下面这种错误时不再手足无措我整理了这篇文章。
error while loading shared libraries: libxxx.so.2: cannot open shared object file: No such file or directory

1. Linux下so的版本机制介绍

如果大家在自己的linux系统上执行 ls -l /usr/lib64 这条命令,则会看到很多具有下列特征的软连接,其中x、y、z为数字,
那么这些软连接和他们后面的数字有什么用途呢?
libfoo.so    ->  libfoo.so.x
libfoo.so.x  ->  libfoo.so.x.y.z
libbar.so.x  ->  libbar.so.x.y
这里的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的命名格式中,可能有2个数字尾缀,也可能有3个数字尾缀,
但这不重要。你只要记住,真实的那个,不是以软连接形式存在的,就是一个real name。例如下面的两个文件,libdns.so.100.1.11.9M大
小,而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的作用

2. 编译器怎么知道应该用哪个主版本号呢

上一节中我们提到,可执行文件里会存储精确到主版本号的SONAME,但是在编译生成可执行文件的过程中,编译器怎么知道应该用哪个主版本号
呢?为了回答这个问题,我们从编译链接的过程来梳理一下。
假设我们使用gcc编译生成一个依赖foo库的可执行文件A:
gcc A.c -lfoo -o 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仅仅在编译的过程中,可以起到指定连接那个库版本

的作用,除此之外,再无其他作用。

3. 总结一下上面的逻辑:

通常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环境变量:
gcc -o main main.c -lfoo -L. -Wl,-rpath,"\$ORIGIN"
我们再来结合这个编译命令,来回顾一下上一节提到的几个名字:
-lfoo 告诉编译器,我需要一个叫做foo的库,于是gcc根据命名规则,拼接出libfoo.so这个linker name,但是去哪里找这个linker name
呢?
-L. 告诉gcc,优先在当前工作目录下去找libfoo.so, 如果不指定这个,则gcc就会默认去/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,于是在可执行文件所在的目录下开始寻找所有的so文件,并检查其中的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
此外,系统中的ldconfig命令可以帮我们来维护系统中的各种依赖关系的软连接,有兴趣的同学可以自己去深入研究。毕竟手动去创建那些软连接
很容易出错。

参考文献
[1]http://tldp.org/HOWTO/Program-Library-HOWTO/shared-libraries.html
[2]https://amir.rachum.com/blog/2016/09/17/shared-libraries/
[3]https://en.wikipedia.org/wiki/Rpath
[4]https://gitlab.kitware.com/cmake/community/-/wikis/doc/cmake/RPATH-handling

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值