动态库与静态库是编程中十分常见的玩意儿,但是如此常见的东西在我真正用心去了解梳理过一遍之后才发现原来这里面有这么多的门道。本文就介绍一波 Linux 平台下,特指 GCC 编译器生成的动态库与静态库的依赖于关联,甚至还拓展一波编译工具的冷门用法。虽然有一些内容看的时候觉得都知道,但是我保证,一定会有那么一些不知道的,所谓硬核。

库的目的

从表面上来讲,粗略点来讲,库的用处是为了方便代码复用以及发布,都知道哈,但是我觉得还是要深入说下这个玩意儿。

库分动态库与静态库(你又知道了),其中动态库是用于执行时加载并映射的,如果有众多的可执行程序用到了很多的公共部分的代码,那么就可以把这一部分代码作为动态库给独立出来,这样虽然增加了一些运行速度,但是对减少内存占用有很大的帮助。并且库可以解决版本升级的问题,我们事先约定好 API 接口,然后在自己的程序里面调用这些接口,等到需要升级功能或者解 bug 的时候只需要把动态库更新一下即可,完全不需要动到业务逻辑部分的代码,Glibc 就是如此。

总结下来动态库大的目的就两个:

  1. 节省体积,增加代码的复用率。
  2. 便于维护、升级、版本管理。

动态库的特点:

  1. 运行时装载,提供运行时的高复用。
  2. 不占用过多可执行文件的本身体积,并且升级之后可执行文件无需重新编译链接,即生即用。

静态库就不会是运行时加载了,也不存在什么多次复用这样子的功能,它就是一个高可复用的代码包的集合,但是它只提供代码编写、编译时候的复用性,而不提供程序装载、运行时候的复用性。静态库的特点是编译时直接拷贝符号表到可执行文件里面,这也意味着可执行文件的大小会大很多,并且运行时的可复用性被打破。不过它有一个优点就是可执行文件在运行时无需加载额外的库,我们在不同物理机上转移可执行文件的时候只需要拷贝少量文件即可。

静态库的目的:

  1. 提供编码、编译时候的代码可复用性。
  2. 减少可执行文件的依赖,拷贝复制时需要的文件少。

静态库的特点:

  1. 生成的可执行文件体积大,因为包含了真正的代码段,破坏了运行时的复用性,但无需依赖额外的库。
  2. 目标文件的集合,本身不提供其它目标文件之外的额外信息。
  3. 升级时可执行文件需要重新编译,升级过程相较于动态库依赖更加简单。

库的分解

静态库完全是由一个个的 .o 目标文件组成,不包含什么动态库里面的符号表链接信息等等,而动态库里面的东西显然比静态库的更加复杂,因为动态库加载是需要大小、符号表位置、符号依赖项等等信息的,我这里选取一个自建的动静态库举例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
X@ubuntu:~/workstation/apps/compiles/Libs$ readelf -d libdylib1.so
 
Dynamic section at offset 0xf00 contains 24 entries:
Tag Type Name/Value
0x00000001 (NEEDED) Shared library: [libc.so.6]
0x0000000c (INIT) 0x3c4
0x0000000d (FINI) 0x5b0
0x00000019 (INIT_ARRAY) 0x1ef4
0x0000001b (INIT_ARRAYSZ) 4 (bytes)
0x0000001a (FINI_ARRAY) 0x1ef8
0x0000001c (FINI_ARRAYSZ) 4 (bytes)
0x6ffffef5 (GNU_HASH) 0x138
0x00000005 (STRTAB) 0x258
0x00000006 (SYMTAB) 0x178
0x0000000a (STRSZ) 198 (bytes)
0x0000000b (SYMENT) 16 (bytes)
0x00000003 (PLTGOT) 0x1ff4
0x00000002 (PLTRELSZ) 24 (bytes)
0x00000014 (PLTREL) REL
0x00000017 (JMPREL) 0x3ac
0x00000011 (REL) 0x36c
0x00000012 (RELSZ) 64 (bytes)
0x00000013 (RELENT) 8 (bytes)
0x6ffffffe (VERNEED) 0x33c
0x6fffffff (VERNEEDNUM) 1
0x6ffffff0 (VERSYM) 0x31e
0x6ffffffa (RELCOUNT) 3
0x00000000 (NULL) 0x0
X@ubuntu:~/workstation/apps/compiles/Libs$ nm --size-sort -r libdylib1.so
00000029 T dy1_print
00000029 T dy1_clean
00000001 b completed.6874

