链接器到底是如何工作的?

4,739 阅读7分钟

前言

本篇文章是一篇学习总结。主要记录“源代码-->可执行目标文件”过程中发生的一系列事情,主要描述链接器的工作流程。如有错误之处,欢迎大家指正。

正文

运行时的程序内存划分

一个程序在运行时,它应当具有以下几个部分:

  • 数据段
  • 代码段

当我们执行一个可执行目标文件(对于window来说就是.exe文件),操作系统加载器将其加载到内存,为其分配栈和堆空间,并将程序计数器指向代码段第一条指令的位置。其中栈用来存储局部变量,每个方法都对应一个栈帧。堆用来为程序动态的分配空间(malloc);这些都是在运行时产生的。而数据段和代码段整合起来就是加载的可执行目标文件。

举个例子
int a = 5;
int main{
int val = a+1;
return a;
}
我们可以简单的理解为语句int a=5;在数据段中申请了一个4个字节的空间(int占用4个字节)存储5。而main方法的代码行将会编译成指令存储在代码段中。

可执行目标文件的前世今生

“源文件->可执行目标文件”的过程是由编译驱动程序负责的。以C语言为例,编写两个源文件code.c,hello.c:

code.c:
int i=1;   
void sayHello();   
void main(){  
    int val = i+1
    sayHello(val);   
}

hello.c:
sayHello(int val){
    printf("hello %d!",val);
}

形成可执行目标文件要经历以下几个步骤:

  • 第一步:驱动程序调用预处理器,将源程序转换成中间文件。
  • 第二步:驱动程序调用编译器,将中间文件编译成汇编代码文件。
  • 第三步:驱动程序调用汇编器,将汇编文件转成可重定位目标文件(与具体机器相关的机器指令)。
  • 第四步:调用链接器将code.o与hello.o以及其他的系统可重定位目标文件链接起来,生成可执行目标文件。

目标文件的分类

目标文件可分为下面三种类型:

  • 可重定位目标文件:包含数据和代码,编译时多个可重定位目标文件生成可执行目标文件。
  • 可执行目标文件:包含数据和代码,可以被直接加载运行。
  • 共享目标文件:特殊的目标文件,可以在加载或运行时被动态的加载进内存并链接。(这个用于动态链接)

不同系统目标文件结构都各不相同,本文以linux系统使用的可执行可链接格式(Executable and Linkable Format,ELF)目标文件举例。

可重定向目标文件的结构

下图给出一个可重定位目标文件的文件结构:

图中第一格为ELF头,里面存储着目标文件的类型(可重定向、可执行、共享的),以及节头部表的偏移地址等信息。最后一格为节头部表,记录着不同节的起始地址和大小。它们中间的绿色的格子我们称之为节,一共12个节,每个节中都存储一些有用的信息。

  • .text节中存储机器指令
  • .rodata存储printf语句中的字符串等。
  • .data存储已初始化的全局和静态变量。
  • .bss 存储未初始化以及初始化为0的全局和静态变量。
  • .symtab为符号表。
  • .rel.text节存储代码段中需要重定位的引用
  • .rel.data节存储数据段中需要重定位的引用
  • .strtab存储字符串

为了更直观的反映模块之间的关系,图中加入了一些箭头。

注意:局部变量既不会存储在.rodata,也不会存储在.bss节中。它是运行时存储在栈中的,相关信息由编译时维护。

.debug和.line节仅当开启debug模式编译时才会产生,想一想我们的目标程序中没有源代码以及变量名这些信息,debug模式下却可以断点定位到源代码的行数以及显示变量中存储的内容。这两个节存储这源代码和机器代码间的一些对应关系。所以这两个节在非debug模式下时不存在的,可以忽略。

符号和符号表

何为符号?

可重定位目标文件m中任何定义或引用的全局变量或者静态变量以及函数都被称之为符号。可分为:

  • 由m定义并能被其他目标文件引用的全局符号,对应于m中定义的非静态的变量和函数
  • 由其他目标文件定义并在m中引用的全局符号,称为外部符号。对应于在其他目标文件中定义的非静态变量和函数
  • 只被m定义和引用的局部符号:对应于m中定义的static修饰的函数和全局变量。

符号表

.symtab节被称为符号表,是一个存储着目标文件中所有符号的数组;每个元素对应一个符号的信息。下面看看符号表中元素的数据结构:

其中主要解释五个重要的字段:

  • name:指向.strtab节中一个字符串的首地址,该字符串为符号的名字。
  • type:符号的类型(变量或方法)。
  • section:符号定义所在的节头部表的索引。
  • value: 符号定义在节内的偏移。
  • bingding:全局还是局部符号。

引用和重定位条目

何为引用?

可重定向目标文件m中任何定义的函数以及变量都具有一个名称,通过名称访问方法和变量时这个名称称为引用,它指向一个已定义的变量或函数。

重定位条目

由于引用在运行时指代的真实地址并不确定,所以编译器会为每个无法确定位置的引用生成一个可重定位条目,告诉链接器某个位置上有需要重定位的引用。

其中引用类型大致分为绝对引用和相对引用等等。
计算偏移是一个数值。
引用类型决定了真实地址的计算方式。

链接器的工作

前面我们花很长的时间介绍了汇编器生成的可重定位目标文件的结构,下面开始介绍链接器的工作流程。
链接器的任务是将所有的可重定位目标文件链接起来,形成一个可执行目标文件。那么它应当完成三个任务:

  • 关联所有的引用与符号
  • 合并可重定位目标文件
  • 修改引用为运行时地址

关联引用与符号

链接器会将重定位条目中的所有引用与符号表中的一个符号关联起来(这个符号可能在同一个可重定位目标文件中,也可能在其他可重定位目标文件中)。

合并可定位目标文件

合并所有的可重定位目标文件,将相同的节合并,并修改符号表和节头部表中的偏移地址,使其指向合并后的真实地址。

修改引用为运行时地址

链接器在第一步已经将所有引用与一个唯一的符号进行了关联,在第二步将可重定位文件进行了合并,并将符号的地址进行了修改。这时链接器可以很方便的将重定位条目中指向的引用替换成符号表中符号指向的运行时地址。

可执行目标文件结构

最终合并后的可执行目标文件结构如下图:正如前面所说,.symtab、.debug等节都是供调试用的,对代码的真正执行无意义,段头部表为加载器提供一些映射信息,比如说数据段和代码段的位置和字节长度。

结尾

这时回到我们最开始说的程序的组成,加载器一开始将数据段和代码段复制到主存,并为程序分配堆空间和栈空间。并将程序计数器指向代码段的第一条指令。