本文是对CSAPP第7章学习整理的简单笔记


什么是链接

链接是将各种代码和数据片段收集并组合为一个单一文件的过程,这个文件可以被加载到内存中执行。

链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。

链接执行于以下三个时间点:

  • 编译时: 源代码通过编译期翻译为机器码
  • 加载时: 程序被加载器加载到内存中并执行时
  • 运行时: 应用程序执行时

程序执行的过程:

//main.c
int sum(int * a,int n);

int array[2]={1,2};

int main(){
    int val=sum(array,2);
    return val;
}
//sum.c
int sum(int* a,int n){
    int i,s=0;
    for(int i=0;i<n;i++){
        s+=a[i];
    }
    return s;
}

在这里插入图片描述
最后 , 运行可执行文件:

linux> ./prog

shell调用操作系统中的一个叫做加载器的函数,它将可执行文件prog中的代码和数据复制到内存,然后跳转到程序入口地址执行。


目标文件

目标文件有三种形式:

  • 可重定位目标文件: 包含二进制代码和数据,编译时可以和其他可重定位文件合并起来,创建一个可执行目标文件。
  • 可执行目标文件: 包含二进制代码和数据,可以执行被复制到内存并执行。
  • 共享目标文件: 一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。

在这里插入图片描述
汇编器和编译器生成可重定位目标文件(包括共享目标文件),链接器生成可执行目标文件。

一个目标模块就是一个字节序列,而一个目标文件就是一个以文件形式存放在磁盘中的目标模块。


目标文件格式

现代X86-64 Linux和Unix系统使用可执行可链接的格式(Executable and Linkedable Format),简称ELF。

可重定位目标文件格式

  • elf文件格式如下: elf头信息+节数组+节头部表(描述每个节所在位置)

在这里插入图片描述

  • elf头信息如下所示:

在这里插入图片描述

  • elf头以一个16个字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序:
    在这里插入图片描述
  • elf头剩下部分包含帮助链接器语法分析和解释目标文件的信息,其中包括: elf头大小,节头部表的偏移量,以及节头目表中条目的数量和大小。
  • 不同节的位置和大小是由节头部表描述的,节头目表看做是一个数组,里面存放了一堆entry,每个entry描述一个节的位置和大小。

常见的节有以下几种:

  • .text : 存放编译好的程序的机器代码
  • .rodata: 只读数据,比如printf语句中的格式串和switch语句的跳转表
  • .data: 存放已经初始化的全局和静态c变量,局部c变量在运行时被保存在栈中
  • .bss: 存放未初始化的全局和静态c变量,以及所有被初始化为0的全局或静态变量。 .bss节不在目标文件中占用实际空间,它仅仅只是作为一个占位符,目标文件格式区分已初始化和未初始化变量是为了空间效率--->better save space。
  • .symtab: 符号表,存放程序中定义和引用的函数和全局变量信息
  • .strtab: 字符串表
  • .line: 原始c程序的行号和.text节中机器指令之间的映射
  • .debug: 调试信息

符号和符号表

每个可重定位目标模块m都有一个符号表,它包含当前m定义和引用的符号信息。在链接器上下文中,符号又分为以下三类:

  • 由模块m定义并能被其他模块引用的全局符号全局符号对应于 非静态的c函数和全局变量
  • 由其他模块定义并被模块m引用的全局符号,这些符号被称为外部符号,对应于在其他模块中定义的非静态c函数和全局变量
  • 被模块m定义和引用的局部符号,它们对应于带static属性的c函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。

c语言中任何带有static属性声明的全局变量或者函数都是模块私有的,类似c++和java中的private

下面给出一个实际案例:

#include<stdio.h>
int count=10;
int value;
void func(int sum){
   printf("sum is %d\n",sum);
}

int main(){
   static int a=1;
   static int b=0;
   int x=1;
   func(a+b+x);
   return 0;
}

