动态链接出现的原因
静态连接的方式对于计算机内存和磁盘的空间浪费非常严重。特别是多进程操作系统情况下,如果每个程序内部除了都保留着printf()函数、scanf()函数、strlen()等这样的公用库函数,还有数量相当可观的其他库函数及它们所需要的辅助数据结构。使用静态链接极大地浪费了内存空间。
另一个问题是静态链接对程序的更新、部署和发布也会带来很多麻烦。比如当多个程序都依赖一个lib.o的情况下,一旦lib.o 中有任何bug,lib.o 修改问题发布后,所有依赖lib.o 的程序都需要重新link,并发布,让用户下载整个新的程序。
动态链接的基本实现
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个个单独的可执行文件。
在Linux系统中,ELF动态链接文件被称为动态共享对象(DSO,Dynamic Shared Objects),简称共享对象。
当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库(最基本的就是libc.so)装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。
由于动态链接机制使得程序在调用某个动态库的时候,才去真的加载到内存中,并且动态库在磁盘和内存中只会存在一份,多个程序共享,不仅极大的节省了空间,而且,动态库升级后,各个程序只需要重新运行,链接新的动态库文件,即可调用最新的目标文件。并且使用动态链接使得程序可扩展性和兼容性更高
动态链接中模块的概念
在动态链接下,一个程序被分成了若干个文件,有程序的主要部分,即可执行文件(Program1)和程序所依赖的共享对象(Lib.so),很多时候我们也把这些部分称为模块,即动态链接下的可执行文件和共享对象都可以看作是程序的一个模块。
动态链接程序运行时地址空间分布
在系统开始运行时,动态链接器与普通共享对象一样被映射到了进程的地址空间,在主程序运行前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交给主程序,然后开始执行
共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象
可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定空闲的地址,比如Linux下一般都是0x08040000
我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link Time Relocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation),在Windows中,这种装载时重定位又被叫做基址重置(Rebasing)
使用动态链接遇到的问题
- 装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势
解决方案: 地址无关代码
实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为 地址无关代码 (PIC, Position-independent Code)的技术
在GCC 编译器中 参数“-shared” 代表输出的共享对象就是使用装载时重定位的方法 , #-fpic和-fPIC 参数 都是指示GCC产生地址无关代码,由于地址无关代码都是跟硬件平台相关的,不同的平台有着不同的实现,“-fpic”在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而“-fPIC”则没有这样的限制。所以为了方便起见,绝大部分情况下我们都使用“-fPIC”参数来产生地址无关代码
- 在动态链接的方式下,由于装载地址是不确定的,如何解决内部或外部的符号调用?
解决方案: 全局偏移表
在共享文件中,模块内部的数据和函数调用,由于装在在一个模块内,它们之间的相对位置是固定的,可以采用相对地址调用或寄存器相对寻址。所以对于这种指令是不需要重定位的。
对于模块间的数据访问,由于数据和函数被定义到了其他模块,对应的地址到装载时才能被确定,所以,在ELF中的做法是 ,建立一个指向这些变量的指针数组,叫做全局偏移表(Global Offset Table,GOT)当程序需要访问定义在其他模块中的变量时,可以通过GOT中记录的指针来间接访问。
如果是,模块间的函数调用,和上面查找其他模块的变量方法类似,通过GOT中记录的其他模块的函数指针,进行间接跳转,ELF共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,通过GOT来实现变量的访问
当不使用 -fpic 参数时,既不产生地址无关代码,无法达到进程间共享一份目标代码,但是装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快
- 由于动态链接下全局符号和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先定位GOT,然后再进行间接跳转,如此一来,程序的运行速度必定会减慢。另外一个原因是, 程序开始执行时,动态链接器都要进行一次链接工作,正如我们上面提到的,动态链接器会寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作,这些工作势必减慢程序的启动速度,如何解决程序启动变慢的问题?
解决方案: 延迟绑定
很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者是一些用户很少用到的功能模块等,如果一开始就把所有函数都链接好实际上是一种浪费。所以ELF采用了一种叫做 延迟绑定(Lazy Binding)的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。
ELF使用PLT(Procedure Linkage Table)的方法来实现。当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过GOT中相应的项进行间接跳转。PLT为了实现延迟绑定,在这个过程中间又增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫作PLT项的结构来进行跳转。
例如bar()函数在PLT中的结构如下:
bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump _dl_runtime_resolve
第一条指令是跳转到 bar() 函数在 GOT记录的位置的值,但是链接器初期没有将 bar() 函数的真实地址填写到 GOT 对应的项中,而是将上面第二条指令 push n 填写到对应的位置,此时执行第一条指令不会产生有效的跳转,程序自动进入第二条指令,紧接着执行了 push n, push mduleID,将 n 和 moduleID 入栈,n 是bar这个符号引用在重定位表“.rel.plt”中的下标。然后跳转到 _dl_runtime_resolve ,实现绑定 bar() 函数的真实地址。在进行一系列工作以后将bar()的真正地址填入到bar@GOT中,第二次执行上面的指令时,第一条指令便可以跳转到真实的bar函数对应地址了。
动态链接相关结构
ELF将GOT拆分成了两个表叫做“.got”和“.got.plt”。其中“.got”用来保存全局变量引用的地址,“.got.plt”用来保存函数引用的地址
“.got.plt”的前三项是有特殊意义的,分别含义如下:
第一项保存的是“.dynamic”段的地址,这个段描述了本模块动态链接相关的信息,我们下面还会介绍“.dynamic”段。
第二项保存的是本模块的ID。
第三项保存的是_dl_runtime_resolve()的地址。
其中第二项和第三项由动态链接器在装载共享模块的时候负责将它们初始化。
.interp
实际上,动态链接器的位置既不是由系统配置指定,也不是由环境参数决定,而是由ELF可执行文件决定。在动态链接的ELF可执行文件中,有一个专门的段叫做“.interp”段,.interp 段的内容很简单,里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径。在Linux中,操作系统在对可执行文件的进行加载的时候,它会去寻找装载该可执行文件所需要相应的动态链接器,即“.interp”段指定的路径的共享对象
.dynamic
动态链接ELF中最重要的结构应该是 .dynamic 段 ,这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等
.dynsym
为了表示动态链接这些模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表(Dynamic Symbol Table)的段用来保存这些信息,这个段的段名通常叫做 .dynsym(Dynamic Symbol)
与“.symtab”不同的是,“.dynsym”只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有“.dynsym”和“.symtab”两个表,“.symtab”中往往保存了所有符号,包括“.dynsym”中的符号
.dynstr
与“.symtab”类似,动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表。静态链接时叫做符号字符串表“.strtab”(String Table),在这里就是动态符号字符串表 .dynstr
网友评论