深入理解计算机系统7——链接

时间:2024-03-06 12:55:20

链接(linking)是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载到存储器并执行。

链接于编译时,也就是源代码被翻译成机器代码时;

链接于加载时,也就是在程序被加载器加载到存储器执行时;

链接于运行时,由应用程序来执行。

传统静态链接、加载时的共享库动态链接、运行时的共享库动态链接;

 

在早期链接是手动执行的;

在现代系统中,链接是由链接器(linker)的程序自动执行的;

链接由链接器默默处理;

 

链接在软件开发中扮演非常重要的角色;它使得分离编译成为可能。

这样我们不用将一个大型应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块。

可以独立地修改和编译这些模块。

当我们改变这些模块中的一个时,只需要简单地重新编译它。

 

学习链接有什么好处?

1、理解链接器可以帮助你构造大型程序

经常遇到缺少模块、缺少库或者不兼容的库版本引起的链接器错误;

必须理解如何解析引用、什么是库、链接器是如何使用库来解析引用; 

2、理解链接器可以帮助避免一些危险的编程错误

Unix链接器解析符号引用时所做的决定回不动声色地影响你程序的正确性;

错误地定义多个全局变量的程序将通过链接器,而不产生任何警告信息; 

3、理解链接帮助你理解语言的作用域规则如何实现

全局和局部变量之间的区别是什么?

当定义一个具有static属性的变量或者函数时,到底实际意味着什么? 

4、理解链接帮助你理解其他重要的系统概念

 链接器产生的可执行目标文件在重要的系统功能中扮演重要角色;

比如加载和运行程序、虚拟存储器、分页和存储器映射;

5、理解链接让你能够利用共享库

 共享库动态链接在现代操作系统中扮演越来越重要的角色,链接成为一个复杂的过程;

===================================================

一、编译器驱动程序

大多数编译系统提供编译驱动程序(complier driver),它代表用户在需要时调用语言预处理器编译器汇编器链接器

 

以两个示例程序为例:

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

 

 1 //swap.c
 2 
 3 extern int buf[];
 4 
 5 int *bufp0 = &buf[0];
 6 int *bufp1;
 7 
 8 void swap()
 9 {
10     int temp;
11 
12     bufp1 = &buf[1];
13     temp = *bufp0;
14     *bufp0 = *bufp1;
15     *bufp1 = temp;
16 }

例如要用GNU编译系统构造示例程序,就要在shell中输入一下命令:(可以用-v来查看详细步骤)

unix>gcc -02 -g -o p main.c swap.c

驱动程序首先运行C预处理器(cpp),它将C源程序main.c翻译成ASCII码的中间文件main.i;

即: cpp [other arguments] main.c  /tmp/main.i

接下来,驱动程序运行C编译器(cc1),它将main.i翻译成一个ASCII汇编语言文件main.s;

即:cc1 /tmp/main.i  main.c -o2 [other arguments] -o /tmp/main.s

然后,驱动程序运行汇编器(as),它将main.s翻译成一个可重定位目标文件(relocatable object file)main.o:

即:as [other arguments]  -o  /tmp/main.o  /tmp/main.s

驱动程序经过相同的过程生成swap.o;

 

最后它运行链接器程序ld,将main.o和swap.o以及一些必要的系统目标文件组合起来,创建一个可执行目标文件(executable object file)p:

ld -o p [system object files ad args]  /tmp/main.o  /tmp/swap.o

 

要运行可执行文件p,可以在Unix的shell的命令行上输入它的名字:

unix> ./p

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

===================================================

二、静态链接

像Unix ld程序这样的静态链接器(static linker)以一组可以重定位目标文件和命令行参数作为输入

生成一个完全链接的可以加载和运行的可执行目标文件作为输出

作为输入的可重定位目标文件由各种不同的代码和数据节(section)组成。

指令在一个节中、初始化的全局变量在另一个节中、未初始化的变量又在另一个节中;

 

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

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

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

 

对于链接器要记住一些基本事实: 

目标文件纯粹是字节块的集合。

链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。

链接器对目标机器了解甚少。

产生目标文件的编译器和汇编器已经完成了大部分工作;

===================================================

三、目标文件

目标文件有三种形式:

1)可重定位目标文件

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

2)可执行目标文件

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

3) 共享目标文件

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

 

