本节详细介绍c++的编译过程,c++从代码到可执行文件有四个阶段:
- 预处理 运行以#好开头的代码,引入头文件,做预处理定义常量等
- 编译 对代码进行优化,进行词法与语法的分析,生成与平台无关的中间表示,再将中间代码转换为目标平台的汇编代码。
- 汇编 将汇编代码转换为机器码(二进制格式)。
- 链接 将目标文件中的未定义符号(如
printf
)与库文件中的定义匹配。
预处理
我们可以使用g++ -E main.cpp -o main.i
这一命令进行预处理,预处理后的文件还是文本文件可以打开查看以.i为结尾
这一阶段主要处理以井号#开头的代码,比如#include
, #define
, #ifdef
,对代码进行一个初步的处理,并且会把所有注释删除,通过#include将头文件的实际内容放入当前文件中,主要有两种格式#include <header>
或 #include "header"
,对于尖括号的格式,编译器会在默认的头文件搜索路径(通常是标准库头文件所在的系统目录)中查找指定的头文件。例如像<iostream>
、<vector>
这样的标准库头文件,通常使用尖括号来引用,对于双引号格式,编译器会优先在当前源文件所在的目录下查找头文件。如果没找到,才会继续在编译器默认的头文件搜索路径中查找。通常用于包含用户自定义的头文件
预处理阶段还会进行宏展开,在这个阶段编译器会将#define定义的宏替换为实际的值,预处理器会将宏的名称和对应的替换内容存储在内部的宏定义表中,预处理器会扫描源代码,查找与宏名称完全匹配的标识符,当找到匹配的宏名称时,会用宏的替换内容替换掉宏名称。下面的代码演示了预处理阶段的宏替换
#define PI 3.14159
#define SQUARE(x) ((x) * (x))
double area = PI * SQUARE(12);// g++ -E main.cpp -o main.i 预处理
double area = 3.14159 * ((12) * (12));
预处理阶段另一个主要作用是条件编译,可以使用#ifdef
、#ifndef
等条件选择性地包含或排除代码块,它主要有两点作用:1.避免头文件重复引用,2.可以实现对于不同操作系统,使用不同的代码实现,并且可以让其他系统的实现不被编译,这两种作用我们一一来讲,先来看下如何发生头文件重复引用的
// myClass.hpp 定义一个类
class MyClass {};// test.h 需要用到MyClass类,引用了它的头文件
#include"myClass.hpp"// main.cpp 这里需要用到myClass.hpp与test.h,两者的内容全部引用了
#include"test.h"
#include"myClass.hpp"
//这里不难发现我们引用了两次myClass.hpp,test中已经引用了,而在main文件中又引用了一次// 报错信息
In file included from main.cpp:6:
myClass.hpp:2:7: error: redefinition of 'MyClass'2 | class MyClass { /* ... */ };| ^
test.h:5:9: note: 'myClass.hpp' included multiple times, additional include site here5 | #include"myClass.hpp"| ^
main.cpp:6:9: note: 'myClass.hpp' included multiple times, additional include site here6 | #include"myClass.hpp"| ^
myClass.hpp:2:7: note: unguarded header; consider using #ifdef guards or #pragma once2 | class MyClass { /* ... */ };| ^
1 error generated.
make[2]: *** [CMakeFiles/cpp_study.dir/main.cpp.o] Error 1
make[1]: *** [CMakeFiles/cpp_study.dir/all] Error 2
make: *** [all] Error 2
你可以会觉得这种事情是可以手动避免的,因为我们完全可以不再引用myClass.hpp,而上使用test中的myClass.hpp啊,是可以这样但如果你的项目非常庞大,你还能理清楚你的引用关系吗?况且又不是没有更好更简单的解决办法,为什么要劳累自己呢,将myClass.hpp文件修改成如下,就可以完美解决问题了
#ifndef MYCLASS_HPP
#define MYCLASS_HPP
class MyClass {};
#endif // MYCLASS_HPP
这个代码的意思是检测MYCLASS_HPP是否定义,如果没定义就往下走,如果定义了就自己退出,#define的作用是定义MYCLASS_HPP,通过这个代码我们可以保证当前头文件,只会被定义一次,这里建议所有头文件都要加上这种条件编译来防止头文件重复引用
现在还有另外一种方式可以实现同样的方法,#pragma once
功能与上面一样,只需要放在头文件的开头非常简洁,但要注意这不是c++的标准,你正在使用的编译器可能没有实现这个功能,不过目前主流的编译器都可以使用(GCC、Clang、MSVC、MinGW 均支持)
对于第二个作用,cpp提供了几个宏定义用来区分各个平台,预处理后下面的代码,,会被直接替换成a对于几,我这里是mac所以是4,实际预处理输出会保留行号标记(如 # 31 "main.cpp"
),建议注明这是简化后的示意代码。
int a = 1;
int main()
{
#ifdef _WIN32// Windows 平台a = 2;
#elif __linux__// Linux 平台a = 3;
#elif __APPLE__// macOS 平台a = 4;
#endifstd::cout << "a = " << a << std::endl;return 0;
}// g++ -E main.cpp -o main.i 预处理后
int a = 1;
int main()
{
# 31 "main.cpp"a = 4;std::cout << "a = " << a << std::endl;return 0;
}
编译
编译只是把我们写的代码转为汇编代码,它的工作是检查词法和语法规则,所以,如果程序没有词法或则语法错误,那么不管逻辑是怎样错误的,都不会报错,编译不是指程序从源文件到二进制程序的全部过程,而是指将经过预处理之后的程序转换成特定汇编代码(assembly code)的过程。
使用g++ -S main.i -o main.s
这个命令,可以将预处理后的代码转换为汇编代码,汇编也是文本文件我们可以打开查看,以.s为结尾
这个过程是整个程序构建的核心部分,也是最复杂的部分之一。
- 词法分析:将代码分解为词法单元(Token)。
- 语法分析:构建抽象语法树(AST),检查语法错误。
- 语义分析:检查类型匹配、函数声明等语义错误。
- 中间代码生成:生成与平台无关的中间表示(如 LLVM IR)。
- 优化:对代码进行优化(如删除冗余代码、循环优化等)。
- 生成汇编代码:将中间代码转换为目标平台的汇编代码。
汇编
这一阶段会通过不同平台的汇编器,将汇编代码翻译成机器码,生成二进制可重定向文件(.o),在编译阶段,源代码文件(如 .cpp
文件)被翻译成汇编代码,然后通过汇编器将汇编代码翻译成机器码,生成二进制的目标文件(.o
文件)。这个目标文件包含了源代码翻译后的机器指令,但这些指令是孤立的,尚未与其它文件或库进行链接。
- 未解决外部引用:目标文件中可能包含对其他文件或系统库中函数和变量的引用,这些引用在编译阶段未被解析。例如,如果你的代码中使用了
std::cout
,编译后的目标文件中会有对std::cout
的引用,但此时这个引用尚未指向实际的实现。 - 缺少运行时环境:可执行文件需要包含程序运行所需的所有代码和数据,包括标准库函数、启动代码(如
main
函数的初始化代码)等,而目标文件中可能缺少这些内容。 - 格式不同:目标文件的格式与可执行文件的格式不同。目标文件通常包含重定位信息(relocation information),这些信息告诉链接器在最终的可执行文件中如何调整地址和符号引用。
这些问题都需要通过链接解决 - 生成目标文件(Object File),包含:
- 代码段(Text Section):可执行的机器指令。
- 数据段(Data Section):全局变量、静态变量等初始化数据。
- 符号表(Symbol Table):记录函数和变量的地址(可能未解析)。
链接
在 C++ 的编译流程中,链接阶段(Linking Stage) 是将多个目标文件(.o/.obj) 和库文件(.a/.lib、.so/.dll) 合并成一个可执行文件(如 .exe
)或共享库的关键步骤。它的核心任务是解决符号之间的跨文件引用关系,并最终生成一个可执行程序。
这一阶段主要解决符号解析、 符号重定位、合并代码与数据段、汇编后不同目标文件中可能存在对同一符号(函数、变量)的引用,例如在 main.cpp
中调用 func()
,但 func()
的定义在 func.cpp
中。链接器会确每个被引用的符号(如函数名、全局变量)都有唯一的定义,若找不到定义,链接器会报错(如 undefined reference to 'func'
),每个目标文件都有一个符号表,记录其导出的符号(定义的符号)和需要的符号(引用的符号)。链接器通过遍历所有目标文件的符号表,匹配“需要”和“导出”的符号。
对于符号重定向,汇编阶段生成的机器码中,符号(如函数、变量)的地址是临时的(基于目标文件内部偏移),这个地址是不可用的,链接器会将所有目标文件的代码段(.text
)和数据段(.data
、.bss
)合并到最终的可执行文件中,再根据合并后的布局,修正代码中所有符号的引用地址。
处理完这些后链接器会合并代码与数据段,并将需要的依赖加入到可执行文件中
静态库本质是一组目标文件的集合(归档文件),链接器从静态库中仅提取被引用的目标文件,合并到可执行文件中。若程序使用了数学库 libm.a
中的 sqrt()
,链接器会从 libm.a
中找到 sqrt.o
并合并。常见的lib文件是一种静态库
动态库的代码不会复制到可执行文件中,仅在运行时由操作系统动态加载。链接器仅记录动态库中符号的引用信息(如名称和版本)。常见的dll是一种动态库
下面我们看下总体如何运行的
C++程序通常由多个源文件(.cpp文件)和头文件(.h或.hpp文件)组成。每个源文件可以独立编译成目标文件(.obj或.o文件),然后这些目标文件在链接阶段被链接成最终的可执行文件。
每个源文件(.cpp文件)在编译时是独立的。编译器在编译一个源文件,时只会看到该文件中包含的代码和通过头文件引入的声明。具体实现的代码(如函数定义)如果在另一个源文件中,编译器在编译当前文件时是看不到的。
在编译完成后,链接器会将多个目标文件链接在一起,解决函数和变量的引用问题。链接器会根据目标文件中的符号引用找到对应的实现,并将它们组合成一个完整的可执行文件。
// MyClass.h
class MyClass {
public:void func();
};// MyClass.cpp
void MyClass::func() { /* 实现 */ }// main.cpp
MyClass obj;
obj.func(); // 链接时能找到 MyClass::func
- 编译过程:
MyClass.cpp
编译时生成MyClass::func
的二进制代码,并保存在目标文件(如MyClass.obj
)中。main.cpp
编译时只知道func
的声明,调用时会生成一个“符号引用”(如_MyClass_func
)。- 链接阶段:链接器将
main.obj
中的符号引用与MyClass.obj
中的实现地址绑定。