避免链接问题

库、链接、初始化和 C++ 系列的第 4 部分

作者:Darryl Gove 和 Stephen Clamage,2011 年 5 月

第 1 部分 — 库和链接简介
第 2 部分 — 解析库中的符号

符号重复

同一符号可能出现在多个库中。这种情况下,链接程序通常绑定到它遇到的该符号的第一个定义(尽管稍后我们将讨论直接绑定如何使不同的库绑定到同一符号名称的不同实例)。这可以通过修改测试程序使其包含重复符号来演示,如清单 1 所示。

清单 1:几个包括重复符号的库
$ more main.c
 #include <stdio.h>

 void f();

 void main()
 {
   f();
   printf("In main\n");
 }

 $ more lib1.c
 #include <stdio.h>

 void f()
 {
   printf("In library 1\n");
 }

 $ more lib2.c
 #include <stdio.h>

 void f()
 {
   printf("In library 2\n");
 }

如果在链接行中将 lib1.so 列在前面进行编译,符号 f() 将解析成该库中的定义。如果将 lib2.so 列在前面进行编译,它将是所执行的代码。如清单 2 所示。

清单 2:符号解析成遇到的第一个定义
 $ cc -G -Kpic lib1.c -o lib1.so
 $ cc -G -Kpic lib2.c -o lib2.so
 $ cc -o main main.c -L. -R'$ORIGIN' -l1 -l2
 $ ./main
 In library 1
 In main
 $ cc -o main main.c -L. -R'$ORIGIN' -l2 -l1
 $ ./main
 In library 2
 In main

注意,在链接时和运行时都没有警告。可以使用调试工具 lari 识别这些重复的符号,如清单 3 所示。

清单 3:使用 lari 识别重复的符号
 $ lari ./main
 [2:0]: f(): codes/library/lib1.so
 [2:1E]: f(): codes/library/lib2.so

输出显示有两个名为 f() 的符号,在输出中通过该符号旁边的文字 [2:*] 来指示。绑定的是 lib2.so 中的定义,这由文字 [2:1E](字母 E 指示绑定来自外部对象)指示,而未绑定 lib1.so 中的定义,这由 [2:0] 指示。

另一种检查重复符号的办法是检查库定义了哪些符号。这可以使用 nm 实用程序搜索已定义的全局符号来完成,如清单 4 所示。带标志 -s 的实用程序 elfdump 也可用于报告符号信息。

清单 4:使用 nm 确定库所定义的符号
 % nm ./lib1.so|grep GLOB |grep -v UNDEF
 [47]    |     66300|       0|OBJT |GLOB |0    |13     |_DYNAMIC
 [45]    |     66452|       0|OBJT |GLOB |0    |15     |_edata
 [41]    |     66452|       0|OBJT |GLOB |0    |16     |_end
 [40]    |       690|       0|OBJT |GLOB |0    |10     |_etext
 [39]    |       660|      12|FUNC |GLOB |0    |8      |_fini
 [48]    |     66228|       0|OBJT |GLOB |0    |11     |_GLOBAL_OFFSET_TABLE_
 [42]    |       648|      12|FUNC |GLOB |0    |7      |_init
 [44]    |       672|       4|OBJT |GLOB |0    |9      |_lib_version
 [38]    |     66236|       0|OBJT |GLOB |0    |12     |_PROCEDURE_LINKAGE_TABLE_
 [43]    |       592|      56|FUNC |GLOB |0    |6      |f

多重定义符号的真正问题在于链接程序可以自由使用它首先找到的任意一个库来解析该符号。这意味着应用程序的运行时行为可能根据首先加载的库的不同而发生变化。这是一个严重问题,我们在后面的部分中将会发现这一点。

循环依赖关系

另一个潜在问题就是循环依赖关系。具体来说就是一个库依赖于另一个库提供的功能,而另一个库依赖于第一个库提供的功能。这种循环依赖有两种形式。

  • 第一种是真正的依赖关系,开发人员在编写应用程序时就是这样写的,这是预期的结果。
  • 第二种可能出现的情况是,假设存在多个同名的符号,链接程序最后将符号解析成了第一个加载的依赖关系,而不是解析成库自己的定义。

预设循环依赖的主要问题是,如果在链接时不保留几个未解析的依赖项,就不可能链接到库。清单 5 中的应用程序在 lib1.solib2.so 之间存在循环依赖关系。在该代码中,lib1.so 依赖 lib2.so 提供 g2() 的定义,lib2.so 依赖 lib1.so 提供 g1() 的定义。

清单 5:库之间包含预设循环依赖关系的应用程序
$ more main.c
 #include <stdio.h>

 void f1();

 void main()
 {
   f1();
   printf("In main\n");
 }

 $ more lib1.c
 #include <stdio.h>

 void g2();

 void f1()
 {
   g2();
   printf("In library 1\n");
 }

 void g1()
 {
  printf("Back in library 1\n");
 }

 $ more lib2.c
 #include <stdio.h>

 void g1();

 void g2()
 {
   g1();
   printf("In library 2\n");
 }

如果尝试使用 -z defs 警告未解析的依赖关系来链接 lib1.solib2.so,链接将失败。为了使编译继续,我们需要链接这两个库中的一个来允许未解析的符号。链接的过程如清单 6 所示。

清单 6:存在循环依赖关系时的链接
$ cc -G -Kpic -o lib1.so lib1.c -z defs -L. -R'$ORIGIN' -lc
 Undefined                       first referenced
  symbol                             in file
 g2                                  lib1.o
 ld: fatal: symbol referencing errors. No output written to lib1.so
 $ cc -G -Kpic -o lib1.so lib1.c  -L. -R'$ORIGIN' -lc
 $ cc -G -Kpic -o lib2.so lib2.c -z defs -L. -R'$ORIGIN' -l1 -lc
 $ cc -o main main.c -L. -R'$ORIGIN' -l1 -l2
 $ ./main
 Back in library 1
 In library 2
 In library 1
 In main

存在循环依赖关系意味着在链接应用程序时不能使用最佳实践,这样可能会掩盖潜在的问题。应用程序运行时,循环依赖可能导致不能保证库的正确初始化顺序。

通过设计避免循环依赖的最佳实践

当应用程序由多个库(除系统库外)组成时,最佳做法是确保任意两个库(如 libA.solibB.so)之间是相互独立的或位于不同的层次。也就是说,如果 libA.so 使用 libB.so 中的某项内容,libB.so 就不使用 libA.so 中的任何内容。而且,此层次结构规则必须扩展到混合体中的其他库。

使用 libA.so->libB.so 这种表示法意味着 libA.so 使用 libB.so 中的某项内容,假设我们有这样一组依赖关系:

 libA.so->libB.so->libC.so
 libC.so->libA.so

必须打破这种依赖关系的循环,可以通过确保 libC.so 不使用 libA.so 中的任何内容来实现。必须将库组织成一个或多个层次结构。在本示例中,必须有以下层次结构:

 Layer 1: libA.so
 Layer 2: libB.so
 Layer 3: libC.so

如果无法通过简单重构代码来避免循环,可能必须将公共代码提出来,放到另一个层级较低的库中。在本示例中,libC.so 使用 libA.so 中的某项内容。应提取此代码并将其放入一个新库 libD.so 中。

 Layer 1: libA.so
 Layer 2: libB.so
 Layer 3: libC.so
 Layer 4: libD.so

要求是 libD.so 不使用 libA.solibB.solibC.so 中的任何内容。

理想情况下,应从应用程序中提取出循环依赖关系。然而,还需要考虑的是生成过程和编译器可能会实际创建重复符号,导致在应用程序中引入循环依赖关系。这将在后文中讨论。

建议总结

  • 应用程序应避免定义同一符号的重复副本。此问题可通过对可执行文件使用 lari 工具来检测。
  • 应用程序不应包含循环依赖关系。库与库之间应该要么彼此不依赖,要么彼此之间层次分明。
修订版 1,2011 年 5 月 10 日