在这里插入图片描述

  • func和main都是全局符号,根据type可知符号表示一个函数类型,它所在节头部表索引为1,可以定位到.text节中,再根据value表示在.text节中的偏移量得到函数入口地址,size大小得到函数结束地址。
  • printf是外部符号,由于printf函数定义未在main.c中给出,所以在没有进行符号解析前,printf函数的Ndx为UnDefine,表示未定义符号,也就是在本模块引用,在其他模块中定义的符号。
  • count是全局符号,根据type可知符号表示是一个数据对象类型,它所在节头部表索引为3,可以定位到.data节中,再根据value表示在.data节中的偏移量得到对象地址,size表示对象大小。
  • value属于未初始化的全局变量,存放于COMMON,COMMON和.bss的区别如下:
    • common: 存放未初始化的全局变量
    • .bss: 存放未初始化的静态变量,以及初始化为0的全局或静态变量
  • 局部静态变量a和b都是局部符号,但是局部静态变量a存放在.data节中,局部静态变量b存放在.bss节中,因为b被赋予0值。为了防止局部静态变量a和b同名,编译器还进行了重命名处理

局部变量在运行时的栈中被管理,链接器对此类符号不感兴趣,所以不会出现在符号表中。


符号解析

链接器解析符号引用的方法就是将每个引用与它输入的可重定位目标文件的符号表中的一个确定符号定义关联起来。

局部符号解析:

  • 编译器只允许每个模块中每个局部符号有一个定义,静态局部变量也会有本地链接器符号,编译器需要确保它们拥有唯一的名字。

全局符号解析:

  • 当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,编译器会假设该符号在其他某个模块中进行的定义,生成一个链接器符号表条目,并把它交给链接器处理,如果链接器在任何输入模块中都找不到这个被引用符号的定义,就输出一条错误信息并终止。

多重定义全局符号处理

如果多个外部输入模块都定义了同名的全局符号,那么此时在编译时,编译器会向汇编器输出每个全局符号,或者是强或者是弱,而汇编器把该信息存储于可重定位目标文件的符号表中。

  • 函数和已初始化的全局变量是强符号
  • 未初始化的全局变量是弱符号

根据强弱符号的定义,Linux链接器使用下面的规则来处理多重全局符号定义:

  • 规则1: 不允许存在多个同名的强符号
  • 规则2: 如果有一个强符号和多个弱符号同名,那么选择强符号
  • 规则3: 如果有多个弱符号同名,那么从这些弱符号中任意选择一个

上面3个规则的应用还是会产生一些意向不到的问题,具体参考csapp 471到474页,我们可以通过GCC-fno-common这样的选项调用链接器,在遇到多重定义的全局符号时,触发一个错误,或者使用-Werror选项,它会把所有的警告都变成错误,


静态库

我们可以通过链接器读取一组可重定位文件,并把它们链接起来,形成一个可执行文件。实际上,所有编译系统都提供了一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库,它可以用做链接器的输入。

当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的模板模块。

没有静态库的话,如果我们程序中需要调用标准库中的函数,有几种方式实现呢?

  • 编译器层面做手脚: 通过让编译器辨认出对标准函数的调用,然后直接生成相应的的代码。
    • 缺点: c语言定义了大量的标准函数,这样会使得编译器变得很复杂,而且每次修改标准库中的函数时,都需要一个新的编译器版本。
    • 优点: 对于程序员来说很方便,因为标准库总是可用的。
  • 将所有标准c函数都放在一个可重定位目标模块中,应用程序直接把该模块链接到他们的可执行文件中。
    • 缺点: 每个可执行文件都包含一份标准函数集合的完全副本,对磁盘和内存空间浪费特别大!并且一旦要修改标准库中的函数,都需要重新编译整个源文件,非常耗时,增加了标准函数库模块的开发和维护复杂度。
    • 优点: 编译器实现与标准函数实现分离开来。
  • 通过为每个标准函数创建一个独立的可重定位文件,把它们存放在一个大家都知道的目录中。
    • 缺点: 程序员必须手动链接合适的目标模块到他们的可执行文件中。
    • 优点: 编译器实现与标准函数实现分离开来,并且应用程序只需链接自己需要的模块即可

