为什么要动态链接:
静态链接使得不同的程序开发者能够相对独立地开发和测试自己的程序模块,从某种意义上来讲大大促进了程序开发的效率,原先限制程序的规模也就随之扩大,但是慢慢的静态链接的缺点也暴露出阿里,比如浪费内存和磁盘空间,模块更新困难等问题
内存和磁盘空间
静态链接这种方法很简单,原理上很容易理解,实践上很难实现,在操作系统和硬件不发达的早期,绝大部分系统采用这种方案。但后面人们发现这种静态链接的方式对于计算机内存和磁盘的空间浪费非常严重,特别是多进程操作系统,静态链接打打的浪费了内存空间。想像一下每个程序都使用的C语言静态库至少在1MB以上,如果有100个这样的程序就要浪费100M的内存,如果有2000个这样的程序,就要浪费近2GB的空间。
程序开发和发布
空间浪费是一个缺点,还有一个缺点是静态链接对程序的更新,部署和发布也会带来很多麻烦。比如一个程序的lib.o是由一个第三方的厂商提供的,当该厂商更新了lib.o的时候,就不得不重新链接lib.o再打包程序,发布程序。
要解决空间浪费和更新困难这两个最简单的方法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态的连接在一起,简单说,就是不对那些组成程序的目标文件进行链接,等到程序要运行的时候再去链接。即是把链接的过程推迟到了运行时在进行。
静态链接是在运行前,由链接器完成链接形成可执行文件。动态链接是在运行时,按需加载目标文件到内存,如果这个时候有其他使用到了相同目标库文件的程序运行,不用再重新加载,因为此时该目标文件已经加载到内存了,直接链接执行就行了。这样能极大的节省磁盘和内存空间。发布版本时只需要更换环境中的动态链接库就能完成所有依赖该动态链接库运行的程序。
动态链接的基本思想是把程序按照模块拆分成各个相对独立的部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个单独的可执行文件。
假设有一个程序a.c 把他编译成目标文件a.o ,a.c 里面调用了 b.c 里面的 fun() 方法,如果是将a.o 和 b.o 静态链接一起的话,前面说过,静态链接的时候a.o 里面的 fun() 方法会进行重定位。但是如果将b.c 文件编译成动态链接库,a.c 链接时,会将这个符号的引用标记为一个动态链接的符号,不会对它进行地址重定位,这个过程留在装载时再进行。
这里有已分割问题,链接器如何知道fun 的引用是一个静态符号还是一个动态符号。这就是要用到so格式动态链接库的原因,so中保存了完整的符号信息,把so文件也作为连接的输入文件时链接器在解析符号时就能知道,fun是一个定义在so的动态符号。
对于静态链接的可执行文件,整个进程只有一个文件要被映射,那就是可执行文件自身。动态链接运行时,通过命令查看后,里面不仅有so的虚拟空间,还有一个ld.so ,这是动态链接器,所以动态链接器与普通共享对象一样都被映射到了进程的地址空间。
为了能使共享对象在任意地址装载,在链接时,对所有绝对地址的引用先不作重定位,把这一步推到装载时再完成。;一旦模块装载位置确定,系统就对所有的符号引用进行重定位。 这就是装载时重定位
装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的优势。所以就有了后面的地址无关代码技术。就是把那些指令中需要被修改和重定位的分割出来,然后跟数据放在一起,这样部分指令就可以保持不变,数据部分在每个进程中都有一个副本,那些进程独有的指令部分就单独跟数据放一起进行重定位。
可执行文件被加载进内存的时候,文件中不同的section 会被加载进内存的不同的segment, 比如。data 和 。bss 段被加载进数据段,而.code,.rodata 被加载进代码段。
在多进程共享动态链接库的时候,因为代码段是不可写的,所以进程间共享不存在问题,而数据段可写,系统必须保证一个进程写了共享库的数据段,另外一个进程看不到。这时内存映射的情况如下图所示:
虽然libc.so 在屋里内存中只有一份,但它可以被多个进程进行映射。而且进程1映射的libc.so 代码段的虚拟地址与进程2映射libc.so 代码段的虚拟地址可以不相等。
如果共享的动态库超过了两个,并且这些动态库之间还有相互引用的时候,情况就变得复杂了,还是用图来说明:
如上图,如果两个进程共享了libc.so 和 libd.so 两个动态库,而且libc中会调用libd中定义的foo方法。
进程1 将foo 方法映射到自己的虚拟地址 0x1000 处,而调用 foo 方法的指令被映射到 0x2000 处,那么call 指令如果采用依赖rip寄存器相对寻址的方法,这个偏移量应该是-0x1000。进程2将 foo 方法 映射到自己的虚拟地址 0x2000 处,调用foo 方法的指令被映射到0x5000处,那么call指令的参数就是 -0x3000.这就产生了冲突。所以,这种寻址方法在这里已经行不通了,相对寻址要求目标地址和本条指令的地址之间的绝对值是固定的,这就是地址有关代码。当目标地址和调用者地址之间的相对值不固定是,就需要地址无关代码技术了。
在计算机领域,有一句名言:计算机领域的所有问题都可以使用新加一层抽象来解决。统一,要实现代码段的地址无关代码,思路也是通过添加一个中间层,使得对全局符号的访问由直接访问变成间接访问。
我们可以引入一个固定地址,让引用者与这个固定地址之间的相对偏移是固定的,然后这个地址出再填入 foo 函数真正的地址。当然,这个地址必然位于数据段中,是每个进程私有的,这样才能做到在不同进程里,可以访问不同的虚拟地址。这个新引入的固定地址就是全局偏移表(Global offset table,GOT)。GOT的工作原理如下图所示:
在上图,call 指令被填入了0x3000,这是因为进程1的GOT与Call 指令之间的偏移是0x5000-0x2000 =0x3000,同时进程2的GOT与call指令之间的偏移是0x8000-0x5000 = 0x3000。所以这一段共享代码,不管是进程1执行还是进程2执行,它们都会先跳到自己的GOT表里面。
然后进程1通过访问自己的GOT表,查到foo地址是0x1000,它就能调用到foo函数了。进程2访问自己的GOT表,查到foo函数的地址是0x2000,它也能顺利的调用到foo函数了。这样我们就通过引入GOT这个间接层,解决了call指令和foo函数定义之间的偏移不固定的问题。