背景
我们知道linux为了降低可执行程序体积,提高空间利用效率,经常采用动态链接方式生成动态库或者可执行程序。在动态链接时,为了避免在加载时对代码段进行重定位导致动态库代码段无法实现共享,我们采用了位置无关代码PIC技术(Position-Independent Code)。针对模块外代码,为了实现PIC技术,我们需要借助全局偏移表(GOT,Gobal Offset Table)。
全局偏移表GOT
首先写一个小的测试程序进行说明。在SendMessage.c文件中实现了一个函数SendMessage1供外部模块调用。
SendMessage.c:
在SendMessage.h文件中进行声明。
SendMessage.h:
首先将SendMessage.c编译成动态库libSendMessage.so
gcc -o libSendMessage.so -fPIC -shared SendMessage.c
在main函数中调用动态库libSendMessage.so中的SendMessage1函数。
main.c:
现在编译最终的可执行程序test
gcc -o test main.c -L ./ -lSendMessage -Wl,rpath=./
测试验证test程序可正常执行:
上面可执行程序test调用了外部动态库libSendMessage.so中的函数SendMessage1。为了实现位置无关代码PIC,需要增加GOT段。其原理简单说就是,我们test程序在生成时是不知道外部函数SendMessage1的实际地址的,但我们又想实现动态库libSendMessage.so的代码共享,因此想出了一个办法,即test中对SendMessage1函数的地址引用均通过test自身数据段中一个具体位置(即GOT)的间接引用,也即该具体位置存放了SendMessage1最终加载的实际地址。因为test中调用SendMessage1函数指令位置和GOT间相对位置固定,两者存在固定偏移,因此动态库代码段在各个程序中使用时不需要修改,可以直接共享使用,实际只需要在加载时对GOT中填充各个外部引用全局变量、函数的实际加载地址即可。GOT段又存放在Data数据段中,即各个可执行程序都有独立的备份,互不干扰(和动态库数据段处理类似)。
过程链接表PLT
上面PIC中使用GOT可以解决动态库代码段直接共享问题,但在使用中发现由于程序运行前需要加载动态库并更新所有的外部符号表位置引用(GOT),这会导致程序启动较慢。为了解决该问题,我们又引入了过程链接表PLT(Procedure Linkage Table)。
我们在引用动态库时,经常发现动态库中很多函数并未使用,如果在加载时也对这部分函数进行重定位会变得很没效率和无意义,因此我们可以考虑在程序加载时先让程序运行,待需要用到某个外部符号时再对该符号进行重定位(即延迟绑定,Lasy Binding),这将有利于程序的启动速度,提高程序的运行效率。
为了实现延迟绑定技术,我们需要过程链接表PLT。PLT其实就是一段段可执行代码,该代码会实现外部符号的引用。具体为:
- 第一次调用该外部函数时会通过_dl_runtime_resole函数查找该外部符号的实际加载地址,并填充对应的GOT表,顺带调用该外部函数。
- 第二次调用该外部函数时,即可直接通过GOT表获取到该外部函数加载地址,从而达到引用效果,不再需要查找。
PLT结构如下:(bar为外部引用函数)
PLT0:
push *(GOT + 4)
jump *(GOT + 8)
....
bar@plt:
jmp *(bar@GOT)
push n
jump PLT0
PLT在ELF中结构如下:
从上面可知,PLT将GOT分为.got和.got.plt,其中.got用于保存外部全局变量地址引用,.got.plt保存了外部函数符号地址引用。PLT段为可读可执行属性,和代码段放在一起。.got.plt段的前三行固定,分别保存动态库段.dynamic地址、引用模块ID和外部符号查找函数_dl_runtime_resolve地址。后面分别存在各个函数实际加载地址(第一次未初始化钱存放plt中下一行地址)。
实例说明
以上面程序为例进行说明。
- 当test程序未运行时,查看其重定位段、plt段和got段,分别如下:
重定位段:
从重定位段,我们可以看到外部函数符号SendMessage1放在GOT表中的0x000000200fd0地址。
plt段:
got段:(本编译服务器got段未区分.got和.got.plt)
从.got段,我们可以看到0x000000200fd0地址处当前存放的地址为0x00000000000005f6,也即SendMessage1@plt段中jmpq *0x2009da(%rip)下一行地址,也即程序未运行时,got中存放的是下一行代码地址,这代价较小,不影响效率。
- 当程序运行时.got段如下:
为了便于查看指定内存内容,利用gdb对test程序进程调试。在main函数调用SendMessage1处打断点使程序暂停。
1. 利用shell ps命令查看目标test程序的PID为32187
2. 查看test进程各个段加载地址
从上图可知,test的代码段加载地址为0x555555554000,而从上面test的ELF文件信息中可知外部符号SendMessage1函数的GOT表位置(相对偏移)为0x200fd0,两者相加即可知加载后SendMessage1函数的GOT表位置的物理地址:
0x555555554000 + 0x200fd0 = 0x5555 5575 4fd0
3. 利用x命令查看地址0x5555 5575 4fd0保存内容:
test程序为64位,且为小端模式,则可知0x5555 5575 4fd0中保存的内容为0x00007fff f7bd161a,也即为SendMessage1函数符号最终的加载的物理地址。
4. 利用disassemble命令查看0x00007fff f7bd161a地址进行验证:
该地址即为SendMessages1函数,符合预期。
5. 从libSendMessage.so动态库实际加载位置角度验证符号SendMessage1函数最终的加载物理地址。
- 首先查看SendMessage1函数在libSendMessage.so动态库中相对偏移。
从上面可知SendMessag1函数在libSendMessage.so库中的偏移为0x61a。
- 从上面步骤二图中可知libSendMessage.so动态库代码段加载地址(也为整个libSendMessage.so库的加载基地址)为0x7ffff7bd1000。其和SendMessage1函数在libSendMessage.so库中偏移0x61a相加即为函数SendMessage1在test中的最终加载地址:
0x7ffff7bd1000 + 0x61a = 0x7ffff7bd161a
这和上面步骤4中推算的函数SendMessag1最终加载地址一致,即验证了推导结果的正确性。