介绍
从一个简单的程序开始,逐步扩充,展开 CMake 的各项功能的讲解。
用户运行程序时将一个数字作为参数,程序计算该参数的平方根并在屏幕上显示出来。
准备
- 系统:win10
- 编辑器:VS Code
- 编译器:Visual Studio 17 2022 MSVC 19.39.33523.0
或者 MinGW, g++ 13.2.0
- CMake:3.28.2
打开 CMake 官方文档,遇到想详细了解的命令,随时到这里搜索。
创建一个空文件夹 sqrt,用 VSCode 打开。
基础
1. 最简单的项目
用一个单文件程序作为例子,先手动编译,后再用 CMake 编译,演示最基础的部分。
创建文件sqrt.cpp
,内容如下:
先不用 CMake,用 g++ 手动编译看看:
上述输出证明程序运行正常,下面我们在用 CMake 编译这段代码的时候,如果编译失败,从我们写得 CMake 配置找原因即可,就不用怀疑代码有问题了。
创建文件CMakeLists.txt
,这是 CMake 的配置文件。配置文件写好后,用 CMake 构建项目有两个步骤,配置(Configure)和生成(Generate)。下面来写配置文件CMakeLists.txt
。
首先指定项目需要的最低的 CMake 版本,意思是,如果把项目放到另外一台电脑上构建,如果那台电脑上 CMake 的版本低于配置文件这里指定的版本,则无法构建,CMake 版本必须要高于这里指定的版本号。使用命令 cmake_mini_required()
:
接下来需要设置项目名称,使用命令project()
:
之后,需要告诉 CMake 要生成的可执行文件的名称和源文件名称,CMake将根据这些源文件生成该可执行文件,使用命令 add_executable()
:
经过上述配置,就可以构建、运行一个最简单的CMake项目了。现在的文件CMakeLists.txt
是这样的:
打开终端(Ctrl + Shift + 反引号),输入如下命令构建、运行项目:
第一句 cmake -B build
是 cmake -S . -B build
的简写,意思是,从当前文件夹(.
)寻找源代码文件,把生成的构建项目所需的文件放在文件夹build
中。如果用的不是 MSVC,而是 MinGW,则可能需要像这样指定需要使用的生成器:cmake -G "MinGW Makefiles" -B build
。
第二句 cmake --build build
用于实际构建项目,告诉 CMake 从文件夹 build
中寻找所需的文件。
第三句 build\Debug\sqrt.exe 9
运行程序,如果不是用 MSVC 构建项目,而是用 MinGW,则可执行程序可能直接放在 build
目录下,而不是在 build\Debug
里。
从 C++20 开始,可以使用 std::format()
格式化字符串,相比手动拼接字符串更加优雅。修改文件代码sqrt.cpp
如下:
手动编译运行项目:g++ sqrt.cpp -o sqrt -std=c++20
,一切正常。注意-std=c++20
用于指定C++版本为20。
用 CMake 构建项目会怎样样呢?再次运行命令构建项目,将得到一大堆报错,报错信息很晦涩。报错的原因是,std::format()
是 C++20 新引入的特性,而 CMake 构建项目时使用的却不是 C++20,而是比较低的标准。那么怎样让 CMake 用 C++20 标准构建项目呢?
修改文件CMakeLists.txt
,设置变量CMAKE_CXX_STANDARD
和CMAKE_CXX_STANDARD_REQUIRED
:
第一句 set(CMAKE_CXX_STANDARD 20)
指定 CMake 使用 C++20 标准;第二句 set(CMAKE_CXX_STANDARD_REQUIRED True)
要求编译器强制使用上一句指定的 C++ 标准。
现在的文件CMakeLists.txt
是这样的:
再次构建项目,一切正常。
3. 添加版本号
我想要一个这样的效果,不带参数运行程序时,打印程序当前的版本号,如下(假想):
这固然可以通过直接在源代码中添加字符串“sqrt Version 1.0”实现,但后续修改不方便。现在这个程序只有一个源代码文件还好说,如果是一个大型的项目,在很多处显示程序当前的版本号,每当程序更新一次就一个个去源代码里更改版本号很不现实。
我的想法是,在 文件CMakeLists.txt
里指定一个版本号,然后在源代码里使用这个版本号。随着程序的更新,我每次只需要更改这一处的版本号数值即可,很方便,很优雅。
修改文件CMakeLists.txt
中的命令project()
,改为:
通过上述命令设置了版本号后,CMake 会在幕后定义变量sqrt_VERSION_MAJOR
和sqrt_VERSION_MINOR
,在这里,两个变量的值分别为1
和0
。如果能够在程序中使用这两个变量,就可以得到版本号了。
接下来,创建一个新文件SqrtConfig.h.in
(input header file,输入头文件),内容如下:
我们设想的是,在 CMake 构建项目时,把这个文件替换为 SqrtConfig.h
(这是一个头文件),把文件的内容替换为:
然后在源代码文件sqrt.cpp
中引用这个头文件SqrtConfig.h
,就相当于定义了两个宏SQRT_VERSION_MAJOR
和SQRT_VERSION_MINOR
,通过这两个宏就可以得到程序版本号。
怎么实现呢?
修改文件CMakeLists.txt
,使用命令configure_file()
:
通过这句命令,SqrtConfig.h.in
将替换为 SqrtConfig.h
,替换后的文件 SqrtConfig.h
将被放在项目存放最后生成的二进制文件的文件夹(project binary directory,项目二进制目录)中。
现在可执行文件sqrt
还不知道在哪里寻找头文件 SqrtConfig.h
,所以也就没办法包含(include)它,所以需要使用命令target_include_directories()
:
这样,编译器就知道要去项目的二进制目录中去寻找头文件SqrtConfig.h
了。
现在程序可以访问到在 CMake 配置文件中设定的程序版本号了,为了使用它,编辑源代码文件sqrt.cpp
:
源代码文件sqrt.cpp
完整代码如下:
此时,文件CMakeLists.txt
完整内容如下:
构建项目,运行程序,和我们在这一小节开头设想的效果相同。
添加库(library)
4. 库的基础使用
前面的程序,在计算平方根的时候,调用的是<cmath>
里的函数std::sqrt()
。接下来我们计划改用自己编写的函数来计算平方根。我们自己编写一个“数学函数库”,计算平方根的函数是库里的一个函数。下面来编写该库。
在项目根目录下创建文件夹MathFunctions
,并在其中创建如下几个文件:
文件 MathFunctions.h
代码为:
文件 MathFunctions.cpp
代码为:
文件 mysqrt.h
代码为:
文件 mysqrt.cpp
代码为:
接下来要做的,是让 CMake 认为文件夹 MathFunctions
是一个库,以及在代码sqrt.cpp
中调用该库。
在文件MathFunctions/CMakeLists.txt
中需要告诉 CMake 要生成的库的名称和源文件名称,CMake将根据这些源文件生成该库,使用命令 add_library()
:
在项目根目录下的CMakeLists.txt
文件(以后就用/CMakeLists.txt
表示项目根目录下的CMakeLists.txt
文件)中,告诉 CMake 在构建项目时需要构建MathFunctions
库,使用 add_subdirectory()
命令:
如果要在可执行文件 sqrt
中使用库 MathFunctions
中的函数,需要将 MathFunctions
链接到sqrt
,使用target_link_libraries()
命令:
现在可执行文件sqrt
还不知道在哪里寻找库MathFunctions
的头文件,所以需要再次使用命令target_include_directories()
:
表示sqrt
可以从源代码根目录下的MathFunctions
文件夹中寻找库MathFunctions
的头文件。
对于可执行文件sqrt
,现在在 CMakeLists.txt
文件中就用了两次target_include_directories()
命令:
可以把它们合并为一句:
现在可执行目标sqrt
可以调用库目标MathFunctions
中的函数了。接下来修改sqrt
的源文件sqrt.cpp
:
源文件sqrt.cpp
完整代码如下:
此时,文件/CMakeLists.txt
完整内容如下:
文件 MathFunctions/CMakeLists.txt
的完整内容如下:
构建项目,运行程序,一切正常。
5. 添加选项,option()
现在我想在库MathFunctions
中添加一个选项,允许用户选择使用自定义的实现还是使用标准库的实现来计算平方根。
我的思路是,用户在构建 CMake 项目时,可以通过指定一个变量的值来选择使用不同的实现。CMake 接收到这个变量的值后,为一个宏设定不同的值。在代码里,根据宏的值的不同,调用不同的代码。
在文件MathFunctions/CMakeLists.txt
中添加一个选项,使用命令option()
:
用户在配置 CMake 构建时,可以通过参数(-DUSE_MYMATH=ON
或 -DUSE_MYMATH=OFF
)更改该变量的值(开/关,ON/OFF)来控制程序使用的不同实现。用户只需要设定该变量一次,之后该变量的值会储存在缓存中,后续用户在每次配置时可自动从缓存中读取值,无需手动设置。
接下来继续修改文件 MathFunctions/CMakeLists.txt
,当选项USE_MYMATH
为ON
时添加宏USE_MYMATH
,使用命令 if()
和 target_compile_definitions
:
配置好选项和宏定义后,接下来在源代码中使用宏。修改文件MathFunctions/MathFunctions.cpp
后完整代码如下:
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下:
文件 /CMakeLists.txt
未改变。
按照如下方式构建、运行程序,可以选择使用不同的实现:
虽然看起来一切正常,但是这样仍然不够好,原因是,当USE_MYMATH=OFF
时虽然 mysqrt.cpp
没有被使用,但该文件仍然会被编译。
怎样实现“若一个源文件没有被使用,就不编译它”?有两种方法。
不编译未使用的源文件 方法1
将库 MathFunctions
添加文件 mysqrt.cpp
的命令放在条件判断里,如果USE_MYMATH=OFF
,就不将文件 mysqrt.cpp
添加到库MathFunctions
。使用命令 target_sources()
:
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下:
文件 /CMakeLists.txt
未改变。
构建过程和之前一样,一切正常。
不编译未使用的源文件 方法2
将源文件 mysqrt.cpp
放到一个单独的库里,将添加这个库的命令(add_library()
)放到条件判断里。如果USE_MYMATH=OFF
,就不在库MathFunctions
中链接这个库;如果USE_MYMATH=ON
,就在库MathFunctions
中链接这个库。
修改文件MathFunctions/CMakeLists.txt
如下,使用命令add_library()
和target_link_libraries()
:
当 USE_MYMATH=ON
,条件判断成立,就添加库SqrtLibrary
,并将其链接到库MathFunctions
。当 USE_MYMATH=OFF
,条件判断不成立,CMake 就不会把文件 mysqrt.cpp
添加到构建文件里,编译器就不会看到文件mysqrt.cpp
,也就不会编译它了。
命令 add_library(SqrtLibrary STATIC mysqrt.cpp)
中的 STATIC
告诉 CMake 创建一个静态库。静态库会在链接时被完整地复制到目标(target,这里是指库MathFunctions
)中。
命令 target_link_libraries(MathFunctions PRIVATE SqrtLibrary)
中的 PRIVATE
是指SqrtLibrary
只会被链接到目标MathFunctions
中,并不会传递到依赖于该目标(指MathFunctions
)的其他目标中。
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下:
文件 /CMakeLists.txt
未改变。
构建过程和之前一样,一切正常。
优雅地使用库
6. 添加使用要求(Usage Requirements)
前面,可执行目标sqrt
在使用库MathFunctions
时,不仅要建立二者的链接(命令target_link_libraries()
),还要指出sqrt
应该到哪里寻找MathFunctions
的头文件(命令target_include_directories()
),这样很不优雅。文件/CMakeLists.txt
相关内容如下:
我想要的是,任何链接到库MathFunctions
的目标,都自动包含库MathFunctions
的头文件。
编辑文件MathFunctions/CMakeLists.txt
,像下面这样使用命令 target_include_directories()
:
上述命令中的INTERFACE
表示,目录${CMAKE_CURRENT_SOURCE_DIR}
将会被传递给任何链接到库MathFunctions
的目标,而不会被传递给库MathFunctions
自身(因为MathFunctions
自己原本就知道自己应该到哪里寻找自己需要的头文件)。INTERFACE
表示消费者(consumers)需要但生产者(producer)不需要的东西,这正好与PRIVATE
的作用相反。
接下来从文件 /CMakeLists.txt
中删去为目标sqrt
包含库MathFunctions
头文件所在目录的命令:
也就是说,目标 sqrt
链接到库MathFunctions
只需要这一句命令就可以了:
此时文件 /CMakeLists.txt
的完整内容如下:
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下:
构建过程和之前一样,一切正常。
在本例中,我理解的“使用要求(Usage Requirements)”是,如果你(目标target
)要想使用我这个库MathFunctions
,(要求)必须要包含这个文件夹${CMAKE_CURRENT_SOURCE_DIR}
。
7. 使用接口库(Interface Library)设置C++标准
前面我们通过在 target_include_directories()
命令中使用 INTERFACE
关键字,指定了一个”库自己不需要,但是依赖该库的目标(target,本例中指可执行程序sqrt
)需要”的文件夹。发散一下思维,是否可以利用这种特性为目标添加别的使用要求(Usage Requirements)?
下面我们利用接口库(INTERFACE
library)为目标指定应当使用的 C++ 标准。
修改文件/CMakeLists.txt
,首先删去(或注释掉)之前用于指定 C++ 标准的命令:
其次,添加一个名字为sqrt_compiler_flags
的 INTERFACE
库,并为该库添加编译器特征。使用命令add_library()
和target_compile_features()
:
最后,将各个目标sqrt
,SqrtLibrary
,MathFunctions
都链接到接口库sqrt_compiler_flags
,使用命令target_link_libraries()
:
/CMakeLists.txt
MathFunctions/CMakeLists.txt
这样一来,指定C++20标准的编译器特征,将会被传递给那些链接到接口库target_compile_features
的目标,也就相当于为这些目标设置了使用C++20标准。一次设置,多处使用。
此时文件 /CMakeLists.txt
的完整内容如下:
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下:
构建过程和之前一样,一切正常。
添加生成器表达式(Generator Expressions)
8. 添加编译器警告标志
在编译程序时添加编译器警告标志,优点很多,有助于提升代码质量。如果直接用编译器(如g++
)编译代码,若要添加警告标志,直接在命令后添加相应参数即可。如果用 CMake 构建项目,怎样添加警告标志呢。
CMake可以使用不同的编译器构建项目。警告标志因编译器而异。对于g++
,我想启用的警告标志有:-Wall;-Wextra;-Wshadow;-Wformat=2;-Wunused
;对于msvc
,我想启用的警告标志是/W3
。关于msvc
的各个警告标志的详细解释,可以看这个页面:/w, /W0, /W1, /W2, /W3, /W4, /w1, /w2, /w3, /w4, /Wall, /wd, /we, /wo, /Wv, /WX (Warning level) | Microsoft Learn。
添加编译器警告标志,可以使用命令 target_compile_options()
,这个命令专门用于添加编译选项(compile options,警告标志也是编译选项的一种)。但使用这个命令之前,首先需要判断当前设备上CMake 构建项目时使用的编译器是哪一种,这可以通过使用生成器表达式实现:
/CMakeLists.txt
gcc_like_cxx
和msvc_cxx
这两个变量必定不会同时为1
,通过这两个变量的取值,就可以判断出当前设备上 CMake 使用的编译器。
$<COMPILE_LANG_AND_ID:language,compiler_ids>
是一个生成器表达式,是$<COMPILE_LANGUAGE:language>
和$<LANG_COMPILER_ID:compiler_ids>
两者组合后的缩写形式。
通过例子来理解这几个生成器表达式:
如$<COMPILE_LANGUAGE:CXX>
,意思是如果编译单元(compilation unit,我理解为“源文件”)使用的语言是CXX
,则该生成器表达式的值为1
,否则为0
;又如$<LANG_COMPILER_ID:Clang,GNU,LCC>
,意思是如果CMake使用的编译器能够与Clang,GNU,LCC
其中之一匹配上,则该生成器表达式的值为1
,否则为0
。
理解了这两个生成器表达式,那么$<COMPILE_LANG_AND_ID:language,compiler_ids>
就好理解了,就是它们两个的组合罢了。如:$<COMPILE_LANG_AND_ID:CXX,ARMClang,AppleClang,Clang,GNU,LCC>
,意思是如果编译单元使用的语言是CXX
,并且 CMake 使用的编译器能够和ARMClang,AppleClang,Clang,GNU,LCC
其中之一匹配上,则该生成器表达式的值为为1
,否则为0
。$<COMPILE_LANG_AND_ID: CXX,ARMClang,AppleClang,Clang,GNU,LCC>
等价于$<AND:$<COMPILE_LANGUAGE:CXX>,$<LANG_COMPILER_ID:ARMClang,AppleClang,Clang,GNU,LCC>>
。
参考:cmake-generator-expressions(7)
gcc_like_cxx
和msvc_cxx
这两个变量必定不会同时为1
,通过这两个变量的取值,就可以
现在已判断出当前设备上 CMake 使用的编译器,接下来就可以添加警告标志了。使用命令target_compile_options()
:
/CMakeLists.txt
可以把$<${msvc_cxx}:-W3>
看作$<0:-W3>
或$<1:-W3>
,它们称为0,1表达式(0 and 1 expressions)。$<0:-W3>
会输出一个空字符串;$<1:-W3>
会输出:
后面的字符串,也就是-W3
。$<${gcc_like_cxx}:...>
同理。
为接口库sqrt_compiler_flags
添加警告标志后,这些警告标志将会被传递给那些链接到接口库target_compile_features
的目标,也就相当于为这些库设置了警告标志。一次设置,多处使用。
还有一个问题。我们在构建当前项目时为目标添加了警告标志,后面我们的库MathFunctions
会被导出(或者说“安装”),供其他项目使用。而其他的项目在构建时可能并不想添加这些警告标志。
但对于当前CMakeLists.txt
的配置,sqrt_compiler_flags
把警告标志传递给库MathFunctions
,其他项目链接到库MathFunctions
,警告标志会继续传递给这些库。因此需要修改文件/CMakeLists.txt
,修改命令 target_compile_options()
。使用生成器表达式$<BUILD_INTERFACE:...>
:
经过上述修改后,警告标志只会在项目构建期间被添加,而在安装期间不会被添加。也就是,安装后的项目不会继承这些警告标志,这些警告标志也就不会被传递给别的项目。
生成器表达式$<BUILD_INTERFACE:...>
和$<INSTALL_INTERFACE:...>
是一组的,一个用于将内容...
限定在构建阶段,一个用于将内容...
限定在安装阶段。可以在这里详细了解。
此时文件 /CMakeLists.txt
的完整内容如下:
文件 MathFunctions/CMakeLists.txt
未变。
构建过程和之前一样,一切正常。如果代码有问题,将显式更丰富的警告信息。
安装和测试
安装
现在项目的功能已经非常完善了。接下来我想将项目的可执行程序和库安装到系统某个位置。这样就不再需要完整路径,而只需要使用程序名称,就可以使用程序sqrt
(还需要把程序bin
目录放到PATH环境变量里);别的项目也可以调用库MathFunctions
。
对于库MathFunctions
,我想要将库文件和头文件分别安装到lib
和include
目录中;对于可执行目标sqrt
,我想要将可执行文件和配置的头文件(configured header,指SqrtConfig.h
,它由SqrtConfig.h.in
生成)分别安装到bin
和include
目录中。指定安装规则可以使用命令install()
。
在 MathFunctions/CMakeLists.txt
的末尾,添加以下规则:
在 /CMakeLists.txt
的末尾,添加以下规则:
这样,CMake 就知道该怎样安装项目了。
此时文件 /CMakeLists.txt
的完整内容如下:
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下:
构建、安装过程如下所示:
如果安装命令中不指定--prefix
,项目会被默认安装到C:\Program Files (x86)
,但由于没有管理员权限,可能安装失败。因此,如果你想要将项目安装到C:\Program Files (x86)
,可以另外打开一个以管理员身份运行的 powershell 窗口。
如果使用的是 MSVC,它支持多个构建配置(如 Debug 和 Release 等),构建、安装命令可能像这样:
安装后,目录的结构应该像这样:
测试 CTest
单元测试的意义和重要性无需多言。接下来为项目添加一些基础测试,使用 CTest 验证可执行程序sqrt
能否正常工作。使用命令enable_testing()
启用测试,使用命令add_test()
添加测试:
在 /CMakeLists.txt
的末尾,添加下述命令:
启用测试:
测试程序是否运行直到结束、无报错:
对于上述测试,我们不关心计算结果,只是验证程序能否平安运行到结束。
测试当提供的参数不正确时程序是否打印帮助信息:
set_tests_properties()
用于设置测试的属性。原型为:
命令 set_tests_properties(Usage PROPERTIES PASS_REGULAR_EXPRESSION "Usage:.*num")
的含义是,为测试Usage
设置属性PASS_REGULAR_EXPRESSION
,如果程序的输出不匹配正则表达式Usage:.*num
,就表示测试失败。
测试程序的计算结果是否正确:
为测试程序计算结果的正确性,添加更多的测试。为了方便添加测试,使用函数来帮忙:
使用的函数名为do_test
,它接收三个参数,分别是要测试的可执行程序名(target
)、要传递给程序的参数(arg
)、程序计算结果应匹配的正则表达式(result
)。
此时文件 /CMakeLists.txt
的完整内容如下:
文件 MathFunctions/CMakeLists.txt
未变。
构建、测试过程如下:
MSVC 支持多个构建配置(如 Debug 和 Release ),如果要测试Release
配置,可能像这样:
经过测试,我确实在程序中发现一个bug,ctest 的部分输出如下:
在将字符串"0.0001"
转换为双精度浮点数值后,用std::format()
打印该值时,打印的是用科学计数法表示的值9.999999747378752e-05
,而不是0.0001
。怎样解决?
在sqrt.cpp
和MathFunctions\mysqrt.cpp
中,为std::format()
指定格式化精度,相关语句如下:
再次构建、测试,全部通过。
测试仪表盘(Testing Dashboard)
CDash
可以把 CDash 理解成一个管理面板,上面汇总了每一次 CTest 的测试结果,并且可以根据这些测试结果数据生成统计信息。
接下来演示在本地运行测试后,怎样将本次的测试结果自动上传到 CDash。实际使用时,应当在 CDash 上创建一个 CTest 项目,然后从项目的设置页面下载到一个 cmake 脚本CTestConfig.cmake
,将这个脚本放到本地项目的根目录。这里只是演示,因此使用演示项目CMakeTutorial
,该脚本内容如下:
CTestConfig.cmake
然后编辑文件/CMakeLists.txt
,将enable_testing()
替换为include(CTest)
:
有了include(CTest)
,在 CTest 启动时,会自动加载 CTestConfig.cmake
文件,并根据其中的配置信息来设置 CTest 的行为。
此时文件 /CMakeLists.txt
的完整内容如下:
构建、测试过程如下:
命令ctest [-VV] -D Experimental
将构建项目、运行所有的测试,并把结果提交到 CDash。打开my.cdash.org/index.php?project=CMakeTutorial可以看到本次测试的报告和统计信息。
添加系统自省(System Introspection)
检查依赖项可用性(Assessing Dependency Availability)
在库SqrtLibrary
的源文件mysqrt.cpp
中,我们通过使用循环来计算 x
的平方根,这样做的速度不佳,因此我打算换用另一种计算方式:double result{std::exp(std::log(x) * 0.5)};
。
std::exp()
和std::log()
定义在头文件<cmath>
中,假如在不同的平台上不一定有 std::exp()
和std::log()
这两个函数(只是假如,为了演示的需要)。因此,在使用它们前需要先做一个判断,如果当前构建项目的平台上可以使用这两个函数,那就用,如果不能用,就继续用之前的方式来计算。怎样实现?
可以使用模块CheckCXXSourceCompiles
中的函数check_cxx_source_compiles()
来实现。编辑文件MathFunctions/CMakeLists.txt
,改动内容如下:
如果作为函数check_cxx_source_compiles()
第一个参数的代码片段可以编译通过,那么就将第二个参数设置为True
,否则设置为False
。上面的命令测试了std::exp()
和std::log()
两个函数,并将结果保存在变量HAVE_LOG
和HAVE_EXP
里。如果两个变量都为True
,就表明这两个函数都可用,这时使用命令target_compile_definitions()
在库SqrtLibrary
中定义两个宏HAVE_LOG
和HAVE_EXP
,这样就把 CMake 的变量传递到了源代码,源代码就能知道资源是否可用。
接下来修改源代码mysqrt.cpp
,变动内容如下:
如果定义了宏 HAVE_LOG
和HAVE_EXP
(#if defined(HAVE_LOG) && defined(HAVE_EXP)
),就使用新的计算平方根的方式,如果未定义,就继续使用旧的方式。
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下:
文件 /CMakeLists.txt
未变。
构建、运行过程与之前相同,不同的是,在生成构建项目所需文件时(cmake -B build
)会测试指定功能的可用性,输出内容节选如下:
添加自定义命令(Custom Command)
在程序中使用自定义命令生成的文件(Generated File)
前面,我们自己编写了函数来计算平方根,使用了 for 循环。后面又更改了实现,如果 std::log()
和 std::exp()
可用,就使用 std::log()
和std::exp()
来计算平方根。
我想要制作一个表格,对于常用的数字,比如 0 到 10,直接查表就可以得到平方根的计算结果,而不用再完整走一遍计算流程,这样速度可以更快。对于表里没有的数字,还是和之前一样计算。怎么实现?
我的思路是,首先写一个制作表格的程序,程序运行后可以生成一个头文件,头文件包含一个“表格”(数组)储存了预先计算好的结果;在构建项目时调用这个程序生成头文件;在源代码中引入这个头文件,做一个条件判断,当接收到的数值在表格内的时候,直接返回表格内对应的结果,如果不在表格内,照常计算。
创建源文件MathFunctions/MakeTable.cpp
,内容如下:
创建脚本文件MathFunctions/MakeTable.cmake
,内容如下:
脚本中的命令,首先添加一个可执行程序MakeTable
,并将其链接到接口库sqrt_compiler_flags
;然后添加了一个自定义命令,该命令告诉CMake,它将运行命令MakeTable ${CMAKE_CURRENT_BINARY_DIR}/Table.h
,运行完后将生成一个文件${CMAKE_CURRENT_BINARY_DIR}/Table.h
,这个命令依赖于目标(target)MakeTable
,所以得等它(指MakeTable
)编译完后才能调用此命令,并且,如果MakeTable
发生了改变,这个自定义命令也需要重新执行。
接下来,编辑文件MathFunctions/CMakeLists.txt
,变动内容如下:
在文件MathFunctions/CMakeLists.txt
顶部包含MakeTable.cmake
,MakeTable.cmake
里的命令会先于下面添加库SqrtLibrary
的命令运行,add_library(SqrtLibrary STATIC mysqrt.cpp ${CMAKE_CURRENT_BINARY_DIR}/Table.h)
又指出mysqrt.cpp
依赖于${CMAKE_CURRENT_BINARY_DIR}/Table.h
,因此 CMake 就知道了要等MakeTable.cmake
里的自定义命令运行完毕后才能编译SqrtLibrary
。命令target_include_directories()
的作用是让mysqrt.cpp
能够找到Table.h
(用于#include "Table.h"
)。
最后,修改源代码文件MathFunctions/mysqrt.cpp
,修改后的代码如下所示:
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下:
文件 /CMakeLists.txt
未变。
构建、运行过程与之前相同。
打包安装程序
之前,我们在“安装和测试”这一小节中,安装了从源代码构建的二进制文件。这一小节不一样,我们想把程序分发给其他人使用,而他们不需要从源代码开始构建项目,只是把我们预先构建好的二进制文件安装在它们系统上,之后就可以使用了。我们使用 CPack 来实现。
编辑文件/CMakeLists.txt
,在末尾添加下述内容:
首先引入模块InstallRequiredSystemLibraries
,该模块包括当前平台项目所需的所有运行时库。接下来设置一些变量,告诉 CPack 从哪里找项目许可证文本、项目版本号、使用 TGZ 作为源代码包生成器(创建完整源代码树的存档时压缩包的格式将是.tgz
)。最后,引入模块CPack
,它会使用上面设置的变量和一些当前系统的其他属性来设置安装程序。
在项目根目录下创建文件License.txt
,内容我随便写的”Hello, World!”。
此时文件 /CMakeLists.txt
的完整内容如下:
文件 MathFunctions/CMakeLists.txt
未变。
构建项目、打包安装程序的过程如下:
选择静态库或动态库
前面,构建库MathFunctions
时,我们一直构建的都是静态库。接下来我想要使用动态库。
在添加库(命令add_library()
)时,如果不显式指定库类型(STATIC
, SHARED
, MODULE
或 OBJECT
),CMake 将其默认构建为静态库(STATIC
)。如果想要修改这一默认行为,则可以添加选项BUILD_SHARED_LIBS
,使用option()
命令:
如果选项BUILD_SHARED_LIBS
为ON
,则如果在命令add_library()
中没有显式指定库类型,则默认构建动态库(SHARED
)。更多细节可看:BUILD_SHARED_LIBS。
对于三个变量CMAKE_ARCHIVE_OUTPUT_DIRECTORY
、CMAKE_LIBRARY_OUTPUT_DIRECTORY
、CMAKE_RUNTIME_OUTPUT_DIRECTORY
,我的理解比较粗浅。变量CMAKE_ARCHIVE_OUTPUT_DIRECTORY
指生成的静态库(.lib
, .a
)所在文件夹;变量 CMAKE_LIBRARY_OUTPUT_DIRECTORY
指在非Windows平台生成的动态库文件(.so
)所在文件夹;变量CMAKE_RUNTIME_OUTPUT_DIRECTORY
指可执行程序、在Windows平台生成的动态库文件(动态库.dll
和导入库.lib
)所在文件夹。
由于现在使用的是动态库,可执行程序sqrt
将在运行时(而不是编译时)链接到库MathFunctions
,如果库MathFunctions
的动态库文件(类似MathFunctions.dll
的名字)和可执行文件不在同一个目录,那么就无法找到动态库,所以程序就不能正确运行。所以需要把动态库和可执行文件放在同一个目录中(这里是${PROJECT_BINARY_DIR}
)。
如果使用 MinGW,按照和以前一样的步骤构建项目,可以构建成功,一切正常。但是 MSVC 将会构建失败,会提示LINK : fatal error LNK1104: 无法打开文件“Debug\MathFunctions.lib” [C:\Users\Aoyu\Desktop\sqrt\build\sqrt.vcxproj]
,这是由 MSVC 链接动态库的机制导致的:(来自:这里)通过 MSVC 编译DLL项目A时,会生成目标DLL文件和同名的LIB文件。在其他项目B引用该DLL时,在编译时项目B需要把LIB文件链接到项目中,编译完成后,运行时才需要DLL文件。上面我们在构建项目时只是生成了.dll
文件,没有同名的.lib
文件,找不到文件,所以就报错了。
解决办法有两种。
方法1
一种是传统的方式,修改源代码,使用__declspec(dllexport)
和__declspec(dllimport)
,但由于修改了源代码(专门为生成动态库修改了源代码),使用这种方式,就不能方便地在生成动态库和生成静态库之间切换了。
编辑文件MathFunctions\MathFunctions.h
,改为:
若当前平台为Windows(定义了宏_WIN32
),并且正在生成DLL文件(根据宏EXPORTING_MYMATH
来区分),就将宏DECLSPEC
定义为__declspec(dllexport)
;若当前平台为Windows并且是在调用该DLL文件,就将宏DECLSPEC
定义为__declspec(dllimport)
。如果非Windows平台,则宏DECLSPEC
定义为空。在函数声明中使用宏DECLSPEC
,指示导出(被导出到动态链接库中)还是导入(从动态链接库中导入)该函数,也就是区分是正在生成动态库,还是在调用动态库。
接下来编辑文件MathFunctions\CMakeLists.txt
,在编译时定义宏EXPORTING_MYMATH
:
此时文件 /CMakeLists.txt
的完整内容如下:
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下:
文件 /CMakeLists.txt
未变。
构建项目,一切正常。
方法2
另一种方式是使用变量CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS
。编辑文件/CMakeLists.txt
,设置该变量为ON
:
这个选项CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS
,用于设定目标(可执行程序或库)的属性WINDOWS_EXPORT_ALL_SYMBOLS
的默认值。关于属性WINDOWS_EXPORT_ALL_SYMBOLS
更深入的信息可看这里。
此时文件 /CMakeLists.txt
的完整内容如下:
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下:
构建项目,一切正常。
添加导出配置(Adding Export Configuration)
旧的调用库的方法
前面,我们在“安装和测试”这一小节中,安装了从源代码构建的二进制文件;在“打包安装程序”这一小节,我们把程序打包为二进制文件分发给其他人使用。在别的项目中怎么使用呢。
构建、打包项目的过程如下:
将项目二进制文件安装到(解压到)如下位置:C:\Users\wenidc\Desktop\sqrt-1.0-win64\sqrt-1.0-win64
。文件列表如下:
(此时运行bin
目录里的sqrt.exe
将失败,因为没有把动态库MathFunctions
相关的文件放到bin
目录下面,sqrt.exe
找不到动态库。后面会修改相关配置,但在此处我们只是测试外部项目调用库MathFunctions
,不影响这里的演示。)
有另一个项目项目test1
,源文件test1/main.cpp
内容如下:
我们分别看看直接用命令行(g++
)编译时,以及用CMake管理项目时,应该怎样调用库MathFunctions
。
命令行编译
选项-I
指定头文件路径,-L
指定动态库文件路径,-l
指定需要链接的动态库名称。
然后需要把sqrt-1.0-win64/sqrt-1.0-win64/lib
里面的库文件放到与生成的main.exe
相同目录下,这样才能成功运行main.exe
。(动态库必须和可执行文件放到同一个目录吗?可以问问GPT)
CMake构建
test1/CMakeLists.txt
构建过程如下:
将生成可执行文件test1.exe
。但这时test1.exe
还运行不了,还需要将sqrt-1.0-win64/sqrt-1.0-win64/lib
里面的库文件放到与生成的test1.exe
相同的目录下(也就是build
目录中)。
让调用库更方便
从上面可以看到,我们使用库MathFunctions
的方式与使用其他普通的、未使用 CMake 构建的动态库没有什么区别。下面为项目sqrt
添加必要的信息,其他 CMake 项目只需使用命令find_package()
就可以很方便地使用库MathFunctions
。
首先编辑MathFunctions/CMakeLists.txt
,修改install()
命令:
新增的EXPORT
关键字,用于生成一个 CMake 脚本文件,这个脚本文件描述了(其他项目)怎样从项目安装后的目录中导入目标(列在${installable_libs}
里的这些目标)。但注意,EXPORT
关键字本身不会直接生成文件,而是将指定的目标(列在${installable_libs}
里的这些目标)导出到一个命名的导出集中(名称为MathFunctionsTargets
)。若要生成 CMake 文件,需要使用命令install(EXPORT ...)
。
编辑文件/CMakeLists.txt
,在底部添加:
上面的命令,用于根据MathFunctionsTargets
生成文件MathFunctionsTargets.cmake
。
构建项目,将遇到以下报错:
报错原因是在MathFunctions/CMakeLists.txt
中有这一句命令:
这句命令的含义是,所有链接到 MathFunctions
的目标(targets),都需要包含文件夹${CMAKE_CURRENT_SOURCE_DIR}
里的头文件。但是${CMAKE_CURRENT_SOURCE_DIR}
是一个绝对路径,如果项目打包后安装在其他电脑上,还去这个路径去找头文件,肯定会出错。CMake 察觉到了这个问题,所以抛出了一个错误。
如何解决?思路是,在构建时,依然从${CMAKE_CURRENT_SOURCE_DIR}
中寻找头文件;在安装后,从安装目录中的include
文件夹中寻找头文件。
编辑文件MathFunctions/CMakeLists.txt
,进行如下修改:
关于生成器表达式$<BUILD_INTERFACE:...>
和$<INSTALL_INTERFACE:...>
,本文前面有介绍。
修改后,构建项目、安装项目:
在test_install\lib\cmake\MathFunctions
中可以看到成功生成的文件MathFunctionsTargets.cmake
。(构建目录build
里面是没有这个文件的)
想让其他项目的find_package()
命令能够找到本项目中的库MathFunctions
,需要在项目安装目录的lib\cmake\MathFunctions
目录下(和文件MathFunctionsTargets.cmake
同目录)放一个文件MathFunctionsConfig.cmake
,这个文件使用”模板”生成:
在项目根目录下创建新文件Config.cmake.in
:
变量@PACKAGE_INIT@
在最终生成的文件中将被替换为“获取项目安装目录根目录的绝对路径”的代码。一份生成的文件MathFunctionsConfig.cmake
示例如下:
变量CMAKE_CURRENT_LIST_DIR
指当前文件MathFunctionsConfig.cmake
所在的文件夹(项目安装目录/lib\cmake\MathFunctions
),语句include("${CMAKE_CURRENT_LIST_DIR}/MathFunctionsTargets.cmake")
指将相同文件夹下的文件MathFunctionsTargets.cmake
中的代码包含进来。
修改文件CMakeLists.txt
,在末尾添加如下代码:
命令configure_package_config_file()
获取项目源码根目录下的文件Config.cmake.in
,将其作为模板生成文件MathFunctionsConfig.cmake
放在构建的二进制目录${CMAKE_CURRENT_BINARY_DIR}
里,并指明在安装项目时将该文件安装在安装目录下的文件夹lib/cmake/MathFunctions
中。NO_SET_AND_CHECK_MACRO
和NO_CHECK_REQUIRED_COMPONENTS_MACRO
两个选项用于控制在生成的配置文件中是否包含特定的 CMake 宏。
构建项目、安装项目:
一切正常。
可选地,还可以在文件MathFunctionsConfig.cmake
同目录下包含一个文件MathFunctionsConfigVersion.cmake
,用于指出项目的版本号以及兼容性(如果当前项目的版本号是3.2,而另一个项目在链接当前项目时指明需要版本2.8,CMake就会提示版本不兼容)。
修改文件/CMakeLists.txt
,在末尾添加如下代码:
上述命令指出,要在项目构建二进制目录${CMAKE_CURRENT_BINARY_DIR}
生成一个文件MathFunctionsConfigVersion.cmake
;VERSION
指出项目版本号信息,变量sqrt_VERSION_MAJOR
和sqrt_VERSION_MINOR
在前面有介绍过;COMPATIBILITY AnyNewerVersion
指出当前项目版本兼容于任何比它更新的版本。以及,在安装项目时将该文件安装在安装目录下的文件夹lib/cmake/MathFunctions
中。
现在,构建、安装项目,在其他 CMake 项目中就可以使用命令find_package()
方便地导入库MathFunctions
了。
以前面出现过的项目test1
为例,之前的文件test1/CMakeLists.txt
是这样的:
现在只需这样写:
注意,变量MathFunctions_DIR
不是随意命名的,必须用这个名字,变量MathFunctions_DIR
的值必须是MathFunctionsConfig.cmake
所在文件夹的路径,而不是项目安装目录的根目录。关于此,官方有介绍(摘录自 不加这句set(MathFunctions_DIR ...)
构建项目时的报错信息):
虽说现在项目sqrt
的库MathFunctions
可以被其他项目方便地调用了,但我仍然感觉有那么一点不方便。在开发时,我如果修改了项目的代码,其他库如果想使用本项目最新的代码,我除了要构建本项目之外,还要安装本项目。我能不能让其他库直接从本项目的构建目录导入库?构建目录下现在有文件MathFunctionsConfig.cmake
和MathFunctionsConfigVersion.cmake
,也就是还缺一个MathFunctionsTargets.cmake
。
编辑文件/CMakeLists.txt
,在末尾添加代码:
这样就将导出集(Export Set)MathFunctionsTargets
导出为文件${CMAKE_CURRENT_BINARY_DIR}/MathFunctionsTargets.cmake
。
构建项目:
现在文件齐全了。试一试其他项目能不能直接从sqrt
的构建目录导入。依然以项目test1
为例,更改文件test1/CMakeLists.txt
中变量MathFunctions_DIR
的值为项目sqrt
的构建目录:
一切正常。
此时文件 /CMakeLists.txt
的完整内容如下:
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下:
打包Debug版本和Release版本
这一小节不适用于多配置生成器(如MSVC)。我使用的是 MinGW。
我想要同时构建本项目的两个版本,一个Debug版本,一个Release版本,把它们放在同一个目录下。为以示区分,在Debug版本的目标(target)名称后加一个字符“d”。
编辑文件/CMakeLists.txt
,在开头处添加:
构建项目,指定构建类型为Debug:
可以注意到,生成的库目标文件名称末尾都带一个“d”。但是可执行文件后不带“d”。
编辑文件/CMakeLists.txt
,为可执行目标sqrt
设置属性DEBUG_POSTFIX
:
重新构建项目,注意到这次生成的可执行文件后也带“d”了,变成了sqrtd.exe
。
接下来给库MathFunctions
设置版本号。前面通过命令write_basic_package_version_file
设置过一次版本号,那个版本号是指“包(Package)的版本”,而这里的版本号是“具体的目标(库MathFunctions
)的版本”。
编辑文件MathFunctions/CMakeLists.txt
,为MathFunctions
设置属性VERSION
和SOVERSION
:
VERSION
和SOVERSION
属性分别指“共享库目标的版本号”和“共享库目标的 ABI(Application Binary Interface) 版本号。”当你对库进行重大更改且不兼容旧版本时,你应该增加 SOVERSION
,以便系统可以区分新版本和旧版本,并在需要时自动使用正确的版本。
在项目根目录创建子目录debug
和release
,使用选项CMAKE_BUILD_TYPE
在这两个目录中分别构建 Debug 版和 Release 版:
两个版本都已构建完毕,接下来将它们打包到同一个发行版中。在项目根目录创建文件MultiCPackConfig.cmake
,写入如下内容:
变量 CPACK_INSTALL_CMAKE_PROJECTS
的值中用分号分隔的4个值分表代表:构建目录(Build Directory),项目名称(Project Name),项目组件(Project Component),相对于安装目录根目录的路径(Directory)。
使用 CPack 及上述自定义配置打包项目:
生成的项目二进制文件都是双份的,一份是 Debug 版,一份是 Release 版。
此时文件 /CMakeLists.txt
的完整内容如下:
此时文件 MathFunctions/CMakeLists.txt
的完整内容如下: