动态链接(一)

2023-11-02

1. 为什么要动态链接

静态链接的缺点:

(1)内存和磁盘空间:

比如有两个程序,目标文件分别为Program1.o,Program2.o,并且都用到Lib.o这个模块。静态链接生成可执行文件Program1,Program2时,它们都分别存有Lib.o模块的一个副本。当同时运行Program1和Program2时,Lib.o在磁盘和内存中都有两个副本。可见会造成内存和磁盘空间的浪费。

(2)程序开发和发布:

在静态链接下,如果某个模块发生了改变,整个程序需要重新链接,然后再重新发布。对于用户来说,每次更新都需要重新下载整个程序。

动态链接的基本思想就是将链接这个过程推迟到运行时再进行。

以Program1和Program2为例,假设现在有Program1.o,Program2.o和Lib.o,当运行Program1时,系统首先会加载Program.o,然后发现它依赖于Lib.o,于是加载Lib.o,按照同样的方法将需要的所有目标文件都加载至内存,接着进行链接工作,和静态链接类似,包括符号解析,地址重定位等,最后系统把控制权交给Program1.o的程序入口处,程序开始运行。如果现在需要运行Program2,系统则加载Program2.o,发现Program2.o依赖的Lib.o已经在内存中了,因此系统接着直接执行链接工作。

可以看到动态链接情况下,当某个模块发生改变时,无需重新链接一遍,只需要简单地将目标文件覆盖掉,程序下次运行时,新版本的目标文件会自动被加载并链接,程序自动完成升级。动态链接使各个模块耦合度更小。

动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这就是插件(Plug-in)的原理。如某个公司开发完成了某个产品,并且给出了指定好的程序接口,第三方开发者可以按照这种接口来编写符合要求的动态链接文件。该产品程序可以动态地载入各种由第三方开发的模块,实现程序功能的扩展。

动态链接还可以加强程序的兼容性。一个程序在不同的平台下运行时可以动态地链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加一个中间层。对于静态链接,程序需要分别链接成能够在系统A和系统B下运行的两个版本就分开发布,对于动态链接,只要系统提供了动态链接所需要的接口,则程序即可在该系统下运行,理论上只需要一个版本。

动态链接文件和目标文件的结构会有所不同。Linux下ELF动态链接文件被称为动态共享对象(DSO,Dynamic Shared Objects),扩展名为“.so”,Windows下,动态链接文件被称为动态链接库(Dynamical Linking Library),扩展名为“.dll”。

2. 简单的动态链接例子

Windows下的PE动态链接机制和Linux下的ELF稍有不同,这里先以ELF作为例子。例子源码如下:

Program1.c

#include"Lib.h"

int main() {
	foobar(1);
	return 0;
}

Program2.c

#include"Lib.h"

int main() {
	foobar(2);
	return 0;
}

Lib.c

#include<stdio.h>

void foobar(int i) {
	printf("Printing from Lib.so %d\n",i);
	sleep(-1);
}

Lib.h

#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif

将Lib.c编译成一个共享对象文件:

gcc -fPIC -shared -o Lib.so Lib.c

-shared表示产生共享对象。然后分别编译链接Program1.c和Program2.c:

gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so

整个编译和链接过程如下:

在命令行中可以看到Lib.so也参与了链接过程,但实际上链接的输入目标文件只有Program1.o(当然还有C语言运行库,这里暂时忽略)。在链接过程中,对于一个定义于其他静态目标模块的符号,链接器会按照静态链接的规则将符号地址重定位,而如果该符号定义于动态共享对象,则链接器会将这个符号的引用标记为一个动态链接的符号,把重定位过程留到装载时再进行。那么如何知道一个符号的引用属于静态符号还是动态符号呢,这就是前面命令中需要加上Lib.so的原因。Lib.so中保存了完整的符号信息(因为动态链接时需要用到这些信息),链接器可以通过这些信息确定那些符号属于动态符号。因此在这里,Lib.so只是起提供符号信息的作用,并没有链接进最终可执行文件。

与静态链接不同,动态链接下,除了可执行文件本身之外,所依赖的共享对象文件也需要映射到进程的虚拟地址空间。

查看进程的虚拟地址空间分布:

