静态链接中的包顺序

原文地址:https://eli.thegreenplace.net/2013/07/09/library-order-in-static-linking
首先我们来看一个范例:

volatile char src[] = {1, 2, 3, 4, 5};
volatile char dst[50] = {0};

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

int main(int argc, const char* argv[])
{
    memcpy(dst, src, sizeof(src));
    return dst[4];
}

这段代码会如我们所愿返回5。如果假设这段代码是某个大型项目的一部分,二这个大型项目又包含了一些其他的库,其中一个库包含了下面的这样一段代码:

void memcpy(char* aa, char* bb, char* cc) {
    int i;
    for (i = 0; i < 100; ++i) {
        cc[i] = aa[i] + bb[i];
    }
}

如果之前的那段代码与这个库链接在一起,将会发生什么呢?仍然返回5还是返回其他值,或者是闪退。答案是:看情况,有可能返回正确的值,或者是段错误。这取决于项目中的对象与库在链接器中被处理的顺序。如果你完全理解了为什么这需要取决于链接顺序,以及如何避免这种问题(有时候是一些更严峻的问题,如循环引用),那么你就可以跳过这篇文章了。

基础知识

首先澄清一下,本文的所有示例都是使用Linux上的gcc和binutils toolchain,对于clang也同样适用;本文仅仅针对编译和链接时刻的静态链接过程进行讨论。
为了理解为什么链接顺序如此重要,首先就要知道链接器是如何工作的。第一个概念就是一个对象文件会导出两张符号表,exported符号表(供其他对象和库使用),imported符号表,表示引用了哪些其他的对象或库。在C语言中,如:

int imported(int);

static int internal(int x) {
    return x * 2;
}

int exported(int x) {
    return imported(x) * internal(x);
}

这里特意使用两种符号表的名字来命名函数名,编译后可以看到如下的符号表:

$ gcc -c x.c
$ nm x.o
000000000000000e T exported
                                 U imported
0000000000000000 t internal

这里exported是external符号,在对象文件中定义,对外部可见;imported是未定义的符号,也就是说需要链接器从某些地方找到他们。在接下来我们讨论的链接器工作流程中,“为定义”符号就是用来表示需要链接器从某处找到他们的符号。internal表示在对象哪定义但是外部不可见。
一个库文件就是一堆对象文件的集合,创建一个库文件的过程,就是把一堆的对象文件放到一起,别无其他。

链接过程

链接命令

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

c或者c++的链接通常是通过编译器gcc来驱动的,因为gcc知道如何向链接器提供正确的命令行参数,包括支持的库等。
命令行中的参数顺序就是链接器链接的顺序,链接器会做如下的工作:

  • 链接器维护一个符号表,符号表中主要维护了两个列表
    --目前为止所有对象和库所提供的exported符号
    --目前为止所有对象和库需要用到的未定义符号
  • 当链接器链接一个新的对象文件的时候
    -- 该文件生成的exported符号被添加到上面提到的exported符号表中,如果未定义符号表中有相同的符号,那么从为定义符号表中删除,因为现在它已经被找到了。如果在exported符号表中已经存在该符号,那么会得到一个“重复定义”的错误,不同的对象生导出了相同的符号,链接器无法工作
    --该文件需要的imported符号中,那些无法从现有的exported符号表中找到的符号,会被添加到未定义符号表中
  • 当链接器链接一个新的库文件的时候,情况会有所不同。链接器会遍历库中的所有文件,针对每一个文件执行下面的动作
    -- 如果该文件的exported符号中的任何一个在未定义符号表中可以被找到,那么该对象被链接,并执行如下的步骤
    -- 如果对象被链接,那么将会按照单个对象文件的流程,将它的未定义和exported符号添加到符号表中
    -- 最后,如果库中的任何一个文件被链接了,那么将会重新扫描该库,因为库中的某个文件所需要的未定义符号可能正好是库中其他的文件所生成的exported 符号,只不过第一次扫描的时候,因为文件顺序的问题,该对象被跳过了(因为在未定义符号表中还未出现它),没有被链接
    当所有的链接完成后,链接器会检查符号表,如果在未定义表中还有未被链接的符号,那么链接器会抛出一个“未定义”错误。例如,当你创建了一恶搞可执行程序,但是却忘了包含main方法,那么你就会得到如下报错:
/usr/lib/x86_64-linux-gnu/crt1.o: In function '_start':
(.text+0x20): undefined reference to 'main'
collect2: ld returned 1 exit status

这里需要注意的是,当链接器对某个库工作了之后,就不会再管他了。就算它本可以导出被其他库需要的符号。链接器重新扫描库文件的情况就只有一种,就是上面提到的,库中有文件被链接到程序中的时候,库中的所有其他文件都会被重新扫描一遍。当然, 向链接器传入不同的flag参数可以修改默认的流程,后面会再讲。
另外需要注意的一点事,当库中的某个对象文件所导出的exported符号已经在符号表的exported列表中存在的时候,这个文件是会被略过不被链接的。这是静态链接中非常重要的一点。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编译成一个静态库

