此系列为我学习经典的读书笔记,目的是理清知识脉络帮助理解记忆,内容深度、质量远远不及原书。如果您对相关知识感兴趣,强烈建议阅读原书程序员的自我修养
带着问题学习
- 为什么要动态链接
- 动态链接的关键结构有哪些
- 动态链接有哪些步骤
为什么要动态链接
静态链接使得不同开发者或部门之间能够按模块开发,这大大提高了生产效率,与此同时静态链接的两个缺点也显现出来。一个是占用过大的磁盘空间,一个是模块更新困难问题。
内存和磁盘空间
静态链接方式对计算机内存和磁盘的空间浪费非常严重。
在现在的 Linux 系统中,一个普通程序会使用到的 C 语言静态库至少在 1 MB 以上,如果磁盘中有 2000 个这样的程序,就要浪费近 2 G 的磁盘空间。
程序开发和发布
除了空间浪费,静态链接的更新过程也非常麻烦,任何一个模块的任何更新都会导致程序的全量更新
动态链接
要解决这两个问题的简单办法就是把程序的模块分割开,形成独立文件,也就是不对那些组成程序的目标文件进行链接,把链接这个过程推迟到了运行时再进行,这也是动态链接(Dynamic Linking)的基本思想。
比如有两个程序 Program1 和 Program2 都用到了一个库叫 lib。那我们只保留 Program1.o,Program2.o 和 lib.o 三个可执行文件。运行 Program1 时,首先加载 Program1.o,系统发现依赖 lib.o,这时候再把 lib.o 加载到内存,其他依赖一样,全部加载之后再进行链接。这时候如果需要运行 Program2,就不需要重新加载 lib.o 了,内存中已经存在了相关副本,只需要把 Program2.o 与 lib.o 链接就可以了。
这样就解决了内存和磁盘空间浪费的问题,相同的库并不会重复加载。而且程序更新也更加容易,共享库的修改只需要将旧的可执行文件覆盖掉就可以了。
地址无关代码
要实现动态链接,首先要解决的问题就是共享对象地址冲突问题。解决这个问题的基本思路就是我们能不能让共享对象在任意位置加载,也就是共享对象在加载时不能假定自己在进程虚拟空间地址空间中的位置
装载时重定位
在链接时,对所有的绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。
区别于静态链接中的链接时重定位(Link Time Relocation),这种叫做装载时重定位(Load Time Relocation)也叫基址重置(Rebasing)
这种方法的局限在于不解决共享对象问题,动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程时间共享的,由于装载时重定位方法需要修改指令,所以没办法做到同一份指令被多个进程共享。
但是可修改数据部分对于不同进程来说存在多个副本,它们可以使用装载时重定位的方法来解决。
地址无关代码
为了解决上述的指令部分不能共享的问题,主要思路就是希望共享指令部分不因装载时装载地址的改变而改变。所以一个基本解决方案就是把指令中需要修改的部分抽离出来跟可修改数据放在一起,多个进程之间存在多个副本,这种方案就叫地址无关代码(PIC, Position-independent Code)的技术。
把共享模块中的地址引用按照是否跨模块分为:模块内部引用和模块外部引用两种;不同引用方式又分为指令引用和数据访问两种,就会得到 4 种情况,逐个分析。
类型一 模块内部调用或跳转
这种类型最简单,相对地址调用就可以基本解决。
类型二 模块内部数据访问
同样,模块内部数据的访问也是通过相对地址访问解决。
类型三 模块间数据访问
比较复杂,因为目标地址要在装载时才能确定,Linux 可执行文件 ELF 的做法是在数据段里面,建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT)
当指令需要访问某个变量是,程序会先找到 GOT,根据 GOT 中变量所对应的项找到变量的目标地址。GOT 本身放在数据段,装载时可以修改,每个进程有单独副本,互相不受影响。
类型四 模块间调用、跳转
与类型三类似,也是通过 GOT 方式进行解决,不同的是,GOT 中保存的是目标函数的地址。
延迟绑定(PLT)
动态链接相对于静态链接的灵活优势是由一部分性能损耗为代价的。复杂的 GOT 定位,间接寻址都会拖慢程序的运行速度,那么有什么方法能够减少这种性能损失呢。答案是延迟绑定。
类似于前端范畴的懒加载。ELF 采取一种延迟绑定(Lazy Binding)的做法,基本思想就是当函数第一次被用到时候再进行绑定(符号查找,重定位等),没有用到则不进行绑定。
动态链接的关键结构
“.interp”段
“.interp”的内容很简单,保存的是可执行文件所需要的动态链接器的路径。
“.dynamic”段
“.dynamic”段里保存着动态链接器所需要的基本信息,比如依赖那些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的位置。
动态符号表
动态符号表(Dynamic Symbol Table)段用来保存模块间符号导入导出关系
动态链接重定位表
包含对数据段以及函数引用的修正信息。
动态链接时进程堆栈初始化信息
进程初始化时候,堆栈里保存了关于进程执行环境和命令行参数以及辅助信息数组等信息。
动态链接的步骤
动态链接器自举
动态链接器本身也是一个共享对象,所以它需要完成自举(Bootstrap)。
动态链接器是一个有点特殊的共享对象。首先,它不依赖其他共享对象;其次,动态链接器所需要的全局和静态变量的重定位工作由它本身完成。
动态链接器的入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的代码开始执行。首先找到自己的 GOT 获取“dynamic”段信息,再通过“dynamic”段信息获取链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们重定位。从这一步开始,动态链接器才可以使用自己的全局变量和静态变量。
装载共享对象
完成基本自举后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们称为全局符号表(Global Symbol Table)。然后链接器开始寻找可执行文件依赖的共享对象,找到对象后把相应的代码段和数据段映射到进程空间,如果共享对象还有依赖的其他共享对象,就一直循环装载。基本算法就是图的广度优先搜索遍历。
当所有的共享对象都被装载进来后,全局符号表里将包括进程中所有的动态链接所需要的符号。
重定位和初始化
当上面步骤完成后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的 GOT/PTL 中每个需要重定位的位置进行修正。
重定位完成后,如果某个对象有 “.init” 段,那么动态链接器会执行 “.init” 段中的代码,实现共享对象持有的初始化过程。
当完成了重定位和初始化工作后,所有的准备工作就宣告完成了,所需的共享对象也已经完成了装载和链接,动态链接器可以把控制权交给程序入口并开始执行了。
总结
经过刚才的梳理,可以回答开头的问题了吗,如果还不能,或者想了解更多,欢迎阅读原书。如果发现了我的错误与纰漏,也请不吝赐教,十分感谢。