静态库概念被提出来,就是为了解决上面这些不同方法的缺点。

解决办法:

  • 相关的函数被编译为独立的目标模块,然后封成一个单独的静态库文件,然后应用程序可以通过在命令行指定单独的文件名字来使用这些在库中定义的函数。

ISO C99定义了一组广泛的标准I/O,字符串操作和整数数学函数,它们在libc.a库中,对于每个c程序来说都是可用的。

例如: 使用c标准库中函数的程序可以用如下命令行进行编译和链接

gcc main.c /usr/lib/libm.a

在链接时,链接器只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小,另一方面,应用程序只需要包含较少的库文件名字即可。

在Linux系统中,静态库以一种称为存档的特殊文件格式存放在磁盘中,存档文件是一组连续的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置,存档文件名由后缀.a标识。

示例:

  • 准备两个目标模块
    在这里插入图片描述
  • 将以上两个模块打包为一个静态库文件
ar rcs libvector.a addvec.o multvec.o
  • 编写应用,并使用libvector静态库中的addvec模块
    在这里插入图片描述
  • -static参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行文件,它可以加载到内存并运行,在加载时无需更进一步链接
    在这里插入图片描述
    链接器运行时,它判定main.o引用了addvec.o定义的addvec符号,所以复制addvec.o到可执行文件。因为程序不引用任何由multvec.o定义的符号,所以链接器不会复制整个模块到可执行文件。链接器还会复制libc.a中的printf.o模块,以及许多C运行时系统中的模块。

静态库解析过程

在符号解析阶段,链接器从左到右按照他们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件(驱动程序自动将命令行中所有.c文件翻译为.o文件)。

  • 链接器维护一个可重定位目标文件的集合E,这个集合中的文件会被合并起来形成可执行文件。
  • 一个未解析的符号集合U,即引用了但是尚未定义的符号集合。
  • 一个在前面输入文件中已经定义的符号集合D。

初始时,三个集合均为空。

  • 对于命令行上的每个输入文件f,链接器都会判断f是一个目标文件还是一个静态库文件,如果是一个目标文件,那么链接器把f添加到E,修改U和D来反映f中的符号定义和引用,然后继续输入下一个文件

在这里插入图片描述

  • 如果f是一个静态库文件,那么链接器就尝试匹配U中未解析的符号和由静态库文件中成员定义的符号,如果在某个静态库文件成员m中定位到了U中某个未解析的符号,那么就将m添加到E中,并通过修改U和D来反映m中的符号定义和引用。对静态库文件中所有成员重复此过程,直到U和D不再发生变化。此时,任何不包含在E中的成员目标文件都简单被丢弃,而链接器继续处理下一个输入文件。

在这里插入图片描述

  • 如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,构建输出可执行文件。

注意:如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。

详情参考csapp 478

重定位

  1. 重定位节和定义
  • 将所有相同类型的节合并为同一类型的新的聚合节,并把运行时内存地址赋给新的聚合节。这样,程序中的每一条指令和全局变量都有唯一的运行时内存地址了。

2.重定位节中的符号引用

  • 链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时内存地址,要执行这一步,链接器需要依赖于可重定位目标模块中称为重定位条目的数据结构。

重定位条目

当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。

当源代码经过编译生成可重定位目标文件后,其中无法识别的符号引用,对应的call调用或者引用地址会被设置为0,等到链接重定位时进行地址。修正

代码的重定位条目放在 .rel.text 中。已初始化数据的重定位条目放在 .rel.data 中。

下图展示了ELF重定位条目格式:
在这里插入图片描述
ELF定义了32种不同的重定位类型,我们只需要关心其中两种即可:

  • 重定位PC相对引用
  • 重定位绝对引用

链接器会根据我们目标文件或者静态库中的重定位表,找到那些需要被修正的全局变量和函数,从而修正他们的地址:
在这里插入图片描述

