Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

8.2 符号版本

历史回顾

在一些早期的系统中,应用程序在被构建时,静态链接器会把程序所依赖的所有共享库的名字、主版本号和次版本号都记录到最终的应用程序二进制输出文件中。在运行时,由于动态链接器知道应用程序所依赖的共享库的确切版本号,所以兼容性问题比较容易处理。比如在SunOS 4.x中,动态链接器会根据程序的共享库依赖列表中的记录,在系统中查找相同共享库名和主版本号的共享库;如果某个共享库在系统中存在相同主版本号不同次版本号的多个副本,那么动态链接器会使用那个最高次版本号的副本。

动态链接器在查找共享库过程中,如果找到的共享库的次版本号高于或等于依赖列表中的版本,那么链接器就默认共享库满足要求,因为更高次版本号的共享库肯定包含所有需要的符号;如果找到的共享库次版本号低于所需要的版本,SunOS 4.x系统的策略是向用户发出一个警告信息,表示系统中仅有低次版本号的共享库,但运行程序还是继续运行。程序很有可能能够正常运行,比如该程序只用了低次版本号中的接口,而没有用到高次版本号中新添加的那些接口。当然,程序如果用到了高次版本号中新添加的接口而目前系统中的低次版本号的共享库中不存在,那么就会发生重定位错误。有些采取更加保守策略的系统中,对于这种系统中没有足够高的次版本号满足依赖关系的情况,程序将会被禁止运行,以防止出现意外情况。

这两种策略或可能导致程序运行错误(第一种只通过警告的策略),或者会阻止那些实际上能够运行的程序(第二种保守策略)。实际上很多应用程序在高次版本的系统中都有构建,但实际上它只用到了低次版本的那部分接口,在采取第二种策略的系统中,如果系统中只有低次版本号的共享库,那么这些程序就不能运行。我们可以把这个问题叫做次版本号交会问题(Minor-revision Rendezvous Problem)。

次版本号交会问题并没有因为SO-NAME而解决

动态链接器在进行动态链接时,只进行主版本号的判断,即只判断SO-NAME,如果某个被依赖的共享库SO-NAME与系统中存在的实际共享库SO-NAME一致,那么系统就认为接口兼容,而不再进行兼容性检查。这样就会出现一个问题,当某个程序依赖于较高的次版本号的共享库,而运行于较低次版本号的共享库系统时,就可能产生缺少某些符号的错误。因为次版本号只保证向后兼容,并不保证向前兼容,新版的次版本号的共享库可能添加了一些旧版没有的符号。这种次版本号交会问题并没有因为SO-NAME的存在而得到任何改善。对于这个问题,现代的系统通过一种更加精巧的方式来解决,那就是符号版本机制。

8.2.1 基于符号的版本机制

正常情况下,为了表示某个共享库中增加了一些接口,我们就把这个共享库的次版本号升高(表示里面添加了一些东西)。但是我们需要一种更为巧妙的方法,来解决次版本号交会问题。Linux下的Glibc从版本2.1之后开始支持一种叫做基于符合的版本机制(Symbol Versioning)的方案。这个方案的基本思路是让每个导出和导入的符号都有一个相关联的版本号,它的实际做法类似于名称修饰的方法。与以往简单地将某个共享库的版本号重新命名不同(比如将libfoo.so.1.2升级到libfoo.so.1.3),当我们将libfoo.so.1.2升级至1.3时,仍然保持libfoo.so.1这个SO-NAME,但是给在1.3这个新版中添加的那些全局符号打上一个标记,比如"VERS_1.3"。那么,如果一个共享库每一次次版本号升级,我们都能给那些在新的次版本号中添加的全局符号打上相应的标记,就可以清楚地看到共享库中的每个符号都拥有相应的标签,比如"VERS_1.1"、"VERS_1.2"、"VERS_1.3"、"VERS_1.4"。

8.2.2 Solaris中的符号版本机制

这个基于符号版本的方案最早是Sun在1995年的Solaris 2.5中实现的,在这个新的机制中,Solaris的ld链接器为共享库新增了版本机制(Versioning)和范围机制(Scoping)。

版本机制的想法很简单,也就是定义一些符号的集合,这些集合本身都有名字,比如叫"VERS_1.1"、"VERS_1.2"等,每个集合都包含一些指定的符号,除了可以拥有符号以外,一个集合还可以包含另外一个集合,比如"VERS_1.2"可以包含集合"VERS_1.1"。就概念而言与其说是"包含",不如说是"继承",比如"VERS_1.2"的符号集合包含(继承)了所有"VERS_1.1"的符号,并且包含所有"VERS_1.2"的符号。

