解决初始化顺序问题

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

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

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

使用链接顺序解决库依赖性问题

解决链接程序从错误库中选取函数这一问题的最快方法之一,是更改命令行中库的链接顺序。这不仅是最快速的方法,也是满意度最低的方法。不满意是因为它未解决多重符号定义的基本问题;而是鼓励链接程序选取适当的符号。

因此,该方法是一种变通方法,而不是真正的解决方案,问题仍有可能出现。例如,如果库是动态加载而不是显式链接到应用程序,或者,正如通常的情况,以延迟方式加载库,就可能出现问题。此外,不同操作系统上的链接程序或相同操作系统的不同版本上的链接程序,对于符号的解析可能不同。

例如,在 Oracle Solaris 中,可以通过在与 libdata.so 链接之前先与 libstat.so 链接来解决库顺序问题,如清单 1 所示。

清单 1:使用链接排序帮助运行时链接程序选取适当的符号
$ CC -o main main.cpp -L. -R'$ORIGIN' -lstat -ldata
$ ./main
data

Oracle Solaris 运行时链接程序按命令行中指定的顺序对符号进行解析,因此在 libdata.so 中搜索符号之前,将先在 libstat.so 中搜索。因此 libstat.so 最终会绑定到自身的 Cstat 构造函数,而非 libdata.so 中的构造函数。

遗憾的是,这种方法有时可能会失败。如果 Oracle Solaris 运行时链接程序发现库的 init 部分存在循环依赖,它将按从最后加载的库到第一个加载的库的顺序依次调用 init 部分,这可能导致出现问题。其他系统上的链接程序的行为可能有所不同。

这意味着对链接行上的库进行重新排序可能是一种快速修复办法,但不能修复所有可能的情形,并且可能不是一种强健可靠的修复办法,因为某个其他符号可能会导致问题重现。
 

使用 -instlib 避免多重模板定义

编译器有两种办法生成库中的函数。第一种方法是通过生成例程的外部(即非内联)版本。第二种办法是从模板代码生成例程。如果可执行文件或库包含模板代码,其正确的功能则依赖于该代码还含有模板所依赖的所有例程的定义。为了确保这一点,最安全的方式是模块同时包含这些函数的定义。这将出现同一符号的多个定义存在于多个加载对象中的情况。

C++ 编译器标志 -instlib=<library> 接受库名,导致编译器不会为指定库中找到的任何模板实例给出重复定义。

C++ 选项 -instlib=<lib> 导致编译器检查 <lib> 中是否存在任何不应生成的模板实例。一个命令行这种可以有多个 -instlib 选项。假定示例中的所有库均可能生成模板实例,我们按自下而上的顺序编译库,并为层次结构中处于底层的每个库添加 -instlib 选项。则四个库的编译过程将如清单 2 所示。

清单 2:编译库的层次结构
$ CC -G -o libD.so D.cpp ...
$ CC -G -o libC.so -instlib=libD.so C.cpp ...
$ CC -G -o libB.so -instlib=libC.so -instlib=libD.so B.cpp ...
$ CC -G -o libA.so -instlib=libB.so -instlib=libC.so -instlib=libD.so A.cpp ...

libD.so 中生成的任何模板不会在任何其他库中生成,在层次结构中依此类推。libA.so 中只能生成 libB.solibC.solibD.so 中尚未存在的模板实例。

您还将 -instlib 选项用于在编译主程序的命令行上显式链接的任何库。例如,假设 libA.solibB.so 是主程序唯一显式知道的库,而其他库实际上是 libA.solibB.so 的详细实现。用于编译该应用程序的命令如清单 3 所示。

清单 3:使用 -instlib 避免重复的模板实例
$ CC -o myprog -instlib=libA.so -instlib=libB.so myprog.cpp ... -lA -lB

在外部生成的内联函数不受 -instlib 选项的影响。这是因为编译器无法知道您是否打算使用一个碰巧在应用程序中声明内联的函数干预库函数,并且无法判断库中的某个函数是否原来被声明为内联。

例如,我们来看一个在头文件中声明的模板,如清单 4 所示。对于一个要在头文件中声明的模板,该模板成员函数的所有定义均应包括在该头文件中。编译器在编译时必须有权访问模板的所有成员函数,这样才能在模板应用于特定数据类型时生成所需的专门版本。

清单 4:在头文件中声明的模板
$ more template.h
template <class CType> class Counter
{
  CType value;
public:
  Counter(CType initial) { value = initial; }
  CType add(CType increment) { value += increment; return value; }
  CType get() {return value; }
};

假设模板针对两个库中的类型 int 进行了实例化。清单 5 显示了这两个库的源代码。

清单 5:在 int 数据类型上对 Counter 模板进行实例化的两个库
$ more lib1.cpp
#include <stdio.h>
#include "template.h"

void usetemplate1()
{
  Counter<int> object(0);
  object.add(10);
  printf("Library 1 Value = %i\n",object.get());
}

$ more lib2.cpp
#include <stdio.h>
#include "template.h"

void usetemplate2()
{
  Counter<int> object(0);
  object.add(17);
  printf("Library 2 Value = %i\n",object.get());
}

清单 6 显示对这两个库进行编译并检查其中的 get() 符号定义的结果。

