在本文章里面,我们讲会讲解C语言程序是如何从我们写的代码一步步变成计算机可以执行的二进制指令,并最终执行的。C语言程序运行主要包括两大步骤 -- 编译和链接,接下来我们就来一一讲解。
目录
[1 翻译环境和运行环境](#1 翻译环境和运行环境)
[2 翻译环境](#2 翻译环境)
[1) 预处理(预编译)](#1) 预处理(预编译))
[2) 编译](#2) 编译)
[(1) 词法分析](#(1) 词法分析)
[(2) 语法分析](#(2) 语法分析)
[(3) 语义分析](#(3) 语义分析)
[3) 汇编](#3) 汇编)
[4) 链接](#4) 链接)
1 翻译环境和运行环境
在**ANSI C(标准C)**的任何一种实现里面,都会包含两个不同的环境:
1) 翻译环境:会将源代码转变为可执行的二进制的机器指令。
2) 运行环境:用于执行实际的代码。
两个环境之间的关系可以用图片来描述一下:
用语言简单改过一下就是,各种源文件通过翻译环境中的编译和链接两步,会将我们写的C代码转变成机器能够执行的二进制指令,然后生成可执行程序(.exe为后缀的文件),在运行环境中就可以运行了。
所以可执行程序中存放的都是二进制指令,如果直接用记事本打开会看到是一堆乱码:
2 翻译环境
翻译环境主要由编译和链接两步组成,而编译又由预处理(预编译)、编译、汇编三个过程,所以翻译环境也可以说成是由预处理、编译、汇编、链接四小部分组成。
一个C语言的项目一般会由多个源文件共同构成,每一个源文件会先经过编译器(在vs中为cl.exe)生成对应的目标文件(Windows环境下后缀为.obj,Linux环境下后缀为.o),然后在经过链接器(vs中为link.exe)把各个目标文件和链接库(运行时库(支持程序运行的基本函数的集合)和第三方库)一起链接为可执行程序。
1) 预处理(预编译)
在预处理阶段,源文件和头文件会被处理成为后缀为 .i的文件。
预处理阶段主要会处理一些以 # 开头的预处理指令,具体规则如下:
|---|----------------------------------------------------|
| 1 | 将所有的 #define 删除,并展开所有的宏定义 |
| 2 | 处理所有的预编译指令,如 #if 、#endif、#ifdef、#elif、#else 等等 |
| 3 | 处理 #include 预编译指令,将包含的头文件的内容插入到预编译指令的位置 |
| 4 | 删除所有注释 |
| 5 | 添加行号和文件名标识,方便后续编译器生成调试信息等 |
| 6 | 保留所有的**#pragma**的编译器指令,编译器后续使用 |
[预处理(预编译)处理规则]
第6条我们曾在结构体内存对齐中曾经使用过,如:
cpp
//修改默认对齐数为1
#pragma pack(1)
//还原为默认对齐数
#pragma pack()
2) 编译
编译的主要作用将C语言代码转变为汇编代码 ,会对文件进行一系列的:词法分析,语法分析,语义分析及优化,生成相应的汇编代码文件。
如以下的这个代码:
cpp
arr[index] = (index + 5) * (3 * 8);
(1) 词法分析
将原代码程序输入扫描器,然后由扫描器进行词法分析,将代码中的字符分割成一系列的记号(关键词,标识符,特殊字符等)。上述代码会在扫描器中会分割成以下记号:
(2) 语法分析
经过词法分析之后,语法分析器会对产生的记号进行语法分析,产生语法树(以表达式为结点的树):
(3) 语义分析
接下来就是由语义分析器来完成语义分析,即对表达式进行语法层面分析。编译器的分析是语义分析的静态分析,通常包括声明和类型的匹配,类型转换等等。在这个阶段会报告错误的语法信息。
语义标识后的语法树如图所示:
3) 汇编
经过了预处理和编译之后,就到了汇编阶段,在该阶段会用汇编器将汇编代码转变为机器执行的指令,也就是二进制指令,不做指令优化。
经过了以上三个阶段,就生成了目标文件。
4) 链接
链接会将一堆文件链接在一起,生成一个可执行程序。
链接过程主要包括:地址和空间分配,符号决议和重定位等。其实链接解决的是一个项目中多文件、多模块之间的相互调用问题。
比如:
cpp
//add.c
int Add(int x, int y)
{
return x + y;
}
//test.c
#include<stdio.h>
//extern声明外部函数
extern int Add(int, int);
int main()
{
int a = Add(3, 5);
printf("%d\n", a);
return 0;
}
在add.c源文件中有一个Add函数。而在test.c源文件中,用extern关键字声明了外部函数并在main函数中使用了Add函数。
其实在链接的过程中,每一个源文件中分别会对函数和全局变量来生成一个符号表,这个符号表里面存储了函数和全局变量的地址,对于定义了的函数和全局变量会在符号表中存储真正的地址,而对于文件中没有定义只是声明的函数和全局变量的地址会先在符号表中存储一个无效的地址,然后链接的时候,会将符号表合并,在其他模块里面寻找真正定义过相同函数和全局变量的地址,来修正文件中引用到该函数的地址,若符号表中仍存在无效地址,咋会报链接错误。
比如在以上例子中,假设test.c 生成的符号表为:
|--------|------------------|
| Add函数 | 0x00000000(无效地址) |
| main函数 | 0x1187f3c0 |
[test.c 文件的符号表]
|-------|------------|
| Add函数 | 0x12f2d7c4 |
[add.c 文件的符号表]
在链接时,会将两个符号表合并生成一个新的符号表:
|--------|------------|
| Add函数 | 0x12f2d7c4 |
| main函数 | 0x1187f3c0 |
[合成的符号表]
再比如以下这个例子:
cpp
//add.c
int add(int x, int y)
{
return x + y;
}
//test.c
#include<stdio.h>
extern int Add(int x, int y);
int main()
{
int ret = Add(3, 5);
return 0;
}
|--------|------------------|
| Add函数 | 0x00000000(无效地址) |
| main函数 | 0x1187f3c0 |
[test.c 文件的符号表]
|-------|------------|
| add函数 | 0x12f2d7c4 |
[add.c 文件的符号表]
|--------|------------------|
| Add函数 | 0x00000000(无效地址) |
| main函数 | 0x1187f3c0 |
| add函数 | 0x12f2d7c4 |
[合成的符号表]
合成的符号表中出现了无效地址,所以运行时会报链接错误: