目录
· 动态链接要解决什么问题?
· 矛盾:代码段不可写
· 解决矛盾:增加一层间接性
· 示例代码
· b.c
· a.c
· main.c
· 编译成动态链接库
· 动态库的依赖关系
· 动态库的加载过程
· 动态链接器加载动态库
· 动态库的加载地址分析
· 符号重定位
· 全局符号表
· 全局偏移表GOT
· liba.so动态库文件的布局
· liba.so动态库的虚拟地址
· GOT表的内部结构
· 反汇编liba.so代码
大家好,我是道哥,你技术修炼道路上的垫脚石。
在上一篇文章中,我们一起学习了Linux系统中 GCC编译器在编译可执行程序时,静态链接过程中是如何进行符号重定位的。
为了完整性,我们这篇文章来一起探索一下:动态链接过程中是如何进行符号重定位的。
老样子,文中使用大量的【代码+图片】的方式,来真实的感受一下实际的内存模型。
文中使用了大量的图片,建议您在电脑上阅读此文。
关于为什么使用动态链接,这里就不展开讨论了,无非就几点:
1. 节省物理内存;
2. 可以动态更新;
动态链接要解决什么问题?
静态链接得到的可执行程序,被操作系统加载之后就可以执行执行。
因为在链接的时候,链接器已经把所有目标文件中的代码、数据等Section,都组装到可执行文件中了。
并且把代码中所有使用的外部符号(变量、函数),都进行了重定位(即:把变量、函数的地址,都填写到代码段中需要重定位的地方),因此可执行程序在执行的时候,不依赖于其它的外部模块即可运行。
详细的静态链接过程,请参考上一篇文章:【图片+代码】:GCC 链接过程中的【重定位】过程分析。
也就是说:符号重定位的过程,是直接对可执行文件进行修改。
但是对于动态链接来说,在编译阶段,仅仅是在可执行文件或者动态库中记录了一些必要的信息。
真正的重定位过程,是在这个时间点来完成的:可执行程序、动态库被加载之后,调用可执行程序的入口函数之前。
只有当所有需要被重定位的符号被解决了之后,才能开始执行程序。
既然也是重定位,与静态链接过程一样:也需要把符号的目标地址填写到代码段中需要重定位的地方。
矛盾:代码段不可写
问题来了!
我们知道,在现代操作系统中,对于内存的访问是有权限控制的,一般来说:
· 代码段:可读、可执行;
· 数据段:可读、可写;
如果进行符号重定位,就需要对代码进行修改(填写符号的地址),但是代码段又没有可写的权限,这是一个矛盾!
解决这个矛盾的方案,就是Linux系统中动态链接器的核心工作!
解决矛盾:增加一层间接性
David Wheeler有一句名言:“计算机科学中的大多数问题,都可以通过增加一层间接性来解决。”
解决动态链接中的代码重定位问题,同样也可以通过增加一层间接性来解决。
既然代码段在被加载到内存中之后不可写,但是数据段是可写的。
在代码段中引用的外部符号,可以在数据段中增加一个跳板:让代码段先引用数据段中的内容,然后在重定位时,把外部符号的地址填写到数据段中对应的位置,不就解决这个矛盾了吗?!
如下图所示:
理解了上图的解决思路,基本上就理解了动态链接过程中重定位的核心思想。
示例代码
我们需要3个源文件来讨论动态链接中重定位的过程:main.c、a.c、b.c,其中的a.c和b.c被编译成动态库,然后main.c与这两个动态库一起动态链接成可执行程序。
它们之间的依赖关系是:
b.c
代码如下:
代码说明:
定义一个全局变量和一个全局函数,被 a.c 调用。
a.c
代码如下(稍微复杂一些,主要是为了探索:不同类型的符号如何处理重定位):
代码说明:
1. 定义了 2 个全局变量:一个静态,一个非静态;
2. 定义了 3 个函数:
func_a2是静态函数,只能在本文件中调用;
func_a1和func_a3是全局函数,可以被外部调用;
3. 在 main.c 中会调用func_a1。
main.c
代码如下:
纠正:代码中本来是想打印变量的地址的,但是不小心加上了 *,变成了打印变量值。最后检查的时候才发现,所以就懒得再去修改了。
代码说明:
利用 dlopen 函数(第一个参数传入 NULL),来打印此进程中的一些符号信息(变量和函数);
赋值给 liba.so 中的变量 a2,然后调用 liba.so 中的 func_a1 函数;
编译成动态链接库
把以上几个源文件编译成动态库以及可执行程序:
有几点内容说明一下:
1. -fPIC 参数意思是:生成位置无关代码(Position Independent Code),这也是动态链接中的关键;
2. 既然动态库是在运行时加载,那为什么在编译的时候还需要指明?
因为在编译的时候,需要知道每一个动态库中提供了哪些符号。Windows 中的动态库的显性的导出和导入标识,更能体现这个概念(__declspec(dllexport), __declspec(dllimport))。
此时,就得到了如下几个文件:
动态库的依赖关系
对于静态链接的可执行程序来说,被操作系统加载之后,可以认为直接从可执行程序的入口函数开始(也就是ELF文件头中指定的e_entry这个地址),执行其中的指令码。
但是对于动态链接的程序来说,在执行入口函数的指令之前,必须把该程序所依赖的动态库加载到内存中,然后才能开始执行。
对于我们的实例代码来说:main程序依赖于liba.so库,而liba.so库又依赖于libb.so库。
可以用ldd工具来分别看一下动态库之间的依赖关系:
可以看出:
1. 在 liba.so 动态库中,记录了信息:依赖于 libb.so;
2. 在 main 可执行文件中,记录了信息:依赖于 liba.so, libb.so;
也可以使用另一个工具patchelf来查看一个可执行程序或者动态库,依赖于其他哪些模块。例如:
那么,动态库的加载是由谁来完成的呢?动态链接器!
动态库的加载过程
动态链接器加载动态库
当执行main程序的时候,操作系统首先把main加载到内存,然后通过.interp段信息来查看该文件依赖哪些动态库:
上图中的字符串/lib/ld-linux.so.2,就表示main依赖动态链接库。
ld-linux.so.2也是一个动态链接库,在大部分情况下动态链接库已经被加载到内存中了(动态链接库就是为了共享),操作系统此时只需要把动态链接库所在的物理内存,映射到 main进程的虚拟地址空间中就可以了,然后再把控制权交给动态链接器。
动态链接器发现:main依赖liba.so,于是它就在虚拟地址空间中找一块能放得下liba.so的空闲空间,然后把liba.so中需要加载到内存中的代码段、数据段都加载进来。