共享库的开发者会不停地更新共享库的版本,以修正原有的Bug、增加新的功能或改进性能等。但是共享库版本的更新可能会导致接口的更改或删除,这可能导致依赖于该共享库的程序无法正常运行。最简单的情况下,共享库的更新可以被分为两类:
没有改变ABI,则是兼容更新,改变ABI则是不兼容更新。导致C语言的共享库ABI改变的行为主要有如下4个:
Linux有一套规则来命名系统中的每一个共享库,它规定共享库的文件名规则必须如下:libname.so.x.y.z
最前面使用前缀lib
、中间是库的名字和后缀.so
,最后面跟着的是三个数字组成的版本号。x
表示主版本号(Major Version Number),y
表示次版本号(Minor Version Number),z
表示发布版本号(Release Version Number)。
三个版本号的含义不一样:
当然现在Linux中也存在不少不遵循上述规定的顽固分子,比如最基本的C语言库Glibc就不使用这种规则。
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会为其创建相应的软链接。
SO-NAME无法解决次版本号的交会问题,如:libmyso.2.2.1
和libmyso.2.1.1
的SO-NAME都是libmyso.2
,程序依赖的共享库是libmyso.2.2.1
,但是环境中只有libmyso.2.1.1
,程序可能会无法运行,这时只靠SO-NAME是无法解决这个问题的。现代系统使用一种叫做符号版本的机制来解决。
基本思路是让每个导出和导入的符号都有一个相关联的版本号,它的实际做法类似于名称修饰的方法。与以往简单地将某个共享库的版本号重新命名不同(比如将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系统下共享库的符号版本机制并没有被广泛应用,主要使用共享库符号版本机制的是Glibc软件包中所提供的20多个共享库。这些共享库比较有效地利用了符号版本机制来表示符号的版本演化及利用范围机制来屏蔽一些不希望暴露给共享库使用者的符号。
GCC在Solaris系统中的符号版本机制的基础上还提供了两个扩展:
.symver
的汇编宏指令来指定符号的版本,这个汇编宏指令可以被用在GAS汇编中,也可以在GCC的C/C++源代码中以嵌入汇编指令的模式使用。asm(".symver add, add@VERS_1.1");
int add(int a, int b){
return a + b;
}
asm(".symver old_printf, printf@VERS_1.1");
asm(".symver new_printf, printf@VERS_1.2");
int old_printf(){
...
}
int new_printf(){
...
}
目前大多数包括Linux在内的开源操作系统都遵守一个叫做FHS(File Hierarchy Standard)的标准,这个标准规定了一个系统中的系统文件应该如何存放,包括各个目录的结构、组织和作用,这有利于促进各个开源操作系统之间的兼容性。FHS规定,一个系统中主要有两个存放共享库的位置,它们分别为:
/lib
:这个位置主要存放系统最关键和基础的共享库,比如动态链接器、C语言运行库、数学库等,这些库主要是那些/bin
和/sbin
下的程序所需要用到的库,还有系统启动时需要的库;/usr/lib
:这个目录下主要保存的是一些非系统运行时所需要的关键性的共享库,主要是一些开发时用到的共享库,这些共享库一般不会被用户的程序或shell脚本直接用到。这个目录下面还包含了开发时可能会用到的静态库、目标文件等;/usr/local/lib
:这个目录用来放置一些跟操作系统本身并不十分相关的库,主要是一些第三方的应用程序的库。GNU的标准推荐第三方的程序应该默认将库安装到/usr/local/lib
下。所以总体来看,/lib
和/usr/lib
是一些很常用的、成熟的,一般是系统本身所需要的库;而/usr/local/lib
是非系统所需的第三方程序的共享库。
在Linux系统中,动态链接器是/lib/ld-linux.so.X
(X是版本号),程序所依赖的共享对象全部由动态链接器负责装载和初始化。任何一个动态链接的模块所依赖的模块路径保存在.dynamic
段里面,由DT_NEED类型的项表示。动态链接器对于模块的查找有一定的规则:如果DT_NEED里面保存的是绝对路径,那么动态链接器就按照这个路径去查找;如果DT_NEED里面保存的是相对路径,那么动态链接器会在/lib
、/usr/lib
和由/etc/ld.so.conf
配置文件指定的目录中查找共享库。为了程序的可移植行和兼容性,共享库的路径往往是相对的。
ld.so.conf
是一个文本配置文件,它可能包含其它的配置文件,这些配置文件中存放着目录信息。如我的机器中,文件指定的目录包含在如下文件中:
$ cat /etc/ld.so.conf
include /etc/ld.so.conf.d/*.conf
$ ls /etc/ld.so.conf.d/ -l
总用量 20
-rw-r--r-- 1 root root 45 6月 26 2018 fakeroot-mips64el-linux-gnuabi64.conf
-rw-r--r-- 1 root root 44 12月 22 2020 libc.conf
-rw-r--r-- 1 root root 121 12月 22 2020 mips64el-linux-gnuabi64.conf
-rw-r--r-- 1 root root 58 12月 22 2020 zz_mips32-biarch-compat.conf
-rw-r--r-- 1 root root 56 12月 22 2020 zz_mipsn32-biarch-compat.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。
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=/home/user /bin/ls
Linux中还有一种方法可以实现与LD_LIBRARY_PATH类似的功能,那就是直接运行动态链接器来启动程序,可以到达和前面一样的效果:
/lib/ld.so.1 -library-path /home/user /bin/ls
有了LD_LIBRARY_PATH之后,再来总结动态链接器查找共享库的顺序。动态链接器会按照下列顺序依次装载或查找共享对象(目标文件):
LD_LIBRARY_PATH对于共享库开发来说很方便,但是也不该滥用。随意修改并导出全局范围,将可能引起其他应用程序出现问题。
系统中有一个环境变量叫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里面指定的一样,也会被提前装载。
这个变量可以打开动态链接器的调试功能,当我们设置这个变量时,动态链接器会在运行时打印出各种有用的信息,对于我们开发和调试共享库有很大的帮助。比如运行malloc_test程序:
$ LD_DEBUG=files ./malloc_test
10375:
10375: file=libc.so.6 [0]; needed by ./malloc_test [0]
10375: file=libc.so.6 [0]; generating link map
10375: dynamic: 0x000000fff7d2be20 base: 0x000000fff7b90000 size: 0x00000000001a1208
10375: entry: 0x000000fff7bc171c phdr: 0x000000fff7b90040 phnum: 10
10375:
10375:
10375: calling init: /lib/loongarch64-linux-gnu/libc.so.6
10375:
10375:
10375: initialize program: ./malloc_test
10375:
10375:
10375: transferring control: ./malloc_test
10375:
number of loops = 20255
maximum malloc size = 131548295622 Byte
maximum malloc size = 128465132 KB
maximum malloc size = 125454 MB
maximum malloc size = 122 GB
10375:
10375: calling fini: ./malloc_test [0]
10375:
LD_DEBUG设置值的作用:
files
:动态链接器打印整个装载过程,显示程序依赖于哪个共享库并且按照什么步骤装载和初始化,共享库装载时的地址等。bindings
:显示动态链接的符号绑定过程。libs
:显示共享库的查找过程。versions
:显示符号的版本依赖关系。reloc
:显示重定位过程。symbols
:显示符号表查找过程。statistics
:显示动态链接过程中的各种统计信息。all
:显示以上所有信息。help
:显示上面的各种可选值的帮助信息。可以使用以下命令来生成一个共享库:
gcc -shared -fPIC -Wl,soname,my_soname -o library_name source_files library_files
其中:
shared
:输出结果为共享类型。fPIC
:输出文件为地址无关代码。Wl
:可以将指定的参数传递给链接器。比如我们使用-Wl,soname,my_soname
,GCC会将-soname my_soname
传递给链接器。比如我们有libfoo1.c
和libfoo2.c
两个源代码文件,希望产生一个libfoo.so.1.0.0
共享库,这个共享库依赖与libbar1.so
和libbar2.so
这两个共享库,则可以使用以下命令:
gcc -shared -fPIC -Wl,soname,libfoo.so.1 -o libfoo.so.1.0.0 libfoo1.c libfoo2.c -lbar1 -lbar2
几个值得注意的事项:
-fomit-frame-pointer
选项,这样做虽然不会导致共享库停止运行,但是会影响调试共享库,给后面的工作带来很多麻烦。-rpath
选项(或者GCC的-Wl,-rpath
),这种方法可以指定链接产生的目标程序的共享库查找路径。dlopen()
动态加载某个共享模块,而该共享模块需反向引用主模块的符号时,有可能主模块的某些符号因为在链接时没有被其它共享模块引用而没有被放到动态符号表里面,导致了反向引用失败。ld链接器提供了一个-export-dynamic
的参数,这个参数表示链接器在生产可执行文件时,将所有全局符号导出到动态符号表,以防止出现上述问题。我们也可以在GCC中使用-Wl,-export-dynamic
将该参数传递给链接器。正常情况下编译出来的共享库或可执行文件里面带有符号信息和调试信息,这些信息在调试时非常有用,但是对于最终发布的版本来说,这些符号信息用处并不大,并且使得文件尺寸变大。我们可以使用一个叫strip的工具清除掉共享库或可执行文件的所有符号和调试信息(“strip是binutils的一部分):$ strip libfoo.so
$ ls -lh
lrwxrwxrwx 1 huangqiqi huangqiqi 22 8月 24 11:05 libluajit-5.1.so.2 -> libluajit-5.1.so.2.1.0
-rwxr-xr-x 1 huangqiqi huangqiqi 2.5M 8月 24 11:05 libluajit-5.1.so.2.1.0
$ strip libluajit-5.1.so.2.1.0
$ ls -lh
lrwxrwxrwx 1 huangqiqi huangqiqi 22 8月 24 11:05 libluajit-5.1.so.2 -> libluajit-5.1.so.2.1.0
-rwxr-xr-x 1 huangqiqi huangqiqi 980K 9月 1 18:05 libluajit-5.1.so.2.1.0
去除符号和调试信息以后的文件往往比之前要小很多。除了使用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,只不过需要指定共享库所在的目录:
$ ldconfig -n shared_library_directory
在编译程序时,也需要指定共享库的位置,GCC提供了两个参数-L
和-l
,分别用于指定共享库搜索目录和共享库。也可以使用-rpath
参数。
很多时候你希望共享库在被装载时能够进行一些初始化工作,比如打开文件、网络连接等,使得共享库里面的函数接口能够正常工作。GCC提供了一种共享库的构造函数,只要在函数声明时加上__attribute__((constructor))
的属性,即指定该函数为共享库构造函数,拥有这种属性的函数会在共享库加载时被执行,即在程序的main函数之前执行。如果我们使用dlopen()
打开共享库,共享库构造函数会在dlopen()
返回之前执行。
与共享库构造函数相对应的是析构函数,我们可以使用在函数声明时加上__attribute__((destructor))
的属性,这种函数会在main()
函数执行完毕之后执行(或者是程序调用exit()
时执行)。如果共享库是在运行时加载的,那么我们使用dlclose()
来卸载共享库时,析构函数将会在dlclose()
返回之前执行。
声明构造函数和析构函数的格式如下:
void __attribute__((constructor)) init_function(void);
void __attribute__((destructor)) finit_function(void);
这种__attribute__
属性是GCC对C/C++的扩展,在其他语法上这种语法并不一定通用。值得注意的是,如果我们使用了这种析构或构造函数,那么必须使用系统默认的标准运行库和启动文件,即不可以使用GCC的-nostartfiles
或-nostdlib
这两个参数。因为这些构造和析构函数是在系统默认的标准运行库或启动文件里面被运行的,如果没有这些辅助构造,它们可能不会被运行。
另外还有一个问题是,如果我们有多个构造函数,那么默认情况下,它们被执行的顺序是没有规定的。如果我们希望构造和析构函数能够按照一定的顺序执行,GCC为我们提供了一个参数叫做优先级,我们可以指定某个构造或析构函数的优先级:
void __attribute__((constructor(5))) init_function1(void);
void __attribute__((constructor(10))) init_function2(void);
对于构造函数来说,属性中优先级数字越小的函数将会在优先级大的函数之前运行;而对于析构函数来讲,则刚好相反。这种安排有利于构造函数和析构函数能够匹配,比如某一对构造函数和析构函数分别用来申请和释放某个资源,那么它们可以拥有一样的优先级。这样做的结果往往是先申请的资源后释放,符合资源释放的一般规则。
前面所提到的共享库都是动态链接的ELF共享对象文件(.so),事实上,共享库还可以是符合一定格式的链接脚本文件。通过这种脚本文件,我们可以把几个现有的共享库通过一定的方式组合起来,从用户的角度看就是一个新的共享库。比如我们可以把C运行库和数学库组合成一个新的库libfoo.so
,其内容如下:
GROUP( /lib/libc.so.6 /lib/libm.so.2 )
这里的脚本与LD的脚本从语法和命令上来讲没什么区别,它们的作用也相似,即将一个或多个输入文件以一定的格式经过变换以后形成一个输出文件。我们也可以将这种共享库脚本叫做动态链接脚本,因为这个链接过程是动态完成的,也就是运行时完成的。