程序员的自我修养--链接、装载与库笔记:Linux共享库的组织

共享库(Shared Library)概念:其实从文件结构上来讲,共享库和共享对象没什么区别,Linux下的共享库就是普通的ELF共享对象。由于共享对象可以被各个程序之间共享,所以它也就成为了库的很好的存在形式,很多库的开发者都以共享对象的形式让程序来使用,久而久之,共享对象和共享库这两个概念已经很模糊了,所以广义上我们可以将它们看作是同一个概念。

1. 共享库版本

共享库兼容性:共享库的开发者会不停地更新共享库的版本,以修正原有的Bug、增加新的功能或改进性能等。由于动态链接的灵活性,使得程序本身和程序所依赖的共享库可以分别独立开发和更新。但是共享库版本的更新可能会导致接口的更改或删除,这可能导致依赖于该共享库的程序无法正常运行。最简单的情况下,共享库的更新可以被分为两类:(1). 兼容更新:所有的更新只是在原有的共享库基础上添加一些内容,所有原有的接口都保持不变;(2). 不兼容更新:共享库更新改变了原有的接口,使用该共享库原有接口的程序可能不能运行或运行不正常。

接口这个词有着很广泛的含义,在软件的很多层次上都有所谓的”接口”。但是这里讨论的接口是二进制接口,即ABI(Application Binary Interface)共享库的ABI跟程序语言有着很大的关系,不同的语言对于接口的兼容性要求不同。ABI对于不同的语言来说,主要包括一些诸如函数调用的堆栈结构、符号命名、参数规则、数据结构的内存分布等方面的规则。对于一个C语言编写的共享库来说,什么样的更改会导致ABI变化呢?常见的更改方式,如下表所示:

导致C语言的共享库ABI改变的行为主要有如下4个:

(1). 导出函数的行为发生改变,也就是说调用这个函数以后产生的结果与以前不一样,不再满足旧版本规定的函数行为准则。

(2). 导出函数被删除。

(3). 导出数据的结构发生变化,比如共享库定义的结构体变量的结构发生改变:结构成员删除、顺序改变或其它引起结构体内存布局变化的行为(不过通常来讲,往结构体的尾部添加成员不会导致不兼容,当然这个结构体必须是共享库内部分配的,如果是外部分配的,在分配该结构体时必须考虑成员添加的情况)。

(4). 导出函数的接口发生变化,如函数返回值、参数被更改。

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

对于C++来说,ABI问题就更为严重了。由于C++非常复杂,它支持诸如模板等一些高级特性,这些特性对于ABI兼容来说简直就是灾难。因为C++标准对于C++的ABI没有做出规定,所以不同的编译器甚至同一个编译器的不同版本对于C++的一些特性的实现都有着各自的方案,而且相互不兼容,比如虚函数表、模板实例化、多重继承等。对于Linux来说,如果你要开发一个导出接口为C++的共享库,需要注意以下事项,以防止ABI不兼容(完全遵循以下准则还是不能保证ABI完全兼容):(1).不要在接口类中使用虚函数,万不得已要使用虚函数时,不要随意删除、添加或在子类中添加新的实现函数,这种会导致类的虚函数表结构发生变化;(2).不要改变类中任何成员变量的位置和类型;(3).不要删除非内嵌的public或protected成员函数;(4).不要将非内嵌的成员函数改变成内嵌成员函数;(5).不要改变成员函数的访问权限;(6).不要在接口中使用模板;(7).最重要的是,不要改变接口的任何部分或干脆不要使用C++作为共享库接口。

共享库版本命名:有几种办法可用于解决共享库的兼容性问题,有效办法之一就是使用共享库版本的方法。Linux有一套规则来命名系统中的每一个共享库,它规定共享库的文件名规则必须如下:libname.so.x.y.z

最前面使用前缀”lib”、中间是库的名字和后缀”.so”,最后面跟着的是三个数字组成的版本号。”x”表示主版本号(Major Version Number),”y”表示次版本号(Minor Version Number),”z”表示发布版本号(Release Version Number)。三个版本号的含义不一样。主版本号表示库的重大升级,不同主版本号的库之间是不兼容的,依赖于旧的主版本号的程序需要改动相应的部分,并且重新编译,才可以在新版的共享库中运行;或者系统必须保留旧版的共享库,使得那些依赖于旧版共享库的程序能够正常运行。次版本号表示库的增量升级,即增加一些新的接口符号,且保持原来的符号不变。在主版本号相同的情况下,高的次版本号的库向后兼容低的次版本号的库。一个依赖于旧的次版本号共享库的程序,可以在新的次版本号共享库中运行,因为新版中保留了原来所有的接口,并且不改变它们的定义和含义。发布版本号表示库的一些错误的修正、性能的改进等,并不添加任何新的接口,也不对接口进行更改。相同主版本号、次版本号的共享库,不同的发布版本号之间完全兼容,依赖于某个发布版本号的程序可以在任何一个其它发布版本号中正常运行,而无需做任何修改。当然现在Linux中也存在不少不遵循上述规定的”顽固分子”,比如最基本的C语言库Glibc就不使用这种规则。

