WangYu::Space

Study, think, create, and grow. Teach yourself and teach others.

编译与链接 - 动态链接

分类:编译与链接创建时间:2023-08-24 15:08:44

在[编译/链接/装载 - 链接]({% include link.html uid=20231613 %})中提到了静态链接和动态链接,描述了各自的实现原理和优缺点,但重点放在从宏观层面了解大致原理。关于动态链接,还有许多细节需要说明,本文将展开介绍。

链接是整合多个目标文件的过程,分为静态链接和动态链接。静态链接是将多个目标文件组合成一个可执行文件或可动态加载的动态库的过程。动态链接在程序运行时加载动态链接库并执行链接。静态链接在程序运行之前进行,动态链接在程序启动后执行。

动态链接的优缺点

相比于静态链接,动态链接有如下优点:

  1. 基于虚拟存储器内存页共享的机制,可以将动态库映射到不同进程的地址空间中,如此就可以实现基础库的共享,可有效节省内存
  2. 动态库中的指令、数据、符号表等内容无需包含到可执行程序中,可执行程序的文件大小更小
  3. 只要动态库对外暴露的接口未变动,升级动态库后,使用该动态库的其他程序无需升级

动态库也有明显的缺点:

  1. 整个程序被拆分为多个部分,维护成本比较大。稍有不慎,程序可能出现找不到动态库的错误。
  2. 可能碰到到版本冲突的问题,比如动态库搜索路径上存在相同动态库的不同版本。使用静态链接时,所有依赖都包含在可执行文件中,简单方便,不易出错。

动态链接库的构造

下使用如下代码演示动态链接库的构建过程:

// log.c
#include <stdio.h>

void info(const char *message) {
    printf("[INFO] %s\n", message);
}

void warn(const char *message) {
    printf("[WARN] %s\n", message);
}

void error(const char *message) {
    printf("[ERR] %s\n", message);
}

这里我希望把 log.c 构建为动态链接库,使用的命令如下:

# 编译为目标文件
$ gcc -c log.c -fPIC -o log.o

# 构建动态链接库
$ gcc -shared log.o -o liblog.so

使用以上命令即可构造出一个名为 liblog.so 的动态链接库。

下面的代码使用了此动态库中的函数:

// main.c
#include <stdio.h>
#include "log.h"

int main() {
    info("hello C");
    warn("hello C++");
    error("hello Java");
    printf("hello world\n");
    return 0;
}

可以使用如下代码构造动态加载 liblog.so 的可执行文件:

# 编译为目标文件
$ gcc -c main.c -o main.o

# 链接动态链接库
$ gcc main.o liblog.so -o main

使用如下命令运行可执行文件:

$ LD_LIBRARY_PATH=. ./main

上面命令中设置了环境变量 LD_LIBRARY_PATH,这是为了指定动态库的搜索路径。

动态库的加载流程

对于静态链接的程序,程序启动后,操作系统调用装载器将可执行文件中必要的段载入内存,然后跳转到程序入口开始执行,整个过程比较容易理解。

如果一个程序依赖动态库,程序启动后就会开启动态链接的过程。操作系统的装载器会读取 ELF 文件中 .interp 节的内容,这里面是一个用于加载动态库的辅助程序的地址。

这里以 hello world 程序为例,使用如下命令编译生成可执行文件 hello:

$ gcc main.c -o main

查看 .interp 中的内容:

$ readelf -p .interp main

String dump of section '.interp':
  [     0]  /opt/gcc/lib64/ld-linux-x86-64.so.2

这个 ld-linux-x86-64.so.2 可以视为链接器用来做动态加载工作的子程序,它会读取 ELF 文件中的 .dynamic 节,从中获取动态加载的细节,然后把动态库载入到内存中。

.dynamic 中存储的是一组 Elf64_Dyn 结构,它们告知链接器如何执行动态加载,Elf64_Dyn 的定义如下:

typedef struct {
    Elf64_Sxword    d_tag;
    union {
        Elf64_Xword d_val;
        Elf64_Addr  d_ptr;
    } d_un;
} Elf64_Dyn;

Elf64_Dyn 用于描述动态链接某方面的信息,其中 d_tag 为类型,d_un 的内容如何解读则依据 d_tag 而定。

可使用 readelf -d 来查看 .dynamic 的内容:

$ readelf --dynamic main