从技术上来说,一个目标模块就是一个字节序列;一个目标文件就是一个存放在磁盘文件中的目标模块。

 

各系统之间,目标文件的格式不相同。

贝尔实验室诞生的第一个Unix系统使用的时a.out格式(至今,可执行文件仍然被称为a.out文件);

System V Unix早期版本使用的时一般目标文件格式(Common Object File Format)COFF

Windows NT使用的是COFF的一种变种,叫做可移植可执行(Portable Executable, PE)格式;

现代Unix系统(如Linux)使用的是可执行可链接(Executable and Linkable Format,ELF)格式;

不管是哪种格式,基本概念都是相同的;

===================================================

四、可重定位目标文件

                              图:一个典型的ELF可重定位目标文件

 

ELF头

  以一个16字节的序列开始;这个序列描述了生成该文件的系统的字的大小和字节顺序。

  ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。

  其中包括ELF头的大小、目标文件的类型(可重定位、可执行or共享的)、

  机器类型(IA32)、节头部表的文件偏移,节头部表中的条目大小和数量。

  夹在ELF头和节头部表之间的都是节。

  不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)

   一个典型的ELF可重定位目标文件包含下面几个节:

    .text 已编译程序的机器代码;

    .rodata 已初始化的全局C变量,局部变量在运行时保存在栈中,既不出现在.data节中,也不出现在.bss节中;

    .bss 未初始化的全局C变量,在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分初始化和未初始化仅仅是为了空间效率;在目标文件中,未初始化变量不需要占据任何实际的磁盘空间;

    .symtab 一个符号表,它存放 在程序中定义和引用的函数和全局变量 的信息。和编译器中的符号表不同,.symtab符号表不包含局部变量的条目

    .rel.text  一个.text节中位置的列表,当链接器把这个目标文件和其他文件结合时,需要修改这些位置。  //不清楚

    .rel.data   被模块引用或定义的任何全局变量的重定位信息//不清楚

    .debug 一条调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译驱动程序时才会得到这张表;

    .line 原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译驱动程序时才会得到这张表;

    .strtab 一个字符串表,其内容包括.symtab和.debug节中的符号表

 

===================================================

五、符号和符号表

每个可重定位目标模块m都有一个符号表,它包含m所定义和引用的符号的信息。

在链接器的上下文中,有三种不同的符号;

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

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

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

 

认识到本地链接器符号本地程序变量的不同是很重要的。

.symtab中的符号表不包含对应于本地非静态程序变量的任何符号。

这些符号在运行时在栈中被管理,链接器对此类符号不感兴趣。

 

定义为带有C static属性的本地过程变量是不在栈中管理的。

编译器在.data和.bss中未每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。

int f()

{

  static int x =0;

  return x;

}

int g()

{

  static int x = 1;

  return x;

}

在这种情况中,编译器在.data中为两个整数分配空间,并引出(export)两个唯一的本地链接器符号给汇编器。

比如,它可以用x.1表示函数f中的定义,而用x.2表示函数g中的定义。

编译器把初始化为0的变量放在.bss而不是.data中,所以函数f()中定义的x的实例存储在.bss中而不是.data中。

 

尽可能用static属性隐藏变量和函数名字

  任何声明带有static属性的全局变量或者函数都是模块私有的。

  任何声明为不带static属性的全局变量和函数都是公有的,可以被其他模块访问。

  尽可能用static属性来保护你的变量和函数时很好的编程习惯。

 

符号表是由汇编器构造的;使用编译器输出到汇编语言.s文件中的符号。

.symtab节中包含ELF符号表

 

符号表是一个 条目的数组,每个条目的格式如下:

typedef struct{

  int name;

  int value;

  int size;

  char type:4;

          binding:4;

  char reserved;

  char section;

}Elf_Symbol

接下来解释一下该条目的格式:

  name是字符串表:指向以null结尾的字符串名字;

  value是符号的地址:对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移;对于可执行目标文件来说,该值是一个绝对运行时地址。

  size是目标的大小:以字节为单位;

  type:表示要么是数据,要么是函数;

  binding:表示符号是本地的还是全局的;

  section:每个条目都与目标文件的某个节相关联;该字段也是一个到节头部表的索引

 

有三个特殊的伪节,它们在节头部表中是没有条目的:

ABS代表不该被重定位的符号;

