使用符号作用域避免链接问题

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

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

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


符号的默认作用域是全局可见。这意味着一个库中定义的符号可以被其他库和可执行文件看到和使用。nm 命令与 grep 结合使用可以识别全局作用域的符号。清单 1 显示编译库然后查看符号定义的过程。

清单 1:全局作用域的符号
$ CC -g -G -Kpic -o libstat.so stat.cpp
$ nm libstat.so|grep __1cFCstatEshow6M_v_
[Index]   Value      Size      Type  Bind  Other Shndx   Name
...
[87]    |      3848|        60|FUNC |GLOB |0    |9      |__1cFCstatEshow6M_v_

__1cFCstatEshow6M_v_ 符号是一个具有全局绑定的函数。注意,Other 列包含值 0,这表示该函数具有默认作用域。Other 列如果是其他值,则表示不同级别的作用域:2 表示隐藏作用域,3 表示受保护作用域。如果使用 elfdump 查看符号,它将以更具可读性的格式报告作用域,其中 D 代表默认,H 代表隐藏,而 P 代表受保护,如清单 2 所示。

清单 2:使用 elfdump 查看作用域
$ elfdump  libstat.so |grep __1cFCstatEshow6M_v_
[71]  0x00000f90 0x0000003c  FUNC GLOB  D  0 .text          __1cFCstatEshow6M_v_

以下几部分讨论如何使用不同的作用域级别来缩小符号作用域。
 

使用符号绑定限制符号作用域

符号绑定对应于受保护作用域。如果库包含一个符号作用域函数,库将使用该函数,并且该函数还可供其他希望使用的库使用。考虑这种用法的最好方法是将函数导出以供其他库使用,但库需要使用该函数本身。

可以使用标志 -xldscope=symbolic 将此设置为库中所有函数的默认作用域级别。清单 3 显示使用此标志编译 libstat.so 的效果。Other 列显示值 3,这表示受保护作用域;但函数仍是全局的。

清单 3:使用符号作用域
$ CC -g -G -Kpic -o libstat.so stat.cpp -xldscope=symbolic
$ nm libstat.so|grep __1cFCstatEshow6M_v_
[87]    |      3824|        60|FUNC |GLOB |3    |9      |__1cFCstatEshow6M_v_

使用此作用域作为默认值,问题应用程序将正常工作。这可以使用 truss 来观察,如清单 4 所示。

清单 4:使用 truss 观察应用程序的初始化
$ truss -u libstat,libdata,a.out ./main
/1@1:   -> libstat:_init(0x0, 0x0, 0xfefd2a40, 0x1)
/1@1:   <- libstat:_init() = 0
/1@1:   -> libdata:_init(0x0, 0x0, 0xfefd2a40, 0x1)
/1@1:     -> libstat:__1cFready6F_v_(0xff3511f4, 0x0, 0x0, 0x0)

在此有两件事情值得注意。首先,libstat.so 的初始化不调用 libdata.so,这避免了在 libstat.so 初始化完成之前触发 libdata.so 初始化的问题。其次,不会出现 libstat.so 初始化调用任何库的情况。这实际上是 truss 工作方法的人工实现。因为 truss 无法介入 libstat.so 库中的调用,它看不到对 Cstat::Cstat() 的调用。

这将带来使用符号作用域的副作用。不可能干预对库提供的函数的所有调用。库之间的调用可见,但库内的调用不可见。这意味着无法通过干预符号可靠地替代函数功能。有关详细信息,请参见干预函数部分。

与直接绑定一样,符号绑定可能导致定义同一对象或函数的多个副本。
 

使用隐藏作用域隐藏符号

还可以使用 -xldscope=hidden 隐藏符号。这意味着符号绑定在库内,但在库外不可见。如果使用隐藏作用域重新编译 libstat.so,我们会看到符号变成本地符号,如清单 5 所示。

清单 5:使用隐藏作用域编译
$ CC -g -G -Kpic -o libstat.so stat.cpp -xldscope=hidden
$ nm libstat.so|grep __1cFCstatEshow6M_v_
[40]    |      3728|        60|FUNC |LOCL |2    |9      |__1cFCstatEshow6M_v_

这样一更改,默认作用域就变成本地了,这意味着符号对其他库或可执行文件不可见。清单 6 显示了这些效果。

清单 6:将默认作用域声明为隐藏的效果
$ ./main
ld.so.1:main: fatal: relocation error: file /codes/library/stl2/libdata.so: symbol __1cFready6F_v_: referenced symbol not found
Killed

隐藏作用域的优点在于符号仅供库内部使用。它们不参与库的接口。这减少了出现多次定义符号问题的几率,还有一个优点是可以简化运行时链接器必须执行的工作,从而可以缩短应用程序的启动时间。

但是,要实际利用隐藏符号作用域,需要如下面讨论的那样将其与全局和符号作用域一起使用。
 

使用作用域界定为库产生最少接口

上面已在库或可执行文件级讨论了作用域。但是,实际上希望在函数级实际执行这个作用域界定。这样才可能识别需要导出的函数和需要导入的函数。然后可以隐藏无需导出或导入的函数,从而产生最少的库接口。

可以使用指定符 __global__symbolic__hidden 指定各个函数或变量的作用域。需要由库导入的函数或变量必须定义为 __global。从库导出的函数应定义为 __symbolic,除非需要对这些函数进行干预,或者可能存在符号的多个定义但仅使用一个定义很重要。然后可以在命令行中使用 -xldscope=hidden 隐藏所有其他符号。

这就给头文件出了个难题。如果有一个函数是由库导出的,并且在头文件中已经声明该函数,那么当另一个库包含该头文件时,需要将该函数作用域指定为 __global,但当该库本身包含该头文件时,则需要将该函数声明为 __symbolic。解决此问题的最好方法是使用 #define 控制作用域。清单 7 显示了为使用最小作用域进行编译而对测试程序进行的修改。

清单 7:为使用最小作用域而修改的源代码
$ more stat.cpp
#include <iostream>

#define BUILD_STAT_LIBRARY /*Indicating a library build*/
#include "stat.hpp"

Cstat stats;

void ready()
{
  stats.show();
}

$ more stat.hpp
#include <iostream>

#ifdef BUILD_STAT_LIBRARY
#define SCOPE __symbolic /*Building library, export symbol*/
#else
#define SCOPE __global   /*Building other modules, import symbol*/
#endif

class Cstat
{
  std::string data;
  public:
  SCOPE Cstat() { data = "data"; }
  SCOPE void show() {std::cout<<data;}
};

SCOPE void ready();

$ more data.cpp
#include "stat.hpp"

class Cdata
{
  Cstat stats;
  public:
  __symbolic Cdata() { ready(); }
};

Cdata data;

__symbolic void notready()
{
}

$ more data.hpp
__global void notready();

$ more main.cpp
#include "data.hpp"

int main()
{
  notready();
}

程序可以使用默认的隐藏作用域进行编译,如清单 8 所示。

清单 8:使用隐藏作用域作为默认值进行编译
$ CC -g -G -Kpic -o libstat.so stat.cpp -xldscope=hidden
$ CC -g -G -Kpic -o libdata.so data.cpp -xldscope=hidden -L. -R'$ORIGIN' -lstat
$ CC -g -o main main.cpp -xldscope=hidden -L. -R'$ORIGIN' -ldata

在使用默认作用域与单个作用域之间存在细微差别。全局符号作用域不会指定未定义的函数或变量的作用域。但是将 __symbolic 关键字与定义放在一起可以指定未定义变量的作用域。这意味着 __symbolic 只能应用于库中定义的函数。如果将 __symbolic 作用域应用于未定义的函数,链接器将报错,如清单 9 所示。

清单 9:将 __symbolic 作用域应用于未定义的函数
$ more lib.cpp
__symbolic extern int value;

void myfunction()
{
  value=0;
}
$ CC -g -G -Kpic -o liblib.so lib.cpp 
Undefined                       first referenced
 symbol                             in file
value                               lib.o  (symbol scope specifies local binding)
ld: fatal: symbol referencing errors. No output written to liblib.so

从 Sun Studio 9 开始的编译器均实现了标志 -qoption ccfe -xldscoperef=global 以强制使未定义符号具有全局作用域。清单 10 显示使用此标志强制未定义变量 value 具有全局作用域,从而解决了链接错误。

