一、静态链接
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程。
1.1 链接器驱动程序
考虑main.c文件代码形如
int sum(int *a, int n);
int array[2] = {1, 2};
int main(){
int val = sum(array, 2);
return val;
}
与sum.c文件代码形如
int sum(int *a, int n){
int i, s = 0;
for (i = 0; i < n; i++){
s += a[i];
}
return s;
}
大多数编译系统提供编译器驱动程序,指令为
linux> gcc -o prog main.c sum.c
详细的,main.c其依次通过C预处理器【cpp】翻译为ASCII码中间文件main.i;通过C编译器【ccl】翻译为ASCII码汇编语言文件main.s;通过汇编器【as】翻译为可重定向目标文件main.o;sum.c也经过同样的过程生成sum.o。最后,运行链接器程序【ld】,将main.o、sum.o与一些必要的系统目录文件组合起来,创建了可执行目标文件prog,最后运行
linux> ./prog
Linux shell将调用操作系统的加载器,将prog的代码和数据复制到内存,并将控制转移到程序开头。
1.2 静态链接
静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全连接的、可以加载和运行的可执行目标文件作为输出。连接的主要步骤为:
-符号解析,程序定义和引用了全局变量与函数等,而符号定义储存在目标文件的符号表中,链接器将每个符号引用和确定的符号定义关联起来;
-重定位,将多个单独的代码节和数据节合并为单个节,将符号的相对位置定位到可执行文件的绝对内存位置,并更新符号的引用。
目标文件有三种形式:
-可重定位目标文件.o,包含于其他可重定位目标文件相结合的代码和数据,形成可执行目标文件。每个.o文件都是由一个.c源文件生成的,且代码和数据地址都从0开始;
-可执行目标文件a.out,包含可以直接复制到内存并执行的代码和数据,其地址为虚拟地址空间中的地址;
-共享目标文件.so,其是一种特殊类型的可重定位目标文件,在加载和运行时动态的加载到内存并链接。
1.3 可重定位目标文件
现代x86-64 Linux和Unix系统使用可执行可链接格式【Executable and Linkable Format,ELF】的可重定位目标文件,其格式形如
其中:
-ELF头,包含16字节标识信息、文件类型、机器类型、节头表偏移、节头表表项大小及表项个数;
-.text,编译后的代码;
-.rodata,只读数据,如输出格式串、开关跳转表等;
-.data,已初始化的全局变量;
-.bss,未初始化的全局变量。该节并不占据实际的空间,只是一个占位符,在运行时,在内存中会分配这些变量,初始化为0;
-.symtab,存放函数和全局变量信息,但不包含局部变量;
-.rel.text,.text的重定位信息,用于重新修改代码段的指令中的地址信息;
-.rel.data,.data的重定位信息,用于对被模块使用或定义的全局变量进行重定位的信息;
-.debug,调试用符号表;
-.strtab,包含.symtab与.debug节中符号及节名;
-节表头,包含每个节的节名、偏移和大小。
除了ELF头之外,节头表时ELF可重定位目标文件中重要的部分内容,描述了每个节的节名,在文件中的偏移、大小、访问属性和对齐方式等,要注意的是每个可装入节的起始地址总是0。
ELF可执行目标文件与ELF可重定位文件稍有不同,其格式为
其与重定位文件稍有不同:
-ELF头中会给出执行程序时第一条指令的地址,该字段在可重定位文件中为0;
-段头表,也称程序头表,是一个结构数组;
-.init,用于定义_init函数,其用来进行可执行目标文件开始执行时的初始化工作;
-重定位已经完成,因此少了.rel节。
从ELF头到.rodata节属于只读代码段,而.data与.bss节属于读写数据段,要注意其与内核虚拟内存的映像。
1.4 符号解析
符号解析的目的是将每个模块中引用的符号与某个模块中的定义符号建立关联。
每个可重定位目标模块m都有一个符号表,其维护了三种符号:
-全局符号由模块m定义,可以被其他模块引用的,例如非静态C函数与非静态全局变量;
-外部符号由模块m引用,但由其他模块定义;
-局部符号由模块m定义,仅由m唯一引用,如静态C函数和静态全局变量。
全局符号又分为强符号,通常为函数和初始化的全局变量;以及弱符号,为未初始化的全局变量。对于强符号与弱符号,链接器的符号处理规则如下:
-不允许多个同名的强符号,每个强符号只能定义一次,否则链接器错误;
-当强符号与弱符号同名时,则选择强符号,而弱符号被引用时也会被解析为强符号;
-如果有多个弱符号定义,则任选其一,可选在链接器在遇到多个弱定义的全局符号时输出警告。
考虑如下p1.c与p2.c的代码
#include <stdio.h>
int x = 7;
int y = 5;
int p1(){
printf("%d\n", y);
return 0;
}
#include <stdio.h>
double x;
int p1();
int main(){
x = 1.;
printf("%f\n", x);
p1();
return 0;
}
使用指令
linux> gcc -o prog p2.c p1.c
linux> ./prog
有结果
1.000000
1072693248
注意到p1中定义的int类型值为5的y被p2的double类型的x的写入而覆盖了,这是由于p1中的x是强符号,p2中的x是弱符号,在p2访问全局变量x时会选择强符号,即p1中x的地址,并进行了64位操作,从而覆盖了32位x接下来的32位,即int类型的y。
在不同模块定义相同的变量名,很可能造成意想不到的错误。因此,应尽量避免使用全局变量,尽可能的使用静态的本地变量,或定义全局变量时便初始化,以及在使用外部全局符号时使用extren声明。
1.5 重定位
在符号解析完成后,进行重定位的工作,包括如下步骤:
-合并相同节作为新节,将集合内的所有目标模块中相同的节合并;
-将定义符号进行重定位,确定其地址,包括函数的首地址、变量的首地址;
-对引用符号进行重定位,确定其地址,需要用到.rel的重定位信息;
当编译器遇到引用时,由于引用全局变量的地址未知,会使用0x0占位,同时会生成一个重定位条目,其中数据引用的重定位条目在.rel.data节中,而指令引用的重定位条目在.rel.text节中。ELF中32位重定位条目格式为
typedef struct{
int offset;
int symbol: 24,
int type: 8;
}Elf32_Rel
其中,offset为节内偏移,symbel为24位标识符号,type为8位重定位类型。考虑汇编代码与可重定位目标文件二进制编码对应关系为
add B 05 00 00 00 00
jmp L0 02 FC FF FF FF
...
L0: sub 32
...
B:...
其中,IA-32将地址的重定位类型分为
-R_386_32绝对地址,如add的操作数B的地址为0x00000000;
-R_386-PC32相对地址,如jmp的操作数L0的地址为当前地址偏移0xFFFFFFFC;
那么.rel.text的32位重定位条目为
offset: 0x1
symbol: B
type: R_386_32
offset: 0x6
symbel: L0
type: R_386_PC32
考虑函数代码如下
int array[2] = {1, 2};
int main(){
int val = sum(array, 2);
return val;
}
其二进制编码、汇编代码与重定位条目为
0000000000000000 <main>:
0: 48 83 ec 08 sub $0x8,%rsp
4: be 02 00 00 00 mov $0x2, %esi
9: bf 00 00 00 00 mov $0x0, %edi
a: R_X86_64_32 array
e: e8 00 00 00 00 callq 13 <main+0x13>
f: R_X86_64_PC32 sum-0x4
13: 48 83 c4 08 add $0x8, %rsp
17: c3 retq
当前,由于缺少引用目标,callq指令偏移的操作数0,但在汇编时标记了其重定位条目。在其与sum函数重定位后,其二进制编码与汇编代码为
00000000004004d0 <main>:
4004d0: 48 83 ec 08 sub $0x8,%rsp
4004d4: be 02 00 00 00 mov $0x2, %esi
4004d9: bf 18 10 60 00 mov $0x601018, %edi # %edi = &array
4004de: e8 05 00 00 00 callq 4004e8 <sum> # sum()
4004e3: 48 83 c4 08 add $0x8, %rsp
4004e7: c3 retq
00000000004004e8 <sum>:
4004e8: b8 00 00 00 00 mov $0x0, %eax
...
其中使用了PC相对寻址,即下一指令0x4004e3偏移了操作数0x00000005,得到了sum()的首地址。
1.6 静态库
所有的编译系统都提供了一种机制,将所有相关的目标模块.o打包成一个单独的库文件.a,称为静态库,也成为存档文件【archive】。通过静态库,增强链接器通过查找一个或多个库文件中定义的符号来解析符号引用,若存在存档成员文件解析了符号引用,就可以链接入可执行文件,且只链接该成员文件,例如gcc默认的链接了C的标准库libc.a。
链接器维护了一个可重定位目标文件的集合
E
E
E,用于合并成可执行文件;一个未解析符号集合
U
U
U,一个当前已定义的符号集合
D
D
D。链接器符号解析的过程如下:
-初始化
E
E
E、
U
U
U、
D
D
D均为空;
-对于输入文件
f
f
f,链接判断其是目标文件还是存档文件。若是目标文件,则把
f
f
f加入
E
E
E中,并修改
U
U
U和
D
D
D以反映
f
f
f中符号的定义与引用;若是存档文件,则尝试匹配
U
U
U中为匹配的符号,如果存在m定义了
U
U
U中引用的符号,就将
m
m
m加入
E
E
E中,并修改
U
U
U与
D
D
D以反映
m
m
m的符号的定义与引用;
-若链接器完成所有输入文件的扫描,而
U
U
U非空,则输出错误并中止;否则合并、重定位
E
E
E的目标集合。
考虑这样的情况,链接器的输入依次为静态库与调用库的文件。由于
U
U
U为空,静态库中的.o无法被匹配而丢弃;再扫描调用库的文件时,其调用的符号由于已经被丢弃而无法解析,导致链接错误。
因此,静态库正确解析与命令行给出的顺序有关,需要将静态库放在命令行的最后。
二、动态链接
静态库存在明显的缺点,在库更新时,必须显式的重新将程序与更新了的库连接;并且标准库会被复制到每个运行进程的文本段中,是对内存系统资源的极大浪费。使用动态链接解决上述问题。
2.1 共享库
共享库致力于解决静态库的缺陷,其在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来,这个过程称为动态链接,由动态链接器执行。共享库在Linux系统中用.so表示。
对于任何给定的文件系统,一个库只有一个.so文件,所有引用该库的可执行目标文件共享.so文件中的代码与数据;在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。
程序在被加载后执行前时,可以使用动态链接器加载和连接共享库。可重定位目标文件通过链接器与动态链接器形成可执行目标文件,并且在运行时可以和共享库进行连接。
加载器加载可执行目标文件时,会注意到可执行目标文件包含.interp节,包含了动态链接器的路径名,而动态链接器本身就是一个共享目标。那么加载器会加载运行动态链接器,完成如下步骤:
-重定位共享库的代码与数据到某个内存段;
-重定位可执行目标文件对共享库定义的符号的引用。
Linux系统为动态链接器提供了一个简单的接口,允许程序在运行时加载和链接共享库,形如
#include <dlfcn.h>
void *dlopen(const char *filename, int flag);
其中,若成功,则返回指向句柄的指针,否则返回NULL,其中,句柄表示一个对象的标识符,只要获得对象的句柄,我们就可以对对象进行任意的操作。
2.2 位置无关代码
共享库允许多个正在运行的进程共享内存中相同的代码。现代系统以一种方式编译共享模块的代码段,使得将其加载到内存的任何位置而无需链接器修改,从而使无限多个进程可以共享一个共享模块的代码段的单一副本,当然,每个程序仍然有自己的读/写数据块。
可以加载到内存的任何位置而无需重定位的代码称为位置无关代码【Position Independent Code,PIC】。
当程序对PIC数据进行引用时,利用了有一个有趣的事实,无论在内存中的何处加载一个目标模块,模块中的数据段与代码段的距离总是保持不变。编译器在数据段开始的地方创建了一个全局偏移量表【Global Offset Table,GOT】。在GOT中,每个被这个目标模块引用的全局数据目标都有一个8字节的条目,维护着目标模块引用的全局目标的地址,并为条目生成重定位记录。在加载时,动态链接器重定位GOT中的每个条目,目标模块对全局数据的引用通过GOT,从而不需要改变代码。
当程序对PIC函数调用时,编译器没有办法预测函数的运行时地址,GUN编译系统通过延迟绑定将过程地址的绑定推迟到第一次调用该过程。
延迟绑定需要使用GOT与过程链接表【Procedure Linkage Table,PLT】。一个目标模块调用了定义在共享库的函数时,就会产生GOT和PLT。
PLT是一个数组,每个条目是16字节的代码。PLT[0]维护着跳转动态链接器;PLT[1]调用系统启动函数初始化执行环境,PLT[2]以后的条目维护着用户代码调用的函数。
GOT与PLT联合使用时,GOT[0]与GOT[1]包含动态链接器在解析函数时用到的信息,GOT[2]维护了ld-linux.so,即动态链接器模块的入口地址,其余的条目对应了被调用函数,其地址需要在运行时被解析。每个条目都有响应的PLT条目。
在调用函数addvec()时,其GOT与PLT条目如下
.data:
GOT[0]: addr of .dynamic
GOT[1]: addr of reloc entries
GOT[2]: addr of dynamic linker
GOT[3]: 0x4005b6 #system start
GOT[4]: 0x4005c6 #addvec()
...
.text:
#PLT[0]: #call dynamic linker
4005a0: pushq *GOT[1]
4005a6: jump *GOT[2]
...
#PLT[2]: #call addvec()
4005c0: jmpq *GOT[4]
4005c6: pushq $0x1
4005cb: jmpq 4005a0
在addvec()被第一次调用时,有:
-控制进入addvec()的PLT条目;
-执行0x4005c0,跳转到GOT[4]指向的4005c6,即下一指令;
-将addvec的ID,即0x1压栈,继续执行,跳转到4005a0,即跳转动态链接器指令;
-跳转动态链接器指令将某些信息压栈,并跳转到动态链接器,动态链接器弹出栈的两个条目,确定addvec()的运行地址,并重写GOT[4],再把控制传递给addvec()函数。
那么再次调用addvec(),有
-控制进入addvec()的PLT条目;
-执行0x4005c0,跳转到GOT[4]指向的地址,即addvec()的运行地址,从而把控制传递给addvec()函数。
2.3 可执行文件的加载
将程序复制到内存并运行的过程叫做加载。系统通过调用execve系统调用函数来调用加载器。加载器根据可执行文件的段头表的信息,将可执行文件的代码据和数据从磁盘映射到存储器中。加载后,PC将指向符号_start处,启动程序执行。
execve()函数用于在当前进程上下文中加载并运行一个新程序,形如
int execve(const char *filename, const char *argv[], const char *envp[]);
其中,filename是加载并运行的可执行文件名,可带参数列表argv和环境变量列表envp。若找不到指定文件,则返回-1,并将控制交给调用程序;若函数执行成空,则不返回,并将控制交给可执行目标的主函数main()。因此,main()函数的原型形式其实是
int main(int argc, const char *argv[], const char *envp[]);
其中argc指定了参数个数。
2.4 库打桩
Linux支持强大的链接技术,称为库打桩,允许截获对共享库函数的调用,取而代之执行其他代码。库打桩可以发生在编译、链接与运行时。库打桩可以跟踪经过分配和释放的内存块的地址等而不破坏程序状态,也不修改源码。
在编译时,可以显式的调用宏来进行打桩,考虑代码
#include <stdio.h>
#include <malloc.h>
int main(){
int *p = malloc(32);
return 0;
}
在头文件中调用宏
#define malloc(size) mymalloc(size)
void *mymalloc(size_t size);
并定义
#include <stdio.h>
#include <malloc.h>
void *mymalloc(size_t size){
void *ptr = malloc(size);
printf("malloc(%d) = %p\n", (int)size, ptr);
return ptr;
}
那么执行
linux> gcc -c mymalloc.c
linux> gcc -I. -o intc int.c mymalloc.o
linux> ./intc
其中-I.显式的指明了打桩,那么运行可以得到
malloc(32) = 0x557e9ec1e260
在链接时,通过标志--warp, f
进行链接打桩,其使得链接器把对符号f的引用解析成__warp_f,把__real_f的引用解析成f。
在运行时,基于动态链接器的LD_PRELOAD变量。在解析引用时,动态链接器会搜索LD_PRELOAD的库,在搜索其他库,因此,通过改变LD_PRELOAD变量的值,可以实现对任何库中任何函数的打桩。