可以看到Lib.so也被映射到进程的虚拟地址空间,还有动态链接形式的C语言运行库libc-2.23.so,可以看到还有一个是ld-2.23.so,实际上这是Linux下的动态链接器。首先系统会把控制权交给动态链接器,由它完成所有的动态链接工作之后再把控制权交给Program1,然后开始执行。

还有一点是,共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。

3. 地址无关代码

3.1 装载时重定位

这个想法的基本思路是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。

这种方法存在缺点,在动态链接模块被装载映射至虚拟空间后,指令部分理论上是可以在多个进程共享的。但由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然对于可修改数据部分,这部分在每个进程中都有一个副本,所以这部分可以使用装载时重定位的方法来解决。前面的编译命令使用了-shared和-fPIC参数,如果只使用-shared,那么输出的共享对象就是使用装载时重定位的方法。

3.2 地址无关代码

对于装载时重定位的缺点,所以目的是希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变。地址无关代码(PIC,Position-independent Code)技术的基本想法是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分可以保持不变,而数据部分可以在每个进程中拥有一个副本。

把共享对象模块中的地址引用按照是否跨模块分成模块内部引用和模块外部引用,按不同的引用方式分为指令引用和数据引用,因此有4种情况:

(1)模块内部的函数调用,跳转。

(2)模块内部的数据访问。

(3)模块外部的函数调用,跳转。

(4)模块外部的数据访问。

pic.c

static int a;
extern int b;
extern void ext();

void bar() {
	a=1; 	//type 2
	b=2; 	//type 4
}

void foo() {
	bar(); 	//type 1
	ext(); 	//type 3
}

实际上编译器并不能确定b和ext是模块外部的还是模块内部的,因为它们有可能是被定义在同一个共享对象的其他目标文件中。因此统一当作模块外部来处理。

(1)模块内部调用跳转

被调用函数和调用者处于同一个模块,它们之间的相对位置是固定的。模块内部的跳转,函数调用都可以是相对地址调用,或者是基于寄存器的相对调用。相对地址调用指令是指指令中的数据部分代表被调函数相对于调用指令下一条指令的偏移,因为这个偏移是固定不变的,因此对于这种指令是不需要重定位的。无论模块被装载到哪个位置,这条指令都是有效的。

(2)模块内部数据访问

在一个模块内,页之间的相对位置是固定的。即任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令的下一条指令的地址加上固定的偏移量就可以访问模块内部数据了。rip寄存器存放的正是下一条指令的地址:

可以看到%rip加上偏移0x200916就是变量a的地址,即0x71e+0x200916=0x201034。即如果模块被装载到0x10000000这个地址的话,则a的地址为0x10000000+0x71e+0x200916=0x10201034。

(3)模块间数据访问

