linker

链接

链接可以在编译时,也可以是在加载时,更可以在链接时。由于链接器的出现,使得分离编译成为了可能,也就是每次只要修改某个模块的代码就可以重新链接应用,不用重新编译其它代码。

编译器驱动程序

假如有两个源文件:main.c和sum.c

那么要通过这两个源程序生成可执行文件,要经过这些步骤:预处理——编译——汇编——链接。

img

以main.c为例,预处理器会将main.c翻译成一个ascii码的中间文件mian.i。接着运行编译器,生成main.s的汇编语言文件。然后是运行汇编器,将汇编语言翻译成二进制的可重定位目标文件main.o。最后与经过同样步骤生成的sum.o进链接,生成可执行的目标文件。

当shell要执行文件时,shell会调用一个loader的函数,将代码和数据复制到内存,然后将控制权移动到程序的开头。

静态链接

Linux的linker以一组可重定位的目标文件和命令行参数,生成一个可执行文件,这就叫静态链接。

链接器的两个功能:

  • 符号解析:每个符号对应于一个函数,一个全局变量或是一个静态变量;
  • 重定位:由于汇编器和编译器生成的代码都是从0地址开始的,链接器需要将原地址映射到真事的内存位置;

目标文件

三种目标文件:

  • 可重定位目标文件:可与其它的可重定位目标文件进行链接,生成可执行的目标文件;
  • 可执行的目标文件:能直接复制进内存并执行;
  • 共享目标文件:特殊的可重定位目标文件,能够动态地进行链接;

可重定位目标文件

首先来看这种类型的文件具体表示

img

我们逐个section来看:

  • .text:已经编译程序的机器代码;
  • .rodata:只可以读的数据;
  • .data:已经初始化的全局和静态变量;
  • .bss:未初始化的全局和静态变量;
  • .symtab:符号表,存放在程序中函数和全局变量的信息;
  • .rel.text:一个.text节中位置的列表,当与其他文件合并时需要修改;
  • .rel.data:被模块引用或者定义的所有全局变量的重定位信息;
  • .debug:一个调试符号表;
  • .line:源文件的行号与.text杰中机器指令之间的映射;
  • .strtab:字符串表;

符号与符号表

每个可重定位目标文件的模块都会有一个符号表,里面存着本模块定义或者从外部引用的符号,主要分为三类:

  • 由本模块定义并被其它模块引用的全局符号,对应于非静态的全局变量和C函数;
  • 由其它模块定义的并被模块引用的全局符号,对应于在其它模块定义的非静态的全局变量和C函数;
  • 只被本模块定义和引用的符号,对应于带static属性的的全局变量和C函数;

通常情况,利用static属性可以隐藏变量和函数名字,保证只能被本模块使用;

符号解析

链接器符号解析的方法就是将输入的引用与符号表中一个确定的符号定义关联起来。

对于局部静态变量,会有一个本地链接器,确保它们有唯一的名字。

至于全局符号会比较麻烦,有可能抛出错误,也可能通过某个规则选择某个定义。

对于C++和Java的重载而言,编译器会将每个唯一的函数名和参数列表组合编码成对于链接器唯一的符号,这叫mangling。

如何解析多重定义的全局符号

我们先来区分两种类型的符号:强类型和弱类型。这是由编译器输出到链接器的,函数和已经初始化的全局变量是强符号,未初始化的全局变量是弱符号。

如何处理多重定义的符号名:

  • 规则1:不允许有同名的强符号;
  • 规则2:如果有1个强符号和多个弱符号同名;
  • 规则3:如果有多个弱符号同名,那么可以从这些弱符号中任意选择一个;

来看规则2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*foo2.c*/
#include <stdio.h>
void f();
int x = 15213;

int main()
{
f();
printf("%d\n", x);
return 0;
}

/*bar2.c*/
int x;

void f()
{
x = 15213;
}

linux> gcc -o foobar2 foo2.c bar2.c
linux> ./foobar2
15213

再来看规则3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*foo2.c*/
#include <stdio.h>
void f();
int x;

int main()
{
x = 15213;
f();
printf("%d\n", x);
return 0;
}

/*bar2.c*/
int x;

void f()
{
x = 15212;
}

linux> gcc -o foobar2 foo2.c bar2.c
linux> ./foobar2
15213

与静态库链接

静态库:将所有相关的目标文件模块打包成一个单独的可执行文件,它可以作为链接器的输入。这种做法的好处就是只复制静态库里被应用程序引用的目标模块。

例如:libm.a库中定义了一组浮点函数,sin, cos和sqrt;

在Linux系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中,是一组连接起来的可重定位目标文件的集合。以后缀.a标识。

举个与静态库连接的例子:

img

创建一个静态库的方法:

1
2
3
4
5
6
linux> gcc -c addvec.c multvec.c
linux> ar rcs libvector.a addvec.o multvec.o

创建可执行文件
linux> gcc -c main2.c
linux> gcc -static -o prog2c main2.o ./libvector.a

如何使用静态库来解析引用

在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令上的顺序来扫描可重定位目标文件和存档文件。shell会自动将.c文件翻译成.o文件。

对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义是在对s的引用之后的。

假如依赖关系如下:p.o->libx.a->liby.a且liby.a->libx.a->p.o

那么编译命令应该为:gcc p.c libx.a liby.a libx.a

重定位

链接器进行符号解析之后,就要进行重定位了。重定位操作把输入模块进行合并,并且为每个符号分配运行时的地址。主要分为两步:

  • 重定位节和符号定义:把相同类型的节合并成一个节;
  • 重定位节中的符号引用:修改代码节和数据节中对每个符号的引用,使其指向正确的运行时地址;

重定位条目

由于汇编器有可能会遇到不知道数据和代码的内存位置的情况,因此会把信息存储在重定位条目,即代码的重定位条目在.rel.text中,数据的重定位条目在.rel.data中。

img

ELF有基本的两种重定位类型:R_X86_64_PC32(相对地址),R_X86_64_32(绝对地址)

重定位符号引用

迭代地在每个节和每个节相关联的重定位条目执行重定位:

img

可执行目标文件

文件格式

img

ELF头会描述文件的总体格式,还会有一个程序的入口点。.init节中有一个_init的函数来初始化代码。

可执行的目标文件可以轻易加载到内存,并且文件的连续的chunk与内存段有着直接的映射。

加载可执行目标文件

运行命令

1
linux> ./prog

因为prog不是一个内置的命令,所以shell会调用loader去加载运行它,loader将可执行文件的代码和数据从磁盘复制到内存,然后跳转到程序的第一条指令或入口点来运行该程序。

内存映像如下:

img

动态链接共享库

静态链接库的问题是,运行时调用的库函数会被复制到每个运行进程的文本段里面,这里带来的问题就是对内存造成了浪费。

而共享库可以解决这个问题,共享库是一个目标模块,在运行时会加载到任意的内存地址,并和一个在内存的程序链接起来,这是由动态链接器完成的。

img

一个库只有一个.so文件,所有引用该库的可执行目标文件共享这个.so文件的代码和数据,而不是像静态文件那样复制嵌入到它们的可执行文件里面去。

执行指令:

1
2
linux> gcc -shared -fpic -o libvector.so addvec.c multvec.c
linux> gcc -o prog21 main.c ./libvector.so

这里进行了两次链接,先是静态执行一些链接,复制一些重定位和符号表信息;然后是动态链接,但.so文件的代码和数据是不会被复制到可执行文件里的。