编译器链接过程 静态链接 动态链接
编译器链接过程 静态链接 动态链接
理解链接有很多好处:
有助于构造大型程序
有助于避免一些危险编程错误
有助于理解其他重要的系统概念
让你能够利用共享库
1. 编译器驱动程序
编译命令,假设有main.c和swap.c两个源文件
1.$ gcc -O2 -g -o p main.c swap.c
实际上编译过程可以分解为以下步骤
1. 运行C预处理器(cpp),将main.c翻译成一个中间文件
$cpp [options] main.c main.i
2. 运行C编译器(ccl),将main.i翻译成汇编语言
$ccl main.i main.c -O2 [options] -o main.s
$gcc -S main.c -O2 [options] -o main.s
3. 运行汇编器(as),将main.s翻译成可重定位目标文件(relocatable object file) main.o
$as [options] -o main.o main.s
4. 重复以上步骤生成swap.o
5. 运行连接器(ld),将main.o swap.o以及一些必要的系统目标文件组合起来,生成可执行目标文件(executable object file) p
$ ld -o p [system object files and args] main.o swap.o
编译好后就可以通过shell运行了,shell调用操作系统中叫‘加载器’的函数,它将可执行文件p中的代码和数据拷贝到内存,然后叫控制交给程序开始处
2. 静态链接
上面提到的“ld”是一个静态连接器,它需要完成两个主要任务来构造可执行文件
1. 符号解析(symbol resolution)
将符号引用(object reference)和符号定义联系起来
2. 重定位(relocation)
编译器和汇编器生成从地址0开始的代码和数据节(section),链接器通过把每个符号定义与一个内存地址联系起来,然后修改所有对这些符号的引用,
使得他们指向这个内存地址,从而重定位这些sections
3. 目标文件
一共有3种目标文件类型
可重定位目标文件
可执行目标文件
共享目标文件:可动态加载到存储器与可执行文件链接执行
目标文件格式,这里讨论的都是ELF格式
COFF(Common Object File Format):System V Unix早期版本使用
PE(Portable Executable):COFF变种,Windows NT使用
ELF(Executable and Linkable Format):System V Unix后来的版本使用
ELF格式相关知识可以参考下面两篇博客:
ELF Format
ELF Format:程序加载和动态链接
4. 可重定位目标文件
更详细内容参考:ELF Format
下图为一个典型的ELF可重定位目标文件格式
ELF头以一个16字节的序列开始,其中包含了生成该文件的系统的字大小和字节顺序,ELF头剩下部分包括ELF头大小、目标文件类型、机器类型、节头部表偏移(section header table)。
ELF文件中其他节(section)的位置信息都在节头部表中可以找到
ELF头和节头部表之间的都是各种各样的节(section)
1..text: 已编译程序的机器代码
2..rodata: 只读数据
3..data: 已初始化的全局变量(ELF文件中不含局部变量,他们保存在栈中)
4..bss:(Block Storage Start) 未初始化的全局变量,区分已初始化和未初始化全局变量的目的是为了节省磁盘空间,目标文件中这个节不占用空间,只是一个占位符
5..symtab: 符号表,存放程序中定义和引用的函数和全局变量的信息(没有局部变量的条目)
6..rel.text: 一个.text节中位置的列表。当链接器将此文件与其他目标文件链接时需要修改这些位置,一般任何调用外部函数或引用全局变量的指令都要修改
8..debug: 调试符号表,包含了程序中定义的局部变量和类型定义,定义或引用的全局变量,以及源文件。编译时使用-g选项才能生成这个section
9..line: 源文件中的行号和.text节中机器指令间的映射,编译时使用-g生成这表
10..strtab: 字符串表,包含.symtab和.debug节中的符号表,以及节头部中的节名字
5. 符号和符号表
每个可重定位目标模块m都有一个符号表,包含m所定义和引用的符号信息。有3种不同的符号:
1. 由m定义,能被其他模块引用的全局符号。非static函数和非static全局变量
2. 其他模块定义,被m引用的全局符号。 源文件中使用external修饰
3. 只被m定义和引用的本地符号。带static的函数和带static的全局变量和本地变量
注意:本地符号与函数中的本地变量是不同的,.symtab中的符号表不包含函数中的本地变量,这些本地变量(除static 变量外)运行时由栈管理,链接器不理会他们
1.利用static隐藏变量和函数名:
2.一个源文件中声明的全局变量和函数,其他模块都可以看到。如果不想其他模块使用全局变量和函数,可以用static修饰,static全局变量和函数只有声明它的源文件可用
符号表结构如下:
1.name: symbol名字,指向字符串表中的字节偏移量
2.value: 符号地址
3.size: 目标大小
4.type/binding: 目标类型,binding表示符号是本地还是全局的
5.reserved: 保留
6.section: 每个符号都和目标文件中某个节相关联,这个字段存储的是到节头部表的索引。除了具体节,还有3个伪节(pseudo section):
7. ABS:不该被重定位的符号
8. UNDEF:未定义符号,表明被这个目标文件引用,但是在其他地方定义
9. COMMON:表示还未分配位置的未初始化的数据目标
6. 符号解析
链接器解析符号时,将符号引用于输入的可重定位目标文件的符号表中的个确定的符号定义联系起来。
本地符号的解析很简单,就在本目标文件中找到符号定义就行了。但是当链接器在本地没有找到符号定义时,就会尝试到其他目标文件中查找。如果其他文件也没找到,就会产生链接错误!
6.1 解析多重定义的全局符号
编译器将全局符号分为‘强’和‘弱’符号,函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
链接器使用如下规则处理多重符号定义(这是C的规则,C++中不允许出现多重定义,弱符号也不行)
规则1:不允许有多个同名强符号
规则2:如果有一个强符号和多个同名弱符号,那么选择强符号
规则3:如果有多个同名弱符号,那么从中任选一个
6.2 与静态库链接
系统可以将一组相关的目标模块打包成一个单独的文件,称为静态库(static library)。链接时可以使用静态库里的目标模块作为输入,链接器只会拷贝被应用程序引用的目标模块。
使用ar创建静态库
$ ar rcs libvector.a addvec.o multvec.o
6.3 使用静态库进行引用解析
进行符号解析时,链接器按照命令行上从左到右顺序扫描可重定位目标文件和库文件,此过程中链接器维护3个集合:
可重定位目标文件集合E
未解析的符号集合U
在输入文件中已定义的符号集合D
初始时3个集合都为空,链接器按照如下规则填充3个集合:
对于输入文件f,判断它是一个目标文件还是一个库文件
如果是目标文件,添加到E,并且扫描f里的符号定义和引用来修改集合U和D。继续下一个文件
如果f是库文件,尝试在库文件中查找U中未定义的符号。如果在库文件的某个成员m中找到一个符号来解析U中的引用,就将m添加到E,并且扫描m来修改U和D。对库文件中所有成员目标文件都反复进行此过程,直到U和D都不再变化
当处理完所有文件,如果U是非空,那么就会产生链接错误。否则就就合并和重定位E中的目标文件,构建可执行文件
但是这个过程有一个问题,那就是输入文件需要以一定的顺序出现在命令行上,不然就可能出现链接错误(如果后面的文件中引用前面文件的符号)。不过现在的链接器应该使用了不同的策略(或者有其他步骤保证)。
7. 重定位
完成符号解析后,链接器就把代码中的每个符号引用和符号定义联系起来,此时链接器已经知道当前所有输入目标模块中的代码节和数据节的大小,可以进行重定位了。
重定位由两部组成:
7.1 重定位条目
编译器在编译目标文件时,它并不知道数据和代码最终会放在内存的什么位置,也不知道引用的外部函数或全局变量的位置。所以,当编译器遇到最终内存位置未知的目标引用时,就会生成一个“重定位条目”,链接器根据重定位条目修改对应引用。代码(函数)的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。
ELF重定位条目格式如下:
typedef struct {
int offset; /* Offset of the reference to relocate */
int symbol:24, /* Symbol of the reference should point to */
type:8; /* Relocation type */
} Elf32_Rel;
ELF有11种重定位类型,以下是其中两种最基本的:
7.2 重定位符号引用
下面是链接器重定位算法的伪代码
1.foreach section s {
2. foreach relocation entry r {
3. refptr = s + r.offset; /* ptr to reference to be relocated */
4.
5. /* Relocate a PC-relative reference */
6. if (r.type == R_386_PC32) {
7. refaddr = ADDR(s) + r.offset; /* ref's runtime address */
8. *refptr = (unsigned) (ADDR(r.symbol) + *refptr - refaddr);
9. }
10.
11. /* Relocate an obsolute reference */
12. if (r.type == R_386_32)
13. *refptr = (unsigned) (ADDR(r.symbol) + *refptr);
14. }
15.}
重定位PC相对引用
前例中,main.o的.text节中,main函数调用了swap函数(在swap.o中定义),反汇编main.o如下:
1.$ objdump -d main.o
2.
3.....
4.6: e8 fc ff ff ff call 7 << span="">main+0x7> swap();
5. 7: R_386_PC32 swap relocation entry
6......
1.r.offset = 0x7
2.r.symbol = swap
3.r.type = R_386_PC32
从中链接器可以得出需要修改开始于偏移量0x7处的32位PC相对引用,使得在运行时指向swap函数。
1.ADDR(s) = ADDR(.text) = 0x80483b4
2.ADDR(r.symbol) = ADDR(swap) = 0x80483c8
1.refaddr = ADDR(s) + r.offset
2. = 0x80483b4 + 0x7
3. = 0x80483bb
然后重新计算引用的值,使之在运行时指向swap函数
1.*refptr = (unsigned) (ADDR(r.symbol) + *refptr - refaddr)
2. = (unsigned) (0x80483c8 + (-4) - 0x80483bb)
3. = (unsigned) (0x9)
因此在生成的可执行文件中,call指令的形式如下
1.80483ba: e8 09 00 00 00 call 80483c8
1.push PC onto stack
2.PC <- PC + 0x9 = 0x80483bf + 0x9 = 0x80483c8
1.注意:
2.为什么call指令中引用的初始值为-4?
重定位绝对引用
前例中,swap.o中全局指针bufp0指向了全局数组buf的第一个元素
1.int *bufp0 = &buf[0];
1.00000000
2. 0: 00 00 00 00 int *bufp0 = &buf[0];
3. 0: R_386_32 buf Relocation entry
这是个32位引用,bufp0的指针值为0x0,这是个绝对引用,开始于偏移位置0处,需要重定位使它指向符号buf。
1.ADDR(r.symbol) = ADDR(buf) = 0x8049454
使用重定位算法修改引用:
1.*refptr = (unsigned) (ADDR(r.symbol) + *refptr)
2. = (unsigned) (0x8049454 + 0)
3. = (unsigned) (0x8049454)
最终可执行文件中的形式如下
1.0804945c
2. 804945c: 54 94 04 08 Relocated
8. 可执行目标文件
下图为一个典型的ELF可执行文件格式:
从上图可看出,运行时加载了两个内存段:code segment和data segment。
数据段同样对齐到4KB的边界,开始于0x8049448处,内存大小为0x104字节,其中的0xe8字节(.data节)使用文件中的内容初始化,剩下的初始化为0(也就是.bss)。
9. 加载可执行目标文件
运行可执行文件时,系统使用一个被称为加载器(loader)的程序,将可执行文件的代码和数据从磁盘加载到内存中,然后跳转到程序的第一条指令(或者入口点entry point)开始执行。
Unix程序运行时在有一个内存映像,表示程序在内存中的结构,如下图
10. 动态链接共享库
静态库可以为编译链接提供方便,但是缺点也很明显:每次改动使用到静态库的程序都有重新链接、很多程序使用相同的静态库会增加内存负载等
解决这些问题我们可以使用共享库(shared library,dll),在运行时使用动态链接器(dynamic linker)与程序进行动态链接来执行。
使用gcc可以生产共享库:
$ gcc -shared -fPIC -o libvector.so addvec.c multvec.c
可以将它链接到程序中:
$ gcc -o p2 main.c libvector.so
这样运行可执行文件时就可以和libvector.so进行链接。动态链接的基本思路是创建可执行文件时,静态进行一些链接,程序加载过程中再动态完成链接过程。
在与共享库进行静态链接的过程中,并没有拷贝共享库中的任何代码和数据,而只是拷贝了一些重定位和符号表信息,动态链接时使用这些信息解析共享库中的代码和数据。
当加载器加载和运行可执行文件时,先加载只进行了部分链接的可执行文件,它会发现其中有一个.interp节,里面包含了动态链接器的路径名,这时加载器会加载这个动态链接器,执行如下链接任务:
重定位libc.so的文本和数据到某个内存段
重定位libvector.so的文本和数据到另一个内存段
重定位可执行文件中所有对libc.so libvector.so中符号的引用
链接完成后,动态链接器将控制交给程序执行。
11. 从程序加载和连接共享库
除了在运行时由系统加载共享库,我们也可以在代码中直接加载指定的共享库,在编译时要加上编译选项-rdynamic
1.$ gcc -rdynamic -O2 -o p3 dll.c -ldl
代码中加载共享库的函数如下:
1.#include
2.void* dlopen(const char* filename, int flag); // 成功时返回指针为指向句柄的指针,否则返回NULL
3.
4.flag:
5.RTLD_GLOBAL: 解析库‘filename’中的外部符号
6.RTLD_NOW: 让链接器现在就解析符号引用
7.RTLD_LAZY: 使用到该符号引用时才解析
1.#include
2.void dlsym(void *handle, char *symbol); // 成功则返回指向符号的指针,否则返回NULL
使用完共享库调用dlclose关闭,如果没有其他进程正在使用此共享库,dlclose函数就卸载该库
1.#include
2.int dlclose(void* handle); // 成功返回0, 否则返回-1
可用dlerror函数验证之前的几个函数是否调用成功
1.#include
2.const char* dlerror(void); //如果dlopen、dlsym、dlclose调用失败,则返回错误信息,成功则返回NULL
Java中的JNI(Java Native Interface,Java本地接口)就是利用共享库来实现的,它允许Java程序调用“本地的”C和C++函数。JNI的思想是将本地C函数,如foo,编译到共享库foo.so中,当Java程序试图调用函数foo时,Java解释程序(位于JVM中)利用dlopen动态链接和加载foo.so,然后再调用。
12. 位置无关代码(PIC)
PIC:position-independent code
共享库可以让多个进程共享同一段内存中的代码,以节省宝贵的内存资源,那么它是怎么实现的呢?
同一个目标模块中的过程调用不需要特殊处理,因为引用的都是本地符号,他们的偏移量是已知的,所以已经是PIC代码了。但是对于外部定义的过程调用和全局变量的引用通常都不是PIC,都需要连接是进行重定位。
12.1. PIC数据引用
生成全局变量的PIC引用有一个前提:加载目标模块(包括共享目标模块)时,数据段总是被分配成紧随代码段后面。这样代码段中的任何指令和数据段中的任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置无关。
运行时,使用形如下面的代码,通过GOT间接引用全局变量:
1. call L1
2.L1: popl %ebx ebx contains the current PC
3. addl $VAROFF, %ebx ebx points to the GOT entry for the var
4. movl (%ebx), %eax reference indirect through the GOT
5. movl (%eax), %eax got the real content of the reference
6.
7.为什么popl %ebx会得到PC的值?
8.这是因为call L1会将当前PC的值压栈后再跳转到L1处开始执行,所以popl指令取的其实就是call压入的PC值。
9.
10.取PC值的目的是什么呢?
11.当然是为了找到GOT中当前引用对应的条目,因为引用实际上存的是它在GOT中对应条目相对于下一条指令地址(PC值)的偏移量,
12.所以(%PC)加上这个偏移量就是此引用在GOT中对应的条目。
可以看出PIC代码有性能方面的问题,每个全局变量引用都需要五条指令,而且还需要额外空间存储GOT表。
12.2 PIC函数调用
PIC代码的外部函数调用也可以用同样的方式:
1.call L1
2.popl %ebx ebx contains the current PC
3.addl $PROCOFF, %ebx ebx points to the GOT entry for proc
4.call *(%ebx) call indirect through the GOT
延迟绑定通过两个数据结构的交互来实现:GOT和PLT(Procedure Linkage Table,过程链接表)。
任何调用了共享库中定义的函数的目标模块,都包含了自己的GOT和PLT。GOT位于.data节,PLT位于.text节。
下图为一个例子的GOT格式:
前3个条目是特殊的:
GOT[1]包含定义这个模块的信息
GOT[2]包含动态链接器的延迟绑定代码的入口点
其他的对应于目标模块中的外部过程调用,可以看出调用了printf(在libc.so中)和addvec(libvector.so中)函数
下图为该例的PLT:
PLT是一个数组,其中每个条目大小为16字节。第一个条目PLT[0]是特殊条目,用于跳转到动态链接器中。从PLT[1]开始的条目对应于目标模块中的外部过程调用。
PLT[1]对应于printf
PLT[2]对应于addvec
程序刚被加载运行时,调用printf和addvec的地方分别绑定到相应PLT条目的第一条指令上,如调用addvec指令如下:
1.08485bb: e8 a4 fe ff ff call 8048464
当下一次再调用addvec时,PLT[2]的第一条指令通过GOT[4]直接跳转到addvec开始执行。
13. 处理目标文件的工具
下面的工具可以帮助理解目标文件:
1.AR:创建静态库,插入、删除、列出和提取成员
2.STRINGS:列出一个目标文件中所有可打印的字符串
3.STRIP:从目标文件中删除符号表信息
4.NM:列出一个目标文件的符号表中定义的符号
5.SIZE:列出目标文件中节的名字和大小
6.READELF:显示一个目标文件的完整结构,包括ELF头中编码的所有信息。包含SIZE和NM的功能
7.OBJDUMP:所有二进制工具之母。。能够显示一个目标文件的所有信息。最大作用就是反汇编.text中的二进制指令
8.LDD:列出可执行文件在运行时需要的共享库
总结:
静态库的链接顺序是:越底层的越放到后面,是一层一层往下链接寻找的,否则很容易出现报错,未定义的引用。