目录
一、假如你来发明编程语言
1.机器语言(低级语言)
2.汇编语言(低级语言)
3.高级语言
(1)编译器
二、彻底理解链接器
1.彻底理解链接器 - 前言
2.彻底理解链接器 - 符号决议
(1)符号决议过程
3.彻底理解链接器 - 库与可执行文件的生成
(1)静态库
(2)动态库
(3)静态库VS动态库
4.彻底理解链接器 - 重定位
(1)第一个阶段:编译器的工作
(2)第二个阶段:链接器的工作
5.彻底理解链接器 - 大型项目是如何被构建出来的
(1)make自动化
三、程序员应如何理解include
1.头文件是被预编译器处理的
2.头文件引入格式
四、程序员应如何理解标准库
一、假如你来发明编程语言
1.机器语言(低级语言)
0 和 1 编写指令,计算机能直接解读、运行。
2.汇编语言(低级语言)
用一些容易理解和记忆的字母、单词来代替一个特定的指令。
3.高级语言
高级计算机 语言便于人编写、阅读交流、维护。
(1)编译器
编译器就是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的程序。
一个现代编译器的主要工作流程:源代码 (source code) →预处理器 (preprocessor) → 编译器 (compiler) → 目标代码(object code) → 链接器(Linker) → 可执行程序 (executables)。
二、彻底理解链接器
1.彻底理解链接器 - 前言
链接器是一个将编译器产生的目标文件打包成可执行文件或者库文件或者目标文件的程序。链接器的工作过程:
- 链接器对给定的目标文件或库的集合进行符号决议以确保模块间的依赖是正确的。
- 链接器将给定的目标文件集合进行拼接打包成需要的库或最终可执行文件。
- 链接器对链接好的库或可执行文件进行重定位。
2.假如你来发明编程语言
(1)符号决议过程
在这个过程当中,链接器需要做的工作就是确保所有目标文件中的符号引用都有唯一的定义。其实,一个目标文件由数据部分+代码部分+符号表组成。
- 数据部分:源文件中定义的全局变量。如果是已经初始化后的全局变量,该全局变量的值也存在于数据部分。
- 代码部分:你可能会想,一个源文件中不都是代码吗,这里的代码指的是计算机可以执行的机器指令,也就是源文件中定义的所有函数。
- 符号表中保存的信息有两部分:
- 该目标文件中引用的全局变量以及函数。
- 该目标文件中定义的全局变量以及函数。
本质上整个符号表只是想表达两件事:
- 我能提供给其它文件使用的符号。
- 我需要其它文件提供给我使用的符号。
假设链接器需要链接三个目标文件,链接器会依次扫描每一个给定的目标文件,同时链接器还维护了两个集合,一个是已定义符号集合D,另一个是未定义符合集合U,下面是链接器进行符合决议的过程:
- 对于当前目标文件,查找其符号表,并将已定义的符号并添加到已定义符号集合D中。
- 对于当前目标文件,查找其符号表,将每一个当前目标文件引用的符号与已定义符号集合D进行对比,如果该符号不在集合D中则将其添加到未定义符合集合U中。
- 当所有文件都扫描完成后,如果未定义符号集合U不为空,则说明当前输入的目标文件集合中有未定义错误,链接器报错,整个编译过程终止。
3.彻底理解链接器 - 库与可执行文件的生成
(1)静态库
不是所有静态库中的目标文件都会用到,而是用到哪个链接器就链接哪个。
静态链接会将用到的目标文件直接合并到可执行文件当中,注意,如果有这样的一种静态库,几乎所有的程序都要使用到,也就是说,生成的所有可执行文件当中都有一份一模一样的代码和数据,这将是对硬盘和内存的极大浪费。
- 使用静态库时,静态库的代码段和数据段都会直接打包copy到可执行文件当中,使用静态库无疑会增大可执行文件的大小。
- 静态库在编译链接期间就被打包copy到了可执行文件,也就是说静态库其实是在编译期间(Compile time)链接使用的。
(2)动态库
动态库允许使用该库的可执行文件仅仅包含对动态库的引用而无需将该库拷贝到可执行文件当中。也就是说,同静态库进行整体拷贝的方式不同,对于动态库的使用仅仅需要可执行文件当中包含必要的信息即可。
动态链接可以在两种情况下被链接使用,分别是load-time dynamic linking(加载时动态链接) 以及 run-time dynamic linking(运行时动态链接)。
a.load-time dynamic linking(加载时动态链接)
在编译链接生成可执行文件的过程中要提供所依赖的动态库信息。
加载指的是程序的加载,而所谓程序的加载就是把可执行文件从磁盘搬到内存的过程,因为程序最终都是在内存中被执行的。
当把可执行文件复制到内存后,且在程序开始运行之前,操作系统会查找可执行文件依赖的动态库信息(主要是动态库的名字以及存放路径),找到该动态库后就将该动态库从磁盘搬到内存,并进行符号决议,如果这个过程没有问题,那么一切准备工作就绪,程序就可以开始执行了,如果找不到相应的动态库或者符号决议失败,那么会有相应的错误信息报告为用户,程序运行失败。
从总体上看,加载时动态链接可以分为两个阶段:阶段一:将动态库信息写入可执行文件;阶段二:加载可执行文件时依据动态库信息进行动态链接。
2.run-time dynamic linking(运行时动态链接)
在编译链接生成可执行文件的过程中没有提供所依赖的动态库信息,因此这项任务就留给了程序员,在代码当中如果需要使用某个动态库所提供的函数,我们可以使用特定的API来运行时加载动态库,在Windows下通过LoadLibrary或者LoadLibraryEx。
在可执行文件被启动运行之前,可执行文件对所依赖的动态库信息一无所知,只有当程序运行到需要调用动态库所提供的代码时才会启动动态链接过程。
在动态链接下,可执行文件当中会新增两段,即dynamic段以及GOT(Global offset table)段,这两段内容就是我们之前所说的必要信息。
dynamic段中保存了可执行文件依赖哪些动态库,动态链接符号表的位置以及重定位表的位置等信息。
(3)静态库VS动态库
在编译链接过程中,可以同时使用动态库以及静态库。这两种库的使用并不冲突,那么在这种情况下生成的可执行文件中,可执行文件中包含了静态库的数据和代码,以及动态库的必要信息。
静态库:
- 静态链接会导致可执行文件过大,且多个程序静态链接同一个静态库的话会导致磁盘浪费的问题。
- 使用简单,编译好的可执行文件是完备的。
动态库:
- 磁盘不浪费
- 程序启动慢(可忽略)
- 耦合性低
- 动态链接下的可执行文件不可以被独立运行。
- 动态库的使用使得同一个项目不同语言混合编程成为可能,而且动态库的使用更大限度的实现了代码复用。
4.彻底理解链接器 - 重定位
可执行文件中代码以及数据的运行时内存地址是链接器指定的。确定程序运行时地址的过程就是这里重定位(Relocation)。之所以叫做重定位是因为确定可执行文件中代码和数据的运行时地址是分为两个阶段的,在第一个阶段中无法确定这些地址,只有在第二个阶段才可以确定,因此就叫做重定位。
(1)第一个阶段:编译器的工作
源文件首先被编译器编译生成目标文件,目标文件种有三段内容:数据段、代码段以及符号表,所有的函数定义被放在了代码段,全局变量的定义放在了数据段,对外部变量的引用放到了符号表。
编译器在将源文件编译生成目标文件时可以确定一下两件事:
- 定义在该源文件中函数的内存地址
- 定义在该源文件中全局变量的内存地址
注意这里的内存地址其实只是相对地址,相对于谁的呢,相对于自己的。因为在生成一个目标文件时编译器并不知道这个目标文件要和哪些目标文件进行链接生成最后的可执行文件,而链接器是知道要链接哪些目标文件的。因此编译器仅仅生成一个相对地址。
对于引用类的变量,也就是在当前代码中引用而定义是在其它源文件中的变量,对于这样的变量编译器是无法确定其内存地址的。也就是说对于编译器不能确定的地址都这设置为空(0x000000),同时编译器还会生成一条记录,该记录告诉链接器在进行链接时要修正这条指令中函数的内存地址,这个记录就放在了目标文件的.rel.text段中。相应的如果是对外部定义的全局变量的使用,则该记录放在了目标文件的.rel.data段中。即链接器需要在链接过程中根据.rel.data以及.rel.text来填好编译器留下的空白位置(0x000000)。
也就是说,生成目标文件后,编译器完成任务,编译器确定了定义在该源文件中函数以及全局变量的相对地址。对于编译器不能确定的引用类变量,编译器在目标文件的.rel.text以及.rel.data段中生成相应的记录告诉链接器要修正这些变量的地址。
(2)第二个阶段:链接器的工作
a.重定位第一阶段
以静态库下可执行文件的生成为例,链接器会将所有的目标文件进行合并,所有目标文件的数据段合并到可执行文件的数据段,所有目标文件的代码段合并到可执行文件的代码段。当所有合并完成后,各个目标文件中的相对地址也就确定了。因此在这个阶段,链接器需要修正目标文件中的相对地址。
相对地址 + offset(偏移) = 最终内存地址
而每个段的偏移只有在链接完成后才能确定,因此对相对地址的修正只能由链接器来完成,编译器无法完成这项任务。
当所有目标文件的同类型段合并完毕后,数据段和代码段中的相对地址都被链接器修正为最终的内存位置,这样所有的变量以及函数都确定了其各自位置。
b.重定位第二阶段
我们知道编译器引用外部变量时将机器指令中的引用地址设置为空(比如call 0x000000),并将该信息记录在了目标文件的.rel.text以及.rel.data段中。因此在这个阶段链接器依次扫描所有的.rel.text以及.rel.data段并找到相应变量的最终地址(这些位置都已在第一阶段确定),并将机器指令中的0x000000修正为所引用变量的最终地址就可以了。
5.彻底理解链接器 - 大型项目是如何被构建出来的
(1)make自动化
如果某个源文件被修改了,也只需要简单的重新执行一下make命令,因为整个过程的规则并没有改变,而make也会很聪明的只编译链接那些需要更新的目标文件,库,并重新进行可执行文件的生成。对于那些没有改动的源文件,make不会重新编译它们。
三、程序员应如何理解include
1.头文件是被预编译器处理的
预编译的工作非常简单,预编译器找到源文件中#include指定的文件,然后copy这些文件的内容并粘贴到#include这一行所在的位置。例如在源文件a.c的第一行有一句#include <stdio.h>,那么预编译器怎么处理?预编译找到stdio.h,把stdio.h的内容粘贴到a.c的第一行中。
头文件是被预编译器处理的,编译器在编译源文件时拿到的是已经被预编译器处理过后的源文件,因此头文件是不会被编译器直接处理的。
实际上#include可以出现在代码中的任意一行,只不过我们习惯了在开头使用#include,这是因为变量在声明之前是不能被使用的。
2.头文件引入格式
预编译器要想处理头文件首先必须要能找到这个头文件。
- 如果一个头文件放到了<>中,那么预编译器会在系统头文件所在的路径下开始找。
- 如果头文件被放到了双引号“”中呢?很显然只不过就是预编译器搜索路径不再是系统头文件所在路径了,而是以源文件所在位置开始查找,当然不同的编译器策略可能稍有差别。
头文件里仔仔细细的写好了该模块有哪些函数可供使用者调用、返回值是什么、参数是什么,但头文件中并不会包含实现,这是因为C/C++语言不要求函数的声明和实现必须呆在同一个地方。
四、程序员应如何理解标准库
C/C++以及任何一门编程语言都是这样的一堆规则,对于C/C++来说,每年都有一群来自被称为International Organization for Standardization (ISO)组织的人来制定C/C++语言的规则,因此这群人坐下来讨论的这堆规则实际上就是一个标准,每一次讨论都会重新修改制定新的标准并对外发布,这就是为什么C/C++有各种版本:C99, C11, C++03, C++11, C++14等等,其中的数字其实就是来自制定标准的年份。
对外发布的标准中包含两部分内容:
- C/C++支持哪些特性
- C/C++API,程序员可以在他们的C/C++程序中直接调用这些API,这些API就被称为标准库(Standard Library)
从Visual Studio 2015之后,Windows中C/C++标准库被称为Universal C Runtime Library (Universal CRT,简称UCRT),即UCRTBASE.DLL,此后Windows标准库开始同Win10一起发布。