动态链接分析和总结

为什么需要动态链接

  1. 空间原因:每个程序都会用到一些例如scanf、printf等常用的公共库函数,如果使用静态链接,那么每个程序都会保留一份对应的实现,磁盘空间就会被大量浪费,而且特别是在现代多进程操作系统下,每一个静态链接的进程在运行状态下也会浪费大量的内存空间。

  1. 程序的更新:静态链接下,如果某一个库出现问题,那么整个程序就需要重新链接,比较不便利。

动态链接主要就是解决上述这两个问题,其思想就是将目标文件的连接过程推迟到运行时再进行。

简单的例子

假设A.o和B.o依赖C.o;当系统加载A.o的时候,发现依赖C.o,就会把C.o 也加载到内存中。如果C.o依赖其他模块也会加载到内存中,直到所有依赖被加载完之后才开始链接过程。当之后系统在其他程序中使用到B.o 的时候,系统发现C.o已经在内存中了,就不会重复将C.o加载到内存。

引用《程序员的自身修养》一书中的例子:

//program1.cpp
#include "lib.h"

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

// program2.cpp
#include "lib.h"

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


// lib.h
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif

// lib.cpp
#include <cstdio>
#include <unistd.h>
#include "lib.h"

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

通过一下方式编译成共享库对象

g++ lib.cpp  -fPIC -shared -o Lib.so 

然后再对Program1.cpp 和 Progream2.cpp 进行连接

g++ program1.cpp -o program1 ./Lib.so
g++ program2.cpp -o program2 ./Lib.so

整个例子的链接过程如图所示

查看进程空间分布

>>>./program1 &
[1] 21966

>>>cat /proc/21966/maps
55b4f8ed8000-55b4f8ed9000 r--p 00000000 fe:21 537520621               ..../example1/program1
                                      ..............
55b4f9055000-55b4f9076000 rw-p 00000000 00:00 0                          [heap]
                                      ..............
7f407272c000-7f407274e000 r--p 00000000 fd:00 33598804                   /usr/lib/x86_64-linux-gnu/libc-2.28.so
                                      ..............
7f40728e8000-7f40728ec000 rw-p 00000000 00:00 0 
7f40728ec000-7f40728ef000 r--p 00000000 fd:00 33576861                   /usr/lib/x86_64-linux-gnu/libgcc_s.so.1
                                      ..............
7f4072a89000-7f4072b12000 r--p 00000000 fd:00 33575055                   /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.25
                                      ..............
7f4072c09000-7f4072c0d000 rw-p 00000000 00:00 0 
7f4072c16000-7f4072c17000 r--p 00000000 fe:21 537520620                ..../example1/Lib.so
                                      ..............
7f4072c1b000-7f4072c1d000 rw-p 00000000 00:00 0 
7f4072c1d000-7f4072c1e000 r--p 00000000 fd:00 33598790                   /usr/lib/x86_64-linux-gnu/ld-2.28.so
                                      ..............
7f4072c46000-7f4072c47000 rw-p 00000000 00:00 0 
7ffe82a9c000-7ffe82abd000 rw-p 00000000 00:00 0                          [stack]
7ffe82af2000-7ffe82af5000 r--p 00000000 00:00 0                          [vvar]
7ffe82af5000-7ffe82af7000 r-xp 00000000 00:00 0                          [vdso]

可以看到进程虚拟空间中多出了几个文件的映射,

  • Lib.so和program1被操作系统用相同的方式映射到了虚拟地址空间之中,只是位置和长度会有不同

  • 还可以看出program1使用到了C的动态运行库libc-2.28.so、gcc的动态运行库libstdc++.so.6.0.25等。

  • ld-2.28.so这个是Linux的动态链接器,在运行Program1之前,动态链接器会持有控制权,等完成所有的链接工作之后才开始执行Program1

查看动态库的属性

使用readelf来看看动态库中的装载属性

readelf -l Lib.so 

Elf file type is DYN (Shared object file)
Entry point 0x1060
There are 9 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000500 0x0000000000000500  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x000000000000014d 0x000000000000014d  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x00000000000000bc 0x00000000000000bc  R      0x1000
  LOAD           0x0000000000002de0 0x0000000000003de0 0x0000000000003de0
                 0x0000000000000250 0x0000000000000258  RW     0x1000
  DYNAMIC        0x0000000000002df0 0x0000000000003df0 0x0000000000003df0
                 0x00000000000001f0 0x00000000000001f0  RW     0x8
.........................

 Section to Segment mapping:
  Segment Sections...
   00     .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 
   01     .init .plt .plt.got .text .fini 
   02     .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .fini_array .dynamic .got .got.plt .data .bss 
   04     .dynamic 
.........................

可以看到动态库文件和普通程序的区别很少,主要在于文件类型和装载地址。可以看到动态库的装在地址是从0x0000000000000000开始的。而可以肯定地是,Lib.s的最终装在地址肯定不是0x00000000000(从上面省略的进程空间分布可以看出,55b4f8ed8000-55b4f8ed9000是Lib.so的其中一段)。至于地址是如何分配的,会在之后进行解释。

地址无关代码

地址相关的代码就是固定装载到某一个位置的代码,因为动态链接的思想就是将链接过程推迟到运行时,所以如果某些库固定装载到某个位置的话,就会导致地址冲突。

为了解决地址冲突问题,最先能想到的就静态链接中的重定位,假设foo函数相对于代码段的地址是0x10,那么当确定装载地址在0x10000后,就可以遍历重定位表将所有foo函数的引用都重定位到0x10010这个地址。但这个方法不适用与动态链接,因为动态连接库的指令是在多个进程之间共享的,而多个进程之间的装载地址也不一定能保证完全一致。

所以更好的方式是将被修改的指令从代码段中分离出来,和数据段放在一起,这样指令部分就可以做到每个进程中共享。这种方式就是地址无关代码(PIC, Position-independent Code) 。

根据下面的代码,模块中对于各种函数和变量的访问方式可以分成4中情况

//example2.cpp

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

void bar(){
    a = 1; // type2: Inner-module data access
    b = 2; // type4: Inter-module data access
}

void foo(){
    bar(); // type1: Inner-module call 
    ext(); // type3: Inter-module call
}

type1:模块内函数调用

被调用的函数和调用函数的位置处于同一个模块内,这种情况是最简单的情况。对于现代操作系统来讲,这种调用选择的是相对地址调用或者是基于寄存器的调用,是不需要进行重新定位的。但实际上因为存在全局符号介入(Global Symbol Interpostion)的问题, 代码type1的位置实际上也是采用类似于type3的方式进行的调用。

不过修改一下源代码,在bar的定义之前加上static,就可以看到调用的方式是采用相对调用,在重定位表中也不会出现bar的调用了。

0000000000001105 <_ZL3barv>:
    1105:       55                      push   %rbp
    ..............

0000000000001123 <_Z3foov>:
    ..............
    1127:       e8 d9 ff ff ff          callq  1105 <_ZL3barv>
    112c:       e8 ff fe ff ff          callq  1030 <_Z3extv@plt>
    ..............

type2: 模块内数据访问

对于模块内得数据访问,很明显在指令中是不能包含数据得绝对地址的。而一个模块的构成前面一般是若干页的代码,后面紧跟着若干页的数据。换句话说指令与其访问的内部数据之间的地址是相对固定的,通过相对寻址就可以找到数据。

