理解操作系统7——链接装载和库

操作系统只关心与装载相关的问题:

段的权限(可读、可写、可执行);


装载方案:

ELF文件中相同权限的段放在一起当做一个段进行映射;->这样的话可以减少映射时页面的占用,节约内存空间。

不是每个段的长度都是页长度的整数倍。如果不是,多余的部分也将占用一个页。->这会导致内存的页内碎片,造成内存浪费。

从装载的角度重新划分了ELF各个段,用术语“段”、Segment

这里的段 和 ELF文件结构中的段实际上不太一样,ELF文件结构中的段,也可以叫做节Section

前者是OS对ELF重新划分,后者是ELF文件的结构划分;->为了区分二者,把后者的段叫做节;

OS装载时的段好处是可以明显减少页面内部碎片。从而节约内存空间。

每个ELF文件有描述节的结构,叫做段表
同样的描述段的结构,叫做程序头Program Header;->这个结构描述了ELF文件该如何被操作系统映射到进程的虚拟空间;

进程在运行的时候还需要用到 栈(Stack)、堆(Heap)这样的结构。

实际上也是通过VMA实现的;VMA除了被用来映射可执行文件中的各个节之外,还有上述作用。

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

动态链接:

首先讲一下静态链接的缺点:
1、浪费内存空间;(多个在CPU中运行的程序都引用同一个库,那么这些程序在链接时都要把这个库链接进去,那么在运行时的内存中就有多个该库的副本。就有很大一部分空间被浪费了。)

2、程序开发和发布:如果一个库更新了,所有引用这个库的程序都要重新编译链接一次新库。甚至是微小的改动也要如此。

动态链接就是为了解决上述两个难题(空间浪费、更新困难)而产生的:

  方法就是把程序模块相互分割开来,形成独立的文件。而不是静态地链接在一起。这样就不用对组成程序的目标文件进行链接。等待程序要运行时才链接。

  也就是把整个链接过程推迟到运行过程中进行。

  操作系统加载程序运行时会检查依赖关系,如果依赖关系不满足,就会一直寻找依赖库并加载进来。直到依赖关系完全满足,系统把控制权移交到程序的入口处。

  共享的目标文件只会有一份。

  动态链接可以解决共享的目标文件多个副本的问题。

动态链接还有一个特点:

  程序可以在运行时动态地选择加载各种程序模块。

  这个优点后来被人们用来制作程序的插件。

  能不能直接使用目标文件进行动态链接?

  理论上可行,实际上实现方案稍有差别。

  动态链接情况下的虚拟地址空间会比静态链接更加复杂;

动态链接的基本实现:
  程序与动态链接库的链接工作是由动态链接器完成的。

  当程序被装载到进程的时候,系统的动态链接器会将程序所需要的所有动态链接库装载到进程的地址空间中去。

  并将所有未决议的符号绑定到动态链接库中,并进行重定位工作。

  动态链接相比静态链接在运行性能上会有所损失。

  但是动态链接的链接过程可以进行优化。例如:延迟绑定;使得动态链接的性能损失尽可能减小;

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

源代码经过编译器后产生的文件叫目标文件
多个目标文件链接后产生可执行文件
所以目标文件除了有些符号和地址没有通过链接来调整,其基本格式与可执行文件相似。

目前流行的可执行文件
Windows下的PE(Portable Executable)
Linux下的ELF(Executable Linkble Format)

都是COFF(Common File Format)的变种。

Linux中目标文件就是常见的中间文件 .o
Windows中就是 .obj文件。

中间文件与可执行文件的格式相近,基本可以看做是一种类型的文件。
在Windows下统称为PE-COFF文件格式。
在Linux下,统称为ELF文件。


动态链接库(Windows下的.dll和Linux下的.so)
静态链接库(Wondows下的.lib和Linux下的.a)
都是按照以上格式存储的。
在Windows下的格式都是PE-COFF,Linux下则按照ELF格式存储。

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

装载的过程:

为每个进程定义一个独立的内存空间;
我们必须要定义一个进程所能够访问的合法的地址的范围;
这个范围由CPU的两个寄存器决定:base register基址寄存器、limit register偏移寄存器
如何表示这个内存空间,如何提供这样的保护呢?

Address Binding:
通常一个程序存在于disk中,以二进制可执行文件的形式;
为了要执行该程序,通常要被带入内存中,然后放进一个进程里。

由于内存管理的存在,一个进程在他的执行过程中,可能被放入磁盘或者内存中;
在磁盘中等待被带入内存中去执行的进程形成了input queue,输入队列;

symbolic address -> relocatable address -> linkage editor(bind relocatable address to absolute address)

