-
定义1
链接:将不同部分的代码和数据收集和组合成一个单一的文件的过程,这个文件可以被加载(或者被拷贝)到存储器并执行。
链接是由链接器来执行的。
-
链接执行的时间
在编译时(compile time)链接;
在加载时(load time)链接;
在运行时(run time)链接;
-
定义2
编译时(compile time):源代码被翻译成机器代码时
加载时(load time):程序被加载器加载到存储器时
运行时(run time):程序在计算机中被执行(或者运行)时
-
为什么要理解链接器
构造大型的程序:理解链接器将帮助你构造大型的程序。(缺少模块、缺少库、不兼容的库版本)
避免一些编程错误:理解链接器将帮助你避免一些危险的编程错误。(链接器解析符号引用的算法影响程序的正确性)
理解语言的作用域规划的实现:理解链接器将帮助你理解语言的作用域规划是如何实现的。(全局和局部变量的区别、定义static属性的变量或函数意味着什么)
理解其他系统概念:理解链接器将帮助你理解其他重要的系统概念。(加载和运行程序、虚拟存储器、分页和存储器映射)
开发共享库:理解链接器将使你能够开发共享库。(共享库可以用于运行时升级程序、Web服务器提供动态内容)
-
编译驱动程序
编译驱动程序(compiler driver),它为用户,调用语言预处理器、编译器、汇编器、链接器。如gcc
-
例子
main.c swap.c
--------------------------------------code/link/main.c
/*main.c*/
void swap();
int buf[2] = {1, 2};
int main()
{
swap();
return 0;
}
--------------------------------------code/link/main.c
--------------------------------------code/link/swap.c
/*swap.c*/
extern int buf[];
int *bufp0 = &buf[0];
int *bufp1;
void swap()
{
int temp;
bufp1 = &buf[1];
temp = *bufp0;
*bufp0 = *bufp1;
*bufp1 = temp;
}
--------------------------------------code/link/swap.c
-
生成.o
.c --> (cpp,ccl,as) --> .o
-
生成可执行文件
.o+必要的系统目标文件 --> (ld) --> 可执行目标文件
-
静态链接
静态链接器生成完全链接的可以加载和运行的可执行目标文件。
静态链接器的主要任务:
符号解析(symbol resolution):可重定位目标文件定义和引用符号,符号解析的目的是将每个符号引用和一个符号定义(也就是,链接器的一个输入目标模块中的一个符号表目)联系起来。
重定位(relocation):编译器和汇编器生成从零开始的代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。
目标文件纯粹是字节块的集合:有些块包含程序数据,有些块包含程序代码,有些块包含指导链接器和加载器的数据结构。
链接器确定被链接块的运行时位置,并修改代码和数据块中的各种位置。
链接器对目标机器了解很少,产生目标文件的编译器和汇编器已经完成了大部分的工作。
-
目标文件三种形式
可重定位目标文件:包含二进制代码和数据,编译时可以与其他可重定位目标文件合并起来,创建可执行目标文件
可执行目标文件:包含二进制代码和数据,可以直接被拷贝到内存并执行
共享目标文件:一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态的加载到存储并链接
编译器和汇编器生成可重定位目标文件(包括共享库目标文件)。链接器生成可执行目标文件。
-
目标模块和目标文件
从技术上说,一个目标模块(object module)是一个字节序列,而一个目标文件(object file)是一个存放在磁盘文件中的目标模块。
-
目标文件的格式
各个系统的目标文件不相同。
a.out格式:第一个贝尔实验室诞生的Unix系统
COFF(Common Object File Format,一般目标文件格式):System V Unix的早期版本
PE(Portable Executable,可移植可执行)格式:是COFF的一个变种,Windows使用
ELF(Executable and Linkable Format,可执行和可链接格式):现代Unix系统(Linux,System V Unix后来版本,BSD Unix,SUN Solaris)
-
可重定位目标文件
ELF典型结构:两头+10节
ELF头:包含7部分内容--- 字的大小、字节顺序、ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移、节头部表中的表目(entry)大小和数量。
10节:
.text: 代码段,存放机器代码
.rodata:读数据段,存储程序中使用的复杂常量,例如字符串,printf语句中的格式串,开关(switch)语句的跳转表
.data: 数据段,存储程序中已经被明确初始化的全局数据。包括C语言中的全局变量和静态变量。如果这些全局数据被初始化为0,则不存储在数据段中,而是被存储在块存储段中。C语言局部变量保存在栈上,不出现在数据段中。
.bss 块存储段,存储未被初始化的全局数据。 在目标文件中这个段并不占用实际的空间,而仅仅是一个占位符,以告知指定位置上应当预留全局数据的空间。块存储段存在的原因是为了提高磁盘上存储空间的利用率。
注意:以上的4个段会在程序运行时加入到内存中,是实实在在的程序段。目标文件中还有一些辅助程序进程链接和加载的信息,这些信息并不加载到内存中。实际上,这些信息在生成最终的可执行目标文件时就已经被去掉了。
.symtab 符号表,存储定义和引用的函数和全局变量。每个可重定位的目标文件中都要有一个这样的表。在该表中,所有引用的本模块内的全局符号(包括函数和全局变量)以及其他模块(目标文件)中的全局符号都会有一个登记。链接中的重定位操作就是将这些引用的全局符号的位置确定。
.rel.text 代码段需要重定位(relocate)的信息,存储需要靠重定位操作修改位置的符号的汇总。这些符号在代码段中,通常是一个函数名和标号。
.rel.data 数据段需要重定位的信息,存储需要靠重定位操作修改位置的符号的汇总。这些符号在数据段中,是一些全局变量。
.debug 调试信息,存储一个用于调试的符号表。在编译程序时使用gcc编译器的-g选项会生成该段,该表包括源程序中所有符号的引用和定义,有了这个段在使用gdb调试器对程序进行调试的时候才可以打印并观察变量的值。
.line 存储源程序中每一个语句的行号和.text节中机器指令的映射。在编译程序时使用gcc编译器的-g选项会生成该段,在使用gdb调试器对程序进行调试的时候这个段的作用很大。
.strtab 字符串表,存储.symtab符号表和.debug符号表符号的名字和节头部中的节名字,这些名字是一些字符串,并且以‘\0’结尾。
-
三类符号
可重定位目标模块m都有一个符号表,包含m所定义和和m所引用的符号的信息。
由m定义并能被其他模块引用的全局符号(全局链接器符号)。全局链接器符号对应于非static的函数和非static的全局变量。
由其他模块定义并被m引用的全局符号,这些符号称为外部符号(external),对应于定义在其他模块中的C函数和变量
只被m定义和引用的本地符号(本地链接器符号),有的本地链接器符号对应于带static属性的C函数和全局变量。这些符号不能被其他模块引用。目标文件中对应于m的节和相应的源文件的名字也能获得本地符号。static的本地过程变量,编译器在.data和.bss中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。
-
符号表
符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号列表。
ELF符号表包含在.symtab节,这张符号表包含一个关于表目的数组。
typedef struct
{
int name; /*string table offset*/
int value; /*section offset, or VM address*/
int size; /*object size in bytes*/
char type:4, /*data,func, section,or src file name*/
binding:4; /*local or global*/
char reserved; /*unused*/
char section; /*section header index, ABS, UNDEF, or COMMON*/
}Elf_Symbol;
name是字符串表中的字节偏移量,指向符号的名字,该名字是以null结尾的字符串
value是符号的地址。对于可重定位目标模块来说,value是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,value是一个绝对运行时地址。
size:是目标的大小(以字节计算)。
type:类型,可以是数据、函数。符号表还可以包含各个节的表目,以及对应原始源文件的路径名的表目,所以这些目标的类型也不同,
binding:符号是本地的还是全局的
section:符号的目关联节,它是到节头部表的索引。
三个伪节:ABS不该被重定位的符号;UNDEF未定义的符号(如,在本目标模块中引用,在其他地方定义);COMMON还未被分配位置的未初始化数据目标,它的value给出对其请求,size给出最小的大小。伪节在节头部表中没有表目。
-
符号解析
符号解析方法:符号引用和符号表中的符号定义联系起来。
编译器保证本地符号只有一个定义、静态本地变量有唯一的本地链接器符号名字
全局符号:当编译器遇到一个不是在当前模块中定义的符号时,它会假设此符号是在其他模块中定义的,生成一个链接器符号表表目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用的符号,它就输出一条错误信息并终止。
符号多定义问题:链接器可以 标志一个错误,或者以某种策略选择一个定义而抛弃其他定义。
-
C++和Java中链接器符号的毁坏(mangling)
函数重载允许使用相同的名字,纯C语言中没有重载的概念。
链接器区分重载函数之间的差异:编译器将每个唯一的方法和参数列表组合编码成一个唯一的链接器符号---毁坏(mangling),相反的过程叫做恢复(demangling)。
C++和Java使用兼容的毁坏策略。
毁坏类名:类名中字符数+类名
毁坏方法名:方法名+ __ + 已毁坏类名 + 每个参数的一个字母
-
多定义的全局符号解析
强符号(strong):函数和已初始化的全局变量
弱符号(weak):未初始化的全局变量
解析规则:
规则1:不允许有多个强符号。
规则2:如果有一个强符号和多个弱符号,那么选择前符号
规则3:如果有多个弱符号,那么任意选择一个
GCC-warn-common: 带GCC-warn-common 调用链接器,在解析多定义的全局符号定义时,输出警告信息
-
与静态库链接
静态库:相关目标模块打包为一个单独的文件
链接器只copy静态库里被应用程序引用的目标模块到可执行文件中。
提供库函数的方法:
编译器直接提供;
所有函数放到一个目标模块中(libc.a约8MB,libm.a约1MB);
每个函数单独放到一个目标模块(需要显式的链接所有用到的目标模块);
静态库;
ANSI C 库函数:标准I/O、串操作和整数算数函数----- libc.a
浮点算数函数-----------libm.a
-
静态库符号解析
从左向右扫描,
维持三个集合:
可重定位目标文件集合E:集合中的可重定位目标文件会被合并为可执行文件);
未解析的符号集合U
已定义符号集合D
命令行上的库和目标文件的顺序非常重要:定义一个符号的库出现在引用这个符号的目标文件之前,那么引用不能解析,链接会失败。
问题解决方案:
库放在命令行的结尾------------如果各个库相互独立,那么这些库可以以任何顺序放在行尾,如果不独立那么不许排序,使得对于每个被存档文件的成员外部引用符号s,在命令行中至少有一个s的定义是在对s的引用之后的;
为满足依赖关系,可以在命令行上重复库,或者相互依赖的库合并成一个单独的库。
-
重定位
完成符号解析后,链接器就知道它的输入目标模块中代码节和数据节的确切大小。现在就可以开始重定位了:合并输入模块,并为每个符号分配运行时地址。
重定位节和符号定义:合并相同类型节,将新的存储器地址赋值给新的节,赋值给符号定义。此时,每个指令和全局变量都有唯一的运行时存储器地址了。
重定位节中的符号引用:修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
重定位表目:汇编器遇到对最终位置未知的目标引用,就生成一个重定位表目(relocation entry)。代码重定位表目在.rel.text,
中,数据的重定位表目在.rel.data中。
typedef struct
{
int offset; /*offset of the reference to relocate*/
int symbol:24, /*symbol the reference should point to*/
type:8; /*relocation type*/
} Elf32_Rel;
重定位类型:
R_386_PC32:重定位一个使用32位PC相关的地址(距PC的当前运行时值(通常是下一条指令的地址)的偏移量)引用;(PC相关地址的使用:将PC相关地址+PC当前运行时值)。
R_386_32:重定位一个使用32位绝对地址的引用。
-
可执行目标文件
FLF头描:述文件的总体格式,包含程序的入口点(当程序运行时,程序要执行的第一条指令的地址)
.text
.rodata
.data
都和可重定位目标文件类似,除了这些节已经被重定位
没有.rel节
段头表:连续的可执行文件的组块被映射到连续的存储器节,段头表描述了这种映射关系
-
加载可执行目标文件
程序被载入内存:由shell调用loader(加载器,一段驻留在存储器中的操作系统代码)(Unix调用execve来执行这段代码)完成,效果是把程序从磁盘copy到存储器。
开始执行:加载完成后,跳转到程序的入口点(第一条指令的地址)
loading(加载): 程序被载入内存+开始执行
-
运行时存储器映像:
每个Unix程序都有一个运行时存储器映像,如下图:
Linux中代码段总是从0x08048000处开始。
数据段是从接下来的下一个4KB对其的地址处开始。
运行时堆在读写段接后的第一个4KB对其的地址处,并调用malloc库往上增长。
共享库存储器映射区:开始于0x40000000处的段
内核虚拟存储器:0xc0000000向上,用户代码不可见区域
用户栈:0xbfffffff向下。
-
加载过程描述:
加载器创建存储器映像,在段头表的指导下将可执行文件的内容copy到代码段和数据段,加载器跳转到程序的入口点(符号_start的地址)。
-
_start处的启动代码:
(startup code)是在目标文件ctrl.o中定义的(所有c程序都一样)。
0x080480c0 <_start>: /*entry point in .text*/
call _libc_init_first /* startup code in .text*/
call _init /*startup code in .init*/
call atexit /* startup code in .text*/
call main /* application main routine */
call _exit /* returns control to OS */
/*control never reaches here */
每个c程序中 ctrl.o 启动启动例程的伪代码。
-
动态链接共享库
静态链接库的缺点:需要定期维护和更新,应用程序员需要每次更新最新库;每个应用程序需要保存库函数的副本,浪费空间。
共享库(shared library):是一个目标模块,在运行时加载到任意的存储器地址,并在存储器中在运行时和一个程序链接起来。这个过程称为动态链接(dynamic linking)。
动态链接由动态链接器(dynamic linker)程序来执行
共享:在任何给定的文件系统中,对于一个共享库只有一个.so文件,所有引用该库的可执行目标文件共享该库中的代码和数据;
在存储器中,一个共享库的.text节只有一个副本可以被不同的正在运行的进程共享。
gcc -shared -fPIC -o libvector.so addvec.c multvec.c
-fPIC 选项 生成与位置无关的代码
-shared 选项 创建共享的目标文件
链接共享库到可执行程序中:gcc -o p2 main2.c ./libvector.so
上述编译语句执行过程中,链接器copy了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so中代码和数据的引用。
过程分析:
加载器加载部分链接的可执行文件p2,并注意到p2包含一个.interp节,这个节包含动态链接器的路径名,动态链接器也是一个共享目标(例如Linux上的LD-LINUX.SO);
加载和运行动态链接器,动态链接器:
重定位libc.so的文本节和数据节到某个存储器段。在IA32/Linux系统中,共享库被加载到从地址0x40000000开始的区域中。
重定位libvector.so的文本节和数据节到另一个存储器段中。
重定位p2中所有对由libc.so和libvector.so定义的符号和引用。
将控制传给应用程序。
-
从应用程序中加载和链接共享库(库的加载和链接都发生在运行时)
动态链接技术现实应用:
分发软件:生成新版本的共享库? ->>用户下载新版本的共享库库替换当前版本->>下次运行程序时即可自动加载和链接新的共享库。
构建高性能web服务器:web服务器生成动态内容,如个性化的web页面,账户余额,广告标语。
早期web服务器使用fork和execve创建一个子进程,并在该子进程的上下文中运行CGI程序。
现代高性能的web服务器使用基于动态链接的方法生成动态内容:把生成动态内容的每个函数打包在共享库中;web浏览器的一个请求到达时,web服务器动态的加载和链接适当的函数并调用它。
优点:
效率高:函数一直缓存在服务器的地址空间中,随后的web浏览器请求只需要一个函数调用的开销就可以被处理了,非常高效;
动态更新处理函数:更进一步,可以在运行时,无需停止服务器,更新已存在的函数,以及添加新的函数。
-
与位置无关的代码(PIC)
多个进程共享代码的一个拷贝:
1. 给每个共享库分配一个实现准备好的专用地址空间组块,然后要求加载器总是在这个地址加载共享库
2. 编译库代码,使得不需要链接器修改库代码,就可以在任何地址加载和执行这些代码。这样的代码叫做与位置无关的代码(position-independent code, PIC)。gcc 使用 -fPIC生成PIC代码。
-
PIC数据引用
目标:编译器要生成全局变量的PIC引用。
事实:一个目标模块加载到存储器,数据段总是分配为紧随在代码段后面,且代码段中任何指令和数据段中任何变量之间的距离是一个运行时常量,与代码段和数据段的绝对位置无关。
编译器在数据段开始的地方创建了一个表,全局偏移量表(global offset table,GOT)。GOT包含每个被这个目标模块引用的全局数据目标的表目和重定位记录。在加载时,动态链接器会重定位GOT中每个表目,使得它包含正确的绝对地址。每个引用全局数据的目标模块都有一张自己的GOT。
call L1
L1:popl %ebx #%ebx包含当前PC值
addl $VAROFF, %ebx #ebx指向此变量的GOT表目
movl (%ebx), %eax #eax指向变量
movl (%eax), %eax #eax中是变量的值
PIC代码性能缺陷:每个全局变量引用需要5条指令,而不是一条。需要一条额外的对GOT的存储器引用,还需要一个额外的寄存器来保存GOT表目的地址。
-
PIC函数调用
-
延迟绑定(lazy binding)
(参考 https://blog.csdn.net/virtual_func/article/details/48789947)
即,将过程地址的绑定推迟到第一次调用该过程时。第一次调用过程的运行时开销很大,之后每次调用只花费一条指令和一个间接的存储器引用。
ELF编译系统使用延迟绑定技术支持PIC函数调用。
延迟绑定是通过两个数据结构之间简洁但又有些复杂的交互来实现的。
两个数据结构:GOT(global offset table, 全局偏移量表 .data节的一部分),PLT(procedure linkage table,过程链接表 .text节的一部分)
当在程序运行过程中需要调用动态链接器来为某一个第一次调用的外部函数进行地址绑定时,需要提供给动态链接器的内容(即需要提供给链接器函数的参数)有:发生地址绑定需求的地方(模块文件名)以及需要绑定的函数名(符号名),也即是说,假设动态链接器使用某一个函数来进行地址绑定工作,那它的函数原型应该为: lookup(module,function)。
Eg:假设文件 liba.so 中需要调用 libb.so 中的函数bar(),那在第一次调用时,将调用动态链接器中的 lookup 函数,其参数为 lookup(liba.so,bar)。
事实上,在Glibc 中,该 lookup函数的真实名字是:_dl_runtime_resolve()
头三条GOT表目有特殊意义:
GOT[0]包含.dynamic段的地址,此段包含动态链接器用来绑定过程地址的信息,比如符号表的位置和重定位信息;
GOT[1]包含一些定义这个模块的信息(本模块的ID);
GOT[2] 包含动态链接器的延迟绑定代码的入口点( _dl_runtime_resolve()的地址);
PLT是一个16字节表目的数组:
PLT[0]:它跳转到动态链接器中(调用 _dl_runtime_resolve())
PLT[1]:对应printf调用的表目
PLT[2]:对应addvec调用的表目
初始地,在程序被动态链接并开始执行后,过程printf和addvec被分别绑定到它们对应的PLT表目的第一条指令上。
参考:https://www.cnblogs.com/xiaomanon/p/4210016.html
例子: