链接:将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。
链接可执行于编译时、加载时、运行时。
代码示例:
静态链接:
编译器驱动程序 gcc 又可细分为:预处理器 cpp、编译器 ccl、汇编器 as、链接器 ld
静态链接
ld这样的静态链接器生成一个完全可链接的、可以加载和运行的可执行文件。输入的可重定位目标文件由各种不同的代码节和数据节组成。
链接器两个主要任务:
- 符号解析
目标文件中定义和引用的符号,每个符号对应一个函数、一个全局变量、一个静态变量。符号解析的目的是将每个符号引用和一个符号定义关联起来。 - 重定位
编译器和汇编器生成地址从0开始的代码节和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
链接器按照汇编器产生的重定位条目的详细指令执行重定位。
目标文件包含程序代码、程序数据、引导链接器和加载器的数据结构。链接器将这些块链接起来,确定被连接块的运行位置,并修改代码和数据块中的各种位置。链接器对目标机器了解甚少,产生目标文件的工资由编译器和汇编器完成。
目标文件
- 可重定位目标文件
在编译时与其他可重定位目标文件合并成一个可执行文件 - 可执行目标文件
可被直接复制到内存并执行 - 共享目标文件
可在加载或运行时被动态加载进内存并链接
可重定位目标文件
- ELF头
以一个16字节序列开始,这个序列描述了生成该文件的系统的字大小和字节顺序。其他部分描述了ELF头大小、目标文件类型(可重定位 可执行 共享)、机器类型、节头部表的文件偏移、节头部表中条目的大小和数量。 - 不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小条目(entry)
- .text
已编译程序的机器代码 - .rodata
只读数据 - .data
已初始化的全局和静态C变量 - :bss
未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。目标文件中这个节不占实际磁盘空间,它仅仅是一个占位符;运行时在内存中分配这些变量,初始值为0。 - .symtab
一个符号表,存放程序中定义和引用的函数和全局变量信息。每个可重定位目标文件在.systab中都有一张符号表,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。 - .rel.text
- .rel.data
- .debug
一个调试符号表,其条目为程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用gcc时才会得到。 - .line
原始C源程序中行号和.text节中机器指令间的映射。使用-g才会得到。 - .strtab
字符串表,包含.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串序列。
符号和符号表
每个可重定位目标模块m都有一个符号表,包含定义和引用的符号信息。在链接器上下文中有三种不同符号:
.symtab 不包含在栈中的符号,链接器对于本地非静态程序变量不感兴趣。带有static属性的本地变量在.data或.bss中定义分配空间。
在C源文件中任何带有static属性声明的全局变量或函数都是模块私有的,不带static的全局变量和函数都是共有的,可被其他模块访问。
.symtab 节中包含ELF符号表,这张表包含一个条目的数组:
- name
字符串表中字节偏移,指向符号的以null结尾的字符串名字 - type
是数据或函数 - binding
表示符号是本地还是全局的 - section
指定每个符号被分配到的目标文件的节,也是一个到节头部表的索引。
有3个特殊的伪节,可重定位目标文件中才有,可执行文件中没有:- ABS:不该被重定位的符号
- UNDEF:未定义的符号,即本目标模块中引用,其他地方定义的符号
- COMMON:还未被分配位置的未初始化的数据目标。对于这种符号,value字段给出对齐要求,size给出最小大小
- value 符号地址
可重定位模块:定义目标节的起始位置的偏移
可执行目标文件:一个绝对运行时地址 - size
目标的大小
可使用 readelf 程序查看目标文件结构
符号解析
链接器解析符号引用:将每个引用和它输入的可重定位目标文件的符号表中的液体个确定的符号定义关联起来。
对于局部符号的引用:允许每个模块中每个局部符号都有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们名字唯一。
对于全局符号的引用解析要复杂的多。编译器遇到一个不是当前模块定义的的符号时,会假设是在其他模块中定义的,生成一个链接器符号表条目,交给链接器处理。如果链接器在它输入任何模块时都找不到符号的定义,就报错。
解析多重定义的全局符号
编译时,编译器向汇编器输出每个全局符号,强或弱。汇编器把这个信息隐含地编码在可重定位目标文件地符号表里。
函数和已初始化地全局变量是强符号,未初始化地全局变量是弱符号。
根据强弱符号定义,链接器使用下面地规则处理多重定义的符号名:
- 不允许有多个同名的强符号
- 如果有一个强符号和多个弱符号,选择强符号
- 如果有多个弱符号,从这些弱符号中任意选择一个
编译器翻译模块时,遇到弱全局符号,比如x,它并不知道其他模块是否也定义了x,如果是,它无法预测链接器该使用x的多重定义的哪一个。所以编译器把x分配为COMMON,将决定权留给链接器。
如果x初始化为0,那么它是一个强符号,所以编译器将其分配成.bss。
类似地,静态符号地构造必须唯一,所以编译器把它们分配成 .data 或 .bss。
例:
在x64 Linux 机器上,doubel占8字节,int占4字节。在bar5.c 的第6行中 x=-0.0 将用零的双精度覆盖内存中x和y的位置(foo5.c 中第5行和第6行)
使用 GCC -fno-common 在发现多重定义的全局符号时,触发一个错误。或 -Werror 把所有警告变为错误。
与静态库的链接
在Linux系统中,静态库以一种存档的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头文件来描述每个成员目标文件的大小和位置。使用 .a 后缀。在链接时将只复制被程序引用的目标模块,这减少了可执行文件在磁盘和内存中的大小。
创建静态库的例子:
链接时使用静态库的方法:
- -static:链接器应该构建一个完全链接的可执行目标文件,它可以加载到内存并运行,在加载时无需进一步链接。
- -lvector:即 libvector.a 的缩写
- -L. :告诉链接器在当前目录下查找 libvector.a
链接器运行时,判定main.o 引用了 addvec.o 定义的addvec 符号,所以复制 addvec.o 到可执行文件。没有引用 multvec.o 定义的符号,所以不会复制这个模块到可执行文件。链接器还会复制 libc.a 中的 printf.o 等模块。
链接器如何使用静态库解析引用
符号解析阶段,链接器按照从左到右扫描命令行中出现的可重定位目标文件和存档文件。在扫描中,链接器维护一个可重定位目标文件集合E,一个未解析的符号集合U,一个在前面输入文件中已定义的符号集合D。初始时 E、U、D 均为空。
目标文件即 .o 文件;存档文件即 .a 文件。
这种方法会导致一些链接错误,因为命令行上的库和目标文件顺序非常重要。如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接失败。
如:,addvec的定义在 libvector.a 中,使用在 main2.c 中。
一般将库放在命令行结尾,如果又必要则进行排序。必要时可以在命令行中重复库文件名。
重定位
一旦链接器完成符号解析这一步,代码中每个符号引用就和一个符号定义关联起来。此时链接器知道输入目标模块中代码节和数据节的确切大小。现在就可以开始重定位步骤,将输入模块合并,并为每个符号分配运行时地址。重定位分两步:
重定位条目
汇编器生成目标时,不知道数据和代码最终放在内存什么地方,也不知道这个模块引用的任何外部定义的函数或全局变量位置。所以,汇编器对任何最终位置未知的目标引用生成一个重定位条目,告诉链接器在合并时如何修改这个引用。代码的重定位条目放在 .rel.text 中,已初始化数据的重定位放在 .rel.data 中。
ELF 定义了32种不同的重定位类型,最基本的两种:
这两种类型支持 x86-64 小型代码模型,该模型假设可执行目标文件种代码和数据的总和小于2GB,因此可以用32位PC相对地址访问。GCC默认小型代码模型。还有 -mcmodel=medium -mcmodel=large 。
重定位符号引用
在伪代码中,第一个循环是遍历每个节,第二个循环是遍历节相关联的重定位条目。假设每个节s是一个字节数组,每个重定位条目r 是一个类型为 Elf64_Rela 的结构。
ADDR(s)为每个节的运行时地址,ADDR(r.symbol)为每个符号的运行时地址。
第3行计算选哟被重定位的4字节引用的数组s中的地址。PC相对寻址使用5~9行,绝对寻址使用11~13行。
使用objdump反汇编得到:
其中有两个全局符号。sum引用使用32位PC相对寻址,array引用使用32位绝对地址进行重定位。
重定位PC相对寻址
第6行中 e8 00 00 00 00
第一个字节位操作码,后4个字节为PC相对地址占位符。
重定位绝对寻址
mov指令将array地址复制到%edit中。mov指令开始于偏移量0x9的位置,包括1字节操作码0xbf,后为array的32位引用占位符。
可执行目标文件
ELF头描述文件的总体格式和程序的入口点。.init 节定义了一个小函数,叫 _init,程序的初始化代码会调用它。因为可执行程序是完全链接的,所以没有.rel 节。
程序头部表描述了可执行文件加载到内存的映射关系,objdump显示结果:
加载可执行目标文件
在shell中启动一个程序时,通过调用某个驻留在存储器中的加载器的操作系统代码来运行它,任何程序可通过execve函数调用加载器。加载器将可执行目标文件从磁盘复制到内存中,然后跳转到第一条指令或入口点运行程序。在 Linux x86-64 中,代码段从 0x400000 开始,最大合法用户地址为 248-1。从 248开始为内核的代码和数据保留。加载完成后,加载器跳转到程序的入口点,也就是 _start 函数地址。这个函数是在系统目标文件 ctrl.o 中定义的,对所有C程序一样。_start 函数调用系统启动函数 __libc_start_main,该函数定义在 libc.so 中。它初始化执行环境,调用用户从的 main 函数,处理 main 函数的返回值,并在需要时放控制返回给内核。
由于 .data 段有对齐要求,代码段和数据段之间有间隙。分配栈、共享库、堆时,链接器还会使用地址空间布局随机化。每次程序运行的区域的地址会变化,但是相对位置不会变。
动态链接共享库
# 创建共享库
gcc -shared -fpic -o libvector.so addvec.c multvec.c
# -fpic 生成位置无关代码
# -shared 指示链接器创建一个共享的目标文件
# 使用共享库
gcc -o prog main.c ./libvector.so
在创建可执行文件时,静态执行一些链接,在程序加载时,动态完成链接过程。此时没有任何 libvector.so 的代码和数据被复制到程序中,但是链接器复制了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so 中代码和数据的引用。
当加载器加载运行可执行文件时,注意到程序包含一个 .interp 节,这一节包含动态链接器的路径名,动态链接器本身就是一个共享目标(如Linux上 ld-linux.so)。加载器不会像它通常所做的那样传递控制给应用,而是加载运行这个动态链接器,然后动态链接器通过执行下面的重定位完成链接任务:
- 重定位 libc.so 的文本和数据到某个内存段
- 重定位 libvector.so 的文本和数据到另一个内存段
- 重定位 prog 中所有对 libc.so 和 libvector.so 定义的符号的引用
最后,动态链接器将控制传递给应用程序,然后共享库的位置就固定了。
从应用程序中加载和链接共享库
将每个生成动态内容的函数打包在共享库中,在某些条件下动态地加载和链接适当的函数,然后直接调用。
动态加载和链接的函数:
falg 必须带有 RTLD_NOW 或 RTLD_LAZY 指定是否延迟加载符号解析直到执行来自库中的代码。
dlsym 输入是一个指向已打开的共享库的句柄和一个symbol名字,如果存在返回地址,否则返回NULL。
生成可以动态调用动态库的程序:
// gcc -rdynamic -o prog main.c -ldl
// -ldl 用于加载动态库
#include <stdio.h>
#include <stdlib.lh>
#include <dlfcn.h>
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];
int main()
{
void *handle;
void (*addvec)(int *, int *, int *, int);
char *error;
handle = dlopen("./libvector.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "%s/n", dlerror());
exit(1);
}
addvec = dlsym(handle, "addvec");
if (!addvec) {
fprintf(stderr, "%s/n", dlerror());
exit(1);
}
addvec(x, y, z, 2);
if (dlclose(handle) < 0) {
fprintf(stderr, "%s/n", dlerror());
exit(1);
}
return 0;
}
位置无关代码
现代系统以一种方式编译共享模块代码段,使得可以把它们加载到内存的任何位置而无需链接器修改。使用这种方法无限多的进程可共享一个模块的代码段的单一副本。可加载而无需重定位的代码称为位置无关代码(PIC)。用户对GCC使用 -fpic 选项指示 GNU 编译器系统生成 PIC 代码。
对于共享模块定义的外部过程和对全局变量的引用要一些特殊技巧
1. PIC 数据引用
无论在内存何处加载一个目标模块(包括共享目标模块),数据段和代码段间的距离总是保持不变。因此,代码段中任何指令和数据段中任何变量间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置无关。编译器在数据段开始的地方创建了一个全局偏移量表(GOT)。在GOT中,每个被这个目标模块引用的全局数据目标(过程或全局变量)都有一个8字节条目。编译器还为GOT中每个条目生成一个重定位记录。加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确绝对地址。每个引用全局目标的模块都有自己的GOT。
2. PIC 函数调用
编译器没法预测调用动态库定义的函数的运行时地址。正常做法使为该引用生成一条重定位记录,然后动态链接器在程序加载时解析它。但是需要链接器修改调用模块的代码段,所以不是PIC。GNU编译系统使用了延迟绑定技术,将过程地址绑定推迟到第一次调用该过程时。
延迟绑定通过GOT和过程连接表(PLT)的交互实现的。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT。GOT时数据段的一部分,PLT是代码段的一部分。
- 过程链接表(PLT)
PLT 是一个数组,其中每个条目是16个字节代码。PLT[0] 是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有自己的PLT条目。每个条目负责一个具体的函数。PLT[1]调用系统启动函数(__libc_start_main),它初始化环境,调用main函数并处理其返回值。从PLT[2]开始的条目调用用户代码调用的函数。在例子中PLT[2]调用addvec,PLT[3]调用printf - 全局偏移量表(GOT)
GOT是一个数组,每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目有一个匹配的PLT条目。例如,GOT[4]和PLT[2]对应于addvec。初始时,每个GOT条目都指向对应PLT条目的第二天指令。
库打桩机制
允许截获共享库函数的调用,取而代之执行自己的代码。对于一个目标函数,创建一个包装函数,它的原型和目标函数一致,使用打桩机制,可以欺骗系统调用包装函数而不是目标函数。包装函数通常执行自己的逻辑,然后调用目标函数,再返回目标函数的返回值。
编译时打桩
mymalloc.c 中的包装函数调用目标函数并打印记录。本地malloc.h投文件指示预处理器使用包装函数替换目标函数的调用。
# 编译和链接命令
gcc -DCOMPILETIME -c mymalloc.c
gcc -I. -o intc int.c mymalloc.o
链接时打桩
Linux静态链接器支持用 –wrap f 标志进行链接时打桩。这个标志告诉链接器把对符号f的引用解析为 __wrap_f ,还要把对符号 __real_f,的引用解析为f。
# 编译成可重定位目标文件
gcc -DLINKTEIME -c mymalloc.c
gcc -c int.c
# 把目标文件链接为可执行文件
gcc -Wl,--wrap,malloc -Wl,--wrap,free -o int1 int.o myalloc.o
# -Wl,--wrap,malloc 就是将 --wrap malloc 传递给链接器
运行时打桩
编译时打桩要能访问程序源码,链接时打桩要能访问程序的可重定位对象文件,运行时打桩只要能够访问可执行目标文件。
运行时打桩基于动态链接器的 LD_PRELOAD 环境变量。如果LD_PRELOAD 环境变量被设为一个共享库路径名的列表,当加载程序时需要解析为定义的引用时,动态链接器(LD-LINUX.so)会先搜索 LD_PRELOAD 库,然后再搜索其他库。有了这个机制,可对任何共享库中任何函数打桩,包括 libc.so
# 构建包装函数的共享库
gcc -DRUNTIME -shared -fpic -o mymalloc.so mymalloc.c -ldl
gcc -o intr intl.c
运行程序:
处理目标文件的工具
原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/287872.html