德才兼备 知行合一

ELF动态链接机制解析

Posted on By Dason Mo

动态链接是将程序组织起来的一种手段,相对于静态链接而言有节省内存,增强程序的可扩展性和兼容性等优势。本文旨在对动态链接机制进行解析,包括PIC和PLT等关键技术,并辅以例子说明。最后简述动态链接的步骤和符号优先级问题。

动态链接的意义

符号解析与重定位

  • 每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。
  • 链接器需要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。 这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。

静态链接

所有编译得到的目标文件组织在一个可执行文件,不存在外部依赖。

所有目标文件组织在一起是一个很直观的想法,但是会带来什么问题呢?

  1. 相同功能的代码在多个可执行文件之间无法共享(例如printf函数的实现将在各个可执行文件都有拷贝),带来资源的浪费。
  2. 代码可扩展性差,任何修改都需要重新编译全部代码。

动态链接

按模块拆分程序,可执行文件运行时才进行必要的加载和组装。

相对于静态链接而言,动态链接具有以下优点:

  1. 节省内存和磁盘空间,复用公共库函数。
  2. 利用程序模块化发布升级,不需升级整个程序。
  3. 增强程序可扩展性和兼容性,固定的接口可实现不同的插件。

地址无关代码(PIC)

PIC定义

PIC(Position Independent Code)是一个用于动态链接的概念,意味着生成的机器码不依赖于绝对地址引用。 若依赖于绝对地址引用,则存在以下问题

  1. 对单个进程而言,若已加载其他so到当前so需要使用的绝对地址,则发生冲突。
  2. 重定位后指令部分无法在多进程间共享,每个进程需要有自己的copy(浪费空间)。

PIC实现

  • 目标
    进程间共享的指令部分在装载时不需要因为装载的地址改变而改变。
  • 方案
    将指令中需要修改的部分分离出来,放入数据段;指令部分就可以在进程间共享,而各进程各自拥有数据部分。
  • 实现
    定义在其他模块的全局变量和函数,其地址是和模块装载地址有关的。在数据段建立指向这些变量和函数的指针数组,也就是全局偏移表(Global Offset Table, GOT);当代码需要引用这些变量和函数时,通过GOT对应的项间接引用。
    pic_8_1

    每个进程私有GOT,对其有读写权限,可更改变量或函数的地址

PIC例子说明

  1. 编译以下代码 gcc –shared –fPIC –o pic.so pic.c
    pic_8_2

  2. 查看bar函数汇编
    pic_8_3

  3. 查看图中两个重定位入口
    pic_8_4

  4. 查看GOT地址
    pic_8_6

指令需要访问gob_var变量时,程序首先找到GOT的位置,然后根据GOT中变量所对应的项找到目标地址。链接器在装载模块时会查找每个变量的地址,然后填充GOT。由于GOT本身放在数据段,所以它可以在装载时被修改,每个进程有独立的GOT副本,相互不受影响。

延迟绑定(PLT)

PLT定义

  • 意义
    动态链接由于需要进行复杂的GOT定位,间接寻址或跳转,耗时本身比静态链接要慢1%到5%。每次程序运行,动态链接器寻找并装载所有需要的库,然后完成全部符号解析和重定位工作。
  • 定义
    函数第一次用到时才进行绑定(符号解析、重定位等),通过PLT(Procedure Linkage Table)技术实现。

PLT例子说明

  1. 引用上例的bar函数进行分析。延迟绑定并非直接调用got中的函数地址,而是调用plt中foo函数的地址,称为foo@plt pic_8_7
  2. foo@plt第一条指令跳转到got.plt保存的foo的函数地址201020 pic_8_8
  3. 201020处地址为5f6,正是foo@plt的第二条指令的地址。开始时所有未解析符号的地址均填入xxx@plt的第二条指令地址,该步骤不需要查找符号和重定位,正是延迟绑定可以实现快速装载链接的关键。 pic_8_9
  4. foo@plt第二条指令将需要解析的符号的下标压入堆栈,然后调用动态链接器的_dl_runtime_resolve函数完成解析和重定位工作,最后将foo函数真正的位置填入201020处;此后,调用foo@plt第一条指令将直接跳转至foo真正的地址。

动态链接步骤

前面从细节方面分析了动态链接的机制,下面我们从宏观的角度分析一下动态链接的步骤

  1. 动态链接器自举
    动态链接器本身也是一个不依赖其他共享对象的共享对象,需要完成自举。
  2. 装载共享对象
    将可执行文件和链接器自身的符号合并成为全局符号表,开始寻找依赖对象。加载对象的过程可以看做图的遍历过程;新的共享对象加载进来后,其符号将合并入全局符号表;加载完毕后,全局符号表将包含进程动态链接所需全部符号。
  3. 重定位和初始化
    链接器遍历可执行文件和共享对象的重定位表,将它们GOT/PLT中每个需要重定位的位置进行修正。完成重定位后,链接器执行.init段的代码,进行共享对象特有的初始化过程(例如C++里全局对象的构造函数)。
  4. 转交控制权
    完成所有工作,将控制权转交给程序的入口开始执行。

符号优先级

多个共享对象(so)中存在符号名冲突时,链接器是如何解决的?实际上是符号优先级的问题

两种查找序列

  1. 装载序列
    多个同名符号冲突时,先装入的符号优先;舍弃后装入的符号动态链接器进行符号解析和重定位时,默认采用装载序列。
  2. 依赖序列
    以打开的共享对象为根节点,对它依赖的共享对象进行广度优先遍历,直到找到符号为止dlopen打开共享对象,使用dlsym进行符号解析时,采用依赖序列。

Reference

  1. 程序员的自我修养—链接、装载与库
  2. How To Write Shared Libraries