chuaner

本文内容基于《CSAPP》第7章,只是符号解析的一部分,从使用的角度阐述了静态库的由来和使用,仅仅是个人见解,可能从编译的角度看有不严谨的地方,如发现错误,还请指正,谢谢!

1 静态库

首先我们要知道,链接器将一组可重定位目标文件链接起来可以组成一个可执行文件,如

$ ld -o prog ./a.o   ./b.o

但对于一些基础的操作,如C标准库中提供的printf、scanf、rand等一些列常用的函数,如果每次编译,我们都要操作带有这些函数的可重定位目标文件,那么一次简单的编译过程就会变成下面这样:

$ gcc -o a.out main.c /usr/lib/printf.o   /usr/lib/scanf.o /usr/lib/rand.o ...

这样一来,不仅每次都要编写冗长的命令行,而且程序员还必须维护一个包含所需的源文件或目标文件的文件夹。

但实际上,我们在编译我们的程序时,并没有考虑过这样的问题,对于一个仅仅使用了标准库中函数的源文件而言,也并不需要程序员手动的进行额外的链接操作。如对于下面main.c这个源文件而言,

// main.c
#include<stdio.h>

int main()
{
    printf("Hello World!");
    return 0;
}

我们只需要简单的执行

$ gcc -o a.out main.c

这是因为,标准库中的函数都被编译成了独立的目标模块,然后相关模块会被封装成一个单独的静态库文件,如libc.a包含了C标准库中的标准I/O、字符串操作等函数,libm.a包含了C标准库中的整数数学函数,在执行链接操作时,编译器的驱动程序会将这些标准静态库传送给链接器,链接器会从中选择适当的模块同我们自己编写的目标模块(main.o)链接起来得到可执行文件。

在Linux系统中,静态库以一种称为存档(archive)的文件格式存储,后缀名.a,它由一个头和一系列的目标模块构成,头负责描述每个成员目标模块的位置和大小。

2 使用静态库

既然有标准库,那我们也可以把自己编写的函数、全局变量、宏等封装成静态库。

例如我们实现两个自定义的整型操作函数,分别定义在下面两个源文件中,

// add.c
int add(int a, int b){
    return a+b
}
// sub.c
void sub(int a, int b){
    return a-b;
}

创建静态库需要使用AR工具,使用以下命令:

$ gcc -c add.c  sub.c
$ ar rcs libcal.a  add.o sub.o

如此便得到了一个静态库libcal.a,在源文件中引用,即可使用静态库中定义的符号(非static函数、全局变量等)。

// main2.c
#include "cal.h"

int main()
{
    int a = 0, b = 3, c = 0;
    c = add(a, b);
    printf("%d", c);
    return 0;
}

编译该源文件,

$ gcc -c main2.c
$ gcc -static -o prog2c main2.o 

或者等价地使用,

$ gcc -c main2.c
$ gcc -static -o prog2c main2.o -L. -lcal

链接器运行时,它就会判定main2.o引用了add.o定义的add符号,所以复制add.o到可执行文件,此外,他也会从/usr/lib/libc.a中复制printf所在的目标文件到可执行文件。

3 链接器如何使用静态库来解析引用

命令行上库和目标文件的顺序非常重要,如果我们对上一条命令做一些小小的改动,使之变为

$ gcc -static -o prog2c ./libcal.a main2.o

这条命令的执行就会报错“undefined reference to 'add'”,之所以出现这样的情况,是链接器解析外部引用的方式导致的。

链接器是按照命令行上从左到右的顺序来扫描文件的,在扫描文件时,链接器会维护三个集合:E(这个集合中的文件会被合并起来形成可执行文件)、U(未解析的符号)以及D(在前面输入文件中已定义的符号集合),三个集合初始为空。

  • 对于命令行上的每个文件f,链接器会首先判断这一文件是目标文件还是静态库文件。若该文件是一个目标文件,则放入E中,并修改U和D来反映f中的符号定义和引用。
  • 但如果f是一个静态库文件,那么链接器就试图对U中未解析的符号和f的成员所定义的符号进行匹配。如果f中的某一成员m定义了一个符号来解析U中的一个引用,那么就将m加入E中,再相应地修改U和D中的内容来反映m中的符号定义和引用,对f中的所有成员逐个进行匹配操作直至U和D不再发生变化,连接器便开始处理下一个文件。
  • 当链接器扫描完所有命令行中的文件后,若U是空的,那么连接及就会合并和重定位E中的文件,得到一个可执行文件;否则,链接器就会报错并终止。

现在,是不是理解了上面的错误了呢,链接器扫描到libcal.a时,U中尚是空的,故直接继续扫描后面的main2.o,然后,main2.o中的add符号未解析,被加入到U中,随后,结束扫描,U中非空,链接器报错。

需要注意的是,库和库之间也可能存在依赖关系,故使用多个库时要注意其先后顺序,若存在相互依赖的关系,则可以选择在命令行上重复库,如下面一条命令中,libx.a调用了liby.a中的函数,liby.a又调用了libx.a中的函数,

$ gcc foo.c libx.a liby.a libx.a

当然,把两者合并为单独的一个静态库也不失为一种好方法。

相关文章: