1. 前言
Hello!大家好我是小陈,今天来给大家介绍最详细的C语言编译与链接。
2. 编译和链接
我们通常用的编译器,比如Visual Sudio,这样的IDE(集成开发环境)一般将编译和链接的过程一步完成,通常将这这种编译和链接合并到一起的过程称为构建bulid。
我们经常使用这些编译器后,就不会用一些指令再去编译和链接,因为强大的集成开发环境已经满足了这些编译和链接,但是在代码出错的时候,有时我们会无从下手,所以我觉得我们还是需要深入了解编译和链接的具体步骤。
2.1 被隐藏了的过程
只要你稍微学习一点编程,那么下面的语句你并不陌生,因为这是每个程序员刚开始学的第一个语句,我们在vs编译器操作如下。
#include <stdio.h>
int main()
{
char arr[] = { "hello world!!!" };
printf("%s\n",arr);//你好世界
return 0;
}
在 Linux 下,当我们使用 GCC 来编译 Hello World 程序时,只须使用最简单的命令(假设源代码文件名 hello.c):
$ gcc hello.c
$ ./a.out
hello world !!!
事实上,上面可以分解为4个步骤,分别是预处理(Prepressing)编译(Compilation)汇编(Assembly)和链接(Linking)。
2.1.1 预编译
首先是源代码文件 hello.c 和相关的头文件,如 stdio.h 等被 预编译器 cpp 预编译成一个 .i 文件。对于 C++ 程序来说,它的源代码文件的扩展名可能是 .cpp 或 .cxx,头文件的扩展名可能是 .hpp,而预编译后的文件扩展名是 .ii。第一步预编译的过程相当于如下命令(-E 表示只进行预编译):
$ gcc -E hello.c -o hello.i
or
$ cpp hello.c > hello.i
预编译过程主要处理那些源代的文件中的以 # 开始的预编译指令。比如 #include、#define 等,主要处理规则如下:
- 将所有的 #define 删除,并且展开所有的宏定义。
- 处理所有条件编译指令,比如:#if、#ifdef、#elif、#else、#endif。
- 处理 #include 预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。
- 删除所有的注释 // 和 /* */。
- 添加行号和文件名标识,比如 #2 “hello.c” 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
- 保留所有的 #pragma 编译器指令,因为编译器须要使用它们。
当然我们要注意,经过预编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中。所以当我们无法判断宏定义是否正确,或者头文件包含是否正确时,可以查看预编译后的文件来确定问题。
2.1.2 编译
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件。
$ gcc -S hello.i -o hello.s
如何进行编译呢?
array[index] = (index+4)*(2+6);
2.1.3 汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。
$ as hello.s -o hello.o
or
$ gcc -c hello.s -o hello.o
使用 gcc 命令从 C 源代码文件开始,经过预编译、编译和汇编直接输出 目标文件
$ gcc -c hello.c -o hello.o
2.1.4 链接
链接说白了就是把许多文件链接在一起,但是这些文件格式好多不相同,有着一步下一步的关系
链接的过程主要包括 :地址和空间分配,符号决议和重定位等这些步骤。
链接解决的是一个项目中的文件,多模块之间互相调用的问题。
ld -static crt1.o crti.o crtbeginT.o hello.o -start-group -lgcc -lgcc_eh -lc
-end-group crtend.o crtn.o
//test.c
#include <stdio.h>
//test.c
//声明外部函数
extern int Add(int x, int y);
//声明外部的全局变量
extern int g_val;
int main()
{
int a = 10;
int b = 20;
int sum = Add(a, b);
printf("%d\n", sum);
return 0;
}
//add.c
int g_val = 2022;
int Add(int x, int y)
{
return x+y;
}
我们已经知道,每个源⽂件都是单独经过编译器处理⽣成对应的⽬标⽂件。
test.c 经过编译器处理⽣成 test.o
add.c 经过编译器处理⽣成 add.o
我们在 test.c 的⽂件中使⽤了 add.c ⽂件中的 Add 函数和 g_val 变量。
我们在 test.c ⽂件中每⼀次使⽤ Add 函数和 g_val 的时候必须确切的知道 Add 和 g_val 的地 址,但是由于每个⽂件是单独编译的,在编译器编译 test.c 的时候并不知道 Add 函数和 g_val 变量的地址,所以暂时把调⽤ Add 的指令的⽬标地址和 g_val 的地址搁置。等待最后链接的时候由 链接器根据引⽤的符号 Add 在其他模块中查找 Add 函数的地址,然后将 test.c 中所有引⽤到 Add 的指令重新修正,让他们的⽬标地址为真正的 Add 函数的地址,对于全局变量 g_val 也是类 似的⽅法来修正地址。这个地址修正的过程也被叫做:重定位。
2.2 编译器干了什么活
从最直观的角度来讲,编译器就是将高级语言翻译成机器语言的一个工具。比如我们用 C/C++ 语言写的一个程序可以使用编译器将其翻译成机器可以执行的指令及数据。我们前面也提到了,使用机器指令或汇编语言编写程序是十分令费事及乏昧的事情,它们使得程序开发的效率十分低下。并且使用机器语言或汇编语言编写的程序依赖于特定的机器,一个为某种 CPU 编写的程序在另外一种 CPU 下完全无法运行,需要重新编写,这几乎是令人无法接受的。
用一行C语言代码的例子来讲述
array[index] = (index + 4) * (2 + 6)
CompilerExpression.c
2.2.1 词法分析
首先源代码程序被输入到 扫描器(Scanner),扫描器的任务很简单,它只是简单地进行词法分析,运用一种类似于 有限状态机(Finite State Machine) 的算法可以很轻松地将源代码的字符序列分割成一系列的 记号(Token)。
词法分析产生的记号一般可以分为如下几类:关键字、标识符、字面量(包含数字、字符串等) 和 特殊符号(如加号、等号)。在识别记号的同时,扫描器也完成了其他工作。比如将标识符存放到 符号表,将数字、字符串常量存放到 文字表 等,以备后面的步骤使用。
有一个叫做 lex 的程序可以实现词法扫描,它会按照用户之前描述好的 词法规则 将输入的字符串分割成一个个记号。因为这样一个程序的存在,编译器的开发者就无须为每个编译器开发一个独立的词法扫器,而是根据需要改变词法规则就可以了。
另外对于一些有预处理的语言,比如 C 语言,它的宏替换和文件包含等工作一般不归入编译器的范围而交给一个独立的预处理器。
2.2.2 语法分析
语法分析器(Grammar Parser) 将对由扫描器产生的记号进行语法分析,从而产生 语法树(Syntax Tree)。
上下文无关语法(Context-free Grammar)在计算机科学中,若一个 形式文法 G = (N, Σ, P, S) 的产生式规则都取如下的形式:V->w,则谓之。 其中 V∈N ,w∈ (N∪Σ)*
C语言上下文无关法的定义
语法树
2.2.3 语义分析
编译器所能分析的语义是 静态语义(Static Semantic),所谓静态语义是指在编译期可以确定的语义,与之对应的 动态语义(Dynamic Semantic) 就是只有在运行期才能确定的语义。
静态语义通常包括声明和类型的匹配,类型的转换。比如当一个浮点型的表达式赋值给一个整型的表达式时,其中隐含了一个浮点型到整型转换的过程,语义分析过程中需要完成这个步骤。
2.2.4 中间语言生成
三地址码(Three-address Code) 和 P-代码(P-Code)。我们就拿最常见的三地址码来作为例子
x = y op z
这个三地址码表示将变量 y 和 z 进行 op 操作以后,赋值给 x。这里 op 操作可以是算数运算,比如加减乘除等,也可以是其他任何可以应用到 y 和 z 的操作。三地址码也得名于此,因为一个三地址码语句里而有三个变量地址。我们上面的例子中的语法树可以被翻译成三地址码后是这样的:
t1 = 2 + 6
t2 = index + 4
t3 = t2 * t1
array[index] := t3
我们可以看到,为了使所有的操作都符合三地址码形式,这里利用了几个临时变量: t1、t2 和 t3。在三地址码的基础上进行优化时,优化程序会将 2+6 的结果计算出来,得到 t1 = 6。然后将后面代码中的 t1 替换成数字 6。还可以省去一个临时变量 t3,因为 t2 可以重复利用。经过优化以后的代码如下:
t2 = index + 4
t2 = t2 * 8
array[index] := t2
中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。这样对于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端。
2.2.5 目标代码生成与优化
源代码级优化器产生中间代码标志着下面的过程都属于编译器后端。编译器后端主要包括 代码生成器(Code Generator) 和 目标代码优化器(Target Code Optimizer)。
movl index, %ecx ; value of index to ecx
addl $4, %ecx ; ecx = ecx + 4
mull $8, %ecx ; ecx = ecx * 8
movl index, %eax ; value of index to eax
movl %ecx, array(,eax,4) ; array[index] = ecx
//上面都是一个代码反汇编后的指令
面的例子中,乘法由一条相对复杂的 基址比例变址寻址(Base Index Scale Addressing) 的 lea 指令完成,随后由一条 mov 指令完成最后的赋值操作,这条 mov 指令的寻址方式与 lea 是一样的:
movl index, %edx
leal 32(,%edx,8), %eax
movl %eax, array(,%edx,4)
2.3 链接器年龄比编译器长
参考书籍:《C语言程序员的自我修养》
2.4 模块拼装-静态链接
程序设计的模块化是人们一直在追求的目标,因为当一个系统十分复杂的时候,我们不得不将一个复杂的系统逐步分割成小的系统以达到各个突破的目的。一个复杂的软件也如此,人们把每个源代码模块独立地编译,然后按照须要将它们“组装”起来,这个组装模块的过程就是 链接(Linking)。链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接。链接器所要做的工作其实跟前面所描述的“程序员人工调整地址”本质上没什么两样,只不过现代的高级语言的诸多特性和功能,使得编译器、链接器更为复杂,功能更为强大,但从原理上来讲,它的工作无非就是把一些指令对其他符号地址的引用加以修正。链接过程主要包括了 地址和空间分配(Address and Storage Allocation)、符号决议(Symbol Resolution) 和 重定位(Relocation) 等这些步骤。
符号决议有时候称为 符号绑定(Symbol Binding)、名称绑定(Name Binding)、名称决议(Name Resolution),甚至还有叫做 地址绑定(Address Binding)、指令绑定(Instruction Binding) 的,大体上它们的意思都一样,但从细节角度来区分,它们之间还是存在一定区别的,比如“决议”更倾向于静态链接,而“绑定”更倾向于动态链接,即它们所使用的范围不一样。在静态链接,我们将统一称为 符号决议。
2.5 运行环境
- 程序必须载⼊内存中。在有操作系统的环境中:⼀般这个由操作系统完成。在独⽴的环境中,程序 的载⼊必须由⼿⼯安排,也可能是通过可执⾏代码置⼊只读内存来完成。
- 程序的执⾏便开始。接着便调⽤main函数。
- 开始执⾏程序代码。这个时候程序将使⽤⼀个运⾏时堆栈(stack),存储函数的局部变量和返回 地址。程序同时也可以使⽤静态(static)内存,存储于静态内存中的变量在程序的整个执⾏过程 ⼀直保留他们的值。
- 终⽌程序。正常终⽌main函数;也有可能是意外终⽌。