Linux共享库的组织

注意: 该内容属于摘抄部分, 摘抄内容源于**<程序员的自我修养–链接, 装载与库>**一书

由于动态链接的诸多优点,大量的程序开始使用动态链接机制, 导致系统里面存在数量极为庞大的共享对象。如果没有很好的方法将这些共享对象组织起来, 整个系统中的共享对象文件则会散落在各个目录下. 操作系统一般会对共享对象的目录组织和使用方法有一定的规则.

共享库兼容性

共享库的开发者会不停地更新共享库的版本, 以修正原有的Bug, 增加新的功能或改进性能等. 由于动态链接的灵活性, 使得程序本身和程序所依赖的共享库可以分别独立开发和更新,比例当有程序A依赖于libfoo.so, 当libfoo.so的开发者宣布新版本开发完成之后, 理论上我们只需要用新的libfoo.so将旧版本替换掉即可享用新版libfoo.so提供的一切好处. 但共享库版本的更新可能会导致接口的更改或删除, 这可能导致依赖于该共享库的程序无法正常运行. 最简单的情况下, 共享库的更新可以被分为两类.

  • 兼容更新. 所有的更新知识在原有的共享库基础上添加一些内容, 所有原有的接口都保持不变.
  • 不兼容更新. 共享库更新改变了原有的接口, 使用该共享库原有接口的程序可能不能运行或运行不正常.

在这里, 我们讨论的接口是二进制接口, 即**ABI(Application Binary Interface). 共享库的ABI和程序语言有着很大的关系, 不用的语言对于接口的兼容性要求不同. ABI对于不同的语言来说, 主要包括一些诸如函数调用的堆栈接口, 符号命名, 参数规则, 数据结构的内存分布等方面的规则.

要破坏一个共享库的ABI十分容易, 要保持ABI的兼容缺十分困难. 很多因素会导致ABI的不兼容, 比如不同版本的编译器, 操作系统和硬件平台等, 使得ABI兼容尤为困难. 使用不同版本的编译器或系统库可能会导致结构体的成员对齐方式不一致, 从而导致了ABI的变化. 这种ABI不兼容导致的问题可能非常微妙, 表面上看可能无关紧要, 但是一旦发生故障, 相关的Bug非常难以定位, 这也是共享库很大的一个问题.

对于C++来说, ABI问题就更为严重了. 由于C++非常复杂, 它支持诸如模板等一些高级特性, 这些特性对于ABI兼容来说简章就是灾难. 因为C++标准对于C++的ABI没有做出规定, 所以不同的编译器甚至同一个编译器的不同版本对于C++的一些特性的实现都有着各自的方案, 而且互相不兼容, 比如虚函数表, 模板实例化, 多重继承等.

共享库版本命名

既然共享库操作这样那样的兼容性问题, 那么保持共享库在系统红的兼容性, 保证依赖于他们的应用程序能够正常运行是必须要解决的问题. 有几种办法可用于解决共享库的兼容性问题, 有效办法之一就是使用共享库版本的方法. Linux有一套规则来命名系统中的每一个共享库, 它规定共享库的文件命名规则必须如下:

1
libname.so.x.y.z

最前面使用前缀”lib”, 中间是库的名字和后缀”.so”, 最后面跟着的是三个数字组成的版本号. “x”表示主版本号(Major Version Number), “y”表示次版本号(Minor Version Number), “z”表示发布版本号(Release Version Number). 三个版本号的含义不一样.

主版本号表示库的重大升级 不同主版本号的库之间是不兼容, 依赖于旧的主版本号的程序要改动相应的部分, 并且重新编译, 才可以在新版的共享库中运行; 或者, 系统必须保留旧版的共享库, 使得那些依赖于旧版共享库的程序能够正常运行.

次版本号表示库的增量升级, 即增加一些新的接口符号, 且保持原来的符号不变. 在主版本号相同的情况下, 高的次版本号的库向后兼容低的次版本号的库. 一个依赖于旧的此版本号共享库的程序, 可以在新的次版本号共享库中运行, 因为新版中保留了原来所有的接口, 并且不改变它们的定义和含义. 比如系统中有个共享库libfoo.so.1.2.x, 后来在升级过程中添加了一个函数, 版本号变成了1.3.x. 因为1.2.x的所有接口都被保留到1.3.x中了, 所以添加了一个函数, 版本号变成了1.3.x. 因为1.2.x的所有接口都被保留到1.3.x中了, 所以哪些依赖于1.1.x或1.2.x的陈旭都可以在1.3.x中正常运行.

发布版本号表示库的一些错误的修正, 性能的改进等, 并不添加任何新的接口, 也不对接口进行更改. 相同主版本号, 次版本号的共享库, 不同发发布版本号之间完全兼容, 依赖于某个发布版本号的程序可以在任何一个其他发布版本号中正常运行, 而无须做出任何修改.

SO-NAME

程序需要记录什么

可以这么说, 共享库的主版本号和次版本号决定了一个共享库的接口. 那么从一个人可执行程序的角度看, 如何表示它依赖于哪些版本的哪些共享库? 或者说在运行是, 动态链接器怎么知道程序依赖于哪些共享库, 它们的版本号又是什么?