我的动态库里面只有两个函数:dy1_printdy1_clean。我使用的编译链接选项是:-fPIC --shared。使用 nm 命令可以看到里面有两个全局代码段,分别是 dy1_printdy1_clean,使用 readelf 可以看到该动态库有依赖 libc.so.6 这个动态库。

下面是一个静态库的相关信息:

1
2
3
4
5
6
7
8
9
10
X@ubuntu:~/workstation/apps/compiles/Libs$ readelf -d libstlib1.a
 
File: libstlib1.a(stlib1.o)
X@ubuntu:~/workstation/apps/compiles/Libs$ nm --size-sort -r libstlib1.a
 
stlib1.o:
00000029 T st1_print
00000029 T st1_clean
X@ubuntu:~/workstation/apps/compiles/Libs$ ar -t libstlib1.a
stlib1.o

 

可以看到使用 readelf 完全看不出来静态库的依赖什么的,只有一个 File,它代表该静态库是由 stlib1.o 这个目标文件组成。nm 可以看到符号表有 st1_printst1_clean。从这里也可以看出,静态库单纯就是把目标文件打包在一块,避免每次链接时候需要写一大堆文件文件的尴尬,除此之外,它与单纯的多个 .o 目标文件组合链接生成可执行文件没有任何区别。

库与库之间的依赖

库与库之间的依赖可是非常大的一个知识点儿,与其说是知识点,不如说是坑点,不知道其它公司项目的库与库之间的依赖关系是如何,就我自己接触到的稍微大点的项目,库与库之间的依赖关系简直是一团乱麻,稍有不慎编译的时候就万劫不复。

动态库依赖动态库

我在我的本地环境构造了几个动态库,其中动态库1(libdylib1.so)里面包含 dy1_printdy1_clean 两个符号,动态库2(libdylib2.so)里面包含 dy2_printdy2_clean 两个符号,其中 dy2_clean 调用到了动态库1里面的 dy1_clean。下面我按照这样的方法生成动态库与可执行文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
gcc -o libdylib1.so -fPIC --shared dylib1.c
gcc -o libdylib2.so -fPIC --shared dylib2.c
X@ubuntu:~/workstation/apps/compiles/Libs$ cat main.c
#include <stdio.h>
int main(int argc, char *argv[])
{
dy2_clean();
return 0;
}
X@ubuntu:~/workstation/apps/compiles/Libs$ gcc -o main main.c -fPIC -L. -ldylib2
./libdylib2.so: undefined reference to `dy1_clean'
collect2: error: ld returned 1 exit status
X@ubuntu:~/workstation/apps/compiles/Libs$ gcc -o main main.c -fPIC -L. -ldylib1 -ldylib2
./libdylib2.so: undefined reference to `dy1_clean'
collect2: error: ld returned 1 exit status
X@ubuntu:~/workstation/apps/compiles/Libs$ gcc -o main main.c -L. -ldylib2 -ldylib1
X@ubuntu:~/workstation/apps/compiles/Libs$ export LD_LIBRARY_PATH=~/workstation/apps/compiles/Libs
X@ubuntu:~/workstation/apps/compiles/Libs$ ./main
This is dylib2's clean funciton.
This is dylib1's clean funciton.

可以看到只有最后一种编译链接方式可以成功编译、生成、运行程序,可以看到如果动态库之间有相互依赖的话在最终链接可执行文件的时候需要把被依赖项放到后面,否则就会提示找不到某某符号,这里也可以看出其依赖解析关系是从前往后的,前面的动态库会往后面找依赖项。

采用这种方式编译出来的库与可执行文件使用 readelf 查看得到依赖关系如下(隐藏部分不关注信息):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
X@ubuntu:~/workstation/apps/compiles/Libs$ readelf -d libdylib1.so
 
Dynamic section at offset 0xf00 contains 24 entries:
Tag Type Name/Value
0x00000001 (NEEDED) Shared library: [libc.so.6]
 
X@ubuntu:~/workstation/apps/compiles/Libs$ readelf -d libdylib2.so
 
Dynamic section at offset 0xf00 contains 24 entries:
Tag Type Name/Value
0x00000001 (NEEDED) Shared library: [libc.so.6]
 
X@ubuntu:~/workstation/apps/compiles/Libs$ readelf -d main
 
Dynamic section at offset 0xef8 contains 26 entries:
Tag Type Name/Value
0x00000001 (NEEDED) Shared library: [libdylib2.so]
0x00000001 (NEEDED) Shared library: [libdylib1.so]
0x00000001 (NEEDED) Shared library: [libc.so.6]
 
X@ubuntu:~/workstation/apps/compiles/Libs$ readelf -s libdylib2.so
 
Symbol table '.dynsym' contains 15 entries:
7: 00000000 0 NOTYPE GLOBAL DEFAULT UND dy1_clean

 

可以看到在库层级使用这种方式是看不出来真实的依赖关系的,也就是 libdylib2.so->libdylib1.so 这一层依赖,从这里也可以猜到在动态库生成的时候是没有链接这一步骤的,否则肯定会出现错误提示,并且使用 readelf -s 可以看到动态库2里面的 dy1_clean 属于未定义符号,总结如下:

  1. 动态库生成的时候没有链接动作,并且默认允许未定义符号的存在。
  2. 动态库的依赖关系是从前往后解析的,被依赖者需要放在使用者的前面。
  3. 可执行文件需要解析最终的真实依赖关系,因此必须把所有的动态库全部链接进来。

上面的方式有某些弊端,那就是如果我是直接拿到 [libdylib2.so],[libdylib1.so] 两个成品动态库的话,不加以分析我是不知道它们两个之间的依赖关系的,这种情况下如果我没有更加详细的文档的话我是不知道如何去链接这些动态库的,这个时候可以采用下面的方式进行动态库的生成以及可执行文件的链接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
X@ubuntu:~/workstation/apps/compiles/Libs$ gcc -o libdylib2.so -fPIC --shared -Wl,--no-undefined dylib2.c
/tmp/ccrLpPaL.o: In function `dy2_clean':
dylib2.c:(.text+0x4e): undefined reference to `dy1_clean'
collect2: error: ld returned 1 exit status
X@ubuntu:~/workstation/apps/compiles/Libs$ gcc -o libdylib2.so -fPIC --shared -Wl,--no-undefined dylib2.c -L. -ldylib1
X@ubuntu:~/workstation/apps/compiles/Libs$ gcc -o main main.c -L. -ldylib2
yellow@ubuntu:~/workstation/apps/compiles/Libs$ ./main
This is dylib2's clean funciton.
This is dylib1's clean funciton.
X@ubuntu:~/workstation/apps/compiles/Libs$ readelf -d libdylib2.so
 
Dynamic section at offset 0xef8 contains 25 entries:
Tag Type Name/Value
0x00000001 (NEEDED) Shared library: [libdylib1.so]
0x00000001 (NEEDED) Shared library: [libc.so.6]
 
X@ubuntu:~/workstation/apps/compiles/Libs$ readelf -d main
 
Dynamic section at offset 0xf00 contains 25 entries:
Tag Type Name/Value
0x00000001 (NEEDED) Shared library: [libdylib2.so]
0x00000001 (NEEDED) Shared library: [libc.so.6]

 