详情参考csapp 478-482

可执行文件

ELF可执行文件格式如下:
在这里插入图片描述
可执行目标文件的格式类似于可重定位目标文件的格式。ELF 头描述文件的总体格式。它还包括程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址。

.text、.rodata 和 .data 节与可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时内存地址以外。.init 节定义了一个小函数,叫做 _init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位),所以它不再需要 .rel 节。


加载可执行文件

要运行可执行目标文件 prog,我们可以在 Linux shell 的命令行中输入它的名字:

linux> ./prog

因为 prog 不是一个内置的 shell 命令,所以 shell 会认为 prog 是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器(loader)的操作系统代码来运行它。

任何 Linux 程序都可以通过调用 execve 函数来调用加载器。加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载。

每个Linux运行时程序都有一个运行时镜像:
在这里插入图片描述
在程序头部表的引导下,加载器将可执行文件的片(chunk)复制到代码段和数据段。接下来,加载器跳转到程序的入口点,也就是 _start函数的地址。

这个函数是在系统目标文件 ctrl.o 中定义的,对所有的 C 程序都是一样的。_start 函数调用系统启动函数 __libc_start_main,该函数定义在 libc.so 中。它初始化执行环境,调用用户层的 main 函数,处理 main 函数的返回值,并且在需要的时候把控制返回给内核。

  • 加载器是如何工作的 ?

Linux 系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。当 shell 运行一个程序时,父 shell 进程生成一个子进程,它是父进程的一个复制。子进程通过 execve 系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start 地址,它最终会调用应用程序的 main 函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到 CPU 引用一个被映射的虚拟页时才会进行复制,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。


动态链接

静态库缺点:

  • 需要定期维护和更新静态库,然后显示地将他们的程序与更新了的库重新链接
  • 标准库中的I/O函数,基本上每个c程序都会引用,那么意味着,在运行时,这些函数代码会被复制到每个运行的进程的文本段中,这是对内存的极大浪费

共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)的程序来执行的。共享库也称为共享目标(shared object),在 Linux 系统中通常用 .so 后缀来表示。微软的操作系统大量地使用了共享库,它们称为 DLL(动态链接库)。

共享库是以两种不同的方式来“共享”的。首先,在任何给定的文件系统中,对于一个库只有一个. so 文件。所有引用该库的可执行目标文件共享这个 .so 文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。其次,在内存中,一个共享库的 .text 节的一个副本可以被不同的正在运行的进程共享。

这里涉及到CSAPP第九章要讲的虚拟内存机制,该章节中会探讨如何实现库的共享

静态库和共享库构造对比如下:
在这里插入图片描述
动态链接基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。

认识到这一点是很重要的:此时,没有任何 libvector.so 的代码和数据节真的被复制到可执行文件 prog2l 中。反之,链接器复制了一些重定位和符号表信息,它们使得运行时可以解析对 libvector.so 中代码和数据的引用。

在这里插入图片描述

当加载器加载和运行可执行文件 prog2 时,加载部分链接的可执行文件 prog2。

接着,它注意到 prog2 包含一个 .interp 节,这一节包含动态链接器的路径名,动态链接器本身就是一个共享目标文件(如在 Linux 系统上的 ld-linux.so).

加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器。

然后,动态链接器通过执行下面的重定位完成链接任务:

  • 重定位 libc.so 的文本和数据到某个内存段。
  • 重定位 libvector.so 的文本和数据到另一个内存段。
  • 重定位 prog2 中所有对由 libc.so 和 libvector.so 定义的符号的引用。

最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。


运行时动态链接和加载某个共享库

应用程序还可能在它运行时要求动态链接器加载和链接某个共享库,而无需在编译时将那些库链接到应用中。

