《程序员的自我修养-链接装载与库》是一本值得推荐的书,主要介绍系统软件的运行机制和原理,涉及在Windows和Linux两个系统平台上,一个应用程序在编译、链接和运行时刻所发生的各种事项,包括:代码指令是如何保存的,库文件如何与应用程序代码静态链接,应用程序如何被装载到内存中并开始运行,动态链接如何实现,C/C++运行库的工作原理,以及操作系统提供的系统服务是如何被调用的。
本文主要对书中涉及Linux中程序的变异、链接、装载等核心部分内容进行整理,方便查看。
许多IDE和编译器将编译和链接的过程合并在一起,称为构建(Build),使用起来非常方便。但只有深入理解其中的机制,才能看清许多问题的本质,正确解决问题。
一般的编译过程可以分解为4个步骤,预处理,编译,汇编和链接:
而对于第2步,编译由编译器完成器,编译器是将高级语言翻译成机器语言的一个工具,其具体步骤包括:
如果一个源代码文件中有变量或函数等符号定义在其他模块,那么编译后得到的目标代码中,该符号的地址并没有确定下来,因为编译器不知道到哪里去找这些符号,事实上这些变量和函数的最终地址要在链接的时候才能确定。现代的编译器只是将一个源代码编译成一个未链接的目标文件,最终由链接器将这些目标文件链接起来形成可执行文件。
编译器编译源代码后生成的文件称为目标文件,事实上,目标文件是按照可执行文件的格式存储的,二者结构只是稍有不同。Linux下的目标文件和可执行文件可以看成一种类型的文件,统称为ELF文件,一般有以下几类:
目标文件由许多段组成,其中主要的段包括:
除了这几个常用的段之外,ELF可能包含其他的段,保存与程序相关的信息,如:
ELF目标文件的总体结构如下图所示,其中省去了一些繁琐的结果,把最终的提出出来。
ELF Header |
.text |
.data |
.rodata |
.comment |
.shstrtab |
Section Table |
.symtab |
.rel.text |
以下选取较为重要的进行介绍。
ELF文件头(ELF Header):保存描述整个文件的基本属性,如ELF魔数、文件机器字节长度、数据存储格式等。
段表(Section Header Table):保存各个段的基本属性,是除了文件头之最重要的结构。节选样例内容如下:
[Nr] | Name | Type | Addr | Off | Size | ES | Flg | Lk | Inf | Al |
[1] | .text | PROGBITS | 00000000 | 000034 | 00005b | 00 | AX | 0 | 0 | 4 |
其表示的意义为,下标为1的段是.text段,类型是程序段(PROGBITS包括代码段和数据段),加载地址为0,在文件中的偏移量是0×34,长度为0x5b,项的长度为0(表示该段不包含固定大小的项),标志AX表示该段要分配空间及可以被执行,链接信息的两个0没有意义(不是与链接相关的段),最后的4表示段地址对齐为2^4=16字节。
重定位表:链接器在处理目标文件的时候,需要对目标文件中某些部位进行重定位,即代码段和数据段中那些绝对地址的引用位置,这些重定位信息记录在重定位表里。每个需要重定位的代码段或数据段都会有一个相应的重定位表,如.rel.text是针对”.text”段的重定位表,”.rel.data”是针对”.data”段的重定位表。
字符串表:ELF文件中用到很多字符串,如段名、变量名,因为字符串的长度不固定,用固定的结构来表示它比较困难,一般把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。一般字符串表在ELF中以段的形式保存,常见的有.strtab(字符串表,String Table)和.shstrtab(段表字符串表,Section Header String Table),前者保存如符号名字等普通字符串,后者保存如段名等段表中用到的字符串。
符号表:函数和变量统称为符号,其名称称为符号名。链接过程中关键的部分就是符号的管理,每一个目标文件都会有一个相应的符号表,记录了目标文件用到的所有符号,每个符号有一个对应的符号值,一般为符号的地址。一个样例如下:
Num | Value | Size | Type | Bind | Vis | Ndx | Name |
13 | 0000001b | 64 | FUNC | GLOBAL | DEFAULT | 1 | main |
其意义如下:下标为13的符号的符号值为0x1b,大小为64字节,类型为函数,绑定信息为全局符号,VIS可以忽略,Ndx表示其所在段的下标为1(通过上一个样例可知,该段为.text段),符号名称为main。如果Ndx下标一项为UND(undefine),则表示该符号在其他模块定义,以后需要重定位。
调试信息:目标文件里可能保存有调试信息,如在GCC编译时加上”-g”参数,会生成许多以”.debug”开头的段。
几个目标文件进行链接时,每个目标文件都有其自身的代码段、数据段等,链接器需要将它们各个段的合并到输出文件中,具体有两种合并方法:
第一种方法会产生很多零散的段,而且每个段有一定的地址和空间对齐要求,会造成内存空间大量的内部碎片。所以现在的链接器空间分配基本采用第二种方法,而且一般采用一种称为两部链接的方法:
经过第一步后,输入文件中的各个段在链接后的虚拟地址已经确定了,链接器开始计算各个符号的虚拟地址。各个符号在段内的相对地址是固定的,链接器只需要给他们加上一个偏移量,调整到正确的虚拟地址即可。
ELF中每个需要重定位的段都有一个对应的重定位表,也称为重定位段。重定位表中每个需要重定位的地方叫一个重定位入口,包含:
不同的处理器指令对于地址的格式和方式都不一样,对于每一个重定位入口,根据其重定位类型使用对应的指令修正方式修改其指令地址,完成重定位过程。
32位硬件平台上进程的虚拟地址空间的地址为0到2^32-1:0×00000000~0xFFFFFFFF,即通常说的4GB虚拟空间大小。在Linux操作系统下,4GB被划分成两部分,操作系统本身占用了0xC00000000到0xFFFFFFFF共1GB的空间,剩下的从0×00000000到0xBFFFFFFFF共3GB的空间留给进程使用。
可执行文件只有被装载到内存以后才能运行,最简单的办法是把所有的指令和数据全部装入内存,但这可能需要大量的内存,为了更有效地利用内存,根据程序运行的局部性原理,我们可以把程序中最常用的部分驻留内存,将不太常用的数据放在磁盘中,即动态装入。
现在大部分操作系统采用的是页映射的方法进行程序装载。页映射并不是一下把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照”页(Page)”为单位划分成若干个页,以后所有的装载和操作的单位就是页。目前一般的页大小为4K=4096字节。装载管理器负责控制程序的装载问题,当运行到的某条指令不在内存的时候,会将该指令所在的页装载到内存中的一个地方,然后继续程序的运行。如果内存中已经没有位置,装载管理器会根据一定的算法放弃某个正在使用的页,并用新的页来替代,然后程序可以继续运行。
可执行文件中包含代码段、数据段、BSS段等一系列的段,其中很多段都要映射进进程的虚拟地址空间。当段的数量增加时,会产生空间浪费问题。因为ELF文件被映射时是以系统的页长度为单位进行的,一个段映射的长度应为页长度的整数倍,如果不是,那么多余部分也将占用一个页,从而产生内存浪费。
实际上操作系统并不关心可执行文件各个段所包含的实际内容,它只关心一些跟装载有关的问题,最主要的是段的权限(可读、可写、可执行)。ELF中,段的权限组合可以分成三类:
于是,对于相同权限的段,可以把它们合并到一起当做一个段进行映射,这样可以把原先的多个段当做一个整体进行映射,明显地减少页面内部碎片,节省内存空间。这个称为”Segment”,表示一个或多个属性类似的”Section”,可以认为”Section”是链接时的概念,”Segment”是装载时的概念。链接器会把属性相似的”Section”放在一起,然后系统会按照这些”Section”组成的”Segment”来映射并装载可执行文件。
进程的虚拟地址空间中除了被用来映射可执行文件的各个”Segment”之外,还有包括栈(Stack)和堆(Heap)的空间,一个进程中的栈和堆在也是以虚拟内存区域(VMA, Virtual Memrory Area)的形式存在。操作系统通过给进程空间划分出一个个的VMA来管理进程的虚拟空间,基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA,一个进程基本可以分为如下几种VMA区域:
其常见的分布情况如下图所示:
OS |
STACK VMA |
HEAP VMA |
DATA VMA |
CODE VMA |
静态链接允许不同程序开发者相对独立地开发和测试自己的程序模块,促进程序开发的效率,但其也有相应的缺点:
动态链接可以解决空间浪费和更新困难的问题,其不对那些组成程序的目标文件进行链接,而是等到程序运行时才进行链接。使用了动态链接之后,当我们运行一个程序时,系统会首先加载该程序依赖的其他的目标文件,如果其他目标文件还有依赖,系统会按照同样方法将它们全部加载到内存。当所需要的所有目标文件加载完毕之后,如果依赖关系满足,系统开始进行链接工作,包括符号解析及地址重定位等。完成之后,系统把控制权交回给原程序,程序开始运行。此时如果运行第二个程序,它依赖于一个已经加载过的目标文件,则系统不需要重新加载目标文件,而只要将它们连接起来即可。
动态链接可以解决共享的目标文件存在多个副本浪费磁盘和内存空间的问题,因为同一个目标文件在内存中只保存一份。另外,当一个程序所依赖的库升级之后,只需要将简单地用新的库将旧的覆盖掉,无需将所有的程序再重新链接一遍,当程序下次运行时,新版本的库会被自动加载到内存并链接起来,程序仍然可以正常运行,并且完成了升级过程。
对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身。但是对于动态链接来说,除了可执行文件本身,还有它所依赖的共享目标文件,此时,它们都是被操作系统用同样的方法映射进进程的虚拟地址空间,只是它们占用的虚拟地址和长度不同。另外,动态链接器也和普通共享对象一样被映射到进程的地址空间。系统开始运行程序之前,会把控制权交给动态链接器,由它完成所有的动态链接工作,然后再把控制权交回给程序,程序就开始执行。
动态链接的共享对象在被装载时,其在进程虚拟地址空间的位置是不确定的,为了使共享对象能够在任意地址装载,可以参考静态链接时的重定位(Link Time Relocation)思想,在链接时对所有的绝对地址的引用不做重定位,把这一步推迟到装载时再完成。一旦模块装载完毕,其地址就确定了,即目标地址确定,系统就对程序中所有的绝对地址引用进行重定位。这种装载时重定位(Load Time Relocation)又称为基址重置(Rebasing)。
但是动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位之后对于每个进程来讲是不同的。当然,动态链接库中的可修改的数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。
装载时重定位导致指令部分无法在多个进程之间共享,失去了动态链接节省内存的一大优势。为了程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,可以把指令中那些需要改变的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变了,而数据部分可以在每个进程中拥有一个副本。这种方案称为地址无关代码(PIC, Position-independent Code)技术。
我们把共享对象模块中的地址引用按照是否扩模块分成模块内部引用和模块外部引用,按照不用的引用方式分成指令引用和数据引用,然后把得到的4种情况分别进行处理:
于是,四种地址引用方式在理论上都实现了地址无关性。
以上的方法能够保证共享对象中代码部分地址无关,但数据部分并不是地址无关的,比如:
static int a; static int* p = &a;
指针p的地址是绝对地址,指向变量a,但a的地址会随着共享对象的装载地址改变而变。
数据段在每个进程都有一份独立的副本,并不担心被进程改变,于是可以选择装载时重定位的方法来解决数据段中绝对地址引用的问题。对于共享对象来说,如果数据段中有绝对地址的引用,那么编译器和链接器会产生一个重定位表,这个表中包含了”R_386_RELATIVE”类型的重定位入口来解决上述问题。当动态链接器装载共享对象时,如果发现共享对象上有这样的重定位入口,就会对该共享对象进行重定位。
其实对代码段也可以使用装载时重定位而不是地址无关代码的方法,它有以下特点:
代码段不是地址无关,不能被多个进程共享,失去了节省内存的有点。
运行速度比地址无关代码的共享对象块,因为它省去了地址无关代码中每次访问全局数据和函数时都要做一次计算当前地址以及间接地址寻址的过程。
动态链接下可执行文件的装载与静态链接下基本一样,首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的”Program Header”中读取每个”Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置,这些步骤跟前面的静态链接情况下的装载基本无异。在静态链接情况下,操作系统接着就可以把控制权交给可执行文件的入口地址,然后程序开始执行。但在动态链接情况下,操作系统会先启动一个动态链接器,动态链接器得到控制权后,开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。
动态链接涉及到的段主要如下:
动态链接的步骤基本上分为3步:启动动态链接器本身,然后是装载所有需要的共享对象,最后是重定位和初始化。
重定位和初始化后,准备工作宣告完成,所需要的共享对象也都已经装载并且链接完成,这是动态链接器就如释重负,将进程的控制权交给程序的入口并开始执行。
动态链接还有一种更加灵活的模块加载方式,称为显式运行时链接(Explicit Run-time Linking),也叫运行时加载。就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。一般的共享对象不需要进行任何修改就可以进行运行时加载,称为动态装载库(Dynamic Loading Library)。动态库的装载通过以下一系列的动态链接器API完成:
下面是一个简单的例子,这个程序将数学库模块用运行时加载的方法加载到进程中,然后获取sin()函数符号地址,调用sin()并且返回结果。
#include#include int mian () { void *handle; double (*func)(double); char *error; handle = dlopen(argv[1], RTLD_NOW); if (handle == NULL) { printf("Open library %s error: %s\n", argv[1], dlerror()); return -1; } func = dlsym(handle, "sin"); if ( (error = dlerror()) != NULL) { printf("Symbol sin not found: %s\n", error); goto exit_runso; } printf("%f\n", func(3.1415926/2)); exit_runso: dlclose(handle); }
编译运行结果如下:
$gcc -o RunSoSimple RunSoSimple.c -ldl $./RunSoSimple /lib/libm-2.6.1.so 1.000000