我曾经在 这篇文章 中讲过,当动态库被加载到进程的地址空间之后,需要做哪些处理。简单来说,当链接器创建一个共享库的时候,它并不知道共享库将被加载到内存的什么位置。这就会导致共享库中的代码和数据之间的引用出现问题,而它们应该通过一些方法找到正确的内存地址。
在Linux ELF共享库中主要有两种方法来解决这个问题:
加载时重定位
位置无关代码(PIC)
加载时重定位已经 解释过了 。这里我要解释一下第二种方法——PIC。
我原计划在这篇文章中共同解释x86和x64(也叫x86-64)两个平台上的PIC实现,但这使得文章变得越来越长,我认为这很不实际。所以现在,文章将只解释PIC是如何工作在x86平台上的。在这个旧平台上,因为它设计的时候没有考虑到PIC技术,所以在它上面实现PIC将有一点点困难。将来(希望是最近)的文章将解释PIC是如何在x64上实现的。
加载时重定位的几个问题
正如我们在前一篇文章中所见到的那样,加载时重定位是一个简单有效的处理方法。然而,最近,PIC变得越来越受欢迎,并且它通常被作为编译动态库的推荐方法。为什么会这样呢?
加载时重定位有两个问题:它需要一定时间来执行;它导致动态库的text节不能共享。
首先,性能问题。如果一个共享库通过加载时重定位链接,在应用程序在加载它的时候,需要花费一些时间去执行重定位操作。你能想象到这耗费的时间可能不是太大,毕竟加载器不必扫描整个text节,它只是去修正在text节中需要重定位的部分。但是如果一个复杂的软件在它启动的时候加载了多个很大的共享库,并且每个共享库必须首先执行它的加载时重定位操作,那将极大的拖慢软件的启动时间。
其次,text节不可共享的问题,这个问题就有点严重了。最主要的一点就是节省内存。有些共同的共享库被用于多个应用程序。如果text节(代码所在的地方)只被加载一次(之后通过虚拟内存映射的方法共享给多个进程),可以想象这可以节省很大一部分内存。但是加载时重定位无法实现这样的效果。因此,每个应用程序加载共享库,它将必须将共享库的代码重新整块加载到内存中[1]。不同应用程序不可能真正的去共享它。
此外,text节可写(它必须保证text节可写,去允许动态加载器实现重定位操作)将导致安全问题。应用程序会被很容易利用。
如我们在这篇文章中将看到的PIC将减轻这些问题。
PIC – 简介
提出PIC的思路很简单,添加一个额外的间接层去在代码中关联所有的全局数据和函数引用。如果我们通过巧妙的链接并且加载进程,保证共享库的text节真正的 位置无关 是有可能的。这样text节就能被很容易的被映射到不同内存地址并且不需任何改变。在接下来的几个小节中,我将解释它是如何被实现的。
知识点1 – text节和data节的偏移
其中一个关键点就是PIC依赖text节与data节之间的偏移量,链接器 在链接时 会知道这个偏移量。当链接器将几个目标文件组合到一起时,它收集它们的节(例如所有的text节统一整合到一个的大text节)。因此,链接器知道各节的大小和相对位置。
举个例子,data节可能紧跟在text节后。所以每一条在text节中指令相对于data节开始位置的偏移,就是text节大小减去指令相对于text节开始的偏移量,并且这两个值链接器都知道。
在上图中,代码部分被加载到(链接时是未知的)0xXXXX0000(X代表不用关心),并且数据部分被加载到0xXXXXF000。接下来,如果在对代码部分偏移0x80的位置的指令需要引用数据部分的数据,链接器就能够知道它们的相对位置(在这里是0xEF80)并且能够编码这条指令。
注意,它并不用去在意是否有其他部分被放置在了代码部分与数据部分之前,或者数据部分被放在代码部分之前。因为链接器会确定所有节的大小并决定他们应该被加载到哪里。
知识点2 – 在x86平台上实现基于IP的相对偏移
上面的代码仅能用于我们能够使用相对地址的情况。但是对于一些数据引用(比如mov指令)在x86平台上需要绝对地址。所以我们应该怎么做呢?
如果我们有一个相对地址并且需要一个绝对地址,缺失就是指令地址(因为,根据定义, 相对 地址是相对于指令地址的)。
在x86平台上没有任何指令能够提供指令指针的值,但是我们可以通过一点小技巧去得到它。这是一段汇编伪代码示范怎么得到指令指针
Assembly (x86)
call TMPLABEL
TMPLABEL:
pop ebx
代码做了什么:
CPU执行call TMPLABEL指令,导致保存下一条指令的地址到堆栈并且跳转到了TMPLABEL。
TMPLABEL的第一条指令是pop ebx,它得到了下一条指令的地址。它从栈中弹出了一个值到ebx中。但是这个值就是这条指令自己的地址,所以ebx现在包含了指令指针的值。
全局偏移表
在这一小节,我们能够最终完成在x86平台上实现数据地址的位置无关。它是被“全局偏移表”或简称GOT完成的。
GOT是一个简单的存放地址的表,被放置于data节。假设代码节的一些指令想要引用一个全局变量,它通过引用GOT的一项替代直接使用绝对地址引用(需要重定位)。由于GOT是一个在data节中已知的位置,所以这次引用对于链接器来说是相对的而且能够确定的。而在GOT表项将包含变量的绝对地址:
伪汇编代码,我们替换了一个绝对地址引用数据的指令
Assembly (x86)
; 将变量值放入edx中
mov edx, [ADDR_OF_VAR]
通过额外指令,从寄存器中显式的寻址
Assembly (x86)
;1.从GOT中获得地址,存放到ebx中
lea ebx, ADDR_OF_GOT
; 2. 假设ADD_OF_VAR存放在GOT的0x10处,
; 接下来它将代替ADDR_OF_VAR放入edx中
mov edx, DWORD PTR [ebx + 0x10]
; 3.最后,访问变量并且将它的值存放到edx中
mov edx, DWORD PTR [edx]
所以,我们通过由GOT重定向变量引用,避免了在代码节的重定位。但是我们也在data节创造了一个重定位。为什么这么说呢?因为在上面的解决方案中,GOT仍然必须包含变量的绝对地址。所以通过上面的例子,我们得到了什么呢?
很多!事实证明,data节重定位会比在代码节重定位减少很多问题。这里有两个原因(在文章开头介绍加载时重定位的两个问题)
代码节重定位需要对每个数据引用重定位,而在GOT中我们仅需要对每个数据重定位一次。数据引用的次数比数据本身的个数多很多,所以GOT是很高效的。
data节永远都是可写而且不可在进程间共享的,所以在data节中添加重定位没有什么危害。然而,将重定位从代码节中移出,允许将代码节变得可写并且可在多进程间共享。
通过GOT实现数据引用的位置无关(PIC) – 实例
我现在要给出一个完整的展示PIC机制的例子
C
int myglob = 42;
int ml_func(int a, int b)
{
return myglob + a + b;
}
这部分代码将被编译一个名为libmlpic_dataonly.so的共享库(使用-fpic和-shared编译器标志(flags))
让我们看一下ml_func的反汇编代码
Assembly (x86)
0000043c :
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,从GOT表项[ecx-0x10]获得一个值,并且放入eax中。这就是myglob的地址。
450指令结束,myglob的值就被放入eax中了
之后将a和b的值加到myglob中,并且这个值被返回(保持它在eax中)
我们也能够通过readelf -S指令来查询GOT节的位置
C
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.plt PROGBITS 00001ff4 000ff4 000014 04 WA 0 0 4
<snip>
让我们做一些数学运算,检查由编译器完成查找myglob变量的运算过程。正如我上面提到的,在调用__i686.get_pc_thunk.cx地方获得下一条指令的地址到ecx寄存器中。该地址是0x444[2]。下一个指令,将0x1bb0加到它上面,现在ecx的值应该是0x1ff4。最后,根据GOT的表项内容,实际取得myglob的地址,使用偏移地址 – [ecx – 0×10],所以值应该是0x1fe4,这是根据节头中GOT中的第一个表项得到。
为什么有另一节的名称以.got开头将在文章后面解释[3]。需要注意的是,编译器选择将ecx指向到GOT,然后使用负偏移来获得表项。这没什么问题,只要通过数学运算的计算出来。这就是到现在为止,它的作用。
然而,这里有一些我们到现在为止一直忽略的事情。myglob是如何被放到GOT中的0x1fe4处的呢?回顾一下,我发现了一个重定位。让我们找到它:
C
> readelf -r libmlpic_dataonly.so
Relocation section '.rel.dyn' at offset 0x2dc contains 5 entries:
Offset Info Type Sym.Value Sym. Name
00002008 00000008 R_386_RELATIVE
00001fe4 00000406 R_386_GLOB_DAT 0000200c myglob
可以看到,正如我们期望的,重定位节为myglob指定了0x1fe4这个地址。重定位类型是R_386_GLOB_DAT,它将告诉动态加载器 – “将这个符号的实际值(比如它的地址)放到那个偏移位置”。所以所有事情完成的都很好。所有剩余的就是看一下库是什么时候被加载的。我们能够通过写一个简单的连接到libmlpic_dataonly.so并且调用ml_func函数的“驱动程序”,并且通过gdb运行它来了解这件事。
C
> 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 : push ebp
0x0013143d : mov ebp,esp
0x0013143f : call 0x13145a <__i686.get_pc_thunk.cx>
0x00131444 : add ecx,0x1bb0
=> 0x0013144a : mov eax,DWORD PTR [ecx-0x10]
0x00131450 : mov eax,DWORD PTR [eax]
0x00131452 : add eax,DWORD PTR [ebp+0x8]
0x00131455 : add eax,DWORD PTR [ebp+0xc]
0x00131458 : pop ebp
0x00131459 : ret
End of assembler dump.
(gdb) i registers
eax 0x1 1
ecx 0x132ff4 1257460
[...] skipping output
调试器进入ml_func,并且停在IP为0x0013144a[4]处。我们看到ecx现在的值是0x132ff4(是地址加上0x1bb0的值,正如我们之前介绍过的)。注意在运行时,它们都是绝对地址 – 共享库已经被加载到进程的地址空间。
所以现在myglob在GOT表项位置是[ecx – 0x10]。我们看看那有什么:
C
(gdb) x 0x132fe4
0x132fe4: 0x0013300c
所以我们期望0x0013300c是myglob的地址。验证一下:
C
(gdb) p &myglob
$1 = (int *) 0x13300c
嗯。没错!
PIC的函数调用
呐~所以这就是数据地址使如何做到地址无关的。 但是函数调用呢?
理论上,同样的方法也能完成函数调用。由此来代替call指令调用实际函数地址,让它call包含一个已知GOT项并且在加载时填入GOT项。
但是这 不 是PIC工作方法。实际上发生了什么是很复杂的。在我解释它是如何完成PIC之前,我先简单介绍一下为什么会出现这个机制。
延迟绑定优化
当一个共享库引用一些函数,它的真实地址在加载前是不知道的。解析这个地址的过程叫做绑定,它就是动态加载器将动态库加载到进程的内存空间时要做的事情之一。绑定过程不一般,因为加载器必须在一些特殊的表[5]中 查找 符号。
所以,解析每一个函数都需要一定时间。时间并不是很多,一般情况下,但是动态库中函数的数量大于全局变量的数量,同时将这么多函数解析就会耗费很多时间了。另外,大部分这些解析都是不必要的,因为一般情况下,程序运行都只会用到一部分函数(可以想到有很多函数都是为了处理错误和一些特殊情况,一般情况下它们完全不需要被调用)。
所以,为了加速这个过程,一个智能的延迟绑定方案被提出。“延迟(Lazy)”是一个为优化计算机程序的方法的通用名字,处理工作被延迟直到当它真正被需要的时刻被执行。如果一个函数直到程序运行结束也没有被调用,就可以免去做解析函数的工作。一个非常好的例子就是 写时拷贝 和惰性求值 。
延迟绑定的实现是通过增加了一个间接调用层——PLT
过程链接表(PLT)
PLT是可执行的text节的一部分,由一些项(每一个都是额外的共享库调用)。每一个PLT项都是一段简单的代码。代码通过调用PLT项中的相关代码来间接的调用实际函数。这种形式有时候被称为“跳板(蹦床? trampoline )”。每个PLT项有一个与之相对应的包含实际函数位置偏移的GOT项,但这个GOT项仅在动态加载器实际解析它的时候指向真正的函数。我知道这不太容易理解,但是我希望下面几个段落和图能够帮助你理解。
在之前的小节中提到过,PLT允许延迟函数解析过程。也就是说当动态库刚刚被加载的时候,函数还没有被真正的解析:
解释:
在这段代码中,一个名为func的函数被调用。编译器将它转换为调用func@plt,就是PLT的第N项。
PLT的第一项比较特殊,后面几项是一串结构相同的条目,每一项对应的函数都需要被解析。
除了第一个,每个PLT项都包含这些部分:
一个跳转指令,跳转位置由与之对应的GOT项提供。
准备“解析器”执行程序的参数
调用解析器执行程序,解析器执行程序在PLT的第一项。
第一个PLT项是解释程序的调用点,解释程序就在动态加载器自己的代码中[6]。这个解析器将被调用函数实际的地址解析出来。至于如何解释的在后面会解释。
函数真实地址被解析之前,第N个GOT项仅仅指向跳转之前的下一跳指令。这就是为什么途中的箭头颜色不太一样-因为它不是真正的跳转,仅仅是一个指针。
func被第一次调用的时候发生了什么?
PLT[n]被调用并且跳转到由GOT[n]指向的地址。
这个地址指向PLT[n]自己,接下来,PLT准备解析器的参数。
然后,解析器被调用
解析器找出func实际地址,把它填入GOT[n]中,最后调用func。
第一次调用之后,图就变得不一样了:
注意,GOT[n]现在指向了func[7]的实际地址,而不是之前跳回到PLT。所以当func再次被调用的时候:
PLT[n]被调用并且跳转到GOT[n]指向的地址。
GOT[n]指向func,所以这只是将控制权交给func
换句话说,现在func被实际的调用了,而且并没有通过解析器。所有的花费仅仅是一次额外的跳转。这就是所有。这个机制允许函数被延迟解析,并且不解析那些没有被实际调用的函数。
它也保证了动态库的code/text节完全位置无关,因为唯一使用绝对地址的就是GOT,而GOT位于数据段并且会被动态加载器重定位。PLT他自己不仅是PIC而且还能够被保存在只读的text节.
我并没有详细的提及解析器,是因为它在这里确实不重要。解析器是加载器中一块简单的低级代码。由参数由PLT表项准备,指出一个合适的重定位表项,帮助它知道需要解析的符号和需要更新的GOT表项。
通过PLT和GOT的PIC函数调用 – 实例
再一次,通过实际的演示来巩固理论学习得到的知识。这是一个完整的例子,来展示我们上面所讲的函数调用机制。现在,我将讲的快一点。
这是共享库的代码:
C
int myglob = 42;
int ml_util_func(int a)
{
return a + 1;
}
int ml_func(int a, int b)
{
int c = b + ml_util_func(a);
myglob += c;
return b + myglob;
}
代码将被编译进libmlpic.so,并且重点是ml_util_func将要被ml_func调用。让我们首先反编译ml_func:
C
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>
<... 省略了一些代码>
调用ml_util_func@plt的部分很有趣的。注意,ebx现在保存了GOT的地址。这就是 func@plt 的样子(它位于名为.plt的可执行节中)。
C
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])的跳转指令
准备解析器参数
调用解析器
解析器(PLT0)位于地址0x370处,但是我们对此没什么兴趣。我们更感兴趣的是GOT中包含了写什么东西。为了了解这个,我们需要做点数学运算。
ml_func中获得IP的技巧被完成于地址0x483,将这个值加上0x1b71.所以GOT的地址是0x1ff4.我们能够通过readelf[8]获得GOT的内容。
C
> readelf -x .got.plt libmlpic.so
Hex dump of section '.got.plt':
0x00001ff4 241f0000 00000000 00000000 86030000 $...............
0x00002004 96030000 a6030000 ........
GOT项 ml_util_func@plt在偏移量+0x14或者说0x2008的位置。从上面来看,这个位置的数据是0x3a6。这就是ml_util_func@plt的push指令存放的位置。
去帮助动态加载器做这项任务,在GOT中的添加并指定了的ml_util_func的重定位项。
C
> readelf -r libmlpic.so
[...] snip output
Relocation section '.rel.plt' at offset 0x328 contains 3 entries:
Offset Info Type Sym.Value Sym. Name
00002000 00000107 R_386_JUMP_SLOT 00000000 __cxa_finalize
00002004 00000207 R_386_JUMP_SLOT 00000000 __gmon_start__
00002008 00000707 R_386_JUMP_SLOT 0000046c ml_util_func
最后一行意思是动态加载器需要把符号ml_util_func的值(地址)放到0x2008。
它将有兴趣去看GOT项在函数被第一次调用之后改动了什么。让我们再用GDB检查一下。
C
> 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之前。在这份代码中GOT被ebx寄存器指向。让我们看一下ebx的值:
C
(gdb) i registers ebx
ebx 0x132ff4
并且我们需要的那一项在[ebx+0x14]:
C
(gdb) x/w 0x133008
0x133008: 0x001313a6
嗯。最终是0x3a6.貌似是对的。现在,让我们单步调试直到调用ml_util_func之后,并且重新检查一遍:
C
(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的真实地址:
C
(gdb) p &ml_util_func
$1 = (int (*)(int)) 0x13146c <ml_util_func>
跟预期相符。
控制加载器解析行为
动态加载器执行延迟符号解析的过程可以被通过环境变量配置(也可以通过在链接动态库时给ld添加相应的标志(flags))。这在特殊性能要求或者调试时会很有用。
LD_BIND_NOW环境变量,当定义的时候,告诉动态加载器在程序启动时解析所有的符号,并不再延迟。你可以通过设置环境变量,通过GDB重新运行先前的例子来验证这个动作。你会看到ml_util_func的GOT项在函数第一次被调用之前就已经是函数的真实地址了。
相对的,LD_BIND_NOT环境变量要求动态加载器不更新GOT项。每一次调用一个拓展函数都重新当做新的一样去解析。
动态加载器被通过其他标志(flags)配置。我推荐你看一下man ld.so – 它包含了很多有趣的信息。
PIC的消耗
这篇文章由加载时重定位的几个问题开始,介绍了PIC如何接近修正它们。但是PIC并不是什么问题都没有的。一个显而易见的问题就是它耗费了一些额外的指令去在代码中引用数据。那需要额外的内存去加载每个对变量的引用操作,每个函数调用操作。这问题依赖于编译器、CPU架构和特定应用程序。
另一个,不太明显的消耗,实现PIC要求增加寄存器的使用。为了避免寻找GOT太过频繁,它要求编译器编译通用代码时,保持GOT的地址在一个寄存器中(通常是ebx)。但是这样导致整个寄存器被GOT绑架了。。。在RISC架构中这不是一个大问题,那架构有一大堆通用寄存器可用。它在像x86这样的平台上会有比较明显的性能问题,x86只有少量有限的寄存器可用。PIC意味着又减少了一个可用的通用寄存器,这会间接增加内存引用次数。