Dynamic section at offset 0x2e58 contains 21 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000c (INIT)               0x401000
 0x000000000000000d (FINI)               0x401264
 0x0000000000000004 (HASH)               0x400300
 0x000000006ffffef5 (GNU_HASH)           0x400328
 0x0000000000000005 (STRTAB)             0x4003a8
 0x0000000000000006 (SYMTAB)             0x400348
 0x000000000000000a (STRSZ)              61 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000015 (DEBUG)              0x0
 0x0000000000000003 (PLTGOT)             0x404000
 0x0000000000000002 (PLTRELSZ)           48 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x400428
 0x0000000000000007 (RELA)               0x400410
 0x0000000000000008 (RELASZ)             24 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000006ffffffe (VERNEED)            0x4003f0
 0x000000006fffffff (VERNEEDNUM)         1
 0x000000006ffffff0 (VERSYM)             0x4003e6
 0x0000000000000000 (NULL)               0x0

d_tag 的常见取值如下:

上面例子中第一行类型 NEEDED,值为 libc.so.6,这表示需要动态加载 libc.so.6 这个动态库。

一个依赖动态库的可执行程序被执行后,会发生下面这些动作:

  1. 装载器将动态链接器载入到内存中,并让动态链接器接手程序。
  2. 动态链接器读取可执行文件的各个段,将类型为 PT_LOAD 的段载入内存。
  3. 遍历 .dynamic 中的内容,收集当前可执行程序的依赖,并在磁盘上搜索对应的动态库,并将依赖的动态库载入内存。访问被载入的动态库的 .dynamic 节,递归地载入依赖。
  4. 执行重定位。因为动态库在内存中的地址是不确定的,运行时候必须要有绝对地址,因此需要执行重定位。关于动态库的重定位,后面会详细描述。
  5. 重定位完成后,会调用动态库的初始化函数。
  6. 最终将控制权返回给可执行文件。

以上过程可以使用下图来描述:

引自:Executable and Linkable Format 101 Part 4: Dynamic Linking

  1. 程序执行后,首先将 ld-linux.so.2 载入内存,该程序用于完成后续的加载工作。
  2. ld-linux.so.2 载入可执行程序,并把 main 所依赖的标准库 libc.so 载入进来。
  3. 载入 libc.so 后,需要对 libc.so 执行重定位。
  4. libc.so 的重定位完成后,调用初始化函数。
  5. 跳转到可执行程序的入口处,程序开始运行。

以上就是动态库的加载流程,这里 .dynamic 中的信息至关重要。关于重定位的细节,后文会详细描述。

动态链接的符号重定位

静态链接中,目标文件中对函数和变量的引用会在链接时候做重定位。在动态链接中,动态库在程序启动后由装载器载入。动态库加载到内存中以后,其中的函数和数据的位置就固定了,此时可以开始执行重定位。那么重定位如何进行呢?

一种很自然的想法是在载入动态库后执行类似静态链接时的重定位操作。程序启动后,载入动态库,而后修改动态库的 .text 部分,将指令中访问的地址修改为真实的地址,另外对当前进程的 .text 中引用动态库中变量与函数的地址做修改,就像静态链接时候那样。但这种思路会引发几个问题:

  1. 启动时做大量的重定位工作,这会导致程序启动速度变慢。
  2. 这种重定位策略会导致动态库本身的 .text 被修改,这就无法实现动态库的代码共享。
  3. 当前进程的代码段一般会被操作系统设置为只读,动态修改其中的内容难以实现,且不安全。

为了解决以上问题,我们需要找到一种方法,在链接时不需要对动态库 .text 中内容做任何修改,位置无关代码就是其中一种方法。

位置无关代码

位置无关代码(Position Independent Code, PIC) 是一种可以让动态库中的代码不依赖于动态库加载位置的策略。位置无关代码能够工作存在一个前提,无论动态库被加载到内存中的任何地方,动态库的数据段和代码段之间的相对位置是固定的。

有了这个前提后,可以在数据段中创建一个表格,这个表格中用于记录外部函数的地址。当动态库加载完成后,可以修改数据段中的这个表格,将其中填入函数的真实地址。要访问一个函数或变量,首先在表格中找到其指针,然后通过指针找到实际的函数或变量。因为 .text 和 .data 之间的位置固定,在 .text 中指令可以使用相对寻址得到对应表格单元的地址,如此就实现了地址无关代码。

下图更具体地说明了上述思路:

在 main 程序内部,维护了一个表格,这个表格中存放的是 infowarnerrorprintf 这几个函数的地址。同样地在 liblog.so 也有一个表格,用于存放 printf 的地址。