UNDEF代表未定义的符号,也就是本目标模块中引用,但是却在其他地方定义的符号;

COMMON表示还未被分配位置的未初始化的数据目标;

 

下面是main.o的符号表中的最后三个条目,通过GNU READELF工具显示出来。开始的8个条目没有显示出来,它们是链接器内部使用的本地符号。

Num   Value    Size    Type              Bind         Ot   Ndx   Name

8:              0         8     OBJECT      GLOBAL    0       3         buf

9:              0       17     FUNC          GLOBAL     0      1       main

10:            0         0     OBJECT      GLOBAL     0   UND     swap

接下来解释一下这个符号表,Ndx=1表示.text节;Ndx=3表示.data节;例如buf表示的是一个位于.data节中偏移为零(value)处的8字节目标。其后跟随着的是全局符号main的定义,他是一个位于.text节中偏移为零处的17个字节大小的函数。最后一个条目来自对外部符号swap的引用。

 

 

Num   Value    Size    Type              Bind         Ot   Ndx   Name

8:              0         4     OBJECT      GLOBAL    0       3        bufp0

9:              0         0     NOTYPE      GLOBAL     0    UND    buf

10:            0       39     FUNC           GLOBAL     0     1         swap 

11:            4         4     OBJECT      GLOBAL     0   COM     bufp1

全局符号bufp0定义的条目,它是从.data中偏移为0处来时的一个4字节的已初始化目标。

下一个符号来自bufp0的初始化代码中的对外部符号buf的引用。

后面紧随的是一个全局符号swap,它是一个位于.text中的偏移为零处的39字节的函数。

最后一个条目是全局符号bufp1,它是一个未初始化的4字节数据目标,最终当这个模块被链接时它将作为一个.bss目标分配。   

===================================================

六、符号解析

链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义联系起来;

对于那些引用和定义在相同模块中的本地符号的引用,符号解析是非常简单明了的。

编译器只允许每个模块中每个本地符号只有一个定义。

编译器还确保静态本地变量,她们也会有本地链接器符号,拥有唯一的名字。

 

不过,对于全局符号的引用解析就棘手很多。

当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,它会假设该符号实在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。

如果链接器在它的任何输入模块中找不到这个被引用的符号,它就输出一条错误信息并终止。

 

对全局符号的符号解析很棘手,还因为多个目标文件可能会定义相同的符号。

在这种情况中,链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃其他定义。

Unix系统采纳的方法涉及编译器、汇编器和链接器之间的协作,这样也可能给不警觉的程序员带来麻烦。

C++和Java都允许重载方法,这是因为编译器将每个 唯一的方法和参数列表组合  编码成一个 对链接器来说  唯一的名字;

这种编码过程叫做毁坏(mangling)、而相反的过程叫做恢复(demangling)

 

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

在编译的时候,编译器向汇编器输出每个全局符号——要么是强符号、要么是弱符号;

汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。

函数和已初始化的全局变量是强符号、未初始化的全局变量是弱符号。

 

然后Unix链接器使用以下规则来处理多重定义的符号:

1、不允许有多个强符号

2、如果有一个强符号和多个弱符号,那么选择强符号

3、如果有多个弱符号,那么从这些弱符号中任意选择一个

 

错误情况1

/*  fool.c */

int main()

{

  return 0;

}

/*  bar1.c */

int main()

{

  return 0;

}

在这种情况下,链接器将生成一个错误信息,因为强符号main被定义了多次(违反规则1)

 

错误情况2

/*  fool.c */

int x = 15213;

int main()

{

  return 0;

}

/*  bar1.c */

int x = 15213;

intf()

{

  return 0;

}

链接器对于下面的模块也会生成错误信息,因为强符号x被定义了两次(违反规则1)

 

错误情况3

如果在一个模块里x未被初始化,那么链接器将安静地选择定义在另一个模块中的强符号(规则2)

/*  fool.c */

#include <stdio.h>

void f(void);

 

int x =15213;

int main()

{

  f();

  printf("x = %d\n", x);

  return 0;

}

/*  bar1.c */

int x ;

int f()

{

  x = 15212;

}

这种情况下,函数f将x的值由15213改为15212,这会给main函数的作者带来不受欢迎的意外。链接器通常不会表明它检测到多个x的定义;

 

 

