Linux & Unix 共享库 一

Linux & Unix 共享库 一

版权声明:署名-非商业性使用-相同方式共享

@@ Tags: Linux;动态库
@@ Date: 2023-09-03 2211

共享库是一种将库函数打包成一个单元使之能够在运行时被多个进程共享的技术。

静态库

静态库也被称为归档文件,它是 UNIX 系统提供的第一种库。

从结果上来看,静态库实际上就是一个保存所有被添加到其中的目标文件的副本的文件。
这个归档文件还记录着每个目标文件的各种属性,包括文件权限、数字用户和组 ID 以及最后修改时间。通常静态库的名称的形式为 libname.a

创建和维护静态库

这个创建和更新归档文件

gcc -g -c mod1.c mod2.c mod3.c
ar r libdemo.a mod1.o mod2.o mod3.o

查看并列出归档文件的内容, 其中v表示verbose

ar tv libdemo.a

从归档文件中删除一个模块

ar d libdemo.a mod3.o

使用静态库

gcc -g -c prog.c
gcc -g -o prog prog.o libdemo.a

# 静态库在链接器搜索的其中一个标准目录中(如/usr/lib),
# 然后使用-l 选项指定库名(即库的文件名去除了 lib 前缀和.a 后缀)
gcc -g -o prog prog.o -ldemo

# 如果库不位于链接器搜索的目录中,那么可以只用-L 选项指定链接器应该搜索这个额外的目录。
gcc -g -o prog prog.o -Lmylibdir -ldemo

共享库

注意

我们只关心Executable and Linking Format(ELF)共享库,因为现代版本的Linux 以及很多其他 UNIX 实现的可执行文件和共享库都采用了 ELF 格式。

ELF 取代了较早以前的 a.out 和 COFF 格式。

使用静态库的缺陷

  • 浪费磁盘空间
    一个目标模块被连接到了多个可执行程序中,那么它们将包含同一份二进制代码。
  • 虚拟内存冗余
    对于链接到同一个魔表模块的多个可执行程序而言,每个程序会独立地在虚拟内存中保存一份目标模块的副本。
  • 维护时需要重新编译可执行文件
    修复因目标模块导致的缺陷时,需要将所有链接到该模块的可执行文件重新编译。

共享库就是设计用来解决这些缺点的,目标模块不会被复制到链接过的可执行文件中,相反,当第一个需要共享库中的模块的程序启动时,库的单个副本就会在运行时被加载进内存。当后面使用同一共享库的其他程序启动时,它们会使用已经被加载进内存的库的副本。

然共享库的代码是由多个进程共享的,但其中的变量却不是的。每个使用库的进程会拥有自己的在库中定义的全局和静态变量的副本。

通常共享库的名称的形式为 libname.so[.MAJOR.MINOR.RELEASE]

创建共享库

gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
gcc -g -shared -o libfoo.so mod1.o mod2.o mod3.o

位置独立的代码

-fPIC 选项指定编译器应该生成位置独立的代码,这会改变编译器在生成特定操作代码的方式(包括访问全局、静态和外部变量,访问字符串常量,以及获取函数的地址)。
这些变更使代码可以在运行时被放置在任意一个虚拟地址处,因为在链接的时候是无法知道共享库代码位于内存的何处的。

使用共享库

gcc -g -Wall -o prog prog.c libfoo.so

在链接阶段会将共享库的名称嵌入可执行文件中(在 ELF 中,库依赖性是记录在可执行文件的 DT_NEEDED 标签中的。)以便可执行文件在运行时可以通过某种机制找到所指定的共享库文件。一个程序所依赖的所有共享库列表被称为程序的动态依赖列表

如果运行程序时没有找到指定的共享库文件,那么会收到下面的错误:

./prog
./prog: error while loading shared libraries: libfoo.so: cannot open shared object file: No such file or directory

在运行时解析内嵌的库名,这个任务是由动态链接器(也称为动态链接加载器或运行时链接器)来完成的。动态链接器本身也是一个共享库,其名称为/lib/ld-linux.so.2,所有使用共享库的 ELF 可执行文件都会用到这个共享库。

动态链接器会检查程序所需的共享库清单并使用一组预先定义好的规则来在文件系统上找出相关的库文件,其中一些规则指定了一组存放共享库的标准目录。

LD_LIBRARY_PATH 环境变量

