原作者:Eli Bendersky
http://eli.thegreenplace.net/2011/11/03/position-independent-code-pic-in-shared-libraries/
在之前的文章里我已经描写叙述过在将共享库加载程序地址空间时须要特殊的处理。简而言之,在链接器创建共享库时,它不能预先知道这个库将在哪里加载。这给在库里訪问数据与代码带来了麻烦,应该使得这些訪问指向正确的内存位置。
在Linux ELF共享库里解决问题有两个主要途径:
1. 加载时重定位
2. 位置无关代码(PIC)
加载时重定位已经说过了。这里。我想解释第二个方法——PIC。
一開始我计划在本文里同一时候关注x86与x64(即x86-64),但随着文章越来越长。我发现这是不现实的。因此我将仅解释PIC在x86上怎样工作,选择这个更老的架构是由于(不像x64)它设计时没有考虑PIC,因此实现PIC有一点棘手。
将来的一篇文章(希望篇幅能够大大缩短)将在这篇的基础上解释怎样在x64上实现PIC。
加载时重定位的一些问题
正如我们在之前文章里看到的。加载时重定位是一个相当简单的方法。而且奏效。只是时至今日PIC要流行得多。是构建共享库的推荐方法。为什么会这样?
加载时重定位有几个问题:它须要时间运行。而且它使得库的代码节不可共享。
首先,性能问题。
假设以加载时重定位项链接一个共享库,在应用程序加载时,须要花费一些时间运行这些重定位。
你可能会觉得这个代价不会太大——毕竟。加载器不须要扫描整个代码节——它仅需考虑重定位项。但假设一段复杂的软件在启动时加载多个大的共享库。而每一个共享库必须首先应用它自己的加载时重定位。这些代价会累积,导致该应用程序启动时间可观的延迟。
其次。更为严重的不可共享代码节的问题。首先共享库存在的要点之中的一个是节省RAM。
一些通用的共享库被多个应用程序所使用。
假设共享库的代码节(代码所在)能够仅仅加载内存一次(然后映射到很多进程的虚拟内存),数目可观的RAM能够被节省下来。但对加载时重定位这是不可能的,由于使用这个技术时。须要在加载时改动代码节来应用重定位。因此,对于加载这个共享库的每一个应用程序,它将被再次整个地放入RAM[1]。不同的应用程序不能真正地共享它。
另外,拥有一个可写的代码节(它必须保持可写,以同意动态加载器运行重定位)形成了一个安全风险。使得攻击应用程序更easy。
正如我们将在本文中看到的,PIC极大地缓解了这些问题。
PIC——介绍
PIC背后的思想是简单的——对代码中訪问的全部全局数据与函数加入一层额外的抽象。通过巧妙地利用链接与加载过程中的某些工件,使得共享库的代码节真正位置无关是可能的。从这个意义来说它能够不做不论什么改变而easy地映射到不同的内存地址。在下几节我将详解怎样实现这一壮举。
关键的洞察#1——代码与数据节间的偏移
PIC所依赖的关键的洞察之中的一个是代码与数据节间的偏移。在链接时刻为链接器所知。
当链接器将几个目标文件合并起来时,它收集它们的节(比如。全部的代码节合并为一个大的代码节)。因此,链接器知道节的大小与它们的相对位置。
比如,数据节可能紧随代码节。因此代码节中任一给定指令到数据节起始的偏移是该代码节的大小减去代码节起始到该指令的偏移——这两个数据链接器都是知道的。
在上图中,代码节被加载到某个地址(链接时刻未知)0xXXXX000(X表示无关紧要),数据节紧随其后,在0xXXXXF0000。那么。假设在代码节0x80偏移处的指令想訪问数据节里的内容。链接器知道相对偏移(这个情形里是0xEF80)并将它写入该指令。
注意到是否有还有一个节插在代码节及数据节之间,或者代码节跟在数据节后,是无关紧要的。由于链接器知道全部节的大小并决定何处放置它们,这个洞察成立。
关键的洞察#2——使得IP相对偏移在x86上工作
仅仅有我们让相对偏移工作,上面的讨论才实用。
但在x86上訪问数据(即在mov指令里)要求绝对地址。
因此,我们该怎么办?
假设我们有一个相对地址而须要的是一个绝对地址,所缺少的是指令指针的值(由于依据定义,相对地址是相对于指令位置的)。
在x86上没有指令能够获取指令指针的值。但我们能够使用一个简单的技巧办到。以下是展示这个技巧的汇编伪代码:
call TMPLABEL
TMPLABEL:
pop ebx
这里发生的是:
1. CPU运行call TMPLABEL。这使得它将下一条指令(popebx)的地址保存到栈上并跳到这个标记。
2. 由于标记处的指令是pop ebx。它下一步得到运行。它从栈里向ebx弹出一个值。只是这个值就是指令本身的地址。因此ebx如今实际上包括了指令指针的值。
全局偏移表(GOT)
有鉴于此,我们终于能够达成x86上訪问位置无关代码的实现。它依靠一个“全局偏移表”或简称GOT来完毕。
GOT仅仅是一个地址表,位于数据节里。如果在代码节里某条指令想訪问一个变量。指令不是通过绝对地址直接訪问它(这将要求一个重定位),而是訪问GOT里的一个项。由于GOT在数据节的一个已知位置,这个訪问是相对的且链接器已知。
而GOT项将包括该变量的绝对地址:
在伪汇编代码里,我们替换了一条绝对取址指令:
; Place the value of the variable in edx
mov edx, [ADDR_OF_VAR]
以带一个额外间接性的寄存器位移寻址:
; 1. Somehow get the address of the GOT into ebx
lea ebx, ADDR_OF_GOT
; 2. Suppose ADDR_OF_VAR is stored at offset 0x10
; in the GOT. Then thiswill place ADDR_OF_VAR
; into edx.
mov edx, DWORD PTR [ebx + 0x10]
; 3. Finally, access the variable and place its
; value into edx.
mov edx, DWORD PTR [edx]
这样。在代码里通过GOT重定向变量的訪问,我们去掉了一个重定位。只是我们还是要在数据节里创建一个重定位。为什么?由于要让上面描写叙述的场景工作,GOT仍然必须包括变量的绝对地址。
那么我们得到了什么优点?
答案是非常多。由于两个原因(它们直接攻克了文章开头描写叙述的加载时重定位代码的两个主要问题),在数据节里的重定位比代码节里的重定位问题要少得多。
1. 每次变量訪问都要求代码节里的重定位。而在GOT里对每一个变量我们仅仅须要重定位一次。
对变量的訪问数极可能远多于变量数,因此这更高效。
2. 数据节是可写的且不在进程间共享。因此向它加入重定位没有害处。而将重定位移出代码节使得代码节变成仅仅读且在进程间共享。
带有通过GOT的数据訪问的PIC——一个样例
如今我将出示一个展示了PIC机制的完整样例:
int myglob =42;
intml_func(int a,int b)
{
return myglob + a + b;
}
这段代码将被编译为一个名为libmlpic_dataonly.so的共享库(适当地使用-fpic及-shared标记)。
让我们 看一眼它的汇编,关注ml_func函数:
0000043c <ml_func>:
43c: 55 push ebp
43d: 89 e5 mov ebp,esp
43f: e8 16 00 00 00 call 45a <__i686.get_pc_thunk.cx>
444: 81 c1 b0 1b 00 00 add ecx,0x1bb0
44a: 8b 81 f0 ff ff ff mov eax,DWORD PTR [ecx-0x10]
450: 8b 00 mov eax,DWORD PTR [eax]
452: 03 45 08 add eax,DWORD PTR [ebp+0x8]
455: 03 45 0c add eax,DWORD PTR [ebp+0xc]
458: 5d pop ebp
459: c3 ret
0000045a <__i686.get_pc_thunk.cx>:
45a: 8b 0c 24 mov ecx,DWORD PTR [esp]
45d: c3 ret
我准备通过它们的地址訪问指令(反汇编代码里最左側的数字)。这个地址是自该共享库加载地址的偏移。
· 在43f。下一条指令的地址放入了ecx。通过上面关键的洞察#2所描写叙述的技术。
· 在444,从该指令到GOT所在位置的一个已知的常量偏移加上ecx。因此如今ecx用作GOT的基址指针。
· 在44a。从[ecx – 0x10]获取一个值放入eax。它是一个GOT项。
这是myglob的地址。
· 在450运行间接取址,myglob的值被放入eax。
· 随后參数a和b加到myglob,并返回这个值(通过把它保存在eax)。
我们能够以readelf –S查询该共享库来看GOT节放在哪里:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
<snip>
[19] .got PROGBITS 00001fe4 000fe4 000010 04 WA 0 0 4
[20] .got.pltPROGBITS 00001ff4 000ff4 00001404 WA 0 0 4
<snip>
让我们来检验编译器找出myglob所完毕的计算。正如我上面提到的,对__i686.get_pc_thunk.cx的调用将下一条指令的地址放入ecx。
那个地址是0x444[2]。下一条指令将它加上0x1bb0,在ecx中的结果将是0x1ff4。最后。为了实际获取保存myglob地址的GOT项。使用位移地址——[ecx– 0x10],因此这个项在0x1fe4,它是根据节头的GOT的第一个项。
为什么有还有一个名字以.got开头的节将在后面解释[3]。
注意编译器选择将ecx指向GOT末尾,然后使用负的偏移来获取项。
这没问题,仅仅要能算出来。而眼下为止是能够的。
只是我们还是漏了一些东西。Myglob的地址怎样真正地进入到0x1fe4处的GOT槽?记得我提到过一个重定位,因此让我们找找看:
> readelf -r libmlpic_dataonly.so
Relocation section '.rel.dyn' at offset 0x2dc contains 5entries:
Offset Info Type Sym.Value Sym. Name
00002008 00000008R_386_RELATIVE
00001fe4 00000406R_386_GLOB_DAT 0000200c myglob
<snip>
注意myglob的重定位节正如预期那样,指向地址0x1fe4。
该重定位具有类型R_386_GLOB_DAT。它仅仅是告诉动态加载器——“将这个符号的实际值(即它的地址)放入那个偏移”。因此全部东西都工作得非常好。剩下的就是检查在加载这个库时,它实际看起来是怎么样的。为此。我们能够编写一个简单的,链接了libmlpic_dataonly.so并调用ml_func的“driver”可执行文件。并通过GDB执行它。
> gdb driver
[...] skipping output
(gdb) set environment LD_LIBRARY_PATH=.
(gdb) break ml_func
[...]
(gdb) run
Starting program: [...]pic_tests/driver
Breakpoint 1, ml_func (a=1, b=1) at ml_reloc_dataonly.c:5
5 return myglob +a + b;
(gdb) set disassembly-flavor intel
(gdb) disas ml_func
Dump of assembler code for function ml_func:
0x0013143c<+0>: push ebp
0x0013143d<+1>: mov ebp,esp
0x0013143f<+3>: call 0x13145a <__i686.get_pc_thunk.cx>
0x00131444 <+8>: add ecx,0x1bb0
=> 0x0013144a <+14>: mov eax,DWORD PTR [ecx-0x10]
0x00131450<+20>: mov eax,DWORD PTR [eax]
0x00131452<+22>: add eax,DWORD PTR [ebp+0x8]
0x00131455<+25>: add eax,DWORD PTR [ebp+0xc]
0x00131458<+28>: pop ebp
0x00131459<+29>: ret
End of assembler dump.
(gdb) i registers
eax 0x1 1
ecx 0x132ff4 1257460
[...] skipping output
调试器进入了ml_func。在IP0x0013144a处停下[4]。我们看到ecx保存着值0x132ff4(它是指令的地址加上0x1bb0,就像前面解释的那样)。注意执行时在这里,这些都是绝对地址——共享库已经被加载到进程的地址空间。
这样,myglob的GOT项在[ecx –0x10]。让我们查看一些那里有什么:
(gdb) x 0x132fe4
0x132fe4: 0x0013300c
这样。我们期望0x0013300c是myglob的地址。
让我们验证一下:
(gdb) p &myglob
$1 = (int *) 0x13300c
确实是的!
PIC里的函数调用
好了。这就是在位置无关代码里数据取址的工作方式。
但函数调用又怎样呢?理论上,相同的方法应该也能对付函数调用。不是在call实际包括要调用函数的地址,而是让它包括一个已知GOT项的地址。并在加载时填充该项。
只是这不是PIC里函数调用的工作方式。
实际发生的要更复杂一点。
在我解释怎样做之前,要说一下这样机制的动机。
延迟绑定优化
当一个共享库訪问某个函数时,该函数的真实地址直到加载时刻才会知道。解析这个地址称为绑定(binding),它是动态加载器在加载共享库时完毕的。这个绑定过程不简单。由于加载器必须在特殊的表里查找函数符号[5]。
因此,解析每一个函数要花时间。不是非常多,但它是累计的,由于库里函数的数量通常远多于全局变量的数量。
另外。大多数这些解析是无用功,由于通常程序仅仅会调用一小部分函数(想一想各种错误处理函数及特殊的条件,它们通常不会被调用)。
这样,为了加速这个过程,发明了一个聪明的延迟(lazy)绑定方案。“延迟”是计算机编程里一族优化的通用名,其工作被推迟直到它被真正须要的最后一刻,目的是假设在程序的一次特殊执行中不须要其结果,就能够避免执行之。延迟的好样例有写时拷贝以及延迟求值。
这个延迟绑定方案通过加入还有一层的间接性——PLT来实现。
程序链接表(PLT)
PLT是可运行文件代码节的部分,包括了一组入口(每一个共享库调用的外部函数一个)。
每一个PLT项是一小段可运行代码。不是直接调用函数,代码调用PLT里的一个项,它负责调用真正的函数。
这个安排有时称为“弹簧垫(trampoline)”。每一个PLT项还在GOT中有一个相应的项,仅在动态加载器解析它时,才包括函数实际偏移。我知道这令人困惑,但希望在以下几段和图中解释了细节后。这会清楚起来。
就像前面章节提到的,PLT同意函数的延迟解析。
在共享库第一次加载时,函数调用还没解析:
解释:
· 在代码里,函数func被调用。编译器把它翻译为对func@plt的调用,它是PLT里的第N个项。
· PLT第一个项是特殊的。后跟一堆结构同样的项,每一个须要解析的函数人手一个。
· 除了第一个,每一个PLT项包括这些部分:
o 对在相应GOT项里指定位置的跳转
o 为“解析者”例程准备參数
o 调用解析者例程。它位于PLT的第一项。
· 第一个PLT项称为解析者例程。它本身位于动态加载器里[6]。
这个例程解析函数的实际地址。
稍后会有很多其它的讨论。
· 在函数的实际地址被解析出来之前。GOT的第N项仅仅是指向jmp后的位置。这就是为什么在图中这个箭头的颜色不同——它不是一个实际的跳转,仅仅是一个指针。
在第一次调用func时会发生这些事情:
· 调用PLT[n],并跳转到由GOT[n]指向的地址。
· 这个地址本身指向PLT[n],为解析者准备參数。
· 调用解析者。
· 解析者解析func的实际地址。把它的实际地址放入GOT[n]。然后调用func。
在第一次调用后。图看起来有点不一样:
注意GOT[n]如今指向实际的func[7]。而不是指回PLT。因此。当func被再次调用时:
· 调用PLT[n]。并跳转到GOT[n]指向的地址。
· GOT[n]指向func。因此这就将控制权转给func。
换而言之,如今func将被实际调用。无需通过解析者,代价就是一次额外的跳转。真的,这就是全部的一切。这个机制同意函数的延迟解析,对于全然没有被调用的函数根本不解析。
它还使得库的代码/数据节全然位置无关,由于唯一使用绝对地址的地方是GOT,GOT位于代码节而且由动态加载器重定位。甚至PLT本身也是PIC的,因此它能够存在于仅仅读代码节里。
我没有进入解析者的太多细节,但对我们这里的目标它并不重要。解析者仅仅是加载器里运行符号解析的一段低级代码。
在每一个PLT项里为它准备參数,连同一个合适的重定位项,辅助它了解须要重定位的符号及要更新的GOT项。
通过PLT及GOT调用函数的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;
}
这个代码将被编译进libmlpic.so,关注点在从ml_func对ml_util_func的调用。
首先让我们反汇编ml_func:
00000477 <ml_func>:
477: 55 push ebp
478: 89 e5 mov ebp,esp
47a: 53 push ebx
47b: 83 ec 24 sub esp,0x24
47e: e8 e4 ff ff ff call 467 <__i686.get_pc_thunk.bx>
483: 81 c3 71 1b 00 00 add ebx,0x1b71
489: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
48c: 89 04 24 mov DWORD PTR [esp],eax
48f: e8 0c ff ff ff call 3a0 <ml_util_func@plt>
<... snip morecode>
有趣的部分是对ml_util_func@plt的调用。
注意到GOT的地址在ebx。ml_util_func@plt看起来像这样(它在一个叫.plt的可运行节里):
000003a0 <ml_util_func@plt>:
3a0: ff a3 14 00 00 00 jmp DWORD PTR [ebx+0x14]
3a6: 68 10 00 00 00 push 0x10
3ab: e9 c0 ff ff ff jmp 370 <_init+0x30>
回顾每一个PLT项包括三个部分:
· 到GOT指定地址的一个跳转(这是跳转到[ebx + 0x14])
· 为解析者准备參数
· 调用解析者
解析者(PLT项0)位于地址0x370。但我们这里对它不感兴趣。看一下GOT包括了什么更有趣。
为此,我们首先做些算术。Ml_func里的“获取IP”的技巧在地址0x483完毕,加上0x1b71。因此GOT的基址在0x1ff4。我们能够使用readelf看一眼GOT的内容[8]:
> readelf -x .got.plt libmlpic.so
Hex dump of section '.got.plt':
0x00001ff4 241f000000000000 00000000 86030000 $...............
0x00002004 96030000a6030000 ........
ml_util_func@plt着眼的GOT项在偏移+0x14,即0x2008。
由上面,该位置上的内存字是0x3a6。它是ml_util_func@plt里push指令的地址。
为了帮助动态加载器完毕它的工作。也加入了一个重定位项指定在GOT何处对ml_util_func进行重定位:
> readelf -r libmlpic.so
[...] snip output
Relocation section '.rel.plt' at offset 0x328 contains 3entries:
Offset Info Type Sym.Value Sym. Name
00002000 00000107R_386_JUMP_SLOT 00000000 __cxa_finalize
00002004 00000207R_386_JUMP_SLOT 00000000 __gmon_start__
00002008 00000707R_386_JUMP_SLOT 0000046c ml_util_func
最后一行表示动态加载器应该将符号ml_util+func的值(地址)放入0x2008(回顾这是这个函数的GOT项)。
看这个GOT项在第一个调用后发生的实际改动应该是有趣的。让我们再次使用GDB。
> gdb driver
[...] skipping output
(gdb) set environment LD_LIBRARY_PATH=.
(gdb) break ml_func
Breakpoint 1 at 0x80483c0
(gdb) run
Starting program: /pic_tests/driver
Breakpoint 1, ml_func (a=1, b=1) at ml_main.c:10
10 int c = b +ml_util_func(a);
(gdb)
如今我们在第一次调用ml_util_func之前。
回顾在代码里ebx指向GOT。
看一下它里面是什么:
(gdb) i registers ebx
ebx 0x132ff4
我们所需的到该项的偏移在[ebx + 0x14]:
(gdb) x/w 0x133008
0x133008: 0x001313a6
结尾的0x3a6看起来没问题。如今,前进到对ml_util_func的调用之后再检查:
(gdb) step
ml_util_func (a=1) at ml_main.c:5
5 return a + 1;
(gdb) x/w 0x133008
0x133008: 0x0013146c
0x133008处的值被改变了。这样,0x0013146c应该是ml_util_func真正的地址。由动态加载器放在那里:
(gdb) p &ml_util_func
$1 = (int (*)(int)) 0x13146c <ml_util_func>
正如所期望的。
控制是否及何时由加载器完毕重定位
这应该是一个好地方来提及由动态加载器运行的延迟符号解析能够某些环境变量(及在链接共享库时向ld给出的相应标记)来配置。有时这对于特殊的性能要求或调试是实用的。
环境变量LD_BIND_NOW,假设定义了,告诉动态加载器总是在启动时刻对全部的符号执行解析,不作延迟。通过设置这个环境变量并以GDB又一次执行之前的样例。你能够easy地验证这个行为。你将看到ml_util_func的GOT项即使在该函数的第一次调用前也包括它的真实地址。
相反。环境变量LD_BIND_NOT告诉动态加载器全然不要更新GOT项。外部函数的每次调用都将通过动态加载器并又一次解析。
动态加载器也能够由其它标记配置。
我鼓舞你看一下man ld.so——它包括了一些有趣的信息。
PIC的代价
本文以陈述加载时重定位的问题以及PIC方法怎样应付它们開始。但PIC也不是没有问题的。
一个显而易见的代价是PIC中全部对数据及代码的外部訪问都要求额外的间接性。即对全局变量的每次訪问,以及对函数的每次调用,都要一次额外的内存加载。在实践中这个多成问题取决于编译器,CPU架构及特定的应用程序。
还有一个不那么明显的代价,是PIC的实现添加了寄存器的使用。
为了避免太频繁地定位GOT,让编译器生成将其地址保存在一个寄存器(一般是ebx)的代码是合理的。
但这由于GOT的缘故束缚了一整个寄存器。虽然对于倾向于拥有大量通用寄存器的RISC架构这不是大问题。对像x86这样仅仅有少量寄存器的架构这构成了一个性能问题。PIC意味着要少一个通用寄存器,它添加了间接的代价,由于如今要进行很多其它的内存訪问。
结论
本文解释了什么是位置无关代码,以及它怎样以可共享的仅仅读代码节辅助创建共享库。在选择PIC及其替代(加载时重定位)时有一些取舍,终于的结果取决于很多因素,比方执行该程序的CPU架构。
也就是说。PIC正变得越来越流行。
一些非intel架构,像SPARC64对共享库强制PIC代码。而更多架构(比方ARM)包含了IP相对取址模型来使得PIC更高效。
对x86的后继者x64架构,这两点也成立。在将来的文章里我将讨论x64上的PIC。
只是本文的关注点不是性能考虑或架构决定。我的目的是解释,假定使用了PIC,它怎样工作。假设这个解释不够清晰——请在评论里让我知道,我将提供很多其它信息。
[1] 除非全部的应用程序将这个共享库加载同样的虚拟内存地址。但在Linux上通常不会这么做。
[2] 0x444(与其它在这个计算里提到的地址)是相对于共享库的加载地址,它是未知的,直到一个可执行文件在执行时实际加载它。在这个代码里这无关紧要,由于它仅应付相对地址。
[3] 精明的读者可能想知道为什么.got是一个全然独立的节。我不是在图里显示它在数据节吗?在实践中。它是。这里我不想深入ELF节与段的差异。由于这会离题太远。
简要言之,一个库能够定义随意数量的“数据”节并映射到一个可读写段。仅仅要ELF文件组织正确,这不重要。
将数据段分离到不同的逻辑节提供了模块化。并使得链接器的工作变得简单。
[4]注意gdb跳过了向ecx赋值的部分。这是由于它几乎相同被视为函数的prolog(真正的原因当然是gcc组织调试信息的方式)。在函数里对全局数据与函数进行了几次訪问,一个指向GOT的寄存器就能够服务全部这些訪问。
[5] ELF共享库对象带实用于这个目的的特殊的哈希表节。 |
[6] 在Linux上的动态加载器仅仅是还有一个加载全部执行进程地址空间的共享库。
[7] 我将func放入一个独立的代码节,尽管理论上这能够与调用func代码在同一个节(即在同一个共享库)。
这篇文章里“extra credit(额外的学分)一节解释了为什么在同一个共享库里调用一个外部函数还须要PIC(或重定位)。
[8] 回顾在数据訪问样例里我承诺解释为什么在目标文件中有两个GOT节:.got与.got.plt。
如今应该明显了,这仅仅是为了将全局数据要求的GOT项与PLT要求的GOT项方便地分开。
这也是为什么当在函数里计算GOT偏移时,它指向紧跟.got的.got.plt。
这样。负偏移引向.got。而正偏移引向.got.plt。
虽然方便。这种安排不是强制的。这两部分都能够放在一个.got节里。