SO-NAME:对于Solaris和Linux,普遍采用一种叫做SO-NAME的命名机制来记录共享库的依赖关系。每个共享库都有一个对应的”SO-NAME”,这个SO-NAME即共享库的文件名去掉次版本号和发布版本号,保留主版本号。很明显,”SO-NAME”规定了共享库的接口,”SO-NAME”的两个相同共享库,次版本号大的兼容次版本号小的。Linux系统中,系统会为每个共享库在它所在的目录创建一个跟”SO-NAME”相同的并且指向它的软链接(Symbol Link)。比如系统中有存在一个共享库”/lib/libfoo.so.2.6.1”,那么Linux中的共享库管理程序就会为它产生一个软链接”/lib/libfoo.so.2”指向它。由于历史原因,动态链接器和C语言库的共享对象文件名规则不按Linux标准的共享库命名方法。

那么以”SO-NAME”为名字建立软链接有什么用处呢?实际上这个软链接会指向目录中主版本号相同、次版本号和发布版本号最新的共享库。这样保证了所有的以SO-NAME为名的软链接都指向系统中最新版的共享库。建立以SO-NAME为名字的软链接目的是,使得所有依赖某个共享库的模块,在编译、链接和运行时,都使用共享库的SO-NAME,而不使用详细的版本号。当共享库进行升级的时候,如果只是进行增量升级,即保持主版本号不变,只改变次版本号或发布版本号,那么我们可以直接将新版的共享库替换掉旧版,并且修改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。

2. 符号版本

