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

原文地址

https://blog.ideawand.com/2020/02/15/how-does-linux-shared-library-versioning-works/

09661-0fdnynblxbki.png

作为一名经常在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为数字, 那么这些软连接和他们后面的数字有什么用途呢?

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查找过程中的几个名字:SONAMEreal namelinker name,其中SONAME是业界通用名称,而real namelinker 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.1


有1.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.3libbar.so.1.5之间是互相兼容的
  • libbar.so.2.1和上述两个库之间互相不兼容

我们再假设你有两个不同的程序AB,其中A程序依赖libbar.so.1.5这个库文件,而B程序依赖libbar.so.2.1这个库文件。但实际上,在AB两个程序中,并没有写明自己所依赖的是libbar.so.1.5libbar.so.2.1,真正保存在AB中的是两个库的SONAME,也即libbar.so.1libbar.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 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仅仅在编译的过程中,可以起到指定连接那个库版本的作用,除此之外,再无其他作用。

总结一下上面的逻辑:

  • 通常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项才可以让程序正常运行,而这种情况也往往是在工作中困扰我们最多的情况。

对于上述的rpathrunpath两项,都是在编译可执行文件时,由链接器写入到可执行文件中的信息,唯一的区别是这两项相对于LD_LIBRARY_PATH环境变量的位置,也就是说,rpath中指定的搜索路径不可以被LD_LIBRARY_PATH环境变量中指定的路径覆盖,而runpath中指定的内容却可以被覆盖。

rpathrunpath内可以记录一个绝对路径,也可以记录一个相对路径。其绝对路径的表达方式和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

Last modification:April 29th, 2021 at 07:22 pm
安安,亲觉得有用, 请随意打赏嗷