错误情况4

如果x有两个弱定义,也会发生相同的事情(规则3)

/*  fool.c */

#include <stdio.h>

void f(void);

 

 

int x ;

int main()

{

  x = 15213;

  f();

  printf("x = %d\n", x);

  return 0;

}

 

/*  bar1.c */

int x ;

int f()

{

  x = 15212;

}

 

 

错误情况5

规则2和规则3也会造成一些不易察觉的错误;对于不警惕的程序员来说,这是非常难以察觉的;

尤其当如果重复的符号定义还有不同的类型时。

考虑下面的例子,其中x在一个模块中定义为int,而在另一个模块中定义为double;

/*  fool.c */

 

#include <stdio.h>

void f(void) ;

int x = 15213 ;

int y = 15212 ;

int main()

{

  f();

  printf("x = 0x%x  y = 0x%x \n", x, y);

  return 0;

}

 

/*  bar1.c */

double x ;

void f()

{

  x = -0.0;

}

例如在一台IA32/Linux机器上,double类型是8个字节,int类型是4个字节,因此bar.c中x = -0.0 将用负零的双精度浮点表示覆盖存储器中x和y的位置。

这是非常细微而令人讨厌的错误,尤其是因为它是默默发生的,编译系统不会给出警告。

而且通常要在程序执行很久之后才发生,且离错误地很远。

在一个拥有几百模块的大型系统中,这类错误相当难以修正。

尤其是许多程序员不知道链接器是如何工作的。

当你怀疑有此类错误时,用像GCC -fno -common这样的选项调用链接器,这个选项会告诉链接器,在遇到多重定义的全局符号时,输出一条警告信息

 

与静态库链接

我们都是假设链接器读取一组可重定位目标文件,并把它们链接起来,成为一个输出的可执行文件。

实际上,所有编译器都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库(library)

静态库可用作链接器的输入,当链接器构造一个输出的可执行文件时,它只拷贝静态库里被应用程序引用的目标模块。

 

将所有的标准C函数都放在一个单独的可重定位目标模块中,应用程序员可以把这个模块链接到他们的可执行文件中:

unix>gcc main.c  /usr/lib/libc.o

这种库的概念的优点是:将编译器的实现和标准函数的实现分离开来,并且仍然对程序员保持便利性。

然而有个很大的缺点是:每个可执行文件都包含着一份标准函数集合的完全拷贝。这对磁盘空间是比较大的浪费。 

不仅仅是空间上的浪费,另一个很大的缺点是,对任何标准函数的改变,即便是微小的改变,都将要求库的开发人员重新编译整个源文件,这是非常耗时的操作,而且使得标准函数的开发和维护变得复杂。

 

当然可以为每个标准函数创建一个独立地可重定位文件,把它们放在一个为大家所熟知的约定好的目录中来解决其中的一些问题;

然而这种方法要求应用程序员显式地链接合适的目标模块到他们的可执行文件中,这是一个很容易出错且耗时的过程;

 

在Unix系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。

存档文件是一组连接起来的可重定位目标文件的集合。有一个头部用来描述每个成员目标文件的大小和位置。

存档文件名由后缀.a标识。

 

假设我们想在一个叫做libvector.a的静态库中提供两个函数addvec.c mulvec.c

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

unix> gcc -c addvec.c mulvec.c

unix> ar rcs libvector.a addvec.p mulvec.o

 

为了使用库,我们编写一个应用main.c,它调用addvec库的例程。

为了创建可执行文件,我们要编译和链接输入文件main.o和libvector.a:

unix> gcc -o2 -c main.c

unix>gcc -static -o p2 main.o  ./libvector.a

其中:-static告诉编译器驱动程序,链接器应构建一个完全链接的可执行目标文件,它可以加载到存储器中运行,在加载时无需更进一步的链接。

当链接器运行时,它判定addvec.o定义的addvec符号是被main.o引用的,所以它拷贝addvec.o到可执行文件。因为程序不引用任何由mulvec.o定义的符号,所以链接器就不会拷贝这个模块到可执行文件。

 

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

虽然静态库是很有用且重要的工具。

但是它们同时也是程序员迷惑的源头。

因为Unix链接器使用它们解析外部引用的方式是令人困惑的

 

