C语言编译与链接
在之前的多篇文章学习中,我们学到了很多C语言相关知识,有C语言的分支与循环结构、数组、二进制操作符、函数递归、指针、回调函数、字符串函数、结构体、内存函数与动态内存函数......很多很多的知识,这些都是我们平时用来做题或编写代码时所用的,但我们是否有想过:这些代码短短数行,是如何能够实现对应的作用,并且顺利运行代码的?这就与我们今天要了解的——C语言编译和链接有着莫大的关系了~那么闲话不多说,让我们开始今天的学习~:
一、翻译环境和运行环境
在C语言中,其实仅靠我们编写的短短几行代码,是无法实现我们在编译器中运行代码的功能的。而实现运行代码的功能,其实是由于在我们的编译器上,存在着很多潜在的,我们无法看见的功能:在 ANSI C 的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令(二进制指令)。
第2种是执行环境,它用于实际执行代码。
如果将这种过程通过图片来表示,大概是这样的:
二、翻译环境
那么翻译环境又是如何将源代码转换成可执行的可执行的程序的呢?让我们细致的讲一下翻译环境中经历的过程:
C语言的编译过程分为四个阶段:预处理、编译、汇编和链接。
(主要由"编译"和"链接"两个大过程组成,而编译又分为:预处理,编译,汇编。)
📚先让我们写一段代码来作为例子:
📕源文件:
#include "add.c"
int main()
{int a = 0;int b = 0;scanf("%d %d", &a, &b);int c = add(a, b);printf("%d", c);return 0;
}
📕add.c文件:
int add(int a,int b)
{return a + b;
}
这两段代码分别在两个文件,那么这两个文件又是如何联合到一起,成功运行,打印结果的呢?
接下来让我们用图片表示一下这个过程:
先让我们运行一下代码,看一看这段代码的文件路径:
然后我们可以按照文件路径,寻找到编译后生成的"obj文件":
这也就证明了编译器 "cl.exe" 和 "link.exe" 确实在我们看不见的地方存在着。
一个C语言的项目中可能有多个 .C 文件一起构建,那多个 .C 文件如何生成可执行程序呢?
• 多个 .c 文件单独经过编译器,编译处理生成对应的目标文件。
• 注:在Windows环境下的目标文件的后缀是 .obj ,Linux环境下目标文件的后缀是 .O
• 多个目标文件和链接库一起经过链接器处理生成最终的可执行程序。
• 链接库是指运行时库(它是支持程序运行的基本函数集合)或者第三方库。
如果再把编译器展开成3个过程,那就变成了下面的过程:
三、预处理(预编译)
①. 预处理的定义
预处理阶段:预处理器会处理以"#"开头的预处理指令,如#include、#define和#ifdef等。它会根据指令进行文本的替换、条件编译和文件的包含等操作,生成一个被预处理后的源代码文件。
预处理器指令通常包括宏定义、条件编译、包含文件等。下面是一些常见的预处理器指令:
1. 宏定义:使用#define指令定义宏,可以将一段代码或值定义为宏,以后在代码中使用宏名即可展开为宏定义的内容。
2. 条件编译:使用#if、#ifdef、#ifndef、#elif、#else和#endif指令来实现条件编译,根据条件判断是否编译一段代码。
3. 包含文件:使用#include指令将其他文件的内容包含到当前文件中,可以是系统头文件或自定义头文件。
4. 条件包含:使用#ifdef、#ifndef、#elifdef和#endif指令来判断指定的宏是否已定义,根据条件选择是否包含某个文件。
5. 字符串化和标记连接:使用#和##运算符将宏参数字符串化或连接为标记。
预处理器是在编译过程之前执行的,它会处理源代码中所有的预处理器指令,并将处理结果传递给编译器。预处理器指令的处理结果可以在编译阶段被查看和修改。预处理器的目的是在编译之前对代码进行一些预处理操作,以便生成最终的可执行文件。
而对于预处理阶段的定义与过程,虽然通过文字形式为大家讲解了一下,但没有例子还是会显得平淡无味,那么接下来我们用一个例子来表示预处理的作用:
#include<stdio.h>
#define N 100
int main()
{int a = N;int i = 0;for (i = 0; i < 10; i++){printf("%d ", i);}return 0;
}
经过预处理生成的 test.i :
我们可以看到,我们实际写出的代码也就短短数十行,而预处理得到的 test.i 却长达743行!!!这就是预处理的作用:
前面700多行代码其实就是头文件#include <stdio.h>,也就是一个"链接库"(支持程序运行的函数基本集合)。预处理能够将链接库里的代码转入 test.i 中,以便后续使用。
然后我们再看下面的几处不同之处:
📌① " 宏定义 N 为 100 "消失了:这是因为预处理阶段,会将所有的 #define 删除。
📌② a = N 中的 N 替换成了 100:因为将 #define 删除后,展开所有的宏定义。
📌③ 注释在 test.i 中消失了:删除所有的注释。
📌④ 代码量增加700多行:处理 #include 预编译指令,将包含的头文件的内容插入到该预编译指令的位置。这个过程是递归进行的,也就是说被包含的头文件也可能包含其他文件。
②. 条件编译指令
当我们编写一个程序时,想将单独一句或者一组语句进行编译或放弃是很方便的,而这就得益于条件编译指令的存在,条件编译指令是用于在编译过程中根据不同的条件选择性地编译代码。在C语言中,常用的条件编译指令包括:
Ⅰ. #ifdef:
用于判断一个标识符是否已被定义,如果已定义则编译下面的代码。
#ifdef 标识符代码块
#endif
📚代码演示:
#define NUM 5
int main()
{
#ifdef NUMprintf("YES\n");
#elseprintf("NO\n");
#endifreturn 0;
}
运行代码:
因为NUM已经被宏定义过了,所以打印的结果就是 "YES" 。
而如果我们将 #define NUM 5 注释掉,此时便会打印 "NO" :
(需要注意的是,只要指定变量被宏定义过那么 #ifdef 条件就成立,即使被定义后并未初始化(如 #define NUM), #ifdef 依然成立。)
Ⅱ. #ifndef:
用于判断一个标识符是否未被定义,如果未定义则编译下面的代码。
#ifndef 标识符代码块
#endif
#ifndef 和 #ifdef 非常的相似,并且用法也大同小异。
📚代码演示:
#define NUM
int main()
{
#ifndef NUMprintf("YES\n");
#elseprintf("NO\n");
#endifreturn 0;
}
运行代码:
与上一个 #ifdef 类似,注意都是判断是否定义的。
Ⅲ. #if 和 #elif:
根据预定义宏的值或者表达式的结果来判断是否编译某段代码。
📚代码演示:
#define NUM 5
int main()
{
#if NUM == 1printf("YES\n");
#elif NUM == 5printf("NO\n");
#endifreturn 0;
}
运行代码:
注意:这两种语句是用来判断值或表达式结果的,所以不能像 #ifdef 和 #ifndef 一样 宏定义一个没有初始化的变量去使用。
最后让我们总结一下"预编译"的作用:
📌• 将所有的 #define 删除,并展开所有的宏定义。
📌• 处理所有的条件编译指令,如: #if 、 #ifdef 、 #elif 、 #else 、 #endif 。
📌• 处理#include预编译指令,将包含的头文件的内容插入到该预编译指令的位置。这个过程是递归进行的,也就是说被包含的头文件也可能包含其他⽂件。
📌• 删除所有的注释
📌• 添加行号和文件名标识,方便后续编译器生成调试信息等。
📌• 或保留所有的#pragma的编译器指令,编译器后续会使用。
四、编译
编译过程就是将预处理后的文件进行一系列的:词法分析、语法分析、语义分析及优化,生成相应的汇编代码文件。
编译过程的命令如下:
gcc -S test.i -o test.s
而编译这个过程大概会对代码造成什么样的影响呢?让我们随意编写一段代码并且查看一下经过编译过程后此代码生成的 test.s 是何种样子:
#include<stdio.h>
#define N 100
int main()
{int a = N;int i = 0;for (i = 0; i < 10; i++){printf("%d ", i);}return 0;
}
这段代码进行编译后就会变成 ... :
看不懂吧?...我也看不懂...但编译过程就是这样的,将预处理后的文件进行一系列的各种分析与优化,最后生成汇编代码文件(是让电脑看懂)。
词法分析:将源代码按照字符流的形式划分为一个个的词素,并给予每个词素一个词法类型。例如,将源代码中的"int"识别为一个关键字,将"5"识别为一个整数常量。
语法分析:将词法分析得到的词素按照语法规则组织成一个语法树。语法分析器将检查词素之间的关系和顺序是否符合语言的语法规定。例如,将"int x = 5 + 3;"解析为一个变量声明的语法树结构。
语义分析:在语法分析的基础上,进一步检查语法的合法性和语义的正确性。语义分析器将检查变量的定义与使用是否符合规则,类型的匹配性等。例如,检查变量是否已经定义过,表达式中的操作数是否类型匹配等。
📚而编译的过程具体是什么呢?语法树又是如何生成的呢?让我们通过一个例子具体的来查看:
arr [num] = (num + 8) * (2 + 6)
①. 词法分析
词法分析就是将源代码分割成一个个记号。记号是源代码中的最小有效单位,例如关键字、标识符、运算符、常量等。词法分析器会根据C语言的词法规则,从源代码中一次读取一个字符,将字符序列转化为一个个记号,并识别记号的类型。
词法分析的主要过程包括:
📌1. 读取源代码的一个字符。
📌2. 判断字符的类型,例如判断是否为字母、数字、运算符等。
📌3. 根据字符的类型,识别记号的类型和内容。
📌4. 将识别出的记号送给语法分析器进行进一步处理。
上面程序进行词法分析后得到了16个记号:
记号 | 类型 |
arr | 标识符 |
[ | 左方括号 |
num | 标识符 |
] | 右方括号 |
= | 赋值 |
( | 左圆括号 |
num | 标识符 |
+ | 加号 |
8 | 数字 |
) | 右圆括号 |
* | 乘号 |
( | 左圆括号 |
2 | 数字 |
+ | 加号 |
6 | 数字 |
) | 右圆括号 |
②. 语法分析
接下来语法分析器根据C语言的语法规则,逐个读取输入的符号,并根据语法规则进行分析和匹配。在这个过程中,语法分析器识别出程序中的各个语法结构,比如变量声明、函数定义、条件语句等,并将它们组织成一个树状结构,即语法树。
上述就是我们列出的表达式所对应的语法树啦~
③. 语义分析
由语义分析器来完成语义分析,即对表达式的语法层面分析。编译器所能做的分析是语义的静态分 析。静态语义分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息。
📚语义分析有以下的几条规则:
📌1. 标识符的定义和引用:对于每个标识符,需要检查其是否被声明过,以及是否在使用前进行了正确的声明。
📌2. 表达式和运算符的类型检查:对于每个表达式,需要检查其中的操作数和运算符的类型是否匹配。例如,两个整数类型才能做加法运算,而不能对一个整数和一个字符串做加法运算。
📌3. 控制流的语义分析:对于if语句、for循环、while循环等控制流结构,需要检查其中的条件表达式是否为真值,以及是否有可能出现死循环或者无法到达的分支。
📌4. 函数调用和参数传递:对于函数的调用,需要检查其参数的类型和个数是否与函数声明一致。同时还需要对函数返回值进行类型检查,并根据函数的作用域规则确定函数的可见性。
📌5. 类型推导和类型转换:对于C语言中隐式的类型转换,需要根据规则进行类型推导和类型转换。例如,将一个整数赋值给一个浮点数类型的变量时,会进行隐式的整数到浮点数的类型转换。
通过这几条规则,我们将上面得到的语法树进行语义标识:
五、汇编
汇编器会将汇编代码转变成机器可执行的指令,每一个汇编语句几乎都对应着一条机器指令。就是根据汇编指令和机器指令的对照表一一进行翻译,也不做指令优化。
六、链接
当我们将汇编代码转变成机器可执行的指令之后,还有一步非常重要的"链接"的操作。
链接的目的:将各个源文件中定义的函数和变量进行合并,以便程序可以调用和使用这些函数和变量。在链接过程中,链接器会解析函数的引用(调用)和变量的使用,将其与定义进行匹配,并生成可执行文件。
链接是一个复杂的过程:需要把一堆文件连接在一起才能最终生成可执行的程序。
连接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。
📚例如:
在一个项目中存在两个.c文件(test.c 和 add.c),代码如下:
📕test.c
#include<stdio.h>
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;
}
经过我们刚刚学习的各种编译步骤,我们能知道此下过程:
七、运行环境
C语言运行环境是指可以编译和运行C语言程序的软件和硬件的集合。
📌1. 编译器:用于将C语言源代码编译成机器可执行的二进制代码的工具。
📌2. 开发环境:提供编辑、编译、调试等功能的集成开发环境(IDE)或编辑器。
📌3. 运行库:包含了C语言程序运行所需的函数库,如标准C库(C Standard Library)。
📌4. 操作系统:C语言程序需要运行在一个操作系统上。不同的操作系统可能会有不同的C语言运行环境。
📌5. 硬件平台:C语言程序可以运行在不同的硬件平台上。不同的硬件平台可能会有不同的C语言运行环境。
1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2. 程序的执行便开始。接着便调用main函数。
3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
4. 终止程序。正常终止main函数;也有可能是意外终止。
那么关于C语言的编译与链接的相关知识就为大家分享到这里啦~本人能力有限,如果有哪里用词不准确或者有所错误,也请大家在评论区多多指出,我也会吸取教训,多多学习的!那么我们下期再见啦~