简单的编译场景通常由工具链的默认配置处理,或者直接由集成开发环境 (IDE)开箱即用提供。但在专业环境中,业务需求往往需要更高级的解决方案——可能是对更高性能、更小二进制文件、更好可移植性、自动化测试或强大调试能力的要求。要统一管理这些需求并确保方案面向未来,很快就会变成一团复杂混乱的难题(特别是在需要支持多平台时)。
C++书籍往往未能充分解释编译过程(像虚基类这类深入主题似乎更有吸引力)。本章将通过探讨编译的各个层面来弥补这一不足:我们将了解编译的工作原理、其内部阶段如何运作,以及这些阶段如何影响最终的二进制输出。
之后,我们将重点讨论前提条件——探讨可用于微调编译过程的命令、如何要求编译器具备特定功能,以及如何正确指示编译器处理哪些输入文件。
接着,我们会聚焦编译的第一阶段——预处理器。我们将为包含的头文件提供路径,并研究如何通过预处理器定义将 CMake 和构建环境中的变量嵌入代码。我们将涵盖最有趣的用例,学习如何暴露 CMake 变量以便 C++代码可以访问它们。
紧接着,我们将讨论优化器以及不同标志如何影响性能。我们还会探讨优化的代价,特别是它如何影响生成二进制文件的可调试性,以及在不希望出现这种情况时应采取的措施。
最后,我们将解释如何通过使用预编译头文件和统一构建来管理编译过程以减少编译时间。我们将学习如何调试构建过程并找出可能存在的错误。
本章将涵盖以下主要内容:
编译基础
配置预处理器
配置优化器
管理编译过程
编译基础
编译过程大致可描述为将高级编程语言编写的指令翻译成低级机器代码的过程。这使我们能够使用类和对象等抽象概念来创建应用程序,而无需处理特定处理器汇编语言的繁琐细节。我们不必直接操作 CPU 寄存器、考虑短跳转或长跳转,也无需管理栈帧。编译型语言更具表现力、可读性和安全性,它们鼓励创建可维护的代码,同时尽可能提供最佳性能。
在 C++中,我们采用静态编译方式——这意味着整个程序必须先被翻译成本地代码才能执行。这与 Java 或 Python 等语言不同,后者在用户每次运行程序时进行即时解释和编译。每种方法都有其独特的优势。C++旨在提供丰富的高级工具,同时保持原生性能。C++编译器几乎能为所有架构生成独立运行的应用程序。
创建并运行 C++程序包含以下几个步骤:
设计应用程序 :包括规划应用功能、结构和行为。设计方案确定后,需遵循代码可读性和可维护性的最佳实践来编写源代码。
将各个.cpp 实现文件(又称翻译单元)编译成目标文件 :此步骤将您编写的高级语言代码转换为底层机器代码。
链接目标文件生成可执行文件 :在此步骤中,所有其他依赖项(包括动态和静态库)也会被链接。这个过程会创建一个可在目标平台上运行的可执行文件。
要运行程序, 操作系统 (OS)会使用名为加载器的工具将程序的机器码和所有必需的动态库映射到虚拟内存中。加载器随后读取程序头信息以确定执行起始位置,并开始运行指令。
在此阶段,程序的启动代码开始发挥作用。系统 C 库提供的特殊函数 _start
被调用。 _start
函数会收集命令行参数和环境变量、初始化线程、初始化静态符号并注册清理回调函数。完成这些操作后,它才会调用程序员编写代码的函数 main()
。
正如所见,大量工作都在幕后进行。本章重点讨论前文所列的第二步。通过审视全局,我们能更清楚地识别潜在问题的根源。软件开发中没有魔法可言,尽管其复杂性看似深不可测。一切现象皆有解释和成因。我们必须明白:程序运行时可能出现问题,根源可能在于编译方式——即便编译步骤本身看似成功。编译器在运行期间不可能检查所有边界情况。那么,就让我们揭开编译器工作的真实面纱。
编译原理剖析
如前所述,编译是将高级语言翻译为低级语言的过程。具体而言,这会生成机器码——即特定处理器能直接执行的指令,并以目标平台独有的二进制目标文件格式存储。在 Linux 系统中,最常用的格式是可执行与可链接格式 (ELF)。Windows 采用 PE/COFF 格式规范,而在 macOS 上我们会遇到 Mach 对象(即 Mach-O 格式)。
目标文件是单个源文件的直接翻译产物。这些文件必须分别编译,随后通过链接器合并为单一可执行文件或库。这种模块化处理能显著节省代码修改时间,因为只需重新编译程序员更新过的文件。
编译器需执行以下阶段来创建目标文件:
预处理
语言分析
汇编
优化
代码生成
让我们更详细地解释这些内容。
预处理虽然大多数编译器会自动调用,但它被视为实际编译前的准备步骤。其作用是对源代码进行基础处理:执行 #include
指令,通过 #define
指令和 -D
标志将标识符替换为定义值,调用简单宏,并根据 #if
、 #elif
和 #endif
指令有条件地包含或排除代码部分。预处理器完全不了解实际的 C++代码。本质上,它就像一个高级的查找替换工具。
然而,预处理器在构建高级程序中的作用至关重要。将代码分割成多个部分并在多个翻译单元间共享声明,这是代码可重用性的基础。
接下来是词法分析阶段,编译器将执行更复杂的操作。它会逐字符扫描预处理后的文件(此时已包含预处理器插入的所有头文件)。通过称为词法分析的过程,编译器将字符组合成有意义的标记——这些标记可能是关键字、运算符、变量名等。
随后这些标记会被组装成链式结构,并通过语法分析(或称解析)来验证它们的排列顺序和存在是否符合 C++的语法规则。这通常是生成最多错误信息的阶段,因为该过程会识别出各种语法问题。
最后,编译器执行语义分析。在此阶段,编译器会检查文件中的语句是否逻辑合理。例如,确保所有类型正确性检查都得到满足(不能将整数值赋给字符串变量)。这种分析能保证程序在编程语言的规则框架内具有实际意义。
汇编阶段本质上是将这些标记根据平台可用的指令集转换为特定 CPU 指令的过程。有些编译器实际上会生成一个汇编输出文件,随后传递给专门的汇编器程序。该程序产生 CPU 可执行的机器码。其他编译器则直接在内存中生成这种机器码。通常,这类编译器还提供生成人类可读的汇编代码文本输出的选项。然而,这段代码可读并不意味着它容易理解或值得去理解。
优化并不局限于编译过程中的单一环节,而是在每个阶段逐步进行的。不过,在生成初始汇编代码后确实存在一个专门阶段,重点在于最小化寄存器使用和消除冗余代码。
一种有趣且值得注意的优化技术是内联展开或内联 。在这个过程中,编译器会”剪切”函数体并将其”粘贴”到调用位置。C++标准并未明确定义这种情况发生的条件——这取决于具体实现。内联展开可以提高执行速度并减少内存使用,但它也给调试带来了显著弊端,因为执行代码不再对应源代码中的原始行。
代码生成阶段涉及将优化后的机器码按照目标平台规范写入目标文件 。但此时的目标文件尚不能直接执行——它需要传递给工具链中的下一个环节:链接器。链接器负责对目标文件的各段进行重定位并解析外部符号引用,从而生成可执行文件。这一步骤标志着从美国信息交换标准代码 (ASCII)源代码到能被 CPU 直接处理的二进制可执行文件的最终转换。
每个编译阶段都至关重要,且可根据具体需求进行配置。下面我们来看看如何通过 CMake 管理这一流程。
初始配置
CMake 提供多个可影响各编译阶段的命令:
target_compile_features()
: 这需要一个具备特定功能的编译器来编译此目标。
target_sources()
: 这将源添加到已定义的目标中。
target_include_directories()
: 这将设置预处理器的包含路径 。
target_compile_definitions()
: 设置预处理器定义。
target_compile_options()
: 此设置用于指定编译器特定的命令行选项。
target_precompile_headers()
: 此设置用于指定需要通过预编译优化的外部头文件。
这些命令都接受以下格式的相似参数:
target_...(<target name> <INTERFACE|PUBLIC|PRIVATE> <arguments>)
这意味着通过此命令设置的属性会通过传递性使用需求传播,如第 5 章目标对象操作中什么是传递性使用需求? 章节所述,该特性可同时应用于可执行文件和库文件。另外值得注意的是,所有这些命令都支持生成器表达式。
要求编译器具备特定功能
如第 4 章中设置首个 CMake 项目的检查支持的编译器功能一节所述,预见问题并在出错时向软件用户提供明确信息至关重要——例如当可用编译器 X 不提供所需功能 Y 时。这种方法远比让用户自行解读可能正在使用的不兼容工具链产生的错误信息更为友好。我们不希望用户将兼容性问题错误归咎于我们的代码,而非其过时的环境。
您可以使用以下命令来指定目标构建所需的所有功能:
target_compile_features(<target> <PRIVATE|PUBLIC|INTERFACE>
<feature> [...])
CMake 理解 C++ 标准及其支持的编译器特性:
AppleClang
: 适用于 Xcode 4.4 及以上版本的 Apple Clang
Clang
: Clang 编译器 2.9 及以上版本
GNU 编译器版本 4.4+
MSVC
: Microsoft Visual Studio 2010 及以上版本
SunPro
: Oracle Solaris Studio 12.4 及以上版本
Intel
: Intel 编译器 12.1 及以上版本
CMake 支持 60 多项功能特性,完整列表可在官方文档中查阅,具体位于解释 CMAKE_CXX_KNOWN_FEATURES
变量的页面。不过除非您有特殊需求,我建议选择表示通用 C++标准的高级元功能特性:
cxx_std_14
cxx_std_17
cxx_std_20
cxx_std_23
cxx_std_26
请看以下示例:
target_compile_features(my_target PUBLIC cxx_std_26)
这本质上等同于 set(CMAKE_CXX_STANDARD 26)
,其中 set(CMAKE_CXX_STANDARD_REQUIRED ON)
已在第 4 章的设置首个 CMake 项目中介绍。不同之处在于 target_compile_features()
是基于每个目标(target)进行操作的,而非对整个项目全局生效——当您需要为项目中所有目标添加此设置时,后者可能会显得繁琐。
更多关于 CMake 支持的编译器的详细信息,请参阅官方手册(网址见延伸阅读部分)。
管理目标的源文件
我们已经知道如何告诉 CMake 哪些源文件构成单个目标,无论是可执行文件还是库。我们通过在 add_executable()
或 add_library()
命令中提供文件列表来实现这一点。
随着解决方案的扩展,每个目标的文件列表也会增长。这可能导致一些相当冗长的 add_...()
命令。我们该如何处理这个问题?一个诱人的方法可能是利用 GLOB
模式下的 file()
命令,它可以收集子目录中的所有文件并将其存储在变量中。我们可以将其作为参数传递给目标声明,而不再需要处理文件列表:
file(GLOB helloworld_SRC "*.h" "*.cpp")
add_executable(helloworld ${helloworld_SRC})
然而,这种方法并不推荐。让我们来理解原因。CMake 会根据清单文件的变更生成构建系统。因此,如果未检测到更改,您的构建可能会在没有任何警告的情况下失败(开发人员的噩梦)。此外,在目标声明中省略所有源文件会破坏 CLion 等 IDE 中的代码检查功能,这些 IDE 能够解析特定的 CMake 命令来理解您的项目结构。
在目标声明中使用变量还有另一个不建议的原因:它增加了一层间接性,导致开发者在阅读项目时必须解包目标定义。遵循这一建议时,我们面临另一个问题:如何有条件地添加源文件?这在处理平台特定的实现文件(如 gui_linux.cpp
和 gui_windows.cpp
)时是常见场景。
target_sources()
命令允许我们将源文件追加到先前创建的目标中:
add_executable(main main.cpp)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_sources(main PRIVATE gui_linux.cpp)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
target_sources(main PRIVATE gui_windows.cpp)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
target_sources(main PRIVATE gui_macos.cpp)
else()
message(FATAL_ERROR "CMAKE_SYSTEM_NAME=${CMAKE_SYSTEM_NAME} not supported.")
endif()
这样一来,每个平台都能获得自己专属的兼容文件集。这很棒,但面对冗长的资源列表该怎么办呢?目前我们只能接受某些环节尚不完美,继续手动添加它们。如果你正为超长列表困扰,很可能项目结构存在问题:或许该考虑将资源分区为多个库了。
既然我们已经介绍了编译的基本要点,现在让我们深入探讨第一步——预处理。正如计算机科学中的所有事物一样,关键在于细节。
配置预处理器
预处理器在构建过程中扮演着重要角色。考虑到它的功能看似相当直接且有限,这一点可能有些令人意外。在接下来的章节中,我们将介绍如何为包含文件提供路径以及如何使用预处理器定义。我们还将解释如何利用 CMake 来配置包含的头文件。
提供包含文件的路径
预处理程序最基本的功能是通过 #include
指令包含 .h
和 .hpp
头文件,该指令有两种形式:
尖括号形式: #include <path-spec>
引号形式: #include "path-spec"
众所周知,预处理器会将这些指令替换为 path-spec
中指定文件的内容。查找这些文件可能是个挑战。应该搜索哪些目录?按什么顺序搜索?遗憾的是,C++标准并未对此做出明确规定。我们必须查阅所用编译器的使用手册。
通常,尖括号形式会检查标准的包含目录 ,这些目录包含系统中存储标准 C++库和标准 C 库头文件的路径。
引号形式首先在当前文件所在目录中搜索被包含文件,然后再检查尖括号形式的目录。
CMake 提供了一个命令来操作被搜索包含文件的路径:
target_include_directories(<target> [SYSTEM] [AFTER|BEFORE]
<INTERFACE|PUBLIC|PRIVATE> [item1...]
[<INTERFACE|PUBLIC|PRIVATE> [item2...]
...])
这使我们能够添加希望编译器扫描的自定义路径。CMake 会将这些路径添加到生成构建系统中编译器调用的参数里,并通过适合特定编译器的标志(通常是 -I
)来提供这些路径。
target_include_directories()
命令通过使用 AFTER
或 BEFORE
关键字,在目标对象的 INCLUDE_DIRECTORIES
属性中追加或前置目录来修改该属性。不过,具体是由编译器决定此处提供的目录是在默认目录之前还是之后进行检查(通常是在之前)。
SYSTEM
关键字向编译器表明,应将指定目录视为标准系统目录(与尖括号形式配合使用)。对于许多编译器而言,这些目录是通过 -isystem
标志传递的。
预处理器定义
回想之前讨论编译阶段时提到的预处理器指令 #define
、 #if
、 #elif
和 #endif
。让我们看以下示例:
#include <iostream>
int main() {
#if defined(ABC)
std::cout << "ABC is defined!" << std::endl;
#endif
#if (DEF > 2*4-3)
std::cout << "DEF is greater than 5!" << std::endl;
#endif
}
就目前而言,这个示例没有实际效果,因为既没有定义 ABC
也没有定义 DEF
(在此示例中 DEF
会默认为 0
)。我们只需在代码顶部添加两行即可改变这种情况:
#define ABC
#define DEF 8
编译并执行这段代码后,我们可以在控制台中看到两条消息:
ABC is defined!
DEF is greater than 5!
这看起来似乎很简单,但如果我们需要根据外部因素(如操作系统、架构或其他条件)来配置这些部分呢?好消息是,你可以将 CMake 中的值传递给 C++编译器,而且操作一点也不复杂。
target_compile_definitions()
命令就足够了:
set(VAR 8)
add_executable(defined definitions.cpp)
target_compile_definitions(defined PRIVATE ABC "DEF=${VAR}")
上述代码的行为将与两个 #define
语句完全相同,但我们能够灵活使用 CMake 的变量和生成器表达式,并且可以将该命令置于条件块中。
传统上,这些定义是通过 -D
标志(例如 -DFOO=1
)传递给编译器的,一些程序员仍在此命令中使用该标志。
target_compile_definitions(hello PRIVATE -DFOO)
CMake 能够识别这一点,并会自动移除所有前导的 -D
标志。它同样会忽略空字符串,因此以下命令完全有效:
target_compile_definitions(hello PRIVATE -D FOO)
在此情况下, -D
是一个独立参数,移除后会变为空字符串并随后被忽略,从而确保正确行为。
避免在单元测试中访问类的私有字段
一些在线资源建议结合特定的 -D
定义与 #ifdef/ifndef
指令用于单元测试目的。这种方法最直接的应用是将 public
访问修饰符包含在条件编译中,当定义了 UNIT_TEST
时实际上使所有字段变为公开(类字段默认是私有的):
class X {
#ifdef UNIT_TEST
public:
#endif
int x_;
}
虽然这种技术提供了便利(允许测试直接访问私有成员),但不会产生整洁的代码。理想情况下,单元测试应专注于验证公共接口中方法的功能,将底层实现视为黑盒。因此,我建议仅将此方法作为最后手段使用。
使用 git commit 来跟踪编译后的版本
让我们思考那些能从了解环境或文件系统细节中受益的用例。在专业场景中,一个典型例子可能涉及传递用于构建二进制文件的修订版本或提交 SHA
。这可以通过以下方式实现:
add_executable(print_commit print_commit.cpp)
execute_process(COMMAND git log -1 --pretty=format:%h
OUTPUT_VARIABLE SHA)
target_compile_definitions(print_commit
PRIVATE "SHA=${SHA}")
SHA 随后可在我们的应用程序中按如下方式使用:
#include <iostream>
// special macros to convert definitions into c-strings:
#define str(s) #s
#define xstr(s) str(s)
int main()
{
#if defined(SHA)
std::cout << "GIT commit: " << xstr(SHA) << std::endl;
#endif
}
当然,上述代码要求用户已安装 Git 并可在其 PATH
中访问。这一功能在运行于生产服务器的程序来自持续集成/部署流水线时尤为实用。当我们的软件出现问题时,我们可以快速检查构建问题产品时使用的是哪个确切的 Git 提交版本。
准确追踪某个提交版本对调试工作极为有利。向 C++代码传递单个变量很简单,但当需要向头文件传递数十个变量时,我们又该如何处理呢?
配置标头
通过 target_compile_definitions()
传递大量变量定义会变得繁琐。提供一个包含这些变量引用的占位符头文件,并让 CMake 自动填充它们,岂不是更简单?确实如此!
CMake 的 configure_file(<input> <output>)
命令允许您从模板生成新文件,如下例所示:
#cmakedefine FOO_ENABLE
#cmakedefine FOO_STRING1 "@FOO_STRING1@"
#cmakedefine FOO_STRING2 "${FOO_STRING2}"
#cmakedefine FOO_UNDEFINED "@FOO_UNDEFINED@"
您可以按以下方式使用该命令:
add_executable(configure configure.cpp)
set(FOO_ENABLE ON)
set(FOO_STRING1 "abc")
set(FOO_STRING2 "def")
configure_file(configure.h.in configured/configure.h)
target_include_directories(configure PRIVATE
${CMAKE_CURRENT_BINARY_DIR})
CMake 随后会生成如下输出文件:
#define FOO_ENABLE
#define FOO_STRING1 "abc"
#define FOO_STRING2 "def"
/* #undef FOO_UNDEFINED */
如你所见, @VAR@
和 ${VAR}
变量占位符已被替换为 CMake 列表文件中的值。此外, #cmakedefine
被替换为已定义变量的 #define
和未定义变量的 /* #undef VAR */
。若需在 #if
代码块中显式使用 #define 1
或 #define 0
,请改用 #cmakedefine01
。
只需将此配置头文件包含到实现文件中,即可将其集成到应用程序中:
#include <iostream>
#include "configured/configure.h"
// special macros to convert definitions into c-strings:
#define str(s) #s
#define xstr(s) str(s)
using namespace std;
int main()
{
#ifdef FOO_ENABLE
cout << "FOO_ENABLE: ON" << endl;
#endif
cout << "FOO_STRING1: " << xstr(FOO_STRING1) << endl;
cout << "FOO_STRING2: " << xstr(FOO_STRING2) << endl;
cout << "FOO_UNDEFINED: " << xstr(FOO_UNDEFINED) << endl;
}
通过使用 target_include_directories()
命令将二叉树添加到我们的包含路径中,我们可以编译示例并获取来自 CMake 的输出结果
FOO_ENABLE: ON
FOO_STRING1: "abc"
FOO_STRING2: "def"
FOO_UNDEFINED: FOO_UNDEFINED
configure_file()
命令还包含一系列格式化和文件权限选项,由于篇幅限制,我们在此不做深入探讨。如需了解更多详情,可参考在线文档(参见本章延伸阅读部分)。
在完成了头文件和源文件的完整编译后,我们来探讨后续步骤中输出代码是如何成型的。虽然我们无法直接影响语言分析或汇编阶段(因为这些步骤遵循严格标准),但我们可以优化器的配置进行调控。让我们看看这如何影响最终结果。
配置优化器
优化器会分析前几个阶段的输出,并运用多种策略——这些策略程序员通常不会直接使用,因为它们不符合整洁代码原则。但这并无不妥,因为优化器的核心使命就是提升代码性能,追求低 CPU 占用、最少寄存器使用和缩减内存占用。当优化器遍历源代码时,它会大幅改造代码形态,使其变得几乎难以辨认,只为专门适配目标 CPU 架构。
优化器不仅会决定哪些函数可以被移除或压缩;它还会移动代码位置,甚至大量复制代码!如果它能确定某些代码行是冗余的,就会直接从重要函数中将其清除(而你甚至不会察觉)。它会回收内存,使得多个变量可以在不同时段共享同一个存储位置。只要能省去零星几个时钟周期,它甚至会将你的控制结构改造成完全不同的形态。
如果程序员手动对源代码应用上述技术,代码会变成一团糟糕难读的乱麻,既难以编写也难以理解。但当编译器实施这些技术时,它们却大有裨益,因为编译器会严格遵循既定规则。优化器是头无情的猛兽,只为单一目标服务:提升执行速度,无论输出结果变得多么扭曲。这样的输出可能包含调试信息(当我们在测试环境中运行时),也可能不含这些信息——这是为了防止未授权人员篡改代码。
每个编译器都有其独特的技巧,这些技巧与其支持的平台和遵循的理念相一致。我们将以 GNU GCC 和 LLVM Clang 中最常见的技巧为例,来了解哪些是实用且可实现的。
问题在于——许多编译器默认不会启用任何优化(包括 GCC)。这在某些情况下没问题,但在其他情况下就不太理想了。明明可以更快,为何要慢吞吞呢?为了解决这个问题,我们可以使用 target_compile_options()
命令,明确告知编译器我们的优化需求。
该命令的语法与本章其他命令相似:
target_compile_options(<target> [BEFORE]
<INTERFACE|PUBLIC|PRIVATE> [items1...]
[<INTERFACE|PUBLIC|PRIVATE> [items2...]
...])
我们提供了构建目标时可用的命令行选项,并指定了传播关键字。执行时,CMake 会将给定选项追加到目标的相应 COMPILE_OPTIONS
变量中。若希望前置这些选项,则可选用可选的 BEFORE
关键字。在某些场景下顺序至关重要,因此拥有选择权很有益处。
请注意 target_compile_options()
是一个通用命令。它也可用于向类似编译器的 -D
定义提供其他参数,对此 CMake 还提供了 target_compile_definition()
命令。建议尽可能使用最专门的 CMake 命令,因为它们能保证在所有支持的编译器上以相同方式工作。
现在讨论具体细节。后续章节将介绍大多数编译器支持启用的各类优化选项。
通用级别
优化器的所有不同行为都可以通过特定的标志进行深度配置,这些标志可作为编译选项传递。要全面了解这些选项需要耗费大量时间,并且需要对编译器、处理器和内存的内部工作原理有深入认识。如果我们只想要一个在大多数情况下都能良好运行的最佳方案,该怎么办?我们可以设定一个通用解决方案——优化级别指定符。
大多数编译器提供四个基本的优化级别,从 0
到 3
。我们通过 -O<level>
选项来指定它们。 -O0
表示无优化 ,通常是编译器的默认级别。而 -O2
则被视为完全优化 ,它能生成高度优化的代码,但代价是最慢的编译时间。
存在一个介于中间的 -O1
级别,根据需求不同,它可以成为一个很好的折衷方案——在不过度拖慢编译速度的前提下,启用合理数量的优化机制。
最后,我们可以选择 -O3
级别,即完全优化模式,类似于 -O2
,但采用了更激进的子程序内联和循环向量化策略。
此外还存在一些针对生成文件体积(而非执行速度)的优化变体—— -Os
。其中 -Ofast
是一种超级激进优化模式,属于不严格遵循 C++标准的 -O3
优化。最显著的差异体现在 -ffast-math
和 -ffinite-math
标志的使用上,这意味着如果程序涉及精确计算(大多数情况如此),最好避免使用该模式。
CMake 深知编译器各有差异,因此通过为编译器提供默认标志来标准化开发者体验。这些标志存储在系统级(非目标特定)变量中,分别对应所用语言(C++对应 CXX
)和构建配置( DEBUG
或 RELEASE
):
CMAKE_CXX_FLAGS_DEBUG
等于 -g
CMAKE_CXX_FLAGS_RELEASE
等于 -O3 -DNDEBUG
如您所见,调试配置未启用任何优化,而发布配置直接采用了 O3
。您可以根据需要直接使用 set()
命令修改它们,或者添加目标编译选项来覆盖这一默认行为。另外两个标志( -g,
-DNDEBUG
)与调试相关——我们将在本章为调试器提供信息一节中讨论它们。
诸如 CMAKE_<LANG>_FLAGS_<CONFIG>
这样的变量是全局性的——它们适用于所有目标。建议通过属性和命令(例如 target_compile_options()
)来配置目标,而非依赖全局变量。这样,您能够以更精细的粒度控制目标。
通过选择优化级别 -O<level>
,我们间接设置了一系列标志,每个标志控制着特定的优化行为。随后,我们可以通过追加更多标志来微调优化,例如:
启用它们的 -f
选项: -finline-functions
。
使用 -fno
选项禁用它们: -fno-inline-functions
。
其中一些标志值得深入了解,因为它们通常会影响程序的运行方式以及调试方法。让我们来看看。
函数内联
你可能还记得,可以通过在类声明块中定义函数或显式使用 inline
关键字来促使编译器内联某些函数:
struct X {
void im_inlined(){ cout << "hi
"; };
void me_too();
};
inline void X::me_too() { cout << "bye
"; };
函数内联的最终决定权在于编译器。如果启用了内联优化,且函数仅在单一位置使用(或是一个在少数位置使用的较小函数),则很可能会发生内联。
函数内联是一种有趣的优化技术。其工作原理是提取目标函数的代码,并将其嵌入到所有调用该函数的位置。这个过程替换了原始调用,节省了宝贵的 CPU 周期。
让我们用刚才定义的类来看以下示例:
int main() {
X x;
x.im_inlined();
x.me_too();
return 0;
}
如果不进行内联优化,代码会在 main()
帧中执行直到遇到方法调用。然后,它会为 im_inlined()
创建一个新帧,在独立作用域中执行,并返回到 main()
帧。同样的过程也会发生在 me_too()
方法上。
然而,当内联发生时,编译器会替换这些调用,如下所示:
int main() {
X x;
cout << "hi
";
cout << "bye
";
return 0;
}
这并不是一个精确的表示,因为内联发生在汇编或机器代码层面(而非源代码层面),但它确实提供了一个大致的概念。
编译器采用内联是为了节省时间。它跳过了创建和销毁新调用帧的过程,也无需查找下一条要执行(和返回)指令的地址,同时由于指令彼此邻近,还提升了指令缓存的效率。
然而,内联确实会带来一些显著的副作用。如果一个函数被多次使用,它就必须被复制到所有位置,导致文件体积增大和内存使用增加。虽然如今这可能不像过去那样关键,但在为内存有限的低端设备开发软件时,这一点仍然很重要。
此外,内联对调试有着至关重要的影响。内联后的代码不再位于原始行号,这使得追踪变得更加困难,有时甚至无法实现。这就是为什么在已被内联的函数中设置的调试断点永远不会被触发(尽管代码仍以某种方式执行)。为了解决这个问题,您需要为调试版本禁用内联功能(代价是无法测试完全相同的发布版本)。
我们可以通过为目标指定 -O0
(o-zero)级别或直接处理负责内联的标志来实现这一点:
-finline-functions-called-once
: 仅适用于 GCC。
-finline-functions
: 适用于 Clang 和 GCC 两者。
-finline-hint-functions
: 仅适用于 Clang。
虽然可以使用 -fno-inline-...
显式禁用内联,但建议查阅您特定编译器版本的文档以获取详细信息。
循环展开
循环展开,又称循环解卷,是一种优化技术。该策略旨在将循环转换为一系列实现相同结果的语句。通过消除循环控制指令、指针运算和循环结束检查,这种方法以增加程序体积为代价来提升执行速度。
请看以下示例:
void func() {
for(int i = 0; i < 3; i++)
cout << "hello
";
}
之前的代码将被转换成类似这样:
void func() {
cout << "hello
";
cout << "hello
";
cout << "hello
";
}
结果相同,但我们不再需要分配 i
变量、递增它或与 3
值进行三次比较。如果在程序生命周期内足够频繁地调用 func()
,即使展开如此简短的小函数也会产生显著差异。
但需要理解两个限制因素:首先,只有在编译器知道或能准确预估迭代次数时,循环展开才有效;其次,在现代 CPU 上,循环展开可能导致意外后果,因为代码量增大会影响缓存效率。
不同编译器提供的该标记略有差异:
-floop-unroll
:这是 GCC 的版本。
-funroll-loops
: 这是针对 Clang 的。
若不确定该标志是否会影响您的特定程序,请进行充分测试,并明确启用或禁用它。请注意,在 GCC 中,该标志会随隐式启用的 -floop-unroll-and-jam
标志一同默认启用,其中包含 -O3
。
循环向量化
被称为单指令多数据 (SIMD)的机制开发于 20 世纪 60 年代早期,旨在实现并行计算。顾名思义,它被设计用来同时对多个数据执行相同操作。让我们通过以下示例来实际了解这一点:
int a[128];
int b[128];
// initialize b
for (i = 0; i<128; i++)
a[i] = b[i] + 5;
通常情况下,这类代码需要循环 128 次,但借助支持 SIMD 的 CPU,通过同时计算两个或更多数组元素,可以显著加速代码执行。这之所以可行,是因为连续元素之间不存在依赖关系且数组间存在数据重叠。智能编译器能够将前述循环转换为类似这样的形式(这发生在汇编层面):
for (i = 0; i<32; i+=4) {
a[ i ] = b[ i ] + 5;
a[i+1] = b[i+1] + 5;
a[i+2] = b[i+2] + 5;
a[i+3] = b[i+3] + 5;
}
GCC 将在 -O3
级别启用此类循环的自动向量化。Clang 默认开启该功能。两个编译器都提供了不同的标志来专门控制向量化的启用/禁用:
-ftree-vectorize -ftree-slp-vectorize
: 这是用于在 GCC 中启用向量化。
-fno-vectorize -fno-slp-vectorize
: 这是用于在 Clang 中禁用向量化。
向量化的效率源于对 CPU 制造商提供的特殊指令的利用,而非简单地将循环的原始形式替换为展开版本。因此,手动实现相同性能水平是不可行的(此外,这也不会产生简洁的代码 )。
优化器在提升程序运行时性能方面起着至关重要的作用。通过有效运用其策略,我们将获得更高的性价比。效率不仅关乎编码完成后的表现,在软件开发过程中同样重要。如果编译时间过长,我们可以通过优化流程管理来改善这一状况。
管理编译过程
作为程序员和构建工程师,我们还必须考虑编译的其他方面,比如完成编译所需的时间,以及在解决方案构建过程中识别和纠正错误的难易程度。
缩短编译时间
在需要频繁重新编译(可能每小时多次)的繁忙项目中,确保编译过程尽可能快速至关重要。这不仅影响代码-编译-测试循环的效率,还会影响你的专注力和工作流程。
幸运的是,C++在管理编译时间方面已经表现不错,这要归功于独立的翻译单元。CMake 会确保只重新编译受近期更改影响的源代码。但如果需要进一步优化,我们可以采用几种技术:头文件预编译和统一构建。
头文件预编译
头文件( .h
)在实际编译开始前由预处理器包含到翻译单元中。这意味着每当 .cpp
实现文件发生变化时,它们都必须重新编译。此外,如果多个翻译文件使用相同的共享头文件,每次包含时都需要重新编译。这种做法效率低下,但长期以来一直是标准做法。
幸运的是,从 3.16 版本开始,CMake 提供了启用头文件预编译的命令。这使得编译器能够独立于实现文件处理头文件,从而加快编译过程。以下是该命令的语法:
target_precompile_headers(<target>
<INTERFACE|PUBLIC|PRIVATE> [header1...]
[<INTERFACE|PUBLIC|PRIVATE> [header2...]
...])
已添加的头文件列表存储在 PRECOMPILE_HEADERS
目标属性中。如我们在第 5 章目标操作的什么是传递性使用要求? 章节中所讨论的,可以通过选择 PUBLIC
或 INTERFACE
关键字使用传播属性将这些头文件共享给任何依赖目标;但不应将此应用于通过 install()
命令导出的目标。不应强制要求其他项目使用我们的预编译头文件,因为这不是常规做法。
使用第 6 章 《使用生成器表达式》 中描述的 $<BUILD_INTERFACE:...>
生成器表达式,可以防止预编译头文件出现在目标安装时的使用要求中。不过,它们仍会通过 export()
命令被添加到从构建树导出的目标中。如果现在觉得困惑也不必担心——这将在第 14 章 《安装与打包》 中得到完整解释。
CMake 会将所有头文件名放入 cmake_pch.h
或 cmake_pch.hxx
文件中,随后这些文件会被预编译为带有 .pch
、 .gch
或 .pchi
扩展名的编译器特定二进制文件。
我们可以在列表文件中这样使用它:
add_executable(precompiled hello.cpp)
target_precompile_headers(precompiled PRIVATE <iostream>)
我们也可以在对应的源文件中使用它:
int main() {
std::cout << "hello world" << std::endl;
}
请注意,在我们的 main.cpp
文件中,无需包含 cmake_pch.h
或其他头文件——这些将通过 CMake 使用编译器特定的命令行选项自动包含。
在前面的示例中,我使用了内置头文件;不过,您也可以通过类或函数定义轻松添加自己的头文件。使用以下两种形式之一来引用头文件:
header.h
(直接路径)会被解释为相对于当前源目录的路径,并将以绝对路径形式被包含。
[["header.h"]]
(双括号和引号)路径会根据目标的 INCLUDE_DIRECTORIES
属性进行扫描,该属性可通过 target_include_directiories()
进行配置。
一些在线参考资料可能不建议预编译非标准库部分的头文件(例如 <iostream>
),或者完全不建议使用预编译头文件。这是因为更改列表或编辑自定义头文件会导致目标中所有翻译单元重新编译。对于 CMake 而言,这种担忧并不那么重要,特别是当您正确构建项目时(使用专注于狭窄领域的相对小型目标)。每个目标都有独立的预编译头文件,这限制了头文件变更的影响范围。
若认为头文件相对稳定,可考虑在目标中复用预编译头文件。为此,CMake 提供了便捷命令:
target_precompile_headers(<target> REUSE_FROM <other_target>)
该命令设置重用头文件目标的 PRECOMPILE_HEADERS_REUSE_FROM
属性,并在这些目标间创建依赖关系。采用此方法时,使用方目标无法再指定自身的预编译头文件。此外,所有编译选项 、 编译标志和编译定义必须在目标间保持一致。
请注意需求,特别是当您使用双括号格式( [["header.h"]]
)的头部文件时。两个目标都需要正确设置其包含路径 ,以确保编译器能够找到这些头部文件。
Unity 构建
CMake 3.16 引入了另一项编译时间优化功能——unity builds,也称为统一构建或批量构建 。Unity builds 通过利用 #include
指令将多个实现源文件合并工作,这会带来一些有趣的影响,其中部分是有益的,而另一些则可能具有潜在危害。
最明显的优势在于,当 CMake 创建统一构建文件时,可以避免在不同翻译单元中重复编译头文件
#include "source_a.cpp"
#include "source_b.cpp"
当两个源文件都包含 #include "header.h"
行时,由于包含守卫的作用(假设已正确添加),被引用的文件只会被解析一次。虽然不如预编译头文件那样高效,但这不失为一种替代方案。
这类构建的第二个优势在于,优化器现在可以在更大范围内运作,对所有捆绑的源文件进行跨过程调用优化。这与我们在第 4 章 《 创建第一个 CMake 项目 》的过程间优化章节中讨论的链接时优化类似。
然而,这些优势也伴随着权衡取舍。在我们减少目标文件数量和处理步骤的同时,也增加了处理大文件所需的内存消耗。此外,我们还减少了可并行化的工作量。编译器通常并不特别擅长多线程编译——它们原本也不需要具备这种能力,因为构建系统通常会启动多个编译任务来在不同线程上同时执行所有文件。将所有文件合并处理会使得 CMake 可并行编译的文件数量减少,从而增加了并行化难度。
使用 unity 构建时,还需要考虑一些不太容易察觉的 C++语义影响——匿名命名空间中跨文件隐藏的符号现在作用域变成了 unity 文件,而非单个翻译单元。静态全局变量、函数和宏定义也会出现同样的情况。这可能导致名称冲突,或者执行错误的函数重载。
大型构建在重新编译时效率不高,因为它们会编译比实际需要更多的文件。当代码需要以最快速度编译所有文件时,这种构建方式效果最佳。在 Qt Creator(一个流行的 GUI 库)上进行的测试表明,根据所使用的编译器不同,性能提升幅度可达 20%至 50%。
要启用统一构建,我们有两种选择:
将 CMAKE_UNITY_BUILD
变量设置为 true
——这将为之后定义的每个目标初始化 UNITY_BUILD
属性。
对于需要使用统一构建的每个目标,手动将其 UNITY_BUILD
目标属性设置为 true
。
第二种选择通过调用以下方式实现:
set_target_properties(<target1> <target2> ...
PROPERTIES UNITY_BUILD true)
当然,手动为多个目标设置这些属性会增加工作量并提高维护成本,但若需更精细地控制此设置,您可能仍需这样做。
默认情况下,CMake 会创建包含八个源文件的构建,这由目标的 UNITY_BUILD_BATCH_SIZE
属性指定(在创建目标时从 CMAKE_UNITY_BUILD_BATCH_SIZE
变量复制而来)。您可以修改目标属性或默认变量。
从3.18版本开始,您可以显式定义文件应如何通过命名分组进行捆绑。为此,将目标的 UNITY_BUILD_MODE
属性更改为 GROUP
(默认为 BATCH
)。然后,通过将源文件的 UNITY_GROUP
属性设置为您选择的名称来将它们分配到组中:
set_property(SOURCE <src1> <src2> PROPERTY UNITY_GROUP "GroupA")
随后 CMake 将忽略 UNITY_BUILD_BATCH_SIZE
,并将组中的所有文件添加到单个统一构建中。
CMake 文档建议不要默认在公共项目中启用统一构建。建议您的应用程序最终用户应能通过提供- DCMAKE_UNITY_BUILD
命令行参数来决定是否要使用批量构建。如果由于代码编写方式导致统一构建出现问题,您应明确将目标属性设置为 false。但对于内部使用的代码(如公司内部或私人项目),您可以自由启用此功能。
以下是使用 CMake 减少编译时间的最关键要点。编程中还有其他常耗费大量时间的环节——最臭名昭著的当属调试环节。接下来我们看看如何在这方面进行优化。
查找错误
作为程序员,我们花费大量时间寻找程序错误。遗憾的是,这是我们职业的常态。识别和修正错误的过程常常令人抓狂,尤其是需要长时间投入时。当我们缺乏必要工具来应对这些棘手情况时,困难程度更会成倍增加。因此,我们必须高度重视开发环境的配置,尽可能简化这一过程,使其变得轻松可承受。通过配置编译器使用 target_compile_options()
便是实现这一目标的途径之一。那么,哪些编译选项能帮助我们达成目标呢?
错误与警告配置
软件开发中有许多令人压力山大的事情——深夜修复关键错误、处理大型系统中高已关注度高代价的故障,以及应对烦人的编译错误。有些错误难以理解,有些则修复起来冗长棘手。为了简化工作并降低失败概率,你会发现许多关于如何配置编译器警告的建议。
一条看似精妙的建议是将 -Werror
标志设为所有构建的默认选项。表面上看,这个标志的功能简单明了——它把所有警告视为错误,迫使你在解决每个警告后才能编译代码。虽然这看似有益,实则很少如此。
要知道,警告之所以未被归类为错误是有原因的:它们旨在提醒你。如何处理这些警告应由你决定。特别是在实验性或原型开发阶段,能够自由选择忽略某些警告往往非常宝贵。
另一方面,如果你有一段完美无瑕、毫无警告、闪闪发光的代码,似乎让未来的修改玷污这种原始状态是一种遗憾。启用所有警告并保持现状会有什么危害呢?表面上看似乎没有,至少在你的编译器升级之前是这样。新版本的编译器往往对已弃用功能更加严格,或者更擅长提供改进建议。虽然这在警告仍保持为警告时是有益的,但也可能导致未修改的代码出现意外的构建失败,或者更令人沮丧的是,当你需要快速修复与新警告无关的问题时。
那么,什么时候可以接受启用所有可能的警告呢?简短的答案是当你创建公共库时。在这些情况下,你会希望预先解决那些在比你更严格环境中因代码行为不当而产生的问题报告。如果你选择启用此设置,请确保及时了解新编译器版本及其引入的警告。重要的是要将此更新过程与代码更改分开进行明确管理。
否则,就让警告保持原样,专注于错误。如果你坚持要吹毛求疵,可以使用 -Wpedantic
标志。这个特定标志会启用严格 ISO C 和 ISO C++标准要求的所有警告。但请记住,该标志并不验证是否符合标准;它仅识别需要诊断信息的非 ISO 实践。
更为随和务实的程序员会对 -Wall
感到满意,若想增添一丝精致感,还可搭配 -Wextra
使用,这便已足够。这些警告被认为确实有用,在时间允许的情况下,你应当在代码中处理它们。
根据项目类型的不同,还有许多其他可能有用的警告标志。建议您查阅所选编译器的使用手册,了解可用的选项配置。
调试构建过程
有时编译会失败。这通常发生在我们尝试重构大量代码或清理构建系统时。有些问题可以轻松解决;但更复杂的问题则需要深入调查配置步骤。我们已经知道如何打印更详细的 CMake 输出(如第 1 章 《CMake 入门 》所述),但要如何分析每个阶段实际发生了什么?
调试各个阶段
-save-temps
参数可以传递给 GCC 和 Clang 编译器,使我们能够调试编译的各个阶段。该标志会指示编译器将某些编译阶段的输出保存到文件中,而非内存中。
add_executable(debug hello.cpp)
target_compile_options(debug PRIVATE -save-temps=obj)
启用此选项将为每个翻译单元生成两个额外文件( .ii
和 .s
)。
第一个变量 <build-tree>/CMakeFiles/<target>.dir/<source>.ii
存储预处理阶段的输出结果,其中包含注释说明源代码各部分的来源:
# 1 "/root/examples/ch07/06-debug/hello.cpp"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# / / / ... removed for brevity ... / / /
# 252 "/usr/include/x86_64-linux-
gnu/c++/9/bits/c++config.h" 3
namespace std
{
typedef long unsigned int size_t;
typedef long int ptrdiff_t;
typedef decltype(nullptr) nullptr_t;
}
...
第二个文件, <build-tree>/CMakeFiles/<target>.dir/<source>.s
,包含了语言分析阶段的输出结果,已为汇编阶段做好准备:
.file "hello.cpp"
.text
.section .rodata
.type _ZStL19piecewise_construct, @object
.size _ZStL19piecewise_construct, 1
_ZStL19piecewise_construct:
.zero 1
.local _ZStL8__ioinit
.comm _ZStL8__ioinit,1,1
.LC0:
.string "hello world"
.text
.globl main
.type main, @function
main:
( ... )
根据问题的类型,我们通常能够发现实际症结所在。例如,预处理器的输出可以帮助我们识别错误,比如错误的包含路径 (可能导致使用错误的库版本),或是定义错误导致 #ifdef
评估出现偏差。
与此同时,语言分析输出对于针对特定处理器和解决关键优化问题尤为有益。
调试头文件包含问题
调试错误包含的文件可能是一项具有挑战性的任务。我深有体会——在我的第一份企业工作中,我曾需要将整个代码库从一个构建系统移植到另一个。如果你遇到需要精确理解请求头文件包含路径的情况,可以考虑使用 -H
编译选项:
add_executable(debug hello.cpp)
target_compile_options(debug PRIVATE -H)
生成的输出将类似于以下内容:
[ 25%] Building CXX object
CMakeFiles/inclusion.dir/hello.cpp.o
. /usr/include/c++/9/iostream
.. /usr/include/x86_64-linux-gnu/c++/9/bits/c++config.h
... /usr/include/x86_64-linux-gnu/c++/9/bits/os_defines.h
.... /usr/include/features.h
-- removed for brevity --
.. /usr/include/c++/9/ostream
在目标文件名称之后,输出中的每一行都包含一个头文件路径。在此示例中,行首的单个点表示顶层包含(即 #include
指令位于 hello.cpp
中)。两个点表示该文件由后续文件包含( <iostream>)
)。每增加一个点即表示更深一层的嵌套。
在本章节末尾,您还可能找到针对代码改进的建议:
Multiple include guards may be useful for:
/usr/include/c++/9/clocale
/usr/include/c++/9/cstdio
/usr/include/c++/9/cstdlib
虽然不要求您处理标准库中的问题,但您可能会看到列出的某些自己的头文件。在这种情况下,您可能需要考虑进行修正。
为调试器提供信息
机器码是一系列晦涩难懂的指令和数据,以二进制格式编码。它并不传达任何更高层次的意义或目标。这是因为 CPU 并不关心程序的目的是什么,也不关心所有指令的含义。唯一的要求就是代码的正确性。编译器会将前述所有内容转换为 CPU 指令的数字标识符,在需要时存储数据以初始化内存,并提供数以万计的内存地址。换句话说,最终的二进制文件不需要包含实际的源代码、变量名、函数签名或程序员关心的任何其他细节。这就是编译器的默认输出——原始且赤裸的。
这样做主要是为了节省空间并减少执行时的开销。巧合的是,我们也在一定程度上保护了应用程序不被轻易逆向工程破解。没错,即使没有源代码,你也能理解每条 CPU 指令的作用(比如将这个值复制到那个寄存器)。但即便是基础程序也包含了太多这样的指令,要完全理解它们几乎是不可能的。
如果你是个特别有干劲的人,可以使用一种名为反汇编器的工具,凭借丰富的知识(外加一点运气),或许能解读出程序的运行逻辑。不过这种方法并不实用,因为反汇编后的代码失去了原始符号,导致理清代码结构与流向变得极其困难且耗时。
相反,我们可以要求编译器将源代码连同编译代码与原始代码之间的引用映射一起存储在生成的二进制文件中。这样,我们就能将调试器附加到运行中的程序上,随时查看正在执行的源代码行。这对于代码开发工作——比如编写新功能或修正错误——而言是不可或缺的。
这两个用例正是需要两种构建配置的原因: Debug
和 Release
。正如我们之前所见,CMake 会默认向编译器提供一些标志来管理此过程,首先将它们存储在全局变量中:
CMAKE_CXX_FLAGS_DEBUG
包含 -g
CMAKE_CXX_FLAGS_RELEASE
包含 -DNDEBUG
-g
标志仅表示”添加调试信息”。它以操作系统原生格式提供:stabs、COFF、XCOFF 或 DWARF。这些格式随后可被诸如 gdb
(GNU 调试器)等调试工具访问。对于 CLion 等 IDE(因为它们底层使用 gdb
),这通常已足够。其他情况下,请参考所用调试器的手册,查看所选编译器对应的正确标志。
对于 Release
配置,CMake 会添加 -DNDEBUG
标志。这是一个预处理器定义,仅表示”非调试构建”。某些面向调试的宏会因此选项被故意禁用。其中之一是 <assert.h>
头文件中提供的 assert
。若您决定在生产代码中使用断言,它们将完全不起作用:
int main(void)
{
assert(false);
std::cout << "This shouldn't run.
";
return 0;
}
在 Release
配置中, assert(false)
调用不会产生任何效果,但在 Debug
环境下却能正常停止执行。若您正在实践防御性编程,同时又需要在发布版本中使用 assert()
,该如何处理?您有两种选择:要么修改 CMake 提供的默认设置(从 CMAKE_CXX_FLAGS_RELEASE
中移除 NDEBUG
),要么在包含头文件前通过取消宏定义来实现硬编码覆盖:
#undef NDEBUG
#include <assert.h>
更多信息请参阅断言参考:https://en.cppreference.com/w/c/error/assert。
如果你的断言能在编译时完成,可以考虑用 C++11 引入的 static_assert()
替代 assert()
,因为这个函数不像 assert()
那样受到 #ifndef(NDEBUG)
预处理指令的保护。
至此,我们已经学会了如何管理编译过程。
总结
我们又完成了一章!毫无疑问,编译是一个复杂的过程。面对各种边界情况和特定需求,如果没有强大的工具支持,管理起来会相当困难。值得庆幸的是,CMake 在这方面为我们提供了出色的支持。
那么,我们目前学到了什么?我们首先讨论了编译是什么,以及它在操作系统构建和运行应用程序的宏观流程中所处的位置。接着,我们剖析了编译的各个阶段及其内部管理工具。这些知识对于解决未来可能遇到的复杂问题至关重要。
随后,我们研究了如何使用 CMake 来验证主机上的编译器是否满足代码构建的所有必要条件。正如我们已经确认的,当用户使用我们的解决方案时,看到一条提示升级的友好信息,远比面对过时编译器因无法处理语言新特性而抛出的晦涩错误要好得多。
我们简要讨论了如何向已定义的目标添加源文件,随后转向预处理器的配置。这是一个相当重要的主题,因为这一阶段将所有代码片段整合在一起,并决定哪些部分将被忽略。我们讨论了提供文件路径的方法,以及如何单独或批量添加自定义定义(包括一些使用案例)。接着,我们探讨了优化器;我们研究了所有常规优化级别及其隐式添加的标志。我们还详细介绍了其中几个标志—— finline
、 floop-unroll
和 ftree-vectorize
。
终于到了重新审视全局、研究如何管理编译可行性的时刻。我们在此主要探讨了两个关键方面——缩短编译时间(这间接有助于保持程序员的专注力)以及发现错误。后者对于识别问题所在及其原因至关重要。正确配置工具并理解现象背后的原因,将极大助力于保障代码质量(同时维护我们的心理健康)。
在下一章中,我们将学习链接相关知识,以及构建库并在项目中使用时需要考虑的所有事项。
暂无评论内容