彻底理解链接器:一

 

目录

什么是链接器(Linker)

链接器可操作的元素

链接器是如何工作的

过程一:符号决议

c源文件中都有什么

目标文件里有什么

符号表(Symbol table)

符号表存放在哪里

符号决议的过程

实例说明undefined reference

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

静态库

静态连接

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

动态库

动态链接

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

动态库vs静态库

过程三:重定位

编译器的工作

链接器的工作

问题:为什么链接器能确定运行时地址

大型项目是如何被构建(build)出来的

目标是如何实现的

make

构建大型项目

源码组织方式

make的执行过程

总结


在介绍本章主题之前,我们先来看几个问题:

问题一

写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已经对程序员屏蔽了大部分链接器的工作,但理解链接器将极大提高你对工程的驾驭能力,也许你现在还不是很清楚,读完这篇文章你就能明白啦。

什么是链接器(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),压缩软件将一堆文件打包压缩成一个压缩文件,而链接器和压缩软件的区别在于链接器是将多个目标文件打包成一个文件而不进行压缩。那么链接器到底是如何工作的呢,我们接着往下看。


链接器可操作的元素


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

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

接下来我们具体看一下链接器是如何工作的。

链接器是如何工作的


在链接器可操作的元素一节中我们提到,所有的应用程序都是链接器将所需要的一个个简单的目标文件汇集起来形成的。你可以将这个过程想象成拼图游戏,每个拼块就是一个简单的目标文件:
1,拼图游戏当中的每个拼块都依赖于其它拼块提供的拼接口,这就好比我们写的程序模块依赖于其它模块提供的编程接口,比如我们在list.c中实现了一种特定的链表数据结构,其它模块需要使用这种链表,这就是模块间的依赖。而链接器其中一项任务就是要确保提供给链接器进行链接的目标文件集合之间依赖是成立的(也就是说,不会出现在被依赖的模块中链接器找不到需要的接口),这就是后面我们要讲到的符号决议(Symbol Resolution),开篇提到的第一个问题就来自这个过程。

彻底理解链接器:一_第1张图片

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

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

 

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

首先,链接器对给定的目标文件或库的集合进行符号决议以确保模块间的依赖是正确的。

其次,链接器将给定的目标文件集合进行拼接打包成需要的库或最终可执行文件。

最后,链接器对链接好的库或可执行文件进行重定位。

 

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

 

过程一:符号决议

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

 

c源文件中都有什么

如图所示是一个典型的c源文件,该文件中的变量可以划分为两类:

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

彻底理解链接器:一_第2张图片

 

目标文件里有什么

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

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

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

彻底理解链接器:一_第3张图片

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

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

 

符号表(Symbol table)

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

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

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

名字

类型

是否可被外部引用

区域

z_global

引用,未定义

   

fn_a

引用,未定义

   

fn_b

定义

代码段

fn_c

定义

代码段

x_global_init

定义

数据段

y_global_uninit

定义

数据段

x_global_uninit

定义

数据段

y_global_init

定义

数据段

z_global以及fn_a是未定义的,因为在当前文件中,这两个变量仅仅是声明,编译器并没有找到其定义。剩余的变量编译器都可以在当前文件中找到其定义。

fn_b以及fn_c为当前文件定义的函数,因为在代码段。

剩余的符号都是全局变量,因此放在了数据段。

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

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

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

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

接下来的内容我会在该系列的第二篇文章当中介绍,欢迎关注我的微信公众号,码农的荒岛求生,获取更多计算机基础知识。

彻底理解链接器:一_第4张图片

 

彻底理解链接器系列

  1. 彻底理解链接器:一,概念
  2. 彻底理解链接器:二,符号决议
  3. 彻底理解链接器:三,库与可执行文件
  4. 彻底理解链接器:四,重定位

彻底理解操作系统系列

  1. 什么程序?
  2. 进程?程序?傻傻分不清
  3. 程序员应如何理解内存:上篇
  4. 程序员应如何理解内存:下篇
     

 

符号表存放在哪里

符号决议的过程

实例说明undefined reference

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

静态库

静态连接

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

动态库

动态链接

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

动态库vs静态库

过程三:重定位

编译器的工作

链接器的工作

问题:为什么链接器能确定运行时地址

大型项目是如何被构建(build)出来的

目标是如何实现的

make

构建大型项目

源码组织方式

make的执行过程

总结

你可能感兴趣的:(彻底理解链接器)