不过在现代的体系结构中,数据的相对寻址是没有 相对于当前指定地址(PC) 的寻址方式的,所以ELF用了一个巧妙地方式得到PC地址,然后再加上偏移量就得到数据地址了。(注意在X86下,是需要特殊的方式的,但在x86-64下不需要,因为X86-64下直接有相对应的寻址方式,可以看https://stackoverflow.com/questions/6679846/what-is-i686-get-pc-thunk-bx-why-do-we-need-this-call,上面有对于这个问题的解释和讨论)。

ELF用的方式实际上是通过Call指令会将下一条指令地址压入栈顶,通过esp(栈顶指针寄存器)寄存器就可以获取对应的PC地址了。对上述代码进行编译分析,可以看到

>>> objdump -d example2
......
00001159 <_Z3barv>:
    1159:       55                      push   %ebp
    115a:       89 e5                   mov    %esp,%ebp
    115c:       e8 41 00 00 00          call   11a2 <__x86.get_pc_thunk.ax>
    1161:       05 9f 2e 00 00          add    $0x2e9f,%eax
    1166:       c7 80 1c 00 00 00 01    movl   $0x1,0x1c(%eax)
    116d:       00 00 00 
    1170:       8b 80 f0 ff ff ff       mov    -0x10(%eax),%eax
    1176:       c7 00 02 00 00 00       movl   $0x2,(%eax)
    117c:       90                      nop
    117d:       5d                      pop    %ebp
    117e:       c3                      ret    

000011a2 <__x86.get_pc_thunk.ax>:
    11a2:       8b 04 24                mov    (%esp),%eax
    11a5:       c3                      ret    

上述代码是在x86下重新编译测试的, 可以看到0x115c的位置,先call 了get_pc_thunk函数,在get_pc_thunk函数中将esp的内容给了eax并且返回,回到0x1161后将eax 加上了0x2e9f ,最后通过相对于eax偏移0x1c得到变量a 的地址。

a = 0x1161 + 0x2e9f + 0x1c = 0x401c

type3: 模块间数据访问

因为模块间的数据访问需要等到程序装载的时候才能确定地址,ELF做法是在数据段内建立一个存放其他模块的全局变量地址,当访问这种全局变量的时候,就会通过这个表中对应的项进行间接访问,这个表被称为全局偏移表(GOT, Global Offset Table)。

例如上面的代码,在bar中访问b时,程序会先访问GOT,然后根据GOT中的项找到变量的地址,然后进行访问。链接器在装载的时候会查找每个变量的所在地址,然后填充GOT的每一个项。这样每个进程都有独立的副本,不会相互影响。

回头看_Z3barv的汇编代码,eax当前的是0x4000, 访问b的时候,通过eax - 0x10 = 3FF0进行访问。还可以通过objdump看到b的重定位项

>>> objdump -d example2
.......
00003ff0 R_386_GLOB_DAT    b
.......

type4:模块间的调用、跳转

对于模块间的跳转,也可以用类型三的方式来解决,只是GOT中保存的是目标函数地址,调用的时候通过GOT的项进行间接跳转。

这种方式比较简单,但存在一些性能问题,实际ELF会用一种更加复杂和精巧的方式来进行。

小结

指令跳转、调用

数据访问

模块内部

相对跳转

相对地址访问

模块外部

间接跳转和GOT

间接访问GOT

PLT(延迟绑定)

动态链接再执行时也存在一些缺陷,之前说过模块间的调用和跳转如果使用GOT的方式,每次调用都需要先定位GOT,然后再通过GOT 进行间接跳转。而且程序开始的时候需要进行动态链接的工作,即程序开始的需要装载共享对象,然后对符号进行重定位。这两个是影响动态链接性能的主要问题。

为了解决启动时对所有符号进行重定位的问题,采用了延迟绑定(Lazy Bining)的做法,基本思想就是第一次用到对应函数才进行绑定,这种做法可以加快程序的启动速度,也利于引入大量函数的模块。

ELF使用PLT(Procedure Linkage Table)来实现延迟绑定的做法。PLT在原先GOT的基础上增加了一个跳转层,每个外部函数在跳转层中都有一个对应的项,被称为PLT项。bar函数的PLT项被称为bar@plt。

bar@plt由以下几条指令实现:

  1. jmp *(bar@got)

  1. push n

  1. push moduleID

  1. jump _dl_runtime_resolve

第一条指令是通过got进行间接跳转的指令。其中bar@got表中GOT表中保存的bar对应的项,也就是bar函数真实的地址。但因为延迟绑定的原因,链接器初始化时期并没有将bar()的地址填入bar@plt中,而只是将第二步push n的地址填入了bar@plt中,这步没有符号查找,消耗没有那么高。

第二步向栈中压入数字n,这个n是bar符号在重定位表rel.plt的下标。

第三步向栈中压入模块ID。

第四步跳转到_dl_runtime_resolve,这个_dl_runtime_resolve是个查找函数地址的调用,这边在查找到bar的真实地址后,会写入到bar@got中。

第一次调用函数的时候,整个bar@plt会执行到第四步,然后填写到bar@got中。当再次执行到bar@plt的时候,就会直接jmp到bar的真实地址。

实际上,PLT的实现还要再复杂一些。ELF将GOT表拆成两个表,一个叫.got表, 用来保存变量的地址。一个叫.got.plt表,用来保存函数引用的地址,另外.got.plt的前三项有特殊含义:

  1. 第一项保存的是.dynamic段的地址。该段记录了该模块动态链接的信息

  1. 第二项保存的是本模块的ID

  1. 第三项保存_dl_runtime_resolve的地址

第二项和第三项是在运行时动态链接进行的初始化。

由于PLT中的每一项的第三、四步是相同的,所以还可以进一步优化,将第三、四步提取出来放到PLT0的位置,PLT每一项的代码就可以优化成以下形式:

PLT 0:
    push *(GOT+4)
    jump *(GOT+8)
bar@plt:
    jmp *(bar@got)
    push n
    jmp PLT0
........

回到之前的example2代码, objdump可以看到对应得实现内容。

>>> objdump -d eaxmple2
00001020 <.plt>:
    1020:       ff b3 04 00 00 00       pushl  0x4(%ebx)
    1026:       ff a3 08 00 00 00       jmp    *0x8(%ebx)
    102c:       00 00                   add    %al,(%eax)

00001030 <_Z3barv@plt>:
    1030:       ff a3 0c 00 00 00       jmp    *0xc(%ebx)
    1036:       68 00 00 00 00          push   $0x0
    103b:       e9 e0 ff ff ff          jmp    1020 <.plt>

共享模块的全局变量问题

回相地址无关代码中的4中方式,可以看到没有提到对于全局变量的问题,比如在一个共享模块中定义了global变量,而在其他模块中使用下面的方式进行引用:

//module.c
extern global

int foo(){
    global = 1;
}

先假设module.c是主模块的一部分,由于主模块不是地址无关代码,所以主模块就会采用访问普通变量的方式来对global进行访问

movl $0x1, xxxxxxxxxx

其中xxxxxxxxxx 就是global的地址。因为运行时不进行代码重定位,所以xxxxxx必须在链接过程中确定下来,而extern的global变量在链接过程中会在bss段上创建一个副本,但实际上global对象的定义是在另一个共享模块中,这样就出现global变量会有两个地址的问题。

所以共享库做出妥协,在编译共享库的时候把定义在模块内的全局变量都当作其他模块的全局变量进行访问,也就是通过type4 的方式进行访问。

再假设module.c是一个共享模块的一部分,那么再-fPIC的作用下,产生的依然是跨模块的代码,因为编译器无法确定对global的引用时跨模块的还是模块内部的,而且即使时模块内部的,那么可执行文件还是可以对global进行引用,也会导致上面的问题,所以共享模块对global的应用都需要指向可执行文件中的副本。

全局符号介入问题

这边先简单提一下符号介入的问题,实际上指得是当模块加载过程中,符号被加入全局符号表得时候,如果同名符号已经存在,则后加入得符号会被忽略;而动态链接器得装载顺序是按照广度顺序进行加载得。

那么对于之前使用type1的例子中 非static的bar是不能采用type1的方式进行调用的,因为这样bar无法知道会不会其他同名函数覆盖导致对应的调用地址需要重定位。所以编译器只能采用第三种方式来对bar进行调用。而static 的函数不会被其他模块覆盖,所以可以采用第一种方式进行调用。

小结

这边总结了动态链接时地址无关代码的调用方式,以及GOT 和 PLT的相关作用,对于动态链接的相关符号表和重定位表的具体结构这边没有体现,更多的是《程序员的自我修养》动态链接的前2小节的内容,基本上算对动态链接库的编译和链接有所了解了。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值