程序启动后,首先载入动态库 liblog.so,因为 liblog.so 依赖于 libc.so,于是再载入 libc.so。载入 libc.so 后,就能得到 printf 函数的地址,链接器将其地址填入到 liblog.so 的表格中。而后将 infowarnerrorprintf 这四个函数的地址填入到 main 的表格中。

简单来说,通过引入一个中间层,使用类似 C 语言中指针的指针的思路,运行时在 .data 中簿记函数或变量地址,并利用 .text 和 .data 之间相对位置固定这一前提,在 .text 中使用相对寻址实现 .text 中的指令与自身位置无关,并避免了在链接阶段修改 .text 中的指令内容。

位置无关代码的思想大致如此,关于其实现细节,我会在后续文章中详细介绍。

预加载库

预加载库有最高的搜索优先级,如果配置了预加载库,装载器会首先加载这些库。配置预加载库的方式有两种:

1、设置 LD_PRELOAD 环境变量

export LD_PRELOAD=/home/wy/libs/liblog.so:$LD_PRELOAD

2、通过 /etc/ld.so.preload 文件

该文件中包含多个动态库,文件使用空格分隔,这些库文件会在程序启动前加载。

使用预加载库,我们可以实现对其他库函数的替换,假如你的程序使用了一个动态库中的 A 函数,你可以在另外一个动态库中实现 A 函数,通过预先加载使用此动态库中的 A 函数替换原 A 函数,下面是一个例子:

// main.c
#include <stdio.h>

int main(int argc, char **argv) {
    puts("hello world");
    return 0;
}
// puts.c
#include <unistd.h>
#include <string.h>

int puts(const char *s) {
    int n = write(STDOUT_FILENO, "ABCD\n", 5);
    return n;
}
$ gcc main.c -o main
$ gcc puts.c -shared -fPIC -o libputs.so
$ LD_PRELOAD=./libputs.so ./main
ABCD

其实 LD_PRELOAD 的实现原理很简单,就是将 LD_PRELOAD 指定的动态库添加到可执行文件所依赖的动态库的首位。

ldd 命令可以用于查看可执行文件或者动态库所依赖的动态库信息。这里使用 ldd 命令查看前面的 main 程序依赖的动态库:

$ ldd ./main
        linux-vdso.so.1 (0x00007fff4ae79000)
        libc.so.6 => /opt/compiler/gcc-8.2/lib/libc.so.6 (0x00007f007cb79000)
        /opt/gcc/lib64/ld-linux-x86-64.so.2 (0x00007f007cd1e000)

设置了 LD_PRELOAD 环境变量后,再查询 main 的依赖:

$ LD_PRELOAD=./puts.so ldd ./main
        linux-vdso.so.1 (0x00007ffe3c1f5000)
        ./libputs.so (0x00007ff129b95000)
        libc.so.6 => /opt/compiler/gcc-8.2/lib/libc.so.6 (0x00007ff1299ef000)
        /opt/gcc/lib64/ld-linux-x86-64.so.2 (0x00007ff129b9a000)

你会看到,libputs.so 被放置到了 main 的依赖项中,且放置在靠前的位置。这样在查找 puts 函数时,就会从 libputs.so 中找到,如此就实现了使用 libputs.so 中的 puts 函数替换标准库中 puts 的目的。

动态库搜索路径

为了加载动态库,装载器需要知道动态库的准确路径。因为开发环境与运行环境并不相同,将动态库的路径硬编码到可执行文件中并不合适。一种解决方案是在可执行文件中写入了动态链接库的文件名,程序在运行时能够通过某种方式来查找到动态库文件。目前所有主流操作系统都提供了这种机制,可根据程序运行时提供的库文件名来搜索和查找动态库。

可以使用 ldd 命令来查看一个可执行程序需要加载的动态库:

$ ldd a.out 
        linux-vdso.so.1 =>  (0x00007ffcb97d1000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f95f0bf8000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f95f0fb9000)

如果程序启动时,找不到所需要的动态库,程序就无法启动。下面将描述装载器如何搜索动态库的。

rpath

可执行文件中包含了动态库的名称,如果存在多个同名的动态库,从什么路径开始搜索动态库意义重大。ELF 文件的 .dynamic 节可包含一个 DT_RPATH 字段,用于存储动态库搜索路径。这个路径被称为 run path (rpath)。

可在链接时写入此字段信息:

$ gcc -Wl,-R/home/wy/libs main.c liblog.so -o main