$ 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的时候,发现他的exported的符号未func,但是符号表中并不需要这个符号,因此这个对象文件就不会被链接到程序中。后面当链接器搜索simplemain.o的时候,发现了需要func符号,将它添加到未定义符号表中,此时链接器链接完所有的文件,发现仍存在未定义符号,于是报错。
在正常工作的顺序下,simplemain.o掀背处理,func被添加到未定义符号表中,然后链接静态库的时候,发现simplefunc.o导出的func符号正好是在未定义符号表中。
这里我们看到,在链接的过程中非常重要的一条准则

  • 如果库a需要库b中的符号,那么在链接命令的参数中,a应该出现在b之前

循环依赖

虽然上面的准则非常简单,但是在现实中,a与b相互依赖的情况还是非常常见的,那么此时应该怎么办呢?是否可以在参数列表中让a同时出现的b的前面和后面呢?
来看如下的两个文件:

$ 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

如果反过来就ok:

$ gcc  simplemain.o -L. -lfunc_dep -lbar_dep
$ ./a.out ; echo $?
4

按照上面的流程,这解释得通。然后将这个例子再复杂一点:

$ 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

这个时候可以在参数列表中重复提供参数,来保证所有的符号被找到:

$ gcc  -L. simplemain.o -lfunc_dep -lbar_dep -lfunc_dep
$ ./a.out ; echo $?
24

通过flag控制链接过程

之前提到过可以通过flag参数来控制链接的过程。例如针对互相依赖的情况,可以通过--start-group和--end-group参数(man ld中对这两个参数的解释):

--start-group archives --end-group

The specified archives are searched repeatedly until no new undefined references are created. Normally, an archive is searched only once in the order that it is specified on the command line. If a symbol in that archive is needed to resolve an undefined symbol referred to by an object in an archive that appears later on the command line, the linker would not be able to resolve that reference. By grouping the archives, they all be searched repeatedly until all possible references are resolved.

Using this option has a significant performance cost. It is best to use it only when there are unavoidable circular references between two or more archives.

针对上面的例子:

$ gcc simplemain.o -L. -Wl,--start-group -lbar_dep -lfunc_dep -Wl,--end-group
$ ./a.out ; echo $?
24

注意到上面的"significant performance cost"警告,这也是为什么默认流程不支持互相引用的原因。如果让链接器反复重新扫描库文件,直到不会导出新的exported符号为止,这回非常影响效率。因为链接是编译程序中非常重要的一个环节,他针对整个程序同时还非常消耗内存,因此最好是能够在大部分情况以最高效的方式完成,针对特殊情况使用参数的形式来处理。
针对循环引用的情况,还可以通过--undefined标记来告诉链接器,我想要将它添加到未定义列表中,这样可以在只提供一次库参数的情况下,完成链接:

$ gcc simplemain.o -L. -Wl,--undefined=bar -lbar_dep -lfunc_dep
$ ./a.out ; echo $?
24

回到开始的例子

回到文章开始的例子,假设我们在另外的库libstray_memcpy.a中定义了memcpy方法,同时被链接到程序中

$ gcc  -L. main_using_memcpy.o -lstray_memcpy
$ ./a.out
Segmentation fault (core dumped)

出错是因为-lstray_memcpy在main_using_memcpy.o之后,他被连接到了程序中,但是如果我们将顺序反转一下:

$ gcc  -L. -lstray_memcpy main_using_memcpy.o
$ ./a.out ; echo $?
5

程序运行正常,因为虽然我们没有显示地要求,但是gcc还会让链接器去链接C库。gcc完整的链接触发命令是非常复杂的,可以通过传入-###参数来查看,但是在这种情况下基本上类似于:

$ gcc  -L. -lstray_memcpy main_using_memcpy.o -lc

当链接器遇到-lstray_memcpy的时候,因为此时的未定义符号表中尚未出现memcpy,因此自定义的对象文件不会被链接到程序中,直到处理main_using_memcpy.o的时候,才会发现自己需要memcpy符号,然后在处理-lc的时候,标准库中可以导出memcpy符号的文件会被链接到程序中,因此此时memcpy在未定义符号表中。

总结

链接器处理对象文件和库文件的方式非常简单,只要理解了,那么链接中的很多错误都很好理解。如果还是遇到了无法理解的错误,那么文中提到的两个命令可以帮助你调试问题:nm(查看整个对象文件或库的符号表);gcc的-### flag参数,可以完整的展示出传递给底层的参数

你可能感兴趣的:(静态链接中的包顺序)