清单 6:编译两个库并检查其中的模板代码
$ CC -g -G -Kpic -o lib1.so lib1.cpp
$ CC -g -G -Kpic -o lib2.so lib2.cpp
$ nm lib1.so|grep Counter | c++filt |grep get
[60]    |      1512|      36|FUNC |GLOB |0    |8      |int Counter<int>::get()
$ nm lib2.so|grep Counter | c++filt |grep get
[60]    |      1512|      36|FUNC |GLOB |0    |8      |int Counter<int>::get()

本示例中,在无优化的情况下请求调试信息时,编译器将生成模板例程的外部版本。结果,两个库最终都为相同的例程提供定义。如前所述,这可能导致运行时问题。

使用 -instlib 标志,可以请求编译器在当前编译的库代码中不要重复现有库中的模板定义。这可以用于确保 lib1.so 包含模板代码的实现,并且 lib2.so 会使用这些定义。清单 7 显示了此方法。

清单 7:使用 -instlib 确保只有一个库为模板代码提供定义
$ CC -g -G -Kpic -o lib1.so lib1.c
$ CC -g -G -Kpic -o lib2.so -instlib=./lib1.so lib2.c
$ nm lib1.so|grep Counter | c++filt |grep get
[60]    |      1512|      36|FUNC |GLOB |0    |8      |int Counter<int>::get()
$ nm lib2.so|grep Counter | c++filt |grep get
[60]    |         0|       0|FUNC |GLOB |0    |UNDEF  |int Counter<int>::get()

使用 -instlib 标志,模板代码的定义将驻留在 lib1.so 中,并且 lib2.so 包含未解析的符号,这些符号在运行时将使用 lib1.so 提供的定义。
 

使用直接绑定在编译时记录依赖性

链接程序标志 -Bdirect 导致编译时链接程序记录编译时库中的依赖性信息,而不是让运行时链接程序在运行时揣测这种依赖关系。在有些情况下,这可能会降低运行时链接程序选错符号的潜在风险。

使用该标志还可能会带来启动时的性能改进,因为运行时链接程序只需执行较少的工作即可找到正确的符号和库。

但使用直接绑定也存在潜在弊端。当一个符号存在多重定义时,直接绑定增加了程序中单个符号具有多个活动定义的机会。如果程序依赖于对符号的所有引用而引用同一物理地址,则程序可能失败。

作为直接绑定的一个示例,请考虑两个库声明一个具有相同名称但具有不同实现的函数的情况。清单 8 中的两个库显示了这种情况。这两个库均声明了函数 display(),并且每个库提供了不同的实现。

清单 8:声明同一函数的两个库
$ more lib1.c
#include <stdio.h>

void display()
{
  printf("In lib1\n");
}

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

void display()
{
  printf("In lib2\n");
}

现在假设还有两个库,每个都使用 display 函数,但依赖于两个不同实现。清单 9 显示了这两个库,以及一个调用第二对库的应用程序。

清单 9:使用同一符号但期望不同实现的两个库
$ more libu1.c
#include <stdio.h>

void display();

void use1()
{
  printf("In libu1\n");
  display();
}

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

void display();

void use2()
{
  printf("In libu2\n");
  display();
}

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

void use1();
void use2();

int main()
{
  use1();
  use2();
}

清单 10 显示了编译并运行这些库的结果。

清单 10:编译并运行包含重复符号的代码
$ cc -G -Kpic -o lib1.so lib1.c
$ cc -G -Kpic -o lib2.so lib2.c
$ cc -G -Kpic -o libu1.so libu1.c -L. -R'$ORIGIN' -l1
$ cc -G -Kpic -o libu2.so libu2.c -L. -R'$ORIGIN' -l2
$ cc -o main main.c -L. -R'$ORIGIN' -lu1 -lu2
$ ./main
In libu1
In lib1
In libu2
In lib1

正如所预料的那样,运行时链接程序将对函数 display() 的两个调用均解析为 lib1.so 导出的同一符号。但是,这不是我们所期望的。直接绑定提供了一种方法,可令这两个库绑定到同一符号的不同实例,如清单 11 所示。

清单 11:使用直接绑定链接到同一符号的多个实例
$ cc -G -o lib1.so lib1.c
$ cc -G -o lib2.so lib2.c
$ cc -G -o libu1.so libu1.c -L . -R'$ORIGIN' -Bdirect -l1
$ cc -G -o libu2.so libu2.c -L . -R'$ORIGIN' -Bdirect -l2
$ cc -o main main.c -L. -R'$ORIGIN' -lu1 -lu2
$ ./main
In libu1
In lib1
In libu2
In lib2

使用直接绑定链接库时,这两个库将记录满足其未解析符号定义的符号的位置,然后它们将在运行时使用这些定义,而不是依赖运行时链接程序来查找未定义符号的定义。

直接绑定还可以限制干预(替换)库中函数的能力。(请参见本系列第 7 部分中的“干预函数”。)
 

建议总结

  • 您可以通过在链接行上小心地对库进行排序来规避某些符号绑定问题。遗憾的是,这可能不是永久的解决方案,因为库加载顺序上的变化还可能导致问题再次出现。
  • 使用编译时标志 -instlib=<library> 避免模板代码的实现出现在多个加载对象中。
  • 链接程序支持直接绑定,这确保可以在编译时记录准确的库依赖性。直接绑定的弊端是可能定义同一数据对象或函数的多个实例。
修订版 1,2011 年 7 月 11 日