前言

当我们点击Xcode的运行按钮时,你会注意到在界面顶端的提示栏上会出现“Building”的字样,紧接着会出现“Linking”的字样,我们知道Building是编译过程,那这个Linking(链接)是什么过程呢?本文将对链接过程做一个讲解,了解链接的过程,可以帮助你理解计算机系统的底层原理,并解答你平时关于计算机怎样识别并执行程序的一些疑惑。另外,本文也是后续篇章的基础,我们会由链接的知识延展出Mach-O文件、fishhook原理以及hook objc_msgSend的知识讲解。

链接的基本概念

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

链接可以执行与编译时(complie time),也就是源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(load-er)加载到内存并执行时;甚至可以执行在运行时(run time),也就是由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做连接器(linker)的程序自动执行的。

链接的作用

链接器使分离编译成为可能,我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。

下面的讨论基于这样的环境:一个运行Linux的x86-64系统,使用标准的ELF-64目标文件格式。

编译器驱动程序

下面的C语言示例程序,由两个源文件组成,main.c和sum.c。main函数初始化一个整数数组,然后调用sum函数来对数组元素求和。

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

大多数的编译系统会提供编译器驱动程序(compile driver),包含语言预处理器、编译器、汇编器和链接器。首先编译器驱动程序会对main.c与sum.c文件的源代码进行翻译,翻译过程如下:

计算机的十大冷知识(计算机大佬让你彻底了解)(1)

image

其中,main.o称为可重定位目标文件。

之后,编译系统会运行链接器ld,将main.o和sum.o以及一些必要的系统目标文件组合起来,创建一个可以执行目标文件,这个过程是静态链接,过程如下:

计算机的十大冷知识(计算机大佬让你彻底了解)(2)

image

再之后,操作系统会调用加载器(loader),将可执行文件prog中的代码和数据复制到内存中,然后执行。

静态链接

静态链接器(static linker)以一组可重定位目标文件作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件。输入的可重定位目标文件由各种不同的代码和数据节(section)组成,每一节都是一个连续的字节序列。指令在一节中,初始化了的全局变量在另一个节中,而未初始化的变量又在另外一节中。

为了构造可执行文件,链接器必须完成两个重要的任务:

目标文件纯粹是字节块的集合,这些块中,有些包含程序代码,有些包含数据,而有些则是引导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。

目标文件

目标文件有三种形式:

目标文件的生成方式:

目标文件的格式:

可重定位目标文件

计算机的十大冷知识(计算机大佬让你彻底了解)(3)

image.png

下上展示了一个典型的ELF可重定位目标文件的格式。ELF头包含很多信息,包括生成该文件的系统的字节大小,字节顺序,ELF头的大小,目标文件的类型,机器类型等等。节头部表描述了不同节的位置和大小。

加载ELF头和节头部表的是节:

符号和符号表

每个可重定位目标模块(目标文件)m都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:

.symtab中的符号表不包含非静态程序变量的任何符号,这些程序变量符号在栈中被管理,链接器对此类符号不感兴趣。

如何解析多重定义的全局符号

链接器的输入是一组可重定位目标模块。每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块也可见)。如果多个模块定义同名的全局符号,会发生什么呢?下面是 Linux编译系统采用的方法。

在编译时,编译器向汇编器输出每个全局符号,或者是强( strong)或者是弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。

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

静态库

迄今为止,我们都是假设链接器读取一组可重定位目标文件,并把它们链接起来,输出一个可执行目标文件。实际上,所有的编译系统都提供一种机制,将所有相关的目标模块打包成一个单独的文件,称为静态库。静态库可以用做链接器的输入,当链接器构造一个输出的可执行目标文件时,它只复制静态库里被应用程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小。在Linux系统中,静态库由后缀.a标识。

重定位

一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:

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

可执行目标文件 与 加载可执行目标文件

见《深入理解计算机系统》

动态链接共享库

静态库由一些缺点:静态库需要定期维护和更新;每个程序都会使用一些通用的标准函数,在运行时,这些函数的代码会被复制到每个运行进程的文本段中,在一个运行上百个进行的典型系统上,这是对内存资源的浪费。

共享库(shared library)是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器(dynamic linker)的程序来执行的。在Linux系统中,共享库通常由.so后缀标识。

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

计算机的十大冷知识(计算机大佬让你彻底了解)(4)

image.png

,