系统共享库(Shared Library)

系统共享库(Shared Library)是操作系统中的一种库文件,它包含可以被多个程序同时使用的函数、变量或其他资源,而不必将这些资源分别编译到每个程序的可执行文件中。它们通常是动态链接的,这意味着在程序运行时才会被加载到内存中,而不是在编译时直接嵌入到程序中。

主要特点

资源共享:多个程序可以同时使用相同的共享库,从而减少了系统内存的占用,因为多个程序可以共用同一份代码,而无需每个程序都各自加载一份。
动态加载:共享库通常是动态链接库(Dynamic Link Library,DLL,在Windows中)或者共享对象(Shared Object,.so,在Linux中),它们在程序运行时被加载到内存中。这意味着程序启动时并不需要将整个库加载到内存中,只有当需要时才会动态加载,节省了系统资源。
维护便利:因为共享库是独立于应用程序之外的单独文件,如果库中的代码需要更新或修复错误,管理员只需要更新共享库本身,而不需要重新编译依赖于该库的所有应用程序。
代码重用:共享库中的代码可以在多个程序之间重用,提升了开发效率和代码的一致性。比如,系统中的C标准库(如glibc、musl等)就可以被所有C语言编写的程序使用,而不需要每个程序都包含这些库的副本。

共享库名称

每个共享库都有一个特殊的名称,称为“soname”。Soname 以“lib”为前缀,接着是库的名称,随后是“.so”这个短语,再后面是一个版本号,当接口发生变化时,该版本号会递增(作为一个特殊例外,最低级别的C库不会以“lib”开头)。一个完全限定的 soname 还包括其所在目录的前缀;在一个正常运行的系统中,完全限定的 soname 通常是指向共享库“真实名称”的符号链接。

每个共享库还有一个“真实名称”,它是包含实际库代码的文件名。这个“真实名称”在 soname 的基础上增加了一个小版本号、一个点和发行号。最后的点和发行号是可选的。小版本号和发行号支持配置控制,让你能够确切地知道已安装库的版本。请注意,这些数字可能与文档中描述的库版本号不同,尽管一致会让事情变得更加清晰。

此外,还有编译器在请求库时使用的名称(我们称之为“链接器名称”),它仅仅是没有任何版本号的 soname。

管理共享库的关键在于区分这些名称。程序在内部列出所需的共享库时,通常只列出它们需要的 soname。相反,当你创建共享库时,只需为库创建带有具体文件名(包含更详细版本信息)的库文件。当你安装一个新版本的库时,你需要将其安装到某些特殊目录之一,然后运行 ldconfig 程序。ldconfig 会检查现有文件并创建从 soname 到真实名称的符号链接,同时设置缓存文件 /etc/ld.so.cache(稍后会详细说明)。

ldconfig 并不会设置链接器名称;通常这是在库安装过程中完成的,链接器名称通常被创建为指向“最新” soname 或最新真实名称的符号链接。建议将链接器名称作为指向 soname 的符号链接,因为在大多数情况下,如果你更新了库,你可能希望在链接时自动使用它。我曾经询问 H. J. Lu 为什么 ldconfig 不自动设置链接器名称,他的解释是,你可能希望运行使用最新版本库的代码,但开发可能会需要链接到旧版本库。因此,ldconfig 不假设程序需要链接到哪个库,因此安装程序必须明确修改符号链接,以更新链接器将使用的库。

因此,/usr/lib/libreadline.so.3 是一个完全限定的 soname,ldconfig 会将其设置为指向某个真实名称,如 /usr/lib/libreadline.so.3.0 的符号链接。还应有一个链接器名称 /usr/lib/libreadline.so,它可能是一个符号链接,指向 /usr/lib/libreadline.so.3。

在文件系统中的位置

共享库必须放置在文件系统中的某个位置。大多数开源软件倾向于遵循GNU标准;有关更多信息,请参考info文件文档 info:standards#Directory_Variables。GNU标准建议在分发源代码时,默认将所有库安装在/usr/local/lib目录下,而所有命令应放在/usr/local/bin中。它们还定义了覆盖这些默认值的约定以及如何调用安装程序。

