程序员自我修养阅读笔记——动态链接

Posted 落樱弥城

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了程序员自我修养阅读笔记——动态链接相关的知识,希望对你有一定的参考价值。

1 为什么需要动态链接

  动态链接,顾名思义,就是只有在程序需要调用对应的库中的实现时才将对应的库的映像文件加载到内存。相比而言,静态链接是在编译阶段就将需要的目标文件中的相关实现连接到可执行文件中。动态链接和静态链接的使用有以下几点优缺点:

  • 库所占用的内存空间和磁盘空间
    • 对于静态链接是在生成可执行文件时就将实现连接到可执行文件中,也就是说如果多个程序都静态链接了一个库实现,那么最终不同的可执行文件中都会包含各自的一个该库的实现;而动态链接是在运行期才链接对应的库,如果内存中已经存在对应的库的映射的话只需要将地址重定位到对应的地址就行,不需要再进行加载。即当库高度共享时比如C的基础库之类,占用空间较小;
    • 因为静态链接在编译期就完成了可执行文件的打包,所有内容是确定的编译器就可以适当的限制相关的导出符号(毕竟并不是所有的代码你都会用到),来减小最终的可执行文件大小;而动态库是运行期链接的,编译器并不知道你真正需要哪些内容,会将所有的需要导出的内容打包(比如windows可移植性dllexport,clang的export_symbol),占用的空间反而会更大。即当库共享度比较低时,占用空间可能更大。
  • 程序的发布和更新:静态库更新意味着要重新发布可执行文件,而动态库只要保证api的一致性只需要更新库即可;
  • 可扩展性和兼容性
    • 可扩展性:程序的发布方可以预留一些接口供第三方使用,第三方只需要针对对应的接口开发插件,就可以扩展程序;
    • 可兼容性:可以针对不同平台加载不同的库实现,提高可移植性,当然只是理论上的。
  • 性能:动态链接相比于静态链接需要额外的加载过程,性能上有所损失,但是相对于其灵活性,这些损失可以忍受。

2 一个简单的例子

  下面使用一个简单的例子展示下动态链接和静态链接的区别,下面是使用的程序的例子。例子分为三个文件分别为add.h,add.c,main.cadd.c中实现了一个简单的函数,并且该函数中调用了sleep方便等下查看进程的地址空间。

//main.c
#include "add.h"

int main()
    add(1, 2);


//add.h
int add(int a, int b);

//add.c
#include "add.h"

int add(int a, int b)
    sleep(-1);
    return a + b;

  然后使用下面的命令现将add.c编译成动态库,然后再链接成具体的可执行文件,链接成两个文件是为了方便看共享的动态库的情况。

-fPIC 作用于编译阶段,告诉编译器产生与位置无关代码(Position-Independent Code)。

  另外,能够看到链接程序的时候用到了生成的动态库,因为连接时链接器并不知道哪些是动态库中的符号哪些不是,该库中包含完整的符号信息,告诉链接器这些符号是动态符号,暂时不需要进行地址重定位。

➜  tmp gcc -fPIC -shared -o libadd.so add.c
➜  tmp file libadd.so
libadd.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=b2dab749a15b3af44b6439da5fd718c3dc324ac1, not stripped
➜  tmp gcc -o program2 main.c ./libadd.so
➜  tmp gcc -o program1 main.c ./libadd.so

  查看程序加载的镜像文件能够看到文件除了加载可执行文件外,还有libc,ld,libadd三个动态库,libc是C程序运行需要的库,ld是加载动态库时需要的链接器,链接库时,该链接器会先获取控制权,该动态链接器将动态库映射到进程的地址空间后,将控制权移交给程序开始执行。

