引言
我们平时写的所有的代码,其实都是文本信息,我们的代码是不能直接执行C语言代码的,计算机所能执行的是二进制的指令。想让计算机读懂我想要干什么,就需要把我们写的代码转化为二进制的指令(就是编译器的作用)。
一.编译环境和运行环境
在ANSI C的任何一种实现中,存在两个不同的环境:
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令(⼆进制指令)。
第2种是执行环境,它用于实际执行代码。
我们写出的这些text文件,会先到翻译环境把我们写的代码转换成计算机可以读懂的二进制的指令,然后到运行环境生成可执行程序,最后输出结果就行了。
二.翻译环境
那翻译环境是怎么将源代码转换为可执行的机器指令的呢?
其实翻译环境是由编译和链接两个大的过程组成的,而编译又可以分解成:预处理(有些书也叫预编译)、编译、汇编三个过程。
我们的后缀位.c的文件经过编译器后生成了后缀为.obj(在Windows环境下)的目标文件。生成目标文件的过程就叫做编译,从目标文件到链接器链接就叫做链接。
⼀个C语言的项目中可能有多个 .c 文件⼀起构建,那多个 .c 文件如何生成可执行程序呢?
• 多个.c文件单独经过编译器,编译处理生成对应的目标文件。
• 注:在Windows环境下的目标文件的后缀是 .obj ,Linux环境下目标文件的后缀是 .o
• 多个⽬标文件和链接库⼀起经过链接器处理行成最终的可执行程序。
• 链接库是指运行时库(它是支持程序运行的基本函数集合)或者第三方库。
关于编译器可以分为预处理(有些书也叫预编译)、编译、汇编三个过程。下面是在Linux环境下以gcc为例来实行的。
一个C语言代码经过预处理,编译,汇编,链接器才能生成可执行程序。
1.预处理(预编译)
在 gcc 环境下想观察⼀下,对 test.c 文件预处理后的.i文件,命令如下:
gcc -E test.c -o test.i
在预处理阶段,源文件和头文件会被处理成为.i为后缀的文件。
预处理阶段主要处理那些源文件中#开始的预编译指令。
比如:#include,#define,处理的规则如下:
• 将所有的#define 删除,并展开所有的宏定义。
• 处理所有的条件编译指令,如: #if、#ifdef、#elif、#else、#endif 。
• 处理#include预编译指令,将包含的头文件的内容插入到该预编译指令的位置。这个过程是递归进行的,也就是说被包含的头文件也可能包含其他文件。
• 删除所有的注释
• 添加行号和文件名标识,方便后续编译器生成调试信息等。
• 或保留所有的#pragma的编译器指令,编译器后续会使用。
经过预处理后的.i文件中不再包含宏定义,因为宏已经被展开。并且包含的文件都被插入到.i文件中。所以当我们无法知道宏定义或者头文件是否包含正确的时候,可以查看预处理后的.i文件来确认。
2.编译
编译进行了一系列的:词法分析,语法分析,语义分析及优化,生成相应的汇编代码文件。
命令如下:
gcc -S test.i -o test.s
(1)词法分析
比如这个代码
arr[index]=(index+1)
编译时会先把源代码程序输入扫描器,扫描器的作用就是进行简单的词法分析,把代码中的字符分割成一系列的记号(关键字,标识符,字面量,特殊字符等)
记号 | 类型 |
arr | 标识符 |
[ | 左方括号 |
index | 标识符 |
] | 右方括号 |
= | 赋值 |
( | 左圆括号 |
index | 标识符 |
+ | 加号 |
1 | 数字 |
) | 右圆括号 |
(2)语法分析
接下来语法分析器,将对扫描产⽣的记号进行语法分析,从而产生语法树。这些语法树是以表达式为 节点的树。
这就是语法树。
(3)语义分析
由语义分析器来完成语义分析,即对表达式的语法层面分析。编译器所能做的分析是语义的静态分析。静态语义分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息。
注意这里是静态分析,并不会把代码给执行起来,如果有语法错误的话,它会提前报错。因为编译器会识别出类型,比如我上面的(2)语法分析里的图,数字1是整型,标识符index也是整型,经过加法表达式后也是整型,最终到赋值表达式后也是整型。
也就是说,编译中的语义分析是指在编译过程中对程序的语义进行分析和判断的过程。语义分析的主要目标是检查程序中的语义错误、类型错误、作用域问题等,并提供相应的错误提示和修复建议。通过语义分析,编译器能够更好地理解程序的含义,为后续的优化和代码生成阶段提供有价值的信息。
3.汇编
经过编译之后我们得到的就是汇编代码,而汇编就是处理这些汇编代码的。
汇编器是将汇编代码转转变成机器可执行的指令,每⼀个汇编语句几乎都对应⼀条机器指令。就是根据汇编指令和机器指令的对照表⼀⼀的进行翻译,也不做指令优化。
gcc -c test.s -o test.o
4.链接
链接是一个复杂的过程,链接的时候需要把一堆文件链接在一起才生成可执行程序。
链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。
链接解决的是⼀个项目中多文件、多模块之间互相调用的问题。
比如我在一个C项目里包括多个.c的文件
上面说过,如果有多个.c(add.c和test.c)的文件,多个.c文件单独经过编译器,编译处理生成对应的目标文件(如果是在Windows环境下生成add.obj和test.obj,在Linux环境下生成add.o和test.o)。
我们在test.c文件中每⼀次使用Add函数和g_val的时候必须确切的知道Add和g_val的地址,但是由于每个文件是单独编译的,在编译器编译test.c的时候并不知道Add函数和g_val变量的地址,所以暂时把调用Add的指令的目标地址和g_val的地址搁置。等待最后链接的时候由链接器根据引用的符号Add在其他模块中查找Add函数的地址,然后将test.c中所有引用到
Add的指令重新修正,让他们的目标地址为真正的Add函数的地址,对于全局变量g_val也是类似的方法来修正地址。这个地址修正的过程也被叫做:重定位。
这个就是链接的作用,生成可执行程序.exe
三.运行环境
此时我们已经生成了可执行程序
1.程序必须载入内存中。在有操作系统的环境中:⼀般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2.程序的执行便开始接着便调用main函数。
3.开始执行程序代码。这个时候程序将使用⼀个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程⼀直保留他们的值。
4.终止程序。正常终止main函数;也有可能是意外终止。
到这里也只是浅显的解释了一下代码是怎么在计算机里运作的,感谢大家的观看,如有错误请,多多指出。