一、翻译环境和运行环境
我们在Visual Studio上写的C语言代码其实都是一些文本信息,计算机是不能够直接执行他们的,计算机只能够执行二进制指令。
要想计算机执行我们所写的C语言代码,就需要一个"翻译官",将我们写的C语言代码"翻译"成计算机能够执行的二进制指令。而承当"翻译官"这个角色的就是我们常说的编译器。
1. 翻译环境
在ANSI C的任何⼀种实现中,存在两个不同的环境。
- 第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令(二进制指令)。
- 第2种是执行环境,它用于实际执行代码。
1.1 编译
翻译环境是由编译和链接两个大的过程组成的,而编译又可以分解成:预处理(有些书也叫预编译)、编译、汇编三个过程。
编译过程:
- 每个.c源文件都是独立地通过编译器进行编译处理的。编译器会将源代码转换为机器可以理解的中间形式,即目标代码。
- 在Windows环境下,这些目标代码文件通常具有.obj扩展名;而在Linux环境下,目标文件的扩展名通常是.o。
- 编译过程包括预处理、编译(语法分析、语义分析、代码生成等)和汇编(将汇编代码转换为机器代码)。
test.c生成test.obj,Add.c生成Add.obj文件,每个C文件都会生成对应的目标文件,每个源文件都是经过编译器单独处理的。多个目标文件通过链接库生成我们的可执行程序。
如果在细分一点的话,编译又可以分解为预处理、编译、汇编三个过程。
1.1.1 预处理
在预处理阶段,源文件和头文件会被处理成为.i为后缀的文件。
它主要负责处理源代码中的预处理指令。预处理器是编译器的一个组成部分,它在编译器进行实际编译之前对源代码进行一系列的文本替换和宏替换操作。在 gcc 环境下想观察⼀下,对 test.c 文件预处理后的.i文件,命令如下:
gcc test.c -E -o test.i
预处理阶段的主要任务包括:
- 宏替换(Macro Expansion):
- 预处理器会处理所有的宏定义,将宏展开成它们所代表的代码。例如,如果定义了一个宏#define PI 3.14159,那么在预处理阶段,所有的PI宏在源代码中都会被替换成3.14159。
- 文件包含(File Inclusion):
- 使用#include指令可以将其他文件的内容包含进来。预处理器会找到这些指定的头文件,并将它们的内容插入到当前文件的相应位置。这使得程序员可以重用代码,例如在多个文件中共享函数声明和类型定义。
- 条件编译(Conditional Compilation):
- 预处理器还处理条件编译指令,如#ifdef、#ifndef、#if、#elif、#else和#endif。这些指令允许程序员根据特定的条件来包含或排除代码块,从而为不同的编译环境定制源代码。
- 移除注释(Comment Removal):
- 预处理器会删除源代码中的注释,因为注释对于编译器来说是无意义的。注释以//或/* … */开始,直到行尾或注释块的结束。
- 添加编译器指令(Adding Compiler Directives):
- 预处理器会添加一些特殊的编译器指令,如行号和文件名,这些信息对于调试程序非常有用。
处理其他预处理指令:
预处理器还处理一些其他的指令,如#pragma,这些指令通常用于向编译器提供特定的、非标准的指令或请求。
预处理阶段的输出是一个已经经过上述处理的源代码文本,这个文本接下来会被送到编译器的下一阶段——编译阶段。在编译阶段,编译器将对预处理后的代码进行词法分析、语法分析、语义分析等操作,最终生成目标代码或汇编代码。
可以看到,生成的test.i文件里面有八百多行代码,我们写的代码才区区几行,前面的几百行代码都是stdio.h这个头文件里面的内容。另外,我们写的宏定义也直接被替换掉了。源代码中的注释也已经被删除。
所以注释是给程序员们看的,而不是给编译器看的。
1.1.2 编译
编译过程就是将预处理后的文件进行⼀系列的:词法分析、语法分析、语义分析及优化,生成相应的汇编代码文件。
编译过程的命令如下:
gcc test.i -S -o test.s
编译阶段的主要步骤:
以这段代码为例:
array[index] = (index+4)*(2+6);
1.词法分析(Lexical Analysis):
- 编译器首先将预处理后的源代码进行词法分析,这一步骤涉及到将源代码字符串分解成一系列的记号(tokens)。记号是语言中最小的有意义的元素,如关键字、标识符、常量、运算符等。
- 词法分析器通常会构建一个抽象的记号流,供后续阶段使用。
上面程序进行词法分析后得到了16个记号:
- 语法分析(Syntax Analysis):
- 语法分析阶段,编译器根据C语言的语法规则检查记号流,构建一棵抽象语法树(AST)。这棵树表示了源代码的层次结构,反映了程序的逻辑组织。
- 如果源代码不符合语言的语法规则,编译器将在这一阶段报告语法错误。
- 语义分析(Semantic Analysis):
- 在语义分析阶段,编译器检查AST是否有意义,即检查语义正确性。这包括类型检查、变量声明的一致性、表达式的数据类型是否正确等。
- 语义分析还会进行符号表的构建,记录变量、函数等的相关信息。
- 中间代码生成(Intermediate Code Generation):
通过上述分析后,编译器会生成中间代码,这种代码是一种独立于机器语言的低级代码,它更加接近于机器指令,但仍然保持了一定的抽象。
中间代码的设计旨在使得代码优化更加容易进行。
- 代码优化(Code Optimization):
- 编译器会对中间代码进行优化,以提高代码的执行效率和减少资源消耗。优化可以在不同的层次进行,包括局部优化、循环优化、数据流分析等。
- 优化的目标是减少代码的空间和时间复杂度,提高程序的性能。
- 目标代码生成(Code Generation):
- 最后,编译器将优化后的中间代码转换成目标代码,即可以直接在特定硬件和操作系统上执行的机器代码或汇编代码。
- 目标代码生成阶段需要考虑目标平台的具体指令集和调用约定。
编译阶段是一个复杂的过程,涉及到对源代码的深入理解和转换。编译器的设计和实现需要考虑到语言的特性、目标平台的特点以及程序的性能要求。通过编译阶段,高质量的源代码被转换成有效的机器指令,为最终的程序执行奠定了基础。
1.1.3 汇编
汇编器是将汇编代码转转变成机器可执行的指令,每⼀个汇编语句几乎都对应一条机器指令。就是根据汇编指令和机器指令的对照表一一的进行翻译,也不做指令优化。
汇编的命令如下:
gcc test.s -c -o test.o
- 汇编指令:
- 汇编指令是针对计算机硬件的低级指令,它们通常与机器代码一一对应,但是以一种更易于人类理解和编写的形式表示。
- 汇编指令包括操作码(opcode)和操作数(operands),操作码指定要执行的操作,操作数提供操作所需的数据或地址。
- 地址和数据:
- 汇编器负责将汇编指令中的地址和数据转换为计算机可识别的二进制形式。
- 这包括对内存地址、寄存器、立即数等的处理和转换。
- 符号解析:
- 在汇编代码中,可能会使用标签(labels)和符号(symbols)来引用内存位置或数据。汇编器将这些符号解析为具体的地址或值。
- 例如,一个标签可能代表一个内存地址,汇编器需要确保所有对该标签的引用都被正确地转换为对应的地址。
- 目标文件生成:
- 汇编器处理完所有的汇编指令后,会生成一个目标文件(Object File)。目标文件包含了机器代码和与链接器(Linker)相关的符号信息。
- 目标文件通常具有特定的格式,如在Windows上通常是.obj文件,在Unix-like系统上通常是.o文件。
- 代码优化:
- 虽然主要的优化工作在编译阶段进行,但汇编器也可以执行一些简单的优化,比如消除冗余的指令或改善指令的顺序以提高执行效率。
- 依赖处理:
- 汇编器还需要处理源文件中对外部符号的依赖,这些外部符号可能定义在其他汇编源文件或库文件中。
- 汇编器记录这些依赖关系,并在链接阶段由链接器解决。
1.2 链接
链接是编译过程的最后一个阶段,它负责将编译阶段生成的一个或多个目标文件与所需的库文件合并,生成最终的可执行文件。链接过程由链接器(Linker)完成,它解决了目标文件之间的相互引用和依赖问题,确保程序中的所有函数和变量引用都能正确地指向它们的实现和定义。
- 符号解析(Symbol Resolution):
- 链接器处理程序中的符号,如函数和全局变量。每个符号都有一个唯一的名称,链接器需要确保每个符号引用都能正确地找到其对应的定义。
- 当一个目标文件引用了另一个目标文件中的符号时,链接器会找到该符号的定义,并在链接时进行适当的修改。
2.地址分配和重定位(Address Assignment and Relocation):
- 链接器为程序中的所有代码和数据分配内存地址。这个过程涉及到确定每个符号和数据在内存中的确切位置。
- 重定位是链接过程中的一个关键步骤,它涉及到修改代码中的地址引用,确保它们指向正确的内存位置。这是因为在编译时,编译器并不知道最终的内存布局。
3.处理静态和动态库(Static and Dynamic Libraries):
- 静态库在链接阶段被整合到最终的可执行文件中,成为程序的一部分。这意味着程序运行时不再需要这些库的单独文件。
- 动态库(或共享库)在程序运行时被加载。它们可以在多个程序之间共享,节省内存和磁盘空间。链接器在链接动态库时,会记录库的路径和所需的符号,以便在运行时找到它们。
4.生成可执行文件(Generating the Executable File):
- 链接器完成所有必要的链接工作后,会生成一个可执行文件。这个文件包含了程序的所有代码、数据、符号表、以及运行时所需的其他信息。
- 可执行文件的格式依赖于目标操作系统和平台。例如,在Windows上通常是.exe文件,在Linux上通常是没有扩展名的文件。
- 处理链接时错误(Link-Time Errors):
- 如果在链接过程中发现错误,如未定义的符号、多重定义、或者不兼容的库版本,链接器会报告这些错误。程序员需要根据错误信息对代码进行修正,然后重新编译和链接。
【示例】:
test.c
#include<stdio.h> //test.c //声明外部函数 extern int Add(int, int); //声明外部的全局变量 extern int g_val; int main() { int a = 10; int b = 20; int c = Add(a, b); printf("%d\n", c); return 0; }
Add.c
int g_val = 2022; int Add(int x, int y) { return x + y; }
我们已经知道,每个源文件都是单独经过编译器处理生成对应的目标文件。
test.c 经过编译器处理生成 test.o
add.c 经过编译器处理生成 add.o
我们在 test.c 的文件中使用了 add.c 文件中的 Add 函数和 g_val 变量。
我们在 test.c 文件中每⼀次使用 Add 函数和 g_val 的时候必须确切的知道 Add 和 g_val 的地址,但是由于每个文件是单独编译的,在编译器编译 test.c 的时候并不知道 Add 函数和 g_val变量的地址,所以暂时把调用 Add 的指令的目标地址和 g_val 的地址搁置。等待最后链接的时候由链接器根据引用的符号 Add 在其他模块中查找 Add 函数的地址,然后将 test.c 中所有引用到Add 的指令重新修正,让他们的目标地址为真正的 Add 函数的地址,对于全局变量 g_val 也是类似的方法来修正地址。这个地址修正的过程也被叫做:重定位。
2. 运行环境
- 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
- 程序的执行便开始。接着便调用main函数。
- 开始执行程序代码。这个时候程序将使用⼀个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程⼀直保留他们的值。
- 终止程序。正常终止main函数;也有可能是意外终止。