装载与动态链接
1可执行文件的装载与进程
可执行文件只有装载到内存后才能被CPU执行。早期的程序装载十分简陋,装载的基本过程就是把程序从外部存储器中读取到内存中的某个位置。
历史有过的装载方式包括覆盖装载、页映射。
1.1 进程虚拟地址空间
程序是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件;进程则是一个动态的概念,它是程序运行的一个过程。
每个程序被运行起来以后,都有自己的虚拟地址空间,这个虚拟地址空间的大小由计算机的硬件平台决定,具体地说是由CPU的位数决定的。
1.2 装载的方式
程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的办法是将程序运行所需要的指令和数据全都装入内存,这样程序就可以顺利运行,这是最简单的静态装入方法。但是很多情况下程序所需的内存大于物理内存的数量,这时候就不能将程序完全装入。后来研究发现,程序由局部性原理,所以我们可以将程序最常用的部分驻留在内存中,而将一些不常用的数据存放在磁盘里面,这就是动态载入。
覆盖装入和页映射是两种典型的动态装入方法,都是利用局部性原理。动态装入的思想就是用到哪个模块,就将哪个模块装入内存,不用的暂时不装入,存放在磁盘中。
1.2.1 覆盖装入
例如,一个程序由主模块main,main分别调用模块A和模块B,但是A和B之间不会相互调用。如果我们采用覆盖装入,那么内存中可用这样安排,如图所示:
由于模块A和模块B之间相互调用依赖关系,我们可以把模块A和模块B在内存中“相互覆盖”,即两个模块共享内存区域。如果main使用A,则将A调入内存;当模块main使用B时,由覆盖管理器将B读入内存,覆盖A。
由于跨模块间的调用都需要覆盖管理器,以确保所有被调用到的模块都能够正确地驻留在内存,而且一旦模块没有在内存中,还需要从磁盘或其他存储器读取相应的模块,所以覆盖装入的速度肯定比较慢。
1.2.2 页映射
页映射也不是一下子就把程序的所有数据和指令都装入内存,而是将内存和磁盘中的数据和指令按照“页”为单位划分为若干个页,以后所有的装载和操作的单位就是页。(物理内存直接与磁盘打交道)
例如:
假设程序和指令占8个页。我们将它们编号为p0~p7.物理内存只有F0~F3.很显然不能全部装入内存。
很明显,如果只需要p0、p3、p5和p6这4个页,那么程序就能一直运行下去。但是如果此时需要p4,那么装载管理器必须做出抉择,放弃4个内存页中一个来装载p4.至于选择哪个淘汰,使用不同的淘汰算法(FIFO、LRU)。
1.3 从操作系统角度看可执行文件的装载
上面的页映射的动态装入的方式可以看出,可执行文件中的页可能被装入内存中的任意页。比如程序需要p4时,它可能会被装入F0~F3这4个页中的任意一个。很明显,如果程序使用物理地址直接进行操作,那么每次页被装入时都需要重定位。
1.3.1 进程的建立
一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。一般一个程序被执行都伴随着一个新的进程的创建:
1)创建一个独立的虚拟地址空间
2)读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
3)将CPU的指令寄存器设置成可执行文件的入口地址,启动运行
首先是创建虚拟地址空间。虚拟空间由一组页映射函数将虚拟空间的每个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建函数所需要的相应数据结构。
其次,读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。上面那一步的 页映射关系函数是虚拟空间到物理空间的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生页错误时,操作系统将从物理内 存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才能正常运行。但是很明显的一点是,当操作 系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系,也就是装载的过程。
由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被称为映像文件。
下面考虑将可执行文件映射到虚拟空间。假设我们的ELF可执行文件只有一个代码段.text,它的虚拟地址为0x0804800,它在文件中的大小为0x000e1,对齐为0x1000。由于虚拟存储的页映射都是以页为单位的,所以页对齐。如图所示:
很明显,这种映射只是保存在操作系统中的一个数据结构。Linux中将进程虚拟空 间中的一个段叫做虚拟存储区域(VMA);例如.text就是一个VMA;它在虚拟空间中的地址为0x08048000~0x08049000,它对应的 ELF文件中偏移为0的.text,它的属性是只读的。
将CPU指令寄存器设置为可执行文件入口,启动运行 操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。
1.3.2 页错误
上面的步骤之后,其实可执行文件的真正指令和数据都没有装入内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进行虚存之间的映射关系而已。
例如,程序的入口地址刚好为0x08048000,即刚好是.text段的起始地址。当CPU打算执行这个地址的指令时,发现页面0x08048000~0x08049000是个空页面,于是它就认为这是一个页错误。CPU将控制权转交操作系统,操作系统有专门的页错误处理例程来处理这种情况。操作系统查询VMA数据结构,找到空页面所在的VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还给进程,进程从刚才页错误的位置重新开始执行。
随着进程的执行,页错误也会不断发生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求,如图所示。如果物理页不够,需要进行页面的换入换出
1.4 进程虚存空间分布
1.4.1 ELF文件链接视图和执行视图
前面的例子只有一个.text段,所以只有一个VMA。不过实际上,可执行文件往往不止一个代码段,还有数据段、BSS段等。
当段的数量增多时,如果以页长度对齐进行映射,会浪费很多的空间。
当我们站在操作系统装载可执行文件的角度看问题,我们发现其实它并不关心可执行文件各个段的实际内容,它只关心段的权限(可读、可写、可执行)。ELF文件中,根据权限划分:
1)以代码段为代码的权限为可读可执行段
2)以数据段和BSS段为代表的是可读可写的段
3)以只读数据段为代码的是只读的段
因此,我们对于相同权限的段,把它们合并到一起当做一个段映射。比如.text和.init,它们的权限相同,都是可读可执行,可以将它们合并。
下面以一个例子说明:
#include<stdlib.h>
int main()
{
while(1)
{
sleep(1000);
}
return 0;
}
执行:gcc -o Sectionmapping.elf Sectionmapping.c
使用readelf -S读取段信息:
[[email protected] programer]# readelf -S SectionMapping.elf There are 30 section headers, starting at offset 0x794: Section Headers: [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [ 0] NULL 00000000 000000 000000 00 0 0 0 [ 1] .interp PROGBITS 08048134 000134 000013 00 A 0 0 1 [ 2] .note.ABI-tag NOTE 08048148 000148 000020 00 A 0 0 4 [ 3] .note.gnu.build-i NOTE 08048168 000168 000024 00 A 0 0 4 [ 4] .gnu.hash GNU_HASH 0804818c 00018c 000020 04 A 5 0 4 [ 5] .dynsym DYNSYM 080481ac 0001ac 000050 10 A 6 1 4 [ 6] .dynstr STRTAB 080481fc 0001fc 00004b 00 A 0 0 1 [ 7] .gnu.version VERSYM 08048248 000248 00000a 02 A 5 0 2 [ 8] .gnu.version_r VERNEED 08048254 000254 000020 00 A 6 1 4 [ 9] .rel.dyn REL 08048274 000274 000008 08 A 5 0 4 [10] .rel.plt REL 0804827c 00027c 000018 08 A 5 12 4 [11] .init PROGBITS 08048294 000294 000030 00 AX 0 0 4 [12] .plt PROGBITS 080482c4 0002c4 000040 04 AX 0 0 4 [13] .text PROGBITS 08048310 000310 00016c 00 AX 0 0 16 [14] .fini PROGBITS 0804847c 00047c 00001c 00 AX 0 0 4 [15] .rodata PROGBITS 08048498 000498 00000c 00 A 0 0 4 [16] .eh_frame_hdr PROGBITS 080484a4 0004a4 000024 00 A 0 0 4 [17] .eh_frame PROGBITS 080484c8 0004c8 00007c 00 A 0 0 4 [18] .ctors PROGBITS 08049544 000544 000008 00 WA 0 0 4 [19] .dtors PROGBITS 0804954c 00054c 000008 00 WA 0 0 4 [20] .jcr PROGBITS 08049554 000554 000004 00 WA 0 0 4 [21] .dynamic DYNAMIC 08049558 000558 0000c8 08 WA 6 0 4 [22] .got PROGBITS 08049620 000620 000004 04 WA 0 0 4 [23] .got.plt PROGBITS 08049624 000624 000018 04 WA 0 0 4 [24] .data PROGBITS 0804963c 00063c 000004 00 WA 0 0 4 [25] .bss NOBITS 08049640 000640 000008 00 WA 0 0 4 [26] .comment PROGBITS 00000000 000640 000058 01 MS 0 0 1 [27] .shstrtab STRTAB 00000000 000698 0000fc 00 0 0 1 [28] .symtab SYMTAB 00000000 000c44 000410 10 29 45 4 [29] .strtab STRTAB 00000000 001054 000205 00 0 0 1 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings) I (info), L (link order), G (group), x (unknown) O (extra OS processing required) o (OS specific), p (processor specific)
ELF文件中有一个概念叫做“segment”,一个“segment”包含一个或多个属性类似的“section”。正如我们看到的,.text和.init段合并成一个segment。
上面显示有30个section,我们用如下命令显示segment
[[email protected] programer]# readelf -l SectionMapping.elf Elf file type is EXEC (Executable file) Entry point 0x8048310 There are 8 program headers, starting at offset 52 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000034 0x08048034 0x08048034 0x00100 0x00100 R E 0x4 INTERP 0x000134 0x08048134 0x08048134 0x00013 0x00013 R 0x1 [Requesting program interpreter: /lib/ld-linux.so.2] LOAD 0x000000 0x08048000 0x08048000 0x00544 0x00544 R E 0x1000 LOAD 0x000544 0x08049544 0x08049544 0x000fc 0x00104 RW 0x1000 DYNAMIC 0x000558 0x08049558 0x08049558 0x000c8 0x000c8 RW 0x4 NOTE 0x000148 0x08048148 0x08048148 0x00044 0x00044 R 0x4 GNU_EH_FRAME 0x0004a4 0x080484a4 0x080484a4 0x00024 0x00024 R 0x4 GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4 Section to Segment mapping: Segment Sections... 00 01 .interp 02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame 03 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss 04 .dynamic 05 .note.ABI-tag .note.gnu.build-id 06 .eh_frame_hdr 07
可以看到多个section映射为一个segment。很明显,所有相同属性的section被归类为一个segment,并且映射到同一VMA。
如下图所示:
ELF可执行文件中有一个专门的数据结构叫做程序头表用来保存segment的信息。因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF的可执行文件 与共享库文件都有。
1.4.2 堆和栈
在操作系统中,VMA除了被用来映射可执行文件中的各个segment以外,它还可以有其他作用,操作系统通过使用VMA来对进程的地址空间进行管理。我们知道进程在执行的时候它还需要用到栈、堆空间,事实上它们在进程的地址空间也是以VMA的形式存在。很多情况下,一个进程中的栈和堆分别都有一个对应的VMA。在/proc中可以看到:
我看到进程中有5个VMA,只有前两个是映射到可执行文件的两个segment。另外三个段的文件所在设备主设备号和次设备号及文件节点号都是0,则表示它们没有映射到文件中,这种VMA叫做匿名虚拟内存区域。我们可以看到堆和栈,它们分别为140KB和88KB。
通过前面的例子,我们知道:操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可以划分为如下几种VMA区域:
1)代码VMA,权限可读写、可执行;有映像文件
2)堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展
3)栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展
当我们讨论进程虚拟空间的segment的时候,基本上就是指上面的几种VMA。再看一个例子:
1.4.3 堆的最大申请数量
Linux下虚拟地址空间分给进程本身的是3GB,那么程序真正可以用到的有多少呢?我们知道,一般程序中使用malloc()函数进行地址空间的申请,那么malloc()到底最大可以申请多少内存呢?
事实上,堆空间的分配只受虚拟空间的限制,分配最大可达3GB。
1.4.4 段地址对齐
可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页 映射机制完成的。在映射过程中,页是映射的最小单位。也就是说,我们要映射将一段物理内存和进程虚拟空间之间建立映射关系,这段内存空间的长度必须是 4096的整数倍,并且这段空间在物理内存和进程虚拟地址空间中的起始地址必须是4096的整数倍。
1.4.5 进程栈初始化
我们知道进程刚开始启动的时候,须知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数。很常见的一种做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中(也就是VMA中的stack VMA)。假设系统中有两个环境变量:
HOME=/home/user
PATH=/usr/bin
比如我们运行该程序的命令行是:
./prog 123
并且我们假设栈底地址为0xBF802000,那么进程初始化的堆栈如图所示:
栈顶寄存器esp执向的位置是初始化以后堆栈的顶部,最前面的4个字节表示命令行参数的数量,即“prog”和“123”,紧接着就是这两个参数字符串的指针:后面跟了一个0;接着就是环境变量的字符串指针。
进程在启动以后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给main()函数,也就是我们熟悉的main()函数的两个argc和argv参数,这两个参数分别对应这里的命令行参数数量和命令行参数字符串指针数组。
1.5 Linux内核装载ELF过程简介
当我们在Linux系统的bash下输入一个命令执行ELF程序时,Linux系统时怎样装载这个ELF文件并且执行它的呢?
首先在用户层面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
2 动态链接
静态链接使得不同的程序开发者和部门能够相对独立地开发和测试自己的程序模块,从某种意义上来讲大大促进了程序开发的效率,原先程序的规模也随之扩大。但是静态链接也有很多缺点:
1)内存和磁盘空间
静态链接浪费磁盘和内存和严重。例如:
如图所示Program1和Program2分别包含Program1.o和 Program2.o两个模块,并且他们还共用Lib.o这两个模块。这样Lib.o在磁盘中和内存中都有两份副本。这样当有队列的类似于Lib.o被多 个程序共享的目标文件,这样一部分空间被浪费了。
2)程序开发和分布
空间浪费是静态链接的一个问题,另一个问题是静态链接对程序的更新、部署和发布也会很多麻烦。一旦有程序中任何模块更新,整个程序就要重新链接、发布给用户。
3)动态链接
要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块相互分割开来, 形成独立的文件,而不再将它们静态链接在一起。简单地讲,就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把连接这个过 程推迟到了运行时再进行,这就是动态链接的基本思想。
这种方法解决了目标文件中多个副本浪费和内存空间的问题,可以看到,磁盘和内存中只有一份lib.o,而不是两份。
上面的动态链接方案也使程序的升级变得简单,理论上只要简单地将旧的目标文件覆盖掉,而无需将所有的程序再重新链接一遍。当程序下一次运行的时候,新版本的目标文件会被自动装载到内存并且链接起来,程序就完成了升级的目标。
4)程序可扩展性和兼容性
动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块。但是,动态链接也有一个常见的问题就是,当程序所依赖的某个模块更新后,由于新的模块与旧的模块之前接口不兼容,导致了原有程序无法运行。
5)动态链接的基本实现
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将题目链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个单独的可执行文件。
动态链接涉及运行时的链接及多个文件的加载,必须要有操作系统的支持,因为动态链接的情况想,进程的虚拟地址空间的分布会比静态链接情况下更复杂。在Linux系统中,ELF动态链接文件被称为动态链接库,简称共享对象,一般以".so"结尾。
从本质上讲,普通可执行程序和动态链接库都包含指令和数据,这没有区别。在使用动态链接库的情况下,程序本身被分为了程序主要模块和动态链接库,但实际上它们都可以看作是整个程序的一个模块,所以当我们提到程序模块时可以指程序主模块也可以指动态链接库。
在Linux中,常用的C语言库是运行库glibc。整个系统中只保留一份C语言库的动态链接文件"libc.so",而所有的C语言编写的、动态链接的程序都可以在运行时使用它。当程序在装载的时候,系统的动态连接器会将程序所需要的所有动态链接库装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。
程序与 libc.so之间真正的链接工作是由动态连接器完成的,而不是由我们看到的静态连接器ld完成的。也就是说,动态链接是把链接这个过程从本来的程序装载 前被推迟到了装载的时候。这样做带来的问题会不会很慢呢?的确,动态链接会导致程序在性能的一些损失,但是对动态链接的链接过程可以进行优化,例如使用延 迟绑定等方法。
2.2 简单的动态链接例子
例如:
//Program1.c
#include "Lib.h"
int main()
{
foobar(1);
return 0;
}
//Program2.c
#include "Lib.h"
int main()
{
foobar(2);
return 0;
}
//Lib.h
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif
//Lib.c
#include <stdio.h>
void foobar(int i)
{
printf("Printing from Lib.so %d\\n",i);
}
程序很简单,两个程序的主要模块Program1.c和Program2.c分别调用了Lib.c里面的foobar()函数,使用GCC将Lib.c编译成一个共享对象文件:
gcc -fPIC -shared -o Lib.so Lib.c
这时候我们得到一个Lib.so的文件,这就包含了Lib.c的foobar()函数的共享对象文件。然后我们分别编译链接Program1.c和Program2.c:
[[email protected] programer]# gcc -o Program1 Program1.c ./Lib.so [[email protected] programer]# gcc -o Program2 Progra2.c ./Lib.so
这样我们得到了两个程序Program1和Program2,这两个程序都使用了Lib.so里面的foobar()函数。从Program1的角度看,整个编译链接过程如下:
Lib.c被编译成Lib.so共享对象,Program1.c被编译成 Program1.o之后,链接称为可执行程序Program1.图中链接过程与静态链接不同,这里不会将Program1.o和Lib.o链接到一起, 生成可执行文件Program1。动态链接Lib.o没有被链接进来,链接的输入目标文件只有Program1.o。但是Lib.so也参与了链接过程。
动态链接程序运行时地址空间分布
对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,也就是可执行文件本身。但是对于动态链接,除了可执行文件外,还有它所依赖的共享目标文件。
例如,在Lib.c中加入sleep函数:
#include <stdio.h>
void foobar(int i)
{
printf("Printing from Lib.so %d\\n",i);
sleep(-1);
}
我们看到,整个进程虚拟地址空间中,多出了几个文件的映射。Lib.so与Program1一样,它们都是被操作系统用同样的方法映射至进程的虚拟地址空间,只是它们占据的虚拟地址和长度不同。
我们通过readelf工具来查看Lib.so的装载属性
[[email protected] programer]# readelf -l Lib.so Elf file type is DYN (Shared object file) Entry point 0x360 There are 5 program headers, starting at offset 52 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x00000000 0x00000000 0x004d4 0x004d4 R E 0x1000 LOAD 0x0004d4 0x000014d4 0x000014d4 0x000fc 0x00104 RW 0x1000 DYNAMIC 0x0004ec 0x000014ec 0x000014ec 0x000c0 0x000c0 RW 0x4 NOTE 0x0000d4 0x000000d4 0x000000d4 0x00024 0x00024 R 0x4 GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4 Section to Segment mapping: Segment Sections... 00 .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame 01 .ctors .dtors .jcr .data.rel.ro .dynamic .got .got.plt .bss 02 .dynamic 03 .note.gnu.build-id 04
除了文件类型和可执行文件不同以外,其他的都一样。还有一点是动态链接模块的装载地址都是从地址0x00000000开始的。我们知道这个地址是无效地址,而且最终Lib.so的地址并不是0x00000000,而是0xb7efc000.从这一可以推断,共享对象的最终装载地址在编译时是不确定的,而是在装载时,转载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。
2.3 地址无关代码
2.3.1 固定装载地址的困扰
共享对象在被装载时,如何确定它在进程虚拟地址空间的位置呢?
为了实现动态链接,我们首先会遇到的问题是地址的冲突问题。对于不同的动态链接模块,可能分配了相同的虚拟地址,例如:
对于单个程序来讲,我们可以手工指定各个模块的地址,比如把0x1000到 0x2000虚拟地址分配给模块A,把地址0x2000到0x3000虚拟地址分配给模块B。但是,如果某个模块被多个程序使用,当模块B被程序使用使 用,但是该程序不使用模块A,那么他以为地址0x1000到0x2000是空闲的,所以它分配给了另一个模块C。这样C就和原先的模块A的目标地址冲突 了,任何人以后都不能在同一程序中使用模块A和模块C,因为这两个模块的虚拟地址分配的是一样的,加载到进程的虚拟空间会发生虚拟地址冲突。
所以上述这种静态共享库的目标地址导致了很多问题,除了地址冲突以外,静态共享库的升级也很有问题。
为了解决这个问题,设想可以让共享对象在任意地址加载?这个问题另一种表述方法 是:共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。与此不同的是,可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为可执行文件往 往是第一个被加载的文件,它可以选择一个固定空闲的地址。
2.3.2 装载时重定位
为了能够使共享对象在任意地址装载,我们首先能想到的方法就是静态链接中的重定 位。在链接时,对所有的绝对地址的引用不作重定位,而是把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝 对地址引用进行重定位。假设函数foobar相对于代码段的起始地址是0x100,当模块被装载到0x10000000时,那么确定foobar的地址为 0x10000100.这时候,系统遍历模块中的重定位表,把所有对foobar的地址引用重定位到0x10000100.
前面提到的静态链接重定位,叫做链接时重定位,而现在这种情况称为装载时重定位。
装载时重定位的问题:so文件被load并映射至虚拟空间后,指令部分通常是多个进程间共享的,通常的装载时重定位是通过修改指令实现的(主要是根据情况修改指令中涉及到的地址),所以无法做到同一份指令被多个进程共享(因为指令被重定位后对每个进程来讲是不同的)。这样一来,就失去了动态链接节省memory的一大优势。
为解决此问题,引入了地址无关代码(PIC)的技术,基本思路是把指令中那些需要被修改的部分分离出来,跟数据部分放到一起,这样,剩下的指令就可以保持不变,而数据部分在每个进程中拥有一个副本。
2.3.3 地址无关代码
我们把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据引用,这样就有4种情况:
1)第一种模块内部的函数调用、跳转等
2)第二种是模块内部的数据访问
3)第三种是模块外部的函数调用、跳转等
4)第四种是模块外部的数据访问
类型一 模块内部调用或跳转
因为被调用的函数与调用者都处于同一模块,它们之间的相对位置是固定的。模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。
类型二 模块内部数据访问
任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。
类型三 模块间数据访问
因为模块间的数据访问目标地址要等到装载时才决定,因此它是地址相关的。我们前面 提到使得地面地址无关,基本思想是把跟地址有关的部分放到数据段里面。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表 (GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用,它的基本机制如图:
当要访问b时,程序会先找到GOT,然后根据GOT中变量中变量所对应的项找到变量的目标地址。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不影响。
类型四 模块间调用、跳转
与上面一样,在GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转,基本原理如图:
Q&A
Q:如果一个共享对象lib.so中定义了一个全局变量G,而进程A和进程B都使用了lib.so,那么当进程A改变了这个全局变量G的值时,进程B中的G会受到影响吗?
A:不会。因为当lib.so被两个进程加载时,它的数据段部分在每个进程都有独 立的副本,从这个角度看,共享对象中的全局对象实际上和定义在程序内部的全局变量没什么区别。任何一个进程访问的只是自己的那个副本,而不会影响其他进 程。那么,如果我们把这个问题的条件改成同一个进程中的线程A和线程B,它们是否看得到对方对lib.so中的全局变量G的修改?对于同一进程的两个线程 来说,它们访问的是同一个进程的地址空间,也就是同一个lib.so的副本,所以它们对G的修改,对方都可以看到。
2.4 延迟绑定(PLT)
我们知道动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都 要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先定位GOT,然后进行间接调用;另外一个减慢速度的原因是动态链接工作在运行时完成,即出 现开始执行时,动态链接器都要进行一次链接工作,正如我们知道的,动态链接会寻找并装载所需要的共享对象,然后进行符号查找地址重定位工作。
延迟绑定的实现:
在动态链接下,程序模块间存在大量的函数引用,所以在程序开始执行之前,动态链接 会耗费不少时间。不过可以想象,在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数,如果一开始就把所有函数链接好就 是一种浪费。所以ELF采用了一种叫做延迟绑定的方法,基本的思想是当函数第一次调用时才进行绑定,如果没有就不绑定。
2.5 动态链接的数据结构
动态链接情况下,可执行文件与静态链接情况基本一样。首先操作系统会检查可执行文 件合法性,然后从头部中的“Program Header”中读取每个“Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置。但是静态链接情况下,在那之后操作系统 接着就可以把控制权交给可执行文件的入口地址,然后程序开始执行。
但是在动态链接想,操作系统还不能在装载完可执行文件之后就把控制权交给可执行文件,因为可执行文件中还有很多依赖的共享对象,即还没有跟相应的共享对象中的实际位置链接起来,所以在映射完可执行文件后,操作系统会启动一个动态链接器。
在Linux下,动态连接器ld.so实际上是一个共享对象,操作系统同样通过映 射的方式将它加载到进程地址空间中。操作系统在加载完动态连接器之后,将控制权交给动态连接器的入口地址(与可执行文件一样,共享对象也有入口地址)。当 动态连接器得到控制权之后,它开始一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成后,动态 链接器会将控制权转交给可执行文件的入口地址,程序开始执行。
2.5.1 ".interp"段
动态连接器的位置既不是由系统配置指定,也不是由环境参数指定,而是由ELF可执行文件决定。在动态链接的ELF可执行文件中,有一个专门的段叫做".interp"段。
".interp"中保存了动态链接器的路径。例如,Linux下为/lib/ld-linux.so.2.在Linux中,操作系统在对可执行文件的进行加载的时候,它会去寻找".interp"段指定的路径的共享对象。
2.5.2 ".dynamic"段
类似于".interp"段,ELF中用于动态链接的段。里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。
可以还有readelf -d看".dynamic"段的内容:
2.5.3 动态符号表
为了完成动态链接,最关键的是找出所依赖的符号和相关文件的信息。类似于静态链接 中的符号表".symtab"。为了表示动态链接这些模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表来保存这些信息。与".symtab" 不同的是,".dynamic"只保存了与动态链接相关的符号,对于那些模块内部的符号,这不保存。
我们可以使用readelf工具看ELF的动态符号表和它的哈希表:
2.5.4 动态链接重定位表
共享对象需要重定位的主要原因是导入符号的存在。动态连接下,无论是可执行文件还是共享对象,一旦它依赖于其他共享对象,也就是说有导入的符号时,那么他的代码或数据中就会有对于导入符号的引用。
对于不是PIC模式的,那么需要在装载时被重定位;如果一个共享对象时PIC模式编译的,那么它还是需要在装载时重定位,因为虽然它们的代码段不需要重定位,但是数据段还包含了绝对地址的引用。
动态链接重定位相关结构
共享对象的重定位与静态链接中的类似,只是一个在静态链接时完成,另一个在装载时完成。静态链接存在重定位表,比如,".rel.text"表示代码段的重定位表,".rel.data"是数据段的重定位表。
动态链接的文件中,也有类似的".rel.dyn"和".rel.plt",它们类似于".rel.text"和".rel.data",分别修正".got"以及数据段和函数引用的修正,即".got.plt".
使用readelf看动态链接的重定位表:
2.5.5 动态链接时进程堆栈初始化信息
当操作系统把控制权交给动态连接器,它将开始工作,那么至少它需要知道关于可执行文件和本进程的一些信息,比如可执行文件有几个段、每个段的属性、程序的入口地址等。这些信息由操作系统传递给动态连接器,保存在进程的堆栈里面。
2.6 动态链接的步骤和实现
动态链接的步骤基本上分3步:显示启动动态连接器本身,然后装载所有需要的共享对象,最后是重定位和初始化。
2.6.1 动态链接自举
我们知道动态连接器本身也是一个共享对象,相对于普通共享对象文件来说,它的重定位工作由动态连接器来完成;它也可以依赖于其他共享对象,其中的被依赖的共享对象由动态链接器负责链接和装载。可是对于动态链接器本身,它的重定位工作由谁来完成?
事实上,动态连接器本身不可以依赖于其他任何共享对象;其次是动态连接器本身所需要的全局和静态对象的重定位工作由它本身完成。
动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态连接器 时,动态连接器的自举代码即开始执行。自举代码首先会找到它自己的GOT。而GOT的第一个入口保存的即是".dynamic"段的偏移地址,由此找到了 动态连接器的".dynamic"段。通过".dynamic"中的信息,自举代码便可以获得动态连接器本身的重定位表和符号表等,从而得到动态链接器本 身的重定位入口,先将它们全部重定位。从这一步起,动态连接器才可以开始使用自己的全局变量和静态变量。
2.6.2 装载共享对象
完成动态连接器的自举后,动态链接器将可执行文件和链接器本身的符号表都合并到一 个符号表中,我们称为全局符号表。然后链接器开始寻找可执行文件所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对 象的名字放入到一个装载集合中。然后链接器开始从集合里取一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头 和".dynamic"段,然后将它相应的代码段和数据段映射到进程空间中。
符号优先级
在共享对象的全局符号里,一个共享对象的全局符号覆盖另一个共享对象的全局符号的现象称为共享对象全局符号介入。
关于全局符号介入这个问题,Linux下的处理:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
2.6.3 重定位和初始化
链接器在装载共享对象后,然后遍历每个共享对象的重定位表,将它们的GOT/PLT中的每个需要重定位的位置进行修正。
重定位完成后,如果某个共享对象有".init"段,那么动态连接器会执行".init"段中的代码,用以实现共享对象特有的初始化过程。相应地,如果有".finit"段,当进程退出的时候也会执行".finit"段的代码。
2.6.4 Linux动态链接器实现
Linux下的程序通过execve()系统调用被装载到进程的地址空间。内核在装载完ELF可执行文件以后就返回用户空间,将控制权交给程序的入口。对于不同链接形式的ELF可执行文件,这个程序的入口是有区别的。对于静态链接的可执行文件来说,程序的入口就是ELF文件头里面的e_entry知道的入口;对于动态链接的可执行文件来说,如果这时候把控制权交给e_entry指定的入口地址,那么肯定是不行的,因为共享库还么有装载,也没有动态链接。所以对于动态链接的可执行文件,内核会分析它的动态连接器地址(在".interp"段),将动态连接器映射至进程地址空间,然后将控制权交给动态连接器。
其实Linux的内核在执行execve()时不关心目标ELF文件是否可执行, 它只是简单按照程序表里面描述对文件进行装载然后把控制权交给ELF入口地址(没有".interp"就是ELF文件的e_entry;如果 有".interp"的话就是动态连接器的e_entry)。从这里可以看出,可执行文件和动态连接器实际上没甚区别。
很明显,如果指定的用户入口地址是动态链接器本身,那么说明动态连接器是被当作可执行文件执行。
对于动态连接器本身,有几个问题值得思考:
1)动态连接器本身是动态链接的还是静态链接的?
动态连接器本身应该是静态链接的,它不能依赖其他共享对象,动态连接器本身是用来帮助其他ELF文件解决共享对象依赖问题的,如果它也依赖于其他共享对象,那么谁来帮他解决依赖呢?
2)动态连接器本身必须是PIC的吗?
是不是PIC对于动态连接器来说并不关键,动态连接器可以是PIC的也可以不是,但往往使用PIC会更加简单一些。一方面,如果不是PIC的话,会使得代码段无法共享,浪费内存;另一方面会使ld.so初始化更加复杂。
3)动态连接器可以被当作可执行文件运行,它的装载地址是多少?
ld.so的装载地址跟一般的共享对象没有区别,即为0x00000000.这个装载地址是一个无效的装载地址,作为一个共享库,内核在装载它时会为其选择一个合适的装载地址。
2.7 显式运行时链接
支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显式运行时链接,有时候也叫做运行时加载。也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。
主要通过4个api完成:dlopen、dlsym、dlerror、dlclose,这些api比较简单,具体用法man即可搞定,本文不再详述。
这里需要引起注意的还是符号优先级问题:当进程有模块是由dlopen()显式装入的,这些后装入模块的符号可能会与之前已装入模块间有重名符号,此时,符号冲突如何解决?
实际上,不论是之前由动态链接器装入还是之后由dlopen()装入的共享对象,动态链接器在进行符号解析及重定位时,都是采用装载序列的原则,即先装入的符号优先。
那么,当使用dlsym()进行符号的地址查找时,这个函数是不是也按照装载序列的优先级进行符号查找呢?实际情况是,dlsym()对符号的查找优先级分两种类型:
a. 若在全局符号表中进行符号查找,即dlopen()时,第1个参数filename传入NULL,那么由于全局符号表使用装载序列,此时dlsym()也采用装载序列。
b. 若对某个由dlopen()打开的共享对象进行符号查找,则采用一种叫做依赖序列(dependency ordering)的优先级,它以被dlopen()打开的那个共享对象为root node,对它所有依赖的共享对象做广度优先遍历,直到找到符号为止。