在符号解析阶段:链接器从左至右按照它们在编译器驱动程序命令行上出现的相同顺序来扫描可重定位目标文件和存档文件。

链接器维持3个集合:

  可重定位目标文件的集合E

  未解析的符号集合U、即引用了但是尚未定义的符号

  在输入文件中已定义的符号集合D

对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是存档文件。

  如果f是一个目标文件,那么链接器把f添加到E,修改U和D来反映f中的符号定义和引用,并继续下一个输入文件。

  如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。并且链接器修改U和D来反映m中的符号定义和引用。 对于存档文件中的所有的成员目标文件都反复进行这个过程,直到U和D都不再发生变化。此时任何不包含在E中的成员目标文件都被丢弃,而链接器继续处理下一个输入文件。

  如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,从而构建输出的可执行文件。

 

不幸的是,这种算法会导致一些令人困扰的链接时错误,因为命令行上的库和目标文件的顺序非常重要

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

关于库的一般准则是将它们放在命令行的结果。如果各个库成员是相互独立的话,这样做没问题。

但是如果各个库不是相互独立的,那么它们必须排序,使得对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义是在对s的引用之后的。

比如,假设foo.c调用libx.a和libz.a中的函数,而这两个库又调用liby.a中的函数。那么,在命令行中libx.a和libz.a必须处在liby.a之前。

unix> gcc foo.c libx.a libz.a liby.a

===================================================

七、重定位

 一旦链接器完成了符号解析这一步,它就把代码中的每个符号引用和确定的一个符号定义联系起来。

在此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。

现在就可以开始重定位了。

重定位由两步组成:

1)重定位节和符号定义

  链接器将所有相同类型的节合并为同一类型的新的聚合节。

  例如,来自输入模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。

  然后,链接器将运行时存储器地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。

  这一步完成时,程序中的每个指令和全局变量都有唯一的运行时存储器地址了。

2)重定位节中的符号引用

  链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址

  为了执行这一步,链接器依赖于称为重定位条目的可重定位目标模块中的数据结构

  

重定位条目

  当汇编器生成一个目标模块时,它并不知道数据和代码最终将存放在存储器中的什么位置。

  它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。

  所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。

  代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。

 

ELF重定位条目格式:

typedef struct{

  int offset;

  int symbol:24,

     type: 8;

}Elf32_Rel

offset是需要被修改的引用的节偏移;

symbol标识被修改的引用应该指向的符号;

type告知链接器如何修改新的引用;  //重定位类型

 

ELF定义了11中不同的重定位类型,有些相当隐秘。我们只关心其中两种最基本的重定位类型;

R_386_PC32:

  重定位一个使用32位PC相对地址的引用

  当CPU执行一条使用PC相对地址寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址

  PC值通常是存储器中下一条指令的地址。

R_386_32:

  重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。

重定位符号引用

foreach section s{

  foreach relocation entry r{

    refptr = s + r.offset;   /*  ptr to reference to be relocated */

    

    /*   Relocate a PC-relative reference  */

    if (r.type == R_386_PC32) {

      refaddr = ADDR(s) + r.offset;

      *refptr =  (unsigned) (ADDR(r.symbol)+ *refptr -refaddr);

    }

    /*   Relocate a absolute reference  */

    if (r.type == R_386_32)

      *refptr = (unsigned) (ADDR(r.symbol)+ *refptr);

  }

}

                       重定位算法

  1、重定位PC相对引用

   先直接看call指令的重定位形式

    80483ba: e8 09 00 00 00     call  80483c8<swap>

    其中0x80483ba是call指令存放的地址;当CPU执行call指令时,PC的值为0x80483bf,即紧随call指令之后的指令的地址;PC值通常是存储器中下一条指令的地址。

    但是重定位形式中要执行的下一条指令是swap程序中的第一条指令所在处,即0x80483c8

    可以发现0x9 = 0x80483c8 - 0x80483bf;这个0x9就是重定位后的PC相对引用; 

    接下来讨论如何得出这个PC相对引用,*refptr

      *refptr = (unsigned) (ADDR(r.symbol)+*refptr - refaddr)

         = (unsigned) (0x80483+  (-4) - 0x80483bb)

                               = (unsigned) (0x9)

    我们知道原来*refptr的初始值为-4,这是因为对于不同的机器有不同的指令大小和编码方式,该机器的汇编器会使用不同的偏移量,这里是-4,这样就允许链接器透明地重定位引用,很幸运地不用知道某一台机器的指令编码。

    对于ADDR(r.symbol) = ADDR(swap) = 0x80483c8 这是对链接器来说是已知的。这是是什么呢?

    refaddr = ADDR(s)+r.offset = 0x80483b4 + 0x7 = 0x80483bb

    其中ADDR(s) = ADDR(.text) = 0x80483b4 也是链接器已知的,这个是ELF可重定位目标文件.text节的起始地址,即代码段的起始地址。

    重定位条目r,则提供了symbol、offset,即提供了上述公式所需的两个地址。

    offset是引用相对于.text节的偏移量;由此得到引用地址refaddr,然后得到重定位PC相对引用*refptr;

    总之,通过重定位条目就可以让CPU知道下一条指令在哪。

  2、重定位绝对引用

 

