linux 库全局变量_Linux下动态链接实现原理

微信公众号:二进制人生
专注于嵌入式linux开发。
更新日期:2020/06/06,内容整理自网络。

转自:https://www.linuxidc.com/Linux/2015-03/114572.htm,二进制人生将其涉及的代码改成了arm平台(基于海思开发板),并且增加了关于.rel.text节的介绍,和plt函数跳板的介绍,以及重定位介绍的补充。

目录

符号重定位链接时符号重定位(静态链接)加载时符号重定位(动态链接)

符号重定位

讲链接之前,得先说说符号重定位。

C/C++ 程序的编译是以文件为单位进行的,因此每个 c/cpp 文件也叫作一个编译单元(translation unit), 源文件先是被编译成一个个目标文件, 再由链接器把这些目标文件组合成一个可执行文件或库,链接的过程,其核心工作是解决模块间各种符号(变量,函数)相互引用的问题,对符号的引用本质是对其在内存中具体地址的引用,因此确定符号地址是编译,链接,加载过程中一项不可缺少的工作,这就是所谓的符号重定位。

因为编译是以源文件为单位进行的,编译器此时并没有一个全局的视野,因此对一个编译单元内的符号它是无力确定其最终地址的,而对于可执行文件来说,在现代操作系统上,程序加载运行的地址是固定或可以预期的,因此在链接时,链接器可以直接计算分配该文件内各种段的绝对或相对地址,所以对于可执行文件来说,符号重定位是在链接时完成的,但对动态链接库来说,因为动态库的加载是在运行时,且加载的地址不固定,因此没法事先确定该模块的起始地址,所以对动态库的符号重定位,只能推迟。

符号重定位既指在当前目标文件内进行重定位,也包括在不同目标文件,甚至不同模块间进行重定位,这里面有什么不同吗?如果是同一个目标文件内,或者在同一个模块内,链接后,各个符号的相对地址就已经确定了,看起来似乎不用非得要知道最后的绝对地址才能引用这些符号,这说起来好像也有道理,但事实不是这样,对于文件内全局变量的访问,由于全局变量位于data段或者bss段,根本就不知道其偏移地址,因为在编译该文件的时候,最终可执行程序的data段和bss段的布局并未确定;而对于函数调用,虽然是以相对地址进行调用,但计算相对地址也只限于在当前目标文件内进行,跨目标文件跨模块间的调用,编译期也是做不到的,只能等链接时或加载时才能进行相对地址的计算,因此重定位这个过程是不能缺少的,事实上目前来说,对于动态链接即使是当前目标文件内,如果是全局非静态函数,那么它也是需要进行重定位的,当然这里面有别的原因,比如说使得能实现 LD_PRELOAD 的功能等。

符号的重定位实际上发生于静态链接、动态链接、动态加载这三个过程。

全局变量的符号重定位只发生于链接过程。

函数的重定位对于静态链接而言在链接时确定,对于动态链接则在加载时确定。

链接时符号重定位(静态链接)

链接时符号重定位指的是在链接阶段对符号进行重定位,一般来说,构建一个可执行文件可以简单分为两个步骤:编译及链接,如下例子,我们尝试使用静态链接的方式构建一个可执行文件:

// file: a.c

int g_share = 

// file: main.c

extern 

正如前面所说,此时符号的重定位在链接时进行,那么在编译时,编译器是怎么生成代码来引用那些还没有重定位的符号呢?让我们先编译一下,再来看看目标文件的内容:
编译:

2019_project

反汇编:

2019_project

在汇编里,定一个机器字长的全局变量,通过.word指令,.word指令后面跟着的地址即变量的地址。从中可以看到,目标文件里的 .txt 段地址从 0 开始。我们可以留意最后一条指令,对应的就是int g_share;语句。显然这个地址是错的,编译器当前并不知道 g_share 这个变量最后会被分配到哪个地址上,因此在这儿暂时填0,等着到接下来链接时,再把该处地址进行修正。

那么,链接器怎么知道目标文件中哪些地方需要修正呢?很简单,编译器编译文件时时,会建立一系列表项,用来记录哪些地方需要在重定位时进行修正,这些表项叫作“重定位表”(relocatioin table):

2019_project

objdump的-r选项表示打印目标文件的重定位表项。

由于a.o文件也是一种elf格式文件,所以也可以通过readelf -r命令来查看重定向表项:

2019_project

关于elf格式可以见《linux程序员的基本修养》系列文章。

如上最后一行,这条记录记录了在当前编译单元中,哪儿对 g_share 进行了引用,其中 offset 用于指明需要修改的位置在该段(在这里就是代码段.text)中的偏移,TYPE 则指明要怎样去修改,因为 cpu 的寻址方式不是唯一的,寻址方式不同,地址的形式也有所不同,这个 type 用于指明怎么去修改(和平台有关,这里是arm), value 则是配合 type 来最后计算该符号地址的。

备注:对于中间文件.o来说,存在一个节叫.rel.text用来存放代码节.text的重定位信息,也就是上面打印的信息。
.rel.text由编译器产生,然后在连接时候,由链接器负责根据.rel.text对.text段进行修改,从而达到重定位目的。
在编译时,只要涉及全局变量(局部static也会)的访问,或者函数的调用,都会在.rel.text节生成相应的重定向符号表项。

有了如上信息,链接器在把目标文件合并成一个可执行文件并分配好各段的加载地址后,就可以重新计算那些需要重定位的符号的具体地址了, 如下我们可以看到在可执行文件中,对 g_share(00010454处), g_func(0001040c 处)的访问已经被修改成了具体的地址:

