C++ 库

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

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

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

C++ 头文件和库

在 C++ 中,在头文件中定义功能的现象很常见。头文件中定义的函数最终可能出现在所有包含这些头文件的对象中。如果有多个库包含同一个头文件,则这些函数可能在多个库中有定义。

链接程序通常将选择一个定义并绑定到该定义。如果程序的不同部分中对符号的不同引用会绑定到不同的定义,那么程序将违反 C++ 的单一定义规则,该规则规定在一个应用程序中每个符号都应是唯一定义的。

然而,当一个符号存在多个定义时,链接程序可能无法确定要绑定的最佳版本。如果链接程序选择了错误的版本,可能会导致库中出现循环依赖,而循环依赖可能导致有复杂初始化要求的应用程序出现问题。

在 C 代码中也可能出现同样的问题,但此问题在 C++ 中更普遍,因为 C++ 的语言规则允许编译器自动生成所需的函数。

示例

假设支持某统计信息收集所需的功能是在一个名为 Cstat 的类中实现的。其实现方式是使用一个包含该类的定义的头文件和一个包含该实现的库。该库还有一个名为 stats 的此类对象。如清单 1 所示。

清单 1:Cstat 类的实现
 $ more stat.hpp
 #include <iostream>
 #include <string>
 class Cstat
 {
   std::string data;
   public:
   Cstat() data("data"){ }
   void show() {std::cout<<data;}
 };
 void ready();
  
 $ more stat.cpp
 #include "stat.hpp"
 
 Cstat stats;
 
 void ready()
 {
   stats.show();
 }

注意,此类的构造函数是在头文件中定义的,其他成员函数是在 .cpp 文件中定义的。清单 2 显示了编译此库及检查其所含函数的步骤。

注意,工具 nm 提供了选项 -C,该选项可打印解码的函数名称。解释改编名称的其他方法包括实用程序 dem(该实用程序接受一个改编名称,然后打印出对等的解码名称)和工具 c++filt(该工具输出通过管道传递给它的输入的解码版本)。

清单 2:构建和检查 stats 库
  $ CC -G -Kpic -o libstat.so stat.cpp
  $ nm -C libstat.so|grep GLOB|grep -v UND
  [52]    |      3640|       236|FUNC |GLOB |0    |9      |Cstat::Cstat #Nvariant 1()
  [71]    |      3640|       236|FUNC |GLOB |0    |9      |Cstat::Cstat()
  ...
  [63]    |     69936|         4|OBJT |GLOB |0    |17     |stats
  [78]    |      3368|        60|FUNC |GLOB |0    |9      |void Cstat::show()
  [62]    |      3448|        56|FUNC |GLOB |0    |9      |void ready() 

库的 init 部分包含 stat 对象的构造函数。加载库时,此部分将构造该对象。

当有另一个库使用该库时,情况将变得更加复杂。清单 3 显示包含 libstat.so 中头文件的另一个库的代码。该库定义一个 Cdata 类,该类包含一个 Cstat 对象。库 libstat.so 还有一个 Cdata 类的名为 data 的对象。

清单 3:使用 libstat.so 的库
 $ more data.hpp
 void notready();
 
 $ more data.cpp
 #include "stat.hpp"
 
 class Cdata
 {
   Cstat stats;
   public:
   Cdata() { ready(); }
 };
 
 Cdata data;
 
 void notready()
 {
 }

初始化部分将在加载库时构造对象 data,该对象包含一个 Cstat 对象。构造 Cdata 对象时,它将调用 libstat.so 中的 ready()。因此,在初始化 libdata.so 之前先初始化 libstat.so 是非常重要的。

清单 4 显示了编译该库并检查其所导出符号的过程。

清单 4:编译和检查 libdata.so
  $ CC -g -G -Kpic -o libdata.so data.cpp -L. -R'$ORIGIN' -lstat 
  $ nm -C libdata.so|grep GLOB|grep -v UND
  ...
  [79]    |      3832|        64|FUNC |GLOB |0    |9      |Cdata::Cdata #Nvariant 1()
  [83]    |      3832|        64|FUNC |GLOB |0    |9      |Cdata::Cdata()
  [60]    |      3912|       124|FUNC |GLOB |0    |9      |Cstat::Cstat #Nvariant 1()
  [76]    |      3912|       124|FUNC |GLOB |0    |9      |Cstat::Cstat()
  ...
  [81]    |     70132|         4|OBJT |GLOB |0    |17     |data
  [71]    |      3504|        12|FUNC |GLOB |0    |9      |void notready()

该库定义了驻留在库中的函数,但同时还定义 Cstat 对象的构造函数。该符号在 libstat.solibdata.so 中均有定义。因此,运行时链接程序可以自由选择任一定义,这可能会导致问题。此问题可以通过编写一个使用 libdata.so 的应用程序来演示。清单 5 显示了这样一个应用程序。

清单 5:使用 libdata.so 的应用程序
 $ more main.cpp
 #include "data.hpp"
 
 int main()
 {
   notready();
 }

运行时,此应用程序会出现“段错误”。清单 6 中 LD_DEBUG=init 的输出显示导致此问题的事件序列。

清单 6:编译和运行应用程序
  $ CC -o main main.cpp -L. -R'$ORIGIN' -ldata
  $ LD_DEBUG=init ./main
  ... 
  28006: 1: calling .init (from sorted order): /codes/library/stl2/libstat.so
  28006: 1: 
  28006: 1: calling .init (dynamically triggered): /codes/library/stl2/libdata.so
  28006: 1: 
  28006: 1: warning: calling /library/stl2/libstat.so whose init has not completed
  28006: 1: 
  Segmentation Fault (core dumped)

