深入理解动态链接

动态链接库又叫共享库(Shared Library),相信大部分做软件开发的人都很熟悉。简单地说,库是对一系列程序的封装,静态库是会在链接时与可执行程序合并的库,而动态库则在链接后仍然与可执行文件分离,直到运行时才动态加载。显然,动态库可以共享给多个可执行程序同时使用,更节约硬盘和内存空间。

不管是Windows开发者,还是Linux开发者,或者是Android、iOS开发者,我们无时无刻都在生产或者使用动态库,而且很少遇到困难。这得益于一套完整的动态链接机制,该机制保证链接和运行时,能够准确找到正确的动态库。本文就来探讨动态链接的内部机制。

一个简单的案例

看看下面这个简单到不能再简单的例子。有一个main.cpp文件,用来生成可执行程序。

// main.cpp
#include "random.h"

int main() {
    return get_random_number();
}

该程序依赖于一个random库,库的源码如下:

// random.h
int get_random_number();
// random.cpp
#include "random.h"

int get_random_number(void) {
    return 4;
}

现在,我们用clang++编译器编译这个程序。(clang++与g++类似,但更适合于开发,可以sudo apt install clang安装。)

先编译random这个动态链接库:

$ clang++ -o random.o -c random.cpp

其中,-o指定输出文件的名称,我们把源文件random.cpp编译成目标文件random.o-c表示只编译、不链接。然后再把目标文件编译成动态库:

$ clang++ -shared -o librandom.so random.o

其中,-shared表示生成动态链接库而不是静态库,-o指定输出文件名为librandom.so。注意该名称不是随便定的,而是遵守了动态链接的惯例——所有库的命名形式都为lib<name>.so

接下来,我们编译可执行程序,首先生成目标文件main.o

$ clang++ -o main.o -c main.cpp

然后生成可执行文件main

$ clang++ -o main main.o
main.o:在函数‘main’中:
main.cpp:(.text+0x10):对‘get_random_number()’未定义的引用
clang: error: linker command failed with exit code 1 (use -v to see invocation)

不出意外地出错了,因为我们没有链接到random库,所以出现“未定义的引用”。

Tips:这里给个小提示,在开发C++程序的时候,只要看到错误信息“未定义的引用”,一定是某个库忘记链接了,如果用的CMake,很有可能是target_link_libraries里面少写了某个依赖项,或者即便写了,但是拼写有误,像是把${PROTOBUF_LIBRARIES}写成${Protobuf_LIBRARIES}甚至是写成${PROTOBUF_LIBS}而出错的情况可是层出不穷。

这次我们指定需要链接的库:

$ clang++ -o main main.o -lrandom
/usr/bin/ld: 找不到 -lrandom
clang: error: linker command failed with exit code 1 (use -v to see invocation)

又出错了。倒也不难想象,虽然我们指定了库的名称,但并没有指定库的路径,链接器/usr/bin/ld不知道去哪里找random这个库。所以我们得指定搜索路径。

$ clang++ -o main main.o -lrandom -L.

其中-L后面紧跟着路径.,表示在当前目录下查找库。现在,终于编译成功了,让我们运行main这个程序:

$ ./main
./main: error while loading shared libraries: librandom.so: cannot open shared object file: No such file or directory

好了,这又是一个常见的错误。当我们满心欢喜准备见证运行结果的时候,它却告诉我们程序根本不能运行。这是因为动态链接的可执行程序在运行前,需要先加载所需的动态链接库。虽然我们刚刚用-L.指定了链接路径,但该路径只对编译期生效。为了弄清楚运行时动态链接的方式,我们需要更加深入。

ELF文件格式

简单来说,目前Linux系统上的大部分的可执行文件和库文件都是ELF格式(Executable Linkable Format)。与此对应,Windows系统上的可执行文件和库文件是PE格式(Portable Executable)。这些格式定义了可执行文件的二进制结构。通常我们会认为可执行文件里面包含的无非是二进制代码和数据,但其实还包含了其它信息,比如架构信息、大小端模式、调试信息、动态链接信息等等。这里我们只关注ELF中与动态链接相关的信息,执行以下代码:

$ readelf -d main
Dynamic section at offset 0xde8 contains 28 entries:
  标记        类型                         名称/值
 0x0000000000000001 (NEEDED)             共享库:[librandom.so]
 0x0000000000000001 (NEEDED)             共享库:[libstdc++.so.6]
 0x0000000000000001 (NEEDED)             共享库:[libm.so.6]
 0x0000000000000001 (NEEDED)             共享库:[libgcc_s.so.1]
 0x0000000000000001 (NEEDED)             共享库:[libc.so.6]
 0x000000000000000c (INIT)               0x400568
 0x000000000000000d (FINI)               0x400764
 0x0000000000000019 (INIT_ARRAY)         0x600dd0
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x600dd8
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x400298
 0x0000000000000005 (STRTAB)             0x4003f0
 0x0000000000000006 (SYMTAB)             0x4002d0
 0x000000000000000a (STRSZ)              241 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000015 (DEBUG)              0x0
 0x0000000000000003 (PLTGOT)             0x601000
 0x0000000000000002 (PLTRELSZ)           48 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x400538
 0x0000000000000007 (RELA)               0x400520
 0x0000000000000008 (RELASZ)             24 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000006ffffffe (VERNEED)            0x400500
 0x000000006fffffff (VERNEEDNUM)         1
 0x000000006ffffff0 (VERSYM)             0x4004e2
 0x0000000000000000 (NULL)               0x0

readelf是一个查看ELF格式文件信息的工具,-d表示查看动态链接信息。其中,前几行列出了main依赖的动态链接库,总共有5个。除了我们编译时手动指定的librandom之外,还有libstdc++标准C++库、libm基础数学库、libgcc_sGCC运行时库、libc系统调用库,这些库是编译器自动为每个可执行程序添加的。

从上面的结果可以看出,可执行程序会记录每一个它所需要的动态库的名称,但似乎没有记录这些动态库的路径,至少在这个例子中没有。不过,当我们调用./main时,除了random库,其它4个库显然是可以链接成功的。我们如何才能知道运行时实际的链接过程呢?

好在另一个工具可以预览运行时的链接信息:

$ ldd main
    linux-vdso.so.1 =>  (0x00007ffcdde26000)
    librandom.so => not found
    libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f74aaf37000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f74aac2e000)
    libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f74aaa18000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f74aa64e000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f74ab2b9000)

果然,除了random,其它库都链接到了正确的位置。可ldd是如何找到这些路径的呢?这就依赖于动态链接机制中的一项规定,链接器会按照如下的顺序在指定目录中查找所需的动态链接库:

  1. ELF的rpath中规定的路径。
  2. LD_LIBRARY_PATH环境变量中的路径。
  3. ELF的runpath中规定的路径。
  4. /etc/ld.so.conf文件中列出的路径。该文件可包含子文件,因此也包括子文件中列出的路径。
  5. 默认的系统路径/lib/usr/lib

链接器找不到random库,说明它并不在以上这些路径中。最简单的做法,我们可以把random的路径添加到LD_LIBRARY_PATH环境变量中。

$ LD_LIBRARY_PATH=.
$ ./main

终于,我们的main正常执行了。但这种解决方式还不够优雅,让我们回想一下平常使用的程序是怎么运行的。以OpenCV为例,通常有两种安装方式,通过apt install安装或编译安装。如果是apt install安装,会自动把库安装到系统目录/usr/lib下,这种情况链接器可以直接找到它们。如果编译安装,安装路径就由用户自己指定了,默认会安装到/usr/local,当然也可以安装到其它任何位置。这种情况,依赖于OpenCV的程序如何能够找到这些库呢?我通常用CMake编译程序,在CMakeLists.txt中可以指定依赖库的路径,并用find_package找到这些库。如此编译得到的可执行程序是可以直接运行的,我们对照上面的动态链接库查找路径,显然不是在2、4、5中找到的,只能是在1或2中找到的。现在我们就来看看rpathrunpath到底是什么。