首先重新看一段swap.c的代码:

extern int buf[];

 

int *bufp0 = &buf[0];

int bufp1;

...

其中int *bufp0 = &buf[0];bufp0是一个全局指针,被初始化为全局数组buf的第一个元素的地址;

因此bufp0是一个已初始化的数据目标,它将被存放在可重定位目标模块swap.o的.data节中。

因为它被初始化为全局数组的地址,这个地址会变,所以它也需要被重定位。

 

接下来看下.data节的反汇编列表:

00000000 <bufp0>:

     0:        00 00 00 00

        0:R_386_32 buf

bufp0指针的值为0x0,bufp0是一个32位引用;

而且重定位条目告诉链接器,这是一个32位绝对引用

现在链接器已经确定了:ADDR(r.symbol) = ADDR(buf) = 0x8049454;

*refptr = (unsigned) (ADDR(r.symbol) + *refptr)

           = (unsigned) (0x8049454   +   0)

          = (unsigned) 0x8049454

 

由此得到重定位形式:

0804945c  <bufp0>

  804945c:  54 94 04 08  //小端法表示

===================================================

八、可执行目标文件

前面已经看到链接器是如何将多个目标模块合并成一个可执行项目文件;

C语言源程序一开始是一组ASCII文本文件,现在已经被转化成为一个二进制文件,且该二进制文件包含 加载程序在存储器运行它所需的  所有信息; 

 

可执行目标文件的格式类似于可重定位目标文件的格式。

ELF头部描述文件的总体格式。它包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。

.text、.rodata和.data节和可重定位目标文件中的节是相似的;

.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。

因为可执行文件是完全链接的,所以它不再需要.rel节。

 

ELF可执行文件被设计得很容易加载到存储器,可执行文件的连续的片(chunk)被映射到连续的存储器段。

段头部表描述了这种映射关系。

 

段头部表描述如下:

Read only code segment  初始化代码段

LOAD  off      0x00000000   vaddr  0x08048000  paddr   0x08048000   align  2**12

            filesz  0x00000448   memsz 0x00000448 flags r-x

Read/Write data segment  初始化数据段

LOAD  off      0x00000448   vaddr  0x08049448  paddr   0x08049448   align  2**12

            filesz  0x000000e8   memsz 0x00000104 flags rw-

第3和第4行告诉我们第二段被对齐到一个4KB的边界,有读/写许可,开始于存储器地址0x08049448处,总的存储器大小为0x104字节;

并用从文件偏移0x448处开始的0xe8字节初始化;

===================================================

九、加载可执行目标文件

要运行可执行目标文件p,可以在Unix shell 的命令行中输入它的名字:

unix> ./p

因为p不是一个内置的shell命令,所以shell会认为p是一个可执行目标文件。

通过调用某个驻留在存储器(内存)中的加载器(loader)的操作系统代码来运行它。

 

加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一个指令或者叫入口点(entry point)来运行程序。

这个将程序拷贝到存储器并运行的过程叫做加载。 

 

存储器映像://其实我更愿意把它叫成内存映像

每个Unix程序都有一个运行时存储器映像。

 

 

4KB对齐的地址:指的是地址递增值都是0x1000的整数倍,或者说地址都能对0x1000整除;

可以看到上图左侧由下往上,地址为0、0x08048000、0x080449000、0x0804a000,递增都是4KB的整数倍;

 

运行时的:通过调用malloc库往上增长;

 

