main.c
extern int shared;
extern void func(int *a, int *b);
int main(){
int a = 100;
func(&a, &shared);
return 0;
}
func.c
int shared = 1;
int tmp = 0;
void func(int *a, int *b){
tmp = *a;
*a = *b;
*b = tmp;
}
gcc -static -fno-stack-protector main.c func.c -save-temps --verbose -o func.ELF
在func.ELF-main.o和func.ELF-func.o这两个目标文件链接为一个可执行文件时,最简单的方法是按序叠加,即拼在一起(左图),但这种方法如果链接的目标文件过多,那么输出的可执行文件会十分零散。同时段的装载地址和空间以页为单位对齐,不足一页的代码节或数据节也要占用一页,造成内存空间的浪费。
现在的链接器采用的是相似节合并的方法,首先对每个节的长度、属性和偏移进行分析,然后将两个文件中的相同节进行合并,然后将符合表合并,引用生成统一的全局符号表,最后读取输入文件的各类信息对符号进行解析、重定位等操作。相似节的合并发生在重定位时。
为了构造可执行文件,链接器必须完成两个重要工作:
下面比较可执行文件func.ELF和中间产物main.o的区别
其中,VMA(Virtual Memory Address)是虚拟地址,LMA(Load Memory Address)是加载地址,一般情况下两者是相同的。
可以看到,尚未进行链接的main.o中的VMA都是0,而链接后的func.ELF中,相似节被合并,且完成了虚拟地址的分配。
下面查看反汇编代码
main函数的地址从0开始,其中对func()函数的调用在偏移0x25处。此时,0xe8是call指令,后四个字节0x00000000为调用指令的偏移量,此时call所调用的地址是call指令结束的地址+偏移量,即0x25+0x00=0x25,指向func()。
可以看到,链接成功后的func.ELF中,MOV指令在0x40163a,偏移量为0x07,0x40163a+0x07=0x401641,指向func()的地址。
可重定位文件中最重要的是要包含重定位表,用于告诉链接器如何修改节的内容。每一个重定位表对应一个需要被重定位的节,例如.rel.text用于保存.text的重定位。
如图所示,shared的类型R_X86_64_PC32用于相对寻址,(原书中shared的类型为R_X86_64_32为绝对寻址),func的类型R_X86_64_PLT32就是新版本gcc的标记方法,还是相对寻址。
另外,value中的0x04为r_addend域的值,是对偏移的调整。
此外,后缀名为.a的文件是静态链接库文件。一个静态链接库可以视为一组目标文件经过压缩打包后形成的集合。执行各种编译任务时,需要许多不同的目标文件,例如各种.o文件,为了方便管理,便可使用ar工具将他们打包为静态链接库文件。
静态链接中,同一链接文件被不同的可执行文件需要时,该文件大量出现,并且都会加载入内存,而实际上只需要一个文件存储和装载即可,因此静态链接带来的磁盘和内存空间浪费问题愈发严重。
如图所示,testLib.o同时被func1.ELF和func2.ELF需要,静态链接时testLib.o被重复装载,导致内存浪费;而动态链接时func1.ELF和func2.ELF不再包含单独的testLib.o,当运行func1.ELF时,系统将func1.o和依赖的testLib.o装载入内存,进行动态链接,这之后func2.ELF想要执行时,由于内存中已经有testLib.o,因此无需再次重复装载。
gcc -shared -fpic -o func.so func.c
gcc -fno-stack-protector -o func.ELF2 main.c ./func.so
ldd func.ELF2
objdump -d -M intel --section=.text func.ELF2 | grep -A 11 "<main>"
可以加载而无须重定位的代码称为位置无关代码(Position-Independent Code, PIC),它是共享库必须具有的属性,通过gcc传递-fpic参数可以生成PIC。通过PIC,一个共享库的代码可以被无限多个进程所共享,从而节约内存资源。
由于程序或共享库中的数据段和代码段的相对距离总是不变的美因茨,指令和变量的距离之间的距离是一个运行时常量,与绝对内存地址无关。于是就有了全局偏移量表(Global Offset Table, GOT),它位于数据段的开头,用于保存全局变量和库函数的引用,每个条目占8个字节,在加载时会进行重定位并填入符号的绝对地址。
实际上,为了引入RELRO(ReLocation Read-Only,为了保护某些段将他们设置只读)保护机制,GOT被拆分为.got节和.got.plt节两个部分,不需要延迟绑定的前者用于保存全局变量引用,加载到内存后被标记为只读;需要延迟绑定的后者则用于保存函数引用,具有读写权限。
objdump -h func.so | grep "Idx"
objdump -h func.so | grep ".got"
readelf -r func.so | grep tmp
objdump -d -M intel --section=.text func.so | grep -A 20 "<func>"
由于动态链接是由动态链接器在程序加载时进行的,当需要重定位的符号(库函数)多了之后,势必会影响性能。延迟绑定的基本思想是当函数第一次调用时,动态链接器才进行符号查找、重定位等操作,如果未调用则不绑定,从而减小开销。
ELF文件通过过程链接表(Procedure Linkage Table,PLT)和GOT的配合来实现延迟绑定,每个被调用的库函数都有一组对应的PLT和GOT。
位于代码段.plt节的PLT是一个数组,每个条目占16个字节。其中PLT[0]用于跳转到动态链接器,PLT[1]用于调用系统启动函数__libc_start_main(),main()函数从这里调用,从PLT[2]开始就是被调用的各个函数条目。
PLT | 作用/内容 |
---|---|
0 | 跳转到动态链接器 |
1 | 调用系统启动函数__libc_start_main() |
2及以上 | 被调用的各个函数条目的GOT地址 |
位于数据段的.got.plt节的GOT也是一个数组,每个条目占8个字节。其中GOT[0]和GOT[1]包含动态链接器在解析函数地址时所需要的两个地址,GOT[2]是动态链接器ld-linux.so的入口点,从GOT[3]开始就是被调用的各个函数条目,这些条目默认指向对应PLT条目的第二条指令,完成绑定后才会被修改为函数的实际地址。
GOT | 作用/内容 |
---|---|
0 | .dynamic,保存了动态链接器所需要的符号基本信息 |
1 | relor |
2 | 动态链接器ld-linux.so的入口点 |
3及以上 | 被调用的各个函数条目的存放地址 |
以func()为例,执行call后会进入func@plt,第一条jmp指令找到对应的GOT条目,此时该位置保存的还是第二条指令的地址,于是执行第二条指令push,将对应的0x1(func在.rel.plt中的下标)压栈,然后进入PLT[0]。
PLT[0]先将GOT[1]压栈,然后调用GOT[2],也就是动态链接器的_dl_runtime_resolve()函数,完成符号解析和重定位工作,并将func()的真实地址填入func@got.plt,也就是GOT[4],最后把控制权交给func()。
延迟绑定完成后,再次调用func(),就可以通过func@plt的第一条指令直接跳转到func@got.plt,将控制权交给func()。
程序在运行时加载和链接共享库。Linux为此提供了一个简单的接口dlopen。传统的动态链接会生成一个GOT表,记录着可能用到的所有符号,并且这些符号在链接时都是可以找到的。运行时链接则需要在运行时定位这些符号。