那么,这些集合的定义及它们包含哪些符号是怎样指定的呢?在Solaris中,程序员可以在链接共享库时编写一种叫做符号版本脚本的文件,在这个文件中指定这些符号与集合之间及集合与集合之间的继承依赖关系。链接器在链接时根据符号版本脚本中指定的关系来产生共享库,并且设置符号的集合与它们之间的关系。

举个简单的例子,假设有个名为libstack.so.1的共享库编写的符号版本脚本文件如下:

SUNW_1.1 { 
    global: 
    pop; 
    push; 
} 
  
SUNWprivate { 
    global: 
    __pop; 
    __push; 
    local: 
    *; 
} 

在这个脚本文件中,我们可以看到它定义了两个符号集合,分别为"SUNW_1.1"和"SUNWprivate"(在Solaris系统中,符号的集合名通常由"SUNW"开头)。第一个包含了两个全局符号pop和push;在第二个集合中,包含了两个全局符号"__pop"和"__push"。第二个集合中最后的"local: *;"表示:除了上述被标识为全局的"pop"、"push"、"__pop"和"__push"这4个符号以外,共享库中其他的本来是全局的符号都将成为共享库局部符号,也就是说链接器会把原先是全局的符号全部变成局部的,这样一来,共享库外部的应用程序或其他的共享库将无法访问这些符号。这种方式可以用于保护那些共享库内部的公用实用函数,但是共享库的作者又不希望共享库的使用者能够有意或无意地访问这些函数。这种方法又被称为范围机制(Scoping),它实际上是对C语言没有很好的符号可见范围的控制机制的一种补充,或者说是一种补救性质的措施。

假设现在这个共享库升级了,在原有的基础上添加了一个全局函数"swap",那么新的符号版本脚本文件可以在原有的基础上添加如下内容:

SUNW_1.2 { 
    global: 
    swap; 
} SUNW_1.1; 

上面的脚本就表示了一个典型的向上兼容的接口:1.2版的共享库增加了一个swap接口,并且它继承了1.1的所有接口。那么我们可以按照这种方式,共享库中的版本序号SUNW_1.1、SUNW_1.2、SUNW_1.3......分别表示每次共享库添加接口以后的更新,它们依次向后继承,向后兼容。这里值得一提的是,跟在"SUNW_"前缀后面的版本号由主版本号与一个次版本号构成,这里的主版本号对应于共享库实际的SO-NAME中的主版本号。

当共享库的符号都有了版本集合之后,一个最明显的效果就是,当我们在构建(编译和链接)应用程序的时候,链接器可以在程序的最终输出文件中记录下它所用到的版本符号集合。值得注意的是,程序里面记录的不是构建时共享库中版本最新的符号集合,而是程序所依赖的集合中版本号最小的那个(或者那些)。比如,一个共享库libfoo.so.1中有6个符号版本,从SUNW_1.1到SUNW_1.6,某个应用程序app_foo在编译时,系统中的libfoo.so.1的符号版本为SUNW_1.6,但实际上app_foo只用到了最高到SUNW_1.3集合的符号,那么应用程序实际上依赖于SUNW_1.3,而不是SUNW_1.6。链接器会计算出app_foo所用到的最高版本的符号,然后把SUNW_1.3记录到app_foo的可执行文件内。

在程序运行时,动态链接器会通过程序内记录的它所依赖的所有共享库的符号集合版本信息,然后判定当前系统共享库中的符号集合版本是否满足这些被依赖的符号集合。通过这样的机制,就可以保证那些在高次版本共享库的系统中编译的程序在低次版本共享库中运行。如果该低次版本的共享库满足符号集合的要求,比如app_foo在libfoo.so.1次版本号大于等于3的系统中运行,就没有任何问题;如果低次版本共享库不满足要求,如app_foo在libfoo.so.1次版本号小于3的系统中运行,动态链接器就会意识到当前系统的共享库次版本号不满足要求,从而阻止程序运行,以防止造成进一步的损失。

这种符号版本的方法是对SO-NAME机制保证共享库主版本号一致的一种非常好的补充。

8.2.3 Linux中的符号版本

Linux系统下共享库的符号版本机制并没有被广泛应用,主要使用共享库符号版本机制的是Glibc软件包中所提供的20多个共享库。这些共享库比较有效地利用了符号版本机制来表示符号的版本演化及利用范围机制来屏蔽一些不希望暴露给共享库使用者的符号。对于目前2.6.1的Glibc中的C语言运行库libc-2.6.1.so来说,它的符号版本演化如下:

GLIBC_2.0、GLIBC_2.1、GLIBC_2.1.1、GLIBC_2.1.2、GLIBC_2.1.3、GLIBC_2.2、GLIBC_2.2.1、GLIBC_2.2.2、GLIBC_2.2.3、GLIBC_2.2.4、GLIBC_2.2.6、GLIBC_2.3、GLIBC_2.3.2、GLIBC_2.3.3、GLIBC_2.3.4、GLIBC_2.4、GLIBC_2.5、GLIBC_2.6

对于有些像Glibc中的加密解密库libcrypt,它目前的共享库版本是libcrypt-2.6.1.so,但是它内部的符号版本只有GLIBC_2.0,因为它的接口十分稳定,从2.0版本之后就没有改动过。另外我们在Glibc的库中还可以看到类似于"GCC_"为前缀及"GLIBC_PRIVATE"这样的符号版本,这样的符号版本标记分别用于GCC编译器和GLIBC内部,它提醒共享库的使用者:最好不要使用这些符号,因为它并不是对外公开的,有可能随着共享库的版本演化而被删除或改变,总之一句话,后果自负。

GCC对Solaris符号版本机制的扩展

GCC在Solaris系统中的符号版本机制的基础上还提供了两个扩展。第一个扩展是,除了可以在符号版本脚本中指定符号的版本之外,GCC还允许使用一个叫做".symver"的汇编宏指令来指定符号的版本,这个汇编宏指令可以被用在GAS汇编中,也可以在GCC的C/C++源代码中以嵌入汇编指令的模式使用。它的用法如下:

asm(".symver add, add@VERS_1.1");

int add(int a, int b)
{
    return a + b;
}

这样就可以把符号"add"指定为符号标签"VERS_1.1"。第二个扩展是GCC允许多个版本的同一个符号存在于一个共享库中,也就是说,在链接层面提供了某种形式的符号重载机制,比如:

asm(".symver old_printf, printf@VERS_1.1");
asm(".symver new_printf, printf@VERS_1.2");

int old_printf()
{
    ...
}

int new_printf()
{
    ...
}

为什么要提供这种符号多版本重载机制呢?有时候当我们对共享库进行升级的时候,可能仅仅更改了一个符号的接口或含义,那么,如果仅仅为了这个符号的更改而升级主版本号,那么将会对系统带来很大的影响。理想的情况是,当共享库发生比较小的变化时,新版的共享库能够在原来的基础上做些补充,而并不影响旧版的功能,即能完全保持向后兼容性,争取做到不更改共享库的SO-NAME,即不更改主版本号。

Solaris 2.5系统的符号版本方案有一个不足,那就是同一个共享库中,每个函数只能有一个版本号,也就是说不允许多个版本的同一个函数名存在,只允许该函数的某个版本存在。比如符号foo要么是VERS_1.0,要么是VERS_1.1,不允许这两个版本同时存在。Linux下的符号版本机制比Salaris 2.5的要先进一些,它允许同一个名称的符号存在多个版本。当某个符号在新的共享库版本中接口被更改或符号的含义被改变,那么共享库可以保留原来的版本符号,比如前面例子中导出的printf 1.1版实际上即为old_printf;而将新版的new_printf导出成printf版本1.2。这样,链接器可以挑选符合某个程序版本号的符号来进行链接,使用1.1版printf的程序会被链接到old_printf,而使用1.2版的程序会被链接到new_printf,所有的程序都可以正确运行,更改函数的接口和含义并不影响旧版程序的运行。

Linux系统中符号版本机制实践

在Linux下,当我们使用ld链接一个共享库时,可以使用"--version-script"参数;如果使用GCC,则可以使用"-Xlinker"参数加"--version-script",相当于把"--version-script"传递给ld链接器。如编译源代码为"lib.c",符号版本脚本文件为"lib.ver":

gcc -shared -fPIC lib.c -Xlinker --version-script lib.ver -o lib.so

假设lib.c里面定义了一个foo的函数,而main.c调用了这个函数,如我们使用下面的符号版本脚本编译一个lib.so:

VERS_1.2 {
    global:
        foo;
    local:
        *;
};

那么很明显,这个版本的lib.so里面foo的符号版本是VERS_1.2。然后将main.c编译并且链接到当前版本的lib.so:

gcc main.c ./lib.so –o main

于是main程序里面所引用的foo也是VERS_1.2的。如果把这个main程序拿到一台只包含低于VERS_1.2的foo的lib.so系统中运行,那么动态链接器就会报运行错误并且退出程序,防止了符号版本不符所造成额外的损失:

./main
./main: ./lib.so: version `VERS_1.2' not found (required by ./main)