秘诀在于 -Wl,--no-undefined 这个链接选项,它要求在生成动态库的时候不能有未定义的符号,这个选项可以帮助我们检查动态库之间的依赖关系,最终使用 -l 链接生成动态库之后可以看到器依赖项里面包含了 libdylib1.so,这个时候在生成可执行文件的时候就无需再指定链接多个动态库了,只需要指定链接一个 libdylib2.so 即可,剩下的链接工作编译器自己会去做的。

为什么编译的时候不默认使用 -Wl,--no-undefined 选项呢,这个在后面的库于库之间相互依赖这节会说明。

动态库依赖静态库

动态库依赖静态库是十分不推荐的,因为这违背了动态库的初心,这里就不再去重复做试验了,直接说结果,如果动态库里面有调用到某个静态库里面的函数,并且在生成动态库的时候没有去做 -Wl,--no-undefined 的限定动作,那么在生成可执行文件的时候无论如何都是无法成功生成的。

况且,思考一下,这种依赖关系本身就是畸形的,动态库依赖动态库还是可以理解的,它们都是动态库,种类相同,个性相似,功效雷同。但是动态库一旦依赖了静态库,这就变态了,因为它们的目的不同,动态库本质上是为了运行时动态加载,而静态库则是完全拷贝代码段,这两者的属性是相互排斥的,一旦出现这种依赖关系,动态库还怎么动态加载!!!如果允许这种行为的话,动态库就失去了动态库的意义,从设计哲学上来讲就是不应该允许的。

如果无法避免出现这种情况,那就加上 -Wl,--no-undefined 这个链接选项,这样静态库里面的被依赖代码段就会被拷贝到动态库里面成为动态库的一部分,虽然体积会变大,但是完全不影响它的功能与初心。

静态库依赖静态库

关注静态库的时候,就要把它一眼看透,不要关注它的表面,而要关注它的内在,外在衣物不重要,重要的是里面的东西,那才是本质。那么静态库的本质就是一个个的 .o 目标文件的集合,既然是一个集合,就完全可以按照我们常规理解的 .o 文件之间的相互依赖来进行解析。

静态库依赖静态库就需要按照库的解析顺序,上文说过,需要把被依赖者放在使用者的后面,仅此而已。静态库的生成使用类似 ar -cr libstlibname.a x.o xx.o xxx.o 的命令来生成,它是完全没有链接的过程的,也就是不会有上面的 -Wl,--no-undefined 链接选项可用,在最终生成可执行文件的时候必须得人为解析、指定依赖关系,并且遵循一定的依赖先后顺序。

静态库依赖动态库

静态库依赖动态库与普通目标文件的依赖没有二致,也是需要在链接的时候把相关的动态库放在这个静态库的后面,这样就能完成正确的链接过程。

那么为什么静态库就可以依赖动态库而又不至于变态呢,因为静态库的本质是目标文件的合集,你可以完全把它当做是 main.c 文件的一部分,把左右的目标文件看作一个整体,这样就可以想象得到为什么静态库可以依赖动态库了,本质上它与 main.c 文件依赖动态库是一样的性质。

循环依赖

循环依赖就是库 A 依赖库 B,同时库 B 又依赖库 A,完全就是鸡生蛋、蛋生鸡,虽然在实际的开发过程当中不推荐这种依赖关系,但是有时候又会不可避免的出现这种依赖关系,本质上也很难说到底是不是设计缺陷,但是事实是这种情况是会发生的。

有循环依赖的时候上面有几个点就失效了:

  1. 不能使用 -Wl,--no-undefined 来生成动态库,因为相互依赖问题无法在生成动态库的时候解决。
  2. 生成可执行文件的时候依赖顺序规则失效,不管两个库谁在先谁在后都无法完成链接过程。

我构造两个动态库,libdylib1.so 里面调用 libdylib2.so 里面的函数,libdylib2.so 调用 libdylib1.so 里面的函数,形成相濡以沫的关系,接下来我会在 main.c 里面调用两者里面的一个函数。这样就形成了相互依赖的关系。

