C++ 编译与链接

引言

这篇文章用来介绍 C++ 的编译、链接、优化等原理,同时解释了 .cpp、.h、.o、.exe 等文件类型的区别和其分别代表哪个环节。

从 main.cpp 开始

我们先创建一个基本的 main.cpp 源文件:

1
2
3
4
5
6
#include <iostream>

int main() {
std::cout << "Hello World!\n";
return 0;
}

这是个最基本的 C++ 脚本,各函数的功能就不过多介绍了,但是依旧指出几个需要注意的点:

  1. 不使用 using namespace std 引入完整的标准命名空间,而是在需要使用的位置使用 std:: 直接引用,避免出现命名空间污染问题

  2. 使用 cout\n 提升程序执行性能,使用 \n 而不是 std::endl 可以避免换行同时刷新缓冲区,极大减少性能开销

  3. 使用 #include 宏等于直接将 iostream 运行库头文件的全部内容直接复制粘贴到文件的顶端

编译 main.cpp 源文件

接下来,编译刚刚完成的 main 源文件,注意是编译( Compile )而不是构建( Build ),后者会直接生成该程序的可执行文件,而前者只会生成中继文件。

如果使用 Visual Studio,可以直接按下 Ctrl + F7,实现只编译当前的单个源文件。

如果使用 JetBrains CLion 等 CMake IDE,需要查看 CMake 使用的编译器,并在终端中进行手动编译:

1
g++ main.cpp -c

构建完整项目

编译完成后,你应该可以在同一目录下找到生成的 main.o 中继文件,你可以进一步对其进行构建操作( 虽然单个源文件不需要链接任何内容 )并得到可执行文件。

Visual Studio 可以直接执行 Build 操作得到最终的可执行文件 main.exe,CMake 则需要继续对中继文件进行构建:

1
g++ main.o -o program

即可以得到一个可执行文件 program

接下来运行可执行文件,即可看到控制台打印出了 Hello World!,这就是一个最简单的源文件的构建流程:

C++ 源文件 -> .obj 中继文件 -> 多个 .obj 文件通过 linker 连接 -> 编译为 .exe 可执行文件

注意:如果运行可执行文件时,控制台开启瞬间关闭,则需要在 return 0; 前加上 std::cin.get();,确保程序执行完成后会等待输入而不是直接退出。

不同阶段的区别

从第一部分我们知道:C++项目的构建分为两个阶段,即对脚本的单独编译(称为编译阶段)和对多个中继文件的链接(称为链接阶段)。现在我们通过这两个阶段的中间产物 .obj 文件 了解一下编译阶段。

翻译单元

在 C++ 中,中继阶段的单个 .obj 文件可以称为一个 “翻译单元( Translation Unit )”,单个翻译单元可以视为包含一个 .cpp 源文件完整展开后全部代码的文件。

在 .obj 文件产生前,编译阶段大致要经历下面三个过程:

  1. 预处理(Preprocess):编译器处理 #include#define 等指令,将所有包含的头文件内容直接插入,形成 .i 源码文件

  2. 处理(Process):编译器对翻译单元进行语法分析、语义分析,同时会根据优化等级进行各种优化,形成汇编代码 .s 文件

  3. 汇编(Assemble):编译器将汇编代码转为机器码(Machine Code),同时根据不同的目标平台生成中继文件 .obj/.o

所以,如果我们创建一个头文件,在不使用 #include 包含到源文件中的情况下,该头文件会被编译器完全忽略,不会参与编译环节

同时,如果使用 #include 将头文件包含到源文件中,编译器处理这一包含的方式也十分简单粗暴:直接将头文件中的所有内容复制粘贴到源文件中

#include <iostream> 实际上是将该标准库中的所有代码粘贴到 main.cpp 的开头,然后进行处理和汇编步骤。根据这一原理你甚至可以写出一些抽象代码:

1
2
3
4
5
6
7
#include <iostream>

int main()
#include "StartBrace.h" // 这个文件中只有一个 {
std::cout << "Hello World!" << std::endl;
return 0;
#include "EndBrace.h" // 这个文件中只有一个 }

上面这个抽象代码是完全可以通过编译的,充分证明这一强行复制粘贴的预处理完成后,源码才会进行处理。

