预编译:
通过在vscode的终端中输入 $gcc -E hello.c -o hello.i 将源代码文件进行预编译 ,在C语言中.c后缀源代码变成.i后缀的源代码。
功能:处理以#
开头的预处理指令,便于程序员编写和维护代码,这些指令让程序员能在编译前对源码进行特定处理和转换,还涉及去除注释、处理续行符等,以简化代码供编译器编译。
预处理指令包括:
-
#define
:定义宏或常量。(宏定义) -
#include
:包含其他文件的内容。 -
#if
、#ifdef
、#ifndef
、#else
、#elif
、#endif
:条件编译指令。 -
#undef
:取消已定义的宏。 -
#line
:改变当前的行号和文件名。 -
#error
:在编译时输出错误信息,并停止编译。 -
#
pragma:用于给编译器提供特殊的指令或设置。
为什么要在编写代码的时候加入#开头的预处理代码?
文本替换与简化:
#define
等指令允许程序员定义可在代码中重复使用的宏,便于统一管理和修改。代码模块化:
#include
指令帮助程序员将代码分割成模块,提高代码的可读性和复用性。条件编译:通过
#if
等条件指令,程序员可以根据特定条件选择性地编译代码,增加程序的灵活性和可配置性。
如果不使用#开头的指令:
代码会变得冗余,无法复用宏定义,导致相同的值或表达式在代码中多次出现。
代码结构变得混乱,无法使用
#include
来组织代码模块。程序会缺乏灵活性,无法实现条件编译,不能根据不同条件调整编译的代码块。
注:
-
预编译中处理”#include"指令过程,这个过程是递归进行的,被包含的文件中可能还会有其他文件
-
预处理还会中添加行号和文件名标识(文件名以及其后缀),用以快速标识错误信息所出现的位置
-
保留所有#pragma编译器指令 因为编译器必须要使用它们。(并不是所有编译器都支持相同的
#pragma
用法。)(除了传统的
#ifndef
,#define
,#endif
宏保护之外,#pragma也可提供额外的编译时控制。#pragma可以给编译器“打招呼”,用于预防全局变量多次定义或者函数重定义。) -
stdio文件被多个源文件通过include导入而不会发生函数重定义出错,因为
stdio.h
头文件中包含的是函数的声明,其函数定义在标准库中。 -
经过预编译的.i文件不包含任何宏定义(除了#pragma),所以宏定义都已经展开(类似复制粘贴的形式将代码复制源文件中)。
编译
通过在vscode的终端中输入 $gcc -S hello.i -o hello.s 将源代码文件进行编译。(.s后缀源文件是汇编语言源文件)
(gcc这个命令相当于将后台程序进行包装,根据不同的参数要求去调用预编译编译程序cc1、汇编器as、链接器ld)
功能:把预处理完成的文件进行一系列的词法分析、语法分析、语义分析以及优化后产生相应的汇编代码文件,是整个程序构建的核心部分。
1、词法分析
源程序经过扫描器(Scanner),扫描器进行简单的词法分析,将源代码的字符序列分割成一系列的记号(Token),词法分析生产的记号可以分为以下几类:关键字、标识量、字面量(包括数字、字符串等)、和特殊符号(如加号、等号)。在词法分析的过程中,编译器还会进行错误检查,例如检查是否有拼写错误或语法错误等
词法分析阶段有lex程序可以实现词法扫描,使得编译器开发者无需为每个编译器开发一个独立的词法扫描器只需要改变词法规则
注:有些预处理语言,例如C语言,其中宏替换和文件包一般不归入编译器的词法分析范围,而是交给独立的预处理器。
2、语法分析
语法分析(Syntax Analysis或Parsing)是编译过程的一个核心阶段,其主要任务是在词法分析的基础上,将单词序列(token sequence)组合成各类语法短语(如“程序”、“语句”、“表达式”等),并判断源程序在结构上是否正确。源程序的结构通常由上下文无关文法(Context-Free Grammar, CFG)来描述。
token组合举例 ,假设我们有以下的单词序列(token sequence):
int a = 5;
这个序列包含了以下几个token:
-
int
- 一个关键字,表示变量的类型是整数。 -
a
- 一个标识符,表示变量的名称。 -
=
- 一个赋值操作符,表示将右侧的值赋给左侧的变量。 -
5
- 一个整数常量,表示要赋给变量的值。 -
;
- 一个语句结束符,表示语句的结束。
在语法分析阶段,编译器会将这些token组合成语法短语,并构建一个抽象语法树(Abstract Syntax Tree, AST)。对于这个例子,抽象语法树可能看起来像这样:
赋值语句 ├─ 左值 │ └─ 变量声明 │ ├─ 类型: int │ └─ 变量名: a └─ 右值 └─ 整数常量: 5
这个抽象语法树清晰地表示了源代码的语法结构。树的根节点表示一个赋值语句,它有两个子节点:左值和右值。左值子树表示一个变量声明,包含了变量的类型和名称。右值子树则是一个整数常量。
具体来说,语法分析程序(Parser)的主要功能:
构建语法树:语法分析器将输入的单词序列根据语法规则组合起来,构建一个抽象语法树(Abstract Syntax Tree, AST)。这个树状结构清晰地表示了源代码的语法结构,每个节点代表源代码中的一个语法结构单元,如表达式、语句、函数等。
3、语义分析
语义分析(Semantic Analysis)是编译过程的另一个重要阶段,它紧随语法分析之后。语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,特别是进行类型审查。语义分析不仅检查语法上正确的句子所表示的意思是否合法,还执行规定的语义动作,如表达式求值、符号表填写、中间代码生成等。
(1) 表达式求值
虽然表达式求值通常不是语法分析阶段直接执行的任务,但在某些情况下,如即时(JIT)编译器中,或在某些高级编译器的优化过程中,可能会在执行语法分析的同时或之后立即进行表达式的部分求值。然而,更常见的是,在语法分析构建出抽象语法树(AST)之后,在语义分析阶段或之后的某个阶段进行表达式求值。
例子:考虑一个简单的算术表达式 3 + 4 * 2
。在语法分析阶段,这个表达式会被解析成一个抽象语法树,树的结构反映了表达式的语法结构。然后,在语义分析或之后的某个阶段,编译器会遍历这个树,根据运算符的优先级和结合性来求表达式的值。在这个例子中,由于乘法运算符(*
)的优先级高于加法运算符(+
),所以首先计算 4 * 2
得到 8
,然后再与 3
相加得到最终结果 11
。
(2) 符号表填写
符号表是编译器用于存储程序中标识符(如变量名、函数名等)及其属性(如类型、作用域、存储位置等)的数据结构。在语法分析和语义分析阶段,编译器会遍历源代码,识别出所有的标识符,并将它们及其属性添加到符号表中。
例子:考虑以下C语言代码片段:
int a = 10; void func() { int b = 20; a = a + b; }
在语法分析和语义分析过程中,编译器会识别出两个标识符:a
和 b
。它会检查这两个标识符的声明,并将它们及其属性(如类型、作用域等)添加到符号表中。对于 a
,编译器会注意到它是在全局作用域中声明的整数变量;对于 b
,编译器会注意到它是在 func
函数的作用域中声明的整数变量。
具体来说,语义分析器会执行以下操作:
-
类型检查:语义分析器会检查每个运算符是否具有语言规范允许的运算对象,以及运算对象的类型是否匹配。例如,在C语言中,数组变量不能直接用于表达式中,赋值语句的左右两侧类型必须匹配。如果发现类型错误,语义分析器将报告相应的错误信息。
-
符号表管理:语义分析器会维护一个符号表,用于记录程序中定义的各种标识符(如变量名、函数名等)及其属性(如类型、作用域等)。在语义分析过程中,语义分析器会查询符号表以获取标识符的属性信息,并进行相应的处理。
-
中间语言生成:语义分析器还会生成中间代码(Intermediate Code),这是一种介于源程序和目标代码之间的表示形式。中间代码的结构简单、含义明确,便于后续的代码优化和目标代码生成阶段处理。
4、中间语言产生
现代编译器又很多层次的优化,往往在源代码级别会有一个优化过程。通过源码级优化器(Souce Code Optimizer)进行优化,先将语法树整个转变成中间代码(Intermediate Code),中间代码是语法树的顺序表示位于源代码和目标机器代码之间,起到一个桥梁的作用。中间代码的设计是为了优化和跨平台兼容性。
中间代码跟目标机器和运行环境无关,其不包含数据的尺寸,变量地址和寄存器名称等。中间代码在不同的编译器中有不同的形式,常见的类型有(三地址码、P-代码),以三地址码举例,最基本的三地址码为 x = y op z。
为什么要将语法树转换为中间代码 ?
有了中间代码,编译器可以更容易地应用各种优化技术(直接优化语法树比较困难)。中间代码与目标机器无关,这意味着编译器可以首先生成中间代码,然后再针对不同的目标机器生成相应的机器代码。这使得编译器更加灵活,可以更容易地支持多种不同的平台和架构。
中间代码使得编译器被分为前端和后端,前端负责产生机器无关的中间代码,后端则是将中间代码转换为目标机器代码。
5、目标代码的生成与优化
中间代码的生成标志这后续过程都属于编辑器后端。编译器后端主要包括代码生成器(Code Generator)和目标代码优化器(Target Code Optimizer)。
代码生成器用于将中间代码转换为目标机器代码也可能会生成汇编语言代码,这一过程非常依赖目标机器,不同的机器有不同的步长、寄存器数量等。
注:“代码生成”阶段,编译器可以选择生成汇编语言代码(通常是.s
文件)或直接生成机器语言代码(通常是.o
文件)。这取决于编译器的设计和目标。
代码优化器通常是针对中间语言进行一系列优化,以提高程序的运行效率、减少代码的空间占用或改善其他与性能相关的指标。这些优化可以涉及多个方面,包括但不限于常量折叠、死代码消除、循环展开、寄存器重命名等。这些优化技术能够显著提升最终生成的可执行文件的性能和效率。
汇编
汇编将汇编代码编程机器可以执行的指令,每一个汇编语句都对应一条指令。汇编器的汇编过程相当于编译器来说比较简单,没有复杂的语法,只需要根据汇编指令和机器指令的对照表一一翻译就可以。
汇编过程可以调用汇编器as来完成。
链接
当一个软件十分复杂时,我们不得不将一个复杂的软件分割成一个个模块,每个模块的源代码独立地编译,然后按照需要将它们"组装"起来,这一组装的过程就是链接。
链接的主要过程包括地址和空间分配、符号决议(符号绑定)和重定位。
链接过程由链接器负责完成:
-
为所有代码和数据模块分配内存地址和空间,确保每个模块在最终的可执行文件中都有合适的位置。
-
链接器进行符号决议,即确保所有被引用的符号(如函数和变量名)都能找到对应的定义,以便程序在运行时能够正确调用和访问这些符号。
-
链接器执行重定位操作,将代码中的符号引用替换为实际的内存地址,从而确保程序在运行时能够准确地定位和操作所需的代码和数据。
链接的三种方式
-
静态链接:
-
在编译阶段,将所有需要的目标文件和库文件(静态库)链接成一个单独的可执行文件。
-
静态链接生成的可执行文件包含了程序运行所需的所有代码,不依赖于外部库。
-
-
装入时动态链接:
-
在程序装入内存时,才将所需的目标模块和库文件链接在一起。
-
这种链接方式可以减少内存的浪费,因为只有在程序加载时才会链接必要的模块。
-
-
运行时动态链接:
-
在程序运行时,根据需要动态地加载或卸载目标模块。
-
这种链接方式最为灵活,可以实现插件式的功能扩展,降低内存消耗,因为只在需要时才加载模块。
-