程序员的自我修养:装载与动态链接

6 可执行文件的装载与进程

6.1 进程虚拟地址空间

程序被运行起来以后,它将拥有自己独立的虚拟地址空间(Virtual Address Space),这个虚拟地址空间的大小由CPU的位数决定的。硬件决定了地址空间的最大理论上限,比如32位的硬件平台决定了虚拟地址空间的大小是$ 2^{32}$也就是我们常说的4GB虚拟空间大小;而64位的硬件平台具有64位寻址能力,它的虚拟地址空间达到了$2^{64}$,即16384PB大小。

在程序实际运行的过程中,虚拟地址空间也不是全部被程序所使用。如Linux下,操作系统会占据一部分高地址空间,剩下的部分才是分配给应用程序的。

6.2 装载的方式

现在的操作系统装载可执行文件都是使用分页式加载,即把虚拟内存空间分割成固定大小的页面,当实际有代码或数据使用的内存空间时,才会把虚拟的内存空间映射到物理内存中。

6.3 从操作系统角度看可执行文件的装载

6.3.1 进程的建立

从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。很多时候一个程序被执行同时都伴随着一个新的进程的创建 ,那么我们就来看看这种最通常的情形;创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做三件事情:

  • 创建一个独立的虚拟地址空间
  • 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
  • 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行
创建虚拟地址空间

一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构,在x86的Linux下,创建虚拟地址空间实际上只是分配一个页目录(Page Directory)就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置都行。

映射可执行文件到虚拟内存空间

读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。上一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度来看,这一步是整个装载过程中最重要的一步,也是传统意义上“装载”的过程。

跳转到可执行文件入口,运行可执行文件

操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。这一步看似简单,实际上在操作系统层面上比较复杂,它涉及内核堆栈和用户堆栈的切换、CPU运行权限的切换。不过从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令直接跳转到可执行文件的入口地址。ELF文件头中保存有入口地址,CPU也就是从这个地址开始执行代码。

6.3.2 页错误

上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入到内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系。当CPU开始打算执行入口地址的指令时,发现内存所属页面是个空页面,于是它就认为这是一个页错误(Page Fault)。CPU将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。

这时候前面提到的虚拟空间与可执行文件的映射关系结构起到了很关键的作用,操作系统将查询这个数据结构,找到空页面所在的段,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还回给进程,进程从刚才页错误的位置重新开始执行。

随着进程的执行,页错误也会不断地产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求。有时进程所需内存会超过实际可用的内存,这时候系统会把不常用的内存数据转移到磁盘中,来提升内存使用效率。

6.3.2-1

6.4 进程虚拟内存空间分布

6.4.1 ELF文件链接视图和执行视图

在ELF文件中,一般会有很多段,在把段加载到内存中时,会分配单独的内存页来控制段的内存权限。不过很多段都很小,这样单独分配一个内存页太浪费空间。所以处于节省内存的考虑,就在ELF文件中新增了Segment的概念,即把内存属性相似的段合在一起,形成Segment。

Segment的概念实际上是从装载的角度重新划分了ELF的各个分段。在将目标文件链接成可执行文件的时候,链接器会尽可能把相同权限属性的段分配在同一空间。

这样本来以前可能有几十个段,现在合并内存权限相似的同类项,最后只形成几个Segment。这样系统在加载时只用进行有限的几种内存权限控制就行了。

所以总的来说,“Segment”和“Section”是从不同的角度来划分同一个EF文件。这个在ELF中被称为不同的视图(View),从“Section”的角度来看ELF文件就是链接视图(Linking view),从“Segment”的角度来看就是执行视图(Execution view)。当我们在谈到ELF装载时,“段”专门指“Segment”;而在其他的情况下,“段”指是“Section”

ELF文件保存“Segment”信息的数据结构叫做程序头表(Program Header Table)。因为ELF目标文件不需要被装载,所以它没有程序头表;而经过静态/动态链接的ELF的可执行文件以及共享库文件都有。

和段表结构一样,程序头表也是一个数组结构:

1
2
3
4
5
6
7
8
9
10
typedef struct {
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;

具体的字段解释如下:

成员 含义
p_type 这里主要关注“LOAD”类型。它的常量为1。还有“DYNAMIC”等类型(动态链接用)
p_offset 在ELF文件中的偏移量
p_vaddr 在进程虚拟内存空间中的起始位置。整个程序头表中,所有“LOAD”类型的元素按照p_addr从小到大排列
p_paddr 物理装载地址。一般和p_vaddr一样
p_filesz 在ELF文件中所占字节数
p_memse 在进程虚拟内存空间中所占字节数。可能大于p_filesz
p_flags 权限属性,如可写、可读、可执行
p_align 对齐属性。对齐字节数等于$ 2^{align}$。如p_align=10,那对齐字节数就是2的10次方,即1024

对于“LOAD”类型的“Segment”来说,p_memse的值不可以小于p_files。但可以大于p_files。当大于p_files就表示该“Segment”在内存中所分配的空间大小超过文件中实际的大小。这部分“多余”的内存全部填充为“0”。这样做的好处是在构造ELF可执行文件时不需要再额外设立BSS的“Segment”了,可以把数据“Segment”的p_mems扩大,那些额外的部分就是BSS。

6.4.2 堆和栈

Linux把进程虚拟空间中的一个段叫做虚拟内存区域(VMA,Virtual Memory Area)。之前说的Segment都会映射的VMA里。操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间。应用程序使用的栈和堆也是通过VMA来进行管理的。一个常见的进程虚拟空间如下图所示:

6.4.2-1

7 动态链接

7.1 为什么要动态链接

静态链接使得程序可以模块化开发和测试大幅提高开发效率。不过随着程序规模越来越大静态链接的诸多缺点也逐步暴露出来:

  • 内存和磁盘空间浪费

    比如有个流行的库文件LibA,很多软件产品都使用,这些软件产品的最终可执行文件中都会包含LibA。某个用户电脑上安装多个软件产品后,电脑中就会存在多分LibA的副本。而且运行多个程序时,内存中也会存在多个LibA的副本。

  • 程序开发和发布比较麻烦

    如果LibA有bug,厂商发布了补丁以后,必须重新链接。用户需要重新下载整个可执行文件, 但是除了LibA意外,其他的都没有必要再下载一次。

解决空间浪费和更新困难的办法就是把程序的模块相互分割开来,形成独立的文件,当程序要运行时再进行链接。这样库LibA是以独立的文件存在,多个程序可以共享,避免内存和磁盘的浪费;也能独立地发布和更新,提高软件产品的生产效率。上面这种按模块分割、到运行的时候再链接就是动态链接(Dynamic Linking)。

7.3 地址无关代码

7.3.1 固定装载地址的困扰

因为动态链接的库可能被多个程序使用,会被不同的程序加载到不同的位置。所以动态链接文件在编译时不能假设自己在进程的虚拟内存空间中的位置。

7.3.2 装载时重定位

在静态链接时说起过,符号要在编译时进行解析和重定位。这里也可以用同样的做法,即程序在运行时把动态链接库像静态库一样进行解析和重定位,这时的重定位叫做装载时重定位(Load Time Relocation),之前提到的重定位叫链接时重定位(Link Time Relocation)。

装载时重定位技术对于动态链接库的指令部分和数据部分有不同的影响。

对于数据部分来说。每个进程的数据都需要独立存在,所以动态链接库的数据部分在每个进程中都需要一个副本。

对于指令部分来说,从同一个可执行文件运行的进程,其指令部分必然是一样的。所以同一份动态链接库在内存中有多个副本是没有必要的。但是动态链接模块在装载时要进行重定位,需要修改指令引用的地址,A进程重定位后的动态链接库内指令引用的地址已经被修改,以适应A的进程空间里。这时,动态链接库肯定不能用到B上,所以B只能再把动态链接库重新制作一份副本,再进行一次装载时重定位。所以就造成在内存中存在同一份动态库的多个副本。

7.3.3 地址无关代码

要解决动态库指令部分重复的办法关键在于指令的地址。程序模块中共享的指令部分在装载时不要因为装载地址的改变而改变。

解决方案的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PC,Position-independent Code)的技术。

因为模块内的引用可以用模块内的相对地址偏移来实现,比较简单,所以地址无关的代码重点放在模块间的数据和指令的引用。

模块间的数据访问

之前提到,要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面。很显然,这些其他模块的全局变量的地址是跟模块装载地址有关的。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(GOT,Global Offset Table),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。

当指令中需要访问某个模块外的全局变量时,程序会先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址。每个变量都对应一个4个字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。

模块间的函数跳转

对于模块间的函数跳转也可以用GOT的方法,即在GOT表项中保存目标函数地址,当模块要调用函数时,通过GOT间接跳转。

7.3.4 共享模块的全部变量问题

有一种很特殊的情况是,当一个模块引用了一个定义在共享对象的全局变量的时候,比如一个共享对象定义了一个全局变量global,而模块module.c中是这么引用的

1
2
3
4
extern int global;
int foo() {
global = 1;
}

当编译器编译module.c时,它无法根据这个上下文判断global是定义在同一个模块的的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。

假设module.c是程序可执行文件的一部分,那么在这种情况下,由于程序主模块的代码并不是地址无关代码。由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来。为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的“.bss”段创建一个global变量的副本。那么问题就很明显了,现在global变量定义在原先的共享对象中,而在可执行文件的“.bss”段还有一个副本。如果同一个变量同时存在于多个位置中,这在程序实际运行过程中肯定是不可行的。

解决的办法只有一个,那就是所有的使用这个变量的指令都指向位于可执行文件中的那个副本。ELF共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,通过GOT来实现变量的访问。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把GOT中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本里;如果该全局变量在程序主模块中没有副本,那么GOT中的相应地址就指向模块内部的该变量副本。

7.3.5 数据段地址无关性

通过上面的方法,我们能够保证共享对象中的代码部分地址无关,其实数据部分也有绝对地址引用的问题。不过对于数据段来说,它在每个进程都有一份独立的副本,所以并不担心被进程改变。从这点来看,我们可以选择装载时重定位的方法来解决数据段中绝对地址引用问题。

对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,用于解决上述问题。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。

实际上,我们甚至可以让代码段也使用这种装载时重定位的方法,而不使用地址无关代码。不过如果代码不是地址无关的,它就不能被多个进程之间共享,于是也就失去了节省内存的优点。但是装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程。

7.4 延迟绑定(PLT)

动态链接比静态链接灵活,但是这是以牺牲一部分性能为代价的。动态链接比静态链接慢的主要原因是动态链接下要进行复杂的GOT定位以及间接寻址。另外一个原因是动态链接的链接工作在程序启动前完成,在启动前动态链接器会寻找并装载所需要的共享对象,进行符号査找地址重定位等工作,这一套流程下来势会必减慢程序的启动速度。下面就来介绍下优化动态链接性能的一些方法。

延迟绑定实现

在动态链接下,程序模块之间包含了大量的函数引用(全局变量往往比较少,因为大量的全局变量会导致模块之间耦合度变大),所以在程序开始执行前,动态链接会耗费不少时间用于解决模块之间的函数引用的符号査找以及重定位,这也是我们上面提到的减慢动态链接性能的第二个原因。

不过在一个程序运行过程中,可能很多冷门模块的函数在程序执行完时都不会被用到。所以没必要一开始就把所有函数都链接好。因此ELF采用了一种叫做延迟绑定(Lazy Binding)的做法,就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。所以可以大大加快程序的启动速度

ELF使用PLT(Procedure Linkage Table)的方法来实现。PLT为了实现延迟绑定,在GOT间接跳转这个过程中间又增加了一层间接跳转。所有外部函数在PLT中都有一个对应项,调用函数时,会先调用PLT对应项的处理函数,它会先检查GOT中函数指针是否已经绑定,如果没有绑定会进行绑定,把正确的函数地址填入GOT表中,然后再调用GOT中的函数。

7.5 动态链接相关结构

7.5.1 “.interp”段

在Linux下,动态链接器ld.so实际上也是一个共亨对象,操作系统同样通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器之后,就将控制权交给动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址)。当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。

在ELF文件中,有个叫“.interp”的段,专门用来保存可执行文件所需要的动态链接器的路径。

7.5.2 “.dynamic”段

动态链接ELF中最重要的结构就是“.dynamic”段,这个段里面保存了动态链接器所需要的基本信息,比如依赖哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。它的结构如下:

1
2
3
4
5
6
7
typedef struct {
Elf32_Sword d_tag;
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;

EI32Dyn结构由一个类型值加上一个附加的数值或指针,对于不同的类型,后面附加的数值或者指针有着不同的含义。这里列举几个比较常见的类型值:

d_tag类型 d_un含义
DT_SYMTAB 动态链接符号表地址,d_ptr表示“.dynamic”的地址
DT_STRTAB 动态链接字符串表地址,d_ptr表示“.dynstr”的地址
DT_STRSZ 动态链接字符串表大小,d_val表示大小
DT_HASH 动态链接哈希表地址,d_val表示“.hash”的地址
DT_SONAME 本共享对象的“SO-NAME”
DT_RPATH 动态链接共享对象搜索路径
DT_INIT 初始化代码地址
DT_FINIT 反初始化代码地址
DT_NEED 依赖的其他共享对象文件,d_ptr表示所依赖的共享对象文件名
DT_REL / DT_RELA 动态链接重定位表地址
DT_RELENT / DT_RELAENT 动态重定位表入口数量

7.5.3 动态符号表

在静态链接中,有一个专门的段叫做符号表“symtab”(Symbol Table),里面保存了所有关于该目标文件的符号的定义和引用。动态链接的符号表跟它十分相似,叫做动态符号表(Dynamic Symbol Table),用来保存模块间的符号导入导出关系,这个段的段名通常叫做“.dynsym”(Dynamic Symbol)。

与“.symtab”不同的是,“.dynsym”只保存了与动态链接相关的符号,对于那些模块内部的符号则不保存。很多时候动态链接的模块同时拥有“.dynsym”和“.symtab”两个表,“.symtab”中往往保存了所有符号,包括“.dynsym”中的符号。

与“.symtab”类似,动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表。静态链接时叫做符号字符串表“.strtab”(String Table),在这里就是动态符号字符串表”.dynstr”(Dynamic String Table);由于动态链接下,我们需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表(“.hash”)。

7.5.4 动态链接重定位表

共享对象的重定位与静态链接”的目标文件的重定位十分类似,唯一有区别的是目标文件的重定位是在静态链接时完成的,而共享对象的重定位是在装载时完成的。

在静态链接中,日标文件里面包含有专门用于表示重定位信息的重定位表,比如“.rel.text”表示是代码段的重定位表,“.rel.data”是数据段的重定位表。动态链接的文件中,也有类似的重定位表分别叫做“rel.dyn”和“rel.plt’”,它们分别相当于“.rel.text”和“.rel.data”。“rel.dyn”是对数据引用的修正,它所修正的位置位于“got”以及数据段;而“rel.plt”是对函数引用的修正,它所修止的位置位于“.got.plt”。

7.6 动态链接的步骤和实现

7.6.1 动态链接器自举

动态链接器本身也是一个共享对象,但是它有一些特殊性。对于普通共享对象文件来说,它的重定位工作由动态链接器来完成;它也可以依赖于其他共亨对象,其中的被依赖的共亨对象由动态链接器负责链接和装载。可是对于动态链接器本身来说,它是一切动态加载的起源,所以它有一些特殊。

动态链接器本身不可以依赖于其他任何共享对象;其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。对于第一个条件我们可以人为地控制,在编写动态链接器时保证不使用任何系统库、运行库;对于第二个条件,动态链接器必须在启动时有段非常精巧的代码可以完成这项艰巨的工作而同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举(Boot strap)。

动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行。自举代码首先会找到它自己的GOT。而GOT的第一个入口保存的即是“.dynamic”段的偏移地址,由此找到了动态连接器本身的“.dynamic”段。通过“.dynamic”中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始,动态链接器代码中才可以开始使用自己的全局变量和静态变量。

实际上在动态链接器的自举代码中,除了不可以使用全局变量和静态变量之外,甚至不能调用函数,即动态链接器本身的函数也不能调用。因为动态链接库模块使用GOT/PLT的方式编译,对于模块内部的函数调用也是采用跟模块外部函数调用一样的方式,所以在GOT/PLT没有被重定位之前,自举代码不可以使用任何全局变量,也不可以调用函数。

7.6.2 装载共享对象

完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个全局符号表(Global Symbol Table)。然后链接器开始寻找可执行文件所依赖的共享对象,前面提到过“.dynamic”段中,有一种类型的是DT_NEEDED,它所指出的是该可执行文件(或共享对象)所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后链接器开始从集合里取个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和“.dynamic”段,然后将它相应的代码段和数据段映射到进程空间中。如果这个ELF共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止,当然链接器可以有不同的装载顺序,如果我们把依赖关系看作一个图的话,那么这个装载过程就是一个图的遍历过程,链接器可能会使用深度优先或者广度优先或者其他的顺序来遍历整个图,这取决于链接器,比较常见的算法一般都是广度优先的。

当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接所需要的符号。

符号优先级

在加载多个动态链接库时,可能会遇到符号冲突的问题。Linux下的动态链接器规定:当一个符号需要被加入全局符号表时,如果相同符号已经存在,则后加入的符号被忽略。

由于存在这种重名符号被直接忽略的问题,当程序使用大量共享对象时应该非常小心符号的重名问题,如果两个符号重名又执行不同的功能,那么程序运行时可能会将所有该符号名的引用解析到第一个被加入全局符号表的使用该符号名的符号,从而导致程序莫名其妙的错误。

全局符号介入与地址无关代码

前面说到地址无关代码时,对于模块内部调用或跳转处理,只是简单地将其当作是相对地址调用/跳转。但实际上这个问题比想象中要复杂。由于存在全局符号会覆盖的情况,调用或跳转一个模块内的符号时,它可能会被其他的全局符号覆盖,导致那个相对地址部分就需要重定位,这又与共享对象的地址无关性矛盾。所以对于模块内的调用,编译器只能当作模块外部符号处理。

不过也有一个补救办法。就是把函数变成编译单元私有函数,即使用“static”关键字定义函数,这种情况下,编译器要确定函数不被其他模块覆盖,就可以直接用模块内部调用指令,加快函数的调用速度。

7.6.3 重定位和初始化

当上面的步骤完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中的每个需要重定位的位置进行修正。因为此时动态链接器已经拥有了进程的全局符号表,所以修正过程也比较容易,跟前面提到的地址重定位的原理基本相同。

重定位完成之后,如果某个共享对象有“.init”段,那么动态链接器会执行“.init”段中的代码,用以实现共享对象特有的初始化过程,比如最常见的,共享对象中的C++的全局/静态对象的构造就需要通过“.init”来初始化。相应地,共享对象中还可能有“.finit”段,当进程退出时会执行“.finit”段中的代码,可以用来实现类似C++全局对象析构之类的操作

如果进程的可执行文件也有“.init”段,那么动态链接器不会执行它,因为可执行文件中的“.init”段和“.finit”段由程序初始化部分代码负责执行。

当完成了重定位和初始化之后,所有的准备工作就宣告完成了,所需要的共享对象也都已经装载并且链接完成了,这时候动态链接器就将进程的控制权转交给程序的入口并且开始执行。

7.7 显式运行时链接

支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显式运行时链接(Explicit Run-time Linking),有时候也叫做运行时加载。也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。从前面我们了解到的来看,如果动态链接器可以在运行时将共享模块装载进内存并且可以进行重定位等操作,那么这种运行时加载在理论上也是很容易实现的。而且一般的共享对象不需要进行任何修改就可以进行运行时装载,这种共享对象往往被叫做动态装载库(DynamicLoadingLibrary),其实本质上它跟一般的共享对象没什么区别,只是程序开发者使用它的角度不同。

这种运行时加载使得程序的模块组织变得很灵活,可以用来实现一些诸如插件、驱动等功能。当程序需要用到某个插件或者驱动的时候,才将相应的模块装载进来,而不需要从开始就将他们全部装载进来,从而减少了程序启动时间和内存使用。并且程序可以在运行的时候重新加载某个模块,这样使得程序本身不必重新启动而实现模块的增加、删除、更新等,这对于很多需要长期运行的程序来说是很大的优势。最常见的例子是web服务器程序,对于web服务器程序来说,它需要根据配置来选择不同的脚本解释器、数据库连接驱动等,对于不同的脚本解释器分别做成一个独立的模块,当web服务器需要某种脚本解释器的时候可以将其加载进来;这对于数据库连接的驱动程序也是一样的原理。另外对于一个可靠的web服务器来说,长期的运行是必要的保证,如果我们需要增加某种脚本解释器,或者某个脚本解释器模块需要升级,则可以通知web服务器程序重新装载该共亨模块以实现相应的目的。

在Linux中,从文件本身的格式上来看,动态库实际上跟一般的共享对象没有区别,正如我们前面讨论过的。主要的区别是共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都由动态连接器自动完成,对于程序本身是透明的;而动态库的装载则是通过一系列由动态链接器提供的API,具体地讲共有4个函数:打开动态库(dlopen)、查找符号(dsym)、错误处理(dlerror)以及关闭动态库(dlclose),程序可以通过这几个API对动态库进行操作。这几个API的实现是在/lib/libdl.so2里面,它们的声明和相关常量被定义在系统标准头文件

  • dlopen()

    打开一个动态库,并将其加载到进程的地址空间,完成初始化过程。

    如果参数filename为NUL,那返回的就是全局符号表。

  • dlsym()

    通过这个函数来查找符号,包括函数和变量。

    前面在介绍动态链接实现时,已经碰到过许多共享模块中符号名冲突的问题,结论是当多个同名符号冲突时,先装入的符号优先,我们把这种优先级方式称为装载序列。

    当我们的进程中有模块是通过dlopen装入的共享对象时,这些后装入的模块中的符号可能会跟先前已经装入了的模块之间的符号重复。不管是通过动态链接器加载还是程序主动加载,都是采用装载序列。

    dlsym()对符号的查找优先级分两种类型。第一种情况是,如果我们是在全局符号表中进行符号查找,即dlopen()时,参数filename为NUL,那就是和全局符号表生成时的顺序一样,采用装载序列。第二种情况是如果我们是对某个通过dlopen()打开的共享对象进行符号查找的话,那么采用的是一种叫做依赖序列的优先级。它是以被dlopen()打开的那个共享对象为根节点,对它所有依赖的共享对象进行广度优先遍历的顺序。

  • dlerror()

    调用dlopen()、dlsym()、dlclose()后,都可以用dlerror()来检查是否成功

  • dlclose()

    卸载一个已经加载的模块。