在几年前第一次学C语言时,按照书上给的示例,在VC6.0中写了HelloWorld程序,然后按照书上的教程,进行编译,链接,最后执行程序,就能在输出窗口上看到Helloworld。
对于一个用IDE写代码的人来说,代码需要编译链接才能生成可执行文件这是一个常识,那么编译链接到底做了一个什么样的事情呢?
因为我们写到C语言代码是高级程序语言,计算机是没法解析执行的,计算机能读懂的只能是二进制代码,编译就是将我们写的高级程序语言代码转化成二进制代码。这个时候就会产生疑问,既然编译已经生成了二进制代码,那么链接有什么用?
先说结论,链接主要完成两个任务,第一步是合并目标文件,第二步是符号解析与重定位。今天我们先单独说重定位。
在了解链接过程之前先简单的链接一些基础知识
简单的说,编译器将代码转化为可执行文件主要分为预编译、编译、汇编和链接四个过程,前三个是编译过程
这个目标文件就是链接器所需要的文件,链接器会将所有的目标文件拼凑成一个可执行文件。
目标文件的常用格式是linux平台下的ELF格式和windows平台下的PE格式,可执行文件采用的其实也是这两种格式。
在ELF格式中使用以段(Section)为单位来管理数据,比如一般的ELF文件中会有存放执行语句.text段,有存放全局变量和静态变量区的.data段,还有存放未初始化数据的.bss段。
我们在ubuntu系统中写了一段简单的代码:
int global_init_var=84;
int global_unint_var;
void func1(int i)
{
i=i+1;
}
int main()
{
static int static_var=85;
static int static_var2;
int a=1;
int b;
func1(static_var+static_var2+a+b);
return a;
}
保存为SimpleSection.c文件并使用:
gcc -c SimpleSection.c
指令进行编译,-c表示只编译不链接。文件中会生生成一个SimpleSection.o文件,这个就是一个目标文件。我们使用:
objdump -h SimpleSection.o
指令来查看这个文件,结果如下:
这个目标文件中共有五个段,Size列代表了每个段的大小,File off段代表了这个段在文件中的起始位置。
可以看到.text段是从40地址开始的,大小为41,其中存放的是可执行代码,具体内容稍后分析。.
data段存放的是初始化过的全局变量和静态变量,可以看到它的大小是8个字节,我们的代码中刚好有两个,static_var和global_init_var。
.bss段存放的是未初始化的,但是它的大小是4字节,这是因为有些编译器将未初始化的全局变量当作一个未定义的全局变量符号,等着链接之后再在bss段分配空间,所有这个段中只存放了static_var2。
下面我们用指令:
objdump -d SimpleSection.o
来观察一下SimpleSection.o的代码段,-d选项可以以反汇编的形式输出代码段内容,结果如下:
可以看到,最左边一列是指令所在的偏移量,中间一列是二进制指令,右边是二进制指令对应的汇编指令。第一行表示了第0个字节是指令0X55,第二行指令是第1,2,3字节是0x48,0x89,0xe5。一共41个字节,与我们之前的观察一致。其实这SimpleSection文件已经是二进制代码了,而且这个文件也不需要依赖其他任何文件进行链接,但是为什么这个目标文件依然不能执行呢。
了解完了ELF的格式,现在我们来看看链接器到底做了什么工作,我们连接一下这个文件来试一试,使用指令:
ld -static -e main -o SimpleSection SimpleSection.o
对目标文件进行连接,-static是使用静态链接,-e是选择程序入口,链接后会生成一个可执行文件,但是运行这个程序会出现段错误,这个问题稍后再提。
先说一下为什么使用这个指令进行链接而不使用gcc进行链接,其实在gcc里面也是使用的ld链接器,但是gcc的链接会默认跟系统库一起进行链接,这个问题我们留到后面讨论。
先观察一下SimpleSection可执行文件的代码段:
objdump -d SimpleSection
会发现SimpleSection的.text中多出了很多其他函数,先不管这些函数,我们只看func1和main函数,结果如下:
会发现,我嘞个去,居然和SimpleSection.o文件里面的内容是一样的,但是仔细看,最左边一列是不一样的,函数和指令的地址出现了变化,这就是链接器做的其中一件事情,重定位,链接器会为目标文件中的符号进行重定位,给其分配对应的虚拟地址,只有拥有了这个虚拟地址,这个文件才能被操作系统装载然后执行。
其实可以联想到C语言的内存分区中有代码段用来存放执行代码,数据段存放全局变量和数据,还有堆区和栈区。操作系统能够将可执行文件装载进内存进行执行就是因为链接器为其分配了虚拟地址,而目标文件则没有分配虚拟地址,没法装载运行。当然,堆区和栈区是运行时才有的,在ELF文件中是没有的,局部变量都是在堆和栈中的,所以ELF文件中不会存放局部变量。
现在我们弄清楚了链接器的其中一个功能就是进行符号重定向,为符号分配虚拟地址,所以即使不依赖于其他目标文件的程序也需要链接才能生成可执行文件。现在我们来填一下之前的两个坑:
先说问题1,其实gcc也是使用的ld链接器,不过gcc在链接过程中会将我们的目标文件和crt1.o, crti.o, crtbegin.o, crtend.o, crtn.o这些系统库文件进行一起链接,这些系统库文件会为我们的程序做一些初始化和扫尾工作,具体的可以参考:
crt1.o, crti.o, crtbegin.o, crtend.o, crtn.o
再说问题2,执行这个程序会出现段错误的原因就是因为没有和系统库链接,在main程序运行完成后没办法退出,会继续执行后续的其他代码,然后导致了段错误。
和系统库一起进行链接后可执行文件中会出现其他的代码段,影响我们观察文件本身。
现在我们重新写一段代码:
#include
void func1(int i)
{
++i;
}
int main()
{
printf("%lld\n",func1);
return 0;
}
然后使用gcc addr.c -o addr执行生成可执行文件,观察一下可执行文件的.text段,里面多出了其他的许多函数,这就是系统库中的函数,然后我们再来看一下func1函数:
可以看到func1函数在链接重定位之后的地址是0x400526,现在运行addr,可以多运行几次:
输出了func1在虚拟内存中的地址,4195622,即0x400526。也即是之前我们所说的,系统将可执行文件装载进虚拟内存是按照可执行文件中的虚拟地址装载的,也就是说代码中函数和全局变量静态变量的地址在链接的时候就已经确定了。
总结一下就是,链接器会为代码中的函数,全局变量静态变量分配虚拟地址,当操作系统执行这个文件时会按照这个虚拟地址将可执行文件装载进虚拟内存。
学完了之后来练习一下,这是一个计算机考研真题:
在虚拟内存管理中,地址变换机构将逻辑地址转换为物理地址,形成逻辑地址的阶段是()
A. 编辑
B. 编译
C. 链接
D. 装载
很明显答案是选C了,编辑是写代码,和地址无关,编译生成的地址是相对于段本身的地址,链接时才会生成虚拟地址,也即是逻辑地址,装载是按照链接时生成的逻辑地址进行装载的。