动态链接是一项强大有用的技术。下面是一些现实世界中的例子:

  • 分发软件。
    • 微软 Wmdows 应用的开发者常常利用共享库来分发软件更新。他们生成一个共享库的新版本,然后用户可以下载,并用它替代当前的版本。下一次他们运行应用程序时,应用将自动链接和加载新的共享库。
  • 构建高性能 Web 服务器。
    • 许多 Web 服务器生成动态内容,比如个性化的 Web 页面、账户余额和广告标语 早期的 Web 服务器通过使用 fork 和 execve 创建一个子进程,并在该子进程的上下文中运行 CGI 程序来生成动态内容。然而,现代高性能的 Web 服务器可以使用基于动态链接的更有效和完善的方法来生成动态内容。

其思路是将每个生成动态内容的函数打包在共享库中。当一个来自 Web 浏览器的请求到达时,服务器动态地加载和链接适当的函数,然后直接调用它,而不是使用 fork 和 execve 在子进程的上下文中运行函数。函数会一直缓存在服务器的地址空间中,所以只要一个简单的函数调用的开销就可以处理随后的请求了。这对一个繁忙的网站来说是有很大影响的。更进一步地说,在运行时无需停止服务器,就可以更新已存在的函数,以及添加新的函数。

  • Linux 系统为动态链接器提供了一个简单的接口,允许应用程序在运行时加载和链接共享库。
#include <dlfcn.h>

void *dlopen(const char *filename, int flag);

// 返回:若成功则为指向句柄的指针,若出错则为 NULL。

flag 参数必须要么包括 RTLD_NOW,该标志告诉链接器立即解析对外部符号的引用,要么包括 RTLD_LAZY 标志,该标志指示链接器推迟符号解析直到执行来自库中的代码。

  • dlsym 函数的输入是一个指向前面已经打开了的共享库的句柄和一个 symbol 名字,如果该符号存在,就返回符号的地址,否则返回 NULL。
#include <dlfcn.h>

void *dlsym(void *handle, char *symbol);

// 返回:若成功则为指向符号的指针,若出错则为 NULL。
  • 如果没有其他共享库还在使用这个共享库,dlclose函数就卸载该共享库。
#include <dlfcn.h>

int dlclose (void *handle);

// 返回:若成功则为0,若出错则为-1.
  • 用例
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

int main()
{
    void *handle;
    void (*addvec)(int *, int *, int *, int);
    char *error;

    /* 获取打开的共享库句柄 */
    handle = dlopen("./libvector.so", RTLD_LAZY);
    if (!handle) {
        fprintf(stderr, "%s\n", dlerror());
        exit(1);
    }

    /* 去共享库文件中定位某个符号 */
    addvec = dlsym(handle, "addvec");
    if ((error = dlerror()) != NULL) {
        fprintf(stderr, "%s\n", error);
        exit(1);
    }

    /* 调用函数 */
    addvec(x, y, z, 2);
    printf("z = [%d %d]\n", z[0], z[1]);

    /* 卸载共享库 */
    if (dlclose(handle) < 0) {
        fprintf(stderr, "%s\n", dlerror());
        exit(1);
    }
    return 0;
}

共享库和 Java 本地接口:
Java 定义了一个标准调用规则,叫做 Java 本地接口(Java Native Interface,JNI),它允许 Java 程序调用“本地的” C 和 C++ 函数。JNI 的基本思想是将本地 C 函数(如 foo)编译到一个共享库中(如 foo.so)。 当一个正在运行的 Java 程序试图调用函数 foo 时,Java 解释器利用 dlopen 接口(或者与其类似的接口)动态链接和加载 foo.so,然后再调用 foo。


位置无关代码

由于动态链接的通过动态共享一份目标代码,解决了空间浪费和重新链接问题。不同进程之间的共享可以通过地址映射解决。我们现在有两个选择:

  • 为每个动态链接库规定一个固定的地址,这样减小了动态链接库编译的难度,因为在编译期链接库就知道自己需要装载的地址,跟普通的编译没有什么区别。但是这样我们必须为每一个链接库保留地址空间,即使可能我们没有用到它,进一步链接库的更新对于这个保留地址管理都是一个挑战,而且保留地址完全移除了虚拟地址做出的努力。
  • 使得动态链接库的代码可以在任意位置执行,显然采取这种方式更加灵活,且动态链接库中必然不能有绝对地址,与之相伴的技术PIC(position independence code)可以用来解决这个问题。

