x64共享库中的位置无关代码(PIC)

原作者:Eli Bendersky

http://eli.thegreenplace.net/2011/11/11/position-independent-code-pic-in-shared-libraries-on-x64

之前的文章。以为x86架构编译的代码为样例,解释了位置无关代码(PIC)怎样工作。我承诺在还有一篇文章里涉及x64[1]上的PIC,如今就是了。本文将不会太进入细节,由于假定读者已经理解了理论上PIC怎样工作。

总之。对于这两个平台想法是相似的,但由于每一个架构独有的特性,某些细节是不同的。

RIP相对取址

在x86上,虽然訪问函数(使用call指令)使用指令指针的相对偏移,数据訪问(使用mov指令)仅支持绝对地址。正如我们在之前的文章里看到的。这使得PIC代码效率下降,由于PIC天然地要求全部的偏移是IP相对的;绝对地址与位置无关不能非常好地走在一起。

x64以新的“RIP相对取址”修正了这,它是全部64位訪问内存的mov指令的缺省模式(该模式也用于其它指令。比方lea)。以下援引自“Intel架构手冊卷2a”:

64位模式里实现了一个新的取址形式,RIP相对取址(相当于指令指针)。

通过向指向下一条指令的64RIP加入位移来构成一个有效的地址。

在RIP相对模式里使用的位移是32位大小的。由于它应该可用于正负偏移,这个取址模式支持大约最大+/-2GB的RIP偏移。

x64PIC数据訪问——一个样例

为了更easy比較,我将使用与前一篇文章同样的C源码作为数据訪问1样例:

int myglob = 42;

 

intml_func(int a, int b)

{

    return myglob + a + b;

}

让我们看一眼ml_func的反汇编代码:

00000000000005ec <ml_func>:

 5ec:   55                      push   rbp

 5ed:   48 89 e5                mov    rbp,rsp

 5f0:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi

 5f3:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi

 5f6:   48 8b 05 db 09 20 00    mov    rax,QWORD PTR [rip+0x2009db]

 5fd:   8b 00                   mov    eax,DWORD PTR [rax]

 5ff:   03 45 fc                add    eax,DWORD PTR [rbp-0x4]

 602:   03 45 f8                add    eax,DWORD PTR [rbp-0x8]

 605:   c9                      leave

 606:   c3                      ret

这里最有趣的指令在0x5f6:通过訪问GOT中的一个项,它将myglob的地址放入rax。正如我们看到的,它使用RIP相对取址。由于它相对于下一个指令的地址,我们实际得到的是0x5fd+ 0x2009db = 0x200fd8。因此保存myglob地址的GOT项在0x200fd8。

让我们检查一下这是否合理:

$ readelf -S libmlpic_dataonly.so

There are 35 section headers, starting at offset 0x13a8:

 

Section Headers:

  [Nr] Name              Type             Address           Offset

       Size              EntSize          Flags Link  Info  Align

 

[...]

  [20] .got              PROGBITS         0000000000200fc8  00000fc8

       0000000000000020  0000000000000008  WA      0     0     8

[...]

GOT始于0x200fc8,因此myglob是其第三个项。我们还能够看到为GOT訪问myglob而插入的重定位:

$ readelf -r libmlpic_dataonly.so

 

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

  Offset          Info           Type           Sym. Value    Sym. Name + Addend

[...]

000000200fd8  000500000006R_X86_64_GLOB_DAT 0000000000201010 myglob + 0

[...]

的确,0x200fd8的重定位项告诉动态加载器,一旦知道myglob的终于地址。把它放入0x200fd8。

因此在代码里myglob的地址怎样得到应该相当清楚。

汇编代码里下一条指令(0x5fd处)解引用这个地址将myglob的值放入eax[2]

x64PIC函数调用——一个样例

如今让我们看一下在x64上PIC代码怎样进行函数调用。再次,我们将使用之前文章里的样例:

int myglob = 42;

 

intml_util_func(int a)

{

    return a + 1;

}

 

intml_func(int a, int b)

{

    int c = b +ml_util_func(a);

    myglob += c;

    return b + myglob;

}

反汇编ml_func,我们得到:

000000000000064b <ml_func>:

 64b:   55                      push   rbp

 64c:   48 89 e5                mov    rbp,rsp

 64f:   48 83 ec 20             sub    rsp,0x20

 653:   89 7d ec                mov    DWORD PTR [rbp-0x14],edi

 656:   89 75 e8                mov   DWORD PTR [rbp-0x18],esi

 659:   8b 45 ec                mov    eax,DWORD PTR [rbp-0x14]

 65c:   89 c7                   mov    edi,eax

 65e:   e8 fd fe ff ff          call  560 <ml_util_func@plt>

 [... snip more code ...]