根据惯例,通过 gcc 或 g++ 间接调用链接器时,需要在链接器参数前追加 “-Wl,” 前缀。链接器的参数为 -R,其后跟动态库的搜索路径。

使用 readelf -d 查看可执行文件的 .dynamic 节的信息:

$ readelf -d main

Dynamic section at offset 0x2e38 contains 23 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [liblog.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000f (RPATH)              Library rpath: [/home/wy/libs]
 0x000000000000000c (INIT)               0x401000
 ...

也可以使用 LD_RUN_PATH 环境变量来指定 rpath:

$ export LD_RUN_PATH=/home/wy/libs:$LD_RUN_PATH

LD_LIBRARY_PATH

在开发阶段,开发人员希望能临时设置动态库的搜索路径以验证功能,LD_LIBRARY_PATH 环境变量用于满足这种需求。

如果没有设置 rpath,LD_LIBRARY_PATH 指定的路径则拥有最高优先级,可以使用如下命令设置此环境变量:

$ export LD_LIBRARY_PATH=/home/wy/libs:$LD_LIBRARY_PATH

runpath

如果使用了 rpath,那么 rpath 就会被优先搜索,开发人员就不好利用 LD_LIBRARY_PATH 来临时修改动态库的搜索路径,因为有可能在 rpath 中就已经搜索到了,于是 runpath 被引入,它的优先级低于 LD_LIBRARY_PATH

$ gcc -Wl,-R/home/wy/libs -Wl,--enable-new-dtags main.c liblog.so -o main

使用 readelf -d 可查看 RUNPATH 的信息:

$ readelf -d main

Dynamic section at offset 0x2e38 contains 23 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [liblog.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000001d (RUNPATH)            Library runpath: [/home/wy/libs]

runpath 对 rpath 的屏蔽作用,如果配置了 runpath 则 rpath 会失效。

ld.so.cache

通常我们不应该在链接的时候通过 -R 选项指定动态库的搜索路径,因为这个路径在其他机器上不一定存在。在可执行文件中,应该只存在一个动态库的名字,在运行时,操作系统会在某些固定的目录下查找动态库。

操作系统提供了一个动态库配置文件,在 /etc/ld.so.conf 中配置了多个动态库的路径,在搜索动态库时会去这些路径中查找。

$ cat /etc/ld.so.conf
include ld.so.conf.d/*.conf

$ ls /etc/ld.so.conf.d/
mysql-x86_64.conf

$ cat /etc/ld.so.conf.d/mysql-x86_64.conf 
/usr/lib64/mysql

$ ls /usr/lib64/mysql
libmysqlclient_r.so.16  libmysqlclient_r.so.16.0.0  libmysqlclient.so.16  libmysqlclient.so.16.0.0

为了避免每次都去路径中搜索,在 /etc/ld.so.cache 中维护了一份缓存,这份缓存中记录了所有被搜索到的动态库的路径。装载器实际上不会去遍历 /etc/ld.so.conf 中指定的路径,而是去查找 /etc/ld.so.cache 中的内容。因此当 /etc/ld.so.conf 的内容更新后,需要使用 ldconfig 命令来更新 cache。

在机器上部署了动态库后,可以将动态库搜索路径加入到 /etc/ld.so.conf 中,然后更新 cache,这样就可以通过名字找到动态库了。

默认动态库路径

路径 /lib 和 /usr/lib 是 Linux 操作系统上的两个用于保存动态库的默认路径。对于那些会被所有用户使用的动态库,通常被部署到这两个目录中的一个中。

动态库的搜索路径优先级规则总结如下:

  1. 首先搜索 ELF 文件中 DT_RPATH 指定的路径(如果设置了 DT_RUNPATH,则 DT_RPATH 无效)
  2. 环境变量 LD_LIBRARY_PATH
  3. ELF 文件中 DT_RUNPATH 指定的路径
  4. /etc/ld.so.cache 中的路径(如果链接时指定了 -z nodeflib 参数则跳过)
  5. /lib, /usr/lib(如果链接时指定了 -z nodeflib 参数则跳过)
  6. 未找到,报错

总结

本文介绍了动态链接的基本原理,并演示的动态库的创建与使用方法。结合实例介绍了动态库的加载流程,并对位置无关代码的基本原理做了介绍。使用动态库时,常常遇到动态库无法找到的问题,本文最后介绍了动态库的搜索规则。动态库涉及到的内容较多,对于动态库的多版本、位置无关代码实现细节等内容本文尚未详细说明,这部分内容会再单独写文章介绍。

评论 (评论内容仅博主可见,不会公开显示)