相互依赖的关系可以在链接的时候写两遍库来解决,比如:

1
gcc -o main main.c -L. -ldylib1 -ldylib2 -ldylib1 -ldylib2

 

也可以使用:

1
gcc -o main main.c -L. -Wl,--start-group -ldylib1 -ldylib2 -Wl,--end-group

 

不过我发现在一些高版本的编译器中,比如我使用的 gcc-4.9 里面就不用加这些额外的选项,貌似它会自行去解决这些循环依赖关系的。

库的加载

静态库全部都是运行前加载的,在链接时候就全部导入到可执行文件里面的,这个就不说了。动态库有两种加载方式,一个是运行前加载,一个是运行时加载。

  1. 运行前加载
    运行前加载意思就是在程序被执行的时候,在 main 函数之前程序会先去加载它链接时候指定的动态库,等准备好之后才会跑到 main 函数处执行。
  2. 运行时加载
    运行时加载就需要依靠 dlopen、dlsym、dlclose 这些动态库辅助加载函数来完成。这类动态库无需在函数 main 函数之前完成加载,而是在程序里面随用随加载。

使用 strace 跟踪一个可执行文件的运行前加载动态库状况如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
X@ubuntu:~/workstation/apps/compiles/Libs$ strace ./main
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/tls/i686/sse2/cmov/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/tls/i686/sse2/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/tls/i686/cmov/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/tls/i686/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/tls/sse2/cmov/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/tls/sse2/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/tls/cmov/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/tls/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/i686/sse2/cmov/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/i686/sse2/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/i686/cmov/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/i686/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/sse2/cmov/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/sse2/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/cmov/libdylib1.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/home/yellow/workstation/apps/compiles/Libs/libdylib1.so", O_RDONLY|O_CLOEXEC) = 3
open("/home/yellow/workstation/apps/compiles/Libs/libdylib2.so", O_RDONLY|O_CLOEXEC) = 3
open("/home/yellow/workstation/apps/compiles/Libs/libc.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3

 

可以看到它找动态库的路径是极为冗长的,这是跟 ld.so 程序的一个特性有关的,具体的特性是一个动态库版本查找规则,这里就不深入去说了,这个冗长的步骤是无法去除的,也就是无法一步到位,因为这是个特性哈,知道有这个过程就行,并且在 ld.so.cache 文件里面可以自定义程序的库路径查找逻辑。

拓展

  1. 动态库的裁剪
    可以通过 readelf -d 来分析动态库的依赖关系,可执行文件的依赖关系来确认是否有用不到的动态库,如果用不到,删除之,当然要注意添加白名单,主要是照顾那些运行时使用 dl 库函数加载的动态库。
  2. 静态库打包到动态库里面
    有时候我们可能需要把一个静态库成品转化为一个动态库,就可以使用:
    1
    gcc -shared -o libdylib1.so -L. -Wl,--whole-archive libstlib1.a -Wl,--no-whole-archive

这个会把静态库全部打包进动态库,使用 readelf -s 可以看到动态库里面多了一些符号。

    1. 动态库的预加载
      写一个空的 main 函数,只包含一个 hello world,但是链接的时候添加想要预加载的动态库,就可以使用该袖珍版程序提前把动态库加载到内存里面,在真正的可执行程序运行的时候这个动态库的家在过程就快很多了。通常这个特性会用在嵌入式设备的快速启动优化当中,不细讲了,仅抛砖引玉。
    2. 符号表大小分析
      使用 nm 命令可以分析 elf 文件里面的符号表,特别关注其大小信息就可以使用 nm --size-sort -r elf,这个可以把符号表由大到小排列,用于裁剪程序的体积。
    3. 不加载未使用的函数
      在静态库链接的时候可能会有很多未使用到的函数、变量等等,可以使用 -fdata-sections-ffunciton-sections 等选项来去掉那些用不到的函数,这里去掉只是在最终的可执行文件里面看不到而已,并不是从静态库里面删掉。