2019_project

当然,重定位时修改指令的具体方式还牵涉到比较多的细节很啰嗦,这里就不细说了。

加载时符号重定位(动态链接)

前面描述了静态链接时,怎么解决符号重定位的问题,那么当我们使用动态链接来构建程序时,这些符号重定位问题是怎么解决的呢?目前来说,Linux 下 ELF 主要支持两种方式:加载时符号重定位及地址无关代码。地址无关代码接下来会讲,对于加载时重定位,其原理很简单,它与链接时重定位是一致的,只是把重定位的时机放到了动态库被加载到内存之后,由动态链接器来进行。
a.c:

int g_share = 

将a.c文件制作成动态库:

arm-himix200-linux-gcc 

看下liba.so的重定位符号表:

2019_project

和.o文件有很大不同,有很多的表项,来自两个节.rel.dyn,rel.plt,我们后面再介绍。

将main.c链接该库:

arm-himix200-linux-gcc 

动态库的处理就要稍微复杂一下,g_func2调用了内部函数g_func,动态库是在程序启动时动态加载,即使链接之后,g_func2也无法知道g_func的地址。那么解决办法是什么呢?增加了一个“假的”中间函数:g_func@plt。我们通过反汇编可以看到g_func2调用的是中间接口g_func@plt。中间接口g_func@plt的地址在程序链接之后就是确定了,毋庸置疑吧。

所有的这些中间接口,或者叫函数跳板,都位于.plt节。

liba.so的反汇编结果如下:

Disassembly of section .plt:

00000440 <.plt>:440:   e52de004        push    {lr}            ; (str lr, [sp, #-4]!)444:   e59fe004        ldr     lr, [pc, #4]    ; 450 <.plt>448:   e08fe00e        add     lr, pc, lr44c:   e5bef008        ldr     pc, [lr, #8]!450:   00010bb0        .word   0x00010bb000000454 <__cxa_finalize>:454:   e28fc600        add     ip, pc, #0, 12458:   e28cca10        add     ip, ip, #16, 20 ; 0x1000045c:   e5bcfbb0        ldr     pc, [ip, #2992]!        ; 0xbb000000460 <__gmon_start__>:460:   e28fc600        add     ip, pc, #0, 12464:   e28cca10        add     ip, ip, #16, 20 ; 0x10000468:   e5bcfba8        ldr     pc, [ip, #2984]!        ; 0xba80000046c :46c:   e28fc600        add     ip, pc, #0, 12470:   e28cca10        add     ip, ip, #16, 20 ; 0x10000474:   e5bcfba0        ldr     pc, [ip, #2976]!        ; 0xba0

g_func和g_func2的反汇编结果:

000005ec 

我们现在的疑问是g_func@plt这个假的函数做了什么事?所有这些位于plt节的函数跳板都做了一件事:跳转到函数的真正地址去执行。

我们都知道动态库没有被编译进可执行程序,只有少数信息被编译进可执行程序,那么是什么信息呢?
其实也就是这些跳板函数(.plt节)以及动态库里定义的全局变量。

在链接之后,这些plt函数原本是相对地址,会更新成绝对地址:(来自a.out的反汇编)

000104fc 

g_func@plt这个假函数,无论如何,最终还是要调用真函数g_func。如何调用?

由于是动态链接,我们并不知道函数的真正地址,所以开辟了一个节,叫.got节(全局偏移表),里面用于存放函数加载后的真实地址,程序链接之后got表的地址是假地址,只有在第一次调用动态库里的函数时,got表相应的表项才会更新。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。

g_share 的地址仍然是假地址(链接之前),在链接之后更新为真实地址,这一点和静态链接是一样的。和静态链接不同的地方在于函数的调用(就是多了前面说的.plt节和.got节的周转)。

这些地址在动态库加载完成后会被动态链接器进行重定位,最终修改为正确的地址,这看起来与静态链接时进行重定位是一样的过程,但实现上有几个关键的不同之处(其实就是多了前面的.plt节和.got节的周转):

因为不允许对可执行文件的代码段进行加载时符号重定位,因此如果可执行文件引用了动态库中的函数符号,则在该可执行文件内对符号的重定位必须在链接阶段完成,为做到这一点,链接器在构建可执行文件的时候,会在当前可执行文件的数据段里分配出相应的空间(也就是.got表)来存放该符号的内存地址,等到运行时加载动态库后,把got表相应的项更新为动态库加载后真正的内存地址。

ELF 文件对动态库中的函数调用采用了所谓的"延迟绑定”(lazy binding), 动态库中的函数在其第一次调用发生时才去查找其真正的地址,因此我们不需要在调用动态库函数的地方直接填上假的地址,而是使用了一些跳转地址(也就是中间层.plt函数)作为替换。

至此,我们可以发现加载时重定位实际上是一个重新修改动态库代码的过程,但我们知道,不同的进程即使是对同一个动态库也很可能是加载到不同地址上,因此当以加载时重定位的方式来使用动态库时,该动态库就没法做到被各个进程所共享,而只能在每个进程中 copy 一份:因为符号重定位后,该动态库与在别的进程中就不同了,可见此时动态库节省内存的优势就不复存在了。

后面会专门介绍.got、.plt、.rel.dyn和.rel.plt节,以及延迟绑定技术。

喜欢点个在看

c5435fe1412de8e473d80d00db221f8a.png
图:二进制人生公众号
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值