还有一种方法也可以看出 #include 对编译的影响:如果你还写了一个不引入 iostream 的简单源文件,将这两个源文件分别编译为 .o 中继文件后,会发现引入了 iostream 的中继文件明显大于不引入的中继文件,因为前者需要编译 iostream 内的所有代码,而后者不需要。

编译错误:编译阶段的语法错误

现在你已经对编译阶段和翻译单元有了一定的理解,接下来我们稍微修改一下 main.cpp,使其犯一些常见的语法错误。这些错误会在编译阶段被捕获,使得项目的构建不会进入链接阶段,所以此类语法错误通常被称为编译错误(错误代码由 C 开头)。

一些最常见的语法错误(比如少打分号、使用中文字符、关键字没打全)这里不再提及,主要给出一个典型的编译阶段问题:

首先修改我们的 main() 函数,使用一个新的函数替换原本的输出流部分:

1
2
3
4
int main() {
Log("Hello World!");
return 0;
}

然后直接编译这个源文件(注意是编译不是构建,在这之后这两个名词的操作我会仔细区分,编译特指得到该源文件的 .o 中继文件),不出意外会得到一个报错:

1
error: use of undeclared identifier 'Log'   1 error generated.

上述报错是 CMake 编辑器给出,Visual Studio 可能会略有不同。但其内容大致都为编译器不知道什么是 Log() 函数,使得该源文件无法通过编译。

但神奇的是,如果我们在上面添加一个 Log 函数的声明(不需要创建这个函数):

1
2
3
4
5
6
void Log(const char* message);

int main() {
Log("Hello World!");
return 0;
}

再次编译这个源文件,就会发现这个程序完美地通过了编译。由于没有涉及输入输出的流操作,连引入 iostream 的代码都可以删除。

这就是编译阶段的错误判断:这一阶段的语法检查只会检查进行编译的单个 .cpp 源文件中所有代码中使用的函数是否声明,其使用是否正确(符合声明),这一过程没有通过就会报出编译错误。

如果你对该函数进行了声明,则编译器会默认这个函数存在于其他源文件内,即所谓 “声明” 就是开发者告诉编译器这个函数存在,而编译器无条件相信开发者的这句话。因为从其他源文件中查找这一函数是 linker 链接器在链接阶段进行的工作,编译器只负责检查该文件内是否存在问题,自然可以通过。

这也就是一个源文件内什么都不写也可以通过编译阶段生成中继文件的原因:文件中什么都没有,也就意味着没有语法错误,编译器只在有语法错误时报错,则这个空的源文件一定可以通过编译。但一个空的源文件肯定无法通过链接,因为其缺少 main() 函数,这是任何 C++ 项目都不能缺少的入口点。

链接错误:Linker 报错

依旧是刚刚通过编译阶段的 main.cpp,现在选择构建(Build)而不是编译,之前生成的中继文件会自动进入链接阶段。在这一阶段中,Linker 会自动在其他参与链接的单元寻找缺少的内容。

由于我们只对 Log() 函数进行了声明,而没有实现过这个函数,所以这一阶段肯定会出现链接错误:

1
2
3
Undefined symbols for architecture arm64:
"Log(char const*)", referenced from:
_main in main.cpp.o

Visual Studio 的报错可能略有差距,但一定会出现一个 LNK 开头的错误代码,该开头即为链接错误(Linking 缩写)。这意味着链接器 linker 无法在任何中继文件中找到 Log() 函数的主体,所以无法链接通过。

为了解决这个问题,我们需要新建一个源文件 Log.cpp:

1
2
3
4
5
#include <iostream>

void Log(const char* message) {
std::cout << message << std::endl;
}

该源文件不需要给出主函数 main(),因为入口点已经在 main.cpp 内实现完成了。我们编译这个新建的源文件,由于没有语法错误,可以直接通过编译,生成 Log.o 中继文件。

接下来我们重新构建项目,可以发现这次构建非常成功,链接器 linker 成功找到了 Log.cpp 内的函数主体,并将这两个中继文件进行链接,最终生成可执行文件。

自此,我们实现了一个简单 C++ 项目从多个源文件各自编译(Compile)到链接(Linking),最终生成可执行文件的完整构建流程。不论一个项目有多复杂,包含多少头文件、源文件,其最终的构建流程都是相同的:

