分类: LINUX
2014-06-09 11:36:15
引子
为了生成可执行文件,链接器主要完成两个任务:
符号解析:目标文件会定义和引用符号。符号解析的目的就是把对符号的引用和符号的定义关联起来。
重定位:编译器和汇编器生成的是起始于位置0处的代码区段和数据区段的二进制文件。通过把每一个符号的定义和内存位置关联起来,并且修改所有对这些符号的引用使之指向正确的内存位置,链接器会重定位这些区段。
重定位的两种基本类型:
绝对重定位 引用的符号(本地全局函数或者变量)的地址在二进制文件加载到内存的时候,其地址就是确定的,这部分重定位操作是由静态连接器(ld)来完成的。
相对重定位 引用的符号(本地局部函数)的地址相对于call func指令所在的地址,有一个固定的偏移量。那么相对于PC计数器的值,也有一个固定的偏移量。因为执行call指令的时候,pc指向的是call指令后面那条指令的地址。二者数值之差就是call指令的字节长度(addend)。
如果引用的是库中的函数,那么涉及到的重定位操作由动态链接器来完成。
ELF规范区分节/段的概念
节:section 面向链接器
段:segment 面向加载器
虚拟地址空间中的各个段(segment已经由加载器加载到了进程内存地址空间),填充了来自于ELF文件中特定区段(section生成二进制可执行文件时,链接器需要处理的单位)的数据。
节段之间的映射关系,指定了哪些节载入到哪些段。
Gcc -o test test.c Gcc -c -o test.o test.c
Readelf -l test
重定位表项
Relocation as defined by ELF --------- elf.h
typedef struct {
Elf32_Addr r_offset; <--- address to fix
Elf32_Word r_info; <--- symbol table pointer and relocation type
}Elf32_Rel;
typedef struct {
Elf32_Addr r_offset;<--- address to fix
Elf32_Word r_info; <--- symbol table pointer and relocation type
Elf32_Sword r_addend;
} Elf32_Rela;
r_offset 字段指定了需要重定位的地址(相对于section区段的字节偏移量)。
r_info 字段指定重定位类型,该类型具体描述了完成重定位必须要做哪些工作。
addend 的值必定需要存在某个地方,有两种解决方案:
REL 值存放在二进制文件中需要被重定位的地址处。
RELA 直接在重定位表项中指定了addend的值,是特意拿出来存放。
针对引用变量的addend:
比如,a.c文件定义了全局变量int value[10],
b.c文件引用了value,value[8] = 100,
那么addend 的值就是8,意思是找到value的地址,然后向后偏移8个单位(与元素具体数据类型有关,此处为int ,8*sizeof(int)个字节),就能找到第8个元素的地址。
赋值语句value[8] = 100对应的指令所在的地址(假设为0x8048abc)处。该地址中存放的内容即为addend。这就意味着如果想得到重定位的最终地址,首先需要访问一次内存(二进制文件已经映射到内存),从这里读取addend的值,然后加上value的地址,把得到的value[8]的地址写入0x8048abc,即覆盖掉addend。
针对调用函数的addend:
我们知道PC计数器永远指向下一条将要执行的指令的地址。执行Call func指令的时候,pc则指向call指令后面那条指令的地址。Call 指令本身在X86 32位机器上占用4个字节。
通常来说,任何调用了外部函数或者数据的指令都需要被重定位。如果是绝对重定位,那么addend的值会是0,因为只需要直接把符号的地址插入到需要重定位地址处。
相对重定位则是利用的是相对于PC计数器的值,因而需要减去一个addend的值。
二者之间权衡利弊。
REL 为了获取addend的值,需要一次额外的访问内存操作。
RELA把值拿出来存放,则占用了一定的二进制文件的空间,也就是消耗了一定的磁盘空间。
可以说是以空间换取时间,相反,REL则是以时间为代价,节省了磁盘空间。
现代的大多数系统采用的是RELA的方式。IA-32架构采用的是REL的方式。
PIC共享库
提出共享库概念的目的就是为了最大限度的利用已有代码,充分共享内存中的代码片段,尽量减少物理内存空间的占用,这样每个用到该代码片段功能的可执行程序中都不必再有相应的指令(代码)拷贝,否则的话,既浪费磁盘空间,也浪费内存空间。
共享库的好处就在于多个程序能够通过共享内存页使用内存代码。问题在于,共享库会被加载到内存中哪个位置是没有保证的。动态链接器会在进程虚拟内存地址空间找到它自己认为合适的位置并把共享库代码加载到那里。假定,如果不是这样,共享库代码(具有跟可执行程序一样的行为,即代码在编译阶段就已经确定了在虚拟内存中的位置)在系统中都拥有自己的一块既定的虚拟内存地址空间,而且没有任何两个共享库代码地址空间产生重叠。每一次产生一个新的共享库,都需要分配给它地址空间。有的人可能会制造一个非常巨大的库,占用了大片的内存虚拟地址空间,那么留给别的库的地址空间可能就会非常少,甚至以至于产生重叠。
如此一来,如果使用重定位项修改共享库代码,那么这个库就不能共享了。我们就失去了共享库应有的长处。
而调用库函数无非是为了让库函数处理自己程序提供的的数据,甚至需要获得想要的数值结果。总得有种方法告诉库函数自己的数据放到哪里了,好让库函数从中提取数据,GOT(全局偏移量表),就是为了解决这个问题。
在给库函数提交数据之前,还要先搞定库函数入口地址在哪的问题,才能正确调用该函数。这就是PLT(过程查找表)的目的。
这两个工作都是由动态链接器来完成的。
PIC的思想,本质上,就是在二进制可执行文件中提供单独存放调用的库函数入口地址的空间(.plt section),在生成的库代码二进制(可重定位的)目标文件中为需要处理的数据提供单独存放提供变量地址的空间(.got section)。当然,库函数之间也可能会产生引用,所以两种文件各自都包含有这两个区段(section)。
库代码所有引用到的外部变量在与某个进程相关联之前,对库代码来说,其地址都是未知的,而只有到了加载的时候,即二进制库文件被加载到物理内存,并关联到某个进程的地址空间中的时候,库代码才可以知道相应程序(进程)中定义的变量的确切内存位置(这个工作由动态链接起来完成,动态链接器负责为二者牵线搭桥, 确切地说,就是动态链接器采用某种方法使得库代码可以访问到程序自定义的变量,意即,使库代码知道相应变量的内存地址,这样才可以正确引用)。
这些变量的位置如果在编译生成库代码的时候指定为特定的地址值的话(就像生成可执行文件那样),那么库代码就不叫库代码了,是不能够共享的。因为不同的程序自己定义的可以被库代码引用的变量的地址在编译的时候就已经由编译器指派好了,而这些指派的地址值不一定都是相同的,非常有可能是不相同的。
从自己实现的程序的角度来讲,这样的话,库代码就不能由各个程序共享了,除非程序中自定义的变量的地址和库代码中引用的相应变量的地址相吻合,完全一致,达到这个条件的程序才可以正确调用该库代码片段。
所以解决该问题的方法就是生成位置无关(与被加载到内存中时候的虚拟地址无关)的共享库代码,这样,库代码被关联到各个进程地址空间的时候才可以正确地引用相应进程中的变量,反过来说,就是,任何一个程序都能放心的去调用该共享库,而不必担心出现问题。这就是GOT(Global Offst Table)的由来。
目标文件中针对变量的重定位项的方法不可以直接拿来用,不可以拿变量地址直接覆盖掉库代码中引用该变量的相应指令。不可以直接修改共享库二进制代码,进程对库代码只应具有读和执行的权限。
CSAPP上的解释:
其实本质上就是A(调用库代码的程序)和B(PIC库代码)有个约定,A把某个宝藏(变量的值)的藏宝图(变量所在的内存虚拟地址)放到两个人都知道的地方(某个GOT表项),然后B根据藏宝图上给出的具体位置,由B决定是把宝藏起出来(取值),还是再放点宝贝进去(赋值)。
当然为了达成上述目的,生成所谓的PIC,编译器需要对代码做特殊处理,这样PIC代码才可以自己援引GOT表项从而获取变量位置,从而得到变量的值,再进而做出自己相应的运算处理。
做了什么处理,PIC又是如何援引GOT表项的呢?
库代码、内核映像,包括模块,都是ELF格式的。在生成ELF文件的时候,各个区段(其中就包括GOT区段)相对于代码区段的偏移量(offset)已经确定下来。这个offset的确切的值,编译器在编译阶段是可以计算得到的。所以,无论库代码被加载到哪儿,这个offset恒定。所以在PIC中对GOT某个表项的引用都是根据加载到内存的基址base和offset得到的,或者说得更细节、更技术一点,就是相对于PC计数器的固定偏移量。
CSAPP
所以,在库代码开始执行之前,动态链接器(ld-linux.so)会搞定重定位项,确保在offset 处的内存位置中存放的数据是全局变量的地址。
PIC 之 PLT处理
PIC代码中库函数调用过程 测试环境 x86 32位PC
gcc -o hello hello.c objdump -d hello
Call printf, 实际执行的是对应printf/puts的PLT表项中的指令,跳转到0x8049638中存放的地址处,即0x80482f6
实际上就是从对应printf的GOT表项中取目的地址,0x8049638是GOT表项的地址
地址0x8049638中的内容是:0x80482f6 ,其实是跳回printf自己的plt表项中的第二条指令的地址,执行其中存放的指令
objdump -s hello
执行push $0x10,接着跳转到地址0x80482c0,即plt[0]中去执行
多出的唯一开销就是一次间接跳转时的访问内存操作。
建议先熟悉ELF二进制文件格式。
如有谬误,恳请指正!
参考资料:
1. 链接器和加载器(中文版第二版)
2. 深入理解计算机系统 (CSAPP)
3. 深入Linux内核架构(附录 ELF 二进制格式)
4. Computer science from the Bottom Up (short for CSBU). Ian Wienand
5. http://www.ibm.com/developerworks/cn/linux/l-dynamic-libraries/