一、程序环境
在ANSI C的任何一种实现中,存在两个不同的环境:
- 翻译环境:这个环境中源代码被转换为可执行的机器指令。
- ;运行环境:它用于实际运行代码。
1、翻译环境具体步骤
翻译环境(Translation Environment)是指将C语言源代码转换成可执行的机器代码的过程。这个过程通常包括以下几个步骤:
1)预处理 / 预编译(Preprocessing):
- 处理源代码中的预处理指令,如
#include
、#define
、#ifdef
等。 - 宏替换,将宏定义替换为实际的代码。
- 条件编译,根据条件包含或排除某些代码块。
- 删除注释。
- 文件包含,将头文件的内容插入到源代码中。
最终生成一个没有预处理指令的纯C代码文件。
如果我们使用gcc来编译代码,这一步生成的结果我们可以通过以下指令来获得:
gcc -E source_file.c -o output_file.i
-E
选项告诉GCC在预处理阶段之后停止编译过程,不进行编译、汇编和链接。source_file.c
是你的C语言源代码文件。-o output_file.i
指定预处理后的输出文件名。
以下是示例:
如果我们的源代码为:
#include <stdio.h>
int main() {
printf("Hello World!\n");
return 0;
}
这里使用上面的指令可以产生 *.i 文件,这时这个文件可能会有很多代码,这是因为:生成的文件将包含stdio.h
头文件的内容。
因此,即使你的源代码只有几行,.i
文件也可能包含数百行甚至数千行代码。这是正常现象,因为.i
文件旨在展示预处理后的完整代码,包括所有被包含的头文件。
2)编译(Compilation):
- 将预处理后的源代码转换成汇编语言代码。
- 进行语法分析、语义分析、优化等。
- 生成汇编代码文件。
如果我们使用gcc来编译代码,这一步生成的结果我们可以通过以下指令来获得:
gcc -S source_file.c -o output_file.s
-S
选项告诉GCC在编译阶段之后停止,不进行汇编和链接,而是生成汇编代码。source_file.c
是你的C语言源代码文件。output_file.c
是生成的汇编文件
3)汇编(Assembly):
- 将汇编代码转换成机器语言指令。
- 生成目标文件(通常是
.obj
或.o
文件,Windows下是.obj
,Linux下是.o
)。
这个阶段生成的文件是二进制格式的,但还不是完整的可执行程序。
如果我们使用gcc来编译代码,这一步生成的结果我们可以通过以下指令来获得:
gcc -c source_file.c -o output_file.o
-c
选项告诉GCC在汇编阶段之后停止,不进行链接,而是生成目标文件。source_file.c
是你的C语言源代码文件。output_file.c
是生成的目标文件。
4)链接(Linking):
- 将一个或多个目标文件与链接库文件链接在一起。
- 解决外部符号引用。
- 生成最终的可执行文件(在Windows上通常是
.exe
文件,在Unix/Linux上没有扩展名)。
链接器解决外部符号引用,合并代码段和数据段,并可能进行地址重定位。
如果我们在Windows系统上使用gcc来编译代码,这一步生成的结果我们可以通过以下指令来获得:
gcc source_file.c -o output_file.exe
source_file.c
是你的C语言源代码文件。-o output_file.exe
选项告诉GCC将生成的可执行文件命名为output_file.exe
。如果你不指定-o
选项,GCC将使用默认名称,通常是a.exe
。
翻译环境的输出是一个可以在特定操作系统上运行的可执行文件:
2、翻译环境详细介绍
对于前三个步骤,如果一个项目中有多个源文件,那每个源文件都会单独被编译器执行上面的三个步骤,然后产生三个对应的目标文件。
然后第四步链接,则会将多个目标文件与链接库文件链接在一起。这里的链接库就是一些动态和静态库。
在Visual Studio中,编译器是cl.exe,链接器是link.exe。
对于前面的三个步骤,由于它们都是由编译器完成,所以可以合称为一个步骤,合称为编译,第四步由链接器完成,为单独一个步骤——链接。
1)预处理 / 预编译
#include <stdio.h>
#define PI 3.14
int main() {
printf("Hello World!\n");
printf("%d",PI);
//注释注释注释
return 0;
}
上面使我们的源代码,使用指令生成 .i 文件之后,我们可以看到 .i 文件的部分内容:
int main() {
printf("Hello World!\n");
printf("%d",3.14);
return 0;
}
可以发现这里发生了宏替换,宏定义被删除,宏被替换为实际的代码,还发生了删除注释,以及头文件包含,将头文件的内容插入到源代码中(由于头文件的内容太多,以下有部分截图)。
2)编译
将预处理后的源代码转换成汇编语言代码。进行语法分析,词法分析,语义分析,优化等。
①词法分析(Lexical Analysis)
词法分析是编译过程的第一步,它的任务是将源代码(字符流)转换成记号(tokens)流。记号是语言的最小语义单位,比如关键字、标识符、常量、运算符等。词法分析器(lexer或scanner)通过识别字符序列来生成这些记号。
在词法分析阶段,会忽略源代码中的空格、换行等非实质性字符。
②语法分析(Syntax Analysis)
语法分析是编译过程的第二步,它的任务是根据语言的语法规则(通常以文法的形式表示)来分析词法分析器生成的记号流,构建出语法树(syntax tree)或抽象语法树(abstract syntax tree, AST)。
语法分析器(parser)使用上下文无关文法(Context-Free Grammar, CFG)来描述语言的语法结构。在分析过程中,如果记号流不符合语法规则,编译器会报告语法错误。
③语义分析(Semantic Analysis)
语义分析是编译过程的第三步,它的任务是检查语法树是否符合语言的语义规则。语义分析器会进行类型检查,确保操作符和操作数之间的类型匹配,以及检查变量是否在使用前被声明等。
语义分析的结果通常是一个带有语义信息的抽象语法树,或者是一个中间代码表示,如三地址码(three-address code)。
④优化
优化可以发生在不同的层次,包括局部优化、循环优化和全局优化等。优化技术包括常量折叠、公共子表达式消除、死代码消除、循环不变量外提等。
补充
C语言的符号汇总(也称为符号表的构建)发生在编译阶段。在这个阶段,编译器会创建并维护一个符号表(symbol table),这是一个数据结构,用于存储源代码中定义的所有标识符(如变量名、函数名、类型名等)及其相关信息,如类型、作用域、内存地址等。
语义分析阶段是创建符号表的阶段,因为直到语义分析阶段,关于名称的足够信息才得以知晓,从而能够对其进行描述。
但是许多编译器在词法分析阶段就为程序中的各种变量建立了一个表,并在语义分析阶段填充有关符号的更多信息,因为此时对变量的了解更为充分。一个经典的例子来自FORTRAN和Ada语言,它们使用相同的语法来引用函数和数组。在这些语言中,F(2)可能指的是数组F的元素F2,或者是使用参数2计算的函数F的值。对于词法分析器来说,要做出这种区分,就需要添加一些语法和语义分析。
-
符号表的构建: 它是一个持续的过程,从词法分析开始,到语义分析阶段得到进一步的完善。
-
词法分析阶段: 在词法分析阶段,编译器将源代码分解成一个个的词法单元(tokens),如关键字、标识符、常量、运算符等。在这个阶段,编译器开始识别标识符,并将它们的信息初步记录到符号表中。
-
语法分析阶段: 在语法分析阶段,编译器根据语言的语法规则将词法单元组合成语法树。在这个阶段,编译器会进一步处理标识符的声明和定义,更新符号表中的信息。
-
语义分析阶段: 语义分析阶段是编译过程中的一个重要步骤,在这个阶段,编译器会检查语法树中的语义错误,如类型不匹配、变量未声明等。同时,编译器会完善符号表中的信息,确保每个标识符的类型、作用域和内存地址等信息都是准确和完整的。
-
符号表的作用: 符号表在整个编译过程中都起着至关重要的作用。它不仅用于存储标识符的信息,还用于错误检查、代码生成等后续步骤。在链接阶段,符号表的信息也会被链接器用来解析不同编译单元之间的符号引用。
例如下面的例子:
int main() {
int a = 5;
float b = 3.14;
int sum = a + b;
return 0;
}
在编译这个代码时,编译器会逐步构建和维护符号表。下面是一个可能的符号表示例,展示了在不同阶段添加的信息:
符号 | 类型 | 作用域 | 内存地址或偏移 | 其他信息 |
---|---|---|---|---|
main | 函数 | 全局 | 0x00400000 | 返回类型:int |
a | 整型变量 | main 函数 | 栈偏移 -4 | 初始值:5 |
b | 浮点型变量 | main 函数 | 栈偏移 -8 | 初始值:3.14 |
sum | 整型变量 | main 函数 | 栈偏移 -12 | 初始值:未初始化 |
3)汇编
汇编是将汇编语言代码转换成机器语言代码的过程。生成目标文件(通常是.obj
或.o
文件,Windows下是.obj
,Linux下是.o
)。这个阶段生成的文件是二进制格式的,但还不是完整的可执行程序。
这一步一般是由汇编器(Assembler)来进行,汇编器一般可以是编译器的一部分。
-
输入:汇编器的输入是汇编代码,这些代码是由编译器从C语言源代码编译而来的。编译器首先将C语言源代码转换成与特定体系结构相关的汇编代码。
-
输出:汇编器的输出是目标文件(Object File),它包含了机器语言指令和数据,以及一些必要的链接信息,如符号表和重定位信息。
汇编器也会进行一些简单的语法分析和词法分析,因为虽然汇编语言已经很接近机器语言了,但它仍然不是直接的机器代码,依旧需要通过词法和语法分析来解析和转换。
与上面编译步骤的符号表同样,这里也会创建符号表,但是这里的则是对于汇编中的符号。用于存储程序中定义的所有符号(如标签、变量、函数名等)及其相关信息。
4)链接
链接是编译过程中的一个重要步骤,它将多个目标文件(通常是.o
文件)和库文件组合成一个可执行文件。
链接过程包括以下步骤:
- 确定目标文件位置:链接器首先决定各个目标文件在最终可执行文件中的位置。
- 地址重定向:访问所有目标文件的地址重定义表,对其中记录的地址进行重定向。
- 符号解析:遍历所有目标文件的未解决符号表,并在所有导出符号表中查找匹配的符号,填写实现地址。
- 生成可执行文件:将所有目标文件的内容写入各自的位置,生成可执行文件。
下面介绍两个关键步骤:符号解析和重定位。
i.重定位
重定位是为了解决多个目标文件中可能存在的地址冲突问题。链接器需要调整每个目标文件中的地址,以确保它们在最终的可执行文件中能够正确地组合在一起。这个过程涉及到修改目标文件中的地址引用,使其指向正确的内存位置。
合并段表
段表(Section Table)记录了程序的代码段、数据段和其他段的信息。每个目标文件都有一个段表,描述了该文件中的各个段(如 .text
段、 .data
段、 .bss
段等)。一般在确定目标文件位置和地址重定向时进行。
合并段表的步骤:
-
收集段信息:
链接器读取每个目标文件的段表,收集所有段的信息。 -
创建全局段表:
链接器创建一个全局段表,将所有目标文件的段合并成一个统一的段表。 -
分配段地址:
链接器为每个段分配内存地址,确保它们在内存中的布局是连续且不重叠的。例如,所有.text
段合并在一起,所有.data
段合并在一起。 -
更新段偏移量:
链接器根据新的内存地址,更新每个段的偏移量。
ii.符号解析
符号解析的目的是将目标文件中引用的符号(函数和变量)与它们的定义进行匹配。每个目标文件都会提供两个符号表给链接器:
- 未解决符号表:列出了目标文件中引用的但尚未找到定义的符号及其对应的地址。
- 导出符号表:列出了目标文件中定义的,可以供其他目标文件使用的符号及其在本文件中的地址。
链接器会根据未解决符号表,在所有目标文件的导出符号表中查找匹配的符号。如果找到匹配的符号,链接器会将该符号的地址填入未解决符号的地址处;如果没有找到,链接器会报错。
符号表的合并
符号表(Symbol Table)存储了所有符号的信息,包括函数名、变量名及其相应的地址。每个目标文件也有自己的符号表。一般在符号解析和生成可执行文件时进行。
符号表合并的步骤:
-
收集符号信息:
链接器读取每个目标文件的符号表,收集所有的符号信息。 -
创建全局符号表:
链接器将所有目标文件的符号合并到一个全局符号表中。这里需要注意符号的唯一性,避免重复定义的符号。 -
解析符号引用:
链接器解析符号引用,将符号的定义和引用进行匹配。对于每个符号引用,链接器在全局符号表中查找对应的定义。
总结
2、运行环境(Execution Environment)
运行环境是程序实际执行的地方。
-
程序必须被加载到内存中才能执行。在有操作系统的环境中,这个过程通常由操作系统自动完成。在独立的环境中,程序的加载必须由手工安排,或者通过将可执行代码预先存储在只读内存(ROM)中来实现。
-
程序的执行从调用
main
函数开始。这是大多数编程语言中程序执行的入口点。 -
开始执行程序代码。此时,程序将使用堆栈(stack)来存储函数的局部变量和返回地址。程序还可以使用静态(static)内存,存储在静态内存中的变量在整个程序执行过程中一直保留它们的值。
-
程序终止。这可能是正常的,比如
main
函数执行完毕;也可能是异常的,比如由于错误或异常导致的提前终止。
在运行环境中,程序的行为包括:
- 加载:程序的机器代码被加载到内存中,准备执行。
- 执行:CPU按照程序的指令顺序执行代码,完成程序逻辑。
- 输入输出:程序与用户、文件系统、网络等进行数据交换。
- 错误处理:程序在运行时可能会遇到错误,需要进行错误处理和异常恢复。
- 资源管理:程序需要管理内存、文件句柄、网络连接等资源。