彻底理解链接器

一、前言/概念

本文源自 : 公众号码农的荒岛求生.原文分为六个部分,看着不过瘾,现在全文照搬整合到一块看着方便。
在介绍本章的主题之前,我们先来看几个问题:

问题一

  写C/C++的同学应该经常遇到这样的一个Error:

“undefined reference to ABC”

  在遇到这样的问题时你知道这背后到底哪里出问题了吗? 你通常都能顺利解决类似问题吗?

问题二

  作为世界上最大的同性交友网站GitHub,里面有很多很棒的项目,一般我们或者直接下载其发布版(release version),或者下载源码自己编译,不管是直接下载发布版还是自己编译,最终都会得到一个(或几个)以.so或者.a为结尾的文件(Windows下为DLL文件或者lib文件),这时你知道该怎么把这些.so或者.a文件引入你自己的项目吗?当然如果你去搜索一下也能得到答案,但是你知道这些答案背后的原理吗?

问题三

  你的同学、同事在工作学习中可能不时就会提及到静态链接库动态链接库静态链接动态链接,每次听到这些词汇的时候在你脑海里,A)对此有很清晰的认知;B)一头雾水不知道他们在说些什么,你属于A还是B?

  如果你还不能很好的解决上面前两个问题且对于问题三属于B,那么接下来你就要好好看这篇文章啦,解决这几个问题的关键就是这篇文章要介绍的链接器(Linker),虽然现代的集成开发环境IDE比如Visual Studio已经对程序员屏蔽了大部分链接器的工作,但理解链接器将极大提高你对工程的驾驭能力,也许你现在还不是很清楚,读完这篇文章你就能明白啦。

1.1 什么是链接器(Linker)

引用维基百科中对链接器的定义:

a linker or link editor is a computer utility program that takes one or more object files generated by a compiler and combines them into a single executable file, library file, or another 'object' file.

翻译如下:
  链接器是一个将编译器产生的目标文件打包成可执行文件或者库文件或者目标文件的程序。
  这个翻译比较拗口,不太好理解,这句话的意思具体如下:

  • 首先是链接器的本质链接器本质上也是一个程序,本质上和我们经常使用的普通程序没什么不同。
  • 其次是链接器的输入,我们经常使用的程序比如播放器,其输入是一个MP4文件,而链接器的输入是编译器编译好的目标文件(object file,如果你不理解什么是目标文件,请参考之前的文章《不简单的hello world之C标准库》)。
  • 最后是链接器的输出链接器在将目标文件打包处理后,生成或者可执行文件,或者库,或者目标文件

  从这个定义中能够看出,链接器的作用有点类似于我们经常使用的压缩软WinRAR(Linux下是tar),压缩软件将一堆文件打包压缩成一个压缩文件,而链接器和压缩软件的区别在于链接器是将多个目标文件打包成一个文件而不进行压缩。那么链接器到底是如何工作的呢,我们接着往下看。

1.2 链接器可操作的元素

  链接器可操作的最小元素是一个简单的目标文件,通常我们写的.c源文件编译后就生成了对应的目标文件,我们写的实现文件比如list.c编译后就生成了对应的目标文件list.o(Windows下为list.obj),这个list.o就是链接器可以操作的最小元素。我们见到的所有应用程序,小到自己实现的hello world程序,大到复杂的比如浏览器,网络服务器等,都是链接器将一个个所需要用到的目标文件汇集起来最终形成了非常复杂的应用程序(Windows下是我们常见的EXE文件,Linux下为ELF文件)。

  我们可以把最终的应用程序想象成一座房子,构建房子的最基本的原材料就是砖,房子中各个模块像墙面,地面,屋顶等都是由一块块砖构筑成的。而这里的目标文件就好比构建房子时最基本的砖。房子的各个模块就好比我们是用的静态库,动态库。无论多么复杂庞大的应用程序,对于链接器来说最基本的构建材料都是目标文件。链接器可以将目标文件链接器成为各种库以方便使用,然后链接器将目标文件以及程序依赖的各种库再次链接从而形成最终的可执行文件。

二、链接器是如何工作的

  在链接器可操作的元素一节中我们提到,所有的应用程序都是链接器将所需要的一个个简单的目标文件汇集起来形成的。你可以将这个过程想象成拼图游戏,每个拼块就是一个简单的目标文件:

  •   拼图游戏当中的每个拼块都依赖于其它拼块提供的拼接口,这就好比我们写的程序模块依赖于其它模块提供的编程接口,比如我们在list.c中实现了一种特定的链表数据结构,其它模块需要使用这种链表,这就是模块间的依赖。而链接器其中一项任务就是要确保提供给链接器进行链接的目标文件集合之间依赖是成立的(也就是说,不会出现在被依赖的模块中链接器找不到需要的接口),这就是后面我们要讲到的符号决议(Symbol Resolution)

在这里插入图片描述

  •   我们在拼图游戏当中通常都是将一整幅图按组成部位一部分一部分拼接好,然后将这些比较完整的大的组成部分拼接成最后一整副图。这就好比链接器会首先将程序每个模块当中目标文件集合链接成库,然后再将各个库进行链接最终形成可执行程序。这就是后面我们要讲到的可执行程序的生成。

  •   链接器还有一项任务是无法用这个拼图游戏来类比的,但是这项重要的任务对程序员不可见,作为程序员几乎不会在这个过程遇到问题,这项任务就是重定位

  • 通过拼图这个游戏的类比,我们给出链接器的工作过程:

  • 首先, 链接器对给定的目标文件或库的集合进行符号决议以确保模块间的依赖是正确的。
  • 其次, 链接器将给定的目标文件集合进行拼接打包成需要的库或最终可执行文件。
  • 最后, 链接器对链接好的库或可执行文件进行重定位。

接下来我们详细的讲解下每一个过程。

2.1 过程一:符号决议

  在这个过程当中,链接器需要做的工作就是确保所有目标文件中的符号引用都有唯一的定义。要想理解这句话我们首先来看看一个典型的c文件里都有些什么。

2.1.1 c源文件中都有什么

在这里插入图片描述
如图所示是一个典型的c源文件,该文件中的变量可以划分为两类:

  • 全局变量:
      比如x_global_uninit,x_global_init,fn_c。只要程序没有结束运行,全局变量都可以随时使用。注意,用static修饰的全局变量比如y_global_uninit,其生命周期也等同于程序的运行周期,只是这种全局变量只能在所被定义的文件当中使用,对其它文件不可见。
  • 局部变量:
      比如y_local_uninit,y_local_init,局部局部变量的生命周期和全局变量不同,局部变量变量只能在相应的函数内部使用,当函数调用完成后该函数中的局部变量也就无法使用了。因为局部变量只存在于函数运行时的栈帧当中,函数调用完成后相应的栈帧被自动回收(如果你还不能理解这句话是什么意思没有关系,我会在后面的文章当中详细讲解程序运行时的内存模型)。

2.1.2 目标文件里有什么

  编译器的任务就是把人类可以理解的代码转换成机器可以执行的机器指令,源文件编译后形成对应的目标文件,这个我们在之前的章节中已经多次提到过了。
