您的位置:首页 >科技 >

【图片+代码】:Linux动态链接过程中的【重定位】底层原理

时间:2022-03-22 14:16:25 来源:

目录

· 动态链接要解决什么问题?

· 矛盾:代码段不可写

· 解决矛盾:增加一层间接性

· 示例代码

· 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

代码如下:

【图片+代码】:Linux动态链接过程中的【重定位】底层原理

代码说明:

定义一个全局变量和一个全局函数,被 a.c 调用。

a.c

代码如下(稍微复杂一些,主要是为了探索:不同类型的符号如何处理重定位):

【图片+代码】:Linux动态链接过程中的【重定位】底层原理

【图片+代码】:Linux动态链接过程中的【重定位】底层原理

代码说明:

1. 定义了 2 个全局变量:一个静态,一个非静态;

2. 定义了 3 个函数:

func_a2是静态函数,只能在本文件中调用;

func_a1和func_a3是全局函数,可以被外部调用;

3. 在 main.c 中会调用func_a1。

main.c

代码如下:

【图片+代码】:Linux动态链接过程中的【重定位】底层原理

【图片+代码】:Linux动态链接过程中的【重定位】底层原理

【图片+代码】:Linux动态链接过程中的【重定位】底层原理

纠正:代码中本来是想打印变量的地址的,但是不小心加上了 *,变成了打印变量值。最后检查的时候才发现,所以就懒得再去修改了。

代码说明:

利用 dlopen 函数(第一个参数传入 NULL),来打印此进程中的一些符号信息(变量和函数);

赋值给 liba.so 中的变量 a2,然后调用 liba.so 中的 func_a1 函数;

编译成动态链接库

把以上几个源文件编译成动态库以及可执行程序:

【图片+代码】:Linux动态链接过程中的【重定位】底层原理

有几点内容说明一下:

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中需要加载到内存中的代码段、数据段都加载进来。


郑重声明:文章仅代表原作者观点,不代表本站立场;如有侵权、违规,可直接反馈本站,我们将会作修改或删除处理。