从开发者面对的动态库,对linux发行版兼容性差的缺点和痛点出发,本文梳理问题、探讨并分享3种解决思路。
Linux系统如何知道哪些路径下有动态链接库可供链接加载?可借助ldconfig缓存的信息。
ldconfig 是一个工具程序,用于更新动态链接器的缓存。动态链接器在加载动态库时,会先查找缓存,如果缓存中已经存在对应的动态库的记录,则直接使用缓存中的信息,否则再根据环境变量LD_LIBRARY_PATH从对应的目录内找动态库文件。
那么ldconfig的缓存,究竟存储在哪里?在内存吗?还是在文件系统?
可以通过命令查询当前系统已缓存了哪些动态库:
以下通过命令 ldconfig -p 查询当前系统已缓存的动态库,包含库文件名称、版本信息、体系结构、库文件所在路径。以下查询结果仅展示常用的动态库,比如 libstdc++,libMySQLclient等动态库,
user@linuxlibs:~$ ldconfig -p
785 libs found in cache `/etc/ld.so.cache'
…… # std-c++ 动态库
libstdc++.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libstdc++.so.6
libssl3.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libssl3.so
libssl.so.3 (libc6,x86-64) => /lib/x86_64-linux-gnu/libssl.so.3
libssl.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libssl.so
libssh.so.4 (libc6,x86-64) => /lib/x86_64-linux-gnu/libssh.so.4
…… # Python/ target=_blank class=infotextkey>Python核心动态库
libpython3.10.so.1.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libpython3.10.so.1.0
…… # 线程相关的动态库
libpthread.so.0 (libc6,x86-64, OS ABI: Linux 3.2.0) => /lib/x86_64-linux-gnu/libpthread.so.0
……
libodbc.so.2 (libc6,x86-64) => /lib/x86_64-linux-gnu/libodbc.so.2
libodbc.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libodbc.so
…… # mysql 客户端动态库
libmysqlclient.so.21 (libc6,x86-64) => /lib/x86_64-linux-gnu/libmysqlclient.so.21
libmysqlclient.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libmysqlclient.so
…… # 维护链接信息的动态库
ld-linux-x86-64.so.2 (libc6,x86-64) => /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
Cache generated by: ldconfig (Ubuntu GLIBC 2.35-0ubuntu3.4) stable release version 2.35
上面最后的 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 是 Linux 系统的一部分。这个库主要用于加载和运行其他动态链接库。
当一个程序需要使用其他动态链接库中的函数时,它会通过调用 ld-linux-x86-64.so.2 来加载所需的动态链接库,并解析其中的符号(函数、变量等)。这样,程序就可以在运行时动态地使用其他库中的功能,而不需要在编译时将这些库静态链接到程序中。
我们注意到输出信息第一行为785 libs found in cache /etc/ld.so.cache (在缓存文件中找到785个库文件记录),说明 /etc/ld.so.cache 是 ldconfig 搜索动态库时依据的缓存文件,该缓存文件记录了785个动态库文件的信息,每条信息记录了 key=>value 的这样的键值对形式,例如libmysqlclient.so.21 (libc6,x86-64) => /lib/x86_64-linux-gnu/libmysqlclient.so.21。用 ls -lht 命令查看该缓存文件的属性:
user@linuxlibs:~$ ls -lht /etc/ld.so.cache
-rw-r--r-- 1 root root 48K Dec 11 10:45 /etc/ld.so.cache
user@linuxlibs:~$ file /etc/ld.so.cache
/etc/ld.so.cache: data #类型是二进制数据文件,有内部格式无法直接查看内容
从中,我们第一可以明确的是,缓存文件存储在磁盘。
第二可以推断,磁盘的缓存文件,可能有通过mmap()方式映射内到存中,以满足系统各类软件频繁获取动态库信息的效率要求。这个猜测后续会进一步验证。
首先,Ubuntu linux的动态库文件,需要通过apt安装后才会出现在系统库目录内。例如C++ 程序在运行时需要链接的动态库libstdc++.so.6.0.30,可通过apt install 安装包 libstdc++6 获得。
#可通过 dpkg -L 查询软件包 libstdc++6 安装后新增了哪些文件:
user@linuxlibs:~$ dpkg -L libstdc++6
/.
/usr
/usr/lib
/usr/lib/x86_64-linux-gnu
/usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.30
/usr/lib/x86_64-linux-gnu/libstdc++.so.6
/usr/share/doc/libstdc++6
…… 略
安装后,允许存在多个不同版本的libstdc++动态库包,但系统启用的只是其中一个:
user@linuxlibs:~$ ls -l /usr/lib/x86_64-linux-gnu/libstdc++.so.6
…… /usr/lib/x86_64-linux-gnu/libstdc++.so.6 -> libstdc++.so.6.0.30
我们看到这里的libstc++动态库版本号是6.0.30。但千万不要误以为这是Linux采用的C++标准库的代码版本号。动态库版本号 6.0.30 表示该动态库的版本信息。版本号通常由三个部分组成,分别表示主版本号、次版本号和补丁版本号。
动态库的版本号通常用于标识软件或库的不同版本,以便用户和系统能够识别和管理不同版本的软件或库。在使用动态库时,程序会根据需要去加载相应版本的动态库。
动态库的版本号的确定,通常是由库的开发者或维护者根据库的更新和发布情况分配的。因此,即使库的代码进行了重大更新,版本号也可能只进行了微小的变化。所以库的版本号并不一定与库的代码版本直接相关;即使同一个vim软件,在不同的Linux发行版有不同的打包维护人,虽然都从软件官方获得同一源码版本号的vim的代码,但不同Linux发行版的软件打包维护人根据自己发行版的情况决定编译后打包的库文件的版本。
当然,也有些软件采用两个版本号相等的方式发布软件版本和代码,比如Ubuntu的维护者将OpenGL项目的动态库版本与代码版本保持一致,动态库版本后面以-数字表示该版本代码的第几次正式打包,这个数字每次正式打包前都要+1,如图中的2.2.0-4为Ubuntu 22.04系统的libglew2.2软件包的2.2.0版本的第4次打包入库。
虽然这个版本经历了4次打包发布,但libglew-2.2.0的4次生成的软件包,在安装后,路径中的动态库的文件名仍保持.so.2.2.0结尾。
这是因为4次打包期间,库代码接口没变,自然不应该修改X.Y.Z中的任何一个数字。以免破坏 /usr/lib/x86_64-linux-gnu/libGLEW.so -> libGLEW.so.2.2.0 这种libGLEW.so软链接对实际动态库文件libGLEW.so.2.2.0的链接效果:
如果开发依赖了 OpenGL(v1,v2,v3都有,libGLEW、libGLut、libGL、libegl-mesa0等名称繁多) 这种带有较多版本历史包袱的开发库,有时必须确定系统已安装的OpenGL库的版本号跟开发要求的库的代码版本号的是否匹配,才能确保代码调用的函数跟实际运行环境的库的版本能兼容。
那么如何查询呢?这个问题没有为唯一答案,软件的发布方式和维护形式太多了。但那些主流的Linux发行版的软件源安装的软件包往往采用了近似的策略,方便了用户查询帮助信息。如果你的Linux是Ubuntu、centos,那么安装后可以直接从命令中获得大部分信息,包括动态库版本说明:
(1) Ubuntu使用命令$ apt show libglew2.2 查询软件帮助信息
(2) Centos 使用命令$ yum info glew-devel 查询软件帮助信息
[root@device78969 ~]# yum info glew-devel
Loaded plugins: fastestmirror, ovl
Loading mirror speeds from cached hostfile
* base: mirrors.ustc.edu.cn
* extras: mirrors.aliyun.com
* updates: mirrors.163.com
AvAIlable Packages
Name : glew-devel
Arch : i686
Version : 1.10.0
Release : 5.el7
Size : 172 k
Repo : base/7/x86_64
Summary : Development files for glew
URL : http://glew.sourceforge.NET
License : BSD and MIT
Description : Development files for glew
Name : glew-devel
Arch : x86_64
Version : 1.10.0
Release : 5.el7
Size : 172 k
Repo : base/7/x86_64
Summary : Development files for glew
URL : http://glew.sourceforge.net
License : BSD and MIT
Description : Development files for glew
这是很多开发者发布软件包时最头痛的,系统自带的动态库版本,与软件运行所要求的动态库不兼容,直接影响了软件在Linux当前系统的正常功能。
评论区有网友指出,动态库最大的弊端是跨Linux发行版部署的时候,常因为依赖的动态库版本在不同Linux上实际安装的是不同版本,两个版本的动态库未保持向低版本兼容,导致主程序找不到合适的依赖库版本而无法运行。
下面介绍针对这个问题的3种解法(实际本质上是2种:被动、主动)
通过局部环境变量设置 LD_LIBRARY_PATH 和 PRE_LOAD,让软件优先使用受支持版本的动态库。
LD_PRELOAD 环境变量用于指定在加载动态链接库时优先加载的库文件。通过设置 LD_PRELOAD 环境变量,你可以在程序加载动态链接库之前加载你自己的库文件,从而实现对程序行为的修改或调试。
想在局部生效 LD_PRELOAD 环境变量,可以使用以下内容写到一个statup.sh脚本内:
#!/usr/bin/bash
export LD_PRELOAD=/path/to/your/library
./my_dir/my_programe
或:
#!/usr/bin/bash
LD_PRELOAD=/path/to/your/library ./my_dir/my_programe
其中,/path/to/your/library 是你要加载的库文件的路径,最好是当前可执行文件所在目录下的动态库文件,以方便管理;./my_dir/my_programe为你要运行的可执行文件。
LD_PRELOAD 环境变量的使用需要谨慎,因为它可能会影响程序的正常运行,所以只建议在shell脚本内部使用,通过脚本运行后只有你的my_program受这个环境变量加载的动态库的影响:而且会优先加载你指定的动态库而不加载其他同名的动态库,就避免了与系统自带动态库的冲突。
这种设置环境脚本的思路,在Tomcat和Pycharm的安装方式和启动方式中被采用。
!!! 提醒:
在使用 LD_PRELOAD 环境变量进行调试或修改程序行为时,建议在测试环境中进行,并确保对可能的影响有充分的了解,以免影响系统正常运行。
下面是一段伪代码,演示了由C代码控制,在运行时才加载动态库文件到进程中。一个好处是延迟了加载,而且由代码负责检测该动态库是否提供了所需功能,若未提供,则卸载动态库,再去加载其他动态库:
#include <dll_function_headers.h>
// 定义加载动态库的函数
void* load_library(const char* library_path, const char* symbol_name) {
// 打开动态库
void* handle = dlopen(library_path, RTLD_LAZY);
if (handle == NULL) {
printf("dlopen() failed: %sn", dlerror());
return NULL;
}
// 查找符号
void* symbol_address = dlsym(handle, symbol_name);
if (symbol_address == NULL) {
printf("dlsym() failed: %sn", dlerror());
dlclose(handle);
return NULL;
}
// 返回符号地址
return symbol_address;
}
// 定义使用动态库符号的函数
int use_symbol(void* symbol_address) {
// 定义符号的函数指针类型
typedef int (*symbol_func_t)(void);
symbol_func_t symbol_func = (symbol_func_t)symbol_address;
// 调用符号对应的函数
int result = symbol_func();
return result;
}
int main() {
// 假设动态库路径为 /path/to/library.so,符号名称为 symbol
const char* library_path = "/path/to/library.so";
const char* symbol_name = "symbol";
// 加载动态库
void* symbol_address = load_library(library_path, symbol_name);
if (symbol_address == NULL) {
printf("加载动态库失败n");
return 1;
}
// 使用符号
int result = use_symbol(symbol_address);
if (result != 0) {
printf("符号调用失败n");
return 1;
}
// 关闭动态库
dlclose(handle);
return 0;
}
但这种方式没有解决所有本地动态库都无法支持当前Linux系统的特殊场景。
这种方式仍然由本地软件负责加载。但加载的来源改为从服务端API交互,将本地系统的版本信息告诉服务端,由服务端的数据中心提供能匹配本地Linux的动态库文件,由本地软件将服务端告知的动态库文件,下载到本地。然后加载运行。
由于配备了后台服务,且对各种需要兼容的Linux系统做了测试,准备了匹配的动态库下载使用,所以软件安装包可以很小。这种方式其实也是很多软件采用的软件自身新版本的更新机制。
缺点是需要网络,开发阶段需要做多种Linux系统的动态库兼容性测试,在服务端需要维护匹配的动态库文件与信息。
适合于达到一定使用规模的软件采用。