要做到位置无关代码,必然要求每一个链接这个动态共享库的可执行程序对于同一个符号的引用可以是不同的。而由于是共享的,每个动态链接库代码段的内容是完全一样的(代码段可以通过内存映射完成多进程间共享),这样只能在数据段做手脚了,因为数据段不是只读的,无法共享。

例如下面这个例子:

#include <stdio.h>

void print_banner()
{
    printf("Welcome to World of PLT and GOT\n");
}

int main(void)
{
    print_banner();

    return 0;
}

经编译和链接阶段之后,test可执行文件中print_banner函数的汇编指令会是怎样的呢?我猜应该与下面的汇编类似

080483cc <print_banner>:
 80483cc:    push %ebp
 80483cd:    mov  %esp, %ebp
 80483cf:    sub  $0x8, %esp
 80483d2:    sub  $0xc, %esp
 80483d5:    push $0x80484a8  
 80483da:    call **<printf函数的地址>**
 80483df:    add $0x10, %esp
 80483e2:    nop
 80483e3:    leave
 80483e4:    ret

print_banner函数内调用了printf函数,而printf函数位于glibc动态库内,所以在编译和链接阶段,链接器无法知知道进程运行起来之后printf函数的加载地址。故上述的**<printf函数地址>** 一项是无法填充的,只有进程运运行后,printf函数的地址才能确定。

那么问题来了:进程运行起来之后,glibc动态库也装载了,printf函数地址亦已确定,上述call指令如何修改(重定位)呢?

一个简单的方法就是将指令中的**<printf函数地址>**修改printf函数的真正地址即可。

但这个方案面临两个问题:

  • 现代操作系统不允许修改代码段,只能修改数据段
  • 如果print_banner函数是在一个动态库(.so对象)内,修改了代码段,那么它就无法做到系统内所有进程共享同一个动态库。

因此,printf函数地址只能回写到数据段内,而不能回写到代码段上。

注意:刚才谈到的回写,是指运行时修改,更专业的称谓应该是运行时重定位,与之相对应的还有链接时重定位。

说到这里,需要把编译链接过程再展开一下。我们知道,每个编译单元(通常是一个.c文件,比如前面例子中的test.c)都会经历编译和链接两个阶段。

编译阶段是将.c源代码翻译成汇编指令的中间文件,比如上述的test.c文件,经过编译之后,生成test.o中间文件。print_banner函数的汇编指令如下(使用objdump -d test.o命令即可输出):

00000000 <print_banner>:
      0:  55                   push %ebp
      1:  89 e5                mov %esp, %ebp
      3:  83 ec 08             sub   $0x8, %esp
      6:  c7 04 24 00 00 00 00 movl  $0x0, (%esp)
      d:  e8 fc ff ff ff       call  e <print_banner+0xe>
     12:  c9                   leave
     13:  c3                   ret

是否注意到call指令的操作数是fc ff ff ff,翻译成16进制数是0xfffffffc(x86架构是小端的字节序),看成有符号是-4。这里应该存放printf函数的地址,但由于编译阶段无法知道printf函数的地址,所以预先放一个-4在这里,然后用重定位项来描述:这个地址在链接时要修正,它的修正值是根据printf地址(更确切的叫法应该是符号,链接器眼中只有符号,没有所谓的函数和变量)来修正,它的修正方式按相对引用方式。

这个过程称为链接时重定位,与刚才提到的运行时重定位工作原理完全一样,只是修正时机不同。

链接阶段是将一个或者多个中间文件(.o文件)通过链接器将它们链接成一个可执行文件,链接阶段主要完成以下事情:

  • 各个中间文之间的同名section合并
  • 对代码段,数据段以及各符号进行地址分配
  • 链接时重定位修正

除了重定位过程,其它动作是无法修改中间文件中函数体内指令的,而重定位过程也只能是修改指令中的操作数,换句话说,链接过程无法修改编译过程生成的汇编指令。

那么问题来了:编译阶段怎么知道printf函数是在glibc运行库的,而不是定义在其它.o中

答案往往令人失望:编译器是无法知道的

根据前面讨论,运行时重定位是无法修改代码段的,只能将printf重定位到数据段。那在编译阶段就已生成好的call指令,怎么感知这个已重定位好的数据段内容呢?

答案是:链接器生成一段额外的小代码片段,通过这段代码支获取printf函数地址,并完成对它的调用。

链接器生成额外的伪代码如下:

.text
...

// 调用printf的call指令
call printf_stub
...

printf_stub:
    mov rax, [printf函数的储存地址] // 获取printf重定位之后的地址
    jmp rax // 跳过去执行printf函数

.data
...
printf函数的储存地址:
  这里储存printf函数重定位后的地址

链接阶段发现printf定义在动态库时,链接器生成一段小代码print_stub,然后printf_stub地址取代原来的printf。因此转化为链接阶段对printf_stub做链接重定位,而运行时才对printf做运行时重定位。


PLT与GOT

前面由一个简单的例子说明动态链接需要考虑的各种因素,但实际总结起来说两点:

  • 需要存放外部函数的数据段
  • 获取数据段存放函数地址的一小段额外代码

如果可执行文件中调用多个动态库函数,那每个函数都需要这两样东西,这样每样东西就形成一个表,每个函数使用中的一项。

存放函数地址的数据表,称为全局偏移表(GOT, Global Offset Table),而那个额外代码段表,称为程序链接表(PLT,Procedure Link Table)。它们各司其职,联合出手上演这一出运行时重定位好戏。

那么PLT和GOT长得什么样子呢?前面已有一些说明,下面以一个例子和简单的示意图来说明PLT/GOT是如何运行的。

假设最开始的示例代码test.c增加一个write_file函数,在该函数里面调用glibc的write实现写文件操作。根据前面讨论的PLT和GOT原理,test在运行过程中,调用方(如print_banner和write_file)是如何通过PLT和GOT穿针引线之后,最终调用到glibc的printf和write函数的?

我简单画了PLT和GOT雏形图,供各位参考。
在这里插入图片描述


小结

链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。

链接器处理称为目标文件的二进制文件,它有 3 种不同的形式:可重定位的、可执行的和共享的。

  • 可重定位的目标文件由静态链接器合并成一个可执行的目标文件,它可以加载到内存中并执行。
  • 共享目标文件(共享库)是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序调用 dlopen 库的函数时。

链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到亠个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。

静态链接器是由像 GCC 这样的编译驱动程序调用的。它们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,而链接器用来悄悄地解析这些多重定义的规则可能在用户程序中引入微妙的错误。

多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器通过从左到右的顺序扫描来解析符号引用,这是另一个引起令人迷惑的链接时错误的来源。

加载器将可执行文件的内容映射到内存,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有对定义在共享库中的例程和数据的未解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务。

被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载、链接和访问共享库的函数和数据,应用程序也可以在运行时使用动态链接器。


参考

《深入理解计算机操作系统》第七章

【CSAPP-深入理解计算机系统】7-1. 编译器驱动程序

[原创] 深入理解计算机系统 - CSAPP重点导读(更新完毕)

动态链接与位置无关代码

聊聊Linux动态链接中的PLT和GOT(1)——何谓PLT与GOT

入门科普推荐:

隐藏的细节:编译与链接

【底层】动态链接库(dll)是如何工作的?

软件构建: CMake 快速入门

深入研究推荐:

《程序员的自我修养–链接装载与库》

Logo

瓜分20万奖金 获得内推名额 丰厚实物奖励 易参与易上手

更多推荐