清单 10:使用标志为未定义符号指定全局作用域
$ more lib.cpp
__symbolic extern int value;

void myfunction()
{
  value=0;
}
$ CC -g -qoption ccfe -xldscoperef=global -G -Kpic -o liblib.so lib.cpp 
$ nm liblib.so | grep value
[61]    |         0|       0|NOTY |GLOB |0    |UNDEF  |value

干预函数

有时,您希望允许使用应用程序提供的不同版本替换或干预库中的函数。经典示例是基本 C 库中的一组函数 malloc()realloc()calloc()free()。如果替换这些函数,则必须在整个程序内替换对这些函数的所有调用。否则,在代码的不同位置调用产生不同结果可导致微妙或灾难性的程序错误。

如果您希望一个函数可被用户替换,必须记住以下两个考虑事项:

  • 函数必须具有全局绑定,而非符号绑定或隐藏绑定。直接绑定还会产生一些需要干预的问题。
  • 函数不得在库中任意位置内联生成。

如果函数没有全局绑定,那么至少某些引用将绑定到库版本,干预将不完整。

可以通过预先加载库并在该库中提供替换函数来覆盖直接绑定。清单 11 显示了一个使用 LD_PRELOAD 干预直接链接库的示例。

清单 11:干预直接绑定库
$ ./main
In libu1
In lib1
In libu2
In lib2
$ more libpre.c
#include <stdio.h>

void display()
{
  printf("Interposing on display()\n");
}

$ cc -G -Kpic -o libpre.so libpre.c -lc
$ export LD_PRELOAD=./libpre.so
$ ./main
In libu1
Interposing on display()
In libu2
Interposing on display()

还可以通过使用 -z interpose 选项进行链接来覆盖直接绑定。此链接器选项导致库中声明的函数干预同名的现有函数。清单 12 显示了这种情况的示例,干预库最初是不带 -z interpose 选项编译的。将此库链接到应用程序中不会导致行为有任何更改。一旦使用链接器选项 -z interpose 重新编译库,应用程序的行为将发生变化,并且将调用干预库而非直接绑定库。

清单 12:使用 -z interpose 编译干预库
$ cc -o main main.c -L. -R'$ORIGIN/.' -lu1 -lu2 -lpre
$ ./main
In libu1
In lib1
In libu2
In lib2
$ cc -G -Kpic -o libpre.so libpre.c -lc -zinterpose
$ ./main
In libu1
Interposing on display()
In libu2
Interposing on display()

函数不得声明为内联,您应在编译库时添加 -xinline 选项以防止优化器自行内联函数。即,在高优化级别,优化器可能决定内联未声明为内联的函数。尽管您可能不希望允许干预类成员函数,但应记得,类内部定义的函数是隐式声明为内联的。

示例:假设您希望允许干预全局函数 foo()。您可以在库头文件中以如下方式声明该函数:

__global int foo(int);

显式 __global 链接未被命令行中的 -xldscope 选项覆盖,且该函数未声明为内联。编译库时,可以使用 -xlnline= 选项禁用所有优化器内联,也可以使用 -xinline=no%zzzzzzzz 是函数的改编名称)只禁止优化器内联该函数。
 

建议总结

  • 对于不希望干预库中所定义符号并且正确的行为不仅仅依赖于所使用符号的单个定义的情况,使用 -xldscope=symbolic 标志可确保所有库优先使用其符号的本地定义,而不是使用另一个库提供的定义。这可能是向现有代码添加作用域的最简单的办法。
  • 对于新代码,或需要更严格作用域的代码,可使用 -xldscope=hidden 编译库和应用程序。这可确保隐藏所有无作用域的符号,从而使其他库不能访问这些符号。对于应由库导出的符号,可在其定义前面加上关键字 __symbolic 作为前缀。对于应由库导入的符号,应使用前缀 __global 指定其作用域。
  • 使用符号作用域时,应考虑具有对象或函数的多个动态副本是否会影响程序的正确性。如果有影响,则需对此类对象或函数使用全局作用域。
修订版 1,2011 年 7 月 11 日