通知动态链接器一个共享库位于一个非标准目录中的一种方法是将该目录添加到 LD_LIBRARY_PATH 环境变量中以分号分隔的目录列表中。如果定义了LD_LIBRARY_PATH,那么动态链接器在查找标准库目录之前会先查找该环境变量列出的目录中的共享库。

因此可以使用下面的命令来运行程序

LD_LIBRARY_PATH=. ./prog
Called mod1-x1
Called mod2-x2

共享库 soname

在前面例子中使用的libfoo.so称为库的真实名称(real name),但实际上经常使用一种叫做soname的别名(ELF中的DT_SONAME标签)来创建共享库。

如果共享库有soname,那么在静态链接阶段(连接器构建可执行二进制阶段)会将soname嵌入到可执行文件中,而不会使用真实名称,同时动态链接加载器也会以这个名称来搜索库文件。

使用soname需要几个步骤:

  • 在创建共享库时指定soname

    gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
    gcc -g -shared -Wl,-soname,libbar.so -o libfoo.so mod1.o mod2.o mod3.o
    
  • 像普通共享库一样链接并创建可执行文件

    gcc -g -Wall -o prog prog.c libfoo.so
    
  • 为了让动态链接加载器能够找到soname指向的库文件,还需要创建一个符号链接来将soname指向真实名称。

    • 图 41-1:创建一个共享库并将一个程序与该共享库链接起来
      图 41-1:创建一个共享库并将一个程序与该共享库链接起来

    • 图 41-2:加载共享库的程序的执行
      图 41-2:加载共享库的程序的执行

  • 检查共享库的soname

    objname -p libfoo.so | grep SONAME
    SONAME      libbar.so
    
    readelf -d libfoo.so | grep SONAME
    TODO: 输出
    

共享库相关工具

  • ldd 列出动态依赖库(程序运行所需的共享库),还会以动态链接加载器相同的搜索顺序解析库文件。

    ldd prog
          linux-vdso.so.1 (0x00007ffe6d9e5000)
          libfoo.so => ./libfoo.so (0x00007f28eb5d2000)
          libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f28eb3d8000)
          /lib64/ld-linux-x86-64.so.2 (0x00007f28eb73d000)
    
  • objdump 可以读取可执行文件、目标文件、共享库中的各类信息,包括反汇编的二进制机器码。

    objdump -p libfoo.so | grep SONAME
    
  • readelf 能够显示ELF节的头部信息。

    readelf -d libfoo.so | grep SONAME
    
  • nm 可以列出目标库、或可执行文件中定义的符号,常备用来查找某个符号是由哪个库定义的。

    nm -A /usr/lib/lib*.so 2> /dev/null | grep ' crypt$'
    

共享库版本及命名规则

版本命名规则可以简单理解为是语义化版本控制的一种实现,具体表现如下:

  • 版本主要由 major.minor.revision 三个数字组成,分别表示库的兼容性约束如下:
    • 主要版本(major)
      主要版本的改变表示:这个库进行了某种不兼容的修改。
    • 次要版本(minor)
      主要版本相同,但次要版本不同的两个库:每个接口呈现出来签名的是一致的,并且函数的语义是等价的(即它们能取得同样的结果)。
    • 修订版本(revision)
      仅存在修订版本的变动表示:仅做出了某些问题的修复,两个库之间是完全相互兼容的,即新的版本可以完全替换旧版本。

因此共享库的真实名称格式规范为 libname.so.major-id.minor-id

libdemo.so.1.0.1
libdemo.so.1.0.2    Minor version, compatible with version 1.0.1
libdemo.so.2.0.0    New major version, incompatible with version 1.*
libreadline.so.5.0

还要为soname创建指向真实名称的符合连接,并且两者之间在一个目录:

libdemo.so.1       -> libdemo.so.1.0.2
libdemo.so.2       -> libdemo.so.2.0.0
libreadline.so.5   -> libreadline.so.5.0

可能会存在主要版本、次要版本不同的几个库文件,但每个库的soname会指向最新的次要版本。

这种配置使得在共享库的运行时操作期间版本化语义能够正确工作,因为soname被连接到可执行文件,所以可以确保其在运行时能够加载库的最新的次要版本。

另外,还会为每个共享库定义一个连接器名称:即一个不包含任何版本号的符号链接,它总是指向最新的库文件。这样当链接器链接可执行文件时,将总是使用正确的版本(即最新版本)。

Linux 共享库名称的命名规范

安装共享库

一般来讲,共享库及其关联的符号链接会被安装在其中一个标准库目录中,标准库目录包括:

  • /usr/lib
    它是大多数标准库安装的目录。
  • /lib
    应该将系统启动时用到的库安装在这个目录中(因为在系统启动时可能还没有挂载/usr/lib)。
  • /usr/local/lib
    应该将非标准或实验性的库安装在这个目录中(对于/usr/lib 是一个由多个系统共享的网络挂载但需要只在本机安装一个库的情况则可以将库放在这个目录中)。
  • /etc/ld.so.conf中列出的目录。

在大多数情况下,将文件复制到这些目录中需要具备超级用户的权限。

安装完之后就必须要创建 soname 和链接器名称的符号链接了,通常它们是作为相对符号链接与库文件位于同一个目录中。

ldconfig

ldconfig主要解决了共享库的两个潜在问题:

  • 共享库可以位于各种目录中,如果动态链接器需要通过搜索所有这些目录来找出一个库并加载这个库,那么整个过程将非常慢。
    • 它搜索标准目录并创建或更新一个缓存文件/etc/ld.so.cache 使之包含在所有这些目录中的主要库版本(每个库的主要版本的最新的次要版本)列表。
    • 动态链接器在运行时解析库名称时会轮流使用这个缓存文件。
    • 为了构建这个缓存,ldconfig 会搜索在/etc/ld.so.conf中指定的目录,然后搜索/lib/usr/lib
    • /etc/ld.so.conf 文件由一个目录路径名(应该是绝对路径名)列表构成,其中路径名之间用换行、空格、制表符、逗号或冒号分隔。
  • 当安装了新版本的库或者删除了旧版本的库,那么 soname 符号链接就不是最新的。
    • 它检查每个库的各个主要版本的最新次要版本(即具有最大的次要版本号的版本)以找出嵌入的 soname,然后在同一目录中为每个 soname 创建(或更新)相对符号链接。

-N 选项会防止缓存的重建
-X 选项会阻止 soname 符号链接的创建。
-v 选项会使得 ldconfig 输出描述其所执行的动作的信息。

升级共享库

共享库的优点之一是:进程正在使用共享库的某个版本时也能够安装库的新主要版本或次要版本。

mv libdemo.so.1.0.2 /usr/lib
ldconfig -v | grep libdemo
    libdemo.so.1 -> libdemo.so.1.0.2 (changed)

已经运行着的程序会继续使用共享库的上一个版本,只有当它们重启之后才会使用新安装的共享库版本。

在目标文件中指定库搜索目录

除了使用 LD_LIBRARY_PATH 环境变量和将共享库安装到标准库目录之外,还存在第三种方式来指定库的搜索目录(-rpath 链接器选项)。

在二进制链接阶段可以在可执行文件中插入一个在运行时搜索共享库的目录列表,这种方式对于库位于一个固定的但不在动态链接器搜索范围内的位置中时非常有用。

gcc -g -Wall -Wl,-rpath,/home/mtk/pdir -o prog prog.c libdemo.so

上面的命令将/home/mtk/pdir写入到了prog可执行文件的运行时库路径(rpath)列表中,因此当运行这个程序时,动态链接器在解析共享库引用时还会搜索这个目录。

多个目录的情况下,-rpath 选项可以指定多次,也可以用分号分割。在运行时时,动态链接器会按照在–rpath 选项中指定的目录顺序来搜索目录。

-rpath 选项的一个替代方案是 LD_RUN_PATH 环境变量。可以将一个由分号分隔开来的目录的字符串赋给该变量,当构建可执行文件时可以将这个变量作为 rpath 列表来使用。
只有当构建可执行文件时不指定-rpath 选项时才会使用 LD_RUN_PATH 变量。

ELF DT_RPATH 和 DT_RUNPATH 条目

在第一版 ELF 规范中,只有一种 rpath 列表能够被嵌入到可执行文件或共享库中,它对应于 ELF 文件中的 DT_RPATH 标签。后续的 ELF 规范舍弃了 DT_RPATH,同时引入了一种新标签 DT_RUNPATH 来表示 rpath 列表。这两种 rpath 列表之间的差别在于当动态链接器在运行时搜索共享库时它们相对于 LD_LIBRARY_PATH 环境变量的优先级:DT_RPATH 的优先级更高,而 DT_RUNPATH 的优先级则更低。

在默认情况下,链接器会将 rpath 列表创建为 DT_RPATH 标签。为了让链接器将 rpath 列表创建为 DT_RUNPATH 条目必须要额外使用––enable–new–dtags(启用新动态标签)链接器选项。如果使用这个选项重建程序并且使用 objdump 查看获得的可执行文件,那么将会看到下面这样的输出。

gcc -g -Wall -o prog prog.c -Wl,--enable-new-dtags \
    -Wl,-rpath,/home/mtk/pdir/d1 -L/home/mtk/pdir/d1 -lx1

objdump -p prog | grep PATH
 RPATH     /home/mtk/pdir/d1  # 保留DT_RPATH 是为了让老式连接器能正常工作,新连接器会忽略该标签
 RUNPATH   /home/mtk/pdir/d1
$ORIGIN

$ORIGIN(或等价的 ${ORIGIN})特殊字符串,在运行时被动态链接器解释成 “应用程序模块的目录”,使用该关键字可以让应用程序使用自身携带的共享库,而不是使用系统全局安装的共享库。

共享库加载顺序(在运行时找出共享库)

在解析库依赖时,动态链接器首先会检查各个依赖字符串以确定它是否包含斜线(/),因为在链接可执行文件时如果指定了一个显式的库路径名的话就会发生这种情况。如果找到了一个斜线,那么依赖字符串就会被解释成一个路径名(绝对路径名或相对路径名),并且会使用该路径名加载库。否则动态链接器会使用下面的规则来搜索共享库。

  1. 如果可执行文件的 DT_RPATH 运行时库路径列表(rpath)中包含目录并且不包含DT_RUNPATH 列表,那么就搜索这些目录(按照链接程序时指定的目录顺序)。
  2. 如果定义了 LD_LIBRARY_PATH 环境变量,那么就会轮流搜索该变量值中以冒号分隔的各个目录。
    如果可执行文件是一个 set-user-IDset-group-ID 程序,那么就会忽略LD_LIBRARY_PATH 变量。这项安全措施是为了防止用户欺骗动态链接器让其加载一个与可执行文件所需的库的名称一样的私有库。
  3. 如果可执行文件 DT_RUNPATH 运行时库路径列表中包含目录,那么就会搜索这些目录(按照链接程序时指定的目录顺序)。
  4. 检查/etc/ld.so.cache 文件以确认它是否包含了与库相关的条目。
  5. 搜索/lib/usr/lib 目录(按照这个顺序)。

运行时符号解析

如果在多个地方(可执行文件、共享库等)定义了一个全局符号(即函数或变量),那么在默认情况下符号的解析将遵循以下逻辑:

  • 主程序中全局符号的定义覆盖库中相应的定义。
  • 如果一个全局符号在多个库中进行了定义,那么对该符号的引用会被绑定到在扫描库时找到的第一个定义,其中扫描顺序是按照这些库在静态链接命令行中列出时从左至右的顺序。

这种做法会导致一些问题,其中最大的问题是这些语义在使用共享库实现一个自包含的子系统时会与共享库模型产生矛盾。

在默认情况下,共享库无法确保一个指向其自身的某个全局符号的引用会真正被绑定到该符号在库中的定义上,从而导致当该共享库被集成到一个更大的系统中时共享库的属性可能会发生改变。

因此我们可以通过使用 –Bsymbolic 来确保共享库中对某函数的调用正确绑定到库中相应的定义上(如果存在的话),但要注意的是,不论是都定义该选项,主程序总是调用自身定义的函数。

gcc 本章用到的命令行选项说明

这里简单回顾,GCC的常用命令行选项,以便参考之用。

  • -g
    在编译的程序中包含调试信息,以便于GDB调试。
  • -c
    编译或汇编源文件,但是不作连接。
  • -o file
    指定输出文件为file,若没有指定该选项则默认为:
    • 可执行文件为a.out
    • 目标文件是source.o,假设源文件(source.suffix)。
    • 汇编文件是source.s,假设源文件(source.suffix)。
  • -llibrary
    连接名为library的库文件,连接器在标准搜索目录中寻找这个库文件,库文件的真正名字是liblibrary.a
  • -fPIC
    如果支持,编译器将输出与位置无关的目标码,适用于动态连接(dynamic linking)。
  • -shared
    生成一个共享目标文件,它可以和其他目标文件连接产生可执行文件。
  • -static
    在支持动态连接(dynamic linking)的系统上,阻止连接共享库。
  • -Wl,option
    把选项option传递给连接器,如果option中含有逗号,就在逗号处分割成多个选项。