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

2019-04-15 12:44发布

原作者: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数据访问——一个例子 为了更容易比较,我将使用与前一篇文章相同的C源代码作为数据访问1例子: int myglob = 42;   intml_func(int a, int b) {     return myglob + a + b; } 让我们看一眼ml_func的反汇编代码: 00000000000005ec :  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 :  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  [... snip more code ...] 如前,这是对ml_util_func@lt的调用。看一下那里有什么: 0000000000000560 :  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 :    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 :    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目前是服务器、桌面及膝上电脑中最流行的架构,知道这些很重要。因此我尝试关注将代码编译为共享库的另外方面,比如非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。