以下内容来自网上一篇相当不错的文章(PLT and GOT - the key to code sharing and dynamic libraries),原作者是IanWienand,原作地址是http://www.technovelty.org/linux/pltgot.html

共享库是一个现代系统不可分割的部分,但这个实现背后的机制很少被很好地理解。当然,对此有许多的指引。而我希望从另一个角度能引起一些人的共鸣。

让我们从头开始——重定位是二进制文件中要留待以后填充的项——由链接器在链接时刻,或由动态链接器在运行时刻填充。在一个二进制文件中的一个重定位是一个本质上宣告“确定X的值,并把这个值放入这个二进制文件偏移Y处”的描述符——每个重定位有一个特定的类型,定义在ABI文档中,这个类型描述了“确定…的值”实际上如何确定。

这里是最简单的例子:

$ cat a.c

extern int foo;

 

int function(void) {

    return foo;

}

$ gcc -c a.c

$ readelf --relocs ./a.o 

 

Relocation section '.rel.text' at offset 0x2dc contains 1 entries:

 Offset     Info    Type            Sym.Value  Sym. Name

00000004  00000801 R_386_32          00000000   foo

Foo的值在你制作a.o的时刻是未知的,因此编译器留下一个重定位(类型R_386_32),它宣称“在最终的二进制文件里,在这个目标文件偏移为0x4处,补上符号foo的地址”。如果看一下输出,你可以看到在偏移0x4处有4个字节的0,它正在等待一个真正的地址:

$ objdump --disassemble ./a.o

 

./a.o:     file format elf32-i386

 

Disassembly of section .text:

 

00000000 <function>:

   0:              55                                                       push   %ebp

   1:              89 e5                              mov    %esp,%ebp

   3:              a1 00 00 00 00            mov    0x0,%eax

   8:              5d                                   pop    %ebp

   9:              c3                                   ret   

这是在链接时刻;如果你使用foo的一个值构建另一个目标文件,并把它构建入一个最终的可执行文件,这个重定位可以消失。但对于一个完全链接的可执行文件或共享库,这里有一大堆东西直到运行时才能确定。主要的原因,就像我将尝试解释的,是位置无关代码(PIC)。如果你看一下一个可执行文件,你会注意到它有一个固定的载入地址。

$ readelf --headers /bin/ls

[...]

ELF Header:

[...]

  Entry point address:               0x8049bb0

 

Program Headers:

  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align

[...]

  LOAD         0x000000 0x08048000 0x08048000 0x16f88 0x16f88 R E 0x1000

  LOAD         0x016f88 0x0805ff88 0x0805ff88 0x01543 0x01543 RW  0x1000

这不是位置无关的。代码节(带有权限R E;即读及执行)必须被载入到虚地址0x08048000,而数据节(RW)必须在之上载入,确切地址是0x0805ff88。

对于一个可执行文件这很好,因为每次你启动一个新的进程(fork及exec)你有自己新的地址空间。预先计算地址并让它们在最后的输出中固定,这样显著降低了时间的消耗(你可以制作位置无关可执行文件,但这是另一个故事)。

对于一个共享库(.so)这就不好了。一个共享库的全部意义在于应用挑选库的任意组合来实现它们的目的。如果你的共享库被构建为仅当载入到一个特定的地址才工作,可能一切都很好——直到另一个也被构建为使用这个地址的库出现。这个问题实际上不难处理——你可以枚举在仅这个系统上的每个单独的共享库,并向它们分配唯一的地址范围,确保不管载入什么样的库组合,它们都不会重叠。这本质上是预链接所做的内容(虽然这只是一个暗示,而不是一个固定的,被要求的基地址)。除了作为一个维护的噩梦,使用32位系统,如果你尝试给每个可能的库一个唯一的位置,你很快开始用完地址空间。因此当你检查一个共享库时,它们不会指定一个载入的特定基地址:

$ readelf --headers /lib/libc.so.6

Program Headers:

  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align

[...]

  LOAD         0x000000 0x00000000 0x00000000 0x236ac 0x236ac R E 0x1000

  LOAD         0x023edc 0x00024edc 0x00024edc 0x0015c 0x001a4 RW 0x1000