我们假设程序中有一个它所依赖的共享库的列表, 其中每一项对应于它所依赖的一个共享库. 可以肯定的是, 程序中必须包含被依赖的共享库的名字和主版本号. 因为我们知道不同主版本号之间的共享库是完全不兼容的, 所以程序中保存一个诸如libfoo.so.2的记录, 以防止动态链接其在运行时意外地将程序于libfoo.so.1或libfoo.so.3链接到一起. 通过这个可以发现, 如果在系统中运行旧的应用程序, 就需要在系统保留旧应用程序所需要的旧的主版本号的共享库.

SO-NAME

对于新的系统来说, 包括Solaris和Linux, 普遍采用一种叫做SO-NAME的命名机制来记录共享库的依赖关系. 每个共享库都有一个对应的”SO-NAME”, 这个SO-NAME即共享库的文件名去掉次版本号和发布版本号, 保留主版本号. 比如一个共享库叫做libfoo.so.2.6.1, 那么它的SO-NAME即libfoo.so.2. 很明显, “SO-NAME”规定了共享库的接口, “SO-NAME”的两个相同共享库, 次版本号的兼容次版本号小的. 在Linux系统中, 系统会为每个共享库在它所在的目录创建一个跟”SO-NAME”相同的并且指向它的”软链接(Symbol Link)”. 比如系统中有存在一个共享库”/lib/libfoo.so.2.6.1”, 那么Linux中的共享管理程序就好为它产生一个软链接”/lib/libfoo.so.2”指向它.

以”SO-NAME”为名字建立软链接的好处在于, 这个软链接会指向目录中主版本号相同, 次版本号和发布版本号最新的共享库. 也就是说, 比如目录中有两个共享库版本分别为: /lib/libfoo.so.1 和 /lib/libfoo.2.5.3, 那么软链接/lib/libfoo.so.2会指向/lib/libfoo.so/2.6.1. 这样保证了所有的以SO-NAME为名的软链接都指向系统中最新版的共享库.

建立以SO-NAME为名字的软链接的目的是, 使得所有依赖某个共享库的抹开, 在编译, 链接和运行时, 都使用共享库的SO-NAME, 而不使用详细的版本号.

总之, SO-NAME表示一个库的接口, 接口不向后兼容, SO-NAME就发生变化, 这是基本的原则

Linux中提供了一个工具叫做”ldconfig”, 当系统中安装或更新一个共享库时, 就需要运行这个工具, 它会遍历所有的默认共享库目录, 比如/lib, /usr/lib等, 然后更新所有的软链接, 使它们指向最新版的共享库; 如果安装了新的共享库, 那么ldconfig会为其创建相应的软链接.

共享库系统路径

目前大多数包括Linux在内的开源操作系统都遵守一个叫做**FHS(File Hierarchy Standard)**的标准, 这个标准规定了一个系统中的系统文件应该如何存放, 包括各个目录的结构, 组织和作用, 这有利于促进各个开源操作系统之间的兼容性. 共享库作为系统中重要的文件, 它们的存放方式也被FHS列入了规定范围. FHS规定, 一个系统中主要有3个存放共享库的位置, 它们分别如下:

  • /lib, 这个位置主要存放系统最关键和基础的共享库, 比如动态链接器, C语言运行库, 数学库等, 这些库主要是哪些/bin和/sbin下的程序所要用到的库, 还有系统启动时需要的库.
  • /usr/lib, 这个目录下主要保存的是一些非系统运行时所需要的关键性的共享库, 主要是一些开发时用到的共享库, 这些共享库一般不会被用户的程序或shell脚本直接用到. 这个目录下面还包括了开发时可能会用到的静态库, 目标文件等.
  • /usr/local/lib, 这个目录用来放置一些跟操作系统本身并不十分相关的库, 主要是一些第三方的应用程序的库. 比如我们在系统中安装了python语言的解释器, 那么与它相关的共享库可能会被放到/usr/local/lib/python, 而它的可执行文件可能被放到/usr/local/bin下. GUN的标准推荐第三方的程序应该默认将库安装到/usr/local/lib下.

所以总体上看, /lib和/usr/lib是一些很常用的, 成熟的, 一般是系统本身所需要的库; 而/usr/local/lib是非系统所需的第三方程序的共享库.

共享库的查找过程

在开源系统中, 包括所有的Liunx系统在内的很多基于Glibc的. 我们知道在这些系统里面, 动态链接的ELF可执行文件在启动时同时会启动动态链接器. 在Linux系统中, 动态链接器是/lib /ld-linux.so.X(X是版本号), 程序所依赖的共享对象全部由动态链接器负责装载和初始化. 我们知道任何一个动态链接的模块所依赖的模块路径保存在”.dynamic”段里面, 由DT_NEED类型的项表示. 动态链接器对应模块的查找有一定的规则: 如果DT_NEED里面保存的是绝对路径, 那么动态链接器就按照这个路径去查找; 如果DT_NEED里面保存的是相对路径, 那么动态链接器会在/lib, /usr/lib和由/etc/ld/so.conf配置文件指定的目录中查找共享库. 为了程序的可移植性和兼容性, 共享库的路径往往是相对的.

