一、预处理阶段
预处理阶段主要是对源文件(通常是 .cpp
文件)进行一些文本层面的处理,为后续的编译过程做准备,该阶段主要完成以下几方面工作:
-
头文件包含(
#include
指令)
当遇到#include
语句时,预处理器会将指定的头文件内容直接插入到源文件中该#include
语句所在的位置。例如,有#include <iostream>
语句,预处理器会找到系统标准库中iostream
头文件的内容,并将其添加进来。同理,对于自定义的头文件,如#include "myheader.h"
,会把对应的myheader.h
文件内容插入。这样做的目的是让当前源文件能够获取到其他头文件中声明的函数、类、变量等相关信息,便于后续编译时进行类型检查等操作。 -
宏定义展开(
#define
指令)
对于通过#define
定义的宏,预处理器会将代码中使用该宏的地方按照宏定义的规则进行替换。比如:
#define PI 3.1415926
double radius = 5.0;
double area = PI * radius * radius;
在预处理阶段,代码中的 PI
都会被替换成 3.1415926
,经过替换后上述代码就变为:
double radius = 5.0;
double area = 3.1415926 * radius * radius;
另外,带参数的宏定义也会进行相应的参数替换展开,例如:
#define MAX(a,b) ((a) > (b)? (a) : (b))
int num1 = 10;
int num2 = 20;
int result = MAX(num1, num2);
会被替换为:
int num1 = 10;
int num2 = 20;
int result = ((num1) > (num2)? (num1) : (num2));
- 条件编译(
#if
、#ifdef
、#ifndef
、#else
、#endif
等指令)
根据条件编译指令来决定哪些代码块需要被保留,哪些需要被舍弃。例如:
#ifdef DEBUG
std::cout << "This is debug information" << std::endl;
#endif
如果在预处理阶段定义了 DEBUG
这个宏(可以通过命令行参数或者在代码中提前用 #define DEBUG
定义),那么 std::cout
这一行代码就会被保留下来进入后续编译阶段;反之,如果没有定义 DEBUG
,这行代码就会被预处理器直接删掉,不会参与后续编译等操作。
- 去除注释
预处理器会把源文件中的所有注释内容去除,无论是单行注释(//
形式)还是多行注释(/* */
形式)都会被移除,只留下有效的代码语句参与后续处理。
经过预处理后,源文件的内容在文本层面发生了很多改变,生成了一个经过预处理的中间文件(通常这个文件不会被保存下来,只是作为编译阶段的输入),这个文件内容相比原始源文件已经没有了头文件包含、宏都已展开、条件编译也处理好了,注释也都不存在了。
二、编译阶段
编译阶段是将经过预处理后的源文件(虽然它没有实际保存,但概念上是存在这样一个中间文件),从高级编程语言(C++)转换为汇编语言的过程,这一阶段主要涉及以下关键工作:
-
词法分析
编译器首先对输入的代码进行词法分析,将代码分割成一个个的词法单元(Token),比如关键字(如int
、class
、if
等)、标识符(变量名、函数名等)、常量(数值常量、字符串常量等)、运算符(+
、-
、*
、/
等)、界符({
、}
、;
等)。例如,对于代码int num = 10;
,词法分析后会得到int
(关键字)、num
(标识符)、=
(运算符)、10
(常量)、;
(界符)这几个词法单元。 -
语法分析
基于词法分析得到的词法单元,编译器会按照C++语言的语法规则构建出对应的语法树(也叫分析树)。语法树以一种树形结构来表示代码语句之间的语法关系,例如,对于语句if (a > 10) { b = 20; }
,会构建出一个包含if
条件判断节点、比较表达式节点(a > 10
)、语句块节点(包含b = 20
这个赋值语句)等的语法树,通过语法树能清晰地看出语句的语法结构是否正确,若不符合语法规则,编译器就会在这个阶段报错。 -
语义分析
在语法分析确认语法结构正确后,编译器会进行语义分析,主要检查代码中的语义是否合理,比如类型是否匹配、变量是否已声明再使用、函数调用的参数个数和类型是否正确等。例如,代码int num = "abc";
就会在语义分析阶段报错,因为不能将字符串赋值给int
类型的变量;再比如调用一个函数时传入的参数类型不符合函数声明的要求,也会在这个阶段被检测出来并提示错误。 -
代码优化
在确认语义正确后,编译器会对代码进行一定程度的优化,目的是提高生成的汇编代码的性能,比如去除一些冗余的代码、对一些表达式进行简化求值、调整代码执行顺序等。不过这些优化操作都是在符合程序语义的基础上进行的,不会改变程序最终的运行逻辑。 -
目标代码生成
最后,编译器会根据前面的分析和优化结果,将代码转换为汇编语言代码,不同的编译器可能生成的汇编代码风格略有差异,但都是对应于目标机器(如 x86 架构、ARM 架构等)可识别的汇编指令形式,例如对于简单的赋值语句int num = 10;
,可能会生成类似这样的汇编指令(以 x86 汇编举例,实际可能更复杂):
mov eax, 10
mov [num], eax
这意味着将常量 10
先传送到寄存器 eax
中,再将寄存器 eax
中的值存储到变量 num
对应的内存地址中。
经过编译阶段后,源文件就被转换为了汇编语言文件(通常以 .s
文件形式存在,不过有些编译器可能不会单独保存这个文件,而是直接进入下一个汇编阶段)。
三、汇编阶段
汇编阶段负责把编译阶段生成的汇编语言文件进一步转换为目标机器码(通常是二进制形式的机器指令),也就是将汇编指令翻译成机器能直接识别并执行的二进制代码,这个过程主要包括:
-
汇编指令解析
汇编器会读取汇编语言文件中的每一条汇编指令,并根据目标机器的指令集架构(比如 x86 指令集、ARM 指令集等)来确定每一条指令对应的二进制编码方式。不同的汇编指令有不同的二进制编码格式,例如在 x86 架构中,一条简单的mov
指令,根据操作数的不同(是寄存器到寄存器、寄存器到内存、内存到寄存器等情况),其对应的二进制编码也不同,汇编器要准确地将汇编指令转化为对应的机器码形式。 -
符号处理
在汇编语言文件中可能存在一些符号(比如变量名、函数名等),汇编器会给这些符号分配相应的内存地址(相对地址或者绝对地址,取决于具体情况),并将汇编指令中涉及到这些符号的地方用对应的地址进行替换,使得最终生成的机器码能够准确地在内存中找到相应的操作对象,例如在汇编语言中出现的变量num
,汇编器会确定它在内存中的地址,并在相关机器码中体现这个地址信息,方便后续执行时能正确访问到该变量。
经过汇编阶段后,就生成了目标文件(通常以 .o
文件形式存在,在 Windows 系统中可能是 .obj
文件),这个目标文件包含了机器码以及相关的符号信息等,不过它还不能直接运行,需要经过链接阶段进一步处理。
四、链接阶段
链接阶段是将多个目标文件(以及可能用到的库文件)组合在一起,解决符号引用等问题,生成最终可以直接运行的可执行文件,主要完成以下工作:
-
符号解析
不同的目标文件(可能来自不同的源文件编译、汇编后生成)中可能存在相互引用的符号,比如一个源文件中的函数调用了另一个源文件中定义的函数,在目标文件阶段这些符号只是一个未确定的引用,链接器要做的就是找到这些符号对应的实际定义所在的位置。例如,文件main.cpp
中调用了函数func()
,而func()
是在other.cpp
中定义的,经过编译、汇编后分别生成了main.o
和other.o
目标文件,链接器会在other.o
中找到func()
函数的机器码所在位置,将main.o
中对func()
的引用与这个实际定义连接起来。 -
重定位
由于目标文件中的符号地址在汇编阶段大多只是相对地址或者是基于自身所在目标文件的局部地址,在链接阶段需要根据最终可执行文件的整体布局进行重新定位,确定每个符号在最终可执行文件中的准确地址,以便程序运行时能正确地访问到相应的函数、变量等。例如,在一个目标文件中定义的全局变量,其在该目标文件内有一个相对的内存地址,在链接时要结合其他目标文件以及整个可执行文件的内存分配情况,将其地址调整为在最终可执行文件中的实际地址。 -
库文件链接
很多时候程序还会用到一些外部的库文件(静态库或者动态库),链接器会根据程序的需求将相应的库文件内容也整合进来。对于静态库,链接器会把库中用到的代码和数据直接复制到最终的可执行文件中;而对于动态库,链接器会记录程序对动态库的依赖关系以及相关符号的引用信息,以便在程序运行时动态加载和使用这些库。
经过链接阶段后,就生成了最终的可执行文件(在 Linux 系统中可能是没有后缀名或者带可执行权限的文件,在 Windows 系统中一般是 .exe
文件),这个文件就可以直接在对应的操作系统环境下运行了。
总之,C++代码的预处理、编译、汇编、链接这四个阶段是紧密配合、逐步将源文件转换为可执行文件的过程,每个阶段都有其独特且重要的作用,共同保障了程序从代码编写到最终运行的顺利实现。
以下是施磊老师网课截图