C++ 源文件 -> .obj 中继文件 -> 多个 .obj 文件通过 linker 连接 -> 编译为 .exe 可执行文件

优化 Optimization

现在我们把目光移到 IDE 上部配置栏,任何现代 C++ 编辑器都会给出两个选项:构建模式与发行平台

后者通常决定了编译器生成汇编代码与机器码的类型,而前者决定了编译器在处理阶段所使用的优化等级。在 Visual Studio 上,这两个选项通常会自动标为 “Debug” 与 “x64”,前者表明该程序的构建模式为 Debug 模式,后者表明发型平台为 64 位 Windows 平台。

本节教程,我们不关注发行平台,而是关注构建模式中,DebugRelease 存在什么区别。

Debug 与 Release 的性能区别

当前主流 C++ 编辑器的默认构建模式都为 Debug 模式,这一模式是方便开发者调试程序而设计的,其优化等级通常使用 -O0。而 Release 模式是为发行 C++ 项目准备的编译模式,通常使用 -O2 或 -O3 的优化等级。

当选择 Debug 模式时,编译器基本不会修改源代码,同时保留变量名、函数名、行号信息和所有冗余的内存读写操作(即所有中间变量和计算步骤)。同时,编译器还会为代码增加调试符号,这不会为性能带来过多影响,但会增大执行文件的大小。

但当选择 Release 模式时,编译器在优化上会变得非常激进(Visual Studio 会在 Release 模式下开启 Max Speed Optimization 全速优化),其性能可以与 Debug 模式相差 3~10 倍

开启了 Release 模式后,编译器主要会进行以下几个优化:

常量折叠 Constant Folding

假如源文件中包含这样的代码:

1
2
3
4
int Multiply() {
int result = 5 * 8;
return result;
}

在 Release 模式下,当编译器遇到上面的函数时,会执行以下的操作:

  1. result 的中间变量会被移除,直接变成 return 5 * 8;,这一步称为表达式简化

  2. 在更高的优化等级下,编译优化会直接将这个函数优化为一行:int Multiply() { return 40; }

比如在 ARM64 的汇编代码中,该函数只会显示为两行:

1
2
mov w0, #40   ;     # ARM64: 将40移到w0寄存器
ret # 返回

即忽略了函数内的所有运算,直接将 5 * 8 运算的结果 40 放入返回值寄存器,这就是 C++ 编译优化中的经典优化:常量折叠

当然,上面的代码是在编译器可以运算得到结果的情况下才会进行常量折叠,如果是下面的代码则不会被折叠:

1
2
3
4
int Multiply(int a) {
int result = a * 8;
return result;
}

死代码消除

假如源文件中包含这样的代码:

1
2
3
4
5
6
7
8
const char* Log(const char* message) {
return message;
}

int Multiply(const int a, const int b) {
Log("Multiply");
return a * b;
}

在这个函数中,唯一一个使用到 Log() 的函数就是 Multiply() 函数,此时不难看出:Log() 函数完全没有任何输入输出流(cout、cin 等 IO 操作),其返回值也没有起到任何作用或存入任何变量。

所以在 Release 模式(或较高优化等级)下,这个函数的调用可能会被完全移除

或者是在下面的条件判断中:

1
2
3
4
5
6
7
8
void ConstantFolding() {
int x = 5;
bool comparisonResult = x == 6;
if (comparisonResult) {
Log("Constant Folding Log here");
}
Log("Finally Log here");
}

可以很明显看出,comparisonResult 的值始终为 false,因为没有任何地方修改参与判断的变量,所以这里首先会进行常量折叠comparisonResult 将会直接变为 false 常量,这也就意味着 if 语句块内的代码将永远不会执行。所以这个 if 判断也将被完全移除。

在更加激进的优化模式下,整个 ConstantFolding() 函数可能只会剩下一行代码,即最后的 Log 输出。

函数内联

这个比较好理解,即在 Release 模式下,较短的函数将自动被编译器进行内联操作:直接将函数体展开,用函数体内原本的代码替换掉调用该函数的代码。

这样的优化可以减少因为函数调用带来的多次内存操作,优化程序的运行性能。