精华内容
下载资源
问答
  • 2017-05-08 23:20:23

    linux 下动态链接实现原理

    符号重定位

    讲动态链接之前,得先说说符号重定位。(X86)

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

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

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

    链接时符号重定位

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

    
    // file: a.c
    int g_share = 1;
    
    int g_func(int a)
    {
       g_share += a;
       return a * 3;
    }
    
    // file: main.c
    extern int g_share;
    extern int g_func(int a);
    
    int main()
    {
      int a = 42;
      a = g_func(a);
      return 0;
    }

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

    // x86_64, linux 2.6.9
    -bash-3.00$ gcc -c a.c main.c -g
    -bash-3.00$ objdump -S a.o

    然后得到如下输出(对于 main.o 中对 g_func 的引用,实现是一样的,故略):

    a.o:     file format elf64-x86-64
    
    Disassembly of section .text:
    
    0000000000000000 <g_func>:
    int g_share = 1;
    
    int g_func(int a)
    {
       0:   55                      push   %rbp
       1:   48 89 e5                mov    %rsp,%rbp
       4:   89 7d fc                mov    %edi,0xfffffffffffffffc(%rbp)
        g_share += a;
       7:   8b 45 fc                mov    0xfffffffffffffffc(%rbp),%eax
       a:   01 05 00 00 00 00       add    %eax,0(%rip)        # 10 <g_func+0x10>
        return a * 2;
      10:   8b 45 fc                mov    0xfffffffffffffffc(%rbp),%eax
      13:   01 c0                   add    %eax,%eax
    }
      15:   c9                      leaveq
      16:   c3                      retq

    从中可以看到,目标文件里的 .txt 段地址从 0 开始,其中地址为7的指令用于把参数 a 放到寄存器 %eax 中,而地址 a 处的指令则把 %eax 中的内容与 g_share 相加,注意这里g_share 的地址为:0(%rip). 显然这个地址是错的,编译器当前并不知道 g_share 这个变量最后会被分配到哪个地址上,因此在这儿只是随便用一个假的来代替,等着到接下来链接时,再把该处地址进行修正。那么,链接器怎么知道目标文件中哪些地方需要修正呢?很简单,编译器编译文件时时,会建立一系列表项,用来记录哪些地方需要在重定位时进行修正,这些表项叫作“重定位表”(relocatioin table):

    -bash-3.00$ objdump -r a.o
    a.o:     file format elf64-x86-64
    
    RELOCATION RECORDS FOR [.text]:
    OFFSET           TYPE              VALUE
    000000000000000c R_X86_64_PC32     g_share+0xfffffffffffffffc

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

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

    -bash-3.00$ gcc -o am a.o main.o
    -bash-3.00$ objdump -S am
    // skip some of the ouput
     
    extern int g_func(int a);
    
    int main()
    {
      400468:       55                      push   %rbp
      400469:       48 89 e5                mov    %rsp,%rbp
      40046c:       48 83 ec 10             sub    $0x10,%rsp
        int a = 42;
      400470:       c7 45 fc 2a 00 00 00    movl   $0x2a,0xfffffffffffffffc(%rbp)
        a = g_func(a);
      400477:       8b 7d fc                mov    0xfffffffffffffffc(%rbp),%edi
      40047a:       e8 0d 00 00 00          callq  40048c <g_func>
      40047f:       89 45 fc                mov    %eax,0xfffffffffffffffc(%rbp)
        return 0;
      400482:       b8 00 00 00 00          mov    $0x0,%eax
    }
      400487:       c9                      leaveq
      400488:       c3                      retq
      400489:       90                      nop
      40048a:       90                      nop
      40048b:       90                      nop
    
    000000000040048c <g_func>:
    int g_share = 1;
    
    int g_func(int a)
    {
      40048c:       55                      push   %rbp
      40048d:       48 89 e5                mov    %rsp,%rbp
      400490:       89 7d fc                mov    %edi,0xfffffffffffffffc(%rbp)
        g_share += a;
      400493:       8b 45 fc                mov    0xfffffffffffffffc(%rbp),%eax
      400496:       01 05 dc 03 10 00       add    %eax,1049564(%rip)        # 500878 <g_share>
        return a * 2;
      40049c:       8b 45 fc                mov    0xfffffffffffffffc(%rbp),%eax
      40049f:       01 c0                   add    %eax,%eax
    }
      4004a1:       c9                      leaveq
      4004a2:       c3                      retq
    
    // skip some of the ouput

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

    加载时符号重定位

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

    int g_share = 1;
    
    int g_func(int a)
    {
        g_share += a;
        return a * 2;
    }
    
    int g_func2()
    {
        int a = 2;
        int b = g_func(3);
    
        return a + b;
    }
    // compile on 32bit linux OS
    -bash-3.00$ gcc -c a.c main.c
    -bash-3.00$ gcc -shared -o liba.so a.o
    -bash-3.00$ gcc -o am main.o -L. -la
    -bash-3.00$ objdump -S liba.so
    // skip some of the output
    000004f4 <g_func>:
    int g_share = 1;
    
    int g_func(int a)
    {
     4f4:   55                      push   %ebp
     4f5:   89 e5                   mov    %esp,%ebp
        g_share += a;
     4f7:   8b 45 08                mov    0x8(%ebp),%eax
     4fa:   01 05 00 00 00 00       add    %eax,0x0
        return a * 2;
     500:   8b 45 08                mov    0x8(%ebp),%eax
     503:   d1 e0                   shl    %eax
    }
     505:   c9                      leave  
     506:   c3                      ret    
    
    00000507 <g_func2>:
    
    int g_func2()
    {
     507:   55                      push   %ebp
     508:   89 e5                   mov    %esp,%ebp
     50a:   83 ec 08                sub    $0x8,%esp
        int a = 2;
     50d:   c7 45 fc 02 00 00 00    movl   $0x2,0xfffffffc(%ebp)
        int b = g_func(3);
     514:   6a 03                   push   $0x3
     516:   e8 fc ff ff ff          call   517 <g_func2+0x10>
     51b:   83 c4 04                add    $0x4,%esp
     51e:   89 45 f8                mov    %eax,0xfffffff8(%ebp)
    
        return a + b;
     521:   8b 45 f8                mov    0xfffffff8(%ebp),%eax
     524:   03 45 fc                add    0xfffffffc(%ebp),%eax
    }
     527:   c9                      leave  
    
    // skip some of the output

    注意其中地址 4fa 及 516 处的指令:此两处分别对 g_share 及 g_func 进行了访问,显然此时它们的地址仍然是假地址,这些地址在动态库加载完成后会被动态链接器进行重定位,最终修改为正确的地址,这看起来与静态链接时进行重定位是一样的过程,但实现上有几个关键的不同之处:

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

    2. ELF 文件对调用动态库中的函数采用了所谓的"延迟绑定"(lazy binding)策略, 只有当该函数在其第一次被调用发生时才最终被确认其真正的地址,因此我们不需要在调用动态库函数的地方直接填上假的地址,而是使用了一些跳转地址作为替换,这样一来连修改动态库和可执行程序中的相应代码都不需要进行了,当然延迟绑定的目的不是为了这个,具体先不细说。

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

    地址无关代码(PIC, position independent code)

    从前面的介绍我们知道装载时重定位有重大的缺点:

    1. 它不能使动态库的指令代码被共享。
    2. 程序启动加载动态库后,对动态库中的符号引用进行重定位会比较花时间,特别是动态库多且复杂的情况下。

    为了克服这些缺陷,ELF 引用了一种叫作地址无关代码的实现方案,该解决方案通过对变量及函数的访问加一层跳转来实现,非常的灵活。

    1.模块内部符号的访问

    模块内部符号在这里指的是:static 类型的变量与函数,这种类型的符号比较简单,对于 static 函数来说,因为在动态库编译完后,它在模块内的相对地址就已经确定了,而 x86 上函数调用只用到相对地址,因此此时根本连重定位都不需要进行,编译时就能确定地址,稍微麻烦一点的是访问数据,因为访问数据需要绝对地址,但动态库未被加载时,绝对地址是没法得知的,怎么办呢?

    ELF 在这里使用了一个小技巧,根据当前 IP 值来动态计算数据的绝对地址,它的原理很简单,当动态库编译好之后,库中的数据段,代码段的相对位置就已经固定了,此时对任意一条指令来说,该指令的地址与数据段的距离都是固定的,那么,只要程序在运行时获取到当前指令的地址,就可以直接加上该固定的位移,从而得到所想要访问的数据的绝对地址了,下面我们用实例验证一下:

    int g_share = 1;
    static int g_share2 = 2;
    
    int g_func(int a)
    {
        g_share += a;
        return a * 2;
    }
    
    int g_func2()
    {
        int a = 2;
        int b = g_func(3);
    
        return a + b;
    }
    
    static int g_fun3()
    {
        g_share2 += 3;
        return g_share2 - 1;
    }
    
    static int g_func4()
    {
        int a = g_fun3();
    
        a + 2;
        return a;
    }

    以上代码在x86 linux 下编译,再反汇编看看得到如下结果:

    -bash-3.00$ gcc -o liba.so -fPIC -shared a.c
    -bash-3.00$ objdump -S liba.so 
    // skip some of the output
    00000564 <g_fun3>:
     564:   55                      push   %ebp
     565:   89 e5                   mov    %esp,%ebp
     567:   e8 00 00 00 00          call   56c <g_fun3+0x8>
     56c:   59                      pop    %ecx
     56d:   81 c1 60 11 00 00       add    $0x1160,%ecx
     573:   83 81 20 00 00 00 03    addl   $0x3,0x20(%ecx)
     57a:   8b 81 20 00 00 00       mov    0x20(%ecx),%eax
     580:   48                      dec    %eax
     581:   c9                      leave  
     582:   c3                      ret    
    // skip some of the output

    现在我们来分析验证一下:首先是地址 567 的指令有些怪,这儿不深究,简单来说,x86 下没有指令可以取当前 ip 的值,因此这儿使了个技巧通过函数调用来获取 ip 值(x86_64 下就不用这么麻烦),这个技巧的原理在于进行函数调用时要将返回地址压到栈上,此时通过读这个栈上的值就可以获得下一条指令的地址了,在这儿我们只要知道指令 56c 执行后,%ecx 中包含了当前指令的地址,也就是 0x56c,再看 56d 及 573 两条指令,得知 %ecx + 0x1160 + 0x20 = 0x16ec 就是 573 指令所需要访问的地址,这个地址指向哪里了呢?

    -bash-3.00$ objdump -s liba.so
    Contents of section .data:
     16e0 e0160000 f4150000 01000000 02000000  ................

    结果是数据段里的第二个 int,也就是 g_share2!

    2.模块间符号的访问

    模块间的符号访问比模块内的符号访问要麻烦很多,因为动态库运行时被加载到哪里是未知的,为了能使得代码段里对数据及函数的引用与具体地址无关,只能再作一层跳转,ELF 的做法是在动态库的数据段中加一个表项,叫作 GOT(global offset table), GOT 表格中放的是数据全局符号的地址,该表项在动态库被加载后由动态加载器进行初始化,动态库内所有对数据全局符号的访问都到该表中来取出相应的地址,即可做到与具体地址了,而该表作为动态库的一部分,访问起来与访问模块内的数据是一样的。

    仍然使用前面的例子,我们来看看 g_func 是怎么访问 g_share 变量的。

    00000504 <g_func>:
     504:   55                      push   %ebp
     505:   89 e5                   mov    %esp,%ebp
     507:   53                      push   %ebx
     508:   e8 00 00 00 00          call   50d <g_func+0x9>
     50d:   5b                      pop    %ebx
     50e:   81 c3 bf 11 00 00       add    $0x11bf,%ebx
     514:   8b 8b f0 ff ff ff       mov    0xfffffff0(%ebx),%ecx
     51a:   8b 93 f0 ff ff ff       mov    0xfffffff0(%ebx),%edx
     520:   8b 45 08                mov    0x8(%ebp),%eax
     523:   03 02                   add    (%edx),%eax
     525:   89 01                   mov    %eax,(%ecx)
     527:   8b 45 08                mov    0x8(%ebp),%eax
     52a:   d1 e0                   shl    %eax
     52c:   5b                      pop    %ebx
     52d:   c9                      leave  
     52e:   c3                      ret    

    上面的输出中,508 与 50d 处的指令用于获取 ip 值, 执行完 50d 后, %ebx 中放的是 0x50d, 地址 50e 用于计算 g_share 在 GOT 中的地址 0x50d + 0x11bf + 0xfffffff0 = 0x16bc, 我们检查一下该地址是不是 GOT:

    -bash-3.00$ objdump -h liba.so
    liba.so:     file format elf32-i386
    
    Sections:
    Idx Name          Size      VMA       LMA       File off  Algn
    //skip some of the output
    
     16 .got          00000010  000016bc  000016bc  000006bc  2**2
                      CONTENTS, ALLOC, LOAD, DATA

    显然,0x16bc 就是 GOT 表的第一项。

    事实上,ELF 文件中还包含了一个重定位段,里面记录了哪些符号需要进行重定位,我们可以通过它验证一下上面的计算是否与之匹配:

    -bash-3.00$ objdump -R liba.so
    liba.so:     file format elf32-i386
    
    DYNAMIC RELOCATION RECORDS
    OFFSET   TYPE              VALUE
    000016e0 R_386_RELATIVE    *ABS*
    000016e4 R_386_RELATIVE    *ABS*
    000016bc R_386_GLOB_DAT    g_share
    000016c0 R_386_GLOB_DAT    __cxa_finalize
    000016c4 R_386_GLOB_DAT    _Jv_RegisterClasses
    000016c8 R_386_GLOB_DAT    __gmon_start__
    000016d8 R_386_JUMP_SLOT   g_func
    000016dc R_386_JUMP_SLOT   __cxa_finalize

    如上输出, g_share 的地址在 0x16bc,与前面的计算完全吻合!
    致此,模块间的数据访问就介绍完了,模块间的函数调用在实现原理上是一样的,也需要经过一个类似 GOT 的表格进行跳转,但在具体实现上,ELF 为了实现所谓延迟绑定而作了更精细的处理,接下来会介绍。值得一提的是,PIC 也可在编译可执行文件时指定,此时可执行文件中的代码对外部符号的引用方式会改变,不再是直接(绝对地址或相对地址)引用该符号,而是也通过 GOT 来间接的引用。

    延迟加载

    我们知道,动态库是在进程启动的时候加载进来的,加载后,动态链接器需要对其作一系列的初始化,如符号重定位(动态库内以及可执行文件内),这些工作是比较费时的,特别是对函数的重定位,那么我们能不能把对函数的重定位延迟进行呢?这个改进是很有意义的,毕竟很多时候,一个动态库里可能包含很多的全局函数,但是我们往往可能只用到了其中一小部分而已,而且在这用到的一小部分里,很可能其中有些还压根不会执行到,因此完全没必要把那些没用到的函数也过早进行重定位,具体来说,就是应该等到第一次发生对该函数的调用时才进行符号绑定 -- 此谓之延迟绑定。

    延迟绑定的实现步骤如下:

    1. 建立一个 GOT.PLT 表,该表用来放全局函数的实际地址,但最开始时,该里面放的不是真实的地址而是一个跳转,接下来会讲。
    2. 对每一个全局函数,链接器生成一个与之相对应的影子函数,如 fun@plt
    3. 所有对 fun 的调用,都换成对 fun@plt 的调用,每个fun@plt 长成如下样子:

      fun@plt:
      jmp *(fun@got.plt)
      push index
      jmp _init

      其中第一条指令直接从 got.plt 中去拿真实的函数地址,如果已经之前已经发生过调用,got.plt 就已经保存了真实的地址,如果是第一次调用,则 got.plt 中放的是 fun@plt 中的第二条指令,这就使得当执行第一次调用时,fun@plt中的第一条指令其实什么事也没做,直接继续往下执行,第二条指令的作用是把当前要调用的函数在 got.plt 中的编号作为参数传给 _init(),而 _init() 这个函数则用于把 fun 进行重定位,然后把结果写入到 got.plt 相应的地方,最后直接跳过去该函数。

    仍然是使用前面的例子,我们看看 g_func2 是怎样调用 g_func 的:

    0000052f <g_func2>:
     52f:   55                      push   %ebp
     530:   89 e5                   mov    %esp,%ebp
     532:   53                      push   %ebx
     533:   83 ec 14                sub    $0x14,%esp
     536:   e8 00 00 00 00          call   53b <g_func2+0xc>
     53b:   5b                      pop    %ebx
     53c:   81 c3 91 11 00 00       add    $0x1191,%ebx
     542:   c7 45 f8 02 00 00 00    movl   $0x2,0xfffffff8(%ebp) // a = 2
     549:   83 ec 0c                sub    $0xc,%esp
     54c:   6a 03                   push   $0x3 // push argument 3 for g_func.
     54e:   e8 d5 fe ff ff          call   428 <g_func@plt>
     553:   83 c4 10                add    $0x10,%esp
     556:   89 45 f4                mov    %eax,0xfffffff4(%ebp)
     559:   8b 45 f4                mov    0xfffffff4(%ebp),%eax
     55c:   03 45 f8                add    0xfffffff8(%ebp),%eax
     55f:   8b 5d fc                mov    0xfffffffc(%ebp),%ebx
     562:   c9                      leave  
     563:   c3                      ret 

    如上汇编,指令 536, 53b, 53c, 用于计算 got.plt 的具体位置,计算方式与前面对数据的访问原理是一样的,经计算此时, %ebx = 0x53b + 0x1191 = 0x16cc, 注意指令 54e, 该指令调用了函数 g_func@plt:

    00000428 <g_func@plt>:
     428:   ff a3 0c 00 00 00       jmp    *0xc(%ebx)
     42e:   68 00 00 00 00          push   $0x0
     433:   e9 e0 ff ff ff          jmp    418 <_init+0x18>

    注意到此时, %ebx 中放的是 got.plt 的地址,g_func@plt 的第一条指令用于获取 got.plt 中 func 的具体地址, func 放在 0xc + %ebx = 0xc + 0x16cc = 0x16d8, 这个地址里放的是什么呢?我们查一下重定位表:

    -bash-3.00$ objdump -R liba.so
    
    liba.so:     file format elf32-i386
    
    DYNAMIC RELOCATION RECORDS
    OFFSET   TYPE              VALUE
    000016e0 R_386_RELATIVE    *ABS*
    000016e4 R_386_RELATIVE    *ABS*
    000016bc R_386_GLOB_DAT    g_share
    000016c0 R_386_GLOB_DAT    __cxa_finalize
    000016c4 R_386_GLOB_DAT    _Jv_RegisterClasses
    000016c8 R_386_GLOB_DAT    __gmon_start__
    000016d8 R_386_JUMP_SLOT   g_func
    000016dc R_386_JUMP_SLOT   __cxa_finalize

    可见,该地址里放的就是 g_func 的具体地址,那此时 0x16d8 放的是真正的地址了吗?我们再看看 got.plt:

    Contents of section .got.plt:
     16cc fc150000 00000000 00000000 2e040000  ................
     16dc 3e040000 

    16d8 处的内容是: 2e040000, 小端序,换回整形就是 0x000042e, 该地址就是 fun@plt 的第二条指令!是不是觉得有点儿绕?你可以定下心来再看一遍,其实不绕,而是很巧妙。

    后话

    对动态链接库来说,加载时重定位与链接时重定位各有优缺点,前者使得动态库的代码段不能被多个进程间所共享,加载动态库时也比较费时,但是加载完成后,因为对符号的引用不需要进行跳转,程序运行的效率相对是较高的。而对地址无关的代码,它的缺点是动态库的体积相对较大,毕竟增加了很多表项及相关的函数,另外就运行时对全局符号的引用需要通过表格进行跳转,程序执行的效率不可避免有所损失,优点嘛,就是动态库加载比较快,而且代码可以在多个进程间共享,对整个系统而言,可以大大节约对内存的使用,这个好处的吸引力是非常大的,所以你可以看到,目前来说在常用的动态库使用上,PIC 相较而言是更加被推崇的,道理在此。

    更多相关内容
  • 静态链接与动态链接原理

    千次阅读 2022-02-09 10:57:46
    示例程序 main.c: #include <stdio.h> void print_banner() ...源文件想变成能够在内存中运行的程序,需经历编译、链接、载入3个步骤。 编译: gcc -Wall -g -o main.o -c main.c -m32 链接: gcc -o

    示例程序 main.c:

    #include <stdio.h>
    
    void print_banner()
    {
        printf("Welcome to World of PLT and GOT\n");
    }
    
    int main(void)
    {
        print_banner();
        return 0;
    }
    

    一个源文件想变成能够在内存中运行的程序,需经历编译、链接、载入3个步骤。

    编译:gcc -Wall -g -o main.o -c main.c -m32
    链接:gcc -o main main.o -m32

    注:
    可执行文件跟.o的区别在于:可执行文件有一个可由操作系统调用的入口函数main。

    1、编译

    编译生成的每一个.o文件包含3部分内容:
    1)代码段:存放当前源文件定义的函数;
    2)数据段:存放当前源文件定义的全局变量;
    3)符号表:存放当前源文件未定义却使用了的函数或全局变量(它们可能由其他.o或.so定义)。
    在符号表中,未定义的函数/全局变量被标记为UND,即undefined。(可使用命令:objdump -x XXX.o 查看XXX.o的符号表和重定位表)
    在这里插入图片描述
    在编译阶段,可确定前两部分相对当前.o起始位置的偏移地址,但对于第三部分,由于编译器不清楚printf的地址,因此此处预写一个 0XFF FF FF FC(即-4),用于告诉链接器:需根据printf地址来修正该地址。
    在这里插入图片描述

    2、链接(ld)

    链接包括静态链接、载入时动态链接、运行时动态链接。

    2.1、静态链接(.o之间的链接或.o与静态库之间的链接)

    链接器根据链接顺序,将所有的.o(对于静态库,只会合并所用到的目标文件)合并为一个可执行文件:
    1)所有.o中的代码段/数据段 合并到 可执行文件的代码段/数据段。合并后,可执行文件中的函数/全局变量相对文件起始位置的偏移地址,即为最终内存地址。
    在这里插入图片描述
    2)对于符号表中的函数/全局变量,若它们是由其他.o或.a定义,则通过查找前一步的合并结果,即可确定其最终内存地址,并将0XFF FF FF FC修正为最终内存地址即可。

    2.2、载入时动态链接

    在2.1中,对于.so定义的函数/全局变量的最终内存地址,链接器是无法确定的,而只能在载入动态库后确定。那么,在载入动态库glibc后,应如何让call指令找到printf的地址?
    最为简单的方法是,直接将0XFF FF FF FC修改为printf的真正地址即可。问题在于:
    1)call指令位于代码段中,而代码段在运行时是不允许被修改;
    2)即使代码段允许修改,但若print_banner定义在.so中,则修改后就无法在多个进程之间共用这个.so。因为它们共享.so的代码段,但每个进程都有一份独立的数据段。

    那么,能否在.so加载后,将函数/数据的地址存入数据段,而代码直接读取数据段内的地址?
    Yes!
    这个存储函数/数据地址的数据段,即为GOT(Global Offset Table,全局偏移表)。表中的每一项为函数/数据的地址。

    因此链接器的做法是,生成一段额外代码print_stub,并将call printf指令重定位到print_stub,而printf_stub又重定位到GOT中的函数/数据地址。
    这个存储额外代码的表,称为PLT(Procedure Link Table,程序链接表)。

    载入时动态链接是指,在将可执行文件加载到内存后,且在程序开始运行前,查找链接时所指定的动态库,并将GOT中的函数/数据地址修正为其真正地址。

    那么程序在运行阶段,printf的调用过程:
    在这里插入图片描述

    2.3、PLT/GOT

    PLT表包含若干项,第一项为公共项,指向GOT中的第二项,即动态链接器内的_dl_runtime_resolve函数(该项在链接阶段为0X000000,载入阶段由动态链接器填充),其余每一项对应一个动态库函数。(每一项包含若干指令)
    在这里插入图片描述
    第一次调用printf:
    1)调用PLT中printf对应的代码片段;
    2)调用GOT中存储的printf。此处存储的地址是1)中PLT的下一条指令的地址,因此程序调回到PLT中;
    3)调用PLT第一项_dl_runtime_resolve对应的代码段;
    4)调用GOT中存储的_dl_runtime_resolve函数的真实地址,而_dl_runtime_resolve会去.so中查找printf的地址,并将2)中GOT存储的printf地址修改为printf的真实地址(那么下次调用无需_dl_runtime_resolve),并调用printf函数。

    非第一次调用printf:
    在这里插入图片描述

    2.4 PIC(position-independent code,地址无关代码)

    对于动态库来说,是指将动态库中的代码段映射到不同进程的地址空间,其实现是借助GOT/PLT表。
    原理是:当某指令想访问某个函数/数据时,并不是通过绝对地址进行访问,而是访问存放函数/数据的绝对地址的GOT表。(指令与GOT的相对位置链接器已知)

    2.5、运行时动态链接(延迟重定位)

    运行时动态链接是指,在编译链接时不指定所依赖的动态库,而在程序中使用dlopen/dlsym时,才对GOT表项做重定位。因此相比载入时动态链接,可加快程序的启动速度。

    3、静态链接 VS 动态链接

    对于同时包含静态/动态链接库的若干个可执行文件:
    1)磁盘空间:每个可执行文件中都有一份静态链接库的代码/数据,但仅包含动态链接库的必要信息。
    2)独立运行:动态链接生成的执行文件不可独立运行;
    3)性能:加载时动态链接会拖慢程序的启动速度,对于运行时动态链接,当首次调用动态库的函数时,程序会被暂停直至链接结束。
    4)内存:若干个同时运行的程序,都使用了某个静态链接库/动态链接库,则内存中会有该静态链接库的多个副本,而大家共用一份动态链接库。
    5)重新编译:若修改了动态库,只要未改变接口,则无需重新编译可执行文件。

    展开全文
  • DLL原理动态链接程序库)

    千次阅读 2020-01-28 16:24:30
    DLL原理动态链接程序库) 概述 编译分为3步,首先对源文件进行预处理,这个过程主要是处理一些#号定义的命令或语句(如宏、#include、预编译指令#ifdef等),生成*.i文件;然后进行编译,这个过程主要是进行词法...

                            DLL原理(动态链接程序库)

    概述

    编译分为3步,首先对源文件进行预处理,这个过程主要是处理一些#号定义的命令或语句(如宏、#include、预编译指令#ifdef等),生成*.i文件;然后进行编译,这个过程主要是进行词法分析、语法分析和语义分析等,生成*.s的汇编文件;最后进行汇编,这个过程比较简单,就是将对应的汇编指令翻译成机器指令,生成可重定位的二进制目标文件。以上就是编译的过程,下面主要介绍两种链接方式--静态链接和动态链接。

            静态链接和动态链接两者最大的区别就在于链接的时机不一样,静态链接是在形成可执行程序前,而动态链接的进行则是在程序执行时,下面来详细介绍这两种链接方式。

    静态链接

    1 为什么要进行静态链接

     在我们的实际开发中,不可能将所有代码放在一个源文件中,所以会出现多个源文件,而且多个源文件之间不是独立的,而会存在多种依赖关系,如一个源文件可能要调用另一个源文件中定义的函数,但是每个源文件都是独立编译的,即每个*.c文件会形成一个*.o文件,为了满足前面说的依赖关系,则需要将这些源文件产生的目标文件进行链接,从而形成一个可以执行的程序。这个链接的过程就是静态链接。


    2 静态链接的原理

    由很多目标文件进行链接形成的是静态库,反之静态库也可以简单地看成是一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件,如下图,使用ar命令的-a参数查看静态库的组成:

      以下面这个图来简单说明一下从静态链接到可执行文件的过程,根据在源文件中包含的头文件和程序中使用到的库函数,如stdio.h中定义的printf()函数,在libc.a中找到目标文件printf.o(这里暂且不考虑printf()函数的依赖关系),然后将这个目标文件和我们hello.o这个文件进行链接形成我们的可执行文件。

     这里有一个小问题,就是从上面的图中可以看到静态运行库里面的一个目标文件只包含一个函数,如libc.a里面的printf.o只有printf()函数,strlen.o里面只有strlen()函数。

            我们知道,链接器在链接静态链接库的时候是以目标文件为单位的。比如我们引用了静态库中的printf()函数,那么链接器就会把库中包含printf()函数的那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中。由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,那些没有被用到的目标文件就不要链接到最终的输出文件中。


    3 静态链接的优缺点

    静态链接的缺点很明显,一是浪费空间,因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,如多个程序中都调用了printf()函数,则这多个程序中都含有printf.o,所以同一个目标文件都在内存存在多个副本;另一方面就是更新比较困难,因为每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。


    动态链接

     动态链接出现的原因就是为了解决静态链接中提到的两个问题,一方面是空间浪费,另外一方面是更新困难。下面介绍一下如何解决这两个问题。

    1 动态链接的原理

     动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。

          假设现在有两个程序program1.o和program2.o,这两者共用同一个库lib.o,假设首先运行程序program1,系统首先加载program1.o,当系统发现program1.o中用到了lib.o,即program1.o依赖于lib.o,那么系统接着加载lib.o,如果program1.o和lib.o还依赖于其他目标文件,则依次全部加载到内存中。当program2运行时,同样的加载program2.o,然后发现program2.o依赖于lib.o,但是此时lib.o已经存在于内存中,这个时候就不再进行重新加载,而是将内存中已经存在的lib.o映射到program2的虚拟地址空间中,从而进行链接(这个链接过程和静态链接类似)形成可执行程序。


    2 动态链接的优缺点

    动态链接的优点显而易见,就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多分,副本,而是这多个程序在执行时共享同一份副本;

    另一个优点是,更新也比较方便,更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。但是动态链接也是有缺点的,因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。

            据估算,动态链接和静态链接相比,性能损失大约在5%以下。经过实践证明,这点性能损失用来换区程序在空间上的节省和程序构建和升级时的灵活性是值得的。


    3 动态链接地址是如何重定位的呢?

    前面我们讲过静态链接时地址的重定位,那我们现在就在想动态链接的地址又是如何重定位的呢?虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
     

     

    动态链接程序库 动态链接程序库,全称:Dynamic Link Library,简称:DLL,作用在于为应用程序提供扩展功能。应用程序想要调用DLL文件,需要跟其进行"动态链接";从编程的角度,应用程序需要知道DLL文件导出的API函数方可调用。

    由此可见,DLL文件本身并不可以运行,需要应用程序调用。

     

    参考链接

    https://blog.csdn.net/kang___xi/article/details/80210717

    展开全文
  •  讲动态链接之前,得先说说符号重定位。  c/c++ 程序的编译是以文件为单位进行的,因此每个 c/cpp 文件也叫作一个编译单元(translation unit), 源文件先是被编译成一个个目标文件, 再由链接器把这些目标文件...
  • 动态链接VS静态链接: 动态链接从命名中可以看出动态是关键, 那这个动态体现在哪呢? 静态链接中的静态体现在哪呢?, 我们知道代码需要经过几个步骤才会编译成机器认识的符号, 代码一般经过预编译, 编译, 汇编, 链接...

    动态链接VS静态链接:

    动态链接从命名中可以看出动态是关键, 那这个动态体现在哪呢?  静态链接中的静态体现在哪呢?, 我们知道代码需要经过几个步骤才会编译成机器认识的符号, 代码一般经过预编译, 编译, 汇编, 链接然后形成可执行程序或者动态库, 这几个步骤的作用分别如下:
    预编译: 将代码进行整理,#include,#define,注释的代码等等...
    编译:将预编译后的文件进行语法分析, 词法分析, 语义分析及优化等
    汇编:变成机器可以执行的指令
    链接:将目标文件及库一起链接成exe文件,链接过程主要包括地址和空间分配, 地址绑定, 重定位等步骤
    我们看动态链接, 静态链接看定义上看, 就只到其却别在链接这个阶段, 一个是动态的, 一个是静态的, 所谓静态我们可以理解为在链接阶段, 需要把可执行文件所依赖的所有目标文件及库都链接进来, 然后进行地址的重定位,  链接后就是一个完整的可执行文件, 一个不可分割的整体, 可执行文件中包含所有的目标文件及库.
    动态链接在链接阶段不进行链接, 等到程序运行时才进行链接, 也就是说 把链接这个过程推迟到了运行时进行.动态链接时, 链接器需要找到代码中相关函数,变量定制在哪个动态库中, 从动态的符号表中可以检索到对应函数, 变量, 当链接器知道先关函数及变量是定义在动态库中, 则会改成对先关函数, 变量的动态符号引用. 待运行时装载. 装载后进行重定位到对应进程的虚拟内存中.
    

    静态库VS动态库:

    静态库: 浪费内存,占用大量磁盘空间,  程序迭代不方便, 牵一发而动全身.
    动态库: 没有静态库的以上缺点, 也即是它的优点,  缺点是动态库因为是运行时装载, 装载时重定位, 所以程序运行的时候会慢一点, 性能差一点, 一般在5%左右, 都可以接受.
    

    动态链接的基本实现

    动态链接的基本思想是把程序按照模块拆分成各个相对独立的部分, 在程序运行时才将他们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个单独的可执行的文件. 
    当程序被装载时, 也即在运行时, 系统的动态链接器会将程序所需要的所有动态链接库(除延迟加载外, 延迟加载也即只有在用到该库的时候才去装载)装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中, 并进行重定位工作(重定位工作就是将动态库中的函数及变量, 根据地址偏移加载到进程的对应地址空间中)
    动态库在运行时装载, 在编译的时候无法确定库的装载地址, 只有在运行时, 装载器会根据当前的地址空间的空闲情况, 动态分配一块足够大小的虚拟地址空间给动态库来装载.
    -fPIC: 地址无关代码
    	一般我们在编译动态库时, 都会制定两个参数如:gcc -fPIC -shared -o lib.so lib.c,  -fPIC标识地址无关代码, 此处的地址是指在进程中的地址, 无关代码指的是动态库中的指令部分, 也即非数据部分, 库包括指令和数据,  指令是可以共享的, 也即多个进程加载同一个动态库时, 指令部分是共享的, 在物理地址上是只有一份的, 但动态库中的数据部分是独立的, 每个进程都有一份副本, 每个进程独有, 进程之间数据部分互不影响, 这些数据部分一般存放在虚拟地址空间的数据段.
    -shared 装载时重定位
    	这个概念前面已经讲了很多次, 核心思想是: 在程序运行时, 当库的转载地址确定后, 才会去将编译期的所有地址符号引用(也即库中的函数, 变量等符号)重定位到程序的对应的地址空间中, 这部分是根据地址偏移来计算的, 由库的转载地址作为偏移的基地址.
    

    引出问题:

    Q:动态库中全局变量, 当多个进程加载同一个动态库时, 修改库中的全局变量, 对其它进程是否可见:
    	答案:不可见, 解释见-fPIC解释
    Q2 动态库中全局变量, 一个进程中, 修改全局变量,  线程间是否可见?
    	答案: 可见, 库被进程加载, 在进程内可见, 线程当然可以可见
    Q3 一个进程加载相同的动态库(不通版本)多次(不论是静态加载还是动态加载), 请问当调用动态库的对外接口时, 会调到哪个库接口中?
    答案: 动态链接器中有一个进程级别的全局动态符号表, 当哪个库最先被加载(静态加载或者是动态加载)时, 其库中所有符号在全局动态符号表中记录, 下次再加载不同版本同名库时, 因为全局符号表中已存在相同的符号名已经存在, 则后加入的符号会被忽略.也即进程加载不同版本的同名动态库时, 调用的接口都是最先被加载动态库的接口, 后面加载的库的接口会被忽略, 因为全局符号表把后加入的符号给忽略了. 哪那个库最先被装载符号就最先被加入到全局符号表.
    

    动态链接的步骤和实现

    动态链接分为三步: 
    	1:先是启动动态链接器本身
    	2:然后装载所有需要的共享对象
    	3:最后是重定位和初始化
    装载共享对象:
    	动态链接器将可执行文件和链接器本身的符号都合并到一个符号表中-全局符号表,  然后链接器根据符号引用找到对应的动态库, 每个动态库的结构中都有一个.dynamic段, 这个段里面保存了动态链接器所需要的基本信息, 比如依赖于哪些共享对象, 动态链接符号表的位置, 动态链接重定位表的位置, 共享对象初始化代码的地址等等,  从该段中找出所依赖的其它动态库, 依次链接器将可执行文件所依赖的所有动态库对象的名字放入到一个装载集合中, 然后链接器开始从集合中取一个所需要的动态库名字, 找到相应文件并打开, 读取相应的ELF文件头和dynamic段, 然后将他相应的代码段和数据段映射到进程空间中,如果这个ELF动态库对象还依赖其它动态库, 那么将所依赖的库放入装载集合中, 如此循环直到所有动态库都被装载完毕后, 将进行符号重定位和动态库初始化工作(.init段),  至此动态链接器工作完成,  返回用户空间, 将控制权交给程序入口并且开始执行.
    

    Linux进程分布图

    :
    在这里插入图片描述

    展开全文
  • 动态链接原理

    千次阅读 2021-12-23 19:22:46
    创建和使用动态链接库的一般步骤 1. 创建(编译一个).so文件 2. 把创建的.so文件放在文件系统的某个目录下:比如/usr/local/libxxxx 3. 把创建的.so文件通过ldconfig注册到系统 4.下次我们编译其他APP需要用到这个....
  • 随着项目中动态链接库越来越多,我们也遇到了很多奇怪的问题,比如只在某一种OS上会出现的java.lang.UnsatisfiedLinkError,但是明明我们动态库名称没错,ABI也没错,方法也能对应的上,而且还只出现在某一些机型上...
  • 本文详细讲述了VC程序在Win32环境下动态链接库(DLL)编程原理。分享给大家供大家参考。具体分析如下: 一般比较大的应用程序都由很多模块组成,这些模块分别完成相对独立的功能,它们彼此协作来完成整个软件系统的...
  • 动态链接库不能直接运行,也不能接收消息。它们是一些独立的文件,其中包含能被可执行程序或其它DLL调用来完成某项工作的函数或是数据。只有在其它模块调用动态链接库中的函数时,它才发挥作用。Windows API中的所有...
  • so链接动态加载原理分析

    千次阅读 2021-02-01 17:53:46
    本文首先回顾了链接器和装入器的基本工作原理及这一技术的发展历史,然后通过实际的例子剖析了Linux系统下动态链接的实现。了解底层关键技术的实现细节对系统分析和设计人员无疑是必须的,尤其当我们在面对实时系统...
  • 掌握查看通过动态路由协议RIP学习产生的路由; 熟悉广域网线缆的链接方式; 实验背景 假设校园网通过一台三层交换机连到校园网出口路由器上,路由器再和校园外的另一台路由器连接。现要做适当配置,实现校园网...
  • Java动态连接

    千次阅读 2019-07-30 22:46:50
    当编译一个Java程序的时候,会得到程序中每个类或者接口的独立的class文件,当程序运行的时候,JVM转载程序的类和接口,在动态连接的过程中把它们互相勾连起来。 Class文件把它所有的符号引用保存在常量池。 常量池...
  • Nacos Config动态刷新原理

    千次阅读 2021-10-28 22:26:59
    配置文件无法动态更新,修改配置文件后需要重启对应的微服务 当我们引入配置中心,它能解决什么问题? 统一的配置文件的管理 提供统一的服务标准接口,服务根据标准接口拉去对应的配置,也就是常见pull/push模型 ...
  • Windows的动态链接原理 10.1 Windows的动态链接原理  动态链接库(DLLs)是从C语言函数库和Pascal库单元的概念发展而来的。所有的C语言标准库函数都存放在某一函数库中,同时用户也可以用LIB程序创建自己的...
  • NULL 博文链接:https://jadeluo.iteye.com/blog/1940019
  • DLL动态链接库的工作原理

    万次阅读 2012-08-29 11:12:12
    "动态链接"这几字指明了DLLs是如何工作的。对于常规的函数库,链接器从中拷贝它需要的所有库函数,并把确切的函数地址传送给调用这些函数的程序。而对于DLLs,函数储存在一个独立的动态链接库文件中。在创建Windows...
  • 操作系统将公用的动态库加载到物理内存中,假设A,B两个进程都需要用到这些dll, 则只需将dll在物理内存中的地址 与 进程A B的虚拟地址空间做一个映射连接。 《Operation system concepts, 7th edition》中的解释...
  • Win32环境下动态链接库(DLL)编程原理 比较大应用程序都由很多模块组成这些模块分别完成相对独立的功能它们彼此协作来完成整个软件系统的工作其中可能存在一些模块的功能较为通用在构造其它软件系统时仍会被使用在...
  • 【Android】动态链接库so的加载原理

    千次阅读 2018-07-04 14:51:46
    所以本文将从AOSP源码简单跟踪Android中的动态链接库so的加载原理,试图找出一丝线索。 加载入口 首先我们知道在Android(Java)中加载一个动态链接库非常简单。就是我们日常调用的 System.load(...
  • 动态路由原理(RIP协议+实验)

    千次阅读 2019-09-07 16:36:23
    二、工作原理 (1)路由器之间适时地交换路由信息; (2)路由器根据某种路由算法(不同的动态路由协议算法不同)把收集到的路由信息加工成路由表,供路由器在转发IP报文时查阅。 三、动态路由特点: (1)减少了...
  • mybatis接口动态代理原理
  • java虚拟机之动态连接过程

    千次阅读 2019-02-16 14:54:13
    动态连接是一个将符号引用解析为直接引用的过程。当java虚拟机执行字节码时,如果它遇到一个操作码,这个操作码第一次使用一个指向另一个类的符号引用 那么虚拟机就必须解析这个符号引用。在解析时,虚拟机执行两个...
  • Firebase动态链接

    千次阅读 2019-11-26 17:04:02
    Firebase 动态链接可用于多种平台,无论用户是否已经安装了您的应用,这类链接都能按照您所需的方式工作。 通过动态链接,您的用户可以在他们打开您的链接的平台上获得最佳体验。如果用户在 iOS 或 Android 上...
  • VC++动态链接原理、创建和使用 更加详细的熟悉连接库的由来和使用
  • Windows动态链接原理分析及其应用,很不错的文章。
  • 链接与装载---链接原理详解

    千次阅读 2019-09-23 16:55:55
    动态链接原理 动态链接器信息 动态链接步骤 启动动态链接器 装载共享对象 重定位和初始化 前言 为了更好地理解计算机程序的编译和链接的过程, 我们先简单回顾下计算机程序开发的历史一定会非常有益。 ...
  • 通常使用的4位LED显示器如图所示,其内部由多只发光二极管构成,按连接方式不同可分为共阳极与共阴极两种。在显示驱动方式中,采用 动态扫描技术。当扫描到n1~n4公共端时,LED驱动器分别对应输出a~dp显示段,LED就...
  • 动态链接就是根据不同的场景响应不同行为的链接。

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 409,967
精华内容 163,986
关键字:

动态链接原理