➜  tmp ./program1 &
[1] 696
➜  tmp cat /proc/696/maps
7f232edf0000-7f232efd7000 r-xp 00000000 00:00 107213             /lib/x86_64-linux-gnu/libc-2.27.so
7f232efd7000-7f232efe0000 ---p 001e7000 00:00 107213             /lib/x86_64-linux-gnu/libc-2.27.so
7f232efe0000-7f232f1d7000 ---p 001f0000 00:00 107213             /lib/x86_64-linux-gnu/libc-2.27.so
7f232f1d7000-7f232f1db000 r--p 001e7000 00:00 107213             /lib/x86_64-linux-gnu/libc-2.27.so
7f232f1db000-7f232f1dd000 rw-p 001eb000 00:00 107213             /lib/x86_64-linux-gnu/libc-2.27.so
7f232f1dd000-7f232f1e1000 rw-p 00000000 00:00 0
7f232f1f0000-7f232f1f1000 r-xp 00000000 00:00 42060              /mnt/e/code/tmp/libadd.so
7f232f1f1000-7f232f1f2000 ---p 00001000 00:00 42060              /mnt/e/code/tmp/libadd.so
7f232f1f2000-7f232f3f0000 ---p 00002000 00:00 42060              /mnt/e/code/tmp/libadd.so
7f232f3f0000-7f232f3f1000 r--p 00000000 00:00 42060              /mnt/e/code/tmp/libadd.so
7f232f3f1000-7f232f3f2000 rw-p 00001000 00:00 42060              /mnt/e/code/tmp/libadd.so
7f232f400000-7f232f428000 r-xp 00000000 00:00 107209             /lib/x86_64-linux-gnu/ld-2.27.so
7f232f428000-7f232f429000 r-xp 00028000 00:00 107209             /lib/x86_64-linux-gnu/ld-2.27.so
7f232f629000-7f232f62a000 r--p 00029000 00:00 107209             /lib/x86_64-linux-gnu/ld-2.27.so
7f232f62a000-7f232f62b000 rw-p 0002a000 00:00 107209             /lib/x86_64-linux-gnu/ld-2.27.so
7f232f62b000-7f232f62c000 rw-p 00000000 00:00 0
7f232f640000-7f232f643000 rw-p 00000000 00:00 0
7f232f660000-7f232f662000 rw-p 00000000 00:00 0
7f232f800000-7f232f801000 r-xp 00000000 00:00 42073              /mnt/e/code/tmp/program1
7f232fa00000-7f232fa01000 r--p 00000000 00:00 42073              /mnt/e/code/tmp/program1
7f232fa01000-7f232fa02000 rw-p 00001000 00:00 42073              /mnt/e/code/tmp/program1
7fffc400b000-7fffc480b000 rw-p 00000000 00:00 0                  [stack]
7fffc4e06000-7fffc4e07000 r-xp 00000000 00:00 0                  [vdso]

  查看动态库的Segment能够发现起始地址为0,说明其中的地址只是相对地址,具体地址还没确定。

➜  tmp readelf -l libadd.so
Program Headers:
  Type           Offset             VirtAddr           PhysAddr FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000 0x00000000000006e4 0x00000000000006e4  R E    0x200000
  LOAD           0x0000000000000e10 0x0000000000200e10 0x0000000000200e10 0x0000000000000218 0x0000000000000220  RW     0x200000
  DYNAMIC        0x0000000000000e20 0x0000000000200e20 0x0000000000200e20 0x00000000000001c0 0x00000000000001c0  RW     0x8
  NOTE           0x00000000000001c8 0x00000000000001c8 0x00000000000001c8 0x0000000000000024 0x0000000000000024  R      0x4
  GNU_EH_FRAME   0x0000000000000640 0x0000000000000640 0x0000000000000640 0x0000000000000024 0x0000000000000024  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000000e10 0x0000000000200e10 0x0000000000200e10 0x00000000000001f0 0x00000000000001f0  R      0x1

3 地址无关代码

  在进行连接时,为了确定符号的地址,在静态链接中是在链接的过程中确定目标在虚拟内存空间中的绝对地址。现在需要动态链接也就是说在映像文件加载之前是不知道符号的绝对地址的,所以可能的方案有两种:

  • 静态共享:即系统预留一块内存供程序使用,程序只需要将符号的内存映射到该区域即可,缺点显而易见,难以管理与维护;
  • 动态共享:在链接时使用相对位置,仅仅在加载镜像文件时在进行地址重定位。

  由于加载镜像文件时,文件中的符号的相对位置是不会发生改变的,因此只需要重定位基址即可,其他符号的地址完全可以根据基址+相对地址得到,也就是在装载时进行基址重置。

