您的位置:首页 > 其它

静态链接中库的次序

2016-08-19 11:22 162 查看
原作者:Eli Bendersky

http://eli.thegreenplace.net/2013/07/09/library-order-in-static-linking

我将以一个有点卑劣但有教育性的例子开始。假定我们有这样的代码:

volatilechar src[] = {1,
2, 3,
4, 5};

volatilechar dst[50] = {0};

 

void* memcpy(void* dst,
void* src,
int len);

 

intmain(int argc,
constchar* argv[])

{

    memcpy(dst, src, sizeof(src) /
sizeof(src[0]));

    return dst[4];

}

它运行得很好并且返回值是5。现在,假定这是一个更大项目的一部分,它包含了许多目标文件与库,而且在项目的某处有一个包含了这些代码的库:

voidmemcpy(char* aa,
char* bb,
char* cc) {

    int i;

    for (i =
0; i <
100; ++i) {

        cc[i] = aa[i] +bb[i];

    }

}

如果前面代码片段被链接到这个库,会发生什么?你会期望它仍然返回5?返回别的东西?崩溃?回答是:视情况——结果可以是正确或是一个段错误。这依赖于项目中的目标文件与库以何次序递交给链接器。

如果你完全理解为什么这依赖于链接次序,知道如何避免这个问题(以及更严重的问题,像循环依赖),那么恭喜你自己,往前去吧——你不需要本文。不然的话,继续读下去吧。

基础知识

让我们先定义本文的范围:首先,我的例子展示了Linux上gcc与binutils工具链的使用。兼容的工具链(像以clang替代gcc)也适用。其次,这里的讨论围绕在编译/链接时刻完成的静态链接。

为了理解为什么链接程序是紧要的,首先理解链接器如何链接库与目标文件是有指导意义的。作为一个快速提醒——目标文件既向其他目标文件及库提供(输出)符号,也期望(导入)来自其他目标文件及库的符号。例如,在这个C代码里:

int imported(int);

 

staticintinternal(int
x) {

    return x *
2;

}

 

intexported(int x) {

    return imported(x) *internal(x);

}

函数名不言而喻。让我们编译它并看一下符号表:

$ gcc -c x.c

$ nm x.o

000000000000000e T exported

                 Uimported

0000000000000000 t internal

这意味着:exported是一个导出符号——定义在目标文件里,外部可见。

Imported是一个未定义的符号;换而言之,预期链接器在别处找到它。当我们在后面谈及链接时,术语未定义(undefined)会变得令人混淆——因此记住这是它最初来的地方是有帮助的。Internal是定义在这个目标文件里,但外部可见。

现在,一个库只是一组目标文件。只是接在一起的若干目标文件。创建一个库是非常简单的操作,没有什么特别的,除了把许多目标文件放入同一个文件。这本身是重要的,因为一堆目标文件是不方便处理的。例如,在我的系统上libc.a(C的静态库)包含了差不多1500个目标文件。只把lib.c挪来挪去是更好的方式。

链接的过程

本节以一个有点枯燥,算法的方式来定义链接过程。这个过程是了解为什么链接次序是要紧的关键。

考虑一个链接器调用:

$ gcc main.o -L/some/lib/dir -lfoo -lbar -lbaz

链接器几乎总是经由编译器驱动器gcc在编译C或C++代码时调用。这是因为这个驱动知道如何向链接器(ld)提供正确的命令行实参,连同所有的支持库等。后面我们将看到更多。

总之,正如你在命令行上可以看到目标文件与库按一定的顺序提供。这就是链接顺序。下面是链接器要做什么:

·        链接器维护一个符号表。这个符号表做一堆事情,但在这之中是维护两个列表:

o  由所有以遭遇的目标文件与库导出的符号的列表。

o  以遭遇的目标文件与库要求导入但还未找到的未定义符号的列表。

·        当链接器遇到一个新目标文件时,它检查:

o  它导出的符号:它们被添加到上面提到的导出符号列表。如果有符号在未定义列表中,从那里删除它,因为现在已经找到了。如果符号已经在导出列表里,我们得到一个“多个定义”的错误:两个不同的目标文件导出相同的符号,链接器糊涂了。

o  它导入的符号:它们被添加到未定义符号列表中,除非可以在导出符号列表中找到它们。

·        当链接器遇到一个新库时,事情变得更有趣一些。链接器仔细检查库中的所有目标文件。对每一个,它首先检查它导出的符号。

o  如果导出的符号在未定义列表里,这个目标文件被添加链接,并执行下一步。否则,跳过下一步。

o  如果目标文件被添加进行链接,它如上描述那样处理——它的未定义与导出符号被添加到符号表。

o  最后,如果库中有目标文件已经被列入链接了,这个库被再次扫描——很可能由被列入目标文件导入的符号可以在同一个库中的其他目标文件里找到。

当链接器完成工作时,它查看符号表。如果仍有符号在未定义列表中,链接器将抛出“未定义引用”的错误。例如,在你创建一个可执行文件,并且忘记包括包含main函数的文件,你将得到像这样的结果:

/usr/lib/x86_64-linux-gnu/crt1.o: In function '_start':

(.text+0x20): undefined reference to 'main'

collect2: ld returned 1 exit status

注意在链接器检查一个库之后,它不会再检查它。即使它导出了后面某些库可能需要的符号。链接器回头重新扫描它已经看过的目标文件唯一的机会发生在单个库中——正如上面提到的,一旦对来自某个库的目标文件进行链接,同一个库中所有其他目标文件将被重新扫描。传递给链接器的标记可以微调这个过程——再次的,我们将在后面的一些例子中看到。

还要注意在检查一个库时,其中的一个目标文件可以排除在链接之外,如果它不提供符号表所需的符号。这是静态链接十分重要的特性。我之前提到的C库大量使用这个特性,主要通过把自己分解为每函数一个目标文件。因此,例如如果你代码使用的C标准库函数只是strlen,那么从libc.a将仅链接strlen.o——你的可执行文件将非常小。

简单例子

前面的章节不好理解,因此下面是一些简单的例子,展示了活动的过程。

让我们以最基本的案例开始,链接两个目标文件:

$ cat simplefunc.c

int func(int i) {

    return i + 21;

}

 

$ cat simplemain.c

int func(int);

 

int main(int argc, const char* argv[])

{

    return func(argc);

}

 

$ gcc -c simplefunc.c

$ gcc -c simplemain.c

$ gcc simplefunc.o simplemain.o

$ ./a.out ; echo $?

22

链接如预期。注意因为这些是目标文件,链接次序无关重要。目标文件总是计入链接。我们可以将它们以反序传递该链接器,它仍然可以工作:

$ gcc simplemain.o simplefunc.o

$ ./a.out ; echo $?

22

现在让我们做一些不同的事情。让我们把simplefunc.c放入一个库:

$ ar r libsimplefunc.a simplefunc.o

$ ranlib libsimplefunc.a

$ gcc  simplemain.o -L.-lsimplefunc

$ ./a.out ; echo $?

22

就像施了魔法。但注意如果现在以反序链接会发生什么:

$ gcc  -L.-lsimplefunc  simplemain.o

simplemain.o: In function 'main':

simplemain.c:(.text+0x15): undefined reference to 'func'

collect2: ld returned 1 exit status

理解上面概括的链接算法使得这个用例变得容易解释。当链接器遇到libsimplefunc.a时,它还没看到simplemain.o,这意味着func还没有在未定义列表中。当链接器检查这个库时,它看到simplefunc.o导出func。但因为它不需要func,这个目标文件没有列入链接。当链接器查找simplemain.o,并发现func是真正需要的时,func被添加到未定义列表(因为它不在导出列表中)。然后链接器完成链接,func仍然是未定义。

注意在前面的链接顺序中这如何不会发生——因为simplemain.o先出现,在链接器看到这个库之前,func在未定义列表里,因此目标文件导出的它被包含了。

这导致我们得出上面概括的链接过程最重要的推论:

如果目标文件或库AA需要来自库BB的一个符号,那么在链接器的命令行调用中,AA应该出现在库BB之前。

循环依赖

上面的推论是链接过程的一个重要总结——记住它肯定更为实际,因为它很短。但它会让人好奇——如果AA需要来自BB的一个符号,但BB也需要来自AA的一个符号会发生什么?虽然这不是一个好的编程实践,实际中它相当常见。但在命令行上AA不能同时在BB前后,对吧?那是愚蠢的。等一下,是吗,真的?

让我们看一个例子从简单开始。想象不是由simplefunc.c,符号func这样提供:

$ cat func_dep.c

int bar(int);

 

int func(int i) {

    return bar(i + 1);

}

$ cat bar_dep.c

int func(int);

 

int bar(int i) {

    if (i > 3)

        return i;

    else

        return func(i);

}

这两个文件彼此依赖并且放入不同的库。如果我们以一个次序链接它们,我们失败了:

$ gcc  simplemain.o-L.  -lbar_dep -lfunc_dep

./libfunc_dep.a(func_dep.o): In function 'func':

func_dep.c:(.text+0x14): undefined reference to 'bar'

collect2: ld returned 1 exit status

不过,另一个次序成功了:

$ gcc  simplemain.o -L. -lfunc_dep-lbar_dep

$ ./a.out ; echo $?

4

小测试:你能找出原因吗?提示:仔细检查这个命令行的链接过程算法。当链接器第一次看到-lfunc_dep时,符号表包含了哪些未定义符号?不过这是一个非常简单的情形。让我们看一个更有诀窍的例子。我们将向来自libfunc_dep.a的另一个函数添加对bar的依赖,不过这个函数在另一个目标文件中:

$ cat bar_dep.c

int func(int);

int frodo(int);

 