文件系统层次结构标准(FHS)讨论了在系统发行版中不同文件应该放置的位置(请参见http://www.pathname.com/fhs)。根据FHS,大多数库应安装在/usr/lib中,但启动时所需的库应安装在/lib中,而系统中不属于核心系统的库应安装在/usr/local/lib中。

这两个文档之间实际上并没有冲突;GNU标准推荐的默认路径适用于源代码开发人员,而FHS的默认路径适用于发行版的维护者(他们通常通过系统的包管理工具来选择性地覆盖源代码的默认安装路径)。在实际操作中,这种安排非常好用:你下载的“最新的”(可能是有问题的)源代码会自动安装到本地目录(/usr/local),而一旦代码成熟,包管理工具可以很容易地覆盖默认路径,将代码放在发行版的标准路径中。需要注意的是,如果你的库需要调用只能通过库访问的程序,那么应该将这些程序放在/usr/local/libexec目录中(在发行版中变为/usr/libexec)。

一个复杂情况是,基于Red Hat的系统默认不会在其库搜索路径中包含/usr/local/lib;关于/etc/ld.so.conf的讨论可以参考以下内容。其他标准库路径还包括用于X-windows的/usr/X11R6/lib。此外,/lib/security用于PAM模块,但这些模块通常作为DL库加载(在下面讨论)。

如何使用系统库

在基于GNU glibc的系统上,包括所有Linux系统,启动ELF二进制可执行文件时,会自动加载并运行程序加载器。在Linux系统上,这个加载器的名称是/lib/ld-linux.so.X(其中X是版本号)。该加载器随后会找到并加载程序使用的所有共享库。

要搜索的目录列表存储在/etc/ld.so.conf文件中。许多基于Red Hat 的发行版通常不会在/etc/ld.so.conf文件中包含/usr/local/lib路径。我认为这是一个问题,因此在这些系统上,常见的解决方案是手动将/usr/local/lib添加到/etc/ld.so.conf中,以便能够运行许多程序。

如果你只想覆盖库中的某些函数而保留其余部分,可以将覆盖库(.o文件)的名称写入/etc/ld.so.preload文件中。这些“预加载”库将优先于标准库使用。预加载文件通常用于紧急补丁;通常发行版交付时不会包含这样的文件。

在程序启动时搜索所有这些目录将非常低效,因此实际使用的是一种缓存机制。ldconfig(8)程序默认会读取/etc/ld.so.conf文件,设置动态链接目录中的适当符号链接(以便遵循标准约定),然后将缓存写入/etc/ld.so.cache文件中,其他程序随后会使用这个缓存文件。这大大加快了库的访问速度。也就是说,每当新增或删除DLL,或是DLL目录集合发生更改时,都必须运行ldconfig。在安装库时,运行ldconfig通常是包管理器执行的一个步骤。启动时,动态加载器实际上会使用/etc/ld.so.cache文件,然后加载它所需的库。

顺便提一下,FreeBSD使用略有不同的缓存文件名。在FreeBSD中,ELF缓存是/var/run/ld-elf.so.hints,而a.out缓存是/var/run/ld.so.hints。这些文件仍然由ldconfig(8)更新,因此这种位置上的差异仅在少数特殊情况下才会产生影响。

创建共享库

创建共享库相对简单。首先,使用gcc-fPIC-fpic标志来创建将被包含在共享库中的目标文件。-fPIC-fpic选项允许生成“位置无关代码”(PIC),这是共享库所必需的;两者的区别会在后面说明。你可以通过-Wl选项向gcc传递soname参数。-Wl选项会将参数传递给链接器(在此例中为-soname链接器选项),注意,-Wl后的逗号不是拼写错误,选项中不应包含未转义的空白字符。然后,使用以下格式创建共享库:

gcc -shared -Wl,-soname,your_soname \
    -o library_name file_list library_list

以下是一个例子,它创建了两个目标文件(a.ob.o),然后创建包含它们的共享库。注意,这个编译过程包括了调试信息(-g)并生成警告(-Wall),这些并非共享库的必需项,但推荐使用。编译生成目标文件时需使用-c选项,并包含必要的-fPIC选项:

gcc -fPIC -g -c -Wall a.c
gcc -fPIC -g -c -Wall b.c
gcc -shared -Wl,-soname,libmystuff.so.1 \
    -o libmystuff.so.1.0.1 a.o b.o -lc

以下几点值得注意:

  • 不要剥离(strip)生成的库文件,也不要使用-fomit-frame-pointer选项,除非确实必要。这样做会影响调试器的使用。

  • 使用-fPIC-fpic生成代码。是否选择-fPIC-fpic依赖于目标平台。-fPIC总是可行的,但可能生成比-fpic更大的代码(记忆法:PIC是大写,表示可能生成更多代码)。通常,-fpic生成的代码较小且更快,但可能在某些平台上有限制,例如可见符号的数量或代码的大小。当你创建共享库时,链接器会告诉你是否合适。在不确定的情况下,选择-fPIC是更安全的,因为它总是有效。

在某些情况下,创建目标文件时还需要包括-Wl,-export-dynamic选项。通常,动态符号表只包含动态对象使用的符号。该选项(在创建ELF文件时)会将所有符号添加到动态符号表中(详情见ld(1))。如果存在“反向依赖”(即,动态链接库中的某些未解析符号必须由加载这些库的程序定义),则需要使用此选项。为了使“反向依赖”生效,主程序必须动态地提供其符号。请注意,在仅使用Linux系统时,你可以使用-rdynamic代替-Wl,export-dynamic,但根据ELF文档,-rdynamic标志在非Linux系统上的gcc不一定有效。

在开发过程中,修改已被多个其他程序使用的库可能会引发问题——你可能不希望其他程序使用“开发版本”库,只希望特定的应用程序测试该库。在这种情况下,你可以使用链接器的rpath选项,指定该特定程序的运行时库搜索路径。通过gcc可以这样调用rpath选项:

-Wl,-rpath,$(DEFAULT_LIB_INSTALL_PATH)

使用此选项编译库客户端程序时,你不需要为LD_LIBRARY_PATH操心,只需确保它不冲突,或使用其他技术隐藏库。

不兼容的库

当一个库的新版本与旧版本在二进制上不兼容时,soname需要更改。在C语言中,导致库不再二进制兼容的基本原因有四个:

  1. 函数的行为发生变化,以至于不再符合其原始规范。
  2. 导出的数据项发生变化(例外情况:可以在结构的末尾添加可选项,只要这些结构仅在库内分配即可)。
  3. 删除了导出的函数。
  4. 导出函数的接口发生变化。

如果能够避免这些原因,就可以保持库的二进制兼容性。换句话说,如果避免这些变化,就可以保持应用程序二进制接口(ABI)的兼容性。例如,您可以添加新函数,但不要删除旧函数。您可以向结构中添加项目,但前提是旧程序不会受到影响,比如仅向结构的末尾添加项目,并且只允许库(而不是应用程序)分配该结构,或者将这些额外的项目设为可选(或由库填充它们),等等。请注意:如果用户在数组中使用了这些结构,您可能无法扩展它们。

对于C++(以及其他支持模板或编译调度方法的语言),情况会更加复杂。上述所有问题都适用于C++,此外还会出现更多问题。原因在于某些信息在编译代码中是“隐藏”实现的,导致了依赖关系,可能并不显而易见,除非您了解C++的典型实现方式。严格来说,这些并不是“新”问题,只是编译后的C++代码以可能让您感到意外的方式调用它们。以下是一个(可能不完整的)列表,由Troll Tech的技术FAQ提供,列出了在保持二进制兼容性时C++中不能做的事情:

  • 添加虚函数的重新实现(除非对旧的二进制来说调用原始实现是安全的),因为编译器会在编译时(而不是链接时)评估SuperClass::virtualFunction()调用。
  • 添加或删除虚成员函数,因为这会改变每个子类的vtbl(虚表)的大小和布局。
  • 改变任何数据成员的类型或移动任何可以通过内联成员函数访问的数据成员。
  • 改变类层次结构,除非是添加新的叶节点。
  • 添加或删除私有数据成员,因为这会改变每个子类的大小和布局。
  • 除非是内联函数,否则不要删除公共或受保护的成员函数。
  • 不要将公共或受保护的成员函数设为内联。
  • 除非旧版本继续工作,否则不要改变内联函数的功能。
  • 在便携式程序中改变成员函数的访问权限(如公共、受保护或私有),因为某些编译器会将访问权限作为函数名的一部分进行处理。

考虑到这个冗长的列表,C++库的开发人员特别需要为可能打破二进制兼容性的更新做好充分的规划。幸运的是,在类似Unix的系统(包括Linux)上,您可以同时加载多个库版本,因此尽管会损失一些磁盘空间,但用户仍然可以运行需要旧库的“旧”程序。

上一篇:基于yolov5_7.0 pyside6 active_learning 开发的人工智能主动学习外周血细胞目标检测系统


下一篇:多模态大语言模型(MLLM)-Blip3/xGen-MM-前言