基于符号的版本机制(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”。

Linux中的符号版本:Linux系统下共享库的符号版本机制并没有被广泛应用,主要使用共享库符号版本机制的是Glibc软件包中所提供的20多个共享库。这些共享库比较有效地利用了符号版本机制来表示符号的版本演化及利用范围机制来屏蔽一些不希望暴露给共享库使用者的符号。

GCC对Solaris符号版本机制的扩展:GCC在Solaris系统中的符号版本机制的基础上还提供了两个扩展。第一个扩展是,除了可以在符号版本脚本中指定符号的版本之外,GCC还允许使用一个叫做”.symver”的汇编宏指令来指定符号的版本,这个汇编宏指令可以被用在GAS汇编中,也可以在GCC的C/C++源代码中以嵌入汇编指令的模式使用。第二个扩展是GCC允许多个版本的同一个符号存在于一个共享库中,也就是说,在链接层面提供了某种形式的符号重载机制。Linux下的符号版本机制允许同一个名称的符号存在多个版本。当某个符号在新的共享库版本中接口被更改或符号的含义被改变,那么共享库可以保留原来的版本符号。

3. 共享库系统路径

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

(1). /lib:这个位置主要存放系统最关键和基础的共享库,比如动态链接器、C语言运行库、数学库等,这些库主要是那些/bin和/sbin下的程序所需要用到的库,还有系统启动时需要的库;

(2). /usr/lib:这个目录下主要保存的是一些非系统运行时所需要的关键性的共享库,主要是一些开发时用到的共享库,这些共享库一般不会被用户的程序或shell脚本直接用到。这个目录下面还包含了开发时可能会用到的静态库、目标文件等;

(3). /usr/local/lib:这个目录用来放置一些跟操作系统本身并不十分相关的库,主要是一些第三方的应用程序的库。GNU的标准推荐第三方的程序应该默认将库安装到/usr/local/lib下。

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

4. 共享库查找过程

在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

5. 环境变量

LD_LIBRARY_PATH:Linux系统提供了很多方法来改变动态链接器装载共享库路径的方法,通过使用这些方法,我们可以满足一些特殊的需求,比如共享库的调试和测试、应用程序级别的虚拟等。改变共享库查找路径最简单的方法是使用LD_LIBRARY_PATH环境变量,这个方法可以临时改变某个应用程序的共享库查找路径,而不会影响系统中的其它程序Linux系统中,LD_LIBRARY_PATH是一个由若干个路径组成的环境变量,每个路径之间由冒号分割。默认情况下,LD_LIBRARY_PATH为空。如果我们为某个进程设置了LD_LIBRARY_PATH,那么进程在启动时,动态链接器在查找共享库时,会首先查找由LD_LIBRARY_PATH指定的目录。这个环境变量可以很方便地让我们测试新的共享库或使用非标准的共享库。

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

有了LD_LIBRARY_PATH之后,再来总结动态链接器查找共享库的顺序。动态链接器会按照下列顺序依次装载或查找共享对象(目标文件):(1).由环境变量LD_LIBRARY_PATH指定的路径;(2).由路径缓存文件/etc/ld.so.cache指定的路径;(3).默认共享库目录,先/usr/lib,然后/lib。

LD_PRELOAD:这个文件中我们可以指定预先装载的一些共享库或目标文件。在LD_PRELOAD里面指定的文件会在动态链接器按照固定规则搜索共享库之前装载,它比LD_LIBRARY_PATH里面所指定的目录中的共享库还要优先。无论程序是否依赖于它们,LD_PRELOAD里面指定的共享库或目标文件都会被装载。由于全局符号介入这个机制的存在,LD_PRELOAD里面指定的共享库或目标文件中的全局符号就会覆盖后面加载的同名全局符号,这使得我们可以很方便地做到改写标准C库中的某个或某几个函数而不影响其它函数,对于程序的调试或测试非常有用。与LD_LIBRARY_PATH一样,正常情况下应该尽量避免使用LD_PRELOAD。系统配置文件中有一个文件是/etc/ld.so.preload,它的作用于LD_PRELOAD一样。这个文件里面记录的共享库或目标文件的效果跟LD_PRELOAD里面指定的一样,也会被提前装载。

LD_DEBUG:这个变量可以打开动态链接器的调试功能,当我们设置这个变量时,动态链接器会在运行时打印出各种有用的信息,对于我们开发和调试共享库有很大的帮助。如执行Program1,程序代码见https://blog.csdn.net/fengbingchun/article/details/101120761,执行结果如下图所示:

LD_DEBUG设置值的作用

(1). “files”:动态链接器打印整个装载过程,显示程序依赖于哪个共享库并且按照什么步骤装载和初始化,共享库装载时的地址等。

(2). “bindings”:显示动态链接的符号绑定过程。

(3). “libs”:显示共享库的查找过程。

(4). “versions”:显示符号的版本依赖关系。

(5). “reloc”:显示重定位过程。

(6). “symbols”:显示符号表查找过程。

(7). “statistics”:显示动态链接过程中的各种统计信息。

(8). “all”:显示以上所有信息。

(9). “help”:显示上面的各种可选值的帮助信息。

6. 共享库的创建和安装

共享库的创建创建共享库的过程跟创建一般的共享对象的过程基本一致,最关键的是使用GCC的两个参数,即”-shared”和”-fPIC”。”-shared”表示输出结果是共享库类型的;”-fPIC”表示使用地址无关代码(Position Independent Code)技术来生产输出文件。另外还有一个参数是”-Wl”,这个参数可以将指定的参数传递给链接器,比如当我们使用”-Wl,-soname,my_soname”时,GCC会将”-soname my_soname”传递给链接器,用来指定输出共享库的SO-NAME。如果我们不使用-soname来指定共享库的SO-NAME,那么该共享库默认就没有SO-NAME,即使用ldconfig更新SO-NAME的软链接时,对该共享库也没有效果。

几个值得注意的事项:(1).不要把输出共享库中的符号和调试信息去掉,也不要使用GCC的”-fomit-frame-pointer”选项,这样做虽然不会导致共享库停止运行,但是会影响调试共享库,给后面的工作带来很多麻烦。(2).在开发过程中,你可能要测试新的共享库,但是又不希望影响现有的程序正常运行,可以用LD_LIBRARY_PATH指定共享库的查找路径。还有一种方法是使用链接器的”-rpath”选项(或者GCC的-Wl,-rpath),这种方法可以指定链接产生的目标程序的共享库查找路径。(3).默认情况下,链接器在产生可执行文件时,只会将那些链接时被其它共享模块引用到的符号放到动态符号表,这样可以减少动态符号表的大小。也就是说,在共享模块中反向引用主模块中的符号时,只有那些在链接时被共享模块引用到的符号才会被导出。有一种情况是,当程序使用dlopen()动态加载某个共享模块,而该共享模块需反向引用主模块的符号时,有可能主模块的某些符号因为在链接时没有被其它共享模块引用而没有被放到动态符号表里面,导致了反向引用失败。ld链接器提供了一个”-export-dynamic”的参数,这个参数表示链接器在生产可执行文件时,将所有全局符号导出到动态符号表,以防止出现上述问题。我们也可以在GCC中使用”-Wl,-export-dynamic”将该参数传递给链接器。

清除符号信息:正常情况下编译出来的共享库或可执行文件里面带有符号信息和调试信息,这些信息在调试时非常有用,但是对于最终发布的版本来说,这些符号信息用处并不大,并且使得文件尺寸变大。我们可以使用一个叫”strip”的工具清除掉共享库或可执行文件的所有符号和调试信息(“strip”是binutils的一部分):$ strip libfoo.so

去除符号和调试信息以后的文件往往比之前要小很多。除了使用”strip”工具,我们还可以使用ld的”-s”和”-S”参数,使得链接器生成输出文件时就不产生符号信息。”-s”和”-S”的区别是:”-S”消除调试符号信息,而”-s”消除所有符号信息。我们也可以在GCC中通过”-Wl,-s”和”-Wl,-S”给ld传递这两个参数。

 共享库的安装:最简单的办法就是将共享库复制到某个标准的共享库目录,如/lib、/usr/lib等,然后运行ldconfig即可。不过上述方法往往需要系统的root权限,如果没有,则无法往/lib、/usr/lib等目录添加文件,也无法运行ldconfig程序。也可以通过建立相应的SO-NAME软链接方法,并告诉编译器和程序如何查找该共享库等,以便于编译器和程序都能够正常运行。建立SO-NAME的办法也是使用ldconfig,只不过需要指定共享库所在的目录。在编译程序时,也需要指定共享库的位置,GCC提供了两个参数”-L”和”-l”,分别用于指定共享库搜索目录和共享库。也可以使用”-rpath”参数。

共享库构造和析构函数:很多时候你希望共享库在被装载时能够进行一些初始化工作,比如打开文件、网络连接等,使得共享库里面的函数接口能够正常工作。GCC提供了一种共享库的构造函数,只要在函数声明时加上”__attribute__((constructor))”的属性,即指定该函数为共享库构造函数,拥有这种属性的函数会在共享库加载时被执行,即在程序的main函数之前执行。如果我们使用dlopen()打开共享库,共享库构造函数会在dlopen()返回之前执行。与共享库构造函数相对应的是析构函数,我们可以使用在函数声明时加上”__attribute__((destructor))”的属性,这种函数会在main()函数执行完毕之后执行(或者是程序调用exit()时执行)。如果共享库是在运行时加载的,那么我们使用dlclose()来卸载共享库时,析构函数将会在dlclose()返回之前执行。值得注意的是,如果我们使用了这种析构或构造函数,那么必须使用系统默认的标准运行库和启动文件,即不可以使用GCC的”-nostartfiles”或”-nostdlib”这两个参数。因为这些构造和析构函数是在系统默认的标准运行库或启动文件里面被运行的,如果没有这些辅助构造,它们可能不会被运行。另外还有一个问题是,如果我们有多个构造函数,那么默认情况下,它们被执行的顺序是没有规定的。如果我们希望构造和析构函数能够按照一定的顺序执行,GCC为我们提供了一个参数叫做优先级,我们可以指定某个构造或析构函数的优先级。对于构造函数来说,属性中优先级数字越小的函数将会在优先级大的函数之前运行;而对于析构函数来讲,则刚好相反。这种安排有利于构造函数和析构函数能够匹配,比如某一对构造函数和析构函数分别用来申请和释放某个资源,那么它们可以拥有一样的优先级。这样做的结果往往是先申请的资源后释放,符合资源释放的一般规则。

共享库脚本:前面所提到的共享库都是动态链接的ELF共享对象文件(.so),事实上,共享库还可以是符合一定格式的链接脚本文件。通过这种脚本文件,我们可以把几个现有的共享库通过一定的方式组合起来,从用户的角度看就是一个新的共享库。这里的脚本与LD的脚本从语法和命令上来讲没什么区别,它们的作用也相似,即将一个或多个输入文件以一定的格式经过变换以后形成一个输出文件。我们也可以将这种共享库脚本叫做动态链接脚本,因为这个链接过程是动态完成的,也就是运行时完成的。

GitHubhttps://github.com/fengbingchun/Messy_Test

发布了718 篇原创文章 · 获赞 1131 · 访问量 609万+

猜你喜欢

转载自blog.csdn.net/fengbingchun/article/details/101610029