基本思想是把地址相关的部分放到数据段里面。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,称为全局偏移表(Global Offset Table ,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。

链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针指向的地址正确。GOT是存放在数据段的,每个进程都可以有独立的副本,相互不受影响。从第二种类型中可以看到数据段里的模块内部变量相对于当前指令的偏移是固定的,GOT也是存放在数据段的,所以GOT相对于当前指令的偏移也是固定的。通过这个偏移可以找到GOT,再根据变量地址在GOT中的偏移,可以得到变量的地址。当然变量地址在GOT中的偏移是由编译器确定的。

看到上面的反汇编码,先把变量b的地址的偏移赋值给eax,即0x2008b3+0x725=0x200fd8。再通过寄存器间接寻址给变量b赋值。使用objdump -R pic.so查看pic.so在动态链接时需要重定位的项,发现b的地址的偏移确实是0x200fd8。因为还没有进行动态连接,所以可以看到0x200fd8处的内容都是0。

使用objdump -h pic.so查看GOT的位置:

可以知道b的地址在GOT中的偏移是8。如果指针用8字节表示,则表示b在第二项,如果指针用4字节表示,则表示b在第三项。

(4)模块间调用,跳转

这种情况也可以用GOT,GOT中相应的项保存的是目标函数的地址。

调用ext的汇编代码:

调用地址是0x5f0,是ext@plt的地址。ext@plt内容如下,其中plt是什么后面会介绍。

第一条指令是跳转指令,跳转地址是0x5f6+0x200a2a=0x201020,这就是ext函数的地址的真正偏移,查看重定位项发现确实如此。

GCC使用-fpic和-fPIC都可以产生地址无关代码,其中-fpic产生的代码相对较小,而且较快,但在某些平台上会有限制,而-fPIC则没有。

以下命令可以判断某个DSO是否为PIC,没有输出就代表是PIC。

readelf -d pic.so | grep TEXTREL

3.3 共享模块的全局变量问题

对于定义在模块内部的全局变量,实际上并不能简单地按第一种类型来解决。比如一个模块module.c如下:

extern int global;
int foo() {
    global=1;
}

global是定义在其他共享对象的全局变量。当编译module.c时,无法判断global是否属于模块间调用。

假设module.c是可执行文件的一部分,即程序的主模块,如果编译该文件时没有使用类似PIC的机制,那么该程序的主模块代码并不是地址无关代码。它引用这个全局变量就和普通数据访问方式一样,会产生类似下面的代码:

movl $0x1, xxxxxxxx

xxxxxxxx是global的地址,因此变量的地址必须在链接过程中(静态链接)确定下来。为了使链接过程顺利进行,链接器会在可执行文件的.bss创建一个global的副本。xxxxxxxx就是该副本的地址。但实际上global是定义在共享对象中,这样程序运行时会发现global有多个副本。因此解决方法是ELF共享库在编译时,默认把定义在自身模块内部的全局变量当作定义在其他模块的全局变量处理,也就是第三种类型,通过GOT实现数据访问。当共享模块被装载时,发现某个全局变量在可执行文件中存在一个副本,那么动态链接器会把GOT中的相应地址指向该副本,那么程序运行时该变量只有一个实例。如果变量在共享模块被初始化,动态链接器还会将初始值拷贝到主模块的副本中。如果主模块不存在副本,那么共享对象的GOT中相应地址自然就指向自身模块内部的该变量。

如果module.c是一个共享对象的一部分,那么在参数-fPIC的情况下,会把global的调用按照模块间数据访问的方式产生代码。因为即使global属于模块内引用,但它也有可能被主模块可执行文件引用,从而使共享对象中对global的引用要执行可执行文件中的global副本。

3.4 数据段地址无关性

数据段也会存在有绝对地址引用的问题:

static int a;
static int *p=&a;

a的地址随着装载地址的改变而改变,因此这段代码并不是地址无关的。但因为数据段每个进程都有一个副本,因此可以简单地使用装载时重定位的方式来解决数据段中绝对地址引用问题。

4. 延迟绑定(PLT)

动态链接比静态链接灵活,但性能会稍微差一些,主要有两个原因:

(1)对于模块间的调用或数据访问,都需要进行复杂的GOT定位,即先定位GOT,再进行间接跳转。

(2)程序开始执行时,动态连接器需要进行一次链接工作。

如果一个程序有很多个模块,包含很多函数,但其中很大一部分在程序执行完毕都没有被调用,因此为这些函数进行重定位其实是没必要的。因此ELF采用了一种叫做延迟绑定(Lazy Binding)的做法,基本思想就是当函数第一次被用到时进行绑定。

ELF使用PLT(Procedure Linkage Table)的方法来实现。假设liba.so需要调用libc.so中的bar()函数,那么当第一次调用时,需要调用动态连接器中的某个函数来完成地址绑定工作,假设这个函数为lookup()。lookup()至少要知道地址绑定发生在哪个模块,是哪个函数。假设原型为lookup(module,function)。这个例子就分别是liba.so和bar。其实这里lookup()函数真正的名字是_dl_runtime_resolve()。

当调用某个外部函数时,按照通常的做法是通过GOT中相应的项进行间接跳转,PLT为了实现延迟绑定,在这个过程中增加了一层间接跳转,调用函数是通过一个叫作PLT项的结构进行跳转。如ext()函数在PLT中的项的地址为ext@plt。

第一条指令是通过GOT间接跳转的指令。跳转地址是0x201020,这是ext函数在GOT中相应的项的地址,这个项保存的理应是ext函数的真正地址,但因为实现了延迟绑定,因此这个项还没有填入真正的地址,而是下一条指令的地址,可以看到0x201020的内容如下:

内容为0x5f6,正是下一条指令的地址。因此当第一次进入ext@plt时候,第一条指令相当于什么都没做。下一条指令是把ext这个符号引用在重定位表.rel.plt中的索引入栈,可以通过readelf -r pic.so查看:

接着跳转到0x5d0处

第一条指令就是把当前模块ID入栈,第二条就是调用_dl_runtime_resolve()函数来完成符号解析和重定位工作。0x201008和0x201010处的值是由运行时动态链接器初始化的,所以现在看到都是0。一旦解析完毕,下次调用时,ext@plt的第一条指令就直接跳转到了ext函数的入口。并且ext在返回时,根据堆栈里保存的EIP直接返回到调用者,而不会执行ext@plt之后的指令。

以上是PLT的基本原理,实际实现回复杂些。ELF将GOT拆分成了两个表,分别是.got和.got.plt。.got用来保存全局变量引用地址,.got.plt用来保存函数引用地址。其中.got.plt前三项有特殊含义:

(1)第一项保存.dynamic段的地址。(2)第二项保存的是本模块的ID。(3)第三项保存的是_dl_runtime_resolve()的地址。

之后的便是外部函数引用的地址。

这里每一项占8个字节,可以看到第二项和第三项都是0,因为还没初始化。可以看到第四项和第五项的地址初始化都是对应函数的plt中的第二条指令的地址。

关于PLT结构数组,每个外部函数引用都对应PLT结构数组中的一项。因为每个函数的plt中都有两个同样的操作:(1)将当前模块ID入栈。(2)跳转到_dl_runtime_resolve()。因此为了减少代码重复,把这两条指令统一放到了PLT结构数组的第一项,即PLT0。

现在回头看,地址0x5d0就是PLT0,接着是PLT1和PLT2。PLT结构长度是16字节,保证能刚好存放三条指令。PLT在ELF文件中以独立的段存放,段名为.plt,因为本身是地址无关的代码,所以可以和代码段合并成一个Segment被装载。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

动态链接(一) 的相关文章

随机推荐

  • 【OLED驱动函数详解】

    OLED驱动函数详解 前提 通讯方式 地址排列 寻址方式 正文 初始化 一些使用命令的函数 显示一个字符 在指定位置显示一个字符串 字符串居左显示 字符串居右显示 字符串居中显示 在指定位置显示一个中文字符 在指定区域显示图片 在指定位置显
  • 什么是无线路由器网络协议?

    上一篇我们介绍了什么是网络协议转换器 相信看过的朋友对此都有了一定的认知 可能有些朋友在使用协议转换器的时候用的是无线路由器网络 那么 什么是无线路由器网络协议呢 接下来飞畅科技的小编就来为大家详细介绍下无线路由器网络协议是什么 感兴趣的朋
  • ajax下载文件无响应,xml格式解析不正确

    今天朋友在做文件下载时遇到了一个问题 整个请求后台没有报一点错 而且请求也进入了响应Controller 但是页面就是没有任何响应 让我帮看下文件下载代码是否有问题 所有下载文件代码看了一遍确实没发现任何问题 我百思不得其解 突然想到会不会
  • CentOS7.x 安装RabbitMQ后-自定义配置文件

    承接CentOS7 x 安装RabbitMQ 3 7 x 背景 启动rabbitmq 然后登陆后 可以看到刚刚安装完成的rabbitmq使用的是默认的配置 还没有自定义的配置文件 1 配置文件位置 利用下面的命令查询rabbitmq配置文件
  • SaltStack常用模块

    SaltStack常用模块 SaltStack模块介绍 Module是日常使用SaltStack接触最多的一个组件 其用于管理对象操作 这也是SaltStack通过Push的方式进行管理的入口 比如我们日常简单的执行命令 查看包安装情况 查
  • vue vue-router实现路由拦截功能

    vue vue router实现路由拦截功能 1 目录结构 2 设置路由拦截 路由配置如下 在这里自定义了一个对象的参数meta authRequired true 来标记哪些路由是需要登录验证的 导航被触发的时候只要判断是否目标路由中是否
  • 【AI】Diffusion Models

    大家好 我是Sonhhxg 柒 希望你看完之后 能对你有所帮助 不足请指正 共同学习交流 个人主页 Sonhhxg 柒的博客 CSDN博客 欢迎各位 点赞 收藏 留言 系列专栏 机器学习 ML 自然语言处理 NLP 深度学习 DL fore
  • 两种思路解决线程服务死循环

    背景 系统突然error飚高 不停Full GC 最后发现是因为调用的外部jar包中方法触发bug导致死循环 不断产生新对象 导致内存大量占用无法释放 最终JVM内存回收机制崩溃 解决思路 服务一旦进入死循环 对应线程一直处于running
  • (已解决)STM32L151使用串口发送数据第一字节为FE问题!

    已解决 STM32L151使用串口发送数据第一字节为FE问题 参考文章 1 已解决 STM32L151使用串口发送数据第一字节为FE问题 2 https www cnblogs com Irvingcode p 11603583 html
  • 【机器学习】KS值

    KS检验 风控角度 分类模型评判指标 KS曲线与KS值 从统计角度 我们知道KS是分析两组数据分布是否相同的检验指标 在金融领域中 我们的y值和预测得到的违约概率刚好是两个分布未知的两个分布 好的信用风控模型一般从准确性 稳定性和可解释性来
  • Spring创建Bean的全过程(一)

    Spring测试环境搭建 Spring模块概览 Spring中八大模块 黑色表示该模块的jar包 也就是组件 例如我们想要使用IOC容器 也就是绿色的CoreContainer 我们需要导入Beans Core Context SpEL s
  • Python+微信小程序开发实战课

    本套课程Python结合微信小程序开发实战 由前汽车之家架构师武沛齐老师主讲 共分为18天的课程 文件大小共计9G 课程除了讲解微信小程序开发的基础知识点外 更多的是示例演示 让大家知道如何灵活运用这些知识点 真正学到能够运用到具体开发工作
  • Unity3D Shader之路 写Shader前必须要知道的事情3 ShaderForge的简单使用

    版本 unity 5 4 1 语言 Unity Shader Shader Forge版本 1 32 总起 在具体介绍Shader之前准备再写一篇有关于ShaderForge的 虽然我们可能使用代码来直接编写Shader 但拥有Shader
  • python基础——列表推导式

    python基础 列表推导式 文章目录 python基础 列表推导式 一 实验目的 二 实验原理 三 实验环境 四 实验内容 五 实验步骤 一 实验目的 掌握Python数据结构 列表推导式的用法 二 实验原理 列表推导式 list com
  • 「Python 基础」常用模块

    文章目录 1 内建模块 datetime collections namedtuple deque defaultdict OrderedDict ChainMap Counter base64 struct hashlib 摘要算法 摘要
  • Tomcat的基本认识和使用

    服务器 安装了服务器软件的计算机 通常都是高配置的计算机 服务器软件 接收用户的请求 处理请求 做出响应 web服务器软件 通过浏览器来进行访问的一种服务器软件 在web服务器软件中 可以部署web项目 让用户通过浏览器来访问这些项目 常见
  • 常见泰勒展开公式及复杂泰勒展开求法

    目录 https blog csdn net weixin 45792450 article details 104404432 初等的函数泰勒展开 e x e x ex e
  • 【OpenCv】相机标定介绍及python/c++实现

    针孔相机内外参标定简单介绍 之前有一个项目需要公司标内参 之前对这方面没有接触过 网上找了很多资料 记录下相机标定的基础知识 文章是个人浅显理解 如有错误还请指正 非常感谢 参考链接 坐标系转换 相机参数标定 camera calibrat
  • keil4 编译提示 ERROR L107: ADDRESS SPACE OVERFLOW

    单片机型号STC15F2K60s2 编译环境keil4 系统win7 模式 small 错误 ERROR L107 ADDRESS SPACE OVERFLOW 这个错误意思 提示地址超出 又去看了一遍数据手册 不应该是两k吗 为啥用了13
  • 动态链接(一)

    1 为什么要动态链接 静态链接的缺点 1 内存和磁盘空间 比如有两个程序 目标文件分别为Program1 o Program2 o 并且都用到Lib o这个模块 静态链接生成可执行文件Program1 Program2时 它们都分别存有Li