共享库还有另一个目标——代码共享。如果一百个进程使用一个共享库,在内存中有100份占据空间的拷贝是不合理的。如果这个代码完全是只读的,因而永远不会被修改,那么每个进程可以共享相同的代码。不过,我们仍然有在每个进程中这个共享库仍然必须有一个唯一的数据实例这个限制。虽然在运行时把该库的数据放在任何地方是可能的,但这将要求留下修补代码的重定位,并告诉它在哪里找到这些数据——破坏了代码的只读性,从而可共享性。就像你可以在上面头文件中看到的,解决方案是读写数据节总是放在到这个库代码节的一个已知的偏移位置。这样,通过虚拟内存的魔术,每个进程看到自己的数据节,但是可以共享非修改的代码。访问数据所需的只是一些简单的算术;我希望对象的地址= 我当前地址 + 已知固定的偏移。

好了,简单数学都是相对的!“我当前地址”可能或可能不容易找到。考虑以下:

$ cat test.c

static int foo = 100;

 

int function(void) {

    return foo;

}

$ gcc -fPIC -shared -o libtest.so test.c

这样foo将在数据节,自该函数代码的一个固定偏移,而我们需要做的是找到它!在amd64上,这相对容易,观查这个汇编:

000000000000056c <function>:

 56c:                                55                                                       push   %rbp

 56d:                               48 89 e5                        mov    %rsp,%rbp

 570:                               8b 05 b2 02 20 00       mov    0x2002b2(%rip),%eax        # 200828 <foo>

 576:                               5d                                   pop    %rbp

这声称“把这距当前指令指针(%rip)偏移0x2002b2 处的值放入%eax”。也就是说我们知道这个数据在固定的偏移,因此我们做完了。另一个方面,i386不具备从当前指令指针偏移的能力。在这里要求一些诡计:

0000040c <function>:

 40c:           55                                                       push   %ebp

 40d:           89 e5                              mov    %esp,%ebp

 40f:            e8 0e 00 00 00             call   422 <__i686.get_pc_thunk.cx>

 414:           81 c1 5c 11 00 00       add    $0x115c,%ecx

 41a:           8b 81 18 00 00 00       mov    0x18(%ecx),%eax

 420:           5d                                   pop    %ebp

 421:           c3                                   ret   

 

00000422 <__i686.get_pc_thunk.cx>:

 422:           8b 0c 24                        mov    (%esp),%ecx

 425:           c3                                   ret   

这里的魔术是__i686.get_pc_thunk.cx。这个架构不让我们得到当前指令的地址,但我们可以得到一个已知的固定地址——__i686.get_pc_thunk.cx压入cx的值是这个调用的返回地址,即在这个情形中是0x414。那么我们可以为这个add指令进行计算;0x115c + 0x414 = 0x1570,最后的move跑到0x18个字节后的0x1588……查看汇编

00001588 <global>:

    1588:       64 00 00                add    %al,%fs:(%eax)

即,10进制值100,保存在数据节。

我们更接近了些,但仍然有一些问题要处理。如果一个共享库可以载入到任意地址,那么一个可执行文件,或其它共享库,如何知道怎样在其中访问数据或调用函数?理论上,我们可以载入这个库,并拼凑到这个库的任何数据引用或函数调用;不过,就像刚才描述的,这将破坏代码的可共享性。正如我们知道的,所有的问题可以通过一层间接性来解决,在这里这层间接性被称为全局偏移表或GOT。

考虑以下库:

$ cat test.c

extern int foo;

 

int function(void) {

    return foo;

}

$ gcc -shared -fPIC -o libtest.so test.c

注意这看起来跟前面一样,但在这个情形里,foo是extern;假定由其它库提供。让我们更贴近地看一下在amd64上这如何工作:

$ objdump --disassemble libtest.so

[...]

00000000000005ac <function>:

 5ac:                                55                                                       push   %rbp

 5ad:                               48 89 e5                        mov    %rsp,%rbp

 5b0:                               48 8b 05 71 02 20 00                     mov    0x200271(%rip),%rax        # 200828 <_DYNAMIC+0x1a0>

 5b7:                               8b 00                             mov    (%rax),%eax

 5b9:                               5d                                   pop    %rbp

 5ba:                               c3                                   retq  

 

$ readelf --sections libtest.so

Section Headers:

  [Nr] Name              Type             Address           Offset

       Size              EntSize          Flags    Link  Info  Align

[...]

  [20] .got              PROGBITS         0000000000200818  00000818

       0000000000000020  0000000000000008  WA    0     0     8

 

$ readelf --relocs libtest.so

Relocation section '.rela.dyn' at offset 0x418 contains 5 entries:

  Offset          Info           Type           Sym. Value    Sym. Name + Addend

[...]

000000200828  000400000006 R_X86_64_GLOB_DAT 0000000000000000 foo + 0

汇编代码显示要返回的值从当前%rip的偏移0x200271,即0x0200828处载入。看一下节头,我们看到这是.got节的部分。在我们检查这些重定位时,我们看到一个声称“找到符号foo的值,并把它放入地址0x200828”的重定位R_X86_64_GLOB_DAT。

这样,当载入这个库时,动态载入器将检查这个重定位,找到foo的值,并按要求拼凑这个.got项。当载入代码要载入这个值时,它将指向正确的地方,一切都很好;不需要修改任何代码的值,并因而破坏代码的可共享性。

这处理了数据,那么函数调用又如何?在这里使用的间接性被称为一个过程连接表或PLT。代码不会直接调用一个外部函数,只会通过一个PLT存根。让我们看一下这个:

$ cat test.c

int foo(void);

 

int function(void) {

    return foo();

}

$ gcc -shared -fPIC -o libtest.so test.c

 

$ objdump --disassemble libtest.so

[...]

00000000000005bc <function>:

 5bc:                                55                                                       push   %rbp

 5bd:                               48 89 e5                        mov    %rsp,%rbp

 5c0:                                e8 0b ff ff ff                 callq  4d0 <foo@plt>

 5c5:                                5d                                   pop    %rbp

 

$ objdump --disassemble-all libtest.so

00000000000004d0 <foo@plt>:

 4d0:   ff 25 82 03 20 00       jmpq   *0x200382(%rip)        # 200858 <_GLOBAL_OFFSET_TABLE_+0x18>

 4d6:   68 00 00 00 00          pushq  $0x0

 4db:   e9 e0 ff ff ff          jmpq   4c0 <_init+0x18>

 

$ readelf --relocs libtest.so

Relocation section '.rela.plt' at offset 0x478 contains 2 entries:

  Offset          Info           Type           Sym. Value    Sym. Name + Addend

000000200858  000400000007 R_X86_64_JUMP_SLO 0000000000000000 foo + 0

好了,我们看到函数调用了0x4d0处的代码。反汇编后,我们看到一个有趣的调用,我们跳到在超越当前%rip 0x200382(即0x200858)处所保存的值,然后我们可以看到这个重定位所对应的——符号foo。

跟着这个想法很有趣;让我们看一下所跳到的初始值:

$ objdump --disassemble-all libtest.so

 

Disassembly of section .got.plt:

 

0000000000200840 <.got.plt>:

  200840:       98                      cwtl  

  200841:       06                      (bad) 

  200842:       20 00                   and    %al,(%rax)

        ...

  200858:       d6                      (bad) 

  200859:       04 00                   add    $0x0,%al

  20085b:       00 00                   add    %al,(%rax)

  20085d:       00 00                   add    %al,(%rax)

  20085f:       00 e6                   add    %ah,%dh

  200861:       04 00                   add    $0x0,%al

  200863:       00 00                   add    %al,(%rax)

  200865:       00 00                   add    %al,(%rax)

        ...

还原0x200858,我们看到其初始值是0x4d6——即,下一个指令!然后它压入值0,并跳到0x4c0。看一下代码,我们可以看到它从GOT压入一个值,然后跳到GOT中的第二个值:

00000000000004c0 <foo@plt-0x10>:

 4c0:   ff 35 82 03 20 00       pushq  0x200382(%rip)        # 200848 <_GLOBAL_OFFSET_TABLE_+0x8>

 4c6:   ff 25 84 03 20 00       jmpq   *0x200384(%rip)        # 200850 <_GLOBAL_OFFSET_TABLE_+0x10>

 4cc:   0f 1f 40 00             nopl   0x0(%rax)

这是怎么回事?实际发生的是延迟绑定(lazy binding)——按照惯例,当动态链接器载入一个库时,它将把一个标识符及分辨出的函数放入GOT中已知的位置。因此,所发生的大致是:在一个函数的第一次调用中,它直接调用缺省的存根,这个存根载入这个标识符并调用动态链接器,动态链接器此时有足够的信息来理解“嘿,这个libtest.so尝试找出函数foo”。它将前行并找到它,然后把这个地址填充入GOT,这样最初的PLT项下一次被调用时,它将载入这个函数实际的地址,而不是查找存根。巧妙!

这个间接性还有另一个便利性——修改符号绑定次序的能力。例如,LD_PRELOAD只是告诉动态载入器应该首先插入一个要从中查找符号的库;因此当上面的绑定发生时,如果预载入的库定义了一个foo,它将被选中,不管其它库是否定义了foo。

总而言之代码总是应该只读的,而做到这使得你仍然可以从其它库访问数据及调用外部函数,这些访问是通过一个在编译时刻有已知偏移的GOT及PLT间接发生的。


Logo

更多推荐