面向 C++ 的现代 CMake 教程(二)(4)https://developer.aliyun.com/article/1525491
循环展开
循环展开是一种优化技术,也被称为循环展开。通用方法是将循环转换为一组实现相同效果的语句。这样做,我们将用程序的大小换取执行速度,因为我们减少了或消除了控制循环的指令——指针算术或循环末端测试。
请考虑以下示例:
void func() { for(int i = 0; i < 3; i++) cout << "hello\n"; }
之前的代码将被转换为类似这样的内容:
void func() { cout << "hello\n"; cout << "hello\n"; cout << "hello\n"; }
结果将相同,但我们不再需要分配i
变量,增加它,或三次将其与3
进行比较。如果我们程序运行期间调用func()
足够多次,即使是对这样一个简短且小的函数进行展开,也会产生显著的差异。
然而,理解两个限制因素很重要。循环展开只有在编译器知道或可以有效估计迭代次数时才能工作。其次,循环展开可能会对现代 CPU 产生不希望的效果,因为代码尺寸的增加可能会阻止有效缓存。
每个编译器提供这个标志的略有不同的版本:
-floop-unroll
:GCC-funroll-loops
:Clang
如果你有疑问,广泛测试这个标志是否影响你的特定程序,并显式启用或禁用它。请注意,在 GCC 上,-O3
作为隐式启用的-floop-unroll-and-jam
标志的一部分隐式启用。
循环向量化
单指令多数据(SIMD)是 20 世纪 60 年代初为实现并行化而开发的一种机制。它的工作方式正如其名称所暗示的那样;它可以同时对多块信息执行相同的操作。实际意味着什么?让我们考虑以下示例:
int a[128]; int b[128]; // initialize b for (i = 0; i<128; i++) a[i] = b[i] + 5;
通常,前面的代码会循环 128 次,但是有了性能强大的 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
目标属性中。正如你在第四章,《使用目标》中了解到的,我们可以使用传播属性通过使用PUBLIC
或INTERFACE
关键字将头文件与任何依赖的目标共享;然而,对于使用install()
命令导出的目标,不应该这样做。其他项目不应当被迫消耗我们的预编译头文件(因为这不符合常规)。
重要提示:*
如果你需要内部预编译头文件但仍然希望安装导出目标,那么第四章,《使用目标》中描述的$
生成器表达式将防止头文件出现在使用要求中。然而,它们仍然会被添加到使用export()
命令从构建树导出的目标中。*
CMake 会将所有头文件的名称放入一个cmake_pch.h|xx
文件中,然后预编译为具有.pch
、.gch
或.pchi
扩展名的特定于编译器的二进制文件。*
我们可以像这样使用它:*
chapter05/06-precompile/CMakeLists.txt*
add_executable(precompiled hello.cpp) target_precompile_headers(precompiled PRIVATE <iostream>)
chapter05/06-precompile/hello.cpp*
int main() { std::cout << "hello world" << std::endl; }
请注意,在我们的main.cpp
文件中,我们不需要包含cmake_pch.h
或其他任何头文件——CMake 会使用特定的命令行选项强制包含它们。*
在前一个示例中,我使用了一个内置的头文件;然而,你可以很容易地添加自己的头文件,带有类或函数定义:*
header.h
被视为相对于当前源目录的,并将使用绝对路径包含进来。*[["header.h"]]
根据编译器的实现来解释,通常可以在INCLUDE_DIRECTORIES
变量中找到。使用target_include_directiories()
来配置它。*
一些在线参考资料将不鼓励预编译不属于标准库的头文件,如,或使用预编译头文件。这是因为更改列表或编辑自定义头文件会导致目标中所有翻译单元的重新编译。使用 CMake,你不需要担心这么多,尤其是如果你正确地组织你的项目(具有相对较小的目标,专注于狭窄的领域)。每个目标都有一个单独的预编译头文件,限制了头文件更改的扩散。*
另一方面,如果你的头文件被认为相当稳定,你可能会决定从一个小目标中重复使用预编译的头文件到另一个目标中。CMake 为此目的提供了一个方便的命令:
target_precompile_headers(<target> REUSE_FROM <other_target>)
这设置了使用头文件的目标的PRECOMPILE_HEADERS_REUSE_FROM
属性,并在这些目标之间创建了一个依赖关系。使用这种方法,消费目标无法再指定自己的预编译头文件。另外,所有编译选项、编译标志和编译定义必须在目标之间匹配。注意要求,特别是如果你有任何使用双括号格式的头文件([["header.h"]]
)。两个目标都需要适当地设置它们的包含路径,以确保编译器能够找到这些头文件。
Unity 构建
CMake 3.16 还引入了另一个编译时间优化功能——统一构建,也称为统一构建或巨构建。统一构建将多个实现源文件与#include
指令结合在一起(毕竟,编译器不知道它是在包含头文件还是实现)。这带来了一些有趣的含义——有些是非常有用的,而其他的是潜在有害的。
让我们从最明显的一个开始——避免在 CMake 创建统一构建文件时在不同翻译单元中重新编译头文件:
#include "source_a.cpp" #include "source_b.cpp"
当这两个源中都包含#include "header.h"
行时,多亏了包含守卫(假设我们没有忘记添加那些),它只会被解析一次。这不如预编译头文件优雅,但这是一个选项。
这种构建方式的第二个好处是,优化器现在可以更大规模地作用,并优化所有捆绑源之间的跨过程调用。这类似于我们在第二章《CMake 语言》中讨论的链接时间优化。
然而,这些好处是有代价的。因为我们减少了对象文件的数量和处理步骤,我们也增加了处理更大文件所需的内存量。此外,我们减少了并行化工作量。编译器并不是真正那么擅长多线程编译,因为它们不需要——构建系统通常会启动许多编译任务,以便在不同的线程上同时执行所有文件。当我们把所有文件放在一起时,我们会使它变得困难得多,因为 CMake 现在会在我们创建的多个巨构建之间安排并行构建。
在使用统一构建时,你还需要考虑一些可能不是那么明显捕捉到的 C++语义含义——匿名命名空间跨文件隐藏符号现在被分组到一组中。静态全局变量、函数和宏定义也是如此。这可能会导致名称冲突,或者执行不正确的函数重载。
在重新编译时,巨构构建不受欢迎,因为它们会编译比所需更多的文件。当代码旨在尽可能快地整体编译所有文件时,它们效果最佳。在 Qt Creator 上进行的测试表明,您可以期待性能提升在 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
。然而,这并不妨碍您为内部使用的代码启用此功能,例如在公司内部或为您私人项目使用。
不支持的 C++20 模块
如果你密切关注 C++标准的发布,你会知道 C++20 引入了一个新特性——模块。这是一个重大的变革。它允许你避免使用头文件时的许多烦恼,减少构建时间,并使得代码更简洁、更易于导航和推理。
本质上,我们可以创建一个带有模块声明的单文件,而不是创建一个单独的头部和实现文件:
export module hello_world; import <iostream>; export void hello() { std::cout << "Hello world!\n"; }
然后,你可以在代码中简单地导入它:
import hello_world; int main() { hello(); }
注意我们不再依赖预处理器;模块有自己的关键字——import
、export
和module
。最受欢迎的编译器最新版本已经可以执行所有必要的任务,以支持模块作为编写和构建 C++解决方案的新方法。我原本希望在本章开始时,CMake 已经提供了对模块的早期支持。不幸的是,这一点尚未实现。
然而,到你购买这本书的时候(或不久之后)可能就有了。有一些非常好的指标;Kitware 开发者已经创建(并在 3.20 中发布)了一个新的实验性特性,以支持 C++20 模块依赖项扫描对 Ninja 生成器的支持。现在,它只打算供编译器编写者使用,这样他们就可以在开发过程中测试他们的依赖项扫描工具。
当这个备受期待的特性完成并在一个稳定的版本中可用时,我建议彻底研究它。我预计它将简化并大大加快编译速度,超过今天可用的任何东西。
查找错误。
作为程序员,我们花了很多时间寻找 bug。这是一个悲哀的事实。查找并解决错误常常会让我们感到不舒服,尤其是如果它需要长时间的话。如果我们没有仪器帮助我们导航暴风雨,盲目飞行会更困难。这就是为什么我们应该非常小心地设置我们的环境,使这个过程尽可能容易和可忍受。我们通过使用target_compile_options()
配置编译器来实现这一点。那么编译选项能帮助我们什么呢?
配置错误和警告。
软件开发中有许多令人压力很大的事情——比如在半夜修复关键错误、在高知名度的大型系统中处理昂贵的失败、以及处理那些令人烦恼的编译错误,尤其是那些难以理解或修复起来极其繁琐的错误。当研究一个主题以简化你的工作并减少失败的可能性时,你会发现有很多关于如何配置编译器警告的建议。
一条这样的好建议就是为所有构建启用-Werror
标志作为默认设置。这个标志做的简单而无辜的事情是——所有警告都被视为错误,除非你解决所有问题,否则代码不会编译。虽然这可能看起来是个好主意,但几乎从来不是。
你看,警告之所以不是错误,是有原因的。它们是用来警告你的。决定如何处理这是你的事。拥有忽视警告的自由,尤其是在你实验和原型化解决方案时,通常是一种祝福。
另一方面,如果你有一个完美无瑕、没有警告、闪闪发光的代码,允许未来的更改破坏这种情况真是太可惜了。启用它并只是保持在那里会有什么害处呢?表面上看起来没有。至少在你升级编译器之前是这样。编译器的新版本往往对弃用的特性更加严格,或者更好地提出改进建议。当你不将所有警告视为错误时,这很好,但当你这样做时,有一天你会发现你的构建开始在没有代码更改的情况下失败,或者更令人沮丧的是,当你需要快速修复一个与新警告完全无关的问题时。
那么,“几乎不”是什么意思,当你实际上应该启用所有可能的警告时?快速答案是当你编写一个公共库时。这时,你真的想避免因为你的代码在一个比你的环境更严格的编译器中编译而产生问题报告。如果你决定启用它,请确保你对编译器的新版本和它引入的警告了如指掌。
否则,让警告就是警告,专注于错误。如果你觉得自己有必要吹毛求疵,可以使用-Wpedantic
标志。这是一个有趣的选择——它启用了所有严格遵循 ISO C 和 ISO C++所要求的警告。请注意,使用此标志并不能检查代码是否符合标准——它只能找到需要诊断信息的非 ISO 实践。
更加宽容和脚踏实地的程序员会对-Wall
感到满意,可选地加上-Wextra
,以获得那种额外的华丽感觉。这些被认为是有实际用处和意义的警告,当你有空时应该修复你的代码中的这些问题。
还有许多其他的警告标志,这取决于项目的类型可能会有所帮助。我建议你阅读你选择的编译器的手册,看看有什么可用。
调试构建过程
偶尔,编译会失败。这通常发生在我们试图重构一堆代码或清理我们的构建系统时。有时,事情很容易解决,但随后会有更复杂的问题,需要深入分析配置的每个步骤。我们已经知道如何打印更详细的 CMake 输出(如在第一章中讨论的,CMake 的初步步骤),但我们如何分析在每个阶段实际发生的情况呢?
调试单个阶段
我们可以向编译器传递-save-temps
标志(GCC 和 Clang 都有这个标志),它将强制将每个阶段的输出存储在文件中,而不是内存中:
chapter05/07-debug/CMakeLists.txt
add_executable(debug hello.cpp) target_compile_options(debug PRIVATE -save-temps=obj)
前面的片段通常会产生两个额外的文件:
/CMakeFiles/.dir/.ii
:存储预处理阶段的输出,带有注释解释源代码的每一部分来自哪里:
# 1 "/root/examples/chapter05/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; } ...
/CMakeFiles/.dir/.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: ( ... )
根据问题的性质,我们通常可以发现实际的问题所在。预处理器的输出对于发现诸如不正确的include 路径(提供错误版本的库)以及导致错误#ifdef
评估的定义错误等 bug 很有帮助。
语言分析阶段的输出对于针对特定处理器和解决关键优化问题很有用。
解决头文件包含的调试问题
错误地包含的文件可能是一个真正难以调试的问题。我应该知道——我的第一份企业工作就是将整个代码库从一种构建系统移植到另一种。如果你发现自己需要精确了解正在使用哪些路径来包含请求的头文件,可以使用-H
:
chapter05/07-debug/CMakeLists.txt
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
在object file
的名称之后,输出中的每一行都包含一个头文件的路径。行首的一个点表示顶级包含(#include
指令在hello.cpp
中)。两个点意味着这个文件被包含。进一步的点表示嵌套的又一层。
在这个输出的末尾,你也许还会找到对代码可能的改进建议:
Multiple include guards may be useful for: /usr/include/c++/9/clocale /usr/include/c++/9/cstdio /usr/include/c++/9/cstdlib
你不必修复标准库,但可能会看到一些自己的头文件。你可能想修正它们。
提供调试器信息
机器代码是一系列用二进制格式编码的指令和数据,它不传达任何意义或目标。这是因为 CPU 不关心程序的目标是什么,或者所有指令的含义是什么。唯一的要求是代码的正确性。编译器会将所有内容转换成 CPU 指令的数值标识符、一些用于初始化内存的数据以及成千上万的内存地址。换句话说,最终的二进制文件不需要包含实际的源代码、变量名、函数签名或程序员关心的任何其他细节。这就是编译器的默认输出——原始且干燥。
这样做主要是为了节省空间并在执行时尽量减少开销。巧合的是,我们也在一定程度上(somewhat)保护了我们的应用程序免受逆向工程。是的,即使没有源代码,你也可以理解每个 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
,它在头文件中可用。如果你决定在你的生产代码中使用断言,它们将根本不会工作:
int main(void) { bool my_boolean = false; assert(my_boolean); std::cout << "This shouldn't run. \n"; return 0; }
在Release
配置中,assert(my_boolean)
调用将不会产生任何效果,但在Debug
模式下它会正常工作。如果你在实践断言性编程的同时还需要在发布构建中使用assert()
,你会怎么做?要么更改 CMake 提供的默认设置(从CMAKE_CXX_FLAGS_RELEASE
中移除NDEBUG
),要么通过在包含头文件前取消定义宏来实现硬编码覆盖:
#undef NDEBUG #include <assert.h>
有关assert
的更多信息,请参考:en.cppreference.com/w/c/error/assert
。
总结
我们已经完成了又一章!毫无疑问,编译是一个复杂的过程。有了所有的边缘情况和特定要求,如果没有一个好工具,管理起来可能会很困难。幸运的是,CMake 在这方面做得很好。
到目前为止,我们学到了什么?我们首先讨论了编译是什么以及它在操作系统中构建和运行应用程序的更广泛故事中的位置。然后,我们研究了编译的阶段以及管理它们的内部工具。这对于解决我们将来可能会遇到的更高级别案例中的所有问题非常有用。
然后,我们探讨了如何让 CMake 验证宿主上可用的编译器是否满足我们代码构建的所有必要要求。正如我们之前所确定的,对于我们的解决方案的用户来说,看到一个友好的消息要求他们升级,而不是由一个混淆于语言新特性的旧编译器打印出的某些神秘错误,会是一个更好的体验。
我们简要讨论了如何向已定义的目标添加源代码,然后转向预处理器配置。这是一个相当大的主题,因为这一阶段将所有的代码片段汇集在一起,决定哪些将被忽略。我们谈论了提供文件路径以及作为单个参数和批量(还有一些用例)添加自定义定义。
然后,我们讨论了优化器;我们探索了所有通用优化级别的优化以及它们隐含的标志,但我们也详细讨论了其中的一些——finline
、floop-unroll
和ftree-vectorize
。
最后,是再次研究整体编译流程和如何管理编译可行性的时候了。在这里我们解决了两个主要问题——减少编译时间(从而加强程序员的注意力集中)和查找错误。后者对于发现什么坏了和如何坏是非常重要的。正确设置工具并了解事情为何如此发生,在确保代码质量(以及我们的心理健康)方面起着很长的作用。
在下一章中,我们将学习链接知识,以及我们需要考虑的所有构建库和使用它们的项目中的事情。
进一步阅读
- 关于本章涵盖的更多信息,你可以参考以下内容:CMake 支持的编译特性和编译器:
cmake.org/cmake/help/latest/manual/cmake-compile-features.7.html#supported-compilers
- 管理目标源文件:
- 提供包含文件的路径:
- 配置头文件: CMake 官方文档:configure_file 命令
- 预编译头文件: CMake 官方文档:target_precompile_headers 命令
- 统一构建:
- 查找错误——编译器标志:
interrupt.memfault.com/blog/best-and-worst-gcc-clang-compiler-flags
- 为什么使用库而不是对象文件:
stackoverflow.com/questions/23615282/object-files-vs-library-files-and-why
- 分离关注点:[https://nalexn.github.io/separation-of-concerns/](https://nalexn.github.io/separation-of-concerns/
`cpp
#undef NDEBUG
#include
有关`assert`的更多信息,请参考:[`en.cppreference.com/w/c/error/assert`](https://en.cppreference.com/w/c/error/assert)。 # 总结 我们已经完成了又一章!毫无疑问,编译是一个复杂的过程。有了所有的边缘情况和特定要求,如果没有一个好工具,管理起来可能会很困难。幸运的是,CMake 在这方面做得很好。 到目前为止,我们学到了什么?我们首先讨论了编译是什么以及它在操作系统中构建和运行应用程序的更广泛故事中的位置。然后,我们研究了编译的阶段以及管理它们的内部工具。这对于解决我们将来可能会遇到的更高级别案例中的所有问题非常有用。 然后,我们探讨了如何让 CMake 验证宿主上可用的编译器是否满足我们代码构建的所有必要要求。正如我们之前所确定的,对于我们的解决方案的用户来说,看到一个友好的消息要求他们升级,而不是由一个混淆于语言新特性的旧编译器打印出的某些神秘错误,会是一个更好的体验。 我们简要讨论了如何向已定义的目标添加源代码,然后转向预处理器配置。这是一个相当大的主题,因为这一阶段将所有的代码片段汇集在一起,决定哪些将被忽略。我们谈论了提供文件路径以及作为单个参数和批量(还有一些用例)添加自定义定义。 然后,我们讨论了优化器;我们探索了所有通用优化级别的优化以及它们隐含的标志,但我们也详细讨论了其中的一些——`finline`、`floop-unroll`和`ftree-vectorize`。 最后,是再次研究整体编译流程和如何管理编译可行性的时候了。在这里我们解决了两个主要问题——减少编译时间(从而加强程序员的注意力集中)和查找错误。后者对于发现什么坏了和如何坏是非常重要的。正确设置工具并了解事情为何如此发生,在确保代码质量(以及我们的心理健康)方面起着很长的作用。 在下一章中,我们将学习链接知识,以及我们需要考虑的所有构建库和使用它们的项目中的事情。 ## 进一步阅读 + 关于本章涵盖的更多信息,你可以参考以下内容:*CMake 支持的编译特性和编译器:* [`cmake.org/cmake/help/latest/manual/cmake-compile-features.7.html#supported-compilers`](https://cmake.org/cmake/help/latest/manual/cmake-compile-features.7.html#supported-compilers) + *管理目标源文件:* + [Stack Overflow 讨论:为什么 CMake 的文件匹配功能这么“邪恶”?](https://stackoverflow.com/questions/32411963/why-is-cmake-file-glob-evil) + [CMake 官方文档:target_sources 命令](https://cmake.org/cmake/help/latest/command/target_sources.html) + *提供包含文件的路径:* + [C++参考:预处理器中的#include 指令](https://en.cppreference.com/w/cpp/preprocessor/include) + [CMake 官方文档:target_include_directories 命令](https://cmake.org/cmake/help/latest/command/target_include_directories.html) + *配置头文件:* [CMake 官方文档:configure_file 命令](https://cmake.org/cmake/help/latest/command/configure_file.html) + *预编译头文件:* [CMake 官方文档:target_precompile_headers 命令](https://cmake.org/cmake/help/latest/command/target_precompile_headers.html) + *统一构建:* + [CMake 官方文档:UNITY_BUILD 属性](https://cmake.org/cmake/help/latest/prop_tgt/UNITY_BUILD.html) + [Qt 官方博客:关于即将到来的 CMake 中的预编译头文件和大型构建](https://www.qt.io/blog/2019/08/01/precompiled-headers-and-unity-jumbo-builds-in-upcoming-cmake) + *查找错误——编译器标志:* [`interrupt.memfault.com/blog/best-and-worst-gcc-clang-compiler-flags`](https://interrupt.memfault.com/blog/best-and-worst-gcc-clang-compiler-flags) + *为什么使用库而不是对象文件:* [`stackoverflow.com/questions/23615282/object-files-vs-library-files-and-why`](https://stackoverflow.com/questions/23615282/object-files-vs-library-files-and-why) + 分离关注点:*[https://nalexn.github.io/separation-of-concerns/](https://nalexn.github.io/separation-of-concerns/*