文件中的代码无法直接执行。必须进行二次编译后才能使用。
使用了lib中的函数后,编译器会将其代码从lib文件中提取出来,然后追加到自己的程序中去。
所以使用lib编译出来的执行程序比dll大一些。但是执行效率会很高。
是典型的空间换时间策略。

程序是由一个个源文件组成的。

源文件 (会包含头文件)
头文件(预编译时进行替换)

.lib 库文件,是编译好的函数代码的集合。
.h 头文件(里面的声明,会告诉程序去哪里寻找函数的定义)
.cpp 源文件 (会去include这些头文件)

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

固定装载地址的困扰&装载时重定位
共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。->共享对象选择固定装载地址的话,会引发地址冲突。
可执行文件可以在编译时可以确定自己的进程在虚拟空间中的位置。->因为可执行文件是第一个被加载的文件,它可以选择一个固定空闲的地址。

装载时的重定位:使共享对象在任意地址装载
1、一种方法是链接时重定位:链接时,不对所有绝对地址的引用重定位,而把这一步推迟到装载的时候完成。一旦装载地址确定,系统就对程序中的所有绝对地址引用进行重定位。
->这个方法有一个很大的缺点:指令部分无法在多个进程之间共享。这样就失去了动态链接节省内存的一大优势。->装载时重定位要修改指令,就无法做到指令部分在多个进程之间共享。
2、PIC技术,地址无关代码:就是当前模块A会引用到模块B中的函数或变量。但是模块B的绝对地址只有在装载时才能确定。这就需要模块A在模块B装载后,进行绝对地址引用的重定位。这样的过程很麻烦。一种方法就是不对模块A的地址引用进行重定位。
实现的基本思想就是把跟指令中与地址相关的部分放到数据段中。ELF的做法就是在数据段里建立一个指向这些变量的指针数组,也被称为全局偏移表GOT。
链接器在装载模块时会查找每个变量所在的地址,然后填充GOT各个项。以确保每个指针指向的地址正确。由于GOT本身是放在数据段中,所以它可以在模块装载的时候被修改。并且每个进程都有独立的副本,确保相互之间不受影响。

数据段地址无关性->使用的是装载时重定位技术
如果代码段不使用PIC技术,而是使用装载时重定位的话,会怎么样?
如果代码段不是地址无关的话,它就不能被多个进程之间共享。也就失去了节省内存的优点。
总之就是失去了共享这一特性;

但是装载时重定位共享对象的运行速度比使用地址无关代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程。
总结来看,如果可执行文件是动态链接的,那么GCC会使用PIC的方法来产生可执行文件的代码段部分,以便于不同的进程能够共享代码,以节省内存。

全局变量的绝对地址引用问题:
因为编译器编译时无法根据上下文判断一个全局变量是定义在同一个模块还是定义在另一个共性对象之中。即无法判断是否为跨模块间的调用;
===========================================================================
动态链接的优劣:(和静态链接比较)
动态链接确实比静态链接要灵活得多。
但是它是以牺牲部分性能为代价的。ELF程序在静态链接的情况下,要比动态库稍微快一些,大约为1%~5%;
动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;
对于模块间的调用也要先定位GOT,然后再进行间接跳转。

另一个减慢运行速度的原因是:动态链接的链接工作是在运行的时候完成的。即程序开始执行的时候;
===========================================================================
动态链接的改进:
延迟绑定 PLT
当函数第一次被用到时才进行绑定;

这样可以提高程序的启动速度;
===========================================================================
动态链接相关的结构:

模块之间函数的或者符号的导入导出关系在静态链接的情况下,可以看作是普通的函数定义和引用;
动态链接的情况下,ELF有专门的一个叫做动态符号表(Dynamic Symbol Table)的段用来保存这些信息。
这个段名通常叫做.dynsym ->这个段与.symtab不同的是,它只保留与动态链接相关的符号。
动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表;->这里叫做动态符号字符串表.dynstr;
为了加快查找符号的过程,往往还有辅助的符号哈希表.hash;

导入符号的地址在运行时才得到确定,所以需要在运行时将这些导入符号的引用修正,即需要重定位。
动态链接的可执行文件使用的是PIC方法,但是不能改变其需要重定位的本质。
对于使用PIC技术的可执行文件或共享对象,代码段不需要重定位,但是数据段还包含了绝对地址的引用;(包含绝对地址引用是无法做到PIC的)
代码段中绝对地址的引用被分离了出来,变成了GOT;GOT 全局偏移表 Golbal Offset Table;
GOT实际上是数据段的一部分,除了GOT之外,数据段还可能包含绝对地址引用。