源文件被编译后生成的目标文件中本质上只有两部分:

  • 代码部分:
      你可能会想,一个源文件中不都是代码吗,这里的代码指的是计算机可以执行的机器指令,也就是源文件中定义的所有函数。比如上图中定义的函数fn_b以及fn_c。
  • 数据部分:
      源文件中定义的全局变量。如果是已经初始化后的全局变量,该全局变量的值也存在于数据部分。

  到目前为止,你可以把一个目标文件简单的理解为由两部分组成,代码部分中保存的是CPU可以执行的机器指令,这些机器指令来自程序员所定义的函数,编译器将这些定义的函数翻译成机器指令并存放在目标文件的代码部分。数据部分存放的是机器指令所操作的数据
  因此目前,你可以简单的将目标文件理解为一个只有两部分的文件,如图所示:

在这里插入图片描述
  你可能会好奇函数中定义的局部变量为什么没有放到目标文件的数据段当中,这是因为局部变量是函数私有的,局部变量只能在该函数内部使用,而全局变量是没有这个限制的,所以函数私有的局部变量被放在了代码段中,作为机器指令的操作数。

  编译器在编译过程中遇到外部定义的全局变量或函数时,只要编译器能找到相应的变量声明就会在心里默念“all is well, all is well(一切顺利)“,从这里可以看出编译器的要求还是很低的,至于所使用变量的定义编译器是不会费力去四处搜索,而是愉快的继续接下来的编译。注意,这里再次强调一下,编译器在遇到外部定义的全局变量或者函数时只要能在当前文件找到其声明,编译器就认为编译正确。而寻找使用变量定义的这项任务就被留给了链接器。链接器的其中一项任务就是要确定所使用的变量要有其唯一的定义。虽然编译器给链接器留了一项任务,但为了让链接器工作的轻松一点编译器还是多做了一点工作的,这部分工作就是符号表(Symbol table)

2.1.3 符号表(Symbol table)

  我们在上一节中提到,虽然编译器很不厚道的给链接器留了一项任务,但是编译器为了链接器工作的轻松一点还是做了一点事情,这就是符号表。那符号表中保存的是什么呢,
符号表中保存的信息有两部分

  • 该目标文件中引用的全局变量以及函数
  • 该目标文件中定义的全局变量以及函数

  以上图中的代码为例,编译器在编译过程中每次遇到一个全局变量或者函数名都会在符号表中添加一项,最终编译器会统计出如下所示的一张符号表:

名字类型是否可被外部引用区域
z_global引用,未定义//
fn_a引用,未定义//
fn_b定义代码段
fn_c定义代码段
x_global_init定义数据段
y_global_uninit定义数据段
x_global_uninit定义数据段
y_global_init定义数据段

  fn_b以及fn_c为当前文件定义的函数,因为在代码段。
  剩余的符号都是全局变量,因此放在了数据段。

  有同学可能会问,为什么全局变量y_global_uninit ,y_global_init以及函数fn_b不可被其它目标文件引用?
  这是因为这些变量用static修饰过了,在C语言中经static修饰过的函数的函数以及变量都是当前文件私有的,对外部不可见,这里一定要注意。
  所以static这个关键字的用法就是,如果你认为一个变量只应该被当前文件使用而不暴露给外部,那么你就可以使用static关键字修饰一下。

本质上整个符号表只是想表达两件事:

  • 我能提供给其它文件使用的符号
  • 需要其它文件提供给我使用的符号

这里还有一个问题就是,编译器将统计的这张符号表放在哪里了呢?

2.1.4 符号表存放在哪里

  在目标文件里有什么这一小节中,我们将一个目标文件简单的划分了两段,数据段和代码段,现在我们要向目标文件中再添加一段,而符号表也被编译器很贴心的放在目标文件中,因此一个目标文件可以理解为如图所示的三段,而符号表中的内容就是上一节当中编译器统计的表格。
在这里插入图片描述
有了符号表,链接器就可以进行符号决议了。

2.1.5 符号决议的过程

  在上一节符号表中,我们知道符号表给链接器提供了两种信息,一个是当前目标文件可以提供给其它目标文件使用的符号,另一个其它目标文件需要提供给当前目标文件使用的符号
  有了这些信息链接器就可以进行符号决议了。
在这里插入图片描述

  如图所示,假设链接器需要链接三个目标文件:
  链接器会依次扫描每一个给定的目标文件,同时链接器还维护了两个集合:

  • 一个是已定义符号集合D,
  • 另一个是未定义符合集合U,

下面是链接器进行符合决议的过程:

  • 1.对于当前目标文件,查找其符号表,并将已定义的符号添加到已定义符号集合D中
  • 2.对于当前目标文件,查找其符号表,将每一个当前目标文件引用的符号与已定义符号集合D进行对比,如果该符号不在集合D中则将其添加到未定义符合集合U中。
  • 3.当所有文件都扫描完成后,如果未定义符号集合U不为空,则说明当前输入的目标文件集合中有未定义错误,链接器报错,整个编译过程终止。

  上面的过程看似复杂,其实用一句话概括就是:
只要每个目标文件所引用变量都能在其它目标文件中找到唯一的定义,整个链接过程就是正确的

  如果你觉得上面的解释比较晦涩的话,你也可以将链接符号决议这个过程想象成如下的游戏:
  新学期开学后,幼儿园的小朋友们都带了礼物要和其它的小朋友们分享,同时每个小朋友也有自己的心愿单,每个小朋友都可以依照自己的心愿单去其它的小朋友那里拿礼物,整个过程结束后,每个小朋友都能拿到自己想要的礼物。
  在这个游戏当中,小朋友就好比目标文件,每个小朋友自己带的礼物就好比每个目标文件的已定义符号集合,心愿单就好比每个目标文件中未定义符号的集合。

2.1.6 实例说明undefined reference

  假设我们写了一个math.c的数字计算程序,其中定义了一个add函数,该函数在main.c中被引用到,那么很简单,我们只需要在main.c中include写好的math.h头文件就可以使用add函数了,如图所示:
在这里插入图片描述
  但是由于粗心大意,一不小心把math.c中的add函数给注释掉了,当你在写完main.c、打算很潇洒的编译一下时,出现了很经典的undefined reference to add(int, int)错误,如图所示 :
在这里插入图片描述
  这个错误其实是这样产生的:

1, 链接器发现了你写的代码math.o中引用了外部定义的add函数(不要忘了,这是通过检查目标文件math.o中的符号表得到的信息),所以链接器开始查找add函数到底是在哪里定义的。
2,链接器转而去目标文件math.o的目标文件符号表中查找,没有找到add函数的定义。
3,链接器转而去其它目标文件符号表中查找,同样没有找到add函数的定义。
4,链接器在查找了所有目标文件的符号表后都没有找到add函数,因此链接器停止工作并报出错误undefined reference to `add(int, int)’,如上图所示。

  因此如果你很清楚链接器符号决议这个过程的话就会进行如下排查:

1:main.c中对add函数的函数名有没有写正确。
2:链接命令中有没有包含math.o,如果没有添加上该目标文件。
3:如果链接命令没有问题,查看math.c中定义的add函数定义是否有问题。
4:如果是C和C++混合编程时,确保相应的位置添加了extern “C”。

  一般情况下,经过这几个步骤的排查基本能够解决问题。

  所以当你再次看到undefined reference这样的错误的时候,你就应该可以很从容的去解决这类问题了。

2.2 过程二:库、可执行文件的生成

  在链接器可操作的元素这一节中我们提到,链接器可以操作的最小单元为目标文件,也就是说我们见到的无论是静态库、动态库、可执行文件,都是基于目标文件构建出来的。目标文件就好比乐高积木中最小的零部件。

  给定目标文件以及链接选项,链接器可以生成两种库,分别是静态库以及动态库,如图所示,给定同样的目标文件,链接器可以生成两种不同类型的库,接下来我们分别介绍。
在这里插入图片描述

2.2.1 静态库

  假设这样一个应用场景,基础设计团队设计了好多实用并且功能强大的工具函数,业务团队需要用到里面的各种函数。每次新添加其中一个函数,业务团队都要去找相应的实现文件并修改链接选项。使用静态库就可以解决这个问题。静态库在Windows下是以.lib为后缀的文件,Linux下是以.a为后缀的文件

  为解决上述问题,基础设计团队可以提前将工具函数集合打包编译链接成为静态库提供给业务团队使用,业务团队在使用时只要链接该静态库就可以了,每次新使用一个工具函数的时候,只要该函数在此静态库中就无需进行任何修改。

  你可以简单的将静态库理解为由一堆目标文件打包而成, 使用者只需要使用其中的函数而无需关注该函数来自哪个目标文件(找到函数实现所在的目标文件是链接器来完成的,从这里也可以看出,不是所有静态库中的目标文件都会用到,而是用到哪个链接器就链接哪个)。静态库极大方便了对其它团队所写代码的使用。

2.2.1.1 静态链接

  静态库是链接器通过静态链接将其和其它目标文件合并生成可执行文件的,如下图1所示,而静态库只不过是将多个目标文件进行了打包,在链接时只取静态库中所用到的目标文件,因此,你可以将静态链接想象成如下图2所示的过程。
图1
图1
图2
  静态库是使用库的最简单的方法,如果你想使用别人的代码,找到这些代码的静态库并简单的和你的程序链接就可以了。静态链接生成的可执行文件在运行时不依赖任何其它代码,要理解这句话,我们需要知道静态链接下,可执行文件是如何生成的。

2.2.1.2 静态链接下可执行文件的生成

  在上一节中我们知道,可以将静态链接简单的理解为链接器将使用到的目标文件集合进行拼装,拼装之后就生成了可执行文件,同时我们在目标文件里有什么这一节中知道,目标文件分成了三段:代码段、数据段和符号表,那么在静态链接下可执行文件的生成过程如图所示:
在这里插入图片描述
  从上图中我们可以看到可执行文件的特点

  • 可执行文件和目标文件一样,也是由代码段和数据段组成
  • 每个目标文件中的数据段都合并到了可执行文件的数据段,每个目标文件当中的代码段都合并到了可执行文件的代码段。
  • 目标文件当中的符号表并没有合并到可执行文件当中,因为可执行文件不需要这些字段。

  可执行文件和目标文件没有什么本质的不同,可执行文件区别于目标文件的地方在于,可执行文件有一个入口函数,这个函数也就是我们在C语言当中定义的main函数,main函数在执行过程中会用到所有可执行文件当中的代码和数据。而这个main函数是被谁调用执行的呢,答案就是操作系统(Operating System),这也是后面文章当中要重点介绍的内容。

  现在你应该对可执行文件有一个比较形象的认知了吧。你可以把可执行文件生成的过程想象成装订一本书,一本书中通常有好多章节,这些章节是你自己写的,且一本书不可避免的要引用其它著作。静态链接这个过程就好比不但要装订你自己写的文章,而且也把你引用的其它人的著作也直接装订进了你的书里,这里不考虑版权问题 😃,这些工作完成后,只需要按一下订书器,一本书就制作完成啦。

  在这个比喻中,你写的各个章节就好比你写的代码,引用的其它人的著作就好比使用其它人的静态库,装订成一本书就好比可执行文件的生成。

  静态链接是使用库的最简单最直观的形式, 从静态链接生成可执行文件的过程中可以看到,静态链接会将用到的目标文件直接合并到可执行文件当中,想象一下,如果有这样的一种静态库,几乎所有的程序都要使用到,也就是说,生成的所有可执行文件当中都有一份一模一样的代码和数据,这将是对硬盘和内存的极大浪费,假设一个静态库为2M,那么500个可执行文件就有1G的数据是重复的。如何解决这个问题呢,答案就是使用动态库

2.2.2 动态库

  在前三小节中我们了解了静态库、静态链接以及使用静态链接下可执行文件是如何生成的。接下里我们讲解一下动态库,那么什么是动态库?

  动态库(Dynamic Library),又叫共享库(Shared Library),动态链接库等,在Windows下就是我们常见的大名鼎鼎的DLL文件了,Windows系统下大量使用了动态库。在Linux下动态库是以.so为后缀的文件,同时以lib为前缀,比如进行数字计算的动态库Math,编译链接后产生的动态库就叫做libMath.so。从名字中我们知道动态库也是库本质上动态库同样包含我们已经熟悉的代码段、数据段、符号表。只不过动态库的使用方式以及使用时间和静态库不太一样。

  在前面几个小节中我们知道,使用静态库时,静态库的代码段和数据段都会直接打包copy到可执行文件当中,使用静态库无疑会增大可执行文件的大小,同时如果程序都需要某种类型的静态库,比如libc,使用静态链接的话,每个可执行文件当中都会有一份同样的libc代码和数据的拷贝,如图所示,动态库的出现解决了此类问题。
在这里插入图片描述
  动态库允许使用该库的可执行文件仅仅包含对动态库的引用而无需将该库拷贝到可执行文件当中。也就是说,同静态库进行整体拷贝的方式不同,对于动态库的使用仅仅需要可执行文件当中包含必要的信息即可,为了方便理解,你可以将可执行文件当中保存的必要信息仅仅理解为需要记录动态库的名字就可以了,如图所示,同静态库相比,动态库的使用减少了可执行文件的大小。
在这里插入图片描述
  从上面这张图中可以看出,动态库的使用解决了静态链接当中可执行文件过大的问题。

  我们在前几节中将静态链接生成可执行文件的过程比作了装订一本书,静态链接将引用的其它人的著作也装订到了书里,而动态链接可以想象成作者仅仅在引用的地方写了一句话,比如引用了《码农的荒岛求生》,那么作者就在引用的地方写上“此处参考《码农的荒岛求生》”,那么读者在读到这里的时候会自己去找到码农的荒岛求生这本书并查找相应的内容,其实这个过程就是动态链接的基本思想了。

  到这里我们就可以回答之前提到过的问题了,helloworld程序中的printf函数到底是在哪里定义的,答案就是该函数是在libc.so当中定义的,Linux下编译链接生成可执行文件时会默认动态链接libc.so(Windows下也是同样的道理),使用ldd命令就会发现每个可执行文件都依赖libc.so。因此虽然你从没有看到过printf的定义也可以正确的使用这个函数。

接下来我们讲解一下动态链接。

2.2.1.1 动态链接

  我们知道静态库在编译链接期间就被打包copy到了可执行文件,也就是说**静态库其实是在编译期间(Compile time)链接使用的,那么动态库又是在什么时候才链接使用的呢,动态链接可以在两种情况下被链接使用,分别是load-time dynamic linking(加载时动态链接) 以及 run-time dynamic linking(运行时动态链接)**,接下来我们分别讲解一下。

2.2.1.1.1 load-time dynamic linking(加载时动态链接)

  首先可能有的同学会问,什么是load-time呢,load_time翻译过来也就是加载时,那么什么又是加载呢?
  我们大家都玩过游戏,当我们打开游戏的时候经常会跳出来一句话:“加载中,请稍后。。。”和这里的加载意思差不多。这里的加载指的是程序的加载,而所谓程序的加载就是把可执行文件从磁盘搬到内存的过程,因为程序最终都是在内存中被执行的
  至于这个过程的详解内容我会在接下来的文章《加载器与可执行文件》一文中给大家详细讲解。在这里我们只需要简单的把加载理解为程序从磁盘复制到内存的过程,加载时动态链接就出现在这个过程。

  当把可执行文件复制到内存后,且在程序开始运行之前,操作系统会查找可执行文件依赖的动态库信息(主要是动态库的名字以及存放路径),找到该动态库后就将该动态库从磁盘搬到内存,并进行符号决议(关于符号决议,参考符号决议一节),如果这个过程没有问题,那么一切准备工作就绪,程序就可以开始执行了,如果找不到相应的动态库或者符号决议失败,那么会有相应的错误信息报告为用户,程序运行失败。比如Windows下比较常见的启动错误问题,就是因为没有找到依赖的动态库。Linux下同样会有类似信息提示用户程序启动失败。
在这里插入图片描述
  到这里,同学们应该对加载时动态链接应该有一个比较清晰的了解了。
从总体上看,加载时动态链接可以分为两个阶段:阶段一,将动态库信息写入可执行文件;阶段二,加载可执行文件时依据动态库信息进行动态链接

  • 阶段一,将动态库信息写入可执行文件
      在编译链接生成可执行文件时,需要将使用的动态库加入到链接选项当中,比如在Linux下引用libMath.so,就需要将libMath.so加入到链接选项当中(比如libMath.so放到了/usr/lib下,那么使用命令 gcc … -lMath -L/user/lib … 进行编译链接),所以使用这种方式生成的可执行文件中保存了依赖的动态库信息,在Linux可使用一个简单的命令ldd来查看。
  • 阶段二:加载可执行文件时依据动态库信息进行动态链接
      由于在阶段一生成的可执行文件中保存了动态库信息,当可执行文件加载完成后,就可以依据此信息进行中动态库的查找以及符号决议了。

  通过这个过程也可以清楚的看到静态库和动态库的区别,使用动态库的可执行文件当中仅仅保留相应信息,动态库的链接过程被推迟到了程序启动加载时

  为加深你对加载时动态链接这个过程的理解,我们用一个类比来结束本小节,沿用前几节读书的例子,我们正在读的书中引用了《码农的荒岛求生》以及其它著作,那么加载时动态链接就好比,读者开始准备读这本书的时候(还没有真正的读)就把所有该书当中引用的资料著作都找齐放到一旁准备查看,当我们真正看到引用其它文献的地方时就可以直接在一旁找到该著作啦。在这个类比当中,开始读书前的准备工作就好比加载时动态链接。

2.2.1.1.2 run-time dynamic linking(运行时动态链接)

  接下来我们讲解第二种动态链接,run-time dynamic linking(运行时动态链接) 。
  上一小节中我们看到如果我们想使用加载时动态链接,那么在编译链接生成可执行文件阶段时需要告诉编译器所依赖的动态库信息,而run-time dynamic linking 运行时动态链接则不需要在编译链接时提供动态库信息,也就是说,在可执行文件被启动运行之前,可执行文件对所依赖的动态库信息一无所知,只有当程序运行到需要调用动态库所提供的代码时才会启动动态链接过程
  我们在上一节中介绍了load-time,也就是程序加载时,那么程序加载完成后就开始程序执行了,那么所谓run-time(运行时)指的就是从程序开始被CPU执行到程序执行完成退出的这段时间。
  所以运行时动态链接这种方式对于“动态链接”阐释的更加淋漓尽致,因为可执行文件在启动运行之前都不知道需要依赖哪些动态库,只在运行时根据代码的需要再进行动态链接。同加载时动态链接相比,运行时动态链接将链接这个过程再次推迟往后推迟,推迟到了程序运行时。

  由于在编译链接生成可执行文件的过程中没有提供所依赖的动态库信息,因此这项任务就留给了程序员,在代码当中如果需要使用某个动态库所提供的函数,我们可以使用特定的API来运行时加载动态库,在Windows下通过LoadLibrary或者LoadLibraryEx,在Linux下通过使用dlopen、dlsym、dlclose这样一组函数在运行时链接动态库。当这些API被调用后,同样是首先去找这些动态库,将其从磁盘copy到内存,然后查找程序依赖的函数是否在动态库中定义。这些过程完成后动态库中的代码就可以被正常使用了。

  相对于加载时动态链接,运行时动态链接更加灵活,同时将动态链接过程推迟到运行时可以加快程序的启动速度。

  为了和加载时动态链接作比对,我们继续使用上一小节当中读书的例子,加载时动态链接就好比在开始准备读一本书之前,将该书中所有引用到的资料文献找齐全,而运行时动态链接则不需要这个过程,运行时动态链接就好比直接拿起一本书开始看,看到有引用的参考文献时再去找该资料,找到后查看该文献然后继续读我们的书。从这个例子当中运行时动态链接更像是我们平时读书时的样子。

  至此,两种动态链接的形式我们就都已经清楚了,接下来我们看一下动态链接下生成的可执行文件。

2.2.1.2 动态链接下可执行文件的生成

  在静态链接下,链接器通过将各个目标文件的代码段和数据段合并拷贝到可执行文件,因此静态链接下可执行文件当中包含了所依赖的所有代码和数据,而与之对比的动态链接下可执行文件又是什么样的呢?

  其实我们在动态库这一节中已经了解了动态链接下可执行文件的生成,即:在动态链接下,链接器并不是将动态库中的代码和数据拷贝到可执行文件中,而是将动态库的必要信息写入了可执行文件,这样当可执行文件在加载时就可以根据此信息进行动态链接了。为方便理解,我们将该信息仅仅认为是动态库的名字,真实情况当然要更复杂一点,这里我们以Linux下可执行文件即ELF文件为例(这一系列的文章重点关注最本质的原理思想,所以这里讨论的同样适合Windows下的可执行文件即exe文件)。

  在前几节中我们将可执行文件简单的划分为了两段,数据段和代码段,在这里我们继续丰富可执行文件中的内容,如图所示,在动态链接下,可执行文件当中会新增两段,即dynamic段以及GOT(Global offset table)段,这两段内容就是是我们之前所说的必要信息
在这里插入图片描述
  dynamic段中保存了可执行文件依赖哪些动态库,动态链接符号表的位置以及重定位表的位置等信息。关于dynamic以及GOT段的作用限于篇幅就不重点阐述了。

  当加载可执行文件时,操作系统根据dynamic段中的信息即可找到使用的动态库,从而完成动态链接

  这里需要强调一点,在编译链接过程中,可以同时使用动态库以及静态库。这两种库的使用并不冲突,那么在这种情况下生成的可执行文件中,可执行文件中包含了静态库的数据和代码,以及动态库的必要信息。

  至此,关于静态库,静态链接,动态库,动态链接就讲述到这,那么接下来的问题就是静态库和动态库都有什么样的优缺点。

2.2.3 动态库vs静态库

2.2.3.1 动态库的优点

  在计算机的历史当中,最开始程序只能静态链接,但是人们很快发现,静态链接生成的可执行文件存在磁盘空间浪费问题,因为对于每个程序都需要依赖的libc库,在静态链接下每个可执行文件当中都有一份libc代码和数据的拷贝,为解决该问题才提出动态库。

  在前几节我们知道,动态链接下可执行文件当中仅仅保留动态库的必要信息,因此解决了静态链接下磁盘浪费问题。动态库的强大之处不仅仅于此,我们知道对于现代计算机系统,比如PC,通常会运行成百上千个程序(进程),且程序只有被加载到内存中才可以使用,如果使用静态链接那么在内存中就会有成百上千份同样的libc代码,这对于宝贵的内存资源同样是极大的浪费,而使用动态链接,内存中只需要有一份libc代码,所有的程序(进程)共享这一份代码,因此极大的节省了内存资源,这也是为什么动态库又叫共享库

  动态库还有另外一个强大之处,那就是如果我们修改了动态库的代码,我们只需要重新编译动态库就可以了而无需重新新编译我们自己的程序因为可执行文件当中仅仅保留了动态库的必要信息,重新编译动态库后这些必要都信息是不会改变的(只要不修改动态库的名字和动态库导出的供可执行文件使用的函数),编译好新的动态库后只需要简单的替换原有动态库,下一次运行程序时就可以使用新的动态库了,因此动态库的这种特性极大的方便了程序升级和bug修复。我们平时使用都客户端程序,比如我们常用QQ,输入法,播放器,都利用了动态库的这一优点,原因就在于方便升级以bug修复,只需要更新相应的动态库就可以了。

  动态库的优点不止于此,我们知道动态链接可以出现在运行时(run-time dynamic link),动态链接的这种特性可以用于扩展程序能力,那么如何扩展呢?你肯定听说过一样神器,没错,就是插件。你有没有想过插件是怎么实现的?实现插件时,我们只需要实现几个规定好的几个函数,我们的插件就可以运行了,可这是怎么做到的呢,答案就在于运行时动态链接,可以将插件以动态的都方式实现。我们知道使用运行时动态链接无需在编译链接期间告诉链接器所使用的动态库信息,可执行文件对此一无所知,只有当运行时才知道使用什么动态库,以及使用了动态库中哪些函数,但是在编译链接可执行文件时又怎么知道插件中定义了哪些函数呢,因此所有的插件实现函数必须都有一个统一的格式,程序在运行时需要加载所有插件(动态库),然后调用所有插件的入口函数(统一的格式),这样我们写的插件就可以被执行起来了

  动态库都强大优势还体现在多语言编程上。我们知道使用Python可以快速进行开发,但Python的性能无法同C/C++相比(因为Python是解释型语言,至于什么是解释型语言我会在后面码农的荒岛求生系列文章当中给大家详细讲解),有没有办法可以兼具Python的快速开发能力以及C/C++的高性能呢,答案是可以的,我们可以将C/C++代码编译链接成动态库,这样python就可以直接调用动态库中的函数了。不但Python,Perl以及Java等都可以通过动态库的形式调用C/C++代码。动态库的使用使得同一个项目不同语言混合编程成为可能,而且动态库的使用更大限度的实现了代码复用。

2.2.3.1 动态库的缺点

  了解了动态库的这么多优点,那么动态库就没有缺点吗,当然是有的。

  首先由于动态库是程序加载时或运行是才进行链接的,因此同静态链接相比,使用动态链接的程序在性能上要稍弱于静态链接,这时因为对于加载时动态链接,这无疑会减慢程序的启动速度,而对于运行时链接,当首次调用到动态库的函数时,程序会被暂停,当链接过程结束后才可以继续进行
  且动态库中的代码是地址无关代码(Position-Idependent Code,PIC),之所以动态库中的代码是地址无关代码是因为动态库又被成为共享库,所有的程序都可以调用动态库中的代码,因此在使用动态库中的代码时程序要多做一些工作,这里我们不再具体展开讲解到底程序多做了哪些工作,对此感兴趣当同学可以参考CSAPP(深入理解计算机系统)。这里我们说动态链接的程序性能相比静态链接稍弱,但是这里的性能损失是微乎其微的,同动态库可以带来的好处相比,我们可以完全忽略这里的性能损失,同学们可以放心的使用动态库。

  动态库的一个优点其实也是它的缺点,即动态链接下的可执行文件不可以被独立运行(这里讨论的是加载时动态链接,load-time dynamic link),换句话说就是,如果没有提供所依赖的动态库或者所提供的动态库版本和可执行文件所依赖的不兼容,程序是无法启动的。动态库的依赖问题会给程序的安装部署带来麻烦,在Linux环境下尤其严重,以笔者曾参与开发维护的一个虚拟桌面系统为例,我们在开发过程中依赖的一些比较有名的第三方库默认不会随着安装包发布,这就会导致用户在较低版本Linux中安装时经常会出现程序无法启动的问题,原因就在于我们编译链接使用都动态库和用户Linux系统中都动态库不兼容。解决这个问题的方法通常有两种,一个是用户升级系统中都动态库,另一个是我们讲需要都第三方库随安装包一起发布,当然这是在取得许可的情况下。

  在了解了动态库的优缺点后,接下来我们来看一下静态库。

2.2.3.1 静态库的优点

  静态链接是最古老也是最简单的链接技术。静态链接都最大优点就是使用简单,编译好的可执行文件是完备的,即静态链接下的可执行文件不需要依赖任何其它的库,因为静态链接下,链接器将所有依赖的代码和数据都写入到了最终的可执行文件当中,这就消除了动态链接下的库依赖问题,没有了库都依赖问题就意味着程序都安装部署都得到了极大都简化。
  请大家不要小看这一点,这对当今那些拥有海量用户的后端系统来说至关重要,比如类似微信这种量级的系统,其后端会部署在成千上万台机器上,这么多的机器其系统的安装部署以及升级会给运维带来极大挑战,而静态链接下的可执行文件由于不依赖任何库,因为部署非常方便,仅仅用一个新的可执行文件进行覆盖就可以了,因此极大的简化了系统部署以及升级。笔者之前所在的某电商广告后端系统就完全使用静态链接来简化部署升级。

2.2.3.1 动态库的缺点

  而静态库的缺点相信大家都已经清楚了,那就是静态链接会导致可执行文件过大,且多个程序静态链接同一个静态库的话会导致磁盘浪费的问题。

  到这里关于静态库和动态库的讨论就告一段落了,相信大家对于这两种链接类型都有了清晰都认知。接下来让我们稍作休息,开始链接器的下一个重要功能,重定位。

2.3 过程三:重定位

  程序的运行过程就是CPU不断的从内存中取出指令然后执行执行的过程,对于函数调用来说比如我们在C/C++语言中调用简单的加法函数add,其对应的汇编指令可能是这样的:

call 0x4004fd

  其中0x4004fd即为函数add在内存中的地址,当CPU执行这条语句的时候就会跳转到0x4004fd这个位置开始执行函数add对应的机器指令。

  再比如我们在C语言中对一个全局变量g_num不断加一来进行计数,其对应的汇编指令可能是这样的:

mov 0x400fda %eax
add $0x1 %eax

  这里的意思是把内存中 0x400fda 这个地址的数据放到寄存器当中,然后将寄存器中的数据加一,在这里g_num这个全局变量的内存地址就是0x400fda。

  好奇的同学可能会问,那这些函数以及数据的内存地址是怎么来的呢?

  确定程序运行时的内存地址就是接下来我们要讲解的重点内容,这里先给出答案,可执行文件中代码以及数据的运行时内存地址是链接器指定的,也就是上面示例中add的内存地址0x4004fd其是链接器指定的。确定程序运行时地址的过程就是这里重定位(Relocation)。

  为什么这个过程叫做重定位呢,之所以叫做重定位是因为确定可执行文件中代码和数据的运行时地址是分为两个阶段的,在第一个阶段中无法确定这些地址,只有在第二个阶段才可以确定,因此就叫做重定位。接下来让我们来看看这两个阶段,合并同类型段以及引用符号的重定位。

2.3.1 编译器的工作

  让我们回忆一下前几节的内容,源文件首先被编译器编译生成目标文件,目标文件种有三段内容:数据段、代码段以及符号表,所有的函数定义被放在了代码段,全局变量的定义放在了数据段,对外部变量的引用放到了符号表。

  编译器在将源文件编译生成目标文件时可以确定以下两件事:

  • 定义在该源文件中函数的内存地址
  • 定义在该源文件中全局变量的内存地址

  注意这里的内存地址其实只是相对地址,相对于谁的呢,相对于自己的。为什么只是一个相对地址呢?因为在生成一个目标文件时编译器并不知道这个目标文件要和哪些目标文件进行链接生成最后的可执行文件,而链接器是知道要链接哪些目标文件的。因此编译器仅仅生成一个相对地址。

  而对于引用类的变量,也就是在当前代码中引用而定义是在其它源文件中的变量,对于这样的变量编译器是无法确定其内存地址的,这不是编译器需要关心的,确定引用类变量的内存地址是链接器的任务,链接器在进行链接时能够确定这类变量的内存地址。因此当编译器在遇到这样的变量时,比如使用了外部定义的函数时,其在目标文件中对应的机器指令可能是这样的:

call 0x000000

  也就是说对于编译器不能确定的地址都设置为空(0x000000),同时编译器还会生成一条记录,该记录告诉链接器在进行链接时要修正这条指令中函数的内存地址,这个记录就放在了目标文件的.rel.text段中。相应的如果是对外部定义的全局变量的使用,则该记录放在了目标文件的.rel.data段中。即链接器需要在链接过程中根据.rel.data以及.rel.text来填好编译器留下的空白位置(0x000000)。因此在这里我们进一步丰富目标文件中的内容,如图所示:
在这里插入图片描述
  生成目标文件后,编译器完成任务,编译器确定了定义在该源文件中函数以及全局变量的相对地址。对于编译器不能确定的引用类变量,编译器在目标文件的.rel.text以及.rel.data段中生成相应的记录告诉链接器要修正这些变量的地址。

  接下来就是链接器的工作了。

2.3.2 链接器的工作

  我们在静态库下可执行文件的生成一节中知道,链接器会将所有的目标文件进行合并,所有目标文件的数据段合并到可执行文件的数据段,所有目标文件的代码段合并到可执行文件的代码段。当所有合并完成后,各个目标文件中的相对地址也就确定了。因此在这个阶段,链接器需要修正目标文件中的相对地址。
  在这里我们以合并目标文件中的数据段为例来说明链接器是如何修正目标文件的相对地址的,合并代码段时修正相对位置的原理是一样的。

  我们假设链接器需要链接三个目标文件:

  • 目标文件一:该文件数据段定义了两个变量apple和banana,apple的长度为2字节,banana的长度4字节,因此目标文件一的数据段长度为6字节。从图中也可以看出apple的内存地址为0,也就是相对地址,即apple这个变量在目标文件一的地址是0,banana的地址为2。

  • 目标文件二:该文件的数据段比较简单,只定义了一个变量orange,其长度为2,因此该目标文件的数据段长度为2。

  • 目标文件三:该文件的数据段定义了三个变量grape、mango以及limo,其长度分别为4字节、2字节以及2字节,因此该目标文件的数据段长度为8字节。
    在这里插入图片描述
      链接器在链接三个目标文件时其顺序是依次链接的,链接完成后:

  • 目标文件一:该数据段的起始地址为0,因此该数据段中的变量的最终地址不变。

  • 目标文件二:由于目标文件一的数据段长度为6,因此链接完成后该数据段的起始地址为6(这里的起始地址其实就是偏移offset),相应的orange的最终内存地址为0+offset即6。

  • 目标文件三:由于前两个数据段的长度为8,因此该数据段的起始地址为8(即offset为8),因此所有该数据段中的变量其地址都要加上该offset,即grape的最终地址为8,即0+offset,mango的最终地址为4+offset即12,limo的最终地址为6+offset即14。

   从这个过程中可以看到,数据段中的相对地址是通过这个公式来修正的,即:

相对地址 + offset(偏移) = 最终内存地址

   而每个段的偏移只有在链接完成后才能确定,因此对相对地址的修正只能由链接器来完成,编译器无法完成这项任务。
   当所有目标文件的同类型段合并完毕后,数据段和代码段中的相对地址都被链接器修正为最终的内存位置,这样所有的变量以及函数都确定了其各自位置。
   至此,重定位的第一阶段完成。接下来是重定位的第二阶段,即引用符号的重定位

   相对地址是编译器在编译过程中确定了,在链接器完成后被链接器修正为最终地址,而对于编译器没有确定的所引用的外部函数以及变量的地址,编译器将其记录在了.rel.text和.rel.data中。
   由于在第一阶段中,所有函数以及数据都有了最终地址,因此重定位的第二阶段就相对简单了。我们知道编译器引用外部变量时将机器指令中的引用地址设置为空(比如call 0x000000),并将该信息记录在了目标文件的.rel.text以及.rel.data段中。因此在这个阶段链接器依次扫描所有的.rel.text以及.rel.data段并找到相应变量的最终地址(这些位置都已在第一阶段确定),并将机器指令中的0x000000修正为所引用变量的最终地址就可以了。
   到这里链接器的重定位就讲解的这里,作为程序员一般很少会有问题出现在重定位阶段,因此这个阶段对程序员相对透明。请同学们注意一点,这里的分析仅限于目标文件的静态链接。我们知道静态链接下,链接器会将需要的代码和数据都合并到可执行文件当中,因此需要确定代码和数据的最终位置。而对于动态链接库来说情况则有所不同,动态链接库可以同时被多个进程使用,如果动态链接库的机器指令中不可以存在引用变量的最终位置,否则在被多个进程使用时会出现一个进程中使用的数据被其它进程修改。因此动态库下的机器指令都是PIC代码,即位置无关代码(Position-Independent Code)。关于PIC的机制原理就不在这里阐述了,对此感兴趣的同学可以关注微信公众号,码农的荒岛求生,我会在那里来讲解。

问题:为什么链接器能确定运行时地址
   我们知道只有把可执行文件加载到内存当中程序才可以开始运行。不同的程序会被加载到内存的不同位置。我们从前两节的过程中可以看出,链接器完全没有考虑不同的程序会被加载不同的内存位置被执行。比如对于一个可执行文件我们分别运行两次,如下图所示,因为两个程序数据段变量的地址是一样的,那么程序一的数据会不会被程序二修改呢?
在这里插入图片描述
   如果你去试一试的话就会发现显然不会有这种问题的。而当可执行文件加载到内存的时候也不会根据程序加载的起始地址再去修改可执行文件中变量的地址(这样就启动速度就太慢了),那么操作系统又是如何能做到基于同一个可执行文件的两个程序能在各自的内存空间中运行而不相互干扰呢,链接器在可执行文件中确定的到底是不是程序最终的运行地址呢,我会在后面的文章当中给出答案。

三、大型项目是如何被构建出来的

   在讲解大型项目如何被构建之前,我们首先来讨论一个问题,有句话说的很好,梦想总是要有的,万一实现了呢,那么问题来了,要怎么实现呢,这里就涉及到了如何实现目标。

3.1目标是如何实现的

   其实很简单,本质上只有两点:

  • 知道最后想要的是什么
  • 为此需要做些什么

   有时我们的目标可能不是简单的诸如每天跑五公里之类,比如像通过一门考试,学会一项技能这样的系统性工程。这时我们可能一下子不知道要做些什么,那么这就需要进行任务分解了,即这里的规则就是,把一个大的目标分解为一个个小的目标,如果对于其中一个小的目标还是不够具体,那么就继续将小目标进行分解,直到将每个小目标分解为如每天读懂两个章节,做完十个练习题之类很具体可以马上实施的任务为止。到这时,对于如何实现这个大的目标就很清晰了,只需要严格按照计划去实施就好了。比如对于考研,我们就可以列出如下的计划:
在这里插入图片描述

   在考研这个例子中我们就按照上述规则将目标进行了分解,每个目标都按如下格式列出:

目标(target): 依赖什么
         要怎么做

   如果“要怎么做”还不是一个具体的目标就继续分解,直到分解为类似进程这样的目标,因为像进程这样的目标已经有了具体的实现步骤。最后我们将各个已经实现的小目标汇集起来整个大的目标就实现了。

   本质上,一个大型项目的构建过程与此类似。

3.2 Make

   再大的项目最后生成的都是一个可执行文件,只要是可执行文件就需要依赖各种目标文件,动态库,静态库;静态库同样需要依赖其它目标文件,静态库;而动态库可能又依赖其它目标文件,动态库,静态库,知道了这些又该如何构建呢,我们可以利用上面目标划分的方法规划好构建最终的可执行文件需要哪些原材料,这些原材料又是如何获取的。有了这些规划后,我们就可以依次编译出一些小的目标文件,将这些目标文件链接成静态库,动态库以方便使用。然后再一步步连接目标文件以及各种库从而形成更大的库,最后将几个必要库以及目标文件进行链接从而生成最终的可执行文件。

   程序员先驱们确实就是使用这种现在看起来非常原始非常古老的方法进行程序编写的,每个目标文件以及库都是自己手动编译链接出来,然后再将它们链接成更大的库,直到最后生成可执行文件。

   这种方法看上去非常简单,但是缺点也很明显,那就是非常繁琐,一旦某个源文件进行了改动,所有依赖此文件的库都需要重新编译链接,手工来完成这项工作是极其枯燥且容易出错的。为解决这个问题,天才的程序员们想出了一个小工具,没错就是make,从此编译链接这个过程就被make自动化了,程序员得以从繁琐的编译链接中解放出来,使用make时我们只需要编写规则,也就是告诉make最终的可执行文件依赖什么,为此需要做些什么,这些规则类似于上面的目标分解,当编写好这些规则后,然后简单的执行一个命令也就是make就可以了。如果某个源文件被修改了,也只需要简单的重新执行一下make命令,因为整个过程的规则并没有改变,而make也会很聪明的只编译链接那些需要更新的目标文件,库,并重新进行可执行文件的生成。对于那些没有改动的源文件,make不会重新编译它们。

   make中每一条规则与前面的目标划分非常相似,make的规则是这样的:

target: prerequisites
      recipe

   target也就类似于我们的一个目标;而prerequisites,即先决条件,也就是依赖什么;recipe,这个就更形象了,即菜谱,也就是上面的要怎么做。make中的规则保存在了叫做Makefile的文件当中(没错,这个文件的名字就叫做Makefile),当运行make命令时,make程序会自动找到当前路径下的Makefile,然后开始执行里面的规则。

   有些同学可能为此感到疑惑,这里的Makefile其实就是脚本,而make读取这个脚本然后根据里面的内容来执行命令,而对于make大家也不要觉得很神奇,make也是一个普通程序,和我们平时使用的程序没什么区别。确定好了make需要执行的脚本的名字,这样在运行make命令时就少打了几个单词,假如用户可以自定义make的执行脚本名字,比如用户创建了一个脚本叫做foo,那么执行make的时候就需要多打一个单词“make foo”,所以干脆就直接确定好了脚本的名字就叫Makefile,这样在运行命令时只需要打一个单词make就可以了。

   这里举个简单的例子,比如我们写了一个helloworld程序,将源文件命名为了helloworld.c,我们想把该源文件编程成一个叫做hw的可执行文件,那么一个最简单的Makefile就可以写成这样:

hw: helloworld.o
   gcc helloworld.o -o hw
helloworld.o : helloworld.c
   gcc -c helloworld.c

   在这里最终的可执行文件hw依赖目标文件helloworld.o,那么假设我们现在已经有helloworld.o了就可以利用命令gcc helloworld.o -o hw生成我们需要的可执行文件了。那么helloworld.o又该如何获得呢?我们看第二条规则,helloworld.o依赖helloworld.c,因为helloworld.c已经写好了,所以可以直接用命令gcc -c helloworld.c来生成。这样整个目标就达成了。

   本质上现在我们使用的各种集成开发环境(IDE),其自动化编译工具背后的原理和make是一样的,比如我们在使用Visual Studio时从来没有关心过每个文件是如何被编译链接的,这些IDE都为我们代劳了。但是在比如Linux环境下进行开发时,这个过程依然是需要程序员了解的。

   现在让我们来回答本节提出的问题,也就是大型项目是如何被构建的。

3.3 构建大型项目

   大型项目中通常会有成百上千甚至上万个源文件,这些源文件统一放在了一个文件夹中方便管理。典型的项目如图所示,圆形代表源文件,其它为文件夹。注意这里仅仅为说明问题,各个公司团队都有自己的代码组织以及命名方式,而且真实项目要比该图复杂的多,但是本质上这里的讨论适用于其它情况。

3.3.1 源码组织方式

   通常项目的组织方式如下图所示:
在这里插入图片描述  项目源码会被放置在src当中,这个例子当中src下有两个文件夹,lib以及app,lib用于存放一些工具性的代码,比如这里列举的网络通信以及字符串处理模块,通常lib下的代码会被编译成各种库,方便app使用。app中就是各种需要可执行文件(程序)的代码了。通常像这里的lib以及app都会有专门的团队来负责。更大一些的项目,每个lib下的子目录比如这里的net,strings都会有专门的团队来负责以方便项目的模块化管理。

  从这里可以看出一般项目通常会按模块将源文件放入相应的文件夹下进行分类,我们在上一节中简单介绍了make的用法,但是那里仅仅需要编译一个源文件helloworld.c。对于如上图所示的项目,像make这一类的编译工具又该如何处理呢?

  make的嵌套执行能力可以解决这个问题。比如对于模块net,你可以为net模块写一个单独的Makefile,该Makefile只用于编译net下的源文件,具体的脚本如下所示,只需要简单的两行。

network:
   cd net && make

  这句话的意思是告诉make,要想编译网络模块(network)需要进到net文件夹并且执行make命令,当make进入到net文件夹开始执行make时,net下的Makefile就开始被执行了。通过这样一个简单的命令就可以实现make的嵌套执行了。make的这项特性使得每个模块都可以当做独立项目进行维护。

  编译工具的这项功能,方便了项目的模块化管理。使得项目中每个模块都可以有独立的编译脚本,比如使用make进行编译的话,那么每个模块中都会有单独的Makefile,比如在文件夹net,strings中都有自己的Makefile。如上图中蓝色部分,其中白色部分为源文件,更清晰的关于Makefile的组织方式如下图所示:
在这里插入图片描述

  这些脚本中定义了如何编译该模块,以及编译该模块需要依赖什么。这些模块的父目录也就是lib文件夹下同样也有自己的Makefile,lib下的Makefile会收集各个子模块的编译结果,然后将其链接成各种库。而对于app下面的子目录来说,这些子目录中就是各个可执行文件的源码了,比如这里的wechat文件下就是可执行程序微信的源码了,微信中可能会用到lib下提供的功能,那么对于wechat中的Makefile来说,只需要简单的加入对lib中所需要的库的依赖就可以了。wechat的父目录app中同样也有Makefile,这里的Makefile就相对简单了,只需要依次执行QQ,wechat中的Makefile就可以了,因此在src目录下简单的运行make命令,所有app比如QQ和wechat就都被编译出来了。

  接下来我们详细的讲解一下这个过程。请注意一点,接下来讲解的make执行过程仅仅是可能的一种实现方式,但是这个示例已足够说明项目的构建过程。

3.3.2 make的执行过程

  在上面的示例中src下的Makefile是整个编译过程的入口,因此我们进入src文件夹开始执行make命令。

(1)在src目录下,make首先读取src下的Makefile,./src/Makefile非常简单,该文件仅仅告诉make需要去app目录下执行make命令。
(2)make来./src/app目录下,开始读取该目录下的Makefile,该文件定义了编译出QQ,微信的规则,make首先执行编译QQ的规则,该规则告诉make编译QQ则需要到./src/app/QQ目录并执行make命令。
(3)make来到./src/app/QQ目录下,开始读取该目录下的Makefile,该文件定义了编译QQ程序 的规则,make开始执行这些规则,其中一项规则需要依赖网络模块的库,同时该规则告诉了make如果想得到该网络库则需要进入到./src/lib下执行make命令。
(4)make来到./src/lib目录下,开始读取该目录下的Makefile,该文件定义了编译出网络库,字符串处理库的规则,make首先执行编译网络库的规则,该规则告诉make如果想得到该网络库则需要进入到./src/lib/net下执行make命令。

在这里插入图片描述

(5)make来到./src/lib/net目录下,开始读取该目录下的Makefile,该文件定义了编译网络库的规则,编译网络库不再依赖任何其它库,make终于可以安心的开始工作不用再跳来跳去了,make开始执行该目录下的Makefile,将一个个源文件编译成目标文件,最后将这些目标文件链接成了静态库(当然也可以是动态库,依赖编译规则)。make在./src/lib/net完成任务后跳转回./src/lib,因为make会记住自己是从哪个目录跳转到当前目录的。
(6)make再次回到./src/lib下,因为make执行完了网络库的编译规则,因此继续往下执行,也就是字符串库的编译规则,该规则告诉make如果想得到字符串库则需要进入到src/lib/strings下执行make命令。
(7)make来到./src/lib/strings目录项,开始读取该目录下的Makefile,该文件定义了编译字符串库的规则,同样,编译字符串库不需要依赖任何其它库,make开始执行该目录下的Makefile,将一个个源文件编译成目标文件,最后将这些目标文件链接成了静态库(当然也可以是动态库)。make在./src/lib/strings下完成任务后跳转回./src/lib,因为make就是从这个目录跳转到./src/lib/strings的。
(8)make回到./src/lib,如果该目录下的Makefile还有其它编译规则,则继续上面的过程,如果没有其它规则,则该目录下的编译任务执行完成,make返回到./src/app/QQ。
(9)make回到./src/app/QQ下继续执行被中断的规则,这时QQ所依赖的库都已经编译完成,因此make可以直接进行链接了,QQ程序编译链接完成。make返回到./src/app。
在这里插入图片描述

(10)make来到./src/app下继续执行被中断的规则,make开始执行微信程序的编译规则,这里和QQ的编译是一样的,唯一一点即如果微信也需要依赖网络库和字符串库,那么当make调转到./src/lib下会发现这些库已经生成了,因此直接返回。当make执行完./src/app下的编译规则后,QQ和微信程序就都编译完成了。make返回到./src后,发现该目录下的Makefile执行完毕,因此make程序退出,整个编译过程完成。

  如果你对这个过程还不是很清楚的话,我们用一个游戏的类比来加深你对整个过程的理解。

  相信很多同学都玩过RPG(角色扮演)游戏,比如仙剑奇侠传,阴阳师。你可以把大型项目的编译过程想象成玩RPG游戏,这类的游戏通常都会有一个主线,若干支线,通常主线的每一关都需要你去某个支线完成任务,例如拿到宝物之类,当你完成支线任务拿到宝物后,你才能回到主线进入到下一关。
在这里插入图片描述

  在这里,make程序就好比玩家,游戏里的任务就好比编译脚本Makefile,主线任务就好比app下的Makefile,支线任务就好比编译app所依赖的库或者目标文件,比如这里的lib下的Makefile。

  首先玩家make进入主线,也就是app下,读取主线需要完成的任务(app下的Makefile),主线任务告诉玩家make通过其中某一关(比如编译出可执行文件app1)依赖一个支线任务,拿到宝物(app1所依赖的lib下的某个库),这时玩家make开始去支线场景(进入lib文件夹),然后读取支线任务(读取lib下的Makefile),make开始在lib下打怪升级(开始编译链接lib下源文件并生成相应的库),当make完成支线任务拿到宝物(lib中编译出来的库)回到主线任务(回到app下Makefile因跳转到lib被中断的接下来的编译脚本)后,才可以继续接下来的通关。

  有的同学可能已经发现了,像上面的这种编译实现方式其实是比较混乱的,既然我们make给了我们可以将每个模块当做独立项目进行编译的能力,那么对于非应用程序的代码比如这里的src/lib,我们可以提前编译出来,最后再来编译src/app下的代码,这样当依赖某个库时无需再去将该库编译出来。使用上面的编译顺序是为了说明make的构建方式是多样的,实际上使用make这一类的工具你可以使用任何你想要的编译顺序进行项目构建,本质上写Makefile就是写程序,这些程序告诉make该如何构建出最后的可执行文件,至于构建程序该以什么样的顺序构建出可执行文件,一切由你做主。这就是make这类编译工具的灵活以及强大之处。

  还有一点需要注意的就是,真实的项目中会有很多模块是相互独立的,即这些模块互不依赖,为加快编译速度,make支持并行编译以充分利用多核的处理能力。

  关于大型项目的构建到这里就讲解的差不多了,我们可以看到大型项目的构建其实和我们平时完成一个目标是类似的,先有一个大的目标并将其分解为一个个比较容易实现的小目标,当所有的小目标完成后我们的目的也就是实现了。本质上大型项目的构建与此类似。

  至此,彻底理解链接器这一系列的文章就讲解完毕了。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值