ld.so.conf是一个文本配置文件, 它可能包含其他的配置文件, 这些配置文件中存放则目录信息.

如果动态链接器在每次查找共享库时都会遍历这些目录, 那将会非常耗费时间, 所以Linux系统中都有一个叫做ldconfig的程序, 这个程序的作用是为共享库目录下的各个共享库创建, 删除或更新相应的SO-NAME(即相应的符号链接), 这样每个共享库的SO-NAME就能够指向正确的共享库文件; 并且这个程序还会将这些SO-NAME收集起来, 集中存放到/etc/ld.so.cache文件里面, 并建立一个SO-NAME的缓存. 当动态链接器要查找共享库是, 它可以直接从/etc/ld.so.cache里面查找. 而/etc/ld.so.cache的结构是经过特殊设计的, 非常适合查找, 所以这个设计大大加开了共享库的查找过程.

如果动态链接器在/etc/ld.so.cache里面没有找到所需要的共享库, 那么它还会遍历/lib和/usr/lib这两个目录, 如果还是没找到, 就宣告失败.

所以理论上讲, 如果我们在系统指定的共享库目录下添加, 删除或更细任何一个共享库, 或者我们更改了/etc/ld.so.conf的配置, 都应该运行ldconfig这个程序,以便调整SO-NAME和/etc/ld.so.cache. 很多软件包的安装程序在往往系统里面安装共享库以后都会调用ldconfig

环境变量

LD_LIBRARY_PATH

Linux系统提供了很多方法来改变动态链接器装载共享库的路径的方法, 通过使用这些方法, 我们可以满足一些特殊的需求, 比如共享库的调试和测试, 应用程序级别的虚拟等. 改变共享库查找路径最简单的方法是使用LD_LIBRARY_PATH环境变量, 这个方法可以临时改变某个应用程序的共享库查找路径, 而不会改变系统中的其他程序.

在Linux系统中, LD_LIBRARY_PATH是一个由若干个路径组成的环境变量, 每个路径之间由冒号隔开. 默认情况下, LD_LIBRARY_PATH为空. 如果我们为某个进程设计了LD_LIBRARY_PATH, 那么进程在启动时, 动态链接器在查找共享库是, 会首先查找由LD_LIBRARY_PATH指定的目录. 这个环境变量可以很方便地让我们测试新的共享库或使用非标准的共享库. 比如我们系统使用修改过的libc.so.6. 可以将这个新版的libc放在我们的目录/home/user中, 然后指定LD_LIBRARY_PATH

Linux中还有一种方法可以实现与LD_LIBRARY_PATH类似的功能, 那就是直接运行动态链接器来启动程序, 例如:

1
/lib/ld-linux.so.2 -library-path /home/user /bin/ls

LD_LIBRARY_PATH对于共享库的开发和测试来说十分方便, 但是它不应该被滥用. 也就是说, 普通用户在正常情况下不应该随意设置LD_LIBRARY_PATH来调整共享库搜索目录. 随意修改LD_LIBRARY_PATH并且将其导入至全局范围, 将可能引起其他应用程序运行出现的问题; LD_LIBRARY_PATH也会影响GCC编译时查找库的路径, 它里面包含的目录相当于链接时GCC的”-L”参数.

LD_PRELOAD

系统中另外还有一个环境变量叫做LD_PRELOAD, 这个文件中我们可以指定预先装载的一些共享库甚或是目标文件. 在LD_PRELOAD里面指定的文件会在动态链接器按照固定规则搜索共享库之前装载, 它比LD_LIBRARY_PATH里面所指定的目录中的共享库还要优先. 无论程序是否依赖于它们, LD_PRELOAD里面指定的共享库或目标文件都会被装载.

由于全局符号介入这个机制的存在, LD_PRELOAD里面指定的共享库或目标文件中全局符号就会覆盖后面加载的同名全局符号, 这使得我们可以很方便地做到改写标准C库中的某个或某几个函数而不影响其他函数, 对于程序的调试或测试非常有用. 与LD_LIBRARY_PATH一样, 正常情况下应该尽量避免使用LD_PRELOAD, 比如一个发布版本的程序运行不应该依赖于LD_PRELOAD

LD_DEBUG

另外还有一个非常有用的环境变量LD_DEBUG, 这个变量可以打开动态链接器的调试功能, 当我们设计这个变量时, 动态链接器会在运行时打印出各种有用的信息.

动态链接器查询共享库的顺序

动态链接器会按照下列顺序依次装载或查找共享对象(目标文件):

  • 由环境变量LD_LIBRARY_PATH指定路径
  • 由路径缓存/etc/ld.so.cache 指定的路径
  • 默认共享库目录, 先/usr/lib, 然后/lib

共享库的安装

最简单的办法就是将共享库复制到某个标准的共享库目录, 如/lib, /usr/lib等, 然后运行ldconfig即可.

明确依赖关系的工具