博客内容源自深入理解计算机系统
链接
概念
链接(link)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可执行于编译时(compile time),即在源代码被翻译成机器代码时;也可执行于加载时(load time),即在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(runtime),即应用程序来执行。在早期计算机系统中,链接是手动执行的。在现代系统中,链接是由叫链接器(linker)的程序自动执行的。
链接器可以使得分离编译(separate compilation)成为可能,不用将一个大型的应用程序组织为一个巨大的源文件,而是可将它分解为更小,更好管理的模型,可以独立地修改和编译这些模块。
为什么要了解链接及链接器?
理解链接器将有助于构造大型程序。构造大型程序时,经常会遇到缺少模块,缺少库或者不兼容的库版本引起的链接器错误。理解链接器如何解析引用,什么是库以及链接器是如何使用库开解析引用的,有助于解决这些错误。
理解链接器有助于避免一些危险的编程错误。Linux链接器解析符号引用时所作的决定可以影响程序的正确性。默认情况下,错误地定义多个全局变量的程序将通过链接器,而不产生任何警告信息。由此得到的程序会产生令人迷惑的运行时行为,而且非常难以调试。
理解链接将有助于理解语言的作用域规则是如何实现的。如,全局变量和局部变量之间的区别是什么?当定义一个具有static属性的变量或者函数时,实际到底意味着什么?
理解 链接有助于理解其他重要的概念。链接器产生的可执行目标文件在重要的系统功能中扮演着关键角色,如加载和运行程序,虚拟内存,分页,内存映射。
理解链接有助于利用共享库。随着共享库和动态链接的重要性日益加强,链接成为一个复杂的过程。如许多软件产品在运行时使用共享库来升级压缩包装(shrink-wrapped)二进制程序。还有,大多数的Web服务器都依赖于共享库的动态链接来提供动态内容。
下面关于链接各个方面的讨论,从传统静态链接到加载时的共享库的动态链接,以及运行时的共享库的动态链接。所有的讨论是基于的环境为:一个运行Linux的x86-64系统,使用标准的ELF-64目标文件格式。不过无论系统、目标格式文件,基本的链接概念是通用的。
编译器驱动程序
看下面的C语言示例程序
///code/mian.c
int sum(int *a,int n);
int array[2]={1,2};
int main(){
int val = sum(array,2);
return val;
}
//code/sum.c
int sum(int *a,int n){
int i,s = 0;
for(){
s += a[i];
}
return s;
}
大多数编译系统提供编译器驱动程序,它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。如GUN编译系统构造示例程序,用户使用shell调用GCC驱动程序:
linux> gcc -Og -o prog main.c sum.c
图中概括了驱动程序在将示例程序从ASCII源码文件翻译成可执行目标文件。驱动程序首先运行C预处理器(cpp),将C源程序main.c翻译成一个ASCII码中间文件main.i:
cpp [other arguments] main.c /tmp/main.i
接着,驱动程序运行C编译器(cc1),将main.i 翻译成一个ASCII汇编语言文件main.s:
cc1 /tmp/main.i -Og [other arguments] -o /tmp/main.s
然后,驱动程序运行汇编器(as),将main.s翻译成一个可重定位目标文件(relocatable object file)main.o:
as [other arguments] -o /tmp/main.o /tmp/main.s
驱动程序经过相同的过程生成sum.o。最后,它运行链接器程序ld,将main.o和sum.o以及一些必要的系统目标文件组合起来,创建一个可执行目标文件(executable object file)prog:
ld -o prog [system object files and args] /tmp/main.o /tmp/sum.o
在Linux shell中运行可执行文件prog
linux> ./prog
shell 调用操作系统中一个叫做加载器(loader)的函数,将可执行文件prog中的代码和数据复制到内存,然后将控制转移到这个程序的开头。
静态链接
像Linux LD程序这样的静态链接器(static loader)以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可加载和运行的可执行目标文件作为输出。
输入的可重定位目标文件由各种不同的代码和数据(section)组成,每一节都是一个连续的字节序列。指令在一节中,初始化了的全局变量在另一节中,而未初始化的变量又在另外一节中。
为了构造可执行文件,链接器须完成两个主要任务:
**
- 符号解析**(symbol reolution)。目标文件定义和应用符号,每个符号对应于一个函数、一个全局变量或一个静态变量。其目的是将每个符号引用正好和一个符号定义关联起来。
- 重定位(relocation)
。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,而重定位这些节,并修改对应的符号的引用,使得它们指向内存位置。链接器使用汇编器产生的重定位条目(relocation entry)的详细指令,不加甄别地执行这样的重定位。
链接器中目标文件是字节块的集合,其块中,或包含程序代码,或包含程序数据,而其他包含引导链接器和加载器的数据结构。链接器把这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。
目标文件
目标文件有三种形式:
可重定位目标文件:包含二进制代码和数据,在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
可执行目标文件:包含二进制代码和数据,可被直接复制发哦内存并执行;
共享目标文件:一种特殊的可重定位目标文件,可在加载或者运行时被动态地加载进内存并链接。
编译器和汇编器会生成可重定位目标文件(含共享目标文件)。链接器生成可执行目标文件。技术上讲,一个目标模块(object module)是一个字节序列,而一个目标文件(object file)是一个以文件形式存放在磁盘中的目标模块。有时会互换使用该些术语。
目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同,从贝尔实验室诞生的第一个Unix系统使用的a.out格式。Windows使用可移植可执行(Protable Executable,PE)格式。Mac OS-X使用Mach-O格式。现代的x86-64 Linux和Unix系统使用可执行可链接格式(Executable and Linkable Format,ELF),无论使用各种格式,概念都是相似的。
可重定位目标文件
一个典型的ELF可重定位目标文件的格式如下,ELF头(ELF header)以一个16字节的序列开始,该序列描述了生成该文件的系统的字的大小和字节顺序。
ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF大小,目标文件的类型(可重定位、可执行或共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。
不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:
.text:已编译程序的机器代码;
.rodata:只读数据,如printf语句中的格式串和开关语句的跳转表;
.data:已初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这些节不占实际的空间,仅仅是一个占位符。目标文件格式分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。
.bss(Block Storage Start):未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。实际上,不需要通过-g选项来编译一个程序来得到符号表信息。每一个可重定位目标文件在**.symtab中都有一个符号表**(除非使用STRIP去掉)。和编译器中符号表不同,.symtab符号表不包含局部变量的条目。
.rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般,任何调用外部函数或引用全局变量的指令都需要修改,调用本地函数的指令则不需要修改。注意,可执行目标中并不需要重定位信息。
.real.data:被模块引用或定义的所有全局变量的重定位信息,一般,任何已初始化的全局变量,若它的初始值是一个全局变量或者外部定义的函数的地址,都需要被修改。
.debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始C源文件。只有以-g选项调用编译驱动程序时,才会得到这张表。
.line:原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会得到这张表。
.strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表是null结尾的字符串的序列。
符号和符号表
每个可重定位目标模板m都有一个符号表,它包含m定义和引用的符号和的信息。在链接器的上下文中,有三种不同的符号:
- 由模块m定义并能被其他模块引用的全局符号。全局链接器符号对应非静态的C函数和全局变量。
- 由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态C函数和全局变量。
- 只被模块m定义和引用的局部符号。他们对应于带static中的符号表不包含对应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理。
定义为带有C static属性的本地过程变量是不在栈中管理的。相反,编译器在.data或.bss中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。如,假设在同一模块中的两个函数各自定义了一个静态局部变量x:
int f(){
static int x = 0;
return x;
}
int g(){
static int x = 1;
return x;
}
此时,编译器向汇编器输出两个不同名字的局部链接器符号。如,它可用x.1表示函数f中定义,而用x.2表示函数g中的定义。
利用static属性隐藏变量和函数名字:任何带有static属性声明的全局变量或者函数都是模块私有的(其他模块无法访问)。类似,任何不带static属性声明的全局变量和函数都是公共的,可被其他模块访问,局部变量?。
//main.c
#include "stdio.h"
int main(){
extern void test();
extern int a;
extern int b;
test();
printf("a=%d.\n,b=%d.\n",a,b);
return 0;
}
//test.c
#include "stdio.h"
//全局变量
int a,b;
//statci int a,b;//a,b只能模块内访问,模块外部无法访问;
void test(){
a = 1;
b = 2;
}
-bash-4.2$ sudo gcc -o main main.c test.c
-bash-4.2$ ls
main main.c test.c
-bash-4.2$ ./main
a=1.
,b=2.
局部变量
//main.c
#include "stdio.h"
int main(){
extern void test();
extern int a;
extern int b;
test();
printf("a=%d.\n,b=%d.\n",a,b);
return 0;
}
//test.c
#include "stdio.h"
void test(){
//局部变量
int a,b;
a = 1;
b = 2;
}
-bash-4.2$ sudo gcc -o main1 main.c test.c
/tmp/ccUGrX8q.o: In function `main':
main.c:(.text+0x10): undefined reference to `b'
main.c:(.text+0x16): undefined reference to `a'
collect2: error: ld returned 1 exit status
若将需要调用变量写在头文件中,也可:
//test.h
int a,b;
//main.c
#include "stdio.h"
#include "test.h"
int main(){
extern void test();
test();
printf("a=%d.\n,b=%d.\n",a,b);
return 0;
}
// test.c
#include "stdio.h"
#include "test.h"
//int a,b;
void test(){
//int a,b;
a = 1;
b = 2;
}
-bash-4.2$ sudo gcc -o main2 main.c test.c
-bash-4.2$ ./main2
a=1.
,b=2.
其中,注意,GCC中调用头文件,#include <> :先去系统默认处,寻找头文件,若无,则在从所在文件夹中寻找;#include " ":先从本地文件夹中寻找,若无在从系统默认路径寻找。
符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。.symtab节中包含ELF符号表。这张符号表包含一个条目的数组,具体条目格式如下:
typedef struct{
int name;
char type:4,
binding:4;
}ELF64_Symbol;