可以去我的博客看:详解编译与链接(一):通览编译与链接的过程 - DobyAsa's
我们平时的程序设计一般是用IDE或者VSCODE这种集成度很高的编辑器来完成的。这些IDE帮助我们完成了程序从源代码到可执行文件的过程。但是如果我们对具体的编译与链接不了解的话,很容易在编译的过程中看到一些莫名其妙的报错,从而无从下手,所以我会慢慢详细解释一下编译与链接究竟是如何完成的。
###
## 编译源代码的过程
### 预编译
预编译其实也就是我们平时所说的预处理,它会在正是编译之前将源代码的内容进行处理——主要是处理源代码文件中以"#"开头的预编译指令,比如"#include","#define"等等。主要处理规则如下:
- 将所有的"#define"删除,并且展开所有的宏定义。
- 处理所有的条件预编译指令,比如"#if", "#ifdef", "#elif", "#else", "#endif" 等等。
- 处理"#include"指令,将被包含的文件插入到该指令的位置(这个过程是递归进行的,也就是说被包含的文件中如果有其他文件,也会递归的插入进来)。
- 删除所有的注释
- 添加行号与文件名标识,方便编译时编译器产生调试用的行号,同时也能够方面如果编译错误时,能够显示错误的行号。
- 保留所有的#pragma编译器指令,因为编译器必须要使用它们(#pragma是给编译器使用的,用来设置编译器状态或者指示编译器的行为)。
预处理后的文件一般是.i结尾,依然是文本文件,只是里面的宏定义被展开,并且应该被包含的文件也插入进去了,**如果我们想看看宏定义的使用是否正确或者头文件是否被正确包含,可以查看预处理后的文件来确认**。
```c
#include<stdio.h>
int
main(){
printf("Hello World!\n");
return 0;
}
```
上面是一个很常见的C代码,大家应该刚学C就会接触到这个代码。但是大部分人可能都没有尝试自己来一步步编译出可执行文件来,我们先试试将这个源代码预编译为.i文件:
```shell
gcc -E hello.c -o hello.i
```
我们就可以看到一个多达七百多行的文本文件,因为太长了我就不在这里贴出来了,大家可以自己尝试一下。
### 编译
编译是一个很复杂的过程,具体就是让上一步产生的预处理文件进行一系列的词法分析,语法分析,语义分析,然后优化生成对应的汇编代码文件。
我们可以尝试用以下命令来编译文件:
```shell
gcc -S hello.i -o hello.s
```
我们就可以看到生成的汇编代码了,我这个里只贴出主函数的部分代码:
```
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
leaq .LC0(%rip), %rdi
call puts@PLT
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
```
这一步就是将编程语言的源代码生成为了对应的汇编语言代码。
### 汇编
众所周知,汇编代码也是不能直接在机器上运行的,我们需要将汇编指令翻译成对应的机器码,这一步很简单,因为汇编指令与机器指令是一一对应的,只需要一条一条根据对照表来翻译就可以了,翻译过后的二进制文件其实也不能够直接运行,还需要链接,这样的文件叫做可重定位目标文件。
我们可以通过下面命令来生成可重定位目标文件:
```shell
gcc -c hello.s -o hello.o
```
这一步我就不展示结果了..因为其实里面都是二进制文件了,看也看不懂了。
### 链接
链接就是我们整个编译过程的最后一步了,他会最终根据你想要生成的程序,将多个文件链接起来,最终生成可执行文件,后面我们会更详细分析这个过程,这里我先纵览一下整个编译的过程。
## 编译器负责什么?
编译器简单来说,就是帮助我们根据编程语言的源代码,生成对应的机器代码。因为直接编写机器代码实在是太困难了,而且很难构建一个巨大的软件。所以我们需要更高级,更抽象的编程语言来帮助我们编程。而且这样抽象之后,也很容易实现跨平台的特性。我们的代码可以在不同的平台上编译,运行,而不需要修改代码内容;而机器代码是跟机器绑定的,相同的机器代码是无法在不同的平台上运行的。
编译的过程一般可以分成六步:扫描、语法分析、语义分析、源代码优化、代码生成、目标代码优化。
比如下面我们有一行代码:
```c
array[index] = (index + 4) * (2 + 6)
```
首先,扫描会将这个句子变成一个表格,表格记录了这句话所有的Tokens,Tokens可以分为一下几大类:关键字、标识符、字面量、特殊记号(加号、等号)。
继而,通过语法分析器来对tokens分析,将其转化为一个语法树,其实有点类似于我们学习表达式求值算法中所画的那个树,此时也会检测表达式是否合法,语法是否正确。
然后就是语义分析器,编译器能实现的是静态语义的识别,静态语义通常包括声明和类型的匹配,类型的转换。比如如果我将一个浮点型赋值给指针,那么在语法上没有问题,但是在语义上是不允许的,所以会在此时报错;同时类型转换也是在此时完成的。语义分析后,语法树上的节点也会记录其类型。
后面的代码优化其实就是检查一下生成的代码有没有能够省略的步骤或者有更快的实现方式。
但是最后生成的目标代码依旧不能运行,为什么我们生成了二进制文件,但是依旧不能运行呢?因为我们其实无法确定index变量与array变量的地址。如果index变量与array变量与代码是在同一个源文件中声明的,那么我们的编译器是能够确定其地址的。
但是现在的程序开发往往采用模块化开发,很多时候我们所使用的变量或者函数是来自别的模块中的,那么我们的编译器是不知道到底我们源代码中所引用的函数或者变量的位置到底在哪里。所以编译器一般会将其地址留空,等待链接器来处理这个问题。
我们使用链接器来链接多个目标文件的时候,链接器会帮我们计算变量的地址,然后将其填入编译器留空的地方,这个行为就叫做**重定位**,需要被重定位的地方就叫做**重定位入口**,所以等待被链接的目标文件也叫做**可重定位目标文件**。最终链接后的文件就可以直接执行了,就叫做**可执行目标文件**。