✨✨ 欢迎大家来到贝蒂大讲堂✨✨
🎈🎈养成好习惯,先赞后看哦~🎈🎈
所属专栏:C语言学习
贝蒂的主页:Betty‘s blog
1. 翻译环境与运行环境
在**ANSIC(标准C)**的任何⼀种实现中,存在如下两个不同的环境。
- 第1种是翻译环境,在这个环境中源代码被转换为可执⾏的机器指令(⼆进制指令)。
- 第2种是执行环境,它⽤于实际执⾏代码。
首先后缀为
.c
的源文件转变为后缀为.exe
的可执行程序的过程叫做翻译环境,翻译环境又由编译和链接两部分组成,而对于编译来说,又可以继续细分为预编译,编译与汇编三个组成部分。最后生成的可执行程序的文件被计算机执行生成输出结果的过程叫做运行环境。
在知道上述过程之后,接下来我们先探究一下在Windows
环境下一个程序被最终执行到底经历了哪些过程。
首先在翻译环境中,每个源文件都会经过编译器处理生成后缀为.obj
(Windows环境),或.o
(Linux环境下)的目标文件,然后多个目标文件又会经过链接器的处理以及链接库链接生成可执行程序。
然后当进入运行环境时,会把程序加载到内存中,运用堆栈来存储函数的局部变量和返回地址,进而计算得出程序的运行结果。如果程序中存在打印语句,就会把结果在屏幕上显示出来。
其中需要注意的是:
链接库是指运⾏时库(它是⽀持程序运⾏的基本函数集合)或者第三⽅库。其中库函数就是存放在链接库里的,当程序里要使用如:
scanf
,printf
等库函数时,如果包含对应的头文件,在链接时就会把他们所依赖的链接库链接进来。我们日常使用的集成开发环境(IDE)通常包含编辑、编译、链接、调试这些功能,这些具体的功能都有专门的板块实现,以VS2022为例:
编辑功能:【编辑器】
编译功能:【编译器】,VS2022为
cl.exe
链接功能:【链接器】,VS2022为
link.exe
调试功能:【调试器】
2. 翻译环境
接下来我们重点谈论一下翻译环境中的编译与链接。为了方便演示,下面试验将在Linux gcc环境下进行。如果并不了解Linux的基本指令也不影响,只需要观察现象即可。
2.1 编译
首先编译分为预处理,编译,汇编。我们可以借助以下代码进行探究:
在add.c
文件中写入:
int Add(int x, int y)
{
return x + y;
}
在test.c
文件写入:
#include <stdio.h>
extern int Add(int x,int y);
#define MAX 100
int main()
{
//这是一个注释
int a = 10;
int b = 20;
int m = MAX;
int c = Add(a,b);
printf("a = %d\n", a);
return 0;
}
2.1.1 预处理
在写入add.c
与test.c
两个头文件后,我们在Linux环境下执行以下指令。
gcc -E test.c -o test.i
这句指令的含义是test.c
预处理就直接结束,并将输出内容输出到test.i
文件中。
从上图观察我们发现,头文件被展开了,注释消失了,宏定义被替换了。当然预处理阶段进行的操作还不止这些,大致可以分为如下操作:
- 删除所有的
#define
,并展开所有的宏定义。- 处理所有的条件编译指令,如
#if
、#ifdef
、#elif
、#else
、#endif
。- 处理
#include
预编译指令,将包含的头文件的内容递归地插入到该预编译指令的位置。- 删除所有的注释。
- 添加行号和文件名标识,以便后续编译器生成调试信息等。
- 保留所有的
#pragma
的编译器指令,编译器后续会使用。
经过预处理后的.i⽂件中不再包含宏定义,因为宏已经被展开。并且包含的头⽂件都被插⼊到.i⽂件中。所以当我们⽆法知道宏定义或者头⽂件是否包含正确的时候,可以查看预处理后的.i⽂件来确认。
2.1.2 编译
编译过程就是将预处理后的⽂件进⾏⼀系列的:词法分析、语法分析、语义分析及优化,⽣成相应的汇编代码⽂件。 在Linux
环境下执行以下代码:
gcc -S test.i -o test.s
这句指令的含义就是将test.i
编译后直接结束,生成的内容放到test.s
文件中。
打开test.s
文件我们发现都是一些我们看不懂的指令,这些其实就是我们常说的汇编代码。
下面我们以这段代码为例,具体谈谈编译是如何进行的:
array[index] = (index+4)*(2+6);
- 词法分析
将源代码程序被输⼊扫描器,扫描器的任务就是简单的进⾏词法分析,把代码中的字符分割成⼀系列的记号(关键字、标识符、字⾯量、特殊字符等)。上⾯程序进⾏词法分析后得到了16个记号:
记号 | 类型 |
---|---|
array | 标识符 |
[ | 左方括号 |
index | 标识符 |
] | 右方括号 |
= | 赋值 |
( | 左圆括号 |
index | 标识符 |
+ | 加号 |
4 | 数字 |
) | 有圆括号 |
* | 乘号 |
( | 左圆括号 |
2 | 数字 |
+ | 加号 |
6 | 数字 |
) | 右圆括号 |
- 词法分析
接下来语法分析器,将对扫描产⽣的记号进⾏语法分析,从⽽产⽣语法树。这些语法树是以表达式为节点的树。
- 语义分析
由语义分析器来完成语义分析,即对表达式的语法层⾯分析。编译器所能做的分析是语义的静态分析。静态语义分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息。
所以编译阶段做的事情很多,我们这里可以简单总结出以下几个点:
- 编译过程包括以下步骤:扫描程序、语法分析、语义分析、源代码优化、代码生成器、目标代码优化。
- 扫描程序进行词法分析,从左向右、从上往下扫描源程序字符,识别出各个单词并确定其类型。
- 语法分析根据语法规则,将输入的语句构建出分析树或语法树。
- 语义分析根据上下文分析函数返回值类型是否对应,语法分析相当于描述句子主宾谓是否符合规则,而语义分析相当于检测句子的意思是否正确。
- 目标代码生成是把中间代码变换成为特定机器上的低级语言代码。
- 对全局变量如
main
,Add
进行符号汇总。
2.2.3 汇编
汇编器是将汇编代码转转变成机器可执⾏的指令,每⼀个汇编语句⼏乎都对应⼀条机器指令。就是根据汇编指令和机器指令的对照表⼀⼀的进⾏翻译,也不做指令优化。在Linux
环境下执行以下语句:
gcc -c test.s -o test.o
这句指令是将test.s
汇编之后结束,内容放在test.o
中。
我们打开test.o
文件,发现全是一堆乱码一样的东西,这其实就是我们的二进制代码也就是机器指令。
然后汇编阶段还有一个重要的东西叫做生成符号表。我们可以通过在Linux
环境下,执行以下命令查看:
readelf -s test.o
这张符号表会在链接阶段起着非常关键的作用。
2.2 链接
链接是⼀个复杂的过程,链接的时候需要把⼀堆⽂件链接在⼀起才⽣成可执⾏程序。链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。其中链接解决的是⼀个项⽬中多⽂件、多模块之间互相调⽤的问题 。在Linux
环境下,执行以下指令
gcc test.o add.o -o myout
这句指令含义就是将test.o
,add.o
链接最终生成myout
文件,也就是我们的可执行文件。然后执行就会输出结果。
接下来我们来探究在一个文件中调用另一个文件的函数与全局变量究竟是如何实现的?
首先在
test.c
文件中,每次使用Add
函数和g_val
变量时,必须确切知道其地址。然而,因文件单独编译,编译器编译test.c
时不知Add
函数和g_val
变量的地址,故先搁置调用Add
指令的目标地址及g_val
的地址。待最后链接时,链接器依据引用的符号Add
在Add.o
中中查找Add
函数的地址,然后修正test.o
中所有引用Add
的指令,使其目标地址为真正的Add
函数地址。对全局变量g_val
也采用类似方法修正地址,此地址修正过程称为重定位。最终通过这个地址就可以调用对应的函数与全局变量。
而修正地址其实就是通过我们前面汇编所生成的符号表。
最后我们来总结一下再Linux
环境下,源代码是如何生成可执行程序的。
3. 运行环境
最后我们在谈论一下可执行程序在运行的具体步骤:
- 载入内存:
- 在有操作系统的情况下,通常由操作系统负责完成。
- 在独立环境中,需手动安排或通过将可执行代码置入只读内存来实现。
- 执行启动:
程序开始执行,并调用main
函数。- 代码执行:
执行程序代码时,会使用运行时堆栈来存储函数的局部变量和返回地址,同时也能使用静态内存,其中的变量在整个程序执行期间一直保持其值。- 程序终止:
- 正常情况下,
main
函数结束则程序正常终止。- 也可能因意外情况导致程序意外终止。
例如,在 Windows
操作系统中,打开一个应用程序时,操作系统会将其载入内存并启动执行;而在一些嵌入式系统等独立环境中,可能需要开发者手动设置程序的载入方式。在程序运行中,函数内部的临时变量会存放在运行时堆栈中,而像全局的静态变量则一直保留其值。如果程序遇到严重错误或被强制关闭,就属于意外终止的情况。