链接
链接是将各种代码和数据部分收集起来并组合成一个单一文件的过程,这个文件可被加载(或被拷贝)到存储器并执行
链接可以执行于
编译时
在源代码被翻译成机器代码时
加载时
程序被夹在器加载到存储器并执行时
运行时
由应用程序执行
链接器使分离编译称为可能,将一个巨大的源文件分解成为更小、更好管理的模块,可以独立地修改和编译这些模块。当改变其中的一个时,只需要简单地冲洗编译它,并重新链接应用,不必重新编译其它文件
编译驱动程序
大多数系统提供编译驱动程序,代表用户在需要时调用语言预处理器、编译器、汇编器和链接器
unix> gcc -O2 -g -o p main.c swap.c
# 实际包括4个阶段
# 预处理阶段
cpp [other arguments] main.c /tmp/main.i
cpp [other arguments] swap.c /tmp/swap.i
# 编译阶段
ccl /tmp/main.i main.c -O2 [other arguments] -o /tmp/main.s
ccl /tmp/swap.i swap.c -O2 [other arguments] -o /tmp/swap.s
# 汇编阶段
as [other arguments] -o /tmp/main.o /tmp/main.s
as [other arguments] -o /tmp/swap.o /tmp/swap.s
# 链接阶段
ld -o p [system object files and args] /tmp/main.o /temp/swap.o
运行执行文件p,在shell的命令行上输入
unix> ./p
shell调用操作系统一个叫做加载器的函数,拷贝可执行文件p中的代码和数据到存储器,然后将控制转移到程序的开头
静态链接
像Unix ld
程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行文件作为输出
输入的可重定位文件由各种不同的代码和数据节组成,指令在一个节,初始化的全局变量在另一个节中,未初始化的变量在另外一个节中
链接器
链接器的两个主要任务
符号解析
目标文件定义和引用符号
符号解析的目的是将每个符号引用刚好和一个符号定义联系起来
重定位
编译器和汇编器生成从地址0开始的代码和数据节,链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使它们之乡这个存储器位置,从而重定位这些节
目标文件
目标文件纯粹是字节块的集合,这些块中,有些包含程序代码,有些包含程序数据,其它则包含指导链接器和加载器的数据结构
三种形式
可重定位目标文件
由编译器和汇编器生成
包含二进制代码和数据,其形式可以在编译时与其它可重定位目标文件合并起来,创建一个可执行目标文件
共享目标文件
由编译器和汇编器生成
一种特殊类型的可重定位目标文件,可以在加载或运行时被动态加载到存储器并链接
可执行目标文件
由链接器生成
包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行
从技术上说,一个目标模块就是一个字节序列,一个目标文件就是一个存放在磁盘文件中的目标模块
各个系统之间,目标文件格式都不相同
- 第一个Unix系统使用的是
a.out
格式 - System V Unix早期版本使用的是
COFF
格式 - Windows NT使用的是
COFF
的变种PE
格式 - 现代Unix系统使用的是
ELF
格式
ELF可重定位目标文件
一个典型的ELF可重定位目标文件格式包括
ELF头
- 16字节的序列
描述了生成该文件的系统的字的大小和字节顺序
ELF头的大小
目标文件类型
可重定位、共享、可执行
- 机器类型
IA32、x86-64等
节头部表的文件偏移
节头部表中条目大小和数量
节(典型)
- .text
已编译程序的机器代码
- .rodata
只读数据
- .data
已初始化的全局C变量
- .bss
未初始化的全局C变量
- .symtab
符号表,存放着程序中定义和引用的函数和全局变量的信息,不包含局部变量的信息
- .rel.text
.text节中位置的列表,当链接器把这个目标文件和其它文件结合时,需要修改这些位置
一般而言,任何调用外部函数或者引用全局变量的指令都需要修改,调用本地函数的指令不需要修改
- .rel.data
.data节中位置的列表,被模块引用或定义的任何全局变量的重定位信息
一般而言,任何已初始化的全局变量,如果初始值是一个全局变量地址或外部定义的函数的地址,都需要被修改
- .debug
调试符号表,条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,原始的C源文件
只有以
-g
的选项调用编译驱动程序才会得到这张表- .line
原始C源程序中的行号和.text节中机器指令之间的映射
只有以
-g
的选项调用编译驱动程序才会得到这张表- .strtab
字符串表,包括.symtab和.debug节中的符号表,节头部中的节名字
节头部表
- 不同节的位置
- 不同节的大小
符号
在链接器上下文中有三种不同的符号
由可重定位目标模块 m m 定义并能被其它模块引用的全局符号
对应于非静态C函数以及被定义为不带static的全局属性
由其它模块定义并被可重定位目标模块引用的全局符号,称为外部符号
对应于定义在其他模块中的C函数和变量
由被可重定位目标模块 m m 定义和引用的本地符号
对应于带static属性的C函数和全局变量
符号表
符号表由汇编器构造,使用编译器输出到汇编语言.s
文件中的符号
.symtab节中包含ELF符号表,包含一个条目的数组
typedef struct {
int name;
int value;
int size;
char type: 4,
binding: 4;
char reserved;
char section
} Elf_Symbol;
name
字符串表中的字节偏移
value
符号的地址
对可重定位的模块来说,value是距定义目标的节的起始位置的便宜;对可执行目标文件来说,value是绝对运行的地址
size
目标的大小(单位为字节)
type
要么是数据,要么是函数
binding
表示符号是本地的还是全局的
reserved
未使用
section
该符号和目标文件关联的节到节头部表的索引
三个特殊的伪节,在节头部表没有条目
ABS
不该被重定位的符号UNDEF
未定义的符号,即在本目标模块中引用但是却在其它地方定义的符号
- COMMON
未被分配位置的未初始化的数据目标
符号的毁坏(mangling)
C++
和Java
允许重载,因为编译器将每个唯一的方法和参数列表组合编码成一个对链接器来说唯一的名字,这个过程叫做毁坏(mangling),相反的过程叫做恢复(demangling)
- 一个被毁坏的类名字是由名字中字符的整数数量,后面跟上原始名字组成。比如类Foo被编码成3Foo
- 方法被编码成原始方法名,后面加
__
,加上被毁坏的类名,再加上每个参数的单个字母编码。比如Foo::bar(int, long)
被编码成bar__3Fooil
符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义联系起来
- 编译器只允许每个模块中每个本地符号只有一个定义
- 编译器确保静态本地变量拥有唯一的名字
- 编译器遇到不是在当前模块中定义的符号时,假设该符号在其它模块中定义,生成一个链接器符号表条目,交给链接器处理。如果链接器在任何输入模块中都找不到这个被引用的符号,就输出错误信息并终止
解析多重定义的全局符号
函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号
Unix链接器使用下面的规则来处理多重定义的符号
- 不允许有多个强符号
- 如果有一个强符号和多个弱符号,选择强符号
- 如果有多个弱符号,从弱符号中任意选择一个
GCC使用命令行选项-fno-common
,遇到多重定义的全局符号,输出一条警告信息
与静态库的链接
静态库
将所有相关的目标模块打包成一个单独的文件,称为静态库
Unix系统中静态库以一种存档(archive)的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来表述每个成员目标文件的大小和位置。存档文件名由.a
标识
可以用做链接器的输入。当链接器构造一个输出的可执行文件,只拷贝静态库里被应用程序引用的目标模块
相关的函数被编译为独立的目标模块,然后封装成一个单独的静态库文件,应用程序通过命令行使用库中定义的函数
unix> gcc main.c /usr/lib/libm.a /usr/lib/libc.a
链接器只拷贝被程序引用的目标模块,程序员只需要包含较少的库文件名字
非静态库的编译方法
编译器辨认出标准函数的调用,直接生成相应的代码
给编译器增加了显著的复杂性,标准函数的添加、删除、修改都需要新的编译器版本
将所有的标准C函数都放在一个单独的可重定位目标模块中(如
libc.o
),应用程序员可以把这个模块连接到可执行模块中unix> gcc main.c /usr/lib/libc.o
优点是它将编译器的实现与标准函数的实现分离开,对程序员保持适度的便利
缺点是每个执行文件都包含标准函数集合的完全拷贝,浪费磁盘空间;运行的程序将函数拷贝到存储器,浪费存储器空间;对任何标准函数的任何改变,都需要库的开发人员重新编译整个源文件,耗时复杂
每个标准函数创建一个独立的可重定位文件,放在一个大家都知道的目录中
unix> gcc main.c /usr/lib/prinf.o /usr/lib/scanf.o
要求程序员显示链接目标模块,耗时易出错
静态库的创建和使用
使用AR
工具创建静态库
unix> gcc -c addvec.c multvec.c
unix> ar -rcs libvector.a addvec.o multvec.o
编译链接静态库
unix> gcc -O2 -c main2.c
unix> gcc -static -o p2 main2.o libvector.a
-static
告诉编译器驱动程序,编译器应该构造一个完全链接的可执行目标文件,可以加载到存储器并运行,加载时不需要进一步链接
关于库的一般准则是将它们放在命令行的结尾。如果库的成员是相互独立的,可以按照任意顺序;否则必须排序,使得对每个被存档文件的成员外部引用的符号,在命令行中至少有一个 s s 的定义实在对的引用之后
重定位
两步组成
重定位节和符号定义
链接器将所有相同类型的节合并为同一类型的新的聚合节
重定位节中的符号引用
链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址
重定位条目
当汇编器生成一个目标模块时,它并不知道数据和代码最终将存放在存储器中国年的什么位置,也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以无论何时汇编器遇到对最终位置未知的目标引用,就会生成一个重定位目标条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用
代码的重定位条目放在.rel.text中;已初始化数据的重定位条目放在.rel.data中
typedef struct {
int offset;
int symbol: 24,
type:8;
} Elf32_Rel;
offset
需要被修改的引用的节偏移
symbol
被修改的引用应该指向的符号
type
告知链接器如何修改新的应用
ELF定义了11种不同的重定位类型,2种最基本的重定位类型
R_386_PC32
重定位一个使用32位PC相对地址的引用
R_386_32
重定位一个使用32为PC绝对地址的引用
重定位算法
- 重定位PC相对地址的引用
- 链接器首先计算出引用的运行时地址
- 修改引用的当前值,指向真正的地址
- 重定位PC绝对地址的引用
- 修改引用的当前值,指向真正的地址
foreach section s {
foreach relocation entry r {
refptr = s + r.offset;
if (r.type == R_386_PC32) {
refaddr = ADDR(s) + r.offset;
*refptr = (unsigned) (ADDR(r.symbol) + *refptr - refaddr);
} else if (r.type == R_386_32) {
*refptr = (unsigned) (ADDR(r.symbol) + *refptr);
}
}
}
ELF可执行目标文件
ELF头部
还包括程序入口点,也就是当程序需要运行时执行的第一条指令的地址
段头部表
记录了可执行文件的连续片(chunk)到连续存储器段的映射关系
.init
定义了一个小函数,_init,程序的初始化代码会调用它
加载可执行目标文件
shell通过驻留在存储器中的加载器的操作系统代码来运行可执行目标文件,任何Unix程序都可以通过用execve
函数来调用加载器
加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中(加载),然后通过跳转到程序的第一条指令或入口点来运行程序
每个Unix程序都有一个运行时存储器映像
- 在32位Linux系统中,代码段总是从地址
0x08048000
处开始 - 数据段在接下来第一个4KB对齐的地址处
- 运行时堆在读/写段接下来第一个4KB对齐的地址处,通过调用
malloc
库网上增长 - 共享库保留在运行时堆和用户栈中间
- 用户栈总是从最大合法用户地址开始,向下增长
- 栈上部开始的段是为操作系统驻留存储器部分(内核)的代码和数据保留的
当加载器运行时
- 创建存储器映像
- 在可执行文件中段头部表的指导下,将相关内容拷贝到代码和数据段
- 加载器跳转到程序入口点(
_start
的地址) - 执行
_start
处的启动代码,初始化.text, .init, .atexit - 调用main程序
- 调用_exit程序,将控制返回给操作系统
动态链接共享库
静态库的缺点——当静态库维护和更新后,如果程序员想要使用一个库的最新版本,必须显示将程序和更新了的库重新链接;大部分进程都会调用一些函数,每个进程都需要将函数代码拷贝到进程的文本段中,浪费存储器
共享库/共享目标是一个目标模块,在运行时,可以加载到任意的存储器位置,并和一个在存储器中的程序链接起来(动态链接)。在Unix系统中通常用.so
后缀来表示,Windows系统中称为DDL
共享库的共享方式
- 所有引用该库的可执行目标文件共享这个
.so
文件中的代码和数据,而不是像静态库的内容那样被拷贝和嵌入到引用它们的可执行文件中 - 一个共享库的.text节的一个副本可以被不同的正在运行的进程共享
新建和使用共享库
基本思路是当创建可执行文件时,静态执行一些链接,在程序加载时,动态完成链接过程
unix> gcc -shared -fPIC -o libvector.so addvec.c multvec.c
unix> gcc -o p2 main2.c libvector.so
-fPIC
选项指示编译器生成与位置无关的代码-shared
选项指示链接器创建一个共享的目标文件
此时,没有任何libvector.so
的代码和数据真正被拷贝到可执行文件p2中,链接器之时拷贝了一些重定位和符号表信息,使得运行时可以解析堆libvector.so
中代码和数据的引用
当运行可执行文件时
- 加载器加载和运行动态链接器(不是应用)
- 动态链接器进行重定位
- 重定位
libc.so
的文本和数据到某个存储器段 - 重定位
libvector.so
的文本和数据到另一个存储器段 - 重定位程序中所有对
libc.so
和libvector.so
定义的符号的引用
- 重定位
- 动态链接器将控制传递给应用程序
动态链接的应用
- 分发软件
- 构建高性能Web服务器
Linux中动态链接器的接口
#include <dlfcn.h>
/* 加载和链接共享库
* filename 是共享库的文件名
* RTLD_GLOBAL 打开库解析filename中外部符号。 如果带-rdynamic选项编译,全局符号也是可用的
* RTLD_NOW 立即解析对外部符号的引用
* RTLD_LAZY 推迟符号解析直到执行来自库中的代码
* 返回: 若成功则为指向句柄的指针,否则为NULL
*/
void *dlopen(const char *filename, int flag);
/* 获得符号的地址
* handle 是已经打开共享库的句柄
* symbol 是符号名字
* 返回:如果符号存在,返回符号地址(指针),否则返回NULL
*/
void *dlsym(void *handle, char *symbol);
/* 卸载共享库
* handle 是已经打开共享库的句柄
* 返回:如果成功返回0, 否则-1
*/
int dlclose(void *handle);
/* 返回最近的错误
* 返回:如果有错误,返回最近的错误,否则返回NULL
*/
const char *dlerror(void);
与位置无关的代码(PIC)
多进程共享程序的方法
给每个共享库分配一个事先预备的专用地址空间片(chunk),要求加载器总是在这个地址加载共享库
简单,地址空间使用效率不高,难以管理,可能会有片重叠和小洞
编译库代码
不需要链接器修改库代码就可以在任何地址加载和执行这些代码(与位置无关的代码PIC)
对同一个目标模块中过程的调用不需要特殊处理,对外部定义的过程调用和全局变量的引用要求重定位
GCC使用命令行选项
-fPIC
生成PIC代码
PIC数据引用
编译器在数据段开始的地方创建了全局偏移量表(GOT),每个被这个目标块引用的全局数据对象都有一个条目。编译器还为GOT中每个条目生成一个重定位纪录。加载时动态链接器会重定位GOT中每个条目
头3条GOT条目是特殊的
- GOT[0]包含
.dynamic
段的地址,包含了动态链接器用来绑定过程地址的信息 - GOT[1]包含定义这个模块的一些信息
- GOT[2]包含动态链接器的延迟绑定代码的入口点
使用下面代码,通过GOT间接引用每个全局变量
call L1
L1: popl %ebx # current PC
addl $VAROFF, %ebx
movl (%ebx), %eax
movl (%eax), %eax
存在性能缺陷,每个全局变量的引用需要五条指令,额外的一个对GOT存储器引用,额外的一个保存GOT条目地址的寄存器
PIC函数调用
使用延迟绑定技术,将过程地址的绑定推迟到第一次调用该过程时
第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接的存储器引用
延迟绑定使用两个数据结构之间简洁但又复杂的交互实现
GOT
是.data节的一部分
过程链接表(PLT)
是.text节的一部分
PLT中每个条目16字节
PLT[0]条目是特殊条目,跳转到动态链接器中
每个被调用的过程在PLT中都有一个条目,从PLT[1]开始
延迟绑定过程
- 程序被动态链接并开始执行,过程被分别绑定到相应PLT条目的第一条指令上
- 当过程第一次被调用,控制传递到PLT[i]的第一条指令,通过GOT[j]执行一个间接跳转,由于GOT[j]包含相应PLT[i]条目中
pushl
的地址,所以间接跳转仅仅是将控制转移到PLT[i]的下一条指令,这个指令将过程的ID压入栈 - 跳转到PLT[0],从GOT[1]中将另外一个标识信息的字压入栈中
- 通过GOT[2]间接跳转到动态链接器
- 动态链接器用两个栈条目来确定过程的位置,用这个地址覆盖GOT[j]
- 将控制传递给过程
下一次过程调用,控制像从前一样传递给PLT[i],但是直接通过GOT[j]间接跳转将控制传递给过程,从此刻开始唯一的额外开销就是对间接跳转存储器的引用
处理目标文件的工具
AR
创建静态库,插入、删除、列出、提取成员
STRINGS
列出一个目标文件中所有可打印的字符串
STRIP
从目标文件中删除符号表信息
NM
列出一个目标文件的符号表中定义的符号
SIZE
列出目标文件中节的名字和大小
READELF
显示一个目标文件的完整结构,包括ELF头中编码的所有信息
包含SIZE和NM的功能
OBJDUMP
所有二进制工具之母,能够现实一个目标文件中所有的信息
最大的作用是反汇编.text节中的二进制指令
LDD
列出一个可执行文件在运行时所需要的共享库