地址无关代码
  装载时进行基址重置能够解决绝对地址的引用问题,但是修改了指令导致每个进程内存中的映像无法共享,从而失去了动态库可以节省内存的优势。解决方案是利用PIC(地址无关代码)方案:将需要修改的指令分离出来和数据都存储到数据区域,每个进程各自维护一份,其他不需要改动的内容共享。
  PIC需要处理四种地址引用的方式:

  1. 模块内函数调用和跳转。只需要使用和当前指令的偏移即可。
  2. 模块内的数据访问。只需要使用和当前指令的偏移即可。
  3. 模块外部的函数调用。模块间的数据访问只有在转载时才能确定,ELF在数据段中创建了一个指向对应变量的指针数组,即全局偏移表(GOT),指令只需要根据该表间接访问即可。
  4. 模块外的数据访问。同情况3使用GOT间接访问。

共享全局变量
  对于能够判断符号的来源可以分为上面四种情况,但是对于使用extern int a;定义的全局变量我们无法判断其来源,因此ELF的做法是将该情况始终认定为情况4使用GOT间接访问。

数据段地址无关性
  数据段有可能用到绝对地址的地方,比如指针,如果指针指向一个共享库内的地址,则该地址是根据共享库加载的内存而变化的。对于这种情况,编译器和链接器会产生一个重定位表,动态链接器根据重定位表将对应的地址重定位。

4 延迟绑定(PLT)

  由于动态链接是在运行期链接并且进行重定位,本来直接访问的内存可能变成间接访问,会导致性能降低。ELF采用延迟绑定来缓解性能问题,其假设就是动态库中并不是所有的函数与数据都会用到,因此类似copy-on-write,仅仅在第一次符号被使用时才进行相关的重定位工作,避免对一些不必要的符号的重定位。
  ELF使用PLT(Procedure Linkage Table)实现延迟绑定。在进行重定位时每个符号需要了解符号在那个模块(模块ID)以及符号。当调用外部模块中的函数时,PLT为每个外部函数符号添加了PLT项,然后通过PLT项跳转到GOT表再到最终的函数地址。

  下面是简单的演示程序,使用gcc -fPIC -shared -o libpic.so pic.c编译为动态库,然后通过objdump -d libpic.so反汇编处汇编代码。

#include "pic.h"

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

void bar()
    a = 1;
    b = 2;


void foo()
    bar();
    ext();

  下面是bar函数的的反汇编代码,下面的反汇编和书上的反汇编不同,但是从代码中大概能看出第一步跳转到.plt然后再访问GOT的基本流程。另外,.plt在程序中作为一个单独的Section存在,最之用会和代码段合并为一个Segment被装入内存。

0000000000000510 <.plt>:
 510:   ff 35 f2 0a 20 00       pushq  0x200af2(%rip)        # 201008 <_GLOBAL_OFFSET_TABLE_+0x8>
 516:   ff 25 f4 0a 20 00       jmpq   *0x200af4(%rip)        # 201010 <_GLOBAL_OFFSET_TABLE_+0x10>
 51c:   0f 1f 40 00             nopl   0x0(%rax)

0000000000000530 <bar@plt>:
 530:   ff 25 ea 0a 20 00       jmpq   *0x200aea(%rip)        # 201020 <bar+0x2009f6>
 536:   68 01 00 00 00          pushq  $0x1
 53b:   e9 d0 ff ff ff          jmpq   510 <.plt>
 
000000000000062a <bar>:
 62a:   55                      push   %rbp
 62b:   48 89 e5                mov    %rsp,%rbp
 62e:   c7 05 fc 09 20 00 01    movl   $0x1,0x2009fc(%rip)        # 201034 <a>
 635:   00 00 00
 638:   48 8b 05 99 09 20 00    mov    0x200999(%rip),%rax        # 200fd8 <b>
 63f:   c7 00 02 00 00 00       movl   $0x2,(%rax)
 645:   90                      nop
 646:   5d                      pop    %rbp
 647:   c3                      retq

5 动态链接相关结构

  动态链接时,可执行文件的装载会首先读取可执行文件的Header,检查文件的合法性,然后从程序中的Program Header中读取每个Segment的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置。之后操作系统会启动一个动态链接器(ld.so),链接器对可执行文件进行动态链接的工作,最终将控制权移交到可执行文件,程序开始运行。

.interp
  使用哪个链接器不是由系统或者环境决定,而是由interp段决定。该段决定使用的动态链接器的位置,该段保存的是使用的动态链接器的路径的字符串。使用objdump -s program1查看可执行文件第一个段就是该段。

Contents of section .interp:
 0238 2f6c6962 36342f6c 642d6c69 6e75782d  /lib64/ld-linux-
 0248 7838362d 36342e73 6f2e3200           x86-64.so.2.    

  能够看到改路径是一个软连接。通过命令readelf -l program1 | grep interpreter也能查看。

➜  tmp ls -la /lib64/ld-linux-x86-64.so.2
lrwxrwxrwx 1 root root 32 Dec  8  2020 /lib64/ld-linux-x86-64.so.2 -> /lib/x86_64-linux-gnu/ld-2.27.so
➜  tmp readelf -l program1 | grep interpreter
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

.dynamic
  dynamic保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。
  dynamic section的基本结构定义在elf.h文件中,改文件中包含两个版本一个是32bit,另一个是64bit的。该结构包含一个tag表明项的类型,一个指针或者值,具体根据tag的类型而定,tag的所有类型在elf.h文件中Elf64_Dyn结构体下面的DT开头的宏表明。

/* Dynamic section entry.  */

typedef struct

  Elf32_Sword	d_tag;			/* Dynamic entry type */
  union
    
      Elf32_Word d_val;			/* Integer value */
      Elf32_Addr d_ptr;			/* Address value */
     d_un;
 Elf32_Dyn;

typedef struct

  Elf64_Sxword	d_tag;			/* Dynamic entry type */
  union
    
      Elf64_Xword d_val;		/* Integer value */
      Elf64_Addr d_ptr;			/* Address value */
     d_un;
 Elf64_Dyn;

  可以使用readelf -d libpic.so查看库中的dynamic项。

➜  tmp readelf -d libpic.so

Dynamic section at offset 0xe58 contains 20 entries:
  Tag        Type                         Name/Value
 0x000000000000000c (INIT)               0x4f8
 0x000000000000000d (FINI)               0x664
 0x0000000000000019 (INIT_ARRAY)         0x200e48
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x200e50
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x1f0
 0x0000000000000005 (STRTAB)             0x380
 0x0000000000000006 (SYMTAB)             0x230
 0x000000000000000a (STRSZ)              135 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000003 (PLTGOT)             0x201000
 0x0000000000000002 (PLTRELSZ)           48 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x4c8
 0x0000000000000007 (RELA)               0x408
 0x0000000000000008 (RELASZ)             192 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000006ffffff9 (RELACOUNT)          3
 0x0000000000000000 (NULL)               0x0
➜  tmp ldd libpic.so
        statically linked
➜  tmp ldd program1
        linux-vdso.so.1 (0x00007fffda4fb000)
        ./libadd.so (0x00007f6383560000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6383150000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f6383a00000)

动态符号表
  动态链接时为了表明该库导出了哪些符号(大部分编译器支持指定导出符号),存在一个.dynsym表仅仅保存需要导出的动态符号,而通常也会有symtab保存所有的符号,以及为了加快符号查找的.hash表。可以通过readelf -s libpic.so查看动态符号表和静态符号表,使用readelf -sD libpic.so查看hash表。

动态链接重定位表
  动态链接时可能会依赖其他库中的外部符号,这些符号仅仅在运行期才能被确定地址,因此需要在运行时进行重定位。动态链接时,会使用到.rela.dyn.rela.plt。前者对数据引用进行修正,修正的位置位于.got和数据段;后者对函数引用的修正,所修正的位置位于.got.plt。可以通过readelf -r libpic.soreadelf -S libpic.so查看。

➜  tmp readelf -r libpic.so

Relocation section '.rela.dyn' at offset 0x408 contains 8 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000200e48  000000000008 R_X86_64_RELATIVE                    620
000000200e50  000000000008 R_X86_64_RELATIVE                    5e0
000000201028  000000000008 R_X86_64_RELATIVE                    201028
000000200fd8  000200000006 R_X86_64_GLOB_DAT 0000000000000000 b + 0
000000200fe0  000300000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize + 0
000000200fe8  000400000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000200ff0  000500000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000200ff8  000600000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0

Relocation section '.rela.plt' at offset 0x4c8 contains 2 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000201018  000100000007 R_X86_64_JUMP_SLO 0000000000000000 ext + 0
000000201020  000a00000007 R_X86_64_JUMP_SLO 000000000000062a bar + 0

动态链接时进程堆栈初始化信息
  在进行链接时,操作系统需要链接一些可执行文件和当前进程相关的信息才能将权限移交给链接器。而这些基本信息保存在一个辅助信息数组中,该数据项的基本结构定义在elf.h文件中:

/* This vector is normally only used by the program interpreter.  The
   usual definition in an ABI supplement uses the name auxv_t.  The
   vector is not usually defined in a standard <elf.h> file, but it
   can't hurt.  We rename it to avoid conflicts.  The sizes of these
   types are an arrangement between the exec server and the program
   interpreter, so we don't fully specify them here.  */

typedef struct

  uint32_t a_type;		/* Entry type */
  union
    
      uint32_t a_val;		/* Integer value */
      /* We use to have pointer elements added here.  We cannot do that,
	 though, since it does not work when using 32-bit definitions
	 on 64-bit platforms and vice versa.  */
     a_un;
 Elf32_auxv_t;

typedef struct

  uint64_t a_type;		/* Entry type */
  union
    
      uint64_t a_val;		/* Integer value */
      /* We use to have pointer elements added here.  We cannot do that,
	 though, since it does not work when using 32-bit definitions
	 on 64-bit platforms and vice versa.  */
     a_un;
 Elf64_auxv_t;

  该结构体的基本类型的宏定义在bits/auxv.h中。

6 动态链接的步骤与实现

动态链接器自举
  从上面看到动态链接器就是一个库,其他库可以有动态链接器加载那动态链接器自身呢?动态链接器自身是通过自举完成加载,自举代码首先找到GOT的地址,GOT的第一项保存的就是.dynamic的偏移地址,得到了改地址就可以获得重定位表和符号表,就可以对符号进行重定位,在完成这一步之前自举代码不能使用静态和全局变量以及函数调用。下面是自举代码elf/rtld.c中````dl_start```的注释。

  /* Please note that we don't allow profiling of this object and
     therefore need not test whether we have to allocate the array
     for the relocation results (as done in dl-reloc.c).  */

  /* Now life is sane; we can call functions and access global data.
     Set up to use the operating system facilities, and find out from
     the operating system's program loader where to find the program
     header table in core.  Put the rest of _dl_start into a separate
     function, that way the compiler cannot put accesses to the GOT
     before ELF_DYNAMIC_RELOCATE.  */

装载共享对象
  完成自举后,动态链接器会将可执行文件和本身的符号表合并为一个符号表,然后链接器寻找可执行文件依赖的共享对象。链接器找出所有需要的共享对象放到一个集合中,然后依次打开依赖的库寻找需要的符号并将符号放入集合,之后进行内存映射。如果当前库依赖其他库则继续搜索。搜索的顺序按照图遍历的话一般是广度优先搜索。
  当一个可执行文件依赖多个库,而多个库定义了相同的符号,linux的动态链接器会忽略其中一个,这种现象叫全局符号介入。基本规则为:当一个符号需要被加载时放入全局符号表中,如果同名符号已经存在,则后入的符号别忽略。

重定位与初始化
  之后,链接器会重新遍历可执行文件和每个共享对象的重定位表,将GOP/PLT中需要重定位的符号的位置进行修正。重定位完成后,如果包含.initSection则执行该段中的代码进行初始化,.finit是在退出时执行的代码。动态链接器不会执行可执行文件中的.init代码。

Linux动态链接器实现
  动态链接器和一般的共享库不太一样,是可以直接执行的。Linux的共享库和可执行文件基本相同可能是ELF Header中某些标志不同而导致无法直接运行。

➜  tmp /lib64/ld-linux-x86-64.so.2
Usage: ld.so [OPTION]... EXECUTABLE-FILE [ARGS-FOR-PROGRAM...]
You have invoked `ld.so', the helper program for shared library executables.
#省略一部分内容

  有兴趣的可以看下glibc中的elf/rtld.c中的实现。

7 显式运行时链接

  显式运行时链接:即在运行时通过代码控制动态库的加载与卸载而不是链接时添加。

  • dlopen():打开动态库;
  • dlsym():找到符号;
  • dlerror():错误处理函数;
  • dlclose():卸载已经加载的模块。

以上是关于程序员自我修养阅读笔记——动态链接的主要内容,如果未能解决你的问题,请参考以下文章

程序员自我修养阅读笔记——动态链接

程序员自我修养阅读笔记——Widnows下的动态链接

程序员自我修养阅读笔记——Widnows下的动态链接

《程序员自我修养》阅读笔记-静态链接

读书笔记|《程序员的自我修养》- 03 静态链接

读书笔记|《程序员的自我修养》- 03 静态链接