动态链接的重定位:
静态链接中,目标文件的重定位是在静态链接时完成的;
共享对象的重定位是在装载时完成的。
静态链接中,目标文件里包含了专门用于表示重定位信息的重定位表->.rel.text、.rel.data
动态链接中也有类似的重定位表-> .rel.dyn(对数据)、.rel.plt(对函数)

重定位入口的类型用于表示不同地址的计算方法;
R_386_RELATIVE 这种重定位最麻烦,实际上就是基址重置。
->专门用来重定位指针变量这种类型的;
举个例子:
static int a;
statci int* p = &a;
在编译时,共享对象的地址是从0开始的,现在假设该静态变量a相对于起始地址的偏移是B,即p的值为B。
一旦共享对象被装载在地址A,那么实际上该变量a的地址为A+B,即p的值需要加上一个装载地址A。
所以变量p的值要变成A+B,才是正确的结果。
R_386_GLOB_DAT 是对.got的重定位
R_386_JUMP_SLOT 是对.got.plt的重定位
R_386_32 静态链接的重定位方法
R_386_PC32 静态链接的重定位方法

动态链接的重定位和PIC是紧密相连的。一个可执行文件的所有外部符号的绝对地址引用都指向got表某项。got表中真正保留链接后的绝对地址。
这样的可执行文件就不用在编译和静态链接时确定这些外部符号的绝对地址,实际上也确定不了。
而是在运行时,根据需要把共享对象加载入内存后,确定了外部符号的绝对地址,再去补充got表相应符号的地址。

实际上共享对象的数据段是没有办法做到地址无关的,GOT表就存放在数据段中。数据段可以在装载时被修改。

动态链接时进程堆栈初始化信息:
动态链接器开始工作时,需要知道可执行文件和本进程的一些信息。这些信息往往由操作系统传递给动态链接器,保存在进程的堆栈里面。
这些信息如何组织->辅助信息数组;
===========================================================================
动态链接的步骤和实现:

3步走:
1、启动动态链接器
2、装载共享对象
3、重定位和初始化

动态链接器的自举:
其本身就是一个共享对象,那么它的重定位工作由谁来完成?
所以动态链接器有一些不同于其他普通共享对象的特性 -->就叫做自举
如果做到自举:
其本身不可以依赖任何共享对象。->保证不适用任何系统库和运行库;
其本身所需要的全局和静态变量的重定位工作由它本身完成。->动态链接器在启动时有一段非常精巧的代码可以完成这一艰巨工作,又不能用到全局和静态变量
自举代码如何工作?
不能使用任何全局变量,不可以调用函数。
因为动态链接器使用PIC模式编译共享对象,对于模块内部的调用也是使用GOT/PLT方式。所以GOT/PLT必须重定位后才能使用全局变量及调用函数。

装载共享对象:
不断填充全局符号表,并且根据全局符号表去链接新的共享对象的过程。
依赖关系就像一个图,装载过程就是一个图的遍历过程。

符号的优先级
如果两个不同的模块定义了同一个符号。
处理方式是:当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。

对于模块内部调用或跳转的处理时,有个问题,就是由于全局符号介入的存在。可能会把模块内的函数替换成其他模块的同名函数。
所以该函数就无法使用之前的相对地址调用/跳转的方式。因为介入的符号还没有重定位。
有个解决方法就是把模块内函数变成编译单元私有含税,即使用"static"关键字定义该函数。
这种情况下,编译器要确定模块内函数不被其他模块覆盖,就可以使用模块内部调用指令,加快函数的调用速度。

重定位和初始化:
上述步骤完成之后,编译器开始遍历可执行文件和每个共享对象的重定位表;
将这些需要重定位的位置加以修正;

此外关于初始化,每个共享对象有.init段,动态链接器会执行.init段中的代码,用以实现共享对象特有的初始化过程。
相应的共享对象还有.finit段,当进程退出时会执行,可以用来实现C++全局对象析构之类的操作。
但是不会执行可执行文件的.init段代码,动态链接器不会执行。因为这是由程序初始化部分代码负责执行的。类似C++全局/静态变量的构造。

当以上工作完成之后,所需要的共享对象也都装载动态链接器如释重负
===========================================================================
Linux链接器的实现:

===========================================================================
Linux共享库:
由于动态链接的优点,动态链接机制得到大量使用。这就使得系统中存在数量极为庞大的共享对象。
但是没有很好的方法组织这些共享对象的话,会给维护升级这些共享对象造成很大的问题。
所以操作系统一般会对共享对象的目录组织和使用方法有一定的规则。

共享库的更新:
1)兼容更新:不修改接口;
2)不兼容更新:会改变接口,使得原有用该库的程序运行不正常;

原文地址:https://www.cnblogs.com/grooovvve/p/11605658.html