基本术语:
VMS——进程虚拟地址空间,PMS——物理内存空间,DSO——动态共享对象
程序与文件的关系
平时我们所说的“一个程序由多个文件构成”两种视角:程序开发阶段的多个文件(源代码文件或者库文件),程序最终运行时状态所需的多个文件。在此之后谈到“程序由多个文件构成”表述时,均是从程序最终运行时状态的角度来分析的。
构成一个程序的文件又称为“模块”,一个程序要运行必须由一个可执行文件(可执行ELF文件类型为ET_EXEC)启动,至于除此之外程序运行时是否需要其他模块,需要哪些模块,则根据不同的情况有不同的要求,只有在这个程序真正被OS进程化之后才能知道。直观上怎么知道呢?——OS负责将程序运行需要的模块加载到进程虚拟地址空间(VMS),通过 cat /proc/进程ID/maps 命令可以看到当前哪些载模块被加载,以及各个模块在VMS的分布视图。
现代的程序规模不断扩大,导致开发程序时的多模块化分工,即不同的开发人员开发相对独立的模块,但是对开发出来的不同模块如何进行组织(例如,静态链接和动态链接),导致了程序最终的运行时状态却不一致。
在静态链接机制下,程序最终的运行时状态就是由单独一个ELF可执行文件构成的,通过 cat /proc/进程ID/maps 命令看到的进程实时VMS视图中也只有可执行文件这一个磁盘上的实体文件被加载;
但是对于动态链接却不一样了。在动态链接机制下,程序最终的运行时状态是由多个文件/模块构成的。这些文件/模块中,除了仍然必须的一个ELF可执行文件,还可能有多个动态共享对象DSO文件(即Windows下的dll动态链接库文件),其ELF类型为ET_DYN。这也可以通过cat /proc/进程ID/maps 命令在VMS实时视图中看见。
ELF文件的“执行视图”和模块加载的误区
首先再次明晰“模块加载”的概念。在支持虚拟内存管理的OS平台下,任何时候提到模块加载均是指“OS将磁盘中的文件/模块加载到某个进程的虚拟地址空间/虚拟内存”,而不用去考虑虚拟空间和物理内存空间的页交换细节,那个工作由OS存储管理模块和MMU去完成的。之所以可执行文件又被称为“映像文件”,也是因为它实际上是被加载到虚拟地址空间,磁盘上的该文件是其在VMS中的映像。
当仅仅谈及构成程序的文件时,无论是静态链接得到的可执行文件,还是动态链接生成的可执行文件和动态共享对象文件,都只是静态的磁盘文件概念而已,只要程序没有被OS进程化,这些文件都不涉及动态的加载过程,更谈不上程序的执行。
而所谓ELF文件的“执行视图”则是和ELF文件的“链接视图”相对应的概念,它们都只是如何看待ELF文件逻辑划分的方式而已,并不表示这个ELF文件已经被加载和开始执行了。今天很多时候思考问题时的一个误区就是将ELF文件的“执行视图”概念与模块已经被加载混淆了。
/*注意:只有ELF可执行文件和ELF动态共享对象文件才有segment或Program Header的概念,也才可以通过 readelf看到“执行视图”,ELF可重定位目标文件是没有“执行视图”而只有链接视图。*/
ELF可执行文件可以通过 readelf -l 文件名 命令看到其“执行视图”,如下示例:
似乎ELF文件的各个segment都已经被分配了在VMS中的起始地址(第三列),但这个地址实际上仅仅是该文件模块假设的被加载后的VMS地址,至于程序被进程化运行后,该模块究竟被OS加载到VMS中的哪个区域,则是完全由OS在模块真正被加载的时候来决定的,不同的操作系统会有不同的决定,并且模块中其实也只有属性为 PHT_LOAD 的segment(图中的前两行)才会被OS加载成为VMS中的VMA。
另外,任何一个ELF可执行文件的“执行视图”中都是没有堆、栈的segment的,这也是区分文件和程序,区分ELF文件可执行视图与进程地址空间的重要线索,因为只有一个程序才会有heap和stack的概念,一个进程的VMS中才会有heap VMA和stack VMA,而一个文件和文件的执行视图中是没有堆栈概念和堆segment、栈segment的。
动态链接的基本思想
静态链接机制下,一个程序最终的运行时形态就是一个可执行文件,静态链接器将众多的目标文件和诸如libc.a这样的静态库文件链接到一起,成为一个不可分割的整体,运行时由OS将可执行文件中的LOAD类型segment加载到VMS中。这种程序构成和运行模式的缺点主要有两个方面:
C语言静态库为代表的静态库文件在几乎所有的程序中都要使用,根据静态链接规则,在链接生成一个可执行文件时,所有目标文件中的各类section都要被合成可执行文件中对应的新section,这也就意味着任何一个程序的可执行文件都完全地包含了这些静态库的所有内容。因为可执行文件都是要保存在磁盘中的,对这些静态库的重复包含将严重浪费磁盘空间;同时在多进程并发执行环境下,不同进程虚拟地址空间的各个VMA都要参与虚拟空间与物理空间的页交换,这样就必然导致相同静态库的代码和数据内容会被多次交换到物理内存空间的不同区域中,这将造成物理内存的严重浪费。
静态链接工作方式下,如果某个模块开发人员发布了该模块的更新版本(以可重定位目标文件或静态库文件的形式),则整个程序都需要进行重新链接得到新的可执行文件。对于程序的用户而言,他们手中是没有全部目标文件和库文件的,不可能只更新某个*.o或*.a文件而自己去链接生成新版可执行文件,他们必须从网络上下载程序发布商放出来的整个可执行文件。网络下载的速度影响将导致用户更新的不便。
动态链接可以解决这些问题,其基本思想是:将程序开发得到的各个模块,不再静态链接成一个不可分割的整体磁盘文件,而是将编译得到的各模块目标文件单独存放于磁盘中,然后一个或多个目标文件生成一个包含完整符号信息的DSO或DLL,可执行文件生成时,根据这些DSO和DLL提供的信息不再对未定义符号进行重定位。当程序真正需要运行的时候,才由OS将所需要的各个模块分别从磁盘空间加载到VMS中,然后由本身也是一个程序模块的动态连接器完成链接。这就是所谓的“运行时链接”。当其他的程序并发运行时也需要相同的某些DSO/DLL模块,则可以与之前的进程在物理内存中实现共享。
动态共享对象为什么加载时不能固定虚拟地址
一般地,程序运行时OS加载的第一个模块就是ELF可执行文件,并且VMS中只会加载一个可执行文件,所以OS往往选择VMS中一个固定的位置加载可执行文件,这也正是之前提到的ELF可执行文件“执行视图”中各个segment的虚拟地址可以事先假定的原因。对于Linux系统,可执行文件一般都是从0x08040000开始加载,而对于Windows系统,这个地址则是0x00040000,相应的,Linux和Windows下的编译器也会根据这一特性在可执行文件的“执行视图”中假定虚拟地址。
但是对于DSO却没有这样的待遇了。一个程序只用一个可执行文件,但是却可能使用多个DSO,所以每个DSO都使用相同的虚拟地址是不可能的。那么能否为每个DSO都固定一个不同的VMS地址呢?这也是不可能的,因为DSO不计其数,除非将所有的DSO全部不重叠地规划到VMS中,否则不同程序在使用DSO的过程中必然会发生地址冲突。例如素不相识的DSO-1和DSO-2开发者均将自己开发的DSO的加载地址固定为0x100,那么任意两个程序只要同时使用DSO-1和DSO-2,则VMS内就会发生DSO地址冲突。更何况DSO数目是不断增加的,不可能在Linux下3GB程序可用VMS中全部规划在内。
所以DSO模块的“执行视图”中各个segment的地址都使用的不是绝对地址,而是从零开始的相对偏移,以便DSO能够在程序运行时被OS加载到任意位置。如下图:
装载时重定位的缺陷
要得到程序的可执行文件,需要对其中未定义符号进行重定位,如果可执行目标文件引用了DSO中定义的符号,就需要对目标文件中这样的符号标记为动态链接符号,将符号重定位工作推迟到程序开始运行,DSO被装载到VMS后再进行——这就是所谓的“装载时重定位”,采用装载时重定位技术可以使得每个DSO都可以被动态加载到VMS。
但是因为DSO源程序在进行编译时,内部代码对本模块和外模块定义的符号的引用,会使得DSO模块的 .text和 .bss中含有对符号(绝对地址)的引用,当某一个DSO被装载后进行符号重定位的时候,其代码段和数据段引用的绝对地址都要进行重定位修改——这里尤其重要的是“DSO的代码段也需要对绝对地址的引用进行修改”。动态链接的最大目的和优势之一就是实现多进程的代码指令共享,可是如果DSO-1的代码段也需要因为重定位而修改,并且修改值随DSO-1和相关DSO被加载地址而定,将导致使用DSO-1的每个进程中DSO-1的代码段被修改后都不一样了,自然也就无法在物理内存中被多进程共享了。
在用gcc编译得到一个共享对象的时候,如果只使用 -shared 参数,得到的DSO就会采用装载时重定位的方法。DSO代码段中对于未定义符号的引用一律使用的是绝对地址——即使在被装载前可能只是假地址。
不过由于数据不需要所有的进程共享,所以对于DSO的数据段可以采用装载时重定位,从而每个使用该DSO的进程在物理内存中都拥有可能各不相同的DSO数据段副本。
GCC的 -fPIC 参数与地址无关代码
要实现上述的DSO指令共享,必须将DSO指令segment中使用绝对地址的指令进行修改,使之成为“地址无关代码(PIC)”,gcc支持PIC技术,在编译源程序的时候使用 -fPIC 参数就可以得到这样的地址无关代码。
DSO的指令segment中使用绝对地址的情况包括四类:对模块内定义的函数的调用,对模块内定义的全局变量和静态变量的访问,对外模块定义的函数的调用,对外模块定义的全局变量的访问。因此PIC技术也就是对这四种情况的指令进行修正。
对于第一类:因为源代码在被编译完成之后,各个函数的相对位置就已经固定下来,并且在汇编语言级函数调用所用的jmp或call指令都使用的是相对于当前PC寄存器值的相对跳转,故而即使是DSO被加载的VMS位置不确定,DSO代码段中的这种调用情况对应的指令也是不用修改的。
对于第二类:需要分成两种情况,对于本模块定义的静态变量(无论是局部的还是全局的),因为同一个模块源代码编译得到的DSO文件中,代码segment和数据segment的相对位置也是固定的,相应的代码VMA和数据VMA在VMS中的相对位置也是固定的——只不过由于跨segment时存在VMA的二次映射,计算相对偏移量时需要加上一个虚拟页的长度,因为汇编指令集在进行数据访问时,没有类似于call这样的相对于当前PC寄存器值的相对跳转指令,所以ELF文件中使用了特殊的
call <__i686.get_pc_thunk.cx> 方式,其中__i686.get_pc_thunk.cx作为一个编译器内建的函数,其在代码段中的相对位置自然也是固定的。因为在调用call指令之前,下一条指令的地址将会被入栈,而栈顶则是由%esp寄存器保存的,所以这个函数中只做一件事情,就是将esp寄存器的值保存到%ecx寄存器中,这样下一条指令的地址就保存在%ecx寄存器中,然后随后利用%ecx通过寄存器相对寻址的方法就可以访问同模块内的静态变量了。
对于本模块内定义的非静态全局变量(例如global)则有所不同了,因为该变量可能会被其他模块所引用,至于引用改变量的模块究竟是可执行文件,还是其他的DSO是无法事先得知的。如果global是被其他的DSO所引用比较简单,按照下述的第四类情况进行跨模块数据访问处理即可;但是如果global是被可执行模块所引用则必须考虑额外的情况了。
可执行文件的代码段可能并没有使用PIC技术而是直接使用绝对地址,这时global的地址必须在链接时就确定,所以链接器将会在.bss段中创建一个global副本,这样当原本定义global的DSO被加载之后,DSO数据段中也会有一个global的副本——这是不允许的!解决办法之有一个,那就是DSO在编译时,中对global进行访问的所有指令,均采用下述第三类“对外模块中定义的全局变量进行访问”的方式。
对于第三类:ELF文件的做法是,gcc编译器在生成一个DSO时,额外生成一个GOT表(Global Offset Table),实际就是一个指针数组,每个指针指向一个外部模块中定义的全局变量。在ELF链接视图中,GOT是以一个 .got section 形式存在;而在ELF执行视图中,GOT存在于数据segment中。对于每一个使用该DSO的进程而言,共享DSO的代码segment而各自拥有DSO的数据segment副本,所以GOT的内容是在DSO被加载后来确定的。使用objdump -d DSO文件名 命令可见 .got section 中内容全部是0。
与前述第二类“本模块静态变量与代码段相对地址保持固定”类似,GOT也与代码段的相对地址是固定的,所以同样可以通过 call <__i686.get_pc_thunk.cx> 方式先找到GOT的位置,然后在GOT中找到需要的全局变量的地址。
对于第四类:类似于第三类,使用GOT来间接访问。
/*注意:由上述第二类中关于DSO中定义的非静态全局变量的基本原理,如果一个共享对象lib.so中定义全局变量global,而进程A和进程B分别都使用了lib.so,因为所有进程共享lib.so的代码段,独立拥有lib.so的数据段副本,所以即使A和B都修改了global的值,它们也是互不影响的,这样说来global与进程A、B可执行模块内定义的全局变量没有什么区别;
然而当A、B是同一进程的两个线程时,情况就不同了,因为线程A、B共享同一个VMS,自然也就共享VMS中包含global的那个DSO数据段副本,任何一个线程修改global,对另外一方都是可见的。
当然在实际应用中,存在“多个进程共享一个全局变量”,以及“多个线程访问全局变量的不同副本”的需求。*/