还有一个段是为共享库保留的;

 

用户总是从最大的合法用户地址开始,向下增长的。

 

看到栈的上部那段空间是操作系统驻留存储器的部分,也就是内核,对用户是不可见。

 

任何Unix程序都可以通过调用execve函数来调用加载器。后续会详细描述这个函数

当加载器运行时,它创建如上图所示的存储器映像。

在可执行文件段头部表的指导下,加载器将可执行文件的相关内容拷贝到代码和数据段

 

接下来加载器跳转到程序的入口点,也就是符号_start的地址

在_start地址处的启动代码是在目标文件ctr1.o中定义的,对所有C程序都是一样的。

 

//每个C程序中启动例程ctrl.o的伪代码

0x080480c0   <_start>     //.text中的入口点

    call  __libc_init_first    //.text中的启动代码

 call _init       //.init只读段的启动代码

    call atext     //.text只读段的启动代码

    call  main    //Application main routine

    call  _exit   //应用程序返回后,启动代码调用_exit程序,将控制权返回给OS

 

至于加载器是如何工作的?

要理解加载器的实际工作,必须理解进程、虚拟存储器、存储器映射的概念。

后续会加以讨论,全面理解加载器。

===================================================

十、动态链接共享库

静态库有一些明显的缺点,静态库和所有软件一样,需要定期维护和更新。

如果程序员想要使用一个库的最新版本,他们必须以某种方式了解库的更新情况。 

然后显式地将他们的程序与更新了的库重新链接

 

另一个问题就是几乎每个C程序都使用标准I/O函数,在运行时,这些函数的代码会被复制到每个运行进程的文本段中。

在一个运行50~100个进程的典型系统上,这将是对稀缺的存储器系统资源的极大浪费。

 

共享库(shared library)是致力于解决静态库缺陷的一个现代创新产物。

共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并和一个在存储器中的程序链接起来。

这个过程称为动态链接(dynamic linking)。是由一个叫做动态链接器(dynamic linker)的程序来执行的。

共享库也称为共享目标(shared object),在Unix系统中通常用.so后缀来表示,微软的操作系统大量地利用了共享库,它们称为DLL(动态链接库)

 

首先,在任何给定的文件系统中,对于一个库只有一个.so文件。

所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库那样被拷贝和嵌入到它们的可执行文件中。

其次,在存储器中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。

后续学习虚拟存储器时将更加详细地讨论这个问题。

接下来讨论动态链接的过程

unix> gcc -shared -fPIC -o libvector.so addvec.c multvec.c

其中-fPIC选项指示编译器生成与位置无关的代码,-shared指示链接器创建一个共享的目标文件;

一旦创建了这个库,我们随后就要将它链接上图的的实例程序中;

unix> gcc -o p2 main2.c  ./libvector.so

这样就创建了一个可执行目标文件p2,而此文件的形式使得它在运行时可以和libvector.so链接。

基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。

认识到这一点很重要,此时没有任何libvector.so的代码和数据真的拷贝到可执行文件p2中。

反之,链接器拷贝了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so中代码和数据的引用。

 

当加载器加载和运行可执行文件p2时,它利用之前讨论过的技术,加载部分链接的可执行文件p2.接着,它注意到p2包含一个.interp节,这个节包含动态链接器的路径名。

动态链接器本身就是一个共享目标。

加载器不再像它通常那样将控制权交给应用,而是加载和运行这个动态链接器。

动态链接器通过执行下面的重定位完成链接任务

1)重定位libc.so的文本和数据到某个存储器段;

2)重定位libvector.so的文本和数据到另一个存储器段;

3)重定位p2中所有对由libc.so和libvector.so定义的符号的引用。

最后,动态链接器将控制权传递给应用程序,从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变;

===================================================

十一、从应用程序中加载和链接共享库

前面的讨论都是有关应用程序执行之前,即应用程序被加载时,动态连接器加载和链接共享库的情景。

然而,在应用程序运行之时,也会要求动态连接器加载和链接任意共享库,而无需编译时链接那些库到应用之中。

 

动态链接是一项强大的技术。

分发软件:应用开发者常常利用共享库来分发软件更新,他生成一个共享库的新版本,然后用户可以下载,并用它替代当前版本。下次运行应用程序时,应用将自动链接和加载新的共享库。

