之前我们曾在《函数栈帧的创建与销毁》中说过,计算机实际上只能看懂机器语言,但尴尬的是,机器语言人是看不懂的(至少普通人是这样);于是我们有了汇编语言,有了高级语言,这些语言使得编程变得更加人性化和易于理解。
但不论计算机语言如何发展,最后都要回归于机器本身,都要被转化成机器能读懂的二进制语言,C语言是如何从文本一步一步地变成可被机器理解的机器语言,进而生成可执行程序,本文将会给你一个大致的框架。
翻译环境和运行环境
对于任何一个针对C语言的编译器来说,都会存在两个环境:
- 翻译环境,在该环境中,源代码会被转化成可执行的机器语言
- 执行环境,该环境用于执行代码,一般而言,是操作系统
翻译环境
在翻译环境中,C源代码将会经过编译和链接两大过程,从而转变为机器语言;而编译又可以细分为:预处理(也叫预编译),编译,汇编三个过程。
⼀个C语言的项目中可能有多个 .c 文件⼀起构建,那多个 .c 文件如何生成可执行程序呢?
- 多个.c文件单独经过编译器,编译处理生成对应的目标文件。
- 注:在Windows环境下的目标文件的后缀是 .obj ,Linux环境下目标文件的后缀是 .o
- 多个目标文件和链接库⼀起经过链接器处理生成最终的可执行程序。
- 链接库是指运行时库,它是支持程序运行的基本函数集合(库函数)或者第三方库。
如果再把编译器展开成3个过程,那就变成了下面的过程:
下面我们就在Windows操作系统下使用VScode来逐个执行:
环境搭建什么的我就跳过了,教程都是很多的,当然你光看我操作也行:
预处理
现在我们已经写好了一个简单的C代码:
#include<stdio.h>
#define Max 100
//注释
int main()
{
int a = Max;//注释
printf("hello word\n");
printf("%d",a);
return 0;
}
然后执行【终端】->【运行生成任务】
点一下终端,然后按任意键关闭:
使用cd指令切换到.c文件所在文件夹下:
使用指令,创建.i文件:
gcc -E hello.c -o hello.i
点击helio.i我们发现,此时代码已经有一千多行了
预处理阶段主要处理那些源文件中#开始的预编译指令。比如:#include,#define,处理的规则如下:
将所有的#define 删除,并展开所有的宏定义。
我们发现,在#define Max 100后面的所有Max都被替换成了100,#define Max 100也被删除了
处理所有的条件编译指令,如: #if、#ifdef、#elif、#else、#endif 。
处理#include预编译指令,将包含的头文件的内容插⼊到该预编译指令的位置。这个过程是递归进行的,也就是说被包含的头文件也可能包含其他文件。(你觉得这里变多的一千行代码是从哪里来的,不就是头文件展开的吗)
删除所有的注释(注释是给人看的,机器又不要)
添加行号和文件名标识,方便后续编译器生成调试信息等。
保留所有的#pragma的编译器指令,编译器后续会使用
经过预处理后的.i文件中不再包含宏定义,因为宏已经被展开。并且包含的头文件都被插入到.i文件中。所以当我们无法知道宏定义或者头文件是否包含正确的时候,可以查看预处理后的.i文件来确认。
好,预处理已经完成了,接下来是
编译
编译过程就是将预处理后的文件进行⼀系列的:词法分析、语法分析、语义分析及优化,生成相应的汇编代码文件。指令如下:
gcc -S hello.c -o hello.s
打开一看是汇编语言,那就不用再讲了,我不会汇编语言(汇编效率是高,就剩七十多行了)
那这编译过程究竟是怎么进行词法分析、语法分析、语义分析及优化的呢?下面我们以这行代码为例简单谈一谈:
array[index] = (index+4)*(2+6);
词法分析
将源代码程序被输入扫描器,扫描器的任务就是简单的进行词法分析,把代码中的字符分割成⼀系列的记号(关键字、标识符、字面量、特殊字符等)。上面程序进行词法分析后得到了16个记号:
记号 | 类型 |
---|---|
array | 标识符 |
[ | 左方括号 |
index | 标识符 |
] | 右方括号 |
= | 赋值 |
( | 左圆括号 |
index | 标识符 |
+ | 加号 |
4 | 数字 |
) | 右圆括号 |
* | 乘号 |
( | 左圆括号 |
2 | 数字 |
+ | 加号 |
6 | 数字 |
) | 右圆括号 |
语法分析
接下来语法分析器,将对扫描产生的记号进行语法分析,从而产生语法树。这些语法树是以表达式为节点的树。
语义分析
由语义分析器来完成语义分析,即对表达式的语法层面分析。编译器所能做的分析是语义的静态分析。静态语义分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息。
你看,整型加整型还是整型,整型乘整型还是整型,所以赋值表达式右边是整型;整型数组用下标操作符访问还是整型,赋值符左边是整型,右边也是整型,嗯,是对的,没有错误,至少目前没有。
汇编
汇编器是将汇编代码转变成机器可执行的指令,每⼀个汇编语句几乎都对应⼀条机器指令。就是根据汇编指令和机器指令的对照表一一翻译的,也不做指令优化。汇编的命令如下:
gcc -c hello.s -o hello.obj
生成了二进制的机器语言,打都打不开
链接
链接是⼀个复杂的过程,链接的时候需要把⼀堆文件链接在⼀起才能生成可执行程序。链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。链接解决的是⼀个项目中多文件、多模块之间互相调用的问题。
比如:
在⼀个C的项目中有2个.c文件( main.c 和add.c ),代码如下:
//"main.c"
#include<stdio.h>
//声明外部函数
extern int Add(int x,int y);
//声明外部变量
extern int g_val;
int main()
{
int a = 20;
int b = 10;
int sum = Add(a,b);
printf("sum==%d",sum);
printf("g_val==%d",g_val);
return 0;
}
//"add.c"
int g_val = 200;
int Add(int x, int y)
{
return x + y;
}
之前我们进行编译的时候,各个文件会生成各自的一张符号表,这张符号表会对全局的标识符分配一个地址,比如对于"main.c"来说,符号表可能是这样的:
看到了一个名为Add的标识符,再仔细一看,发现这个标识符只是个声明,没有具体内容,于是要对它的地址标记一下;然后看到了一个名为"g_val"的标识符,也是声明,没有具体内容,那就也对它的地址标记一下;最后看到一个名为"main"的标识符,这是有内容的,不用特殊标记
项目 | Value |
---|---|
Add | 被标记的地址Add |
g_val | 被标记的地址g_val |
main | 地址main |
对于"add.c"来说,符号表可能是这样的:
看到一个名为"g_val"的标识符,有具体内容,不用特殊标记;然后看到一个名为"Add"的标识符,地址也不用特殊标记
项目 | Value |
---|---|
g_val | 地址g_val |
Add | 地址Add |
同样的对于引用的头文件"stdio.h"也有一张类似的符号表,因为stdio.h内容太长,就不仔细说明了
链接时,会把这些符号表都拿出来,如果有重复的符号,有比对一下,选择有用的地址
比如"main"和"add.c"都有符号Add和g_val,到底最后选哪个地址作为参考标准呢?然后我们再仔细一看,发现"main"中的Add地址和g_val地址都是被标记的,而"add.c"中的Add地址和g_val地址没有被标记,所以最后后以"add.c"中的Add地址和g_val地址为准;这样,经过链接,整个项目就有了一个统一的符号表,这种地址修正的过程就被称为“重定位”,各个文件也得以组成一个整体。
如果最后得出的统一符号表中有些符号的地址是被标记的,是无效的,或者说之后运行的时候进入到局部的时候某些标识符不在那张统一的符号表上,,那编译器就报错了,说是无法解析的标识符。
前面我们非常简洁的讲解了⼀个C的程序是如何编译和链接,到最终生成可执行程序的过程,其实很多内部的细节无法展开讲解。比如:目标文件的格式elf,链接底层实现中的空间与地址分配,符号解析和重定位等,如果你有兴趣,可以看《程序的自我修养》⼀书来详细了解。据说是国内为数不多的好书。就是有点难度,可能要再学点东西。
运行环境
- 程序必须载入内存中。在有操作系统的环境中:⼀般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排(嵌入式里的程序烧录),也可能是通过可执行代码置入只读内存来完成。
- 程序的执行便开始。接着便调用main函数(main函数是程序入口)。
- 开始执行程序代码。这个时候程序将建立⼀个运行时堆栈(stack),用来存储函数的局部变量和返回地址。程序也可能使用静态(static)内存或动态内存,存储于静态内存中的变量在程序的整个执行过程⼀直保留他们的值,动态内存则要看什么时候释放。
- 程序终止。正常终止main函数;也有可能是意外终止。