共享库是由程序启动时加载的库。当共享库正确安装后,所有之后启动的程序都会自动使用新的共享库。实际上,它比这更灵活和复杂,因为 Linux 使用的方法允许你:
更新库,并且仍然支持想要使用这些库的旧的、不向后兼容版本的程序;
在执行特定程序时,覆盖特定库甚至库中的特定函数。
在程序运行时使用现有库的情况下完成所有这些操作。
为了使共享库支持所有这些期望的属性,必须遵循许多约定和指南。你需要理解库的名称之间的区别,特别是它的 “soname” 和 “真实名称”(以及它们如何交互)。你还需要了解它们应该放置在文件系统中的位置。
每个共享库都有一个特殊的名称,称为 “soname”。soname 具有前缀 “lib”,库的名称,短语 “.so”,后跟一个句点和一个版本号,每当接口更改时,版本号都会递增(作为一个特殊的例外,最低级别的 C 库不以 “lib” 开头)。完全限定的 soname 包括它所在的目录作为前缀;在工作系统中,完全限定的 soname 只是指向共享库 “真实名称” 的符号链接。
每个共享库也有一个 “真实名称”,它是包含实际库代码的文件名。真实名称在 soname 的基础上添加一个句点、一个次版本号、另一个句点和发行版本号。最后的句点和发行版本号是可选的。次版本号和发行版本号通过让你准确了解已安装的库的版本来支持配置控制。请注意,这些数字可能与文档中用于描述库的数字不同,尽管那样做会使事情更容易。
此外,还有编译器在请求库时使用的名称(我称之为 “链接器名称”),它只是没有版本号的 soname。
管理共享库的关键是这些名称的分离。程序在内部列出它们需要的共享库时,应该只列出它们需要的 soname。相反,当你创建一个共享库时,你只创建一个具有特定文件名(具有更详细的版本信息)的库。当你安装新版本的库时,你将其安装在几个特殊目录之一中,然后运行程序 ldconfig(8)。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:standards#Directory_Variables 的 info 文件文档。GNU 标准建议在分发源代码时,默认将所有库安装在 /usr/local/lib 中(所有命令应进入 /usr/local/bin)。它们还定义了覆盖这些默认值和调用安装程序的约定。
文件系统层次结构标准 (FHS) 讨论了发行版中应该放置什么内容(参见 http://www.pathname.com/fhs)。根据 FHS,大多数库应该安装在 /usr/lib 中,但启动所需的库应该在 /lib 中,而系统非必需的库应该在 /usr/local/lib 中。
这两份文档之间实际上没有冲突;GNU 标准建议源代码开发者的默认设置,而 FHS 建议发行商的默认设置(发行商有选择地覆盖源代码默认设置,通常通过系统的包管理系统)。实际上,这工作得很好:你下载的 “最新”(可能存在错误!)源代码会自动安装在 “local” 目录中(/usr/local),一旦代码成熟,包管理器可以轻松地覆盖默认设置,将代码放置在发行版的标准位置。请注意,如果你的库调用只能通过库调用的程序,你应该将这些程序放在 /usr/local/libexec 中(在发行版中变为 /usr/libexec)。一个复杂之处是,基于 Red Hat 的系统默认情况下不包括 /usr/local/lib 在它们的库搜索中;请参阅下面关于 /etc/ld.so.conf 的讨论。其他标准库位置包括用于 X 窗口的 /usr/X11R6/lib。请注意,/lib/security 用于 PAM 模块,但这些模块通常作为 DL 库加载(也在下面讨论)。
在基于 GNU glibc 的系统上,包括所有 Linux 系统,启动 ELF 二进制可执行文件会自动导致程序加载器被加载和运行。在 Linux 系统上,此加载器名为 /lib/ld-linux.so.X(其中 X 是版本号)。此加载器反过来查找并加载程序使用的所有其他共享库。
要搜索的目录列表存储在文件 /etc/ld.so.conf 中。许多基于 Red Hat 的发行版通常不包括 /usr/local/lib 在文件 /etc/ld.so.conf 中。我认为这是一个错误,将 /usr/local/lib 添加到 /etc/ld.so.conf 是在基于 Red Hat 的系统上运行许多程序所需的常见 “修复”。
如果你只想覆盖库中的一些函数,但保留库的其余部分,你可以在 /etc/ld.so.preload 中输入覆盖库(.o 文件)的名称;这些 “预加载” 库将优先于标准集合。此预加载文件通常用于紧急补丁;发行版通常在交付时不会包含这样的文件。
在程序启动时搜索所有这些目录将是非常低效的,因此实际上使用了缓存机制。程序 ldconfig(8) 默认读取文件 /etc/ld.so.conf,在动态链接目录中设置适当的符号链接(以便它们遵循标准约定),然后将缓存写入 /etc/ld.so.cache,然后由其他程序使用。这大大加快了对库的访问。这意味着每当添加 DLL、删除 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) 更新,因此位置上的这种差异只在少数特殊情况下才重要。
各种环境变量可以控制此过程,并且有一些环境变量允许你覆盖此过程。
你可以临时替换此特定执行的不同库。在 Linux 中,环境变量 LD_LIBRARY_PATH 是一个冒号分隔的目录集合,库应该首先在其中搜索,然后再搜索标准目录集;这在调试新库或为特殊目的使用非标准库时非常有用。环境变量 LD_PRELOAD 列出具有覆盖标准集合的函数的共享库,就像 /etc/ld.so.preload 一样。这些由加载器 /lib/ld-linux.so 实现。我应该注意到,虽然 LD_LIBRARY_PATH 在许多类 Unix 系统上有效,但并非在所有系统上都有效;例如,此功能在 HP-UX 上可用,但作为环境变量 SHLIB_PATH,而在 AIX 上,此功能通过变量 LIBPATH(具有相同的语法,一个冒号分隔的列表)。
LD_LIBRARY_PATH 对于开发和测试非常方便,但不应由安装过程为普通用户的正常使用而修改;请参阅 http://www.visi.com/~barr/ldpath.html 上的 “为什么 LD_LIBRARY_PATH 是有害的” 以了解原因。但它仍然对开发或测试以及解决无法以其他方式解决的问题很有用。如果你不想设置 LD_LIBRARY_PATH 环境变量,在 Linux 上,你甚至可以直接调用程序加载器并向其传递参数。例如,以下将使用给定的 PATH 而不是环境变量 LD_LIBRARY_PATH 的内容,并运行给定的可执行文件
/lib/ld-linux.so.2 --library-path PATH EXECUTABLE |
GNU C 加载器中的另一个有用的环境变量是 LD_DEBUG。这会触发 dl* 函数,以便它们提供关于它们正在做什么的非常详细的信息。例如
export LD_DEBUG=files command_to_run |
将 LD_DEBUG 设置为 “help” 然后尝试运行程序将列出可能的选项。同样,LD_DEBUG 并非旨在用于正常使用,但在调试和测试时可能很有用。
实际上还有许多其他环境变量控制加载过程;它们的名称以 LD_ 或 RTLD_ 开头。大多数其他变量用于加载器过程的底层调试或用于实现特殊功能。它们中的大多数没有得到很好的文档记录;如果你需要了解它们,了解它们的最佳方法是阅读加载器的源代码(gcc 的一部分)。
如果不采取特殊措施,允许用户控制动态链接库对于 setuid/setgid 程序将是灾难性的。因此,在 GNU 加载器(它在程序启动时加载程序的其余部分)中,如果程序是 setuid 或 setgid,则这些变量(和其他类似变量)将被忽略或在它们可以执行的操作中受到很大限制。加载器通过检查程序的凭据来确定程序是否为 setuid 或 setgid;如果 uid 和 euid 不同,或者 gid 和 egid 不同,则加载器假定程序是 setuid/setgid(或源自其中之一),因此大大限制了其控制链接的能力。如果你阅读 GNU glibc 库源代码,你可以看到这一点;特别参见文件 elf/rtld.c 和 sysdeps/generic/dl-sysdep.c。这意味着如果你使 uid 和 gid 等于 euid 和 egid,然后调用程序,这些变量将具有完全效果。其他类 Unix 系统以不同的方式处理这种情况,但出于相同的原因:setuid/setgid 程序不应受到环境变量设置的不当影响。
创建共享库很容易。首先,使用 gcc -fPIC 或 -fpic 标志创建将进入共享库的目标文件。-fPIC 和 -fpic 选项启用 “位置无关代码” 生成,这是共享库的要求;请参阅下文了解差异。使用 -Wl gcc 选项传递 soname。-Wl 选项将选项传递给链接器(在本例中为 -soname 链接器选项)——-Wl 后的逗号不是错别字,并且你不得在选项中包含未转义的空格。然后使用以下格式创建共享库
gcc -shared -Wl,-soname,your_soname \ -o library_name file_list library_list |
这是一个示例,它创建两个目标文件(a.o 和 b.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 |
以下是一些值得注意的点
不要剥离生成的库,并且除非你真的需要,否则不要使用编译器选项 -fomit-frame-pointer。生成的库将工作,但这些操作使调试器几乎无用。
使用 -fPIC 或 -fpic 生成代码。是否使用 -fPIC 或 -fpic 生成代码取决于目标。-fPIC 选择始终有效,但可能生成比 -fpic 更大的代码(记住这一点的助记符是 PIC 的字母较大,因此它可能生成大量的代码)。使用 -fpic 选项通常会生成更小更快的代码,但会有平台相关的限制,例如全局可见符号的数量或代码的大小。当你创建共享库时,链接器会告诉你它是否适合。如有疑问,我选择 -fPIC,因为它始终有效。
在某些情况下,调用 gcc 以创建目标文件还需要包括选项 “-Wl,-export-dynamic”。通常,动态符号表仅包含动态对象使用的符号。此选项(在创建 ELF 文件时)将所有符号添加到动态符号表(有关更多信息,请参阅 ld(1))。当存在 “反向依赖关系” 时,你需要使用此选项,即 DL 库具有未解析的符号,这些符号按照约定必须在打算加载这些库的程序中定义。为了使 “反向依赖关系” 工作,主程序必须使其符号动态可用。请注意,如果你仅使用 Linux 系统,则可以说 “-rdynamic” 而不是 “-Wl,export-dynamic”,但根据 ELF 文档,“-rdynamic” 标志并非总是适用于非 Linux 系统上的 gcc。
在开发期间,存在修改库的潜在问题,该库也被许多其他程序使用 —— 并且你不希望其他程序使用 “开发” 库,而只希望特定的应用程序针对它进行测试。你可能使用的一个链接选项是 ld 的 “rpath” 选项,它指定正在编译的特定程序的运行时库搜索路径。从 gcc,你可以通过以下方式调用 rpath 选项
-Wl,-rpath,$(DEFAULT_LIB_INSTALL_PATH) |
创建共享库后,你将需要安装它。简单的方法只是将库复制到标准目录之一(例如,/usr/lib)并运行 ldconfig(8)。
首先,你需要将共享库创建在某个位置。然后,你需要设置必要的符号链接,特别是从 soname 到真实名称的链接(以及从无版本 soname 的链接,即以 “.so” 结尾的 soname,适用于根本不指定版本的用户)。最简单的方法是运行
ldconfig -n directory_with_shared_libraries |
最后,当你编译你的程序时,你需要告诉链接器你正在使用的任何静态库和共享库。为此,请使用 -l 和 -L 选项。
如果你不能或不想将库安装在标准位置(例如,你没有修改 /usr/lib 的权限),那么你需要更改你的方法。在这种情况下,你需要将其安装在某个位置,然后为你的程序提供足够的信息,以便程序可以找到库... 并且有几种方法可以做到这一点。在简单的情况下,你可以使用 gcc 的 -L 标志。你可以使用 “rpath” 方法(如上所述),特别是如果你只有一个特定的程序要使用放置在 “非标准” 位置的库。你还可以使用环境变量来控制事情。特别是,你可以设置 LD_LIBRARY_PATH,这是一个冒号分隔的目录列表,用于在常用位置之前搜索共享库。如果你正在使用 bash,你可以使用以下方式调用 my_program
LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH my_program |
如果你想仅覆盖少数选定的函数,你可以通过创建一个覆盖对象文件并设置 LD_PRELOAD 来做到这一点;此对象文件中的函数将仅覆盖那些函数(将其他函数保持原样)。
通常,你可以更新库而无需担心;如果存在 API 变更,库创建者应该更改 soname。这样,单个系统上可以有多个库,并且为每个程序选择正确的库。但是,如果程序在更新到保持相同 soname 的库时崩溃,你可以通过将旧库复制回某个位置,将程序重命名(例如,重命名为旧名称加上 “.orig”),然后创建一个小的 “包装” 脚本来强制它使用旧库版本,该脚本重置库以供使用并调用真实的(重命名的)程序。如果你愿意,你可以将旧库放在其自己的特殊区域中,尽管编号约定允许多个版本存在于同一目录中。包装脚本可能如下所示
#!/bin/sh export LD_LIBRARY_PATH=/usr/local/my_lib:$LD_LIBRARY_PATH exec /usr/bin/my_program.orig $* |
你可以使用 ldd(1) 查看程序使用的共享库列表。因此,例如,你可以通过键入以下内容来查看 ls 使用的共享库
ldd /bin/ls |
/lib/ld-linux.so.N(其中 N 为 1 或更大,通常至少为 2)。这是加载所有其他库的库。
libc.so.N(其中 N 为 6 或更大)。这是 C 库。即使是其他语言也倾向于使用 C 库(至少实现他们自己的库),因此大多数程序至少包括这一个。
当新版本的库与旧版本二进制不兼容时,soname 需要更改。在 C 中,库不再二进制兼容的基本原因有四个:
函数的行为发生更改,使其不再符合其原始规范;
导出的数据项发生更改(例外:在结构的末尾添加可选项目是可以的,只要这些结构仅在库中分配)。
导出的函数被删除。
导出函数的接口发生更改。
如果可以避免这些原因,你可以保持库二进制兼容。换句话说,如果你避免此类更改,则可以保持你的应用程序二进制接口 (ABI) 兼容。例如,你可能想要添加新函数,但不删除旧函数。你可以向结构添加项目,但前提是你能够通过仅将项目添加到结构的末尾、仅允许库(而不是应用程序)分配结构、使额外项目成为可选的(或让库填充它们)等方式来确保旧程序不会对这些更改敏感。注意 - 如果用户在数组中使用结构,你可能无法扩展结构。
对于 C++(以及其他支持编译时模板和/或编译时分派方法的语言),情况更复杂。以上所有问题都适用,外加更多问题。原因是某些信息在编译后的代码中 “底层” 实现,导致依赖关系可能不明显,如果你不知道 C++ 的典型实现方式。严格来说,它们不是 “新” 问题,只是编译后的 C++ 代码以你可能感到惊讶的方式调用它们。以下是(可能不完整)你不能在 C++ 中执行并保持二进制兼容性的事情列表,正如 Troll Tech 的技术 FAQ 报告的那样:
添加虚函数的重新实现(除非旧的二进制文件调用原始实现是安全的),因为编译器在编译时(而不是链接时)评估 SuperClass::virtualFunction() 调用。
添加或删除虚成员函数,因为这将更改每个子类的 vtbl 的大小和布局。
更改任何数据成员的类型或移动可以通过内联成员函数访问的任何数据成员。
更改类层次结构,除了添加新的叶子。
添加或删除私有数据成员,因为这将更改每个子类的大小和布局。
删除公有或受保护的成员函数,除非它们是内联的。
使公有或受保护的成员函数内联。
更改内联函数的作用,除非旧版本继续工作。
在可移植程序中更改成员函数的访问权限(即,公有、受保护或私有),因为某些编译器会将访问权限混杂到函数名称中。
鉴于这个长长的列表,特别是 C++ 库的开发者必须计划不仅仅是偶尔的更新,这些更新会破坏二进制兼容性。幸运的是,在类 Unix 系统(包括 Linux)上,你可以同时加载库的多个版本,因此虽然存在一些磁盘空间损失,但用户仍然可以运行需要旧库的 “旧” 程序。