rpath和runpath

与其它搜索路径不同,rpathrunpath是直接保存在ELF文件中的,可以在编译可执行程序时设置该路径。现在,我们重新编译main,把rpath加进去。

$ clang++ -o main main.o -lrandom -L. -Wl,-rpath,.

其中,-Wl后面跟着逗号分隔的链接器参数-rpath.,意思是告诉链接器参数-rpath的值是.,也就是当前路径。现在,不必修改环境变量,直接调用./main也可以正常运行了。如果我们再查看一下ELF中的详细信息:

$ readelf -d main
Dynamic section at offset 0xdd8 contains 29 entries:
  标记        类型                         名称/值
 0x0000000000000001 (NEEDED)             共享库:[librandom.so]
 0x0000000000000001 (NEEDED)             共享库:[libstdc++.so.6]
 0x0000000000000001 (NEEDED)             共享库:[libm.so.6]
 0x0000000000000001 (NEEDED)             共享库:[libgcc_s.so.1]
 0x0000000000000001 (NEEDED)             共享库:[libc.so.6]
 0x000000000000000f (RPATH)              Library rpath: [.]
 ...

可以发现多了一项RPATH,正是我们刚刚设置的值。rpathrunpath的区别仅仅是优先级不同,runpath中的路径可以被外部的环境变量LD_LIBRARY_PATH覆盖,而rpath则不会。

现实场景

文中的例子很易于理解,但毕竟有些不切实际,没人会手动编译源文件,也不会手动设置rpath。对于Linux上的开发者而言,CMake可以说是最常用的构建工具。当我们写好CMakeLists.txt后,CMake会帮我们设置好链接库的名称,以及rpath等搜索路径。此外,CMake提供了一些命令用来手动设置rpath,但我们一般都不需要用,这里就不提了。值得一提的是,CMake会根据构建类型来决定是否设置rpath,在build时,会添加库路径到rpath,而在install时,则会把rpath设置为空。这是因为,install之后,认为可执行文件是可移植的,不必依赖于编译时链接的特定的库,库的搜索路径完全由所在系统的默认库路径和环境变量决定。

到这里,本文可以告一段落了。我们了解了动态链接的原理和方式,虽然并不十分深入,但至少懂得了编译器和链接器是如何工作的,了解了常见的链接错误出现的原因及其解决方案。

需要特别提出的是,本文并非原创,而是参考了一篇博客Shared Libraries: Understanding Dynamic Loading的内容,并做了适当的简化。原文中有更详细的内容,建议感兴趣的同学去读一读。在此对原作者Amir Rachum表示感谢。

参考资料

Shared Libraries: Understanding Dynamic Loading Amir Rachum
Executable and Linkable Format Wikipedia
深入理解程序构造(一) 卡巴拉的树
Rpath handling CMake Community Wiki

推荐阅读更多精彩内容

  • 一、温故而知新 1. 内存不够怎么办 内存简单分配策略的问题地址空间不隔离内存使用效率低程序运行的地址不确定 关于...
    SeanCST阅读 5,835评论 0 27
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 118,208评论 14 132
  • 生活是个太过沉重的话题,说不清,道不明,但依然要拖着身体前行。也许你读出了消极的气息,但生活就是不断地挑战自己的心...
    心存善念王李军阅读 403评论 4 18
  • 皱纸抚未平,笔沁旧书音。 三言两句半,字字尽诛心。 前有孟氏女,后有卓文君。 如是秦河柳,西湖小妹坟。 宋词本十色...
    寒菊阅读 72评论 0 1
  • 落花不会有芳香,流光不会有再现;韶华不会有重归,你我不会有重返。 ——题记 曾经,任何人都拥有曾经,区别的是,每个...
    熊大的光头强阅读 120评论 0 0