构建高性能Web服务器:现代高性能Web服务器可以使用基于动态链接的方法更有效地来生成动态内容。其思路是讲生成动态内容的每个函数打包在共享库中。当一个来自Web浏览器的请求到达时,服务器动态地加载和链接适当的函数,然后直接调用它。而且在运行时无需停止服务器,就可以更新已存在的函数,以及添加新的函数。

而早期的Web服务器通过fork和execve创建一个子进程,并在子进程的上下文中运行CGI程序来生成动态内容。这样的话函数会一直存在存储器的地址空间中。

 

Linux系统为动态链接器提供了一个简单的接口,允许应用程序在运行时加载和链接共享库

#include <dlfcn.h>

void * dlopen(const char *filename, int flag);

flag参数:RTLD_NOW告诉链接器立即解析对外部符号的引用、RTLD_LAZY指示链接器推迟符号解析直到执行来自库中的代码; 

 

void * dlsym(void * handle, char *symbol);

dlsym函数的输入是一个指向前面已经打开共享库的句柄和一个符号名字,如果该符号存在就返回符号的地址,否则返回NULL;

 

int dlclose (void *handle);

如果没有其他共享库正在使用这个共享库,dlclose函数就卸载该共享库。

 

const char *dlerror(void)

如果前面对dlopen、dlsym或dlclose调用失败,则为错误消息,如果前面的调用成功,则为NULL;

它描述的是调动上述几个函数时发生的最近的错误; 

===================================================

十二、与位置无关的代码

共享库存在的一个重要目的是允许多个正在运行的进程共享存储器中相同的库代码,因而节约宝贵的存储器资源。

多个进程是如何共享程序的一个拷贝的呢?

首先想到的方法是:为每个共享库分配一个事先预备的专用地址空间片(chunk)。 

要求加载器总是在这个地址加载共享库。但是这种方法虽然简单,却造成一些严重的问题:

首先是对地址空间的使用效率不高,即使一个进程都不使用这个库,那部分空间还是会被分配出来。

其次,它难以管理。当一个库修改了,还得确保分配的空间足够大,以防止片的重叠。

随着时间的推移,库越来越多,很难避免地造成了大量小的,碎片化的不能使用的空间小片。

所以必须想一个更好的办法:

就是编译库代码,使得不需要链接器修改库代码就可以在任何地址加载和执行这些代码。

这样的代码叫作与位置无关的代码(PIC Position-Independent Code)

用户对GCC使用-fPIC选项指示GNU编译系统生成PIC代码。

 

PIC数据引用

 

PIC函数调用

 

===================================================

十三、处理目标文件的工具

 Unix系统提供大量可用的工具可以帮助你理解和处理目标文件。

特别地,GNU binutils包尤其有帮助,而且可以运行在每个Unix平台上。

===================================================

十四、小结

链接可以在编译时由静态编译器来完成。

也可以在加载时和运行时由动态链接器完成。

链接器处理的是称为目标文件的二进制文件。

它有三种不同的形式:可重定位的、可执行的和共享的

可重定位的目标文件由静态链接器合并成一个可执行的目标文件。它可以加载到存储器中并执行。

共享目标文件是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序调用dlopen库的函数时。

 

链接器的两个主要任务是符号解析重定位

符号解析是将目标文件中的每个全局符号都绑定到一个唯一的定义;

重定位确定每个符号的最终存储器地址,并修改对那些目标的引用。

 

静态链接器是由像GCC这样的编译驱动器调用的。它们将多个可重定位目标文件合并成一个单独的可执行文件。

多个目标文件可以定义相同的符号,而链接器用来悄悄地解析这些多重定义的规则可能在用户程序中引入的微妙错误。

 

多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。

许多链接器通过从左到右的顺序扫描来解析符号引用,这是另一个引起令人迷惑的链接时的错误来源。

 

加载器将可执行文件的内容映射到存储器,并运行这个程序。

链接器还可能生成部分链接的可执行目标文件,这样的目标文件中有对定义在共享库中的程序和数据的未解析的引用。

在加载时,加载器将部分链接的可执行文件映射到存储器,然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务。

 

被编译为与位置无关的代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。

为了加载、链接和访问共享库的函数和数据,应用程序还可以在运行时使用动态链接器。

 

最后用一张简图来表示各种概念的关系 :