int bar(int i) {

    if (i > 3)

        return frodo(i);

    else

        return func(i);

}

 

$ cat frodo_dep.c

int frodo(int i) {

    return 6 * i;

}

我们将这些文件重新编译为独立的目标文件,现在库libfunc_dep.a将是:

$ ar r libfunc_dep.a func_dep.o frodo_dep.o

$ ranlib libfunc_dep.a

下面是库的一张图,箭头显示了依赖关系:



现在不管我们以何次序列出库,链接失败:

$ gcc  -L. simplemain.o-lfunc_dep -lbar_dep

./libbar_dep.a(bar_dep.o): In function 'bar':

bar_dep.c:(.text+0x17): undefined reference to 'frodo'

collect2: ld returned 1 exit status

$ gcc  -L. simplemain.o-lbar_dep -lfunc_dep

./libfunc_dep.a(func_dep.o): In function 'func':

func_dep.c:(.text+0x14): undefined reference to 'bar'

collect2: ld returned 1 exit status

为了解决这,考虑在链接命令中多次列出一个库是合法的;因此事实上,我们可以在libbar_dep.a的前后给出libfunc_dep.a:

$ gcc  -L. simplemain.o-lfunc_dep -lbar_dep -lfunc_dep

$ ./a.out ; echo $?

24

另一个小测试:相同的伎俩——两次给出-lbar_dep奏效吗?为什么?

使用链接器标记来控制过程

正如我上面提到的,链接器有若干有趣的标记可以一个细致的方式用来控制这个过程。例如,循环依赖问题可以容易地使用—start-group与—end-group解决。下面是来自man ld的指引部分:

--start-group archives--end-group

反复查找指定的文件(archives),直到没有新的未定义引用被创建。正常地,仅以在命令行上指定的次序查找一个文件一次。如果需要这个文件中的一个符号来解析一个由命令行上稍后出现的文件中的一个对象援引的未定义符号,链接器将不能解析该引用。通过聚合文件,它们可以被反复查找,直到所有可能的引用被解析。

使用这个选项有显著的性能代价。最好仅当两个或多个文件间的循环引用不可避免时使用它。

下面展示了这如何能帮助我们:

$ gcc simplemain.o -L. -Wl,--start-group -lbar_dep -lfunc_dep-Wl,--end-group

$ ./a.out ; echo $?

24

注意上面摘要中“显著性能代价”的注解。这解释了为什么链接过程就是那样。可以假定,链接器只是重新扫描整个库列表,直到没有新符号得到解析。这将消除现实中大多数循环依赖,以及链接次序问题,但它也将是慢的。链接已经成为大系统编译时间的一个关键部分,因为它查看整个程序,要求相当多的内存。对行为良好的程序(得到正确的链接次序)使得它尽可能快,对困难的循环依赖情形提供像groups这样特殊的选项,这会更好。

有至少另一个链接标记可以帮助我们解析这里的循环依赖。我们可以使用—undefined标记告诉链接器——“兄弟,这里有一个符号我希望你加到未定义列表里”。在我们的情形里,这使得链接错误得以消除,即使仅指定这些库一次:

$ gcc simplemain.o -L. -Wl,--undefined=bar -lbar_dep -lfunc_dep

$ ./a.out ; echo $?

24

弄清这如何工作留给读者作为练习。

回到原来的例子

让我们回到本文开始的那个例子。Main假定它从C库得到正确的memcpy,但它链接的memcpy行为别异。假定这里的memcpy被打包入libstray_memcpy.a库:

$ gcc  -L.main_using_memcpy.o -lstray_memcpy

$ ./a.out

Segmentation fault (core dumped)

这是期望的行为。因为在命令行上-lstray_memcpy跟在main_using_memcpy.o之后,它被链接。但如果翻转次序,会发生什么呢:

$ gcc  -L. -lstray_memcpymain_using_memcpy.o

$ ./a.out ; echo $?

5

程序正确地链接及工作。这个的原因是简单的:即使我们没有显式要求它,gcc请求链接器照样链接C库。Gcc的完整链接器调用命令相当复杂,可以向gcc传入-###标记来查看。不过在我们的情形里,这相当于:

$ gcc  -L. -lstray_memcpymain_using_memcpy.o -lc

当链接器看到-lstray_memcpy时,符号表还没有memcpy的一个未定义项,因此带有错误函数的目标文件不会得到链接。链接器仅在它看到main_using_memcpy.o之后添加这个未定义项。因此,当它到达-lc时,持有来自C库memcpy的目标文件得到链接,因为现在memcpy在未定义列表中。

结论

链接器用来解决目标文件与库之间符号的算法相当简单。只要你记住,链接器错误与相关问题应该不难理解。如果你仍然碰到你无法确定如何解决的情形,本文提到了在调试这样的问题中很有用的两个工具:一个是nm,它显示一个目标文件或整个库的符号表。另一个是gcc的-###标记,它展示它传递给底下工具的完整命令。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  linker 链接