为了构造可执行文件,链接器必须完成2个主要任务:
- 符号解析(symbol resolution)。目标文件定义和引用符号,每个符号对应于一个函数,一个全局变量或一个静态变量。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
- 重定位(relocation )。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。
1 符号解析
根据强弱符号的定义,Linux链接器使用下面的规则来处理多重定义的符号名:
- 规则1:不允许有多个同名的强符号。
- 规则2:如果有一个强符号和多个弱符号同名,那么选择强符号。
- 规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
规则1
// foo1.c
int main()
{
return 0;
}
// bar1.c
int main()
{
return 0;
}
$ gcc foo1.c bar1.c
/tmp/cciPFK2b.o: In function `main':
bar1.c:(.text+0x0): multiple definition of `main'
/tmp/ccnEBuRv.o:foo1.c:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status
规则2
// foo3.c
#include <stdio.h>
void f(void);
int x = 15213;
int main()
{
f();
printf("x = %d\n", x);
return 0;
}
// bar3.c
int x;
void f()
{
x = 15212;
}
$ gcc -o foobar3 foo3.c bar3.c && ./foobar3
x = 15212
在运行时,函数f将x的值由15213改为15212。因为链接器通常不会表明它检测到多个x定义。
规则3
// foo4.c
#include <stdio.h>
void f(void);
int x;
int main()
{
x = 15213;
f();
printf("x = %d\n", x);
return 0;
}
// bar4.c
int x;
void f()
{
x = 15212;
}
$ gcc -o foobar4 foo4.c bar4.c
$ ./foobar4
x = 15212
2 与静态库链接
2.1 为什么要使用静态库?
如果不适用静态库,编译器开发人员会使用什么方法来向用户提供这些函数。一种方法是让编译器辨认出对标准函数的调用,并直接生成相应的代码。但是这种方法对C而言不合适,因为C标准定义了大量的标准函数。这种方法给编译器增加显著的复杂性,而且每次添加、删除或修改一个标准函数时,就需要一个新的编译器版本。然而,对于应用程序员而言,这种方法会是非常方便的,因为标准函数将总是可用的。
另一个方法是将所有的标准C函数都放在一个单独的可重定位目标模块中(比如libc.o中)应用程序员可用吧这个模块连接到它们的可执行文件中:
$ gcc main.c /usr/lib/libc.o
这种方法的优点是它将编译器的实现与标准函数的实现分离开来,并且仍然对程序员保存适度的便利。然而,一个很大的缺点是系统中每个可执行文件现在都包含着一份标准函数集合的完全副本,这对磁盘空间是很大的浪费。更糟的是,每个正在运行的程序都将它字节的这些函数的副本放在内存中,这是对内存的极度浪费。另一个大的缺点是,对任何标准函数的任何改变,都要求库的开发人员重新编译整个源文件,这是一个非常耗时的操作,使得标准函数的开发和维护变的很复杂。
我们可以通过为每个标准函数创建一个独立的可重定位文件,把它们存在一个为大家都知道的目录中来解决其中的一些问题。然而,这种方法要求引用程序员显式地链接合适的目标模块到它们的可执行文件中,这是一个容易出错而且耗时的过程:
$ gcc main.c /usr/lib/printf.o /usr/lib/scanf.o ...
静态库概念被提出来,以解决这些不同方法的缺点。相关函数可以被编译为独立的目标模块,然后封装成一个独立的静态库文件。然后,应用程序可以通过在命令行上指定独立的文件名字来使用这些库中定义的函数。比如,使用C标准库和数学库中的函数的程序可以用形式如下的命令行来编译和链接:
$ gcc main.c /usr/lib/libm.a /usr/lib/libc.a
在链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小。另一方面,应用程序员只需要包含较少的库文件的名字(实际上,C编译器驱动程序总是传送libc.a给链接器)。
2.2 创建静态库
/*
* addvec.c
*/
int addcnt = 0;
void addvec(int *x, int *y, int *z, int n)
{
int i;
addcnt++; // 记录自己被调用的次数
for (i = 0; i < n; i++)
z[i] = x[i] + y[i];
}
/*
* multvec.c
*/
int multcnt = 0;
void multvec(int *x, int *y, int *z, int n)
{
int i;
multcnt++; // 记录自己被调用的次数
for (i = 0; i < n; i++)
z[i] = x[i] * y[i];
}
用AR工具创建这些函数的一个静态库:
$ gcc -c addvec.c multvec.c
$ ls
addvec.c addvec.o multvec.c multvec.o
$ ar rcs libvector.a addvec.o multvec.o
$ ls
addvec.c addvec.o libvector.a multvec.c multvec.o
使用这个库:
/*
* vector.h
*/
void addvec(int *x, int *y, int *z, int n);
void multvec(int *x, int *y, int *z, int n);
int getcount();
/*
* main2.c
*/
#include <stdio.h>
#include "vector.h"
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];
int main()
{
addvec(x, y, z, 2);
printf("z = [%d %d]\n", z[0], z[1]);
return 0;
}
为了创建这个可执行文件,我们需要编译和链接输入文件main.o和libvector.a:
$ gcc -c main2.c
$ gcc -static -o prog2c main2.o ./libvector.a
$ ./prog2c
z = [4 6]
或者
$ gcc -c main2.c
$ gcc -static -o prog2c main2.o -L. -lvector
$ ./prog2c
z = [4 6]
上图概括了链接器的行为。-static参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,它可以加载到内存并运行,,在加载时无须更进一步的链接。-lvector参数是libvector.a的缩写,-L.参数告诉链接器在当前目录下查找libvector.a。
当链接器运行时,它判定main2.o引用了addvec.o定义了addvec符号,所以复制addvec.o到可执行文件。因为程序不引用任何由multvec.o定义的符号,所以链接器就不会复制这个模块到可执行文件。链接器还会复制libc.a中的printf.o模块,以及许多C运行时系统中的其他模块。
2.3 如何解析引用
在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序俩扫描可重定位目标文件和存档文件。在这次扫描中,链接器维护一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是未定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。初始时,E,U和D均为空。
- 对于命令行上的每个输入文件
f,链接器会判断f是一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器把f添加到E,修改U和D来反映f中的符号定义和引用,并继续下一个输入文件。 - 如果
f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有的成员目标文件依次进行这个过程,直到U和D都不在发送变化。此时,任何不包含在E中的成员目标文件都简单地被丢弃,二连机器将继续处理下一个输入文件。