8.1 共享库版本
8.1.1 共享库兼容性
共享库的开发者会不停地更新共享库的版本,以修正原有的Bug、增加新的功能或改进性能等。由于动态链接的灵活性,使得程序本身和程序所依赖的共享库可以分别独立开发和更新,比如当有程序A依赖于libfoo.so,当libfoo.so的开发者宣布新版本开发完成之后,理论上我们只需要用新的libfoo.so将旧版本的替换掉即可享用新版libfoo.so提供的一切好处。但是共享库版本的更新可能会导致接口的更改或删除,这可能导致依赖于该共享库的程序无法正常运行。最简单的情况下,共享库的更新可以被分为两类。
- 兼容更新。所有的更新只是在原有的共享库基础上添加一些内容,所有原有的接口都保持不变。
- 不兼容更新。共享库更新改变了原有的接口,使用该共享库原有接口的程序可能不能运行或运行不正常。
接口这个词有着很广泛的含义,在软件的很多层次上都有所谓的"接口"。但是这里讨论的接口是二进制接口,即ABI(Application Binary Interface)。共享库的ABI跟程序语言有着很大的关系,不同的语言对于接口的兼容性要求不同。ABI对于不同的语言来说,主要包括一些诸如函数调用的堆栈结构、符号命名、参数规则、数据结构的内存分布等方面的规则。那么对于一个C语言编写的共享库来说,什么样的更改会导致ABI变化呢?表8-1列举了几种常见的更改方式。

表8-1
导致C语言的共享库ABI改变的行为主要有如下4个:
- 导出函数的行为发生改变,也就是说调用这个函数以后产生的结果与以前不一样,不再满足旧版本规定的函数行为准则。
- 导出函数被删除。
- 导出数据的结构发生变化,比如共享库定义的结构体变量的结构发生改变:结构成员删除、顺序改变或其他引起结构体内存布局变化的行为(不过通常来讲,往结构体的尾部添加成员不会导致不兼容,当然这个结构体必须是共享库内部分配的,如果是外部分配的,在分配该结构体时必须考虑成员添加的情况)。
- 导出函数的接口发生变化,如函数返回值、参数被更改。
如果能够保证上述4种情况不发生,那么绝大部分情况下,C语言的共享库将会保持ABI兼容。注意,仅仅是绝大部分情况,要破坏一个共享库的ABI十分容易,要保持ABI的兼容却十分困难。很多因素会导致ABI的不兼容,比如不同版本的编译器、操作系统和硬件平台等,使得ABI兼容尤为困难。使用不同版本的编译器或系统库可能会导致结构体的成员对齐方式不一致,从而导致了ABI的变化。这种ABI不兼容导致的问题可能非常微妙,表面上看可能无关紧要,但是一旦发生故障,相关的Bug非常难以定位,这也是共享库很大的一个问题。
对于C++来说,ABI问题就更为严重了。由于C++非常复杂,它支持诸如模板等一些高级特性,这些特性对于ABI兼容来说简直就是灾难。因为C++标准对于C++的ABI没有做出规定,所以不同的编译器甚至同一个编译器的不同版本对于C++的一些特性的实现都有着各自的方案,而且相互不兼容,比如虚函数表、模板实例化、多重继承等。对于Linux来说,如果你要开发一个导出接口为C++的共享库(当然我十分不推荐这么做,使用C的接口会让事情变得简单得多),需要注意以下事项,以防止ABI不兼容(完全遵循以下准则还是不能保证ABI完全兼容):
- 不要在接口类中使用虚函数,万不得已要使用虚函数时,不要随意删除、添加或在子类中添加新的实现函数,这样会导致类的虚函数表结构发生变化。
- 不要改变类中任何成员变量的位置和类型。
- 不要删除非内嵌的public或protected成员函数。
- 不要将非内嵌的成员函数改变成内嵌成员函数。
- 不要改变成员函数的访问权限。
- 不要在接口中使用模板。
- 最重要的是,不要改变接口的任何部分或干脆不要使用C++作为共享库接口!
8.1.2 共享库版本命名
既然共享库存在这样那样的兼容性问题,那么保持共享库在系统中的兼容性,保证依赖于它们的应用程序能够正常运行是必须要解决的问题。有几种办法可用于解决共享库的兼容性问题,有效办法之一就是使用共享库版本的方法。Linux有一套规则来命名系统中的每一个共享库,它规定共享库的文件名规则必须如下:
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.1.x或1.2.x的程序都可以在1.3.x中正常运行。
发布版本号表示库的一些错误的修正、性能的改进等,并不添加任何新的接口,也不对接口进行更改。相同主版本号、次版本号的共享库,不同的发布版本号之间完全兼容,依赖于某个发布版本号的程序可以在任何一个其他发布版本号中正常运行,而无须做任何修改。
当然现在Linux中也存在不少不遵守上述规定的"顽固分子",比如最基本的C语言库Glibc就不使用这种规则,它的基本C语言库使用libc-x.y.z.so这种命名方式。Glibc有许多组件,C语言库只是其中一个,动态链接器也是Glibc的一部分,它使用ld-x.y.z.so这样的命名方式,还有Glibc的其他部分,比如数学库libm、运行时装载库libdl等。
Reference: Library Interface Versioning in Solaris and Linux
这篇论文对Salaris和Linux的共享库版本机制和符号版本机制做了非常详细的介绍。
8.1.3 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"指向它。比如Linux系统的Glibc共享库:
$ ls -l /lib/libc*
-rwxr-xr-x 1 root root 1249520 2007-10-25 09:03 libc-2.6.1.so
…
lrwxrwxrwx 1 root root 13 2007-11-10 15:49 libc.so.6 -> libc-2.6.1.so
…
由于历史原因,动态链接器和C语言库的共享对象文件名规则不按Linux标准的共享库命名方法,但是C语言的SO-NAME还是按照正常的规则:Glibc的C语言库libc-2.6.1.so,它的SO-NAME是libc.so.6;为了"彰显"动态连接器的与众不同,它的SO-NAME命名也不按照普通的规则,比如动态链接器的文件名是ld-2.6.1.so,它的SO-NAME是ld-linux.so。
那么以"SO-NAME"为名字建立软链接有什么用处呢?实际上这个软链接会指向目录中主版本号相同、次版本号和发布版本号最新的共享库。也就是说,比如目录中有两个共享库版本分别为:/lib/libfoo.so.2.6.1和/lib/libfoo.2.5.3,那么软链接/lib/libfoo.so.2会指向/lib/libfoo.so.2.6.1。这样保证了所有的以SO-NAME为名的软链接都指向系统中最新版的共享库。
建立以SO-NAME为名字的软链接目的是,使得所有依赖某个共享库的模块,在编译、链接和运行时,都使用共享库的SO-NAME,而不使用详细的版本号。我们在前面介绍动态链接文件中的".dynamic"段时已经提到过,如果某文件A依赖于某文件B,那么A的".dynamic"段中会有DT_NEED类型的字段,字段的值就是B。现在有一个问题是,这个字段值该如何表示B这个文件呢?如果保存的是B的文件名,即包含次版本号和发布版本号,那么会有什么问题呢?很直接的问题是,这个文件A只能依赖于某个特定版本的B。比如程序A依赖于C语言库,它在编译时,系统中存在的C语言库版本是/lib/libc-2.6.1.so,那么编译完成后,它的".dynamic"中的DT_NEED类型如果保存了/lib/libc-2.6.1.so。当系统将C语言库版本升级至2.6.2或2.7.1时,系统必须保留原来的2.6.1的共享库,否则这个这个程序A就无法正常运行。
但是我们知道,因为根据Linux的共享库版本规定,实际上2.6.2或2.7.1版本的共享库是兼容2.6.1的,我们不需要继续保留原来的2.6.1,否则系统中将遗留大量的各种版本的共享库,大大浪费了磁盘和内存空间。所以一个可行的方法就是编译输出ELF文件时,将被依赖的共享库的SO-NAME保存到".dynamic"中,这样当动态链接器进行共享库依赖文件查找时,就会根据系统中各种共享库目录中的SO-NAME软链接自动定向到最新版本的共享库。比如之前Lib.so的依赖文件:
$ readelf -d Lib.so
Dynamic section at offset 0x4f4 contains 21 entries:
Tag Type Name/Value
0x00000001 (NEEDED) Shared library: [libc.so.6]
…
当共享库进行升级的时候,如果只是进行增量升级,即保持主版本号不变,只改变次版本号或发布版本号,那么我们可以直接将新版的共享库替换掉旧版,并且修改SO-NAME的软链接指向新版本共享库,即可实现升级;当共享库的主版本号升级时,系统中就会存在多个SO-NAME,由于这些SO-NAME并不相同,所以已有的程序并不会受影响。
总之,SO-NAME表示一个库的接口,接口不向后兼容,SO-NAME就发生变化,这是基本的原则。
Linux中提供了一个工具叫做"ldconfig",当系统中安装或更新一个共享库时,就需要运行这个工具,它会遍历所有的默认共享库目录,比如/lib、/usr/lib等,然后更新所有的软链接,使它们指向最新版的共享库;如果安装了新的共享库,那么ldconfig会为其创建相应的软链接。
链接名
当我们在编译器里面使用共享库的时候(比如使用GCC的"-l"参数链接某个共享库),我们使用了更为简洁的方式,比如需要链接一个libXXX.so.2.6.1的共享库,只需要在编译器命令行里面指定-lXXX即可,可省略所有其他部分。编译器会根据当前环境,在系统中的相关路径(往往由-L参数指定)查找最新版本的"XXX"库。
这个"XXX"又被称为共享库的链接名(Link Name)。不同类型的库可能会有同样的链接名,比如C语言运行库有静态版本(libc.a)和动态版本(libc.so.x.y.z)的区别,如果在链接时使用参数"-lc",那么链接器会根据输出文件的情况(动态/静态)来选择适合版本的库。比如ld使用"-static"参数时,"-lc"会查找libc.a;如果使用"-Bdynamic"(这也是默认情况),它会查找最新版本的libc.so.x.y.z。