1. 预处理
预处理阶段主要负责做:文件包含(头文件处理),宏替换,条件编译,主要负责处理源代码中的预处理指令。这些指令以 # 开头,常见的预处理指令包括 #include、#define、#ifdef 等.
-
文件包含处理 (#include)
作用:将指定的头文件内容插入到源文件中。通常用来引入库文件或自定义的头文件。
实现:在处理 #include 时,编译器会查找标准库路径;在处理 #include “filename” 时,编译器会优先在当前目录查找。 -
宏定义与替换 (#define)
作用:定义宏,以便在代码中使用简短的符号表示复杂的常量或表达式。
实现:例如,#define PI 3.14 会在后续代码中将 PI 替换为 3.14。 -
条件编译 (#ifdef, #ifndef, #if, #else, #endif)
作用:根据条件编译特定的代码块。适用于在不同平台或配置下包含不同的代码。
实现:如使用 #ifdef DEBUG 可以控制调试信息是否编译。
2. 编译
编译阶段进行语法分析, 词法分析, 语义分析,并且将代码优化后产生相应的汇编代码文件(ASCLL文件),即.s 文件。这个过程是整个程序构建的核心部分,也是最复杂的部分之一
-
词法分析:
在这个阶段,编译器读取预处理后的源代码,并将其分解成一系列的标记(tokens)。标记包括关键字(如 int, class, return)、标识符(如变量名、函数名)、常量(如数字、字符串)和运算符(如 +, -, *)等。 -
语法分析:
语法分析器(parser)接收词法分析器产生的标记流,并根据 C++ 语言的语法规则构造抽象语法树(Abstract Syntax Tree, AST)。AST 是源代码的一种树状表示形式,它反映了源代码的结构和逻辑层次。 -
语义分析:
在这个阶段,编译器会对 AST 进行进一步的分析,以确保代码符合 C++ 的语义规则。这包括类型检查、作用域分析、符号表构建等。
类型检查确保表达式中的类型是兼容的,例如两个整数类型的加法。
作用域分析确保名字(如变量、函数)在正确的范围内被声明和引用。
符号表记录了程序中所有的名字及其相关信息,如类型、作用域等。 -
中间代码生成
作用:将抽象语法树转换为中间代码。
实现:中间代码是一种低级别的代码,通常与具体的机器架构无关,使得编译器可以在不同平台上进行优化和代码生成。常见的中间代码形式包括三地址码、静态单赋值(SSA)形式等。 -
代码优化:
在一些编译器中,优化步骤可能会在此阶段或生成目标代码之前进行。优化技术旨在改进生成的代码,提高其执行效率。常见的优化技术包括常量折叠(constant folding)、循环展开(loop unrolling)、内联展开(inline expansion)等。 -
代码生成:
最后一步是将优化后的中间代码转换成目标代码(通常是汇编语言)。目标代码是对机器指令的一种抽象表示,它可以被后续的链接器用来生成可执行文件或库文件
3. 汇编
这一阶段不同平台(Windows、Linux)的汇编器将汇编代码翻译成机器码,即生成二进制可重定向文件(.o),转换成机器语言代码,也就是可以直接由计算机硬件执行的二进制指令
汇编阶段的具体步骤
-
读取汇编代码:
汇编器读取由编译器生成的汇编代码文件。 -
翻译成机器指令:
汇编器逐行读取汇编代码,并将每条汇编指令转换成对应的机器指令。 -
生成符号表:
汇编器记录所有定义的符号及其地址,并生成符号表。 -
生成重定位信息:
汇编器记录需要在链接时进行重定位的部分,并生成重定位信息。 -
生成目标文件:
汇编器将所有信息打包成一个目标文件,通常是一个二进制文件,如 .o 文件。
4. 链接
链接就是进行符号解析和重定位的过程, 这一阶段负责将多个目标文件( .o 文件)组合成一个可执行文件或库文件。链接器(linker)的任务是解决各个目标文件之间的依赖关系,并确保最终生成的可执行文件或库文件能够在运行时正确加载和执行。
程序的链接阶段主要分两个步骤:
第一步:由于每个.o文件都有都有自己的代码段、bss段,堆,栈等,所以链接器首先将多个.o 文件相应的段进行合并,建立映射关系并且去合并符号表。进行符号解析,符号解析完成后就是给符号分配虚拟地址。
第二步:将分配好的虚拟地址与符号表中的定义的符号一一对应起来,使其成为正确的地址,使代码段的指令可以根据符号的地址执行相应的操作,最后由链接器生成可执行文件。
链接阶段的主要任务包括:
解决符号引用:链接器需要解决不同目标文件之间的符号引用(symbol references),即确保每个目标文件中引用的符号在其他目标文件中有正确的定义。
重定位:链接器需要根据最终的布局对代码和数据进行重定位,以确保在运行时它们位于正确的内存位置。
合并符号表:链接器需要合并所有目标文件中的符号表,以便在整个程序中唯一标识每个符号。
插入库文件:如果程序依赖于外部库,链接器还需要将这些库文件中的相关部分插入到最终的可执行文件或库文件中。
链接阶段的具体步骤
- 收集目标文件:
链接器收集所有需要链接的目标文件(.o 文件)和库文件(如 .a 或 .so 文件)。 - 符号解析:
链接器解析各个目标文件中的符号引用,并查找相应的符号定义。
如果一个目标文件中引用了一个符号,链接器会在其他目标文件或库文件中查找该符号的定义。
如果找不到某个符号的定义,链接器会产生一个错误,指出未定义的符号。 - 重定位:
链接器根据最终的布局对代码和数据进行重定位。
这涉及到更新目标文件中的重定位信息,确保符号在最终的可执行文件或库文件中具有正确的地址。 - 合并符号表:
链接器合并所有目标文件中的符号表,生成一个全局的符号表。
确保每个符号在整个程序中唯一,并且可以被正确引用。 - 插入库文件:
如果程序依赖于外部库,链接器会插入这些库文件中的相关部分。
动态库(如 .so 文件)通常在运行时加载,而静态库(如 .a 文件)则在链接时直接插入到最终的可执行文件中。 - 生成最终文件:
链接器将所有处理后的代码和数据组合成一个可执行文件或库文件。
可执行文件(如 .exe 文件在 Windows 中,.out 或直接为程序名称在 Linux 中)包含了所有必要的信息,使得程序能够在目标平台上运行。
静态链接
要了解静态链接,我们得先了解静态库,静态库(static library)是“库”最典型的使用方式。在UNIX系统中,一般使用 ar 命令生成静态库,并以 .a 作为文件扩展名,”lib” 作为文件名前缀。在Windows平台上,静态库的扩展名为 .lib。链接器在将所有目标文件集链接到一起的过程中,会为所有当前未解决的符号构建一张“未解决符号表”。当所有显示指定的目标文件都处理完毕时,链接器将到“库”中去寻找“未解决符号表”中剩余的符号。如果未解决的符号在库里其中一个目标文件中定义,那么这个文件将加入链接过程,这跟用户通过命令行显示指定所需目标文件的效果是一样的,然后链接器继续工作直至结束。
动态链接
对于像 C 标准库这类常用库而言,如果用静态库来实现存在一个明显的缺点,即所有可执行程序对同一段代码都有一份拷贝。如果每个可执行文件中都存有一份如 printf, fopen 这类常用函数的拷贝,那将占用相当大的一部分硬盘空间,这完全没有必要。所以我们使用动态链接的方法来进行优化。
它是这样进行链接的,当链接器发现某个符号的定义在DLL中,那么它不会把这个符号的定义加入到最终生成的可执行文件中,而是将该符号与其对应的库名称记录下来(保存在可执行文件中)。当程序开始运行时,操作系统会及时地将剩余的链接工作做完以保证程序的正常运行。在 main 函数开始之前,有一个小型的链接器(链接器隶属于系统)将负责检查贴过标签的内容,并完成链接的最后一个步骤:导入库里的代码,并将所有符号都关联在一起。在系统的管理下,应用程序与相应的DLL之间建立链接关系。当要执行所调用DLL中的函数时,根据链接产生的重定位信息,系统才转去执行DLL中相应的函数代码。一般情况下,如果一个应用程序使用了动态链接库,Win32系统保证内存中只有DLL的一份复制品。