概述

链接(linking)是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(或被拷贝)到存储器并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存存储器并执行时;甚至执行于运行时,由应用程序来执行。

编译器驱动程序

考虑如下的c语言程序,它包含两个源文件:main.c和swap.c。函数main()调用swap交换外部全局数组buf中的两个元素。

main.c

1
2
3
4
5
6
7
8
/* main.c */
void swap();
int buf[2] = {1, 2};
int main()
{
swap();
return 0;
}

swap.c

1
2
3
4
5
6
7
8
9
10
11
12
13
/* swap.c */
extern int buf[];

int *bufp0 = &buf[0];
int *bufp1;
void swap()
{
int temp;
bufp1 = &buf[1];
temp = *bufp0;
*bufp0 = * bufp1;
*bufp1 = temp;
}

如果你对如上的代码有困惑,这说明你对c语言中extern关键字不太理解,关于extern的解析请看这里。我就总结一下吧,函数在声明和定义的时候默认用extern修饰,这一点变量则与函数不同。申明的函数或者变量并未分配内存,只有在定义的时候才分配内存。

大多数编译系统提供编译驱动程序,它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。比如,要用GNU编译系统构造示例,我们就要通过在外壳中输入下列命令行来调用GCC驱动程序:

gcc -O2 -g -o p main.c swap.c

-O2 :更多的优化,会尝试几乎全部的优化功能,但不会进行“空间换时间”的优化方法。

gcc编译源代码时指定-g选项可以产生带有调试信息的目标代码。

下图概括了驱动程序在将示例程序从ASCII码源文件翻译成可执行目标文件的行为。

上图中,cpp为C预处理器,ccl为C编译器,as为汇编器。

执行可执行文件,外壳调用操作系统中一个叫做加载器的函数,它拷贝可执行文件p中的代码和数据到存储器,然后将控制转移到这个程序的开头。

静态链接

像Unix ld程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出。输入的可重定位目标文件由各种不同的代码和数据结组成。指令在一个节中,初始化的全局变量在另一个节中,而未初始化的变量又在另一个节中。

为了构造可执行文件,链接器必须完成两个主要任务:

  • 符号解析。目标文件定义和引用符号。符号解析的目的是将每个符号引用刚好和一个符号定义联系起来。

  • 重定位。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。

要记住关于链接器的一些基本事实:目标文件纯粹是字节块的集合。这些块中,有些包含程序代码,有些则包含程序数据,而其他的则包含指导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。

目标文件

目标文件有三种形式:

  • 可重定位目标文件。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。

  • 可执行目标文件。包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行。

  • 共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态加载到存储器并链接。

编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。从技术上来说,一个目标模块就是一个字节序列,而一个目标文件就是存放在磁盘文件中的目标模块。

各个系统之间,目标文件格式都不相同。下表将列出各个系统的目标文件格式。

系统 目标文件格式
System V Unix早期版本 一般目标文件格式(Common Object File Format, COFF)
Windows 可移植可执行(Portable Executable, PE)格式
现代Unix系统,如Linux 可执行和可链接格式(Executable and Linkable Format,ELF)

可重定位目标文件

上图展示了一个典型的ELF可重定位目标文件的格式。

夹在ELF头和节头部表之间的都是节。下面介绍几个常用的节:

  • .text:已编译程序的机器代码。
  • .rodata:只读数据。
  • .data: 已初始化 的全局C变量。局部C变量在运行时保存在栈中,既不出现在.data节中,也不出现在.bss节中。
  • .bss: 未初始化 的全局C变量。在目标文件中这个节不占据实际空间,它仅仅是一个占位符。目标文件格式区分初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。
  • .symtab。一个 符号表 ,它存放在程序中定义和引用的函数和全局变量的信息。

符号和符号表

每个可重定位目标模块m都有一个符号表,它包含m所定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:

  • 全局符号(global)

由 m 定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的 C 函数以及被定义为不带 C static 属性的全局变量。

  • 外部符号(external)

由其他模块定义并被模块 m 引用的全局符号。这些符号称为外部符号,应对与定义在其他模块中的 C 函数和变量。

  • 本地符号(local)

只被模块 m 定义和引用的本地符号。有的本地链接器符号对应于带 static 属性的C 函数和全局变量。这些符号在模块 m 中随处可见,但是不能被其他模块引用。目标文件中对应于模块 m 的节和相应的源文件的名字也能获得本地符号。

认识到本地链接器符号和本地程序变量的不同是很重要的。.symtab 中的符号表不包含对应于本地非静态程序变量的任何符号。

有趣的是,定义为带有 C static 属性的本地过程变量是不在栈中管理的。相反,编译器在.data 和 .bss 中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。

静态链接库

实际上,所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库(staticlibrary)。它可以用作链接器的输入。当链接器构造一个输出的可执行文件时,它只拷贝静态库里被应用程序引用的目标模块。

在 UNIX 系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀 .a 标识。

为了使我们对库的讨论更加形象具体,假设我们想在一个叫做libvector.a的静态库中提供下图中的向量例程。

1
2
3
4
5
6
7
/** addvec.c **/
void addvec(int *x, int *y, int *z, int n)
{
int i;
for (i = 0; i < n; i++)
z[i] = x[i] + y[i];
}
1
2
3
4
5
6
7
/** multvec.c **/
void multvec(int *x, int *y, int *z, int n)
{
int i;
for (i = 0; i < n; i++)
z[i] = x[i] * y[i];
}

为了创建该库,我们将使用AR工具,具体如下:

gcc -c addvec.c multvec.c

ar rcs libvector.a addvec.o multvec.o

为了使用这个库,我们可以编写一个应用main2.c,它调用addvec库例程。

1
2
3
4
/** vector.h **/
void addvec(int *x, int *y, int *z, int n);

void multvec(int *x, int *y, int *z, int n);
1
2
3
4
5
6
7
8
9
10
11
12
/* 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 -O2 -c main2.c

gcc -static -o p2 main2.o ./libvector.a

下图概括了链接器的行为。

-static参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,它可以加载到存储器并运行,在加载时无需更进一步的链接。当链接器运行时,它判定addvec.o定义的addvec符号是被main.o引用的,所以它拷贝addvec.o到可执行文件。因为程序不引用任何由multvec.o定义的符号,所以链接器就不会拷贝这个模块到可执行文件。链接器还会拷贝libc.a中的printf.o模块,以及许多C运行时系统中的其他模块。

在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。

例如在上面的demo中,gcc -static ./libvector.a main2.c,就会出现如下的错误。

对于链接的其他内容,请参考另一篇文章。


参考资料:

  1. 《深入理解计算机系统》