如前,这是对ml_util_func@lt的调用。看一下那里有什么:

0000000000000560 <ml_util_func@plt>:

 560:   ff 25 a2 0a 20 00       jmp   QWORD PTR [rip+0x200aa2]

 566:   68 01 00 00 00          push  0x1

 56b:   e9 d0 ff ff ff          jmp   540 <_init+0x18>

因此保存ml_util_func实际地址的GOT项在0x200aa2+ 0x566 = 0x201008。

就像期望的那样。有一个重定位用于它:

$ readelf -r libmlpic.so

 

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

[...]

 

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

  Offset          Info           Type           Sym. Value    Sym. Name + Addend

[...]

000000201008  000600000007R_X86_64_JUMP_SLO 000000000000063c ml_util_func + 0

性能影响

在这两个样例里,能够看到PIC在x64上比在x86上要求更少的指令。在x86上,GOT地址以两步被加载到某些基址寄存器(依据惯例ebx)——首先以一个特殊的函数调用获取指令的地址,然后加上到GOT的偏移。在x64上这两步都不须要。由于到GOT的相对偏移对链接器是已知的。并且能够简单地使用RIP相对取址编码在指令本身。

在调用一个函数时,也不须要为弹簧垫(trampoline)在ebx里准备GOT地址。就像x86代码做的那样。由于弹簧垫仅仅是直接通过RIP相对取址訪问其GOT项。

因此虽然PIC在x64上,相比非PIC代码。仍然要求额外的指令,但这额外的代价更小。束缚一个寄存器作为GOT指针的间接代价(在x86上令人痛苦)也没有了,由于使用RIP相对取址不须要这种寄存器[3]。总而言之,x64PIC导致的性能影响远小于x86,使得它更有吸引力。

其实。如此有吸引力,这是这个架构上编写共享库的缺省方法。

额外的学分:x64上的非PIC代码

gcc不仅鼓舞你在x64上对共享库使用PIC,它缺省地要求它。比如,假设我们没有使用-fpic[4]编译第一个样例。然后尝试将它链接入一个共享库(使用-shared),我们将从链接器得到一个错误。就像这样:

/usr/bin/ld: ml_nopic_dataonly.o: relocation R_X86_64_PC32against symbol `myglob' can not be used when making a shared object; recompilewith -fPIC

/usr/bin/ld: final link failed: Bad value

collect2: ld returned 1 exit status

发生了什么?让我们看一下ml_nopic_dataonly.o的反汇编代码[5]

0000000000000000 <ml_func>:

   0:   55                      push   rbp

   1:   48 89 e5                mov    rbp,rsp

   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi

   7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi

   a:   8b 05 00 00 00 00       mov   eax,DWORD PTR [rip+0x0]

  10:   03 45 fc               add    eax,DWORD PTR [rbp-0x4]

  13:   03 45 f8                add    eax,DWORD PTR [rbp-0x8]

  16:   c9                      leave

  17:   c3                      ret

注意如今在地址0xa处的指令里,myglob是怎样被訪问的。

它期望链接器在该指令的操作数里填补一个到myglob实际位置的重定位(因此不须要GOT重定位):

$ readelf -r ml_nopic_dataonly.o

 

Relocation section '.rela.text' at offset 0xb38 contains 1entries:

  Offset          Info           Type           Sym. Value    Sym. Name + Addend

00000000000c  000f00000002R_X86_64_PC32     0000000000000000 myglob- 4

[...]

这里链接器抱怨的是R_X86_64_PC32重定位。它不能将带有这样重定位的对象链接进一个共享库。

为什么?由于mov的移位(加到rip的部分)必须能装入32比特,当代码进入共享库时,我们不能预先知道32比特是足够的。

毕竟。这是一个全然的64位架构,带有巨大的地址空间。

终于可能在某个超过32比特所允许距离的共享库里找到该符号。这使得R_X86_64_PC32对x64共享库无效。

但我们仍然能够在x64上创建非PIC代码?是的。我们应该指引编译器使用“大代码模型”。通过加入-mcmodel=larger标记。

代码模型的议题是有趣的。但解释它会使我们离题太远[6]

因此我仅仅能说代码模型是程序猿与编译器之间的一种协议,当中程序猿向编译器做出某种关于程序将要使用偏移大小的承诺。

作为回报,编译器能够生成更好的代码。

结果是要使得编译器在x64上生成能取悦链接器的非PIC代码,仅仅有大代码模型是合适的,由于它是限制最少的。

记住我怎样解释为什么在x64上简单的重定位不够好,操心在链接时偏移会超出32比特。好吧,大代码模型基本上放弃了对偏移的假设。对全部的代码訪问使用最大的64位比特。

这使得加载时重定位总是安全的,使得x64上的非PIC代码生成成为可能。让我们看一下不使用-fpic,使用-mcmodel=large编译第一个样例的反汇编代码:

0000000000000000 <ml_func>:

   0:   55                      push   rbp

   1:   48 89 e5                mov    rbp,rsp

   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi

   7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi

   a:   48 b8 00 00 00 00 00    mov   rax,0x0

  11:   00 00 00

  14:   8b 00                   mov    eax,DWORD PTR [rax]

  16:   03 45 fc                add    eax,DWORD PTR [rbp-0x4]

  19:   03 45 f8                add    eax,DWORD PTR [rbp-0x8]

  1c:   c9                      leave

  1d:   c3                      ret

在地址0xa处的指令将myglob的地址放入eax。注意到它的操作数当前是0,它告诉我们期待一个重定位。还注意到它具有一个完整的64位地址參数。

另外。这个參数是绝对。非RIP相对的[7]

还有将myglob的值放入eax,这里实际须要两条指令。

这是为什么大代码模型效率更低的一个原因。

如今让我们看一下重定位:

$ readelf -r ml_nopic_dataonly.o

 

Relocation section '.rela.text' at offset 0xb40 contains 1entries:

  Offset          Info           Type           Sym. Value    Sym. Name + Addend

00000000000c  000f00000001R_X86_64_64       0000000000000000 myglob+ 0

[...]

注意重定位类型变为R_X86_64,这是一个能够具有64比特值的绝对重定位。它是链接器可接受的,它如今欣然允许将这个目标文件链接入一个共享库。

一些推断性的思考可能让你沉思为什么编译器缺省生成不适合加载时重定位的代码。

答案是简单的。不要忘记代码倾向于直接链接入全然不要求加载时重定位的可运行文件。因此。缺省的编译器假定小代码模型以生成最高效的代码。假设你知道你的代码将进入一个共享库,并且你不希望PIC,那么仅仅要明白告诉它使用大代码模型。我觉得这里gcc的行为是合理的。

还有一件须要考虑的事是为什么PIC代码使用小代码模型就没有问题。原因是GOT总是与訪问它的代码位于同一个共享库里。除非单个共享库超过32位地址空间。使用32位RIP相对偏移訪问PIC是没有问题的。这样巨大的共享库是差点儿不可能的。但万一你碰上一个,AMD64ABI实用于此目的的“大PIC代码模型”。

结论

通过展示PIC怎样在x64架构上工作,本文补充了之前文章没有触及的内容。

X64架构有一个辅助PIC代码更快运行的新的取址模型,因此使得它比x86上代价更高的共享库更令人期待。由于x64眼下是server、桌面及膝上电脑中最流行的架构,知道这些非常重要。因此我尝试关注将代码编译为共享库的另外方面,比方非PIC代码。假设你有不论什么关于未来研究方向的问题或建议,请通过评论或邮件让我知道。



[1]一如既往,我使用x64作为被称为x86-64,AMD64或Intel 64的架构的一个方便短名。

[2] 放入eax而不是rax是由于myglob的类型是int,在x64这仍然是32位大小。

[3] 随便提一下。在x64束缚一个寄存器远没有那么“痛苦”,由于它的通用寄存器两倍于x86。

[4]假设我们通过向gcc传递-fno-pic显式指定我们不希望PIC,也会发生这种情形。

[5] 注意到不像我们在这篇及之前文章里看过的反汇编代码,这是一个目标文件。不是一个共享库或可运行文件。

因此它会包括链接器使用的重定位。

[6] 这个议题某些好的资料,參考AMD64 ABI,及man gcc。

[7] 某些汇编器称这个指令为movabs以差别于接受一个相对參数的mov。

只是Intel架构手冊还是称之为mov。

它的操作码格式是REX.W + B8 + rd。

posted @ 2018-04-19 15:51  llguanli  阅读(334)  评论(0编辑  收藏  举报