问题的顺序是 libstat.so 正在初始化,但这触发了 libdata.so 的初始化部分。libdata.so 调用进入 libstat.so,而 libstat.so 尚未完成初始化,此时出现程序段错误。此实例中的一个红色标志是警告 libstat.soinit 部分尚未完成。

环境设置 LD_DEBUG=bindings 可用于检查从 libstat.so 绑定到 libdata.so 的确切符号。导致初始化失败的正是此符号,如清单 7 所示。

清单 7:使用 LD_DEBUG=bindings 检查符号
  $ LD_DEBUG=bindings ./main 2>&1 |grep libdata |grep libstat |c++filt
  07510: 1: binding file=/codes/library/stl2/libstat.so to file=/codes/library/stl2/libdata.so: symbol `Cstat::Cstat()'
  07510: 1: binding file=/codes/library/stl2/libdata.so to file=/codes/library/stl2/libstat.so: symbol `void ready()'

输出显示符号 Cstat::Cstat()libstat.so 绑定到 libdata.so。这是 Cstat 构造函数。因此,libstat.so 不是使用自己的 Cstat 构造函数定义,而是调用了由 libdata.so 提供的定义。

为进一步确认事件序列,可以使用 dbx 检查调用堆栈,如清单 8 所示。命令 where -l 列出当前调用堆栈以及每个函数所在的库。

清单 8:使用 dbx 检查调用堆栈
  $ dbx - core
  Current function is Cstat::show
      7     void show() {std::cout<<data;}
  (dbx) where -l
    [1] libCstd.so.1:std::operator<< 
  <char,std::char_traits<char>,std::allocator<char> >(0xff0eb170, 0xff1111f4, 
  0xff160f0c, 0x0, 0xff0e5560, 0xff0e77d0), at 0xff06b85c 
  =>[2] libstat.so:Cstat::show(this = 0xff1111f4), line 7 in "stat.hpp"
    [3] libstat.so:ready(), line 8 in "stat.cpp"
    [4] libdata.so:Cdata::Cdata(this = 0xff1711f4), line 7 in "data.cpp"
    [5] libdata.so:__SLIP.INIT_A(), line 10 in "data.cpp"
    [6] libdata.so:__STATIC_CONSTRUCTOR(), line 10 in "data.cpp"
  ...

堆栈帧 2、3 和 4 显示 Cdata 的构造函数,它调用例程 ready(),然后该例程调用 Cstat::show()。在 Cstat::show() 中,当其调用 cout 时会出现应用程序段错误,因为对象 stats 的构造函数尚未对变量 data 进行初始化。

可以使用 truss 检查确切的调用序列,如清单 9 所示。

清单 9:使用 truss 查看调用序列
  $ truss -u libstat,libdata,a.out ./main
  ...
  /1@1:   -> libstat:_init(0x0, 0x0, 0xfefd2a40, 0x1)
  /1@1:     -> libdata:_init(0x0, 0x0, 0xfefd2a40, 0x1)
  /1@1:       -> libstat:__1cFready6F_v_(0xff3511f4, 0x0, 0x0, 0x0)
  /1:         Incurred fault #6, FLTBOUNDS  %pc = 0xFF26B85C
  /1:           siginfo: SIGSEGV SEGV_MAPERR addr=0xFFFFFFF8
  /1:         Received signal #11, SIGSEGV [default]
  /1:           siginfo: SIGSEGV SEGV_MAPERR addr=0xFFFFFFF8
    

所有这些信息对发生的情况给出了解释。核心问题在于 Cstat 的构造函数存在多个定义。在运行时,链接程序首先遇到 libdata.so 中的定义,因此这变成 libstat.so 在其需要构造 Cstat 类时所调用的函数。链接程序将 libstat.so 正确识别为要初始化的第一个库,但在此初始化期间,该库需要调用 Cstat 类的构造函数。该构造函数位于 libdata.so 中,因此需要先运行 libdata.so 的初始化代码。该代码回调进入尚未完成初始化的 libstat.so,正是代码的这最后一部分导致应用程序出现段错误。图 1 显示了此事件序列。

linking_series_five_image1

图 1. 问题应用程序的初始化序列

本示例中 -g 的作用

在本示例中,一个起主要作用的因素是编译行中 -g 的存在。缺少优化标志时,-g 标志将阻止编译器内联某些函数。如果函数被内联,就不会再调用这些函数,这种情况下,不会出现选错版本的问题。

但是,此变通方法只适用于本示例。对于更复杂的应用程序,应用程序的优化和调试版本都可能会出现同样的问题。因此,尽管您可能会以为此问题多少是由于 -g 导致的,但实际上,这里只是通过 -g 演示一下该问题。该问题可能发生在其他更难以调试的情况下。

作为此问题如何可能发生在更复杂的应用程序中的一个示例,可考虑一个被声明内联的函数。在结果代码超过一定复杂程度的场合,此函数可能未内联。在其他情况下,将在外部生成需要其地址的函数,即使该函数在某些调用位置也是内联的。虚拟函数始终将在外部生成,因为虚拟表中需要其地址。因此,有多种原因可能导致应用程序及其库中永久存在函数的多个定义。

建议总结

  • 使用 LD_DEBUG=init 检查应用程序加载的库的初始化。
  • 使用 LD_DEBUG=bindings 检查符号在库之间是如何解析的。
修订版 1,2011 年 5 月 19 日