CMake教程

从可执行文件到库

本章的主要内容有:

  • 将单个源码文件编译为可执行文件

  • 切换生成器

  • 构建和连接静态库与动态库

  • 用条件语句控制编译

  • 向用户显示选项

  • 指定编译器

  • 切换构建类型

  • 设置编译器选项

  • 为语言设定标准

  • 使用控制流进行构造

本章的示例将指导您完成构建代码所需的基本任务:编译可执行文件、编译库、根据用户输入执行构建操作等等。CMake是一个构建系统生成器,特别适合于独立平台和编译器。除非另有说明,否则所有配置都独立于操作系统,它们可以在GNU/Linux、macOS和Windows的系统下运行。

本书的示例主要为C++项目设计,并使用C++示例进行了演示,但CMake也可以用于其他语言的项目,包括C和Fortran。我们会尝试一些有意思的配置,其中包含了一些C++、C和Fortran语言示例。您可以根据自己喜好,选择性了解。有些示例是定制的,以突出在选择特定语言时需要面临的挑战。

将单个源文件编译为可执行文件

本节示例中,我们将演示如何运行CMake配置和构建一个简单的项目。该项目由单个源文件组成,用于生成可执行文件。我们将用C++讨论这个项目,您在GitHub示例库中可以找到C和Fortran的例子。

准备工作

我们希望将以下源代码编译为单个可执行文件:

1
2
3
4
5
6
7
8
#include <cstdlib>
#include <iostream>
#include <string>
std::string say_hello() { return std::string("Hello, CMake world!"); }
int main() {
std::cout << say_hello() << std::endl;
return EXIT_SUCCESS;
}

具体实施

我们把CMake指令放入一个名为CMakeLists.txt的文件中。文件的名称区分大小写,必须命名为CMakeLists.txt,CMake才能够解析。

用编辑器打开一个文本文件,将这个文件命名为CMakeLists.txt。

第一行,设置CMake所需的最低版本。如果使用的CMake版本低于该版本,则会发出致命错误:

1
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

第二行,声明了项目的名称(recipe-01)和支持的编程语言(CXX代表C++):

1
project(recipe-01 LANGUAGES CXX)

指示CMake创建一个新目标:可执行文件hello-world。这个可执行文件是通过编译和链接源文件hello-world.cpp生成的。CMake将为编译器使用默认设置,并自动选择生成工具:

1
add_executable(hello-world hello-world.cpp)

将该文件与源文件hello-world.cpp放在相同的目录中。记住,它只能被命名为CMakeLists.txt。

现在,可以通过创建build目录,在build目录下来配置项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ mkdir -p build
$ cd build
$ cmake ..
-- The CXX compiler identification is GNU 8.1.0
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-01/recipe-01/cxx-example/build

如果一切顺利,项目的配置已经在build目录中生成。我们现在可以编译可执行文件:

1
2
3
4
5
$ cmake --build .
Scanning dependencies of target hello-world
[ 50%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.o
[100%] Linking CXX executable hello-world
[100%] Built target hello-world

工作原理

示例中,我们使用了一个简单的CMakeLists.txt来构建“Hello world”可执行文件:

1
2
3
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES CXX)
add_executable(hello-world hello-world.cpp)

NOTE:CMake语言不区分大小写,但是参数区分大小写。

CMake中,C++是默认的编程语言。不过,我们还是建议使用LANGUAGES选项在project命令中显式地声明项目的语言。

要配置项目并生成构建器,我们必须通过命令行界面(CLI)运行CMake。CMake CLI提供了许多选项,cmake -help将输出以显示列出所有可用选项的完整帮助信息,我们将在书中对这些选项进行更多地了解。正如您将从cmake -help的输出中显示的内容,它们中的大多数选项会让你您访问CMake手册,查看详细信息。通过下列命令生成构建器:

1
2
3
$ mkdir -p build
$ cd build
$ cmake ..

这里,我们创建了一个目录build(生成构建器的位置),进入build目录,并通过指定CMakeLists.txt的位置(本例中位于父目录中)来调用CMake。可以使用以下命令行来实现相同的效果:

1
$ cmake -H. -Bbuild

该命令是跨平台的,使用了-H和-B为CLI选项。-H表示当前目录中搜索根CMakeLists.txt文件。-Bbuild告诉CMake在一个名为build的目录中生成所有的文件。

运行cmake命令会输出一系列状态消息,显示配置信息:

1
2
3
4
5
6
7
8
9
10
11
$ cmake ..
-- The CXX compiler identification is GNU 8.1.0
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-01/recipe-01/cxx-example/build

NOTE:在与CMakeLists.txt相同的目录中执行cmake .,原则上足以配置一个项目。然而,CMake会将所有生成的文件写到项目的根目录中。这将是一个源代码内构建,通常是不推荐的,因为这会混合源代码和项目的目录树。我们首选的是源外构建。

CMake是一个构建系统生成器。将描述构建系统(如:Unix Makefile、Ninja、Visual Studio等)应当如何操作才能编译代码。然后,CMake为所选的构建系统生成相应的指令。默认情况下,在GNU/Linux和macOS系统上,CMake使用Unix Makefile生成器。Windows上,Visual Studio是默认的生成器。

GNU/Linux上,CMake默认生成Unix Makefile来构建项目:

  • Makefile: make将运行指令来构建项目。
  • CMakefile:包含临时文件的目录,CMake用于检测操作系统、编译器等。此外,根据所选的生成器,它还包含特定的文件。
  • cmake_install.cmake:处理安装规则的CMake脚本,在项目安装时使用。
  • CMakeCache.txt:如文件名所示,CMake缓存。CMake在重新运行配置时使用这个文件。

要构建示例项目,我们运行以下命令:

1
$ cmake --build .

最后,CMake不强制指定构建目录执行名称或位置,我们完全可以把它放在项目路径之外。这样做同样有效:

1
2
3
4
$ mkdir -p /tmp/someplace
$ cd /tmp/someplace
$ cmake /path/to/source
$ cmake --build .

由CMake生成的构建系统,即上面给出的示例中的Makefile,将包含为给定项目构建目标文件、可执行文件和库的目标及规则。hello-world可执行文件是在当前示例中的唯一目标,运行以下命令:

1
2
3
4
5
6
7
8
9
10
11
$ cmake --build . --target help
The following are some of the valid targets for this Makefile:
... all (the default if no target is provided)
... clean
... depend
... rebuild_cache
... hello-world
... edit_cache
... hello-world.o
... hello-world.i
... hello-world.s

CMake生成的目标比构建可执行文件的目标要多。可以使用cmake --build . --target <target-name>语法,实现如下功能:

  • all(或Visual Studio generator中的ALL_BUILD)是默认目标,将在项目中构建所有目标。
  • clean,删除所有生成的文件。
  • rebuild_cache,将调用CMake为源文件生成依赖(如果有的话)。
  • edit_cache,这个目标允许直接编辑缓存。

对于更复杂的项目,通过测试阶段和安装规则,CMake将生成额外的目标:

  • test(或Visual Studio generator中的RUN_TESTS)将在CTest的帮助下运行测试套件。我们将在第4章中详细讨论测试和CTest。
  • install,将执行项目安装规则。我们将在第10章中讨论安装规则。
  • package,此目标将调用CPack为项目生成可分发的包。打包和CPack将在第11章中讨论。

切换生成器

CMake是一个构建系统生成器,可以使用单个CMakeLists.txt为不同平台上的不同工具集配置项目。您可以在CMakeLists.txt中描述构建系统必须运行的操作,以配置并编译代码。基于这些指令,CMake将为所选的构建系统(Unix Makefile、Ninja、Visual Studio等等)生成相应的指令。

准备工作

CMake针对不同平台支持本地构建工具列表。同时支持命令行工具(如Unix Makefile和Ninja)和集成开发环境(IDE)工具。用以下命令,可在平台上找到生成器名单,以及已安装的CMake版本:

1
$ cmake --help

这个命令的输出,将列出CMake命令行界面上所有的选项,您会找到可用生成器的列表。例如,安装了CMake 3.11.2的GNU/Linux机器上的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Generators
The following generators are available on this platform:
Unix Makefiles = Generates standard UNIX makefiles.
Ninja = Generates build.ninja files.
Watcom WMake = Generates Watcom WMake makefiles.
CodeBlocks - Ninja = Generates CodeBlocks project files.
CodeBlocks - Unix Makefiles = Generates CodeBlocks project files.
CodeLite - Ninja = Generates CodeLite project files.
CodeLite - Unix Makefiles = Generates CodeLite project files.
Sublime Text 2 - Ninja = Generates Sublime Text 2 project files.
Sublime Text 2 - Unix Makefiles = Generates Sublime Text 2 project files.
Kate - Ninja = Generates Kate project files.
Kate - Unix Makefiles = Generates Kate project files.
Eclipse CDT4 - Ninja = Generates Eclipse CDT 4.0 project files.
Eclipse CDT4 - Unix Makefiles= Generates Eclipse CDT 4.0 project files.

使用此示例,我们将展示为项目切换生成器是多么EASY。

具体实施

我们将重用前一节示例中的hello-world.cpp和CMakeLists.txt。惟一的区别在使用CMake时,因为现在必须显式地使用命令行方式,用-G切换生成器。

首先,使用以下步骤配置项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ mkdir -p build
$ cd build
$ cmake -G Ninja ..
-- The CXX compiler identification is GNU 8.1.0
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-01/recipe-02/cxx-exampl

第二步,构建项目:

1
2
$ cmake --build .
[2/2] Linking CXX executable hello-world

如何工作

与前一个配置相比,每一步的输出没什么变化。每个生成器都有自己的文件集,所以编译步骤的输出和构建目录的内容是不同的:

1
2
3
4
5
build.ninja和rules.ninja:包含Ninja的所有的构建语句和构建规则。
CMakeCache.txt:CMake会在这个文件中进行缓存,与生成器无关。
CMakeFiles:包含由CMake在配置期间生成的临时文件。
cmake_install.cmake:CMake脚本处理安装规则,并在安装时使用。
cmake --build .将ninja命令封装在一个跨平台的接口中。

构建和链接静态库和动态库

项目中会有单个源文件构建的多个可执行文件的可能。项目中有多个源文件,通常分布在不同子目录中。这种实践有助于项目的源代码结构,而且支持模块化、代码重用和关注点分离。同时,这种分离可以简化并加速项目的重新编译。本示例中,我们将展示如何将源代码编译到库中,以及如何链接这些库。

准备工作

回看第一个例子,这里并不再为可执行文件提供单个源文件,我们现在将引入一个类,用来包装要打印到屏幕上的消息。更新一下的hello-world.cpp:

1
2
3
4
5
6
7
8
9
10
#include "Message.hpp"
#include <cstdlib>
#include <iostream>
int main() {
Message say_hello("Hello, CMake World!");
std::cout << say_hello << std::endl;
Message say_goodbye("Goodbye, CMake World");
std::cout << say_goodbye << std::endl;
return EXIT_SUCCESS;
}

Message类包装了一个字符串,并提供重载过的<<操作,并且包括两个源码文件:Message.hpp头文件与Message.cpp源文件。Message.hpp中的接口包含以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
#pragma once
#include <iosfwd>
#include <string>
class Message {
public:
Message(const std::string &m) : message_(m) {}
friend std::ostream &operator<<(std::ostream &os, Message &obj) {
return obj.printObject(os);
}
private:
std::string message_;
std::ostream &printObject(std::ostream &os);
};

Message.cpp实现如下:

1
2
3
4
5
6
7
8
#include "Message.hpp"
#include <iostream>
#include <string>
std::ostream &Message::printObject(std::ostream &os) {
os << "This is my very nice message: " << std::endl;
os << message_;
return os;
}

具体实施

这里有两个文件需要编译,所以CMakeLists.txt必须进行修改。本例中,先把它们编译成一个库,而不是直接编译成可执行文件:

创建目标——静态库。库的名称和源码文件名相同,具体代码如下:

1
2
3
4
5
add_library(message
STATIC
Message.hpp
Message.cpp
)

创建hello-world可执行文件的目标部分不需要修改:

1
add_executable(hello-world hello-world.cpp)

最后,将目标库链接到可执行目标:

1
target_link_libraries(hello-world message)

对项目进行配置和构建。库编译完成后,将连接到hello-world可执行文件中:

1
2
3
4
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

1
2
3
4
5
6
7
8
Scanning dependencies of target message
[ 25%] Building CXX object CMakeFiles/message.dir/Message.cpp.o
[ 50%] Linking CXX static library libmessage.a
[ 50%] Built target message
Scanning dependencies of target hello-world
[ 75%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.o
[100%] Linking CXX executable hello-world
[100%] Built target hello-world
1
2
3
4
5
$ ./hello-world
This is my very nice message:
Hello, CMake World!
This is my very nice message:
Goodbye, CMake World

工作原理

本节引入了两个新命令:

add_library(message STATIC Message.hpp Message.cpp):生成必要的构建指令,将指定的源码编译到库中。add_library的第一个参数是目标名。整个CMakeLists.txt中,可使用相同的名称来引用库。生成的库的实际名称将由CMake通过在前面添加前缀lib和适当的扩展名作为后缀来形成。生成库是根据第二个参数(STATIC或SHARED)和操作系统确定的。

target_link_libraries(hello-world message): 将库链接到可执行文件。此命令还确保hello-world可执行文件可以正确地依赖于消息库。因此,在消息库链接到hello-world可执行文件之前,需要完成消息库的构建。

编译成功后,构建目录包含libmessage.a一个静态库(在GNU/Linux上)和hello-world可执行文件。

CMake接受其他值作为add_library的第二个参数的有效值,我们来看下本书会用到的值:

  • STATIC:用于创建静态库,即编译文件的打包存档,以便在链接其他目标时使用,例如:可执行文件。
  • SHARED:用于创建动态库,即可以动态链接,并在运行时加载的库。可以在CMakeLists.txt中使用add_library(message SHARED Message.hpp Message.cpp)从静态库切换到动态共享对象(DSO)。
  • OBJECT:可将给定add_library的列表中的源码编译到目标文件,不将它们归档到静态库中,也不能将它们链接到共享对象中。如果需要一次性创建静态库和动态库,那么使用对象库尤其有用。我们将在本示例中演示。
  • MODULE:又为DSO组。与SHARED库不同,它们不链接到项目中的任何目标,不过可以进行动态加载。该参数可以用于构建运行时插件。

CMake还能够生成特殊类型的库,这不会在构建系统中产生输出,但是对于组织目标之间的依赖关系,和构建需求非常有用:

  • IMPORTED:此类库目标表示位于项目外部的库。此类库的主要用途是,对现有依赖项进行构建。因此,IMPORTED库将被视为不可变的。
  • INTERFACE:与IMPORTED库类似。不过,该类型库可变,没有位置信息。它主要用于项目之外的目标构建使用。
  • ALIAS:顾名思义,这种库为项目中已存在的库目标定义别名。不过,不能为IMPORTED库选择别名。

本例中,我们使用add_library直接集合了源代码。后面的章节中,我们将使用target_sources汇集源码,特别是在第7章。

更多信息

现在展示OBJECT库的使用,修改CMakeLists.txt,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-03 LANGUAGES CXX)
add_library(message-objs
OBJECT
Message.hpp
Message.cpp
)
# this is only needed for older compilers
# but doesn't hurt either to have it
set_target_properties(message-objs
PROPERTIES
POSITION_INDEPENDENT_CODE 1
)
add_library(message-shared
SHARED
$<TARGET_OBJECTS:message-objs>
)
add_library(message-static
STATIC
$<TARGET_OBJECTS:message-objs>
)
add_executable(hello-world hello-world.cpp)
target_link_libraries(hello-world message-static)

首先,add_library改为add_library(Message-objs OBJECT Message.hpp Message.cpp)。此外,需要保证编译的目标文件与生成位置无关。可以通过使用set_target_properties命令,设置message-objs目标的相应属性来实现。

可能在某些平台和/或使用较老的编译器上,需要显式地为目标设置POSITION_INDEPENDENT_CODE属性。

现在,可以使用这个对象库来获取静态库(message-static)和动态库(message-shared)。要注意引用对象库的生成器表达式语法:$<TARGET_OBJECTS:message-objs>。生成器表达式是CMake在生成时(即配置之后)构造,用于生成特定于配置的构建输出。

是否可以让CMake生成同名的两个库?换句话说,它们都可以被称为message,而不是message-static和message-shared吗?我们需要修改这两个目标的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
add_library(message-shared
SHARED
$<TARGET_OBJECTS:message-objs>
)
set_target_properties(message-shared
PROPERTIES
OUTPUT_NAME "message"
)
add_library(message-static
STATIC
$<TARGET_OBJECTS:message-objs>
)
set_target_properties(message-static
PROPERTIES
OUTPUT_NAME "message"
)

我们可以链接到DSO吗?这取决于操作系统和编译器:

  • GNU/Linux和macOS上,不管选择什么编译器,它都可以工作。
  • Windows上,不能与Visual Studio兼容,但可以与MinGW和MSYS2兼容。

用条件句控制编译

目前为止,看到的示例比较简单,CMake执行流是线性的:从一组源文件到单个可执行文件,也可以生成静态库或动态库。为了确保完全控制构建项目、配置、编译和链接所涉及的所有步骤的执行流,CMake提供了自己的语言。本节中,我们将探索条件结构if-else- else-endif的使用。

具体实施

从与上一个示例的的源代码开始,我们希望能够在不同的两种行为之间进行切换:

  • 将Message.hpp和Message.cpp构建成一个库(静态或动态),然后将生成库链接到hello-world可执行文件中。
  • 将Message.hpp,Message.cpp和hello-world.cpp构建成一个可执行文件,但不生成任何一个库。
    让我们来看看如何使用CMakeLists.txt来实现:

首先,定义最低CMake版本、项目名称和支持的语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-04 LANGUAGES CXX)

我们引入了一个新变量USE_LIBRARY,这是一个逻辑变量,值为OFF。我们还打印了它的值:

1
2
set(USE_LIBRARY OFF)
message(STATUS "Compile sources into a library? ${USE_LIBRARY}")

CMake中定义BUILD_SHARED_LIBS全局变量,并设置为OFF。调用add_library并省略第二个参数,将构建一个静态库:

1
set(BUILD_SHARED_LIBS OFF)

然后,引入一个变量_sources,包括Message.hpp和Message.cpp:

1
list(APPEND _sources Message.hpp Message.cpp)

然后,引入一个基于USE_LIBRARY值的if-else语句。如果逻辑为真,则Message.hpp和Message.cpp将打包成一个库:

1
2
3
4
5
6
7
8
9
if(USE_LIBRARY)
# add_library will create a static library
# since BUILD_SHARED_LIBS is OFF
add_library(message ${_sources})
add_executable(hello-world hello-world.cpp)
target_link_libraries(hello-world message)
else()
add_executable(hello-world hello-world.cpp ${_sources})
endif()

我们可以再次使用相同的命令集进行构建。由于USE_LIBRARY为OFF, hello-world可执行文件将使用所有源文件来编译。可以通过在GNU/Linux上,运行objdump -x命令进行验证。

工作原理

我们介绍了两个变量:USE_LIBRARY和BUILD_SHARED_LIBS。这两个变量都设置为OFF。如CMake语言文档中描述,逻辑真或假可以用多种方式表示:

  • 如果将逻辑变量设置为以下任意一种:1、ON、YES、true、Y或非零数,则逻辑变量为true。
  • 如果将逻辑变量设置为以下任意一种:0、OFF、NO、false、N、IGNORE、NOTFOUND、空字符串,或者以-NOTFOUND为后缀,则逻辑变量为false。

USE_LIBRARY变量将在第一个和第二个行为之间切换。BUILD_SHARED_LIBS是CMake的一个全局标志。因为CMake内部要查询BUILD_SHARED_LIBS全局变量,所以add_library命令可以在不传递STATIC/SHARED/OBJECT参数的情况下调用;如果为false或未定义,将生成一个静态库。

这个例子说明,可以引入条件来控制CMake中的执行流。但是,当前的设置不允许从外部切换,不需要手动修改CMakeLists.txt。原则上,我们希望能够向用户开放所有设置,这样就可以在不修改构建代码的情况下调整配置,稍后将展示如何做到这一点。

else()和endif()中的(),可能会让刚开始学习CMake代码的同学感到惊讶。其历史原因是,因为其能够指出指令的作用范围。例如,可以使用if(USE_LIBRARY)…else(USE_LIBRARY)…endif(USE_LIBIRAY)。这个格式并不唯一,可以根据个人喜好来决定使用哪种格式。

TIPS:_sources变量是一个局部变量,不应该在当前范围之外使用,可以在名称前加下划线。

向用户显示选项

前面的配置中,我们引入了条件句:通过硬编码的方式给定逻辑变量值。不过,这会影响用户修改这些变量。CMake代码没有向读者传达,该值可以从外部进行修改。推荐在CMakeLists.txt中使用option()命令,以选项的形式显示逻辑开关,用于外部设置,从而切换构建系统的生成行为。本节的示例将向您展示,如何使用这个命令。

具体实施

看一下前面示例中的静态/动态库示例。与其硬编码USE_LIBRARY为ON或OFF,现在为其设置一个默认值,同时也可以从外部进行更改:

用一个选项替换上一个示例的set(USE_LIBRARY OFF)命令。该选项将修改USE_LIBRARY的值,并设置其默认值为OFF:

1
option(USE_LIBRARY "Compile sources into a library" OFF)

现在,可以通过CMake的-DCLI选项,将信息传递给CMake来切换库的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ mkdir -p build
$ cd build
$ cmake -D USE_LIBRARY=ON ..
-- ...
-- Compile sources into a library? ON
-- ...
$ cmake --build .
Scanning dependencies of target message
[ 25%] Building CXX object CMakeFiles/message.dir/Message.cpp.o
[ 50%] Linking CXX static library libmessage.a
[ 50%] Built target message
Scanning dependencies of target hello-world
[ 75%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.o
[100%] Linking CXX executable hello-world
[100%] Built target hello-world

-D开关用于为CMake设置任何类型的变量:逻辑变量、路径等等。

工作原理

1
option(<option_variable> "help string" [initial value])

option可接受三个参数:

  • <option_variable>表示该选项的变量的名称。
  • “help string”记录选项的字符串,在CMake的终端或图形用户界面中可见。
  • [initial value]选项的默认值,可以是ON或OFF。

更多信息

有时选项之间会有依赖的情况。示例中,我们提供生成静态库或动态库的选项。但是,如果没有将USE_LIBRARY逻辑设置为ON,则此选项没有任何意义。CMake提供cmake_dependent_option()命令用来定义依赖于其他选项的选项:

1
2
3
4
5
6
7
8
9
10
11
include(CMakeDependentOption)
# second option depends on the value of the first
cmake_dependent_option(
MAKE_STATIC_LIBRARY "Compile sources into a static library" OFF
"USE_LIBRARY" ON
)
# third option depends on the value of the first
cmake_dependent_option(
MAKE_SHARED_LIBRARY "Compile sources into a shared library" ON
"USE_LIBRARY" ON
)

如果USE_LIBRARY为ON,MAKE_STATIC_LIBRARY默认值为OFF,否则MAKE_SHARED_LIBRARY默认值为ON。可以这样运行:

1
$ cmake -D USE_LIBRARY=OFF -D MAKE_SHARED_LIBRARY=ON ..

这仍然不会构建库,因为USE_LIBRARY仍然为OFF。

CMake有适当的机制,通过包含模块来扩展其语法和功能,这些模块要么是CMake自带的,要么是定制的。本例中,包含了一个名为CMakeDependentOption的模块。如果没有include这个模块,cmake_dependent_option()命令将不可用。

手册中的任何模块都可以以命令行的方式使用cmake --help-module <name-of-module>。例如,cmake --help-module CMakeDependentOption将打印刚才讨论的模块的手册页(帮助页面)。

指定编译器

目前为止,我们还没有过多考虑如何选择编译器。CMake可以根据平台和生成器选择编译器,还能将编译器标志设置为默认值。然而,我们通常控制编译器的选择。在后面的示例中,我们还将考虑构建类型的选择,并展示如何控制编译器标志。

具体实施

如何选择一个特定的编译器?例如,如果想使用Intel或Portland Group编译器怎么办?CMake将语言的编译器存储在CMAKE_<LANG>_COMPILER变量中,其中<LANG>是受支持的任何一种语言,对于我们的目的是CXX、C或Fortran。用户可以通过以下两种方式之一设置此变量:

使用CLI中的-D选项,例如:

1
$ cmake -D CMAKE_CXX_COMPILER=clang++ ..

通过导出环境变量CXX(C++编译器)、CC(C编译器)和FC(Fortran编译器)。例如,使用这个命令使用clang++作为C++编译器:

1
$ env CXX=clang++ cmake ..

到目前为止讨论的示例,都可以通过传递适当的选项,配置合适的编译器。

CMake了解运行环境,可以通过其CLI的-D开关或环境变量设置许多选项。前一种机制覆盖后一种机制,但是我们建议使用-D显式设置选项。显式优于隐式,因为环境变量可能被设置为不适合(当前项目)的值。

我们在这里假设,其他编译器在标准路径中可用,CMake在标准路径中执行查找编译器。如果不是这样,用户将需要将完整的编译器可执行文件或包装器路径传递给CMake。

我们建议使用-D CMAKE_<LANG>_COMPILERCLI选项设置编译器,而不是导出CXX、CC和FC。这是确保跨平台并与非POSIX兼容的唯一方法。为了避免变量污染环境,这些变量可能会影响与项目一起构建的外部库环境。

工作原理

配置时,CMake会进行一系列平台测试,以确定哪些编译器可用,以及它们是否适合当前的项目。一个合适的编译器不仅取决于我们所使用的平台,还取决于我们想要使用的生成器。CMake执行的第一个测试基于项目语言的编译器的名称。例如,cc是一个工作的C编译器,那么它将用作C项目的默认编译器。GNU/Linux上,使用Unix Makefile或Ninja时, GCC家族中的编译器很可能是C++、C和Fortran的默认选择。Microsoft Windows上,将选择Visual Studio中的C++和C编译器(前提是Visual Studio是生成器)。如果选择MinGW或MSYS Makefile作为生成器,则默认使用MinGW编译器。

更多信息

我们的平台上的CMake,在哪里可以找到可用的编译器和编译器标志?CMake提供—system-information标志,它将把关于系统的所有信息转储到屏幕或文件中。要查看这个信息,请尝试以下操作:

1
$ cmake --system-information information.txt

文件中(本例中是information.txt)可以看到CMAKE_CXX_COMPILER、CMAKE_C_COMPILER和CMAKE_Fortran_COMPILER的默认值,以及默认标志。我们将在下一个示例中看到相关的标志。

CMake提供了额外的变量来与编译器交互:

  • CMAKE_<LANG>_COMPILER_LOADED:如果为项目启用了语言<LANG>,则将设置为TRUE。
  • CMAKE_<LANG>_COMPILER_ID:编译器标识字符串,编译器供应商所特有。例如,GCC用于GNU编译器集合,AppleClang用于macOS上的Clang, MSVC用于Microsoft Visual Studio编译器。注意,不能保证为所有编译器或语言定义此变量。
  • CMAKE_COMPILER_IS_GNU<LANG>:如果语言<LANG>是GNU编译器集合的一部分,则将此逻辑变量设置为TRUE。注意变量名的<LANG>部分遵循GNU约定:C语言为CC, C++语言为CXX, Fortran语言为G77。
  • CMAKE_<LANG>_COMPILER_VERSION:此变量包含一个字符串,该字符串给定语言的编译器版本。版本信息在major[.minor[.patch[.tweak]]]中给出。但是,对于CMAKE_<LANG>_COMPILER_ID,不能保证所有编译器或语言都定义了此变量。

我们可以尝试使用不同的编译器,配置下面的示例CMakeLists.txt。这个例子中,我们将使用CMake变量来探索已使用的编译器(及版本):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-06 LANGUAGES C CXX)
message(STATUS "Is the C++ compiler loaded? ${CMAKE_CXX_COMPILER_LOADED}")
if(CMAKE_CXX_COMPILER_LOADED)
message(STATUS "The C++ compiler ID is: ${CMAKE_CXX_COMPILER_ID}")
message(STATUS "Is the C++ from GNU? ${CMAKE_COMPILER_IS_GNUCXX}")
message(STATUS "The C++ compiler version is: ${CMAKE_CXX_COMPILER_VERSION}")
endif()
message(STATUS "Is the C compiler loaded? ${CMAKE_C_COMPILER_LOADED}")
if(CMAKE_C_COMPILER_LOADED)
message(STATUS "The C compiler ID is: ${CMAKE_C_COMPILER_ID}")
message(STATUS "Is the C from GNU? ${CMAKE_COMPILER_IS_GNUCC}")
message(STATUS "The C compiler version is: ${CMAKE_C_COMPILER_VERSION}")
endif()

注意,这个例子不包含任何目标,没有要构建的东西,我们只关注配置步骤:

1
2
3
4
5
6
7
8
9
10
11
12
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Is the C++ compiler loaded? 1
-- The C++ compiler ID is: GNU
-- Is the C++ from GNU? 1
-- The C++ compiler version is: 8.1.0
-- Is the C compiler loaded? 1
-- The C compiler ID is: GNU
-- Is the C from GNU? 1
-- The C compiler version is: 8.1.0

当然,输出将取决于可用和已选择的编译器(及版本)。

切换构建类型

CMake可以配置构建类型,例如:Debug、Release等。配置时,可以为Debug或Release构建设置相关的选项或属性,例如:编译器和链接器标志。控制生成构建系统使用的配置变量是CMAKE_BUILD_TYPE。该变量默认为空,CMake识别的值为:

  • Debug:用于在没有优化的情况下,使用带有调试符号构建库或可执行文件。
  • Release:用于构建的优化的库或可执行文件,不包含调试符号。
  • RelWithDebInfo:用于构建较少的优化库或可执行文件,包含调试符号。
  • MinSizeRel:用于不增加目标代码大小的优化方式,来构建库或可执行文件。

具体实施

示例中,我们将展示如何为项目设置构建类型:

首先,定义最低CMake版本、项目名称和支持的语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-07 LANGUAGES C CXX)

然后,设置一个默认的构建类型(本例中是Release),并打印一条消息。要注意的是,该变量被设置为缓存变量,可以通过缓存进行编辑:

1
2
3
4
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")

最后,打印出CMake设置的相应编译标志:

1
2
3
4
5
6
7
8
message(STATUS "C flags, Debug configuration: ${CMAKE_C_FLAGS_DEBUG}")
message(STATUS "C flags, Release configuration: ${CMAKE_C_FLAGS_RELEASE}")
message(STATUS "C flags, Release configuration with Debug info: ${CMAKE_C_FLAGS_RELWITHDEBINFO}")
message(STATUS "C flags, minimal Release configuration: ${CMAKE_C_FLAGS_MINSIZEREL}")
message(STATUS "C++ flags, Debug configuration: ${CMAKE_CXX_FLAGS_DEBUG}")
message(STATUS "C++ flags, Release configuration: ${CMAKE_CXX_FLAGS_RELEASE}")
message(STATUS "C++ flags, Release configuration with Debug info: ${CMAKE_CXX_FLAGS_RELWITHDEBINFO}")
message(STATUS "C++ flags, minimal Release configuration: ${CMAKE_CXX_FLAGS_MINSIZEREL}")

验证配置的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Build type: Release
-- C flags, Debug configuration: -g
-- C flags, Release configuration: -O3 -DNDEBUG
-- C flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C flags, minimal Release configuration: -Os -DNDEBUG
-- C++ flags, Debug configuration: -g
-- C++ flags, Release configuration: -O3 -DNDEBUG
-- C++ flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C++ flags, minimal Release configuration: -Os -DNDEBUG

切换构建类型:

1
2
3
4
5
6
7
8
9
10
$ cmake -D CMAKE_BUILD_TYPE=Debug ..
-- Build type: Debug
-- C flags, Debug configuration: -g
-- C flags, Release configuration: -O3 -DNDEBUG
-- C flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C flags, minimal Release configuration: -Os -DNDEBUG
-- C++ flags, Debug configuration: -g
-- C++ flags, Release configuration: -O3 -DNDEBUG
-- C++ flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C++ flags, minimal Release configuration: -Os -DNDEBUG

工作原理

我们演示了如何设置默认构建类型,以及如何(从命令行)覆盖它。这样,就可以控制项目,是使用优化,还是关闭优化启用调试。我们还看到了不同配置使用了哪些标志,这主要取决于选择的编译器。需要在运行CMake时显式地打印标志,也可以仔细阅读运行CMake —system-information的输出,以了解当前平台、默认编译器和语言的默认组合是什么。下一个示例中,我们将讨论如何为不同的编译器和不同的构建类型,扩展或调整编译器标志。

设置编译器选项

前面的示例展示了如何探测CMake,从而获得关于编译器的信息,以及如何切换项目中的编译器。后一个任务是控制项目的编译器标志。CMake为调整或扩展编译器标志提供了很大的灵活性,您可以选择下面两种方法:

  • CMake将编译选项视为目标属性。因此,可以根据每个目标设置编译选项,而不需要覆盖CMake默认值。
  • 可以使用-DCLI标志直接修改CMAKE_<LANG>_FLAGS_<CONFIG>变量。这将影响项目中的所有目标,并覆盖或扩展CMake默认值。

本示例中,我们将展示这两种方法。

准备工作

编写一个示例程序,计算不同几何形状的面积,computer_area.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "geometry_circle.hpp"
#include "geometry_polygon.hpp"
#include "geometry_rhombus.hpp"
#include "geometry_square.hpp"
#include <cstdlib>
#include <iostream>
int main() {
using namespace geometry;
double radius = 2.5293;
double A_circle = area::circle(radius);
std::cout << "A circle of radius " << radius << " has an area of " << A_circle
<< std::endl;
int nSides = 19;
double side = 1.29312;
double A_polygon = area::polygon(nSides, side);
std::cout << "A regular polygon of " << nSides << " sides of length " << side
<< " has an area of " << A_polygon << std::endl;
double d1 = 5.0;
double d2 = 7.8912;
double A_rhombus = area::rhombus(d1, d2);
std::cout << "A rhombus of major diagonal " << d1 << " and minor diagonal " << d2
<< " has an area of " << A_rhombus << std::endl;
double l = 10.0;
double A_square = area::square(l);
std::cout << "A square of side " << l << " has an area of " << A_square
<< std::endl;
return EXIT_SUCCESS;
}

函数的各种实现分布在不同的文件中,每个几何形状都有一个头文件和源文件。总共有4个头文件和5个源文件要编译:

1
2
3
4
5
6
7
8
9
10
├─ CMakeLists.txt
├─ compute-areas.cpp
├─ geometry_circle.cpp
├─ geometry_circle.hpp
├─ geometry_polygon.cpp
├─ geometry_polygon.hpp
├─ geometry_rhombus.cpp
├─ geometry_rhombus.hpp
├─ geometry_square.cpp
└─ geometry_square.hpp

具体实施

现在已经有了源代码,我们的目标是配置项目,并使用编译器标示进行实验:

设置CMake的最低版本:

1
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

声明项目名称和语言:

1
project(recipe-08 LANGUAGES CXX)

然后,打印当前编译器标志。CMake将对所有C++目标使用这些:

1
message("C++ compiler flags: ${CMAKE_CXX_FLAGS}")

为目标准备了标志列表,其中一些将无法在Windows上使用:

1
2
3
4
list(APPEND flags "-fPIC" "-Wall")
if(NOT WIN32)
list(APPEND flags "-Wextra" "-Wpedantic")
endif()

添加了一个新的目标——geometry库,并列出它的源依赖关系:

1
2
3
4
5
6
7
8
9
10
11
add_library(geometry
STATIC
geometry_circle.cpp
geometry_circle.hpp
geometry_polygon.cpp
geometry_polygon.hpp
geometry_rhombus.cpp
geometry_rhombus.hpp
geometry_square.cpp
geometry_square.hpp
)

为这个库目标设置了编译选项:

1
2
3
4
target_compile_options(geometry
PRIVATE
${flags}
)

然后,将生成compute-areas可执行文件作为一个目标:

1
add_executable(compute-areas compute-areas.cpp)

还为可执行目标设置了编译选项:

1
2
3
4
target_compile_options(compute-areas
PRIVATE
"-fPIC"
)

最后,将可执行文件链接到geometry库:

1
target_link_libraries(compute-areas geometry)

如何工作

本例中,警告标志有-Wall、-Wextra和-Wpedantic,将这些标示添加到geometry目标的编译选项中; compute-areas和 geometry目标都将使用-fPIC标志。编译选项可以添加三个级别的可见性:INTERFACE、PUBLIC和PRIVATE。

可见性的含义如下:

  • PRIVATE,编译选项会应用于给定的目标,不会传递给与目标相关的目标。我们的示例中, 即使compute-areas将链接到geometry库,compute-areas也不会继承geometry目标上设置的编译器选项。
  • INTERFACE,给定的编译选项将只应用于指定目标,并传递给与目标相关的目标。
  • PUBLIC,编译选项将应用于指定目标和使用它的目标。

目标属性的可见性CMake的核心,我们将在本书中经常讨论这个话题。以这种方式添加编译选项,不会影响全局CMake变量CMAKE_<LANG>_FLAGS_<CONFIG>,并能更细粒度控制在哪些目标上使用哪些选项。

我们如何验证,这些标志是否按照我们的意图正确使用呢?或者换句话说,如何确定项目在CMake构建时,实际使用了哪些编译标志?一种方法是,使用CMake将额外的参数传递给本地构建工具。本例中会设置环境变量VERBOSE=1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build . -- VERBOSE=1
... lots of output ...
[ 14%] Building CXX object CMakeFiles/geometry.dir/geometry_circle.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_circle.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_circle.cpp
[ 28%] Building CXX object CMakeFiles/geometry.dir/geometry_polygon.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_polygon.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_polygon.cpp
[ 42%] Building CXX object CMakeFiles/geometry.dir/geometry_rhombus.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_rhombus.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_rhombus.cpp
[ 57%] Building CXX object CMakeFiles/geometry.dir/geometry_square.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_square.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_square.cpp
... more output ...
[ 85%] Building CXX object CMakeFiles/compute-areas.dir/compute-areas.cpp.o
/usr/bin/c++ -fPIC -o CMakeFiles/compute-areas.dir/compute-areas.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/compute-areas.cpp
... more output ...

输出确认编译标志,确认指令设置正确。

控制编译器标志的第二种方法,不用对CMakeLists.txt进行修改。如果想在这个项目中修改geometry和compute-areas目标的编译器选项,可以使用CMake参数进行配置:

1
$ cmake -D CMAKE_CXX_FLAGS="-fno-exceptions -fno-rtti" ..

这个命令将编译项目,禁用异常和运行时类型标识(RTTI)。

也可以使用全局标志,可以使用CMakeLists.txt运行以下命令:

1
$ cmake -D CMAKE_CXX_FLAGS="-fno-exceptions -fno-rtti" ..

这将使用-fno-rtti - fpic - wall - Wextra - wpedantic配置geometry目标,同时使用-fno exception -fno-rtti - fpic配置compute-areas。

更多信息

大多数时候,编译器有特性标示。当前的例子只适用于GCC和Clang;其他供应商的编译器不确定是否会理解(如果不是全部)这些标志。如果项目是真正跨平台,那么这个问题就必须得到解决,有三种方法可以解决这个问题。

最典型的方法是将所需编译器标志列表附加到每个配置类型CMake变量CMAKE_<LANG>_FLAGS_<CONFIG>。标志确定设置为给定编译器有效的标志,因此将包含在if-endif子句中,用于检查CMAKE_<LANG>_COMPILER_ID变量,例如:

1
2
3
4
5
6
7
8
9
10
if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
list(APPEND CMAKE_CXX_FLAGS "-fno-rtti" "-fno-exceptions")
list(APPEND CMAKE_CXX_FLAGS_DEBUG "-Wsuggest-final-types" "-Wsuggest-final-methods" "-Wsuggest-override")
list(APPEND CMAKE_CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES Clang)
list(APPEND CMAKE_CXX_FLAGS "-fno-rtti" "-fno-exceptions" "-Qunused-arguments" "-fcolor-diagnostics")
list(APPEND CMAKE_CXX_FLAGS_DEBUG "-Wdocumentation")
list(APPEND CMAKE_CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()

更细粒度的方法是,不修改CMAKE_<LANG>_FLAGS_<CONFIG>变量,而是定义特定的标志列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
set(COMPILER_FLAGS)
set(COMPILER_FLAGS_DEBUG)
set(COMPILER_FLAGS_RELEASE)
if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
list(APPEND CXX_FLAGS "-fno-rtti" "-fno-exceptions")
list(APPEND CXX_FLAGS_DEBUG "-Wsuggest-final-types" "-Wsuggest-final-methods" "-Wsuggest-override")
list(APPEND CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES Clang)
list(APPEND CXX_FLAGS "-fno-rtti" "-fno-exceptions" "-Qunused-arguments" "-fcolor-diagnostics")
list(APPEND CXX_FLAGS_DEBUG "-Wdocumentation")
list(APPEND CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()

稍后,使用生成器表达式来设置编译器标志的基础上,为每个配置和每个目标生成构建系统:

1
2
3
4
5
6
target_compile_option(compute-areas
PRIVATE
${CXX_FLAGS}
"$<$<CONFIG:Debug>:${CXX_FLAGS_DEBUG}>"
"$<$<CONFIG:Release>:${CXX_FLAGS_RELEASE}>"
)

当前示例中展示了这两种方法,我们推荐后者(特定于项目的变量和target_compile_options)。

两种方法都有效,并在许多项目中得到广泛应用。不过,每种方式都有缺点。CMAKE_<LANG>_COMPILER_ID不能保证为所有编译器都定义。此外,一些标志可能会被弃用,或者在编译器的较晚版本中引入。与CMAKE_<LANG>_COMPILER_ID类似,CMAKE_<LANG>_COMPILER_VERSION变量不能保证为所有语言和供应商都提供定义。尽管检查这些变量的方式非常流行,但我们认为更健壮的替代方法是检查所需的标志集是否与给定的编译器一起工作,这样项目中实际上只使用有效的标志。结合特定于项目的变量、target_compile_options和生成器表达式,会让解决方案变得非常强大。

为语言设定标准

编程语言有不同的标准,即提供改进的语言版本。启用新标准是通过设置适当的编译器标志来实现的。前面的示例中,我们已经展示了如何为每个目标或全局进行配置。3.1版本中,CMake引入了一个独立于平台和编译器的机制,用于为C++和C设置语言标准:为目标设置<LANG>_STANDARD属性。

准备工作

对于下面的示例,需要一个符合C++14标准或更高版本的C++编译器。此示例代码定义了动物的多态,我们使用std::unique_ptr作为结构中的基类:

1
2
std::unique_ptr<Animal> cat = Cat("Simon");
std::unique_ptr<Animal> dog = Dog("Marlowe);

没有为各种子类型显式地使用构造函数,而是使用工厂方法的实现。工厂方法使用C++11的可变参数模板实现。它包含继承层次结构中每个对象的创建函数映射:

1
typedef std::function<std::unique_ptr<Animal>(const std::string &)> CreateAnimal;

基于预先分配的标签来分派它们,创建对象:

1
2
std::unique_ptr<Animal> simon = farm.create("CAT", "Simon");
std::unique_ptr<Animal> marlowe = farm.create("DOG", "Marlowe");

标签和创建功能在工厂使用前已注册:

1
2
3
Factory<CreateAnimal> farm;
farm.subscribe("CAT", [](const std::string & n) { return std::make_unique<Cat>(n); });
farm.subscribe("DOG", [](const std::string & n) { return std::make_unique<Dog>(n); });

使用C++11 Lambda函数定义创建函数,使用std::make_unique来避免引入裸指针的操作。这个工厂函数是在C++14中引入。

具体实施

将逐步构建CMakeLists.txt,并展示如何设置语言标准(本例中是C++14):

声明最低要求的CMake版本,项目名称和语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-09 LANGUAGES CXX)

要求在Windows上导出所有库符号:

1
set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON)

需要为库添加一个目标,这将编译源代码为一个动态库:

1
2
3
4
5
6
7
8
9
10
add_library(animals
SHARED
Animal.cpp
Animal.hpp
Cat.cpp
Cat.hpp
Dog.cpp
Dog.hpp
Factory.hpp
)

现在,为目标设置了CXX_STANDARD、CXX_EXTENSIONS和CXX_STANDARD_REQUIRED属性。还设置了position_independent ent_code属性,以避免在使用一些编译器构建DSO时出现问题:

1
2
3
4
5
6
7
set_target_properties(animals
PROPERTIES
CXX_STANDARD 14
CXX_EXTENSIONS OFF
CXX_STANDARD_REQUIRED ON
POSITION_INDEPENDENT_CODE 1
)

然后,为”动物农场”的可执行文件添加一个新目标,并设置它的属性:

1
2
3
4
5
6
7
add_executable(animal-farm animal-farm.cpp)
set_target_properties(animal-farm
PROPERTIES
CXX_STANDARD 14
CXX_EXTENSIONS OFF
CXX_STANDARD_REQUIRED ON
)

最后,将可执行文件链接到库:

1
target_link_libraries(animal-farm animals)

现在,来看看猫和狗都说了什么:

1
2
3
4
5
6
7
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./animal-farm
I'm Simon the cat!
I'm Marlowe the dog!

工作原理

步骤4和步骤5中,我们为动物和动物农场目标设置了一些属性:

  • CXX_STANDARD会设置我们想要的标准。
  • CXX_EXTENSIONS告诉CMake,只启用ISO C++标准的编译器标志,而不使用特定编译器的扩展。
  • CXX_STANDARD_REQUIRED指定所选标准的版本。如果这个版本不可用,CMake将停止配置并出现错误。当这个属性被设置为OFF时,CMake将寻找下一个标准的最新版本,直到一个合适的标志。这意味着,首先查找C++14,然后是C++11,然后是C++98。

如果语言标准是所有目标共享的全局属性,那么可以将CMAKE_<LANG>_STANDARDCMAKE_<LANG>_EXTENSIONSCMAKE_<LANG>_STANDARD_REQUIRED变量设置为相应的值。所有目标上的对应属性都将使用这些设置。

更多信息

通过引入编译特性,CMake对语言标准提供了更精细的控制。这些是语言标准引入的特性,比如C++11中的可变参数模板和Lambda表达式,以及C++14中的自动返回类型推断。可以使用target_compile_features()命令要求为特定的目标提供特定的特性,CMake将自动为标准设置正确的编译器标志。也可以让CMake为可选编译器特性,生成兼容头文件。

使用控制流

本章前面的示例中,已经使用过if-else-endif。CMake还提供了创建循环的语言工具:foreach endforeach和while-endwhile。两者都可以与break结合使用,以便尽早从循环中跳出。本示例将展示如何使用foreach,来循环源文件列表。我们将应用这样的循环,在引入新目标的前提下,来为一组源文件进行优化降级。

准备工作

将重用第8节中的几何示例,目标是通过将一些源代码汇集到一个列表中,从而微调编译器的优化。

具体实施

下面是CMakeLists.txt中要的详细步骤:

与示例8中一样,指定了CMake的最低版本、项目名称和语言,并声明了几何库目标:

1
2
3
4
5
6
7
8
9
10
11
12
13
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-10 LANGUAGES CXX)
add_library(geometry
STATIC
geometry_circle.cpp
geometry_circle.hpp
geometry_polygon.cpp
geometry_polygon.hpp
geometry_rhombus.cpp
geometry_rhombus.hpp
geometry_square.cpp
geometry_square.hpp
)

使用-O3编译器优化级别编译库,对目标设置一个私有编译器选项:

1
2
3
4
target_compile_options(geometry
PRIVATE
-O3
)

然后,生成一个源文件列表,以较低的优化选项进行编译:

1
2
3
4
5
list(
APPEND sources_with_lower_optimization
geometry_circle.cpp
geometry_rhombus.cpp
)

循环这些源文件,将它们的优化级别调到-O2。使用它们的源文件属性完成:

1
2
3
4
5
message(STATUS "Setting source properties using IN LISTS syntax:")
foreach(_source IN LISTS sources_with_lower_optimization)
set_source_files_properties(${_source} PROPERTIES COMPILE_FLAGS -O2)
message(STATUS "Appending -O2 flag for ${_source}")
endforeach()

为了确保设置属性,再次循环并在打印每个源文件的COMPILE_FLAGS属性:

1
2
3
4
5
message(STATUS "Querying sources properties using plain syntax:")
foreach(_source ${sources_with_lower_optimization})
get_source_file_property(_flags ${_source} COMPILE_FLAGS)
message(STATUS "Source ${_source} has the following extra COMPILE_FLAGS: ${_flags}")
endforeach()

最后,添加compute-areas可执行目标,并将geometry库连接上去:

1
2
add_executable(compute-areas compute-areas.cpp)
target_link_libraries(compute-areas geometry)

验证在配置步骤中正确设置了标志:

1
2
3
4
5
6
7
8
9
10
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Setting source properties using IN LISTS syntax:
-- Appending -O2 flag for geometry_circle.cpp
-- Appending -O2 flag for geometry_rhombus.cpp
-- Querying sources properties using plain syntax:
-- Source geometry_circle.cpp has the following extra COMPILE_FLAGS: -O2
-- Source geometry_rhombus.cpp has the following extra COMPILE_FLAGS: -O2

最后,还使用VERBOSE=1检查构建步骤。将看到-O2标志添加在-O3标志之后,但是最后一个优化级别标志(在本例中是-O2)不同:

1
$ cmake --build . -- VERBOSE=1

工作原理

foreach-endforeach语法可用于在变量列表上,表示重复特定任务。本示例中,使用它来操作、设置和获取项目中特定文件的编译器标志。CMake代码片段中引入了另外两个新命令:

set_source_files_properties(file PROPERTIES property value),它将属性设置为给定文件的传递值。与目标非常相似,文件在CMake中也有属性,允许对构建系统进行非常细粒度的控制。

get_source_file_property(VAR file property),检索给定文件所需属性的值,并将其存储在CMakeVAR变量中。

CMake中,列表是用分号分隔的字符串组。列表可以由list或set命令创建。例如,set(var a b c d e)和list(APPEND a b c d e)都创建了列表a;b;c;d;e。

为了对一组文件降低优化,将它们收集到一个单独的目标(库)中,并为这个目标显式地设置优化级别,而不是附加一个标志,这样可能会更简洁,不过在本示例中,我们的重点是foreach-endforeach。

更多信息
foreach()的四种使用方式:

  • foreach(loop_var arg1 arg2 ...): 其中提供循环变量和显式项列表。当为sources_with_lower_optimization中的项打印编译器标志集时,使用此表单。注意,如果项目列表位于变量中,则必须显式展开它;也就是说,${sources_with_lower_optimization}必须作为参数传递。
  • 通过指定一个范围,可以对整数进行循环,例如:foreach(loop_var range total)foreach(loop_var range start stop [step])
  • 对列表值变量的循环,例如:foreach(loop_var IN LISTS [list1[...]])。参数解释为列表,其内容就会自动展开。
  • 对变量的循环,例如:foreach(loop_var IN ITEMS [item1 [...]])。参数的内容没有展开。

检测环境

检测操作系统

CMake是一组跨平台工具。不过,了解操作系统(OS)上执行配置或构建步骤也很重要。从而与操作系统相关的CMake代码,会根据操作系统启用条件编译,或者在可用或必要时使用特定于编译器的扩展。

具体实施

我们将用一个非常简单的CMakeLists.txt进行演示:

首先,定义CMake最低版本和项目名称。请注意,语言是NONE:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES NONE)

然后,根据检测到的操作系统信息打印消息:

1
2
3
4
5
6
7
8
9
10
11
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
message(STATUS "Configuring on/for Linux")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
message(STATUS "Configuring on/for macOS")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
message(STATUS "Configuring on/for Windows")
elseif(CMAKE_SYSTEM_NAME STREQUAL "AIX")
message(STATUS "Configuring on/for IBM AIX")
else()
message(STATUS "Configuring on/for ${CMAKE_SYSTEM_NAME}")
endif()

测试之前,检查前面的代码块,并考虑相应系统上的具体行为。

现在,测试配置项目:

1
2
3
$ mkdir -p build
$ cd build
$ cmake ..

关于CMake输出,这里有一行很有趣——在Linux系统上(在其他系统上,输出会不同):

1
-- Configuring on/for Linux

工作原理

CMake为目标操作系统定义了CMAKE_SYSTEM_NAME,因此不需要使用定制命令、工具或脚本来查询此信息。然后,可以使用此变量的值实现特定于操作系统的条件和解决方案。在具有uname命令的系统上,将此变量设置为uname -s的输出。该变量在macOS上设置为“Darwin”。在Linux和Windows上,它分别计算为“Linux”和“Windows”。

处理与平台相关的源代码

理想情况下,应该避免依赖于平台的源代码,但是有时我们没有选择,特别是当要求配置和编译不是自己编写的代码时。本示例中,将演示如何使用CMake根据操作系统编译源代码。

准备工作

修改hello-world.cpp示例代码,将第1章第1节的例子进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <cstdlib>
#include <iostream>
#include <string>
std::string say_hello() {
#ifdef IS_WINDOWS
return std::string("Hello from Windows!");
#elif IS_LINUX
return std::string("Hello from Linux!");
#elif IS_MACOS
return std::string("Hello from macOS!");
#else
return std::string("Hello from an unknown system!");
#endif
}
int main() {
std::cout << say_hello() << std::endl;
return EXIT_SUCCESS;
}

具体实施

完成一个CMakeLists.txt实例,使我们能够基于目标操作系统有条件地编译源代码:

首先,设置了CMake最低版本、项目名称和支持的语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-02 LANGUAGES CXX)

然后,定义可执行文件及其对应的源文件:

1
add_executable(hello-world hello-world.cpp)

通过定义以下目标编译定义,让预处理器知道系统名称:

1
2
3
4
5
6
7
8
9
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_compile_definitions(hello-world PUBLIC "IS_LINUX")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
target_compile_definitions(hello-world PUBLIC "IS_MACOS")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
target_compile_definitions(hello-world PUBLIC "IS_WINDOWS")
endif()

继续之前,先检查前面的表达式,并考虑在不同系统上有哪些行为。

现在,准备测试它,并配置项目:

1
2
3
4
5
6
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./hello-world
Hello from Linux!

Windows系统上,将看到来自Windows的Hello。其他操作系统将产生不同的输出。

工作原理

hello-world.cpp示例中,有趣的部分是基于预处理器定义IS_WINDOWS、IS_LINUX或IS_MACOS的条件编译:

1
2
3
4
5
6
7
8
9
10
11
std::string say_hello() {
#ifdef IS_WINDOWS
return std::string("Hello from Windows!");
#elif IS_LINUX
return std::string("Hello from Linux!");
#elif IS_MACOS
return std::string("Hello from macOS!");
#else
return std::string("Hello from an unknown system!");
#endif
}

这些定义在CMakeLists.txt中配置时定义,通过使用target_compile_definition在预处理阶段使用。可以不重复if-endif语句,以更紧凑的表达式实现,我们将在下一个示例中演示这种重构方式。也可以把if-endif语句加入到一个if-else-else-endif语句中。这个阶段,可以使用add_definitions(-DIS_LINUX)来设置定义(当然,可以根据平台调整定义),而不是使用target_compile_definition。使用add_definitions的缺点是,会修改编译整个项目的定义,而target_compile_definitions给我们机会,将定义限制于一个特定的目标,以及通过PRIVATE|PUBLIC|INTERFACE限定符,限制这些定义可见性。

  • PRIVATE,编译定义将只应用于给定的目标,而不应用于相关的其他目标。
  • INTERFACE,对给定目标的编译定义将只应用于使用它的目标。
  • PUBLIC,编译定义将应用于给定的目标和使用它的所有其他目标。

处理与编译器相关的源代码

这个方法与前面的方法类似,我们将使用CMake来编译依赖于环境的条件源代码:本例将依赖于编译器。为了可移植性,我们尽量避免去编写新代码,但遇到有依赖的情况我们也要去解决,特别是当使用历史代码或处理编译器依赖工具,如sanitizers。

准备工作

本示例中,我们将从C++中的一个示例开始,稍后我们将演示一个Fortran示例,并尝试重构和简化CMake代码。

看一下hello-world.cpp源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <cstdlib>
#include <iostream>
#include <string>
std::string say_hello() {
#ifdef IS_INTEL_CXX_COMPILER
// only compiled when Intel compiler is selected
// such compiler will not compile the other branches
return std::string("Hello Intel compiler!");
#elif IS_GNU_CXX_COMPILER
// only compiled when GNU compiler is selected
// such compiler will not compile the other branches
return std::string("Hello GNU compiler!");
#elif IS_PGI_CXX_COMPILER
// etc.
return std::string("Hello PGI compiler!");
#elif IS_XL_CXX_COMPILER
return std::string("Hello XL compiler!");
#else
return std::string("Hello unknown compiler - have we met before?");
#endif
}
int main() {
std::cout << say_hello() << std::endl;
std::cout << "compiler name is " COMPILER_NAME << std::endl;
return EXIT_SUCCESS;
}

Fortran示例(hello-world.F90):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
program hello
implicit none
#ifdef IS_Intel_FORTRAN_COMPILER
print *, 'Hello Intel compiler!'
#elif IS_GNU_FORTRAN_COMPILER
print *, 'Hello GNU compiler!'
#elif IS_PGI_FORTRAN_COMPILER
print *, 'Hello PGI compiler!'
#elif IS_XL_FORTRAN_COMPILER
print *, 'Hello XL compiler!'
#else
print *, 'Hello unknown compiler - have we met before?'
#endif
end program

具体实施

我们将从C++的例子开始,然后再看Fortran的例子:

CMakeLists.txt文件中,定义了CMake最低版本、项目名称和支持的语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-03 LANGUAGES CXX)

然后,定义可执行目标及其对应的源文件:

1
add_executable(hello-world hello-world.cpp)

通过定义以下目标编译定义,让预处理器了解编译器的名称和供应商:

1
2
3
4
5
6
7
8
9
10
11
12
13
target_compile_definitions(hello-world PUBLIC "COMPILER_NAME=\"${CMAKE_CXX_COMPILER_ID}\"")
if(CMAKE_CXX_COMPILER_ID MATCHES Intel)
target_compile_definitions(hello-world PUBLIC "IS_INTEL_CXX_COMPILER")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
target_compile_definitions(hello-world PUBLIC "IS_GNU_CXX_COMPILER")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES PGI)
target_compile_definitions(hello-world PUBLIC "IS_PGI_CXX_COMPILER")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES XL)
target_compile_definitions(hello-world PUBLIC "IS_XL_CXX_COMPILER")
endif()

现在我们已经可以预测结果了:

1
2
3
4
5
6
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./hello-world
Hello GNU compiler!

使用不同的编译器,此示例代码将打印不同的问候语。

前一个示例的CMakeLists.txt文件中的if语句似乎是重复的,我们不喜欢重复的语句。能更简洁地表达吗?当然可以!为此,让我们再来看看Fortran示例。

Fortran例子的CMakeLists.txt文件中,我们需要做以下工作:

需要使Fortran语言:

1
project(recipe-03 LANGUAGES Fortran)

然后,定义可执行文件及其对应的源文件。在本例中,使用大写.F90后缀:

1
add_executable(hello-world hello-world.F90)

我们通过定义下面的目标编译定义,让预处理器非常清楚地了解编译器:

1
2
3
target_compile_definitions(hello-world
PUBLIC "IS_${CMAKE_Fortran_COMPILER_ID}_FORTRAN_COMPILER"
)

其余行为与C++示例相同。

工作原理

CMakeLists.txt会在配置时,进行预处理定义,并传递给预处理器。Fortran示例包含非常紧凑的表达式,我们使用CMAKE_Fortran_COMPILER_ID变量,通过target_compile_definition使用构造预处理器进行预处理定义。为了适应这种情况,我们必须将”Intel”从IS_INTEL_CXX_COMPILER更改为IS_Intel_FORTRAN_COMPILER。通过使用相应的CMAKE_C_COMPILER_IDCMAKE_CXX_COMPILER_ID变量,我们可以在C或C++中实现相同的效果。但是,请注意,CMAKE_<LANG>_COMPILER_ID不能保证为所有编译器或语言都定义。

检测处理器体系结构

准备工作

我们以下面的arch-dependent.cpp代码为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <cstdlib>
#include <iostream>
#include <string>
#define STRINGIFY(x) #x
#define TOSTRING(x) STRINGIFY(x)
std::string say_hello()
{
std::string arch_info(TOSTRING(ARCHITECTURE));
arch_info += std::string(" architecture. ");
#ifdef IS_32_BIT_ARCH
return arch_info + std::string("Compiled on a 32 bit host processor.");
#elif IS_64_BIT_ARCH
return arch_info + std::string("Compiled on a 64 bit host processor.");
#else
return arch_info + std::string("Neither 32 nor 64 bit, puzzling ...");
#endif
}
int main()
{
std::cout << say_hello() << std::endl;
return EXIT_SUCCESS;
}

具体实施

CMakeLists.txt文件中,我们需要以下内容:

首先,定义可执行文件及其源文件依赖关系:

1
2
3
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-04 LANGUAGES CXX)
add_executable(arch-dependent arch-dependent.cpp)

检查空指针类型的大小。CMake的CMAKE_SIZEOF_VOID_P变量会告诉我们CPU是32位还是64位。我们通过状态消息让用户知道检测到的大小,并设置预处理器定义:

1
2
3
4
5
6
7
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
target_compile_definitions(arch-dependent PUBLIC "IS_64_BIT_ARCH")
message(STATUS "Target is 64 bits")
else()
target_compile_definitions(arch-dependent PUBLIC "IS_32_BIT_ARCH")
message(STATUS "Target is 32 bits")
endif()

通过定义以下目标编译定义,让预处理器了解主机处理器架构,同时在配置过程中打印状态消息:

1
2
3
4
5
6
7
8
9
10
11
12
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i386")
message(STATUS "i386 architecture detected")
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i686")
message(STATUS "i686 architecture detected")
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64")
message(STATUS "x86_64 architecture detected")
else()
message(STATUS "host processor architecture is unknown")
endif()
target_compile_definitions(arch-dependent
PUBLIC "ARCHITECTURE=${CMAKE_HOST_SYSTEM_PROCESSOR}"
)

配置项目,并注意状态消息(打印出的信息可能会发生变化):

1
2
3
4
5
6
7
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Target is 64 bits
-- x86_64 architecture detected
...

最后,构建并执行代码(实际输出将取决于处理器架构):

1
2
3
$ cmake --build .
$ ./arch-dependent
x86_64 architecture. Compiled on a 64 bit host processor.

工作原理

CMake定义了CMAKE_HOST_SYSTEM_PROCESSOR变量,以包含当前运行的处理器的名称。可以设置为“i386”、“i686”、“x86_64”、“AMD64”等等,当然,这取决于当前的CPU。CMAKE_SIZEOF_VOID_P为void指针的大小。我们可以在CMake配置时进行查询,以便修改目标或目标编译定义。可以基于检测到的主机处理器体系结构,使用预处理器定义,确定需要编译的分支源代码。

更多信息

除了CMAKE_HOST_SYSTEM_PROCESSOR, CMake还定义了CMAKE_SYSTEM_PROCESSOR变量。前者包含当前运行的CPU在CMake的名称,而后者将包含当前正在为其构建的CPU的名称。这是一个细微的差别,在交叉编译时起着非常重要的作用。另一种让CMake检测主机处理器体系结构,是使用C或C++中定义的符号,结合CMake的try_run函数,尝试构建执行的源代码分支的预处理符号。这将返回已定义错误码,这些错误可以在CMake端捕获

1
2
3
4
5
#if defined(__i386) || defined(__i386__) || defined(_M_IX86)
#error cmake_arch i386
#elif defined(__x86_64) || defined(__x86_64__) || defined(__amd64) || defined(_M_X64)
#error cmake_arch x86_64
#endif

这种策略也是检测目标处理器体系结构的推荐策略,因为CMake似乎没有提供可移植的内在解决方案。另一种选择,将只使用CMake,完全不使用预处理器,代价是为每种情况设置不同的源文件,然后使用target_source命令将其设置为可执行目标arch-dependent依赖的源文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
add_executable(arch-dependent "")
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i386")
message(STATUS "i386 architecture detected")
target_sources(arch-dependent
PRIVATE
arch-dependent-i386.cpp
)
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i686")
message(STATUS "i686 architecture detected")
target_sources(arch-dependent
PRIVATE
arch-dependent-i686.cpp
)
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64")
message(STATUS "x86_64 architecture detected")
target_sources(arch-dependent
PRIVATE
arch-dependent-x86_64.cpp
)
else()
message(STATUS "host processor architecture is unknown")
endif()

这种方法,显然需要对现有项目进行更多的工作,因为源文件需要分离。此外,不同源文件之间的代码复制肯定也会成为问题。

检测处理器指令集

本示例中,我们将讨论如何在CMake的帮助下检测主机处理器支持的指令集。这个功能是较新版本添加到CMake中的,需要CMake 3.10或更高版本。检测到的主机系统信息,可用于设置相应的编译器标志,或实现可选的源代码编译,或根据主机系统生成源代码。本示例中,我们的目标是检测主机系统信息,使用预处理器定义将其传递给C++源代码,并将信息打印到输出中。

准备工作

我们是C++源码(processor-info.cpp)如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include "config.h"
#include <cstdlib>
#include <iostream>
int main()
{
std::cout << "Number of logical cores: "
<< NUMBER_OF_LOGICAL_CORES << std::endl;
std::cout << "Number of physical cores: "
<< NUMBER_OF_PHYSICAL_CORES << std::endl;
std::cout << "Total virtual memory in megabytes: "
<< TOTAL_VIRTUAL_MEMORY << std::endl;
std::cout << "Available virtual memory in megabytes: "
<< AVAILABLE_VIRTUAL_MEMORY << std::endl;
std::cout << "Total physical memory in megabytes: "
<< TOTAL_PHYSICAL_MEMORY << std::endl;
std::cout << "Available physical memory in megabytes: "
<< AVAILABLE_PHYSICAL_MEMORY << std::endl;
std::cout << "Processor is 64Bit: "
<< IS_64BIT << std::endl;
std::cout << "Processor has floating point unit: "
<< HAS_FPU << std::endl;
std::cout << "Processor supports MMX instructions: "
<< HAS_MMX << std::endl;
std::cout << "Processor supports Ext. MMX instructions: "
<< HAS_MMX_PLUS << std::endl;
std::cout << "Processor supports SSE instructions: "
<< HAS_SSE << std::endl;
std::cout << "Processor supports SSE2 instructions: "
<< HAS_SSE2 << std::endl;
std::cout << "Processor supports SSE FP instructions: "
<< HAS_SSE_FP << std::endl;
std::cout << "Processor supports SSE MMX instructions: "
<< HAS_SSE_MMX << std::endl;
std::cout << "Processor supports 3DNow instructions: "
<< HAS_AMD_3DNOW << std::endl;
std::cout << "Processor supports 3DNow+ instructions: "
<< HAS_AMD_3DNOW_PLUS << std::endl;
std::cout << "IA64 processor emulating x86 : "
<< HAS_IA64 << std::endl;
std::cout << "OS name: "
<< OS_NAME << std::endl;
std::cout << "OS sub-type: "
<< OS_RELEASE << std::endl;
std::cout << "OS build ID: "
<< OS_VERSION << std::endl;
std::cout << "OS platform: "
<< OS_PLATFORM << std::endl;
return EXIT_SUCCESS;
}

其包含config.h头文件,我们将使用config.h.in生成这个文件。config.h.in如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#pragma once
#define NUMBER_OF_LOGICAL_CORES @_NUMBER_OF_LOGICAL_CORES@
#define NUMBER_OF_PHYSICAL_CORES @_NUMBER_OF_PHYSICAL_CORES@
#define TOTAL_VIRTUAL_MEMORY @_TOTAL_VIRTUAL_MEMORY@
#define AVAILABLE_VIRTUAL_MEMORY @_AVAILABLE_VIRTUAL_MEMORY@
#define TOTAL_PHYSICAL_MEMORY @_TOTAL_PHYSICAL_MEMORY@
#define AVAILABLE_PHYSICAL_MEMORY @_AVAILABLE_PHYSICAL_MEMORY@
#define IS_64BIT @_IS_64BIT@
#define HAS_FPU @_HAS_FPU@
#define HAS_MMX @_HAS_MMX@
#define HAS_MMX_PLUS @_HAS_MMX_PLUS@
#define HAS_SSE @_HAS_SSE@
#define HAS_SSE2 @_HAS_SSE2@
#define HAS_SSE_FP @_HAS_SSE_FP@
#define HAS_SSE_MMX @_HAS_SSE_MMX@
#define HAS_AMD_3DNOW @_HAS_AMD_3DNOW@
#define HAS_AMD_3DNOW_PLUS @_HAS_AMD_3DNOW_PLUS@
#define HAS_IA64 @_HAS_IA64@
#define OS_NAME "@_OS_NAME@"
#define OS_RELEASE "@_OS_RELEASE@"
#define OS_VERSION "@_OS_VERSION@"
#define OS_PLATFORM "@_OS_PLATFORM@"

如何实施

我们将使用CMake为平台填充config.h中的定义,并将示例源文件编译为可执行文件:

首先,我们定义了CMake最低版本、项目名称和项目语言:

1
2
cmake_minimum_required(VERSION 3.10 FATAL_ERROR)
project(recipe-05 CXX)

然后,定义目标可执行文件及其源文件,并包括目录:

1
2
3
4
5
6
7
8
9
add_executable(processor-info "")
target_sources(processor-info
PRIVATE
processor-info.cpp
)
target_include_directories(processor-info
PRIVATE
${PROJECT_BINARY_DIR}
)

继续查询主机系统的信息,获取一些关键字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
foreach(key
IN ITEMS
NUMBER_OF_LOGICAL_CORES
NUMBER_OF_PHYSICAL_CORES
TOTAL_VIRTUAL_MEMORY
AVAILABLE_VIRTUAL_MEMORY
TOTAL_PHYSICAL_MEMORY
AVAILABLE_PHYSICAL_MEMORY
IS_64BIT
HAS_FPU
HAS_MMX
HAS_MMX_PLUS
HAS_SSE
HAS_SSE2
HAS_SSE_FP
HAS_SSE_MMX
HAS_AMD_3DNOW
HAS_AMD_3DNOW_PLUS
HAS_IA64
OS_NAME
OS_RELEASE
OS_VERSION
OS_PLATFORM
)
cmake_host_system_information(RESULT _${key} QUERY ${key})
endforeach()

定义了相应的变量后,配置config.h:

1
configure_file(config.h.in config.h @ONLY)

现在准备好配置、构建和测试项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./processor-info
Number of logical cores: 4
Number of physical cores: 2
Total virtual memory in megabytes: 15258
Available virtual memory in megabytes: 14678
Total physical memory in megabytes: 7858
Available physical memory in megabytes: 4072
Processor is 64Bit: 1
Processor has floating point unit: 1
Processor supports MMX instructions: 1
Processor supports Ext. MMX instructions: 0
Processor supports SSE instructions: 1
Processor supports SSE2 instructions: 1
Processor supports SSE FP instructions: 0
Processor supports SSE MMX instructions: 0
Processor supports 3DNow instructions: 0
Processor supports 3DNow+ instructions: 0
IA64 processor emulating x86 : 0
OS name: Linux
OS sub-type: 4.16.7-1-ARCH
OS build ID: #1 SMP PREEMPT Wed May 2 21:12:36 UTC 2018
OS platform: x86_64

输出会随着处理器的不同而变化。

工作原理

CMakeLists.txt中的foreach循环会查询多个键值,并定义相应的变量。此示例的核心函数是cmake_host_system_information,它查询运行CMake的主机系统的系统信息。本例中,我们对每个键使用了一个函数调用。然后,使用这些变量来配置config.h.in中的占位符,输入并生成config.h。此配置使用configure_file命令完成。最后,config.h包含在processor-info.cpp中。编译后,它将把值打印到屏幕上。

为Eigen库使能向量化

处理器的向量功能,可以提高代码的性能。对于某些类型的运算来说尤为甚之,例如:线性代数。本示例将展示如何使能矢量化,以便使用线性代数的Eigen C++库加速可执行文件。

准备工作

我们用Eigen C++模板库,用来进行线性代数计算,并展示如何设置编译器标志来启用向量化。这个示例的源代码linear-algebra.cpp文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <chrono>
#include <iostream>
#include <Eigen/Dense>
EIGEN_DONT_INLINE
double simple_function(Eigen::VectorXd &va, Eigen::VectorXd &vb)
{
// this simple function computes the dot product of two vectors
// of course it could be expressed more compactly
double d = va.dot(vb);
return d;
}
int main()
{
int len = 1000000;
int num_repetitions = 100;
// generate two random vectors
Eigen::VectorXd va = Eigen::VectorXd::Random(len);
Eigen::VectorXd vb = Eigen::VectorXd::Random(len);
double result;
auto start = std::chrono::system_clock::now();
for (auto i = 0; i < num_repetitions; i++)
{
result = simple_function(va, vb);
}
auto end = std::chrono::system_clock::now();
auto elapsed_seconds = end - start;
std::cout << "result: " << result << std::endl;
std::cout << "elapsed seconds: " << elapsed_seconds.count() << std::endl;
}

我们期望向量化可以加快simple_function中的点积操作。

如何实施

根据Eigen库的文档,设置适当的编译器标志就足以生成向量化的代码。让我们看看CMakeLists.txt:

声明一个C++11项目:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-06 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

使用Eigen库,我们需要在系统上找到它的头文件:

1
2
3
4
find_package(Eigen3 3.3 REQUIRED CONFIG)
CheckCXXCompilerFlag.cmake标准模块文件:

include(CheckCXXCompilerFlag)

检查-march=native编译器标志是否工作:

1
check_cxx_compiler_flag("-march=native" _march_native_works)

另一个选项-xHost编译器标志也开启:

1
check_cxx_compiler_flag("-xHost" _xhost_works)

设置了一个空变量_CXX_FLAGS,来保存刚才检查的两个编译器中找到的编译器标志。如果看到_march_native_works,我们将_CXX_FLAGS设置为-march=native。如果看到_xhost_works,我们将_CXX_FLAGS设置为-xHost。如果它们都不起作用,_CXX_FLAGS将为空,并禁用矢量化:

1
2
3
4
5
6
7
8
9
10
set(_CXX_FLAGS)
if(_march_native_works)
message(STATUS "Using processor's vector instructions (-march=native compiler flag set)")
set(_CXX_FLAGS "-march=native")
elseif(_xhost_works)
message(STATUS "Using processor's vector instructions (-xHost compiler flag set)")
set(_CXX_FLAGS "-xHost")
else()
message(STATUS "No suitable compiler flag found for vectorization")
endif()

为了便于比较,我们还为未优化的版本定义了一个可执行目标,不使用优化标志:

1
2
3
4
5
add_executable(linear-algebra-unoptimized linear-algebra.cpp)
target_link_libraries(linear-algebra-unoptimized
PRIVATE
Eigen3::Eigen
)

此外,我们定义了一个优化版本:

1
2
3
4
5
6
7
8
9
add_executable(linear-algebra linear-algebra.cpp)
target_compile_options(linear-algebra
PRIVATE
${_CXX_FLAGS}
)
target_link_libraries(linear-algebra
PRIVATE
Eigen3::Eigen
)

让我们比较一下这两个可执行文件——首先我们配置(在本例中,-march=native_works):

1
2
3
4
5
6
7
8
9
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Performing Test _march_native_works
-- Performing Test _march_native_works - Success
-- Performing Test _xhost_works
-- Performing Test _xhost_works - Failed
-- Using processor's vector instructions (-march=native compiler flag set)

最后,让我们编译可执行文件,并比较运行时间:

1
2
3
4
5
6
7
$ cmake --build .
$ ./linear-algebra-unoptimized
result: -261.505
elapsed seconds: 1.97964
$ ./linear-algebra
result: -261.505
elapsed seconds: 1.05048

工作原理

大多数处理器提供向量指令集,代码可以利用这些特性,获得更高的性能。由于线性代数运算可以从Eigen库中获得很好的加速,所以在使用Eigen库时,就要考虑向量化。我们所要做的就是,指示编译器为我们检查处理器,并为当前体系结构生成本机指令。不同的编译器供应商会使用不同的标志来实现这一点:GNU编译器使用-march=native标志来实现这一点,而Intel编译器使用-xHost标志。使用CheckCXXCompilerFlag.cmake模块提供的check_cxx_compiler_flag函数进行编译器标志的检查:

1
check_cxx_compiler_flag("-march=native" _march_native_works)

这个函数接受两个参数:

  • 第一个是要检查的编译器标志。
  • 第二个是用来存储检查结果(true或false)的变量。如果检查为真,我们将工作标志添加到_CXX_FLAGS变量中,该变量将用于为可执行目标设置编译器标志。

检测外部库和程序

检测Python解释器

我们将介绍find_package命令,这个命令将贯穿本章。

具体实施

我们将逐步建立CMakeLists.txt文件:

首先,定义CMake最低版本和项目名称。注意,这里不需要任何语言支持:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES NONE)

然后,使用find_package命令找到Python解释器:

1
find_package(PythonInterp REQUIRED)

然后,执行Python命令并捕获它的输出和返回值:

1
2
3
4
5
6
7
8
execute_process(
COMMAND
${PYTHON_EXECUTABLE} "-c" "print('Hello, world!')"
RESULT_VARIABLE _status
OUTPUT_VARIABLE _hello_world
ERROR_QUIET
OUTPUT_STRIP_TRAILING_WHITESPACE
)

最后,打印Python命令的返回值和输出:

1
2
message(STATUS "RESULT_VARIABLE is: ${_status}")
message(STATUS "OUTPUT_VARIABLE is: ${_hello_world}")

配置项目:

1
2
3
4
5
6
7
8
9
$ mkdir -p build
$ cd build
$ cmake ..
-- Found PythonInterp: /usr/bin/python (found version "3.6.5")
-- RESULT_VARIABLE is: 0
-- OUTPUT_VARIABLE is: Hello, world!
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-03/recipe-01/example/build

工作原理

find_package是用于发现和设置包的CMake模块的命令。这些模块包含CMake命令,用于标识系统标准位置中的包。CMake模块文件称为Find<name>.cmake,当调用find_package(<name>)时,模块中的命令将会运行。

除了在系统上实际查找包模块之外,查找模块还会设置了一些有用的变量,反映实际找到了什么,也可以在自己的CMakeLists.txt中使用这些变量。对于Python解释器,相关模块为FindPythonInterp.cmake附带的设置了一些CMake变量:

  • PYTHONINTERP_FOUND:是否找到解释器
  • PYTHON_EXECUTABLE:Python解释器到可执行文件的路径
  • PYTHON_VERSION_STRING:Python解释器的完整版本信息
  • PYTHON_VERSION_MAJOR:Python解释器的主要版本号
  • PYTHON_VERSION_MINOR :Python解释器的次要版本号
  • PYTHON_VERSION_PATCH:Python解释器的补丁版本号

可以强制CMake,查找特定版本的包。例如,要求Python解释器的版本大于或等于2.7:find_package(PythonInterp 2.7)

可以强制满足依赖关系:

1
find_package(PythonInterp REQUIRED)

如果在查找位置中没有找到适合Python解释器的可执行文件,CMake将中止配置。

软件包没有安装在标准位置时,CMake无法正确定位它们。用户可以使用CLI的-D参数传递相应的选项,告诉CMake查看特定的位置。Python解释器可以使用以下配置:

1
$ cmake -D PYTHON_EXECUTABLE=/custom/location/python ..

这将指定非标准/custom/location/python安装目录中的Python可执行文件。

每个包都是不同的,Find<package>.cmake模块试图提供统一的检测接口。。

除了检测包之外,我们还想提到一个便于打印变量的helper模块。本示例中,我们使用了以下方法:

1
2
message(STATUS "RESULT_VARIABLE is: ${_status}")
message(STATUS "OUTPUT_VARIABLE is: ${_hello_world}")

使用以下工具进行调试:

1
2
include(CMakePrintHelpers)
cmake_print_variables(_status _hello_world)

将产生以下输出:

1
-- _status="0" ; _hello_world="Hello, world!"

检测Python库

可以使用Python工具来分析和操作程序的输出。然而,还有更强大的方法可以将解释语言(如Python)与编译语言(如C或C++)组合在一起使用。一种是扩展Python,通过编译成共享库的C或C++模块在这些类型上提供新类型和新功能,这是第9章的主题。另一种是将Python解释器嵌入到C或C++程序中。两种方法都需要下列条件:

  • Python解释器的工作版本
  • Python头文件Python.h的可用性
  • Python运行时库libpython

三个组件所使用的Python版本必须相同。我们已经演示了如何找到Python解释器;本示例中,我们将展示另外两种方式。

准备工作

我们将一个简单的Python代码,嵌入到C程序中,可以在Python文档页面上找到。源文件称为hello-embedded-python.c:

1
2
3
4
5
6
7
8
9
#include <Python.h>
int main(int argc, char *argv[]) {
Py_SetProgramName(argv[0]); /* optional but recommended */
Py_Initialize();
PyRun_SimpleString("from time import time,ctime\n"
"print 'Today is',ctime(time())\n");
Py_Finalize();
return 0;
}

此代码将在程序中初始化Python解释器的实例,并使用Python的time模块,打印日期。

具体实施

以下是CMakeLists.txt中的步骤:

包含CMake最低版本、项目名称和所需语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-02 LANGUAGES C)

使用C99标准,这不严格要求与Python链接,但有时你可能需要对Python进行连接:

1
2
3
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_EXTENSIONS OFF)
set(CMAKE_C_STANDARD_REQUIRED ON)

找到Python解释器。这是一个REQUIRED依赖:

1
find_package(PythonInterp REQUIRED)

找到Python头文件和库的模块,称为FindPythonLibs.cmake:

1
find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)

使用hello-embedded-python.c源文件,添加一个可执行目标:

1
add_executable(hello-embedded-python hello-embedded-python.c)

可执行文件包含Python.h头文件。因此,这个目标的include目录必须包含Python的include目录,可以通过PYTHON_INCLUDE_DIRS变量进行指定:

1
2
3
4
target_include_directories(hello-embedded-python
PRIVATE
${PYTHON_INCLUDE_DIRS}
)

最后,将可执行文件链接到Python库,通过PYTHON_LIBRARIES变量访问:

1
2
3
4
target_link_libraries(hello-embedded-python
PRIVATE
${PYTHON_LIBRARIES}
)

现在,进行构建:

1
2
3
4
5
6
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Found PythonInterp: /usr/bin/python (found version "3.6.5")
-- Found PythonLibs: /usr/lib/libpython3.6m.so (found suitable exact version "3.6.5")

最后,执行构建,并运行可执行文件:

1
2
3
$ cmake --build .
$ ./hello-embedded-python
Today is Thu Jun 7 22:26:02 2018

工作原理

FindPythonLibs.cmake模块将查找Python头文件和库的标准位置。由于,我们的项目需要这些依赖项,如果没有找到这些依赖项,将停止配置,并报出错误。

注意,我们显式地要求CMake检测安装的Python可执行文件。这是为了确保可执行文件、头文件和库都有一个匹配的版本。这对于不同版本,可能在运行时导致崩溃。我们通过FindPythonInterp.cmake中定义的PYTHON_VERSION_MAJORPYTHON_VERSION_MINOR来实现:

1
2
find_package(PythonInterp REQUIRED)
find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)

使用EXACT关键字,限制CMake检测特定的版本,在本例中是匹配的相应Python版本的包括文件和库。我们可以使用PYTHON_VERSION_STRING变量,进行更接近的匹配:

1
2
find_package(PythonInterp REQUIRED)
find_package(PythonLibs ${PYTHON_VERSION_STRING} EXACT REQUIRED)

更多信息

当Python不在标准安装目录中,我们如何确定Python头文件和库的位置是正确的?对于Python解释器,可以通过CLI的-D选项传递PYTHON_LIBRARY和PYTHON_INCLUDE_DIR选项来强制CMake查找特定的目录。这些选项指定了以下内容:

  • PYTHON_LIBRARY:指向Python库的路径
  • PYTHON_INCLUDE_DIR:Python.h所在的路径

这样,就能获得所需的Python版本。

有时需要将-D PYTHON_EXECUTABLE-D PYTHON_LIBRARY-D PYTHON_INCLUDE_DIR传递给CMake CLI,以便找到及定位相应的版本的组件。

检测Python模块和包

依赖于Python模块或包的项目中,确定满足对这些Python模块的依赖非常重要。本示例将展示如何探测用户的环境,以找到特定的Python模块和包。

准备工作

我们将尝试在C++程序中嵌入一个稍微复杂一点的例子。这个示例再次引用Python在线文档,并展示了如何通过调用编译后的C++可执行文件,来执行用户定义的Python模块中的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include <Python.h>
int main(int argc, char* argv[]) {
PyObject* pName, * pModule, * pDict, * pFunc;
PyObject* pArgs, * pValue;
int i;
if (argc < 3) {
fprintf(stderr, "Usage: pure-embedding pythonfile funcname [args]\n");
return 1;
}
Py_Initialize();
PyRun_SimpleString("import sys");
PyRun_SimpleString("sys.path.append(\".\")");
pName = PyUnicode_DecodeFSDefault(argv[1]);
/* Error checking of pName left out */
pModule = PyImport_Import(pName);
Py_DECREF(pName);
if (pModule != NULL) {
pFunc = PyObject_GetAttrString(pModule, argv[2]);
/* pFunc is a new reference */
if (pFunc && PyCallable_Check(pFunc)) {
pArgs = PyTuple_New(argc - 3);
for (i = 0; i < argc - 3; ++i) {
pValue = PyLong_FromLong(atoi(argv[i + 3]));
if (!pValue) {
Py_DECREF(pArgs);
Py_DECREF(pModule);
fprintf(stderr, "Cannot convert argument\n");
return 1;
}
/* pValue reference stolen here: */
PyTuple_SetItem(pArgs, i, pValue);
}
pValue = PyObject_CallObject(pFunc, pArgs);
Py_DECREF(pArgs);
if (pValue != NULL) {
printf("Result of call: %ld\n", PyLong_AsLong(pValue));
Py_DECREF(pValue);
}
else {
Py_DECREF(pFunc);
Py_DECREF(pModule);
PyErr_Print();
fprintf(stderr, "Call failed\n");
return 1;
}
}
else {
if (PyErr_Occurred())
PyErr_Print();
fprintf(stderr, "Cannot find function \"%s\"\n", argv[2]);
}
Py_XDECREF(pFunc);
Py_DECREF(pModule);
}
else {
PyErr_Print();
fprintf(stderr, "Failed to load \"%s\"\n", argv[1]);
return 1;
}
Py_Finalize();
return 0;
}

我们希望嵌入的Python代码(use_numpy.py)使用NumPy设置一个矩阵,所有矩阵元素都为1.0:

1
2
3
4
5
6
7
8
import numpy as np
def print_ones(rows, cols):
A = np.ones(shape=(rows, cols), dtype=float)
print(A)
# we return the number of elements to verify
# that the C++ code is able to receive return values
num_elements = rows*cols
return(num_elements)

具体实施

下面的代码中,我们能够使用CMake检查NumPy是否可用。我们需要确保Python解释器、头文件和库在系统上是可用的。然后,将再来确认NumPy的可用性:

首先,我们定义了最低CMake版本、项目名称、语言和C++标准:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-03 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

查找解释器、头文件和库的方法与前面的方法完全相同:

1
2
find_package(PythonInterp REQUIRED)
find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)

正确打包的Python模块,指定安装位置和版本。可以在CMakeLists.txt中执行Python脚本进行探测:

1
2
3
4
5
6
7
8
execute_process(
COMMAND
${PYTHON_EXECUTABLE} "-c" "import re, numpy; print(re.compile('/__init__.py.*').sub('',numpy.__file__))"
RESULT_VARIABLE _numpy_status
OUTPUT_VARIABLE _numpy_location
ERROR_QUIET
OUTPUT_STRIP_TRAILING_WHITESPACE
)

如果找到NumPy,则_numpy_status变量为整数,否则为错误的字符串,而_numpy_location将包含NumPy模块的路径。如果找到NumPy,则将它的位置保存到一个名为NumPy的新变量中。注意,新变量被缓存,这意味着CMake创建了一个持久性变量,用户稍后可以修改该变量:

1
2
3
if(NOT _numpy_status)
set(NumPy ${_numpy_location} CACHE STRING "Location of NumPy")
endif()

下一步是检查模块的版本。同样,我们在CMakeLists.txt中施加了一些Python魔法,将版本保存到_numpy_version变量中:

1
2
3
4
5
6
7
execute_process(
COMMAND
${PYTHON_EXECUTABLE} "-c" "import numpy; print(numpy.__version__)"
OUTPUT_VARIABLE _numpy_version
ERROR_QUIET
OUTPUT_STRIP_TRAILING_WHITESPACE
)

最后,FindPackageHandleStandardArgs的CMake包以正确的格式设置NumPy_FOUND变量和输出信息:

1
2
3
4
5
6
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(NumPy
FOUND_VAR NumPy_FOUND
REQUIRED_VARS NumPy
VERSION_VAR _numpy_version
)

一旦正确的找到所有依赖项,我们就可以编译可执行文件,并将其链接到Python库:

1
2
3
4
5
6
7
8
9
10
11
12
13
add_executable(pure-embedding "")
target_sources(pure-embedding
PRIVATE
Py${PYTHON_VERSION_MAJOR}-pure-embedding.cpp
)
target_include_directories(pure-embedding
PRIVATE
${PYTHON_INCLUDE_DIRS}
)
target_link_libraries(pure-embedding
PRIVATE
${PYTHON_LIBRARIES}
)

我们还必须保证use_numpy.py在build目录中可用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
add_custom_command(
OUTPUT
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
COMMAND
${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
)
# make sure building pure-embedding triggers the above custom command
target_sources(pure-embedding
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
)

现在,我们可以测试嵌入的代码:

1
2
3
4
5
6
7
8
9
10
11
12
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- Found PythonInterp: /usr/bin/python (found version "3.6.5")
-- Found PythonLibs: /usr/lib/libpython3.6m.so (found suitable exact version "3.6.5")
-- Found NumPy: /usr/lib/python3.6/site-packages/numpy (found version "1.14.3")
$ cmake --build .
$ ./pure-embedding use_numpy print_ones 2 3
[[1. 1. 1.]
[1. 1. 1.]]
Result of call: 6

工作原理

例子中有三个新的CMake命令,需要include(FindPackageHandleStandardArgs)

  • execute_process
  • add_custom_command
  • find_package_handle_standard_args

execute_process将作为通过子进程执行一个或多个命令。最后,子进程返回值将保存到变量作为参数,传递给RESULT_VARIABLE,而管道标准输出和标准错误的内容将被保存到变量作为参数传递给OUTPUT_VARIABLE和ERROR_VARIABLE。execute_process可以执行任何操作,并使用它们的结果来推断系统配置。本例中,用它来确保NumPy可用,然后获得模块版本。

find_package_handle_standard_args提供了,用于处理与查找相关程序和库的标准工具。引用此命令时,可以正确的处理与版本相关的选项(REQUIRED和EXACT),而无需更多的CMake代码。稍后将介绍QUIET和COMPONENTS选项。本示例中,使用了以下方法:

1
2
3
4
5
6
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(NumPy
FOUND_VAR NumPy_FOUND
REQUIRED_VARS NumPy
VERSION_VAR _numpy_version
)

所有必需的变量都设置为有效的文件路径(NumPy)后,发送到模块(NumPy_FOUND)。它还将版本保存在可传递的版本变量(_numpy_version)中并打印:

1
-- Found NumPy: /usr/lib/python3.6/site-packages/numpy (found version "1.14.3")

目前的示例中,没有进一步使用这些变量。如果返回NumPy_FOUND为FALSE,则停止配置。

最后,将use_numpy.py复制到build目录,对代码进行注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
add_custom_command(
OUTPUT
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
COMMAND
${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
)
target_sources(pure-embedding
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
)

我们也可以使用file(COPY…)命令来实现复制。这里,我们选择使用add_custom_command,来确保文件在每次更改时都会被复制,而不仅仅是第一次运行配置时。还要注意target_sources命令,它将依赖项添加到${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py;这样做是为了确保构建目标,能够触发之前的命令。

检测BLAS和LAPACK数学库

虽然用于数学库底层实现实际所用的编程语言会随着时间而变化(Fortran、C、Assembly),但是也都是Fortran调用接口。本示例中的任务要链接到这些库,并展示如何用不同语言编写的库。

准备工作

为了展示数学库的检测和连接,我们编译一个C++程序,将矩阵的维数作为命令行输入,生成一个随机的方阵A,一个随机向量b,并计算线性系统方程: Ax = b。另外,将对向量b的进行随机缩放。这里,需要使用的子程序是BLAS中的DSCAL和LAPACK中的DGESV来求线性方程组的解。示例C++代码的清单( linear-algebra.cpp):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include "CxxBLAS.hpp"
#include "CxxLAPACK.hpp"
#include <iostream>
#include <random>
#include <vector>
int main(int argc, char** argv) {
if (argc != 2) {
std::cout << "Usage: ./linear-algebra dim" << std::endl;
return EXIT_FAILURE;
}
// Generate a uniform distribution of real number between -1.0 and 1.0
std::random_device rd;
std::mt19937 mt(rd());
std::uniform_real_distribution<double> dist(-1.0, 1.0);
// Allocate matrices and right-hand side vector
int dim = std::atoi(argv[1]);
std::vector<double> A(dim * dim);
std::vector<double> b(dim);
std::vector<int> ipiv(dim);
// Fill matrix and RHS with random numbers between -1.0 and 1.0
for (int r = 0; r < dim; r++) {
for (int c = 0; c < dim; c++) {
A[r + c * dim] = dist(mt);
}
b[r] = dist(mt);
}
// Scale RHS vector by a random number between -1.0 and 1.0
C_DSCAL(dim, dist(mt), b.data(), 1);
std::cout << "C_DSCAL done" << std::endl;
// Save matrix and RHS
std::vector<double> A1(A);
std::vector<double> b1(b);
int info;
info = C_DGESV(dim, 1, A.data(), dim, ipiv.data(), b.data(), dim);
std::cout << "C_DGESV done" << std::endl;
std::cout << "info is " << info << std::endl;
double eps = 0.0;
for (int i = 0; i < dim; ++i) {
double sum = 0.0;
for (int j = 0; j < dim; ++j)
sum += A1[i + j * dim] * b[j];
eps += std::abs(b1[i] - sum);
}
std::cout << "check is " << eps << std::endl;
return 0;
}

使用C++11的随机库来生成-1.0到1.0之间的随机分布。C_DSCALC_DGESV分别是到BLAS和LAPACK库的接口。为了避免名称混淆,将在下面来进一步讨论CMake模块:

文件CxxBLAS.hpp用extern “C”封装链接BLAS:

1
2
3
4
5
6
7
8
9
10
11
#pragma once
#include "fc_mangle.h"
#include <cstddef>
#ifdef __cplusplus
extern "C" {
#endif
extern void DSCAL(int *n, double *alpha, double *vec, int *inc);
#ifdef __cplusplus
}
#endif
void C_DSCAL(size_t length, double alpha, double *vec, int inc);

对应的实现文件CxxBLAS.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
#include "CxxBLAS.hpp"
#include <climits>
// see http://www.netlib.no/netlib/blas/dscal.f
void C_DSCAL(size_t length, double alpha, double *vec, int inc) {
int big_blocks = (int)(length / INT_MAX);
int small_size = (int)(length % INT_MAX);
for (int block = 0; block <= big_blocks; block++) {
double *vec_s = &vec[block * inc * (size_t)INT_MAX];
signed int length_s = (block == big_blocks) ? small_size : INT_MAX;
::DSCAL(&length_s, &alpha, vec_s, &inc);
}
}

CxxLAPACK.hpp和CxxLAPACK.cpp为LAPACK调用执行相应的转换。

具体实施

对应的CMakeLists.txt包含以下构建块:

我们定义了CMake最低版本,项目名称和支持的语言:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-04 LANGUAGES CXX C Fortran)

使用C++11标准:

1
2
3
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

此外,我们验证Fortran和C/C++编译器是否能协同工作,并生成头文件,这个文件可以处理名称混乱。两个功能都由FortranCInterface模块提供:

1
2
3
4
5
6
7
include(FortranCInterface)
FortranCInterface_VERIFY(CXX)
FortranCInterface_HEADER(
fc_mangle.h
MACRO_NAMESPACE "FC_"
SYMBOLS DSCAL DGESV
)

然后,找到BLAS和LAPACK:

1
2
find_package(BLAS REQUIRED)
find_package(LAPACK REQUIRED)

接下来,添加一个库,其中包含BLAS和LAPACK包装器的源代码,并链接到LAPACK_LIBRARIES,其中也包含BLAS_LIBRARIES:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
add_library(math "")
target_sources(math
PRIVATE
CxxBLAS.cpp
CxxLAPACK.cpp
)
target_include_directories(math
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_BINARY_DIR}
)
target_link_libraries(math
PUBLIC
${LAPACK_LIBRARIES}
)

注意,目标的包含目录和链接库声明为PUBLIC,因此任何依赖于数学库的附加目标也将在其包含目录中。

最后,我们添加一个可执行目标并链接math:

1
2
3
4
5
6
7
8
9
add_executable(linear-algebra "")
target_sources(linear-algebra
PRIVATE
linear-algebra.cpp
)
target_link_libraries(linear-algebra
PRIVATE
math
)

配置时,我们可以关注相关的打印输出:

1
2
3
4
5
6
7
8
9
10
11
12
$ mkdir -p build
$ cd build
$ cmake ..
...
-- Detecting Fortran/C Interface
-- Detecting Fortran/C Interface - Found GLOBAL and MODULE mangling
-- Verifying Fortran/C Compiler Compatibility
-- Verifying Fortran/C Compiler Compatibility - Success
...
-- Found BLAS: /usr/lib/libblas.so
...
-- A library with LAPACK API found.

最后,构建并测试可执行文件:

1
2
3
4
5
6
$ cmake --build .
$ ./linear-algebra 1000
C_DSCAL done
C_DGESV done
info is 0
check is 1.54284e-10

工作原理

FindBLAS.cmake和FindLAPACK.cmake将在标准位置查找BLAS和LAPACK库。对于前者,该模块有SGEMM函数的Fortran实现,一般用于单精度矩阵乘积。对于后者,该模块有CHEEV函数的Fortran实现,用于计算复杂厄米矩阵的特征值和特征向量。查找在CMake内部,通过编译一个小程序来完成,该程序调用这些函数,并尝试链接到候选库。如果失败,则表示相应库不存于系统上。

生成机器码时,每个编译器都会处理符号混淆,不幸的是,这种操作并不通用,而与编译器相关。为了解决这个问题,我们使用FortranCInterface模块验证Fortran和C/C++能否混合编译,然后生成一个Fortran-C接口头文件fc_mangle.h,这个文件用来解决编译器性的问题。然后,必须将生成的fc_mann .h包含在接口头文件CxxBLAS.hpp和CxxLAPACK.hpp中。为了使用FortranCInterface,我们需要在LANGUAGES列表中添加C和Fortran支持。当然,也可以定义自己的预处理器定义,但是可移植性会差很多。

检测OpenMP的并行环境

本示例中,我们将展示如何编译一个包含OpenMP指令的程序(前提是使用一个支持OpenMP的编译器)。有许多支持OpenMP的Fortran、C和C++编译器。对于相对较新的CMake版本,为OpenMP提供了非常好的支持。本示例将展示如何在使用CMake 3.9或更高版本时,使用简单C++和Fortran程序来链接到OpenMP。

准备工作

C和C++程序可以通过包含omp.h头文件和链接到正确的库,来使用OpenMP功能。编译器将在性能关键部分之前添加预处理指令,并生成并行代码。在本示例中,我们将构建以下示例源代码(example.cpp)。这段代码从1到N求和,其中N作为命令行参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <omp.h>
#include <string>
int main(int argc, char *argv[])
{
std::cout << "number of available processors: " << omp_get_num_procs()
<< std::endl;
std::cout << "number of threads: " << omp_get_max_threads() << std::endl;
auto n = std::stol(argv[1]);
std::cout << "we will form sum of numbers from 1 to " << n << std::endl;
// start timer
auto t0 = omp_get_wtime();
auto s = 0LL;
#pragma omp parallel for reduction(+ : s)
for (auto i = 1; i <= n; i++)
{
s += i;
}
// stop timer
auto t1 = omp_get_wtime();
std::cout << "sum: " << s << std::endl;
std::cout << "elapsed wall clock time: " << t1 - t0 << " seconds" << std::endl;
return 0;
}

在Fortran语言中,需要使用omp_lib模块并链接到库。在性能关键部分之前的代码注释中,可以再次使用并行指令。例如:F90需要包含以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
program example
use omp_lib
implicit none
integer(8) :: i, n, s
character(len=32) :: arg
real(8) :: t0, t1
print *, "number of available processors:", omp_get_num_procs()
print *, "number of threads:", omp_get_max_threads()
call get_command_argument(1, arg)
read(arg , *) n
print *, "we will form sum of numbers from 1 to", n
! start timer
t0 = omp_get_wtime()
s = 0
!$omp parallel do reduction(+:s)
do i = 1, n
s = s + i
end do
! stop timer
t1 = omp_get_wtime()
print *, "sum:", s
print *, "elapsed wall clock time (seconds):", t1 - t0
end program

具体实施

对于C++和Fortran的例子,CMakeLists.txt将遵循一个模板,该模板在这两种语言上很相似:

两者都定义了CMake最低版本、项目名称和语言(CXX或Fortran;我们将展示C++版本):

1
2
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)
project(recipe-05 LANGUAGES CXX)

使用C++11标准:

1
2
3
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

调用find_package来搜索OpenMP:

1
find_package(OpenMP REQUIRED)

最后,我们定义可执行目标,并链接到FindOpenMP模块提供的导入目标(在Fortran的情况下,我们链接到OpenMP::OpenMP_Fortran):

1
2
3
4
5
add_executable(example example.cpp)
target_link_libraries(example
PUBLIC
OpenMP::OpenMP_CXX
)

现在,可以配置和构建代码了:

1
2
3
4
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

并行测试(在本例中使用了4个内核):

1
2
3
4
5
6
$ ./example 1000000000
number of available processors: 4
number of threads: 4
we will form sum of numbers from 1 to 1000000000
sum: 500000000500000000
elapsed wall clock time: 1.08343 seconds

为了比较,我们可以重新运行这个例子,并将OpenMP线程的数量设置为1:

1
2
3
4
5
6
$ env OMP_NUM_THREADS=1 ./example 1000000000
number of available processors: 4
number of threads: 1
we will form sum of numbers from 1 to 1000000000
sum: 500000000500000000
elapsed wall clock time: 2.96427 seconds

工作原理

我们的示例很简单:编译代码,并运行在多个内核上时,我们会看到加速效果。加速效果并不是OMP_NUM_THREADS的倍数,不过本示例中并不关心,因为我们更关注的是如何使用CMake配置需要使用OpenMP的项目。我们发现链接到OpenMP非常简单,这要感谢FindOpenMP模块:

target_link_libraries(example
PUBLIC
OpenMP::OpenMP_CXX
)
我们不关心编译标志或包含目录——这些设置和依赖项是在OpenMP::OpenMP_CXX中定义的(IMPORTED类型)。如第1章第3节中提到的,IMPORTED库是伪目标,它完全是我们自己项目的外部依赖项。要使用OpenMP,需要设置一些编译器标志,包括目录和链接库。所有这些都包含在OpenMP::OpenMP_CXX的属性上,并通过使用target_link_libraries命令传递给example。这使得在CMake中,使用库变得非常容易。我们可以使用cmake_print_properties命令打印接口的属性,该命令由CMakePrintHelpers.CMake模块提供:

1
2
3
4
5
6
7
8
9
include(CMakePrintHelpers)
cmake_print_properties(
TARGETS
OpenMP::OpenMP_CXX
PROPERTIES
INTERFACE_COMPILE_OPTIONS
INTERFACE_INCLUDE_DIRECTORIES
INTERFACE_LINK_LIBRARIES
)

所有属性都有INTERFACE_前缀,因为这些属性对所需目标,需要以接口形式提供,并且目标以接口的方式使用OpenMP。

对于低于3.9的CMake版本:

1
2
3
4
5
6
7
8
9
add_executable(example example.cpp)
target_compile_options(example
PUBLIC
${OpenMP_CXX_FLAGS}
)
set_target_properties(example
PROPERTIES
LINK_FLAGS ${OpenMP_CXX_FLAGS}
)

检测MPI的并行环境

本示例,将展示如何在系统上找到合适的MPI实现,从而编译一个简单的“Hello, World”MPI例程。

准备工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <mpi.h>
int main(int argc, char **argv)
{
// Initialize the MPI environment. The two arguments to MPI Init are not
// currently used by MPI implementations, but are there in case future
// implementations might need the arguments.
MPI_Init(NULL, NULL);
// Get the number of processes
int world_size;
MPI_Comm_size(MPI_COMM_WORLD, &world_size);
// Get the rank of the process
int world_rank;
MPI_Comm_rank(MPI_COMM_WORLD, &world_rank);
// Get the name of the processor
char processor_name[MPI_MAX_PROCESSOR_NAME];
int name_len;
MPI_Get_processor_name(processor_name, &name_len);
// Print off a hello world message
std::cout << "Hello world from processor " << processor_name << ", rank "
<< world_rank << " out of " << world_size << " processors" << std::endl;
// Finalize the MPI environment. No more MPI calls can be made after this
MPI_Finalize();
}

具体实施

这个示例中,我们先查找MPI实现:库、头文件、编译器包装器和启动器。为此,我们将用到FindMPI.cmake标准CMake模块:

首先,定义了CMake最低版本、项目名称、支持的语言和语言标准:

1
2
3
4
5
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)
project(recipe-06 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

然后,调用find_package来定位MPI:

1
find_package(MPI REQUIRED)

与前面的配置类似,定义了可执行文件的的名称和相关源码,并链接到目标:

1
2
3
4
5
add_executable(hello-mpi hello-mpi.cpp)
target_link_libraries(hello-mpi
PUBLIC
MPI::MPI_CXX
)

配置和构建可执行文件:

1
2
3
4
5
6
7
8
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- Found MPI_CXX: /usr/lib/openmpi/libmpi_cxx.so (found version "3.1")
-- Found MPI: TRUE (found version "3.1")
-- ...
$ cmake --build .

为了并行执行这个程序,我们使用mpirun启动器(本例中,启动了两个任务):

1
2
3
$ mpirun -np 2 ./hello-mpi
Hello world from processor larry, rank 1 out of 2 processors
Hello world from processor larry, rank 0 out of 2 processors

工作原理

请记住,编译包装器是对MPI库编译器的封装。底层实现中,将会调用相同的编译器,并使用额外的参数(如成功构建并行程序所需的头文件包含路径和库)来扩充它。

编译和链接源文件时,包装器用了哪些标志?我们可以使用—showme选项来查看。要找出编译器的标志,我们可以这样使用:

1
2
$ mpicxx --showme:compile
-pthread

为了找出链接器标志,我们可以这样:

1
2
$ mpicxx --showme:link
-pthread -Wl,-rpath -Wl,/usr/lib/openmpi -Wl,--enable-new-dtags -L/usr/lib/openmpi -lmpi_cxx -lmpi

与之前的OpenMP配置类似,我们发现到MPI的链接非常简单,这要归功于FindMPI模块提供的目标:

正如在前面的配方中所讨论的,对于CMake版本低于3.9,需要更多的工作量:

1
2
3
4
5
6
7
8
9
10
11
12
13
add_executable(hello-mpi hello-mpi.c)
target_compile_options(hello-mpi
PUBLIC
${MPI_CXX_COMPILE_FLAGS}
)
target_include_directories(hello-mpi
PUBLIC
${MPI_CXX_INCLUDE_PATH}
)
target_link_libraries(hello-mpi
PUBLIC
${MPI_CXX_LIBRARIES}
)

检测外部库:Ⅰ. 使用pkg-config

目前为止,我们已经讨论了两种检测外部依赖关系的方法:

使用CMake自带的find-module,但并不是所有的包在CMake的find模块都找得到。
使用<package>Config.cmake, <package>ConfigVersion.cmake<package>Targets.cmake,这些文件由软件包供应商提供,并与软件包一起安装在标准位置的cmake文件夹下。

如果某个依赖项既不提供查找模块,也不提供供应商打包的CMake文件,该怎么办?在这种情况下,我们只有两个选择:

  • 依赖pkg-config程序,来找到系统上的包。这依赖于包供应商在.pc配置文件中,其中有关于发行包的元数据。
  • 为依赖项编写自己的find-package模块。

本示例中,将展示如何利用CMake中的pkg-config来定位ZeroMQ消息库。下一个示例中,将编写一个find模块,展示如何为ZeroMQ编写属于自己find模块。

准备工作

我们构建的代码来自ZeroMQ手册 http://zguide.zeromq.org/page:all 的示例。由两个源文件hwserver.c和hwclient.c组成,这两个源文件将构建为两个独立的可执行文件。执行时,它们将打印“Hello, World”。

具体实施

这是一个C项目,我们将使用C99标准,逐步构建CMakeLists.txt文件:

声明一个C项目,并要求符合C99标准:

1
2
3
4
5
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)
project(recipe-09 LANGUAGES C)
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_EXTENSIONS OFF)
set(CMAKE_C_STANDARD_REQUIRED ON)

使用CMake附带的find-module,查找pkg-config。这里在find_package中传递了QUIET参数。只有在没有找到pkg-config时,CMake才会报错:

1
find_package(PkgConfig REQUIRED QUIET)

找到pkg-config时,我们将使用pkg_search_module函数,以搜索任何附带包配置.pc文件的库或程序。该示例中,我们查找ZeroMQ库:

1
2
3
4
5
6
pkg_search_module(
ZeroMQ
REQUIRED
libzeromq libzmq lib0mq
IMPORTED_TARGET
)

如果找到ZeroMQ库,则打印状态消息:

1
2
3
if(TARGET PkgConfig::ZeroMQ)
message(STATUS "Found ZeroMQ")
endif()

然后,添加两个可执行目标,并链接到ZeroMQ。这将自动设置包括目录和链接库:

1
2
3
4
add_executable(hwserver hwserver.c)
target_link_libraries(hwserver PkgConfig::ZeroMQ)
add_executable(hwclient hwclient.c)
target_link_libraries(hwclient PkgConfig::ZeroMQ)

现在,我们可以配置和构建示例:

1
2
3
4
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

在终端中,启动服务器,启动时会输出类似于本例的消息:

1
Current 0MQ version is 4.2.2

然后,在另一个终端启动客户端,它将打印如下内容:

1
2
3
4
5
6
Connecting to hello world server…
Sending Hello 0…
Received World 0
Sending Hello 1…
Received World 1
Sending Hello 2…

当找到pkg-config时, CMake需要提供两个函数,来封装这个程序提供的功能:

1
2
pkg_check_modules,查找传递列表中的所有模块(库和/或程序)
pkg_search_module,要在传递的列表中找到第一个工作模块

与find_package一样,这些函数接受REQUIRED和QUIET参数。更详细地说,我们对pkg_search_module的调用如下:

1
2
3
4
5
6
pkg_search_module(
ZeroMQ
REQUIRED
libzeromq libzmq lib0mq
IMPORTED_TARGET
)

这里,第一个参数是前缀,它将用于命名存储搜索ZeroMQ库结果的目标:PkgConfig::ZeroMQ。注意,我们需要为系统上的库名传递不同的选项:libzeromq、libzmq和lib0mq。这是因为不同的操作系统和包管理器,可为同一个包选择不同的名称。

NOTE:pkg_check_modules和pkg_search_module函数添加了IMPORTED_TARGET选项,并在CMake 3.6中定义导入目标的功能。3.6之前的版本,只定义了变量ZeroMQ_INCLUDE_DIRS(用于include目录)和ZeroMQ_LIBRARIES(用于链接库),供后续使用。

创建和运行测试

创建一个简单的单元测试

CTest是CMake的测试工具,本示例中,我们将使用CTest进行单元测试。为了保持对CMake/CTest的关注,我们的测试代码会尽可能的简单。计划是编写和测试能够对整数求和的代码,示例代码只会对整数进行累加,不处理浮点数。

准备工作

代码示例由三个文件组成。实现源文件sum_integs.cpp对整数向量进行求和,并返回累加结果:

1
2
3
4
5
6
7
8
9
#include "sum_integers.hpp"
#include <vector>
int sum_integers(const std::vector<int> integers) {
auto sum = 0;
for (auto i : integers) {
sum += i;
}
return sum;
}

这个示例是否是优雅的实现并不重要,接口以sum_integers的形式导出。接口在sum_integers.hpp文件中声明,详情如下:

1
2
3
#pragma once
#include <vector>
int sum_integers(const std::vector<int> integers);

最后,main函数在main.cpp中定义,从argv[]中收集命令行参数,将它们转换成整数向量,调用sum_integers函数,并将结果打印到输出中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "sum_integers.hpp"
#include <iostream>
#include <string>
#include <vector>
// we assume all arguments are integers and we sum them up
// for simplicity we do not verify the type of arguments
int main(int argc, char *argv[]) {
std::vector<int> integers;
for (auto i = 1; i < argc; i++) {
integers.push_back(std::stoi(argv[i]));
}
auto sum = sum_integers(integers);
std::cout << sum << std::endl;
}

测试这段代码使用C++实现(test.cpp),Bash shell脚本实现(test.sh)和Python脚本实现(test.py),只要实现可以返回一个零或非零值,从而CMake可以解释为成功或失败。

C++例子(test.cpp)中,我们通过调用sum_integers来验证1 + 2 + 3 + 4 + 5 = 15:

1
2
3
4
5
6
7
8
9
10
#include "sum_integers.hpp"
#include <vector>
int main() {
auto integers = {1, 2, 3, 4, 5};
if (sum_integers(integers) == 15) {
return 0;
} else {
return 1;
}
}

Bash shell脚本调用可执行文件:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
EXECUTABLE=$1
OUTPUT=$($EXECUTABLE 1 2 3 4)
if [ "$OUTPUT" = "10" ]
then
exit 0
else
exit 1
fi

此外,Python脚本调用可执行文件(使用—executable命令行参数传递),并使用—short命令行参数执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import subprocess
import argparse
# test script expects the executable as argument
parser = argparse.ArgumentParser()
parser.add_argument('--executable',
help='full path to executable')
parser.add_argument('--short',
default=False,
action='store_true',
help='run a shorter test')
args = parser.parse_args()
def execute_cpp_code(integers):
result = subprocess.check_output([args.executable] + integers)
return int(result)
if args.short:
# we collect [1, 2, ..., 100] as a list of strings
result = execute_cpp_code([str(i) for i in range(1, 101)])
assert result == 5050, 'summing up to 100 failed'
else:
# we collect [1, 2, ..., 1000] as a list of strings
result = execute_cpp_code([str(i) for i in range(1, 1001)])
assert result == 500500, 'summing up to 1000 failed'

具体实施

现在,我们将逐步描述如何为项目设置测试:

对于这个例子,我们需要C++11支持,可用的Python解释器,以及Bash shell:

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(PythonInterp REQUIRED)
find_program(BASH_EXECUTABLE NAMES bash REQUIRED)

然后,定义库及主要可执行文件的依赖关系,以及测试可执行文件:

1
2
3
4
5
6
7
8
# example library
add_library(sum_integers sum_integers.cpp)
# main code
add_executable(sum_up main.cpp)
target_link_libraries(sum_up sum_integers)
# testing binary
add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test sum_integers)

最后,打开测试功能并定义四个测试。最后两个测试, 调用相同的Python脚本,先没有任何命令行参数,再使用—short:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enable_testing()
add_test(
NAME bash_test
COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.sh $<TARGET_FILE:sum_up>
)
add_test(
NAME cpp_test
COMMAND $<TARGET_FILE:cpp_test>
)
add_test(
NAME python_test_long
COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --executable $<TARGET_FILE:sum_up>
)
add_test(
NAME python_test_short
COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --short --executable $<TARGET_FILE:sum_up>
)

现在,我们已经准备好配置和构建代码。先手动进行测试:

1
2
3
4
5
6
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./sum_up 1 2 3 4 5
15

然后,我们可以用ctest运行测试集:

1
2
3
4
5
6
7
8
9
10
11
12
$ ctest
Test project /home/user/cmake-recipes/chapter-04/recipe-01/cxx-example/build
Start 1: bash_test
1/4 Test #1: bash_test ........................ Passed 0.01 sec
Start 2: cpp_test
2/4 Test #2: cpp_test ......................... Passed 0.00 sec
Start 3: python_test_long
3/4 Test #3: python_test_long ................. Passed 0.06 sec
Start 4: python_test_short
4/4 Test #4: python_test_short ................ Passed 0.05 sec
100% tests passed, 0 tests failed out of 4
Total Test time (real) = 0.12 sec

还应该尝试中断实现,以验证测试集是否能捕捉到更改。

工作原理

这里的两个关键命令:

  • enable_testing(),测试这个目录和所有子文件夹(因为我们把它放在主CMakeLists.txt)。
  • add_test(),定义了一个新的测试,并设置测试名称和运行命令。
1
2
3
4
add_test(
NAME cpp_test
COMMAND $<TARGET_FILE:cpp_test>
)

上面的例子中,使用了生成器表达式:$<TARGET_FILE:cpp_test>。生成器表达式,是在生成构建系统生成时的表达式。此时,我们可以声明$<TARGET_FILE:cpp_test>变量,将使用cpp_test可执行目标的完整路径进行替换。

生成器表达式在测试时非常方便,因为不必显式地将可执行程序的位置和名称,可以硬编码到测试中。以一种可移植的方式实现这一点非常麻烦,因为可执行文件和可执行后缀(例如,Windows上是.exe后缀)的位置在不同的操作系统、构建类型和生成器之间可能有所不同。使用生成器表达式,我们不必显式地了解位置和名称。

也可以将参数传递给要运行的test命令,例如:

1
2
3
4
add_test(
NAME python_test_short
COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --short --executable $<TARGET_FILE:sum_up>
)

这个例子中,我们按顺序运行测试,并展示如何缩短总测试时间并行执行测试(第8节),执行测试用例的子集(第9节)。这里,可以自定义测试命令,可以以任何编程语言运行测试集。CTest关心的是,通过命令的返回码测试用例是否通过。CTest遵循的标准约定是,返回零意味着成功,非零返回意味着失败。可以返回零或非零的脚本,都可以做测试用例。

既然知道了如何定义和执行测试,那么了解如何诊断测试失败也很重要。为此,我们可以在代码中引入一个bug,让所有测试都失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Start 1: bash_test
1/4 Test #1: bash_test ........................***Failed 0.01 sec
Start 2: cpp_test
2/4 Test #2: cpp_test .........................***Failed 0.00 sec
Start 3: python_test_long
3/4 Test #3: python_test_long .................***Failed 0.06 sec
Start 4: python_test_short
4/4 Test #4: python_test_short ................***Failed 0.06 sec
0% tests passed, 4 tests failed out of 4
Total Test time (real) = 0.13 sec
The following tests FAILED:
1 - bash_test (Failed)
2 - cpp_test (Failed)
3 - python_test_long (Failed)
4 - python_test_short (Failed)
Errors while running CTest

如果我们想了解更多,可以查看文件test/Temporary/lasttestsfailure.log。这个文件包含测试命令的完整输出,并且在分析阶段,要查看的第一个地方。使用以下CLI开关,可以从CTest获得更详细的测试输出:

  • --output-on-failure:将测试程序生成的任何内容打印到屏幕上,以免测试失败。
  • -v:将启用测试的详细输出。
  • -vv:启用更详细的输出。

CTest提供了一个非常方快捷的方式,可以重新运行以前失败的测试;要使用的CLI开关是—rerun-failed,在调试期间非常有用。

更多信息

考虑以下定义:

1
2
3
4
add_test(
NAME python_test_long
COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --executable $<TARGET_FILE:sum_up>
)

前面的定义可以通过显式指定脚本运行的WORKING_DIRECTORY重新表达,如下:

1
2
3
4
5
add_test(
NAME python_test_long
COMMAND ${PYTHON_EXECUTABLE} test.py --executable $<TARGET_FILE:sum_up>
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)

测试名称可以包含/字符,按名称组织相关测试也很有用,例如:

1
2
3
4
5
add_test(
NAME python/long
COMMAND ${PYTHON_EXECUTABLE} test.py --executable $<TARGET_FILE:sum_up>
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)

有时候,我们需要为测试脚本设置环境变量。这可以通过set_tests_properties实现:

1
2
3
4
5
6
7
set_tests_properties(python_test
PROPERTIES
ENVIRONMENT
ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR}
ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h
ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account>
)

这种方法在不同的平台上并不总可行,CMake提供了解决这个问题的方法。下面的代码片段与上面给出的代码片段相同,在执行实际的Python测试脚本之前,通过CMAKE_COMMAND调用CMake来预先设置环境变量:

1
2
3
4
5
6
7
8
9
10
11
add_test(
NAME
python_test
COMMAND
${CMAKE_COMMAND} -E env
ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR}
ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h
ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account>
${PYTHON_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/account/test.py
)

同样,要注意使用生成器表达式$<TARGET_FILE:account>来传递库文件的位置。

我们已经使用ctest命令执行测试,CMake还将为生成器创建目标(Unix Makefile生成器为make test,Ninja工具为ninja test,或者Visual Studio为RUN_TESTS)。这意味着,还有另一种(几乎)可移植的方法来运行测试:

1
$ cmake --build . --target test

使用Google Test库进行单元测试

本示例中,我们将演示如何在CMake的帮助下使用Google Test框架实现单元测试。与前一个配置相比,Google Test框架不仅仅是一个头文件,也是一个库,包含两个需要构建和链接的文件。可以将它们与我们的代码项目放在一起,但是为了使代码项目更加轻量级,我们将选择在配置时,下载一个定义良好的Google Test,然后构建框架并链接它。我们将使用较新的FetchContent模块(从CMake版本3.11开始可用)。

准备工作

main.cpp、sum_integers.cpp和sum_integers.hpp与之前相同,修改test.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "sum_integers.hpp"
#include "gtest/gtest.h"
#include <vector>
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
TEST(example, sum_zero) {
auto integers = {1, -1, 2, -2, 3, -3};
auto result = sum_integers(integers);
ASSERT_EQ(result, 0);
}
TEST(example, sum_five) {
auto integers = {1, 2, 3, 4, 5};
auto result = sum_integers(integers);
ASSERT_EQ(result, 15);
}

如上面的代码所示,我们显式地将gtest.h,而不将其他Google Test源放在代码项目存储库中,会在配置时使用FetchContent模块下载它们。

具体实施

下面的步骤描述了如何设置CMakeLists.txt,使用GTest编译可执行文件及其相应的测试:

与前两个示例相比,CMakeLists.txt的开头基本没有变化,CMake 3.11才能使用FetchContent模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# set minimum cmake version
cmake_minimum_required(VERSION 3.11 FATAL_ERROR)
# project name and language
project(recipe-03 LANGUAGES CXX)
# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON)
# example library
add_library(sum_integers sum_integers.cpp)
# main code
add_executable(sum_up main.cpp)
target_link_libraries(sum_up sum_integers)

然后引入一个if,检查ENABLE_UNIT_TESTS。默认情况下,它为ON,但有时需要设置为OFF,以免在没有网络连接时,也能使用Google Test:

1
2
3
4
5
option(ENABLE_UNIT_TESTS "Enable unit tests" ON)
message(STATUS "Enable testing: ${ENABLE_UNIT_TESTS}")
if(ENABLE_UNIT_TESTS)
# all the remaining CMake code will be placed here
endif()

if内部包含FetchContent模块,声明要获取的新内容,并查询其属性:

1
2
3
4
5
6
7
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG release-1.8.0
)
FetchContent_GetProperties(googletest)

如果内容还没有获取到,将尝试获取并配置它。这需要添加几个可以链接的目标。本例中,我们对gtest_main感兴趣。该示例还包含一些变通方法,用于使用在Visual Studio下的编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if(NOT googletest_POPULATED)
FetchContent_Populate(googletest)
# Prevent GoogleTest from overriding our compiler/linker options
# when building with Visual Studio
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
# Prevent GoogleTest from using PThreads
set(gtest_disable_pthreads ON CACHE BOOL "" FORCE)
# adds the targers: gtest, gtest_main, gmock, gmock_main
add_subdirectory(
${googletest_SOURCE_DIR}
${googletest_BINARY_DIR}
)
# Silence std::tr1 warning on MSVC
if(MSVC)
foreach(_tgt gtest gtest_main gmock gmock_main)
target_compile_definitions(${_tgt}
PRIVATE
"_SILENCE_TR1_NAMESPACE_DEPRECATION_WARNING"
)
endforeach()
endif()
endif()

然后,使用target_sources和target_link_libraries命令,定义cpp_test可执行目标并指定它的源文件:

1
2
3
4
5
6
7
8
9
10
add_executable(cpp_test "")
target_sources(cpp_test
PRIVATE
test.cpp
)
target_link_libraries(cpp_test
PRIVATE
sum_integers
gtest_main
)

最后,使用enable_test和add_test命令来定义单元测试:

1
2
3
4
5
enable_testing()
add_test(
NAME google_test
COMMAND $<TARGET_FILE:cpp_test>
)

现在,准备配置、构建和测试项目:

1
2
3
4
5
6
7
8
9
10
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ctest
Test project /home/user/cmake-cookbook/chapter-04/recipe-03/cxx-example/build
Start 1: google_test
1/1 Test #1: google_test ...................... Passed 0.00 sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) = 0.00 sec

可以直接运行cpp_test:

1
2
3
4
5
6
7
8
9
10
11
12
$ ./cpp_test
[==========] Running 2 tests from 1 test case.
[----------] Global test environment set-up.
[----------] 2 tests from example
[ RUN ] example.sum_zero
[ OK ] example.sum_zero (0 ms)
[ RUN ] example.sum_five
[ OK ] example.sum_five (0 ms)
[----------] 2 tests from example (0 ms total)
[----------] Global test environment tear-down
[==========] 2 tests from 1 test case ran. (0 ms total)
[ PASSED ] 2 tests.

工作原理

FetchContent模块支持通过ExternalProject模块,在配置时填充内容,并在其3.11版本中成为CMake的标准部分。而ExternalProject_Add()在构建时(见第8章)进行下载操作,这样FetchContent模块使得构建可以立即进行,这样获取的主要项目和外部项目(在本例中为Google Test)仅在第一次执行CMake时调用,使用add_subdirectory可以嵌套。

为了获取Google Test,首先声明外部内容:

1
2
3
4
5
6
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG release-1.8.0
)

本例中,我们获取了一个带有特定标记的Git库(release-1.8.0),但是我们也可以从Subversion、Mercurial或HTTP(S)源获取一个外部项目。有关可用选项,可参考相应的ExternalProject_Add命令的选项,网址是https://cmake.org/cmake/help/v3.11/module/ExternalProject.html

调用FetchContent_Populate()之前,检查是否已经使用FetchContent_GetProperties()命令处理了内容填充;否则,调用FetchContent_Populate()超过一次后,就会抛出错误。

FetchContent_Populate(googletest)用于填充源并定义googletest_SOURCE_DIR和googletest_BINARY_DIR,可以使用它们来处理Google Test项目(使用add_subdirectory(),因为它恰好也是一个CMake项目):

1
2
3
4
add_subdirectory(
${googletest_SOURCE_DIR}
${googletest_BINARY_DIR}
)

前面定义了以下目标:gtest、gtest_main、gmock和gmock_main。这个配置中,作为单元测试示例的库依赖项,我们只对gtest_main目标感兴趣:

1
2
3
4
5
target_link_libraries(cpp_test
PRIVATE
sum_integers
gtest_main
)

构建代码时,可以看到如何正确地对Google Test进行配置和构建。有时,我们希望升级到更新的Google Test版本,这时需要更改的唯一一行就是详细说明GIT_TAG的那一行。

使用动态分析来检测内存缺陷

内存缺陷:写入或读取越界,或者内存泄漏(已分配但从未释放的内存),会产生难以跟踪的bug,最好尽早将它们检查出来。Valgrind( http://valgrind.org )是一个通用的工具,用来检测内存缺陷和内存泄漏。本节中,我们将在使用CMake/CTest测试时使用Valgrind对内存问题进行警告。

准备工作

对于这个配置,需要三个文件。第一个是测试的实现(我们可以调用文件leaky_implementation.cpp):

1
2
3
4
5
6
7
8
9
10
#include "leaky_implementation.hpp"
int do_some_work() {
// we allocate an array
double *my_array = new double[1000];
// do some work
// ...
// we forget to deallocate it
// delete[] my_array;
return 0;
}

还需要相应的头文件(leaky_implementation.hpp):

1
2
#pragma once
int do_some_work();

并且,需要测试文件(test.cpp):

1
2
3
4
5
#include "leaky_implementation.hpp"
int main() {
int return_code = do_some_work();
return return_code;
}

我们希望测试通过,因为return_code硬编码为0。这里我们也期望检测到内存泄漏,因为my_array没有释放。

具体实施

下面展示了如何设置CMakeLists.txt来执行代码动态分析:

我们首先定义CMake最低版本、项目名称、语言、目标和依赖关系:

1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-05 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(example_library leaky_implementation.cpp)
add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test example_library)

然后,定义测试目标,还定义了MEMORYCHECK_COMMAND:

1
2
3
4
5
6
7
8
9
find_program(MEMORYCHECK_COMMAND NAMES valgrind)
set(MEMORYCHECK_COMMAND_OPTIONS "--trace-children=yes --leak-check=full")
# add memcheck test action
include(CTest)
enable_testing()
add_test(
NAME cpp_test
COMMAND $<TARGET_FILE:cpp_test>
)

运行测试集,报告测试通过情况,如下所示:

1
2
3
4
5
6
$ ctest
Test project /home/user/cmake-recipes/chapter-04/recipe-05/cxx-example/build
Start 1: cpp_test
1/1 Test #1: cpp_test ......................... Passed 0.00 sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) = 0.00 sec

现在,我们希望检查内存缺陷,可以观察到被检测到的内存泄漏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ctest -T memcheck
Site: myhost
Build name: Linux-c++
Create new tag: 20171127-1717 - Experimental
Memory check project /home/user/cmake-recipes/chapter-04/recipe-05/cxx-example/build
Start 1: cpp_test
1/1 MemCheck #1: cpp_test ......................... Passed 0.40 sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) = 0.40 sec
-- Processing memory checking output:
1/1 MemCheck: #1: cpp_test ......................... Defects: 1
MemCheck log files can be found here: ( * corresponds to test number)
/home/user/cmake-recipes/chapter-04/recipe-05/cxx-example/build/Testing/Temporary/MemoryChecker.*.log
Memory checking results:
Memory Leak - 1

最后一步,应该尝试修复内存泄漏,并验证ctest -T memcheck没有报告错误。

工作原理

使用find_program(MEMORYCHECK_COMMAND NAMES valgrind)查找valgrind,并将MEMORYCHECK_COMMAND设置为其绝对路径。我们显式地包含CTest模块来启用memcheck测试操作,可以使用CTest -T memcheck来启用这个操作。此外,使用set(MEMORYCHECK_COMMAND_OPTIONS "--trace-children=yes --leak-check=full"),将相关参数传递给Valgrind。内存检查会创建一个日志文件,该文件可用于详细记录内存缺陷信息。

配置时和构建时的操作

我们将学习如何在配置和构建时,执行自定义操作。先简单回顾一下,与CMake工作流程相关的时序:

  1. CMake时构建时:CMake正在运行,并处理项目中的CMakeLists.txt文件。
  2. 生成时:生成构建工具(如Makefile或Visual Studio项目文件)。
  3. 构建时:由CMake生成相应平台的原生构建脚本,在脚本中调用原生工具构建。此时,将调用编译器在特定的构建目录中构建目标(可执行文件和库)。
  4. CTest时测试时:运行测试套件以检查目标是否按预期执行。
  5. CDash时报告时:当测试结果上传到仪表板上,与其他开发人员共享测试报告。
  6. 安装时:当目标、源文件、可执行程序和库,从构建目录安装到相应位置。
  7. CPack时打包时:将项目打包用以分发时,可以是源码,也可以是二进制。
  8. 包安装时:新生成的包在系统范围内安装。

使用平台无关的文件操作

有些项目构建时,可能需要与平台的文件系统进行交互。也就是检查文件是否存在、创建新文件来存储临时信息、创建或提取打包文件等等。使用CMake不仅能够在不同的平台上生成构建系统,还能够在不复杂的逻辑情况下,进行文件操作,从而独立于操作系统。本示例将展示,如何以可移植的方式下载库文件。

准备工作

我们将展示如何提取Eigen库文件,并使用提取的源文件编译我们的项目。这个示例中,将重用第3章第7节的线性代数例子linear-algebra.cpp,用来检测外部库和程序、检测特征库。这里,假设已经包含Eigen库文件,已在项目构建前下载。

具体实施

项目需要解压缩Eigen打包文件,并相应地为目标设置包含目录:

首先,使能C++11项目:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

我们将自定义目标添加到构建系统中,自定义目标将提取构建目录中的库文件:

1
2
3
4
5
6
7
8
9
10
11
add_custom_target(unpack-eigen
ALL
COMMAND
${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/eigen-eigen-5a0156e40feb.tar.gz
COMMAND
${CMAKE_COMMAND} -E rename eigen-eigen-5a0156e40feb eigen-3.3.4
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
COMMENT
"Unpacking Eigen3 in ${CMAKE_CURRENT_BINARY_DIR}/eigen-3.3.4"
)

为源文件添加了一个可执行目标:

1
add_executable(linear-algebra linear-algebra.cpp)

由于源文件的编译依赖于Eigen头文件,需要显式地指定可执行目标对自定义目标的依赖关系:

1
add_dependencies(linear-algebra unpack-eigen)

最后,指定包含哪些目录:

1
2
3
4
target_include_directories(linear-algebra
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/eigen-3.3.4
)

工作原理

细看add_custom_target这个命令:

1
2
3
4
5
6
7
8
9
10
11
add_custom_target(unpack-eigen
ALL
COMMAND
${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/eigen-eigen-5a0156e40feb.tar.gz
COMMAND
${CMAKE_COMMAND} -E rename eigen-eigen-5a0156e40feb eigen-3.3.4
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
COMMENT
"Unpacking Eigen3 in ${CMAKE_CURRENT_BINARY_DIR}/eigen-3.3.4"
)

构建系统中引入了一个名为unpack-eigen的目标。因为我们传递了ALL参数,目标将始终被执行。COMMAND参数指定要执行哪些命令。本例中,我们希望提取存档并将提取的目录重命名为egan -3.3.4,通过以下两个命令实现:

1
2
3
${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/eigen-eigen-
5a0156e40feb.tar.gz
${CMAKE_COMMAND} -E rename eigen-eigen-5a0156e40feb eigen-3.3.4

注意,使用-E标志调用CMake命令本身来执行实际的工作。对于许多常见操作,CMake实现了一个对所有操作系统都通用的接口,这使得构建系统独立于特定的平台。add_custom_target命令中的下一个参数是工作目录。我们的示例中,它对应于构建目录:CMAKE_CURRENT_BINARY_DIR。最后一个参数COMMENT,用于指定CMake在执行自定义目标时输出什么样的消息。

配置时运行自定义命令

运行CMake生成构建系统,从而指定原生构建工具必须执行哪些命令,以及按照什么顺序执行。我们已经了解了CMake如何在配置时运行许多子任务,以便找到工作的编译器和必要的依赖项。本示例中,我们将讨论如何使用execute_process命令在配置时运行定制化命令。

具体实施

第3章第3节中,我们已经展示了execute_process查找Python模块NumPy时的用法。本例中,我们将使用execute_process命令来确定,是否存在特定的Python模块(本例中为Python CFFI),如果存在,我们在进行版本确定:

对于这个简单的例子,不需要语言支持:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-02 LANGUAGES NONE)

我们要求Python解释器执行一个简短的代码片段,因此,需要使用find_package来查找解释器:

1
find_package(PythonInterp REQUIRED)

然后,调用execute_process来运行一个简短的Python代码段;下一节中,我们将更详细地讨论这个命令:

1
2
3
4
5
6
7
8
9
10
11
# this is set as variable to prepare
# for abstraction using loops or functions
set(_module_name "cffi")
execute_process(
COMMAND
${PYTHON_EXECUTABLE} "-c" "import ${_module_name}; print(${_module_name}.__version__)"
OUTPUT_VARIABLE _stdout
ERROR_VARIABLE _stderr
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_STRIP_TRAILING_WHITESPACE
)

然后,打印结果:

1
2
3
4
5
if(_stderr MATCHES "ModuleNotFoundError")
message(STATUS "Module ${_module_name} not found")
else()
message(STATUS "Found module ${_module_name} v${_stdout}")
endif()

下面是一个配置示例(假设Python CFFI包安装在相应的Python环境中):

1
2
3
4
5
$ mkdir -p build
$ cd build
$ cmake ..
-- Found PythonInterp: /home/user/cmake-cookbook/chapter-05/recipe-02/example/venv/bin/python (found version "3.6.5")
-- Found module cffi v1.11.5

工作原理

execute_process命令将从当前正在执行的CMake进程中派生一个或多个子进程,从而提供了在配置项目时运行任意命令的方法。可以在一次调用execute_process时执行多个命令。但请注意,每个命令的输出将通过管道传输到下一个命令中。该命令接受多个参数:

  • WORKING_DIRECTORY,指定应该在哪个目录中执行命令。
  • RESULT_VARIABLE将包含进程运行的结果。这要么是一个整数,表示执行成功,要么是一个带有错误条件的字符串。
  • OUTPUT_VARIABLE和ERROR_VARIABLE将包含执行命令的标准输出和标准错误。由于命令的输出是通过管道传输的,因此只有最后一个命令的标准输出才会保存到OUTPUT_VARIABLE中。
  • INPUT_FILE指定标准输入重定向的文件名
  • OUTPUT_FILE指定标准输出重定向的文件名
  • ERROR_FILE指定标准错误输出重定向的文件名

设置OUTPUT_QUIET和ERROR_QUIET后,CMake将静默地忽略标准输出和标准错误。

设置OUTPUT_STRIP_TRAILING_WHITESPACE,可以删除运行命令的标准输出中的任何尾随空格

设置ERROR_STRIP_TRAILING_WHITESPACE,可以删除运行命令的错误输出中的任何尾随空格。

有了这些了解这些参数,回到我们的例子当中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
set(_module_name "cffi")
execute_process(
COMMAND
${PYTHON_EXECUTABLE} "-c" "import ${_module_name}; print(${_module_name}.__version__)"
OUTPUT_VARIABLE _stdout
ERROR_VARIABLE _stderr
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_STRIP_TRAILING_WHITESPACE
)
if(_stderr MATCHES "ModuleNotFoundError")
message(STATUS "Module ${_module_name} not found")
else()
message(STATUS "Found module ${_module_name} v${_stdout}")
endif()

该命令检查python -c "import cffi; print(cffi.__version__)"的输出。如果没有找到模块,_stderr将包含ModuleNotFoundError,我们将在if语句中对其进行检查。本例中,我们将打印Module cffi not found。如果导入成功,Python代码将打印模块的版本,该模块通过管道输入_stdout,这样就可以打印如下内容:

1
message(STATUS "Found module ${_module_name} v${_stdout}")

构建时运行自定义命令:Ⅰ. 使用add_custom_command

项目的构建目标取决于命令的结果,这些命令只能在构建系统生成完成后的构建执行。CMake提供了三个选项来在构建时执行自定义命令:

  • 使用add_custom_command编译目标,生成输出文件。
  • add_custom_target的执行没有输出。
  • 构建目标前后,add_custom_command的执行可以没有输出。

这三个选项强制执行特定的语义,并且不可互换。接下来的三个示例将演示具体的用法。

准备工作

我们将重用第3章第4节中的C++示例,以说明如何使用add_custom_command的第一个选项。代码示例中,我们了解了现有的BLAS和LAPACK库,并编译了一个很小的C++包装器库,以调用线性代数的Fortran实现。

我们将把代码分成两部分。linear-algebra.cpp的源文件与第3章、第4章没有区别,并且将包含线性代数包装器库的头文件和针对编译库的链接。源代码将打包到一个压缩的tar存档文件中,该存档文件随示例项目一起提供。存档文件将在构建时提取,并在可执行文件生成之前,编译线性代数的包装器库。

具体实施

CMakeLists.txt必须包含一个自定义命令,来提取线性代数包装器库的源代码:

从CMake最低版本、项目名称和支持语言的定义开始:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-03 LANGUAGES CXX Fortran)

选择C++11标准:

1
2
3
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

然后,在系统上查找BLAS和LAPACK库:

1
2
find_package(BLAS REQUIRED)
find_package(LAPACK REQUIRED)

声明一个变量wrap_BLAS_LAPACK_sources来保存wrap_BLAS_LAPACK.tar.gz压缩包文件的名称:

1
2
3
4
5
6
set(wrap_BLAS_LAPACK_sources
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.hpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.cpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.hpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.cpp
)

声明自定义命令来提取wrap_BLAS_LAPACK.tar.gz压缩包,并更新提取文件的时间戳。注意这个wrap_BLAS_LAPACK_sources变量的预期输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
add_custom_command(
OUTPUT
${wrap_BLAS_LAPACK_sources}
COMMAND
${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
COMMAND
${CMAKE_COMMAND} -E touch ${wrap_BLAS_LAPACK_sources}
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
COMMENT
"Unpacking C++ wrappers for BLAS/LAPACK"
VERBATIM
)

接下来,添加一个库目标,源文件是新解压出来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
add_library(math "")
target_sources(math
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.cpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.cpp
PUBLIC
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.hpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.hpp
)
target_include_directories(math
INTERFACE
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK
)
target_link_libraries(math
PUBLIC
${LAPACK_LIBRARIES}
)

最后,添加linear-algebra可执行目标。可执行目标链接到库:

1
2
3
4
5
add_executable(linear-algebra linear-algebra.cpp)
target_link_libraries(linear-algebra
PRIVATE
math
)

我们配置、构建和执行示例:

1
2
3
4
5
6
7
8
9
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./linear-algebra 1000
C_DSCAL done
C_DGESV done
info is 0
check is 4.35597e-10

工作原理

让我们来了解一下add_custom_command的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
add_custom_command(
OUTPUT
${wrap_BLAS_LAPACK_sources}
COMMAND
${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
COMMAND
${CMAKE_COMMAND} -E touch ${wrap_BLAS_LAPACK_sources}
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
COMMENT
"Unpacking C++ wrappers for BLAS/LAPACK"
VERBATIM
)

add_custom_command向目标添加规则,并通过执行命令生成输出。add_custom_command中声明的任何目标,即在相同的CMakeLists.txt中声明的任何目标,使用输出的任何文件作为源文件的目标,在构建时会有规则生成这些文件。因此,源文件生成在构建时,目标和自定义命令在构建系统生成时,将自动处理依赖关系。

我们的例子中,输出是压缩tar包,其中包含有源文件。要检测和使用这些文件,必须在构建时提取打包文件。通过使用带有-E标志的CMake命令,以实现平台独立性。下一个命令会更新提取文件的时间戳。这样做是为了确保没有处理陈旧文件。WORKING_DIRECTORY可以指定在何处执行命令。示例中,CMAKE_CURRENT_BINARY_DIR是当前正在处理的构建目录。DEPENDS参数列出了自定义命令的依赖项。例子中,压缩的tar是一个依赖项。CMake使用COMMENT字段在构建时打印状态消息。最后,VERBATIM告诉CMake为生成器和平台生成正确的命令,从而确保完全独立。

我们来仔细看看这用使用方式和打包库的创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
add_library(math "")
target_sources(math
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.cpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.cpp
PUBLIC
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.hpp
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.hpp
)
target_include_directories(math
INTERFACE
${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK
)
target_link_libraries(math
PUBLIC
${LAPACK_LIBRARIES}
)

我们声明一个没有源的库目标,是因为后续使用target_sources填充目标的源。这里实现了一个非常重要的目标,即让依赖于此目标的目标,了解需要哪些目录和头文件,以便成功地使用库。C++源文件的目标是PRIVATE,因此只用于构建库。因为目标及其依赖项都需要使用它们来成功编译,所以头文件是PUBLIC。包含目录使用target_include_categories指定,其中wrap_BLAS_LAPACK声明为INTERFACE,因为只有依赖于math目标的目标需要它。

构建时为特定目标运行自定义命令

本节示例将展示,如何使用add_custom_command的第二个参数,来执行没有输出的自定义操作,这对于构建或链接特定目标之前或之后执行某些操作非常有用。由于自定义命令仅在必须构建目标本身时才执行,因此我们实现了对其执行的目标级控制。我们将通过一个示例来演示,在构建目标之前打印目标的链接,然后在编译后,立即测量编译后,可执行文件的静态分配大小。

准备工作

本示例中,我们将使用Fortran代码(example.f90):

1
2
3
4
5
6
7
8
9
10
11
program example
implicit none
real(8) :: array(20000000)
real(8) :: r
integer :: i
do i = 1, size(array)
call random_number(r)
array(i) = r
end do
print *, sum(array)
end program

虽然我们选择了Fortran,但Fortran代码的对于后面的讨论并不重要,因为有很多遗留的Fortran代码,存在静态分配大小的问题。

这段代码中,我们定义了一个包含20,000,000双精度浮点数的数组,这个数组占用160MB的内存。在这里,我们并不是推荐这样的编程实践。一般来说,这些内存的分配和代码中是否使用这段内存无关。一个更好的方法是只在需要时动态分配数组,随后立即释放。

示例代码用随机数填充数组,并计算它们的和——这样是为了确保数组确实被使用,并且编译器不会优化分配。我们将使用Python脚本(static-size.py)来统计二进制文件静态分配的大小,该脚本用size命令来封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import subprocess
import sys
# for simplicity we do not check number of
# arguments and whether the file really exists
file_path = sys.argv[-1]
try:
output = subprocess.check_output(['size', file_path]).decode('utf-8')
except FileNotFoundError:
print('command "size" is not available on this platform')
sys.exit(0)
size = 0.0
for line in output.split('\n'):
if file_path in line:
# we are interested in the 4th number on this line
size = int(line.split()[3])
print('{0:.3f} MB'.format(size/1.0e6))

要打印链接行,我们将使用第二个Python helper脚本(echo-file.py)打印文件的内容:

1
2
3
4
5
6
7
8
9
import sys
# for simplicity we do not verify the number and
# type of arguments
file_path = sys.argv[-1]
try:
with open(file_path, 'r') as f:
print(f.read())
except FileNotFoundError:
print('ERROR: file {0} not found'.format(file_path))

具体实施

来看看CMakeLists.txt:

首先声明一个Fortran项目:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-05 LANGUAGES Fortran)

例子依赖于Python解释器,所以以一种可移植的方式执行helper脚本:

1
find_package(PythonInterp REQUIRED)

本例中,默认为“Release”构建类型,以便CMake添加优化标志:

1
2
3
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()

现在,定义可执行目标:

1
2
3
4
5
add_executable(example "")
target_sources(example
PRIVATE
example.f90
)

然后,定义一个自定义命令,在example目标在已链接之前,打印链接行:

1
2
3
4
5
6
7
8
9
10
11
12
add_custom_command(
TARGET
example
PRE_LINK
COMMAND
${PYTHON_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/echo-file.py
${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/example.dir/link.txt
COMMENT
"link line:"
VERBATIM
)

测试一下。观察打印的链接行和可执行文件的静态大小:

1
2
3
4
5
6
7
8
9
10
11
12
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
Scanning dependencies of target example
[ 50%] Building Fortran object CMakeFiles/example.dir/example.f90.o
[100%] Linking Fortran executable example
link line:
/usr/bin/f95 -O3 -DNDEBUG -O3 CMakeFiles/example.dir/example.f90.o -o example
static size of executable:
160.003 MB
[100%] Built target example

工作原理

当声明了库或可执行目标,就可以使用add_custom_command将其他命令锁定到目标上。这些命令将在特定的时间执行,与它们所附加的目标的执行相关联。CMake通过以下选项,定制命令执行顺序:

  • PRE_BUILD:在执行与目标相关的任何其他规则之前执行的命令。
  • PRE_LINK:使用此选项,命令在编译目标之后,调用链接器或归档器之前执行。Visual Studio 7或更高版本之外的生成器中使用PRE_BUILD将被解释为PRE_LINK。
  • POST_BUILD:如前所述,这些命令将在执行给定目标的所有规则之后运行。

本例中,将两个自定义命令绑定到可执行目标。PRE_LINK命令将${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/example.dir/link.txt的内容打印到屏幕上。在我们的例子中,链接行是这样的:

1
/usr/bin/f95 -O3 -DNDEBUG -O3 CMakeFiles/example.dir/example.f90.o -o example

使用Python包装器来实现这一点,它依赖于shell命令。

第二步中,POST_BUILD自定义命令调用Python helper脚本static-size.py,生成器表达式$作为参数。CMake将在生成时(即生成生成系统时)将生成器表达式扩展到目标文件路径。然后,Python脚本static-size.py使用size命令获取可执行文件的静态分配大小,将其转换为MB,并打印结果。我们的例子中,获得了预期的160 MB:

1
2
static size of executable:
160.003 MB

探究编译和链接命令

生成构建系统期间最常见的操作,是试图评估在哪种系统上构建项目。这意味着要找出哪些功能工作,哪些不工作,并相应地调整项目的编译。使用的方法是查询依赖项是否被满足的信号,或者在代码库中是否启用工作区。接下来的几个示例,将展示如何使用CMake执行这些操作。

准备工作

示例将展示如何使用来自对应的Check<LANG>SourceCompiles.cmake标准模块的check_<lang>_source_compiles函数,以评估给定编译器是否可以将预定义的代码编译成可执行文件。该命令可帮助你确定:

  • 编译器支持所需的特性。
  • 链接器工作正常,并理解特定的标志。
  • 可以使用find_package找到的包含目录和库。

本示例中,我们将展示如何检测OpenMP 4.5标准的循环特性,以便在C++可执行文件中使用。使用一个C++源文件,来探测编译器是否支持这样的特性。CMake提供了一个附加命令try_compile来探究编译。本示例将展示,如何使用这两种方法。

可以使用CMake命令行界面来获取关于特定模块(cmake --help-module <module-name>)和命令(cmake --help-command <command-name>)的文档。示例中,cmake --help-module CheckCXXSourceCompiles将把check_cxx_source_compiles函数的文档输出到屏幕上,而cmake --help-command try_compile将对try_compile命令执行相同的操作。

具体实施

我们将同时使用try_compile和check_cxx_source_compiles,并比较这两个命令的工作方式:

创建一个C++11工程:

1
2
3
4
5
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)
project(recipe-06 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

查找编译器支持的OpenMP:

1
2
3
4
5
6
find_package(OpenMP)
if(OpenMP_FOUND)
# ... <- the steps below will be placed here
else()
message(STATUS "OpenMP not found: no test for taskloop is run")
endif()

如果找到OpenMP,再检查所需的特性是否可用。为此,设置了一个临时目录,try_compile将在这个目录下来生成中间文件。我们把它放在前面步骤中引入的if语句中:

1
set(_scratch_dir ${CMAKE_CURRENT_BINARY_DIR}/omp_try_compile)

调用try_compile生成一个小项目,以尝试编译源文件taskloop.cpp。编译成功或失败的状态,将保存到omp_taskloop_test_1变量中。需要为这个示例编译设置适当的编译器标志、包括目录和链接库。因为使用导入的目标OpenMP::OpenMP_CXX,所以只需将LINK_LIBRARIES选项设置为try_compile即可。如果编译成功,则任务循环特性可用,我们为用户打印一条消息:

1
2
3
4
5
6
7
8
9
try_compile(
omp_taskloop_test_1
${_scratch_dir}
SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/taskloop.cpp
LINK_LIBRARIES
OpenMP::OpenMP_CXX
)
message(STATUS "Result of try_compile: ${omp_taskloop_test_1}")

要使用check_cxx_source_compiles函数,需要包含CheckCXXSourceCompiles.cmake模块文件。其他语言也有类似的模块文件,C(CheckCSourceCompiles.cmake)Fortran(CheckFortranSourceCompiles.cmake):

1
include(CheckCXXSourceCompiles)

我们复制源文件的内容,通过file(READ …)命令读取内容到一个变量中,试图编译和连接这个变量:

1
file(READ ${CMAKE_CURRENT_SOURCE_DIR}/taskloop.cpp _snippet)

我们设置了CMAKE_REQUIRED_LIBRARIES。这对于下一步正确调用编译器是必需的。注意使用导入的OpenMP::OpenMP_CXX目标,它还将设置正确的编译器标志和包含目录:

1
set(CMAKE_REQUIRED_LIBRARIES OpenMP::OpenMP_CXX)

使用代码片段作为参数,调用check_cxx_source_compiles函数。检查结果将保存到omp_taskloop_test_2变量中:

1
check_cxx_source_compiles("${_snippet}" omp_taskloop_test_2)

调用check_cxx_source_compiles并向用户打印消息之前,我们取消了变量的设置:

1
2
unset(CMAKE_REQUIRED_LIBRARIES)
message(STATUS "Result of check_cxx_source_compiles: ${omp_taskloop_test_2}"

最后,进行测试:

1
2
3
4
5
6
7
8
9
10
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- Found OpenMP_CXX: -fopenmp (found version "4.5")
-- Found OpenMP: TRUE (found version "4.5")
-- Result of try_compile: TRUE
-- Performing Test omp_taskloop_test_2
-- Performing Test omp_taskloop_test_2 - Success
-- Result of check_cxx_source_compiles: 1

工作原理

trycompile和check_cxx_source_compiles都将编译源文件,并将其链接到可执行文件中。如果这些操作成功,那么输出变量omp_task_loop_test_1(前者)和omp_task_loop_test_2(后者)将被设置为TRUE。然而,这两个命令实现的方式略有不同。`check_source_compiles`命令是try_compile命令的简化包装。

要编译的代码片段必须作为CMake变量传入。大多数情况下,这意味着必须使用file(READ …)来读取文件。然后,代码片段被保存到构建目录的CMakeFiles/CMakeTmp子目录中。

微调编译和链接,必须通过设置以下CMake变量进行:

  • CMAKE_REQUIRED_FLAGS:设置编译器标志。
  • CMAKE_REQUIRED_DEFINITIONS:设置预编译宏。
  • CMAKE_REQUIRED_INCLUDES:设置包含目录列表。
  • CMAKE_REQUIRED_LIBRARIES:设置可执行目标能够连接的库列表。

调用check_<lang>_compiles_function之后,必须手动取消对这些变量的设置,以确保后续使用中,不会保留当前内容。

使用CMake 3.9中可以对于OpenMP目标进行导入,但是目前的配置也可以使用CMake的早期版本,通过手动为check_cxx_source_compiles设置所需的标志和库:set(CMAKE_REQUIRED_FLAGS ${OpenMP_CXX_FLAGS})set(CMAKE_REQUIRED_LIBRARIES ${OpenMP_CXX_LIBRARIES})

生成源码

配置时生成源码

代码生成在配置时发生,例如:CMake可以检测操作系统和可用库;基于这些信息,我们可以定制构建的源代码。本节和下面的章节中,我们将演示如何生成一个简单源文件,该文件定义了一个函数,用于报告构建系统配置。

准备工作

此示例的代码使用Fortran和C语言编写,第9章将讨论混合语言编程。主程序是一个简单的Fortran可执行程序,它调用一个C函数print_info(),该函数将打印配置信息。值得注意的是,在使用Fortran 2003时,编译器将处理命名问题(对于C函数的接口声明),如示例所示。我们将使用的example.f90作为源文件:

1
2
3
4
5
6
7
8
program hello_world
implicit none
interface
subroutine print_info() bind(c, name="print_info")
end subroutine
end interface
call print_info()
end program

C函数print_info()在模板文件print_info.c.in中定义。在配置时,以@开头和结尾的变量将被替换为实际值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <unistd.h>
void print_info(void)
{
printf("\n");
printf("Configuration and build information\n");
printf("-----------------------------------\n");
printf("\n");
printf("Who compiled | %s\n", "@_user_name@");
printf("Compilation hostname | %s\n", "@_host_name@");
printf("Fully qualified domain name | %s\n", "@_fqdn@");
printf("Operating system | %s\n",
"@_os_name@, @_os_release@, @_os_version@");
printf("Platform | %s\n", "@_os_platform@");
printf("Processor info | %s\n",
"@_processor_name@, @_processor_description@");
printf("CMake version | %s\n", "@CMAKE_VERSION@");
printf("CMake generator | %s\n", "@CMAKE_GENERATOR@");
printf("Configuration time | %s\n", "@_configuration_time@");
printf("Fortran compiler | %s\n", "@CMAKE_Fortran_COMPILER@");
printf("C compiler | %s\n", "@CMAKE_C_COMPILER@");
printf("\n");
fflush(stdout);
}

具体实施

在CMakeLists.txt中,我们首先必须对选项进行配置,并用它们的值替换print_info.c.in中相应的占位符。然后,将Fortran和C源代码编译成一个可执行文件:

声明了一个Fortran-C混合项目:

1
2
cmake_minimum_required(VERSION 3.10 FATAL_ERROR)
project(recipe-01 LANGUAGES Fortran C)

使用execute_process为项目获取当且使用者的信息:

1
2
3
4
5
6
7
8
9
execute_process(
COMMAND
whoami
TIMEOUT
1
OUTPUT_VARIABLE
_user_name
OUTPUT_STRIP_TRAILING_WHITESPACE
)

使用cmake_host_system_information()函数(已经在第2章第5节遇到过),可以查询很多系统信息:

1
2
3
4
5
6
7
8
9
10
11
# host name information
cmake_host_system_information(RESULT _host_name QUERY HOSTNAME)
cmake_host_system_information(RESULT _fqdn QUERY FQDN)
# processor information
cmake_host_system_information(RESULT _processor_name QUERY PROCESSOR_NAME)
cmake_host_system_information(RESULT _processor_description QUERY PROCESSOR_DESCRIPTION)
# os information
cmake_host_system_information(RESULT _os_name QUERY OS_NAME)
cmake_host_system_information(RESULT _os_release QUERY OS_RELEASE)
cmake_host_system_information(RESULT _os_version QUERY OS_VERSION)
cmake_host_system_information(RESULT _os_platform QUERY OS_PLATFORM)

捕获配置时的时间戳,并通过使用字符串操作函数:

1
string(TIMESTAMP _configuration_time "%Y-%m-%d %H:%M:%S [UTC]" UTC)

现在,准备好配置模板文件print_info.c.in。通过CMake的configure_file函数生成代码。注意,这里只要求以@开头和结尾的字符串被替换:

1
configure_file(print_info.c.in print_info.c @ONLY)

最后,我们添加一个可执行目标,并定义目标源:

1
2
3
4
5
6
add_executable(example "")
target_sources(example
PRIVATE
example.f90
${CMAKE_CURRENT_BINARY_DIR}/print_info.c
)

下面是一个输出示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./example
Configuration and build information
-----------------------------------
Who compiled | somebody
Compilation hostname | laptop
Fully qualified domain name | laptop
Operating system | Linux, 4.16.13-1-ARCH, #1 SMP PREEMPT Thu May 31 23:29:29 UTC 2018
Platform | x86_64
Processor info | Unknown P6 family, 2 core Intel(R) Core(TM) i5-5200U CPU @ 2.20GHz
CMake version | 3.11.3
CMake generator | Unix Makefiles
Configuration time | 2018-06-25 15:38:03 [UTC]
Fortran compiler | /usr/bin/f95
C compiler | /usr/bin/cc

工作原理

configure_file命令可以复制文件,并用变量值替换它们的内容。示例中,使用configure_file修改模板文件的内容,并将其复制到一个位置,然后将其编译到可执行文件中。如何调用configure_file:

1
configure_file(print_info.c.in print_info.c @ONLY)

第一个参数是模板的名称为print_info.c.in。CMake假设输入文件的目录,与项目的根目录相对;也就是说,在${CMAKE_CURRENT_SOURCE_DIR}/print_info.c.in。我们选择print_info.c,作为第二个参数是配置文件的名称。假设输出文件位于相对于项目构建目录的位置:${CMAKE_CURRENT_BINARY_DIR}/print_info.c

输入和输出文件作为参数时,CMake不仅将配置@VAR@变量,还将配置${VAR}变量。如果${VAR}是语法的一部分,并且不应该修改(例如在shell脚本中),那么就很不方便。为了在引导CMake,应该将选项@ONLY传递给configure_file的调用,如前所述。

记录项目版本信息以便报告

代码版本很重要,不仅是为了可重复性,还为了记录API功能或简化支持请求和bug报告。源代码通常处于某种版本控制之下,例如:可以使用Git标记附加额外版本号(参见https://semver.org )。然而,不仅需要对源代码进行版本控制,而且可执行文件还需要记录项目版本,以便将其打印到代码输出或用户界面上。

本例中,将在CMake源文件中定义版本号。我们的目标是在配置项目时将程序版本记录到头文件中。然后,生成的头文件可以包含在代码的正确位置和时间,以便将代码版本打印到输出文件或屏幕上。

准备工作

将使用以下C文件(example.c)打印版本信息:

1
2
3
4
5
6
7
8
#include "version.h"
#include <stdio.h>
int main() {
printf("This is output from code %s\n", PROJECT_VERSION);
printf("Major version number: %i\n", PROJECT_VERSION_MAJOR);
printf("Minor version number: %i\n", PROJECT_VERSION_MINOR);
printf("Hello CMake world!\n");
}

这里,假设PROJECT_VERSION_MAJORPROJECT_VERSION_MINORPROJECT_VERSION是在version.h中定义的。目标是从以下模板中生成version.h.in:

1
2
3
4
5
#pragma once
#define PROJECT_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define PROJECT_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define PROJECT_VERSION_PATCH @PROJECT_VERSION_PATCH@
#define PROJECT_VERSION "v@PROJECT_VERSION@"

这里使用预处理器定义,也可以使用字符串或整数常量来提高类型安全性(稍后我们将对此进行演示)。从CMake的角度来看,这两种方法是相同的。

如何实施

我们将按照以下步骤,在模板头文件中对版本进行注册:

要跟踪代码版本,我们可以在CMakeLists.txt中调用CMake的project时定义项目版本:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-04 VERSION 2.0.1 LANGUAGES C)

然后,基于version.h.in生成version.h:

1
2
3
4
5
configure_file(
version.h.in
generated/version.h
@ONLY
)

最后,我们定义了可执行文件,并提供了目标包含路径:

1
2
3
4
5
add_executable(example example.c)
target_include_directories(example
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/generated
)

工作原理

当使用版本参数调用CMake的project时,CMake将为项目设置PROJECT_VERSION_MAJORPROJECT_VERSION_MINORPROJECT_VERSION_PATCH。此示例中的关键命令是configure_file,它接受一个输入文件(本例中是version.h.in),通过将@之间的占位符替换成对应的CMake变量,生成一个输出文件(本例中是generate/version.h)。它将@PROJECT_VERSION_MAJOR@替换为2,以此类推。使用关键字@ONLY,我们将configure_file限制为只替换@variables@,而不修改${variables}。后一种形式在version.h.in中没有使用。但是,当使用CMake配置shell脚本时,会经常出现。

生成的头文件可以包含在示例代码中,可以打印版本信息:

1
2
3
4
5
6
7
8
9
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./example
This is output from code v2.0.1
Major version number: 2
Minor version number: 0
Hello CMake world!

NOTE:CMake以x.y.z格式给出的版本号,并将变量PROJECT_VERSION<project-name>_VERSION设置为给定的值。此外,PROJECT_VERSION_MAJOR(<project-name>_VERSION_MAJOR),PROJECT_VERSION_MINOR(<project-name>_VERSION_MINOR) PROJECT_VERSION_PATCH(<project-name>_VERSION_PATCH)和PROJECT_VERSION_TWEAK(<project-name>_VERSION_TWEAK),将分别设置为X, Y, Z和t。

配置时记录Git Hash值

大多数现代源代码存储库都使用Git作为版本控制系统进行跟踪,这可以归功于存储库托管平台GitHub的流行。因此,我们将在本示例中使用Git;然而,实际中会根据具体的动机和实现,可以转化为其他版本控制系统。我们以Git为例,提交的Git Hash决定了源代码的状态。因此,为了标记可执行文件,我们将尝试将Git Hash记录到可执行文件中,方法是将哈希字符串记录在一个头文件中,该头文件可以包含在代码中。

准备工作

我们需要两个源文件,类似于前面的示例。其中一个将配置记录的Hash(version.hpp.in),详情如下:

1
2
3
#pragma once
#include <string>
const std::string GIT_HASH = "@GIT_HASH@";

还需要一个示例源文件(example.cpp),将Hash打印到屏幕上:

1
2
3
4
5
#include "version.hpp"
#include <iostream>
int main() {
std::cout << "This code has been configured from version " << GIT_HASH << std::endl;
}

此示例还假定在Git存储库中至少有一个提交。因此,使用git init初始化这个示例,并使用git add <filename>,然后使用git commit创建提交,以便获得一个有意义的示例。

具体实施

下面演示了从Git记录版本信息的步骤:

定义项目和支持语言:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-06 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

定义GIT_HASH变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# in case Git is not available, we default to "unknown"
set(GIT_HASH "unknown")
# find Git and if available set GIT_HASH variable
find_package(Git QUIET)
if(GIT_FOUND)
execute_process(
COMMAND ${GIT_EXECUTABLE} log -1 --pretty=format:%h
OUTPUT_VARIABLE GIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
WORKING_DIRECTORY
${CMAKE_CURRENT_SOURCE_DIR}
)
endif()
message(STATUS "Git hash is ${GIT_HASH}")

CMakeLists.txt剩余的部分,类似于之前的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
# generate file version.hpp based on version.hpp.in
configure_file(
version.hpp.in
generated/version.hpp
@ONLY
)
# example code
add_executable(example example.cpp)
# needs to find the generated header file
target_include_directories(example
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/generated
)

验证输出(Hash不同):

1
2
3
4
5
6
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./example
This code has been configured from version d58c64f

工作原理

使用find_package(Git QUIET)来检测系统上是否有可用的Git。如果有(GIT_FOUND为True),运行一个Git命令:${GIT_EXECUTABLE} log -1 --pretty=format:%h。这个命令给出了当前提交Hash的简短版本。当然,这里我们可以灵活地运行Git命令。我们要求execute_process命令将结果放入名为GIT_HASH的变量中,然后删除任何尾随的空格。使用ERROR_QUIET,如果Git命令由于某种原因失败,我们不会停止配置。

由于Git命令可能会失败(源代码已经分发到Git存储库之外),或者Git在系统上不可用,我们希望为这个变量设置一个默认值,如下所示:

1
set(GIT_HASH "unknown")

构建项目

使用函数和宏重用代码

任何编程语言中,函数允许我们抽象(隐藏)细节并避免代码重复,CMake也不例外。本示例中,我们将以宏和函数为例进行讨论,并介绍一个宏,以便方便地定义测试和设置测试的顺序。我们的目标是定义一个宏,能够替换add_test和set_tests_properties,用于定义每组和设置每个测试的预期开销。

准备工作

我们将基于第4章第2节中的例子。main.cpp、sum_integers.cpp和sum_integers.hpp文件不变,用来计算命令行参数提供的整数队列的和。单元测试(test.cpp)的源代码也没有改变。我们还需要Catch 2头文件,catch.hpp。与第4章相反,我们将把源文件放到子目录中,并形成以下文件树:

1
2
3
4
5
6
7
8
9
10
├── CMakeLists.txt
├── src
│ ├── CMakeLists.txt
│ ├── main.cpp
│ ├── sum_integers.cpp
│ └── sum_integers.hpp
└── tests
├── catch.hpp
├── CMakeLists.txt
└── test.cpp

具体实施

定义了CMake最低版本、项目名称和支持的语言,并要求支持C++11标准:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

根据GNU标准定义binary和library路径:

1
2
3
4
5
6
7
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

最后,使用add_subdirectory调用src/CMakeLists.txt和tests/CMakeLists.txt:

1
2
3
add_subdirectory(src)
enable_testing()
add_subdirectory(tests)

src/CMakeLists.txt定义了源码目标:

1
2
3
4
set(CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE ON)
add_library(sum_integers sum_integers.cpp)
add_executable(sum_up main.cpp)
target_link_libraries(sum_up sum_integers)

tests/CMakeLists.txt中,构建并链接cpp_test可执行文件:

1
2
add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test sum_integers)

定义一个新宏add_catch_test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
macro(add_catch_test _name _cost)
math(EXPR num_macro_calls "${num_macro_calls} + 1")
message(STATUS "add_catch_test called with ${ARGC} arguments: ${ARGV}")
set(_argn "${ARGN}")
if(_argn)
message(STATUS "oops - macro received argument(s) we did not expect: ${ARGN}")
endif()
add_test(
NAME
${_name}
COMMAND
$<TARGET_FILE:cpp_test>
[${_name}] --success --out
${PROJECT_BINARY_DIR}/tests/${_name}.log --durations yes
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
)
set_tests_properties(
${_name}
PROPERTIES
COST ${_cost}
)
endmacro()

最后,使用add_catch_test定义了两个测试。此外,还设置和打印了变量的值:

1
2
3
4
set(num_macro_calls 0)
add_catch_test(short 1.5)
add_catch_test(long 2.5 extra_argument)
message(STATUS "in total there were ${num_macro_calls} calls to add_catch_test")

现在,进行测试。配置项目(输出行如下所示):

1
2
3
4
5
6
7
8
9
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- add_catch_test called with 2 arguments: short;1.5
-- add_catch_test called with 3 arguments: long;2.5;extra_argument
-- oops - macro received argument(s) we did not expect: extra_argument
-- in total there were 2 calls to add_catch_test
-- ...

最后,构建并运行测试:

1
2
$ cmake --build .
$ ctest

长时间的测试会先开始:

1
2
3
4
5
Start 2: long
1/2 Test #2: long ............................. Passed 0.00 sec
Start 1: short
2/2 Test #1: short ............................ Passed 0.00 sec
100% tests passed, 0 tests failed out of 2

工作原理

这个配置中的新添加了add_catch_test宏。这个宏需要两个参数_name和_cost,可以在宏中使用这些参数来调用add_test和set_tests_properties。参数前面的下划线,是为了向读者表明这些参数只能在宏中访问。另外,宏自动填充了${ARGC}(参数数量)和${ARGV}(参数列表),我们可以在输出中验证了这一点:

  • -- add_catch_test called with 2 arguments: short;1.5
  • -- add_catch_test called with 3 arguments: long;2.5;extra_argument

宏还定义了${ARGN},用于保存最后一个参数之后的参数列表。此外,我们还可以使用${ARGV0}${ARGV1}等来处理参数。我们演示一下,如何捕捉到调用中的额外参数(extra_argument):

1
add_catch_test(long 2.5 extra_argument)

我们使用了以下方法:

1
2
3
4
set(_argn "${ARGN}")
if(_argn)
message(STATUS "oops - macro received argument(s) we did not expect: ${ARGN}")
endif()

这个if语句中,我们引入一个新变量,但不能直接查询ARGN,因为它不是通常意义上的CMake变量。使用这个宏,我们可以通过它们的名称和命令来定义测试,还可以指示预期的开销,这会让耗时长的测试在耗时短测试之前启动,这要归功于COST属性。

我们可以用一个函数来实现它,而不是使用相同语法的宏:

1
2
3
function(add_catch_test _name _cost)
...
endfunction()

宏和函数之间的区别在于它们的变量范围。宏在调用者的范围内执行,而函数有自己的变量范围。换句话说,如果我们使用宏,需要设置或修改对调用者可用的变量。如果不去设置或修改输出变量,最好使用函数。我们注意到,可以在函数中修改父作用域变量,但这必须使用PARENT_SCOPE显式表示:

1
set(variable_visible_outside "some value" PARENT_SCOPE)

为了演示作用域,我们在定义宏之后编写了以下调用:

1
2
3
4
set(num_macro_calls 0)
add_catch_test(short 1.5)
add_catch_test(long 2.5 extra_argument)
message(STATUS "in total there were ${num_macro_calls} calls to add_catch_test")

在宏内部,将num_macro_calls加1:

1
math(EXPR num_macro_calls "${num_macro_calls} + 1")

这时产生的输出:

1
-- in total there were 2 calls to add_catch_test

如果我们将宏更改为函数,测试仍然可以工作,但是num_macro_calls在父范围内的所有调用中始终为0。将CMake宏想象成类似函数是很有用的,这些函数被直接替换到它们被调用的地方(在C语言中内联)。将CMake函数想象成黑盒函数很有必要。黑盒中,除非显式地将其定义为PARENT_SCOPE,否则不会返回任何内容。CMake中的函数没有返回值。

更多信息

可以在宏中嵌套函数调用,也可以在函数中嵌套宏调用,但是这就需要仔细考虑变量的作用范围。如果功能可以使用函数实现,那么这可能比宏更好,因为它对父范围状态提供了更多的默认控制。

我们还应该提到在src/cmakelist.txt中使用CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE:

1
set(CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE ON)

这个命令会将当前目录,添加到CMakeLists.txt中定义的所有目标的interface_include_directory属性中。换句话说,我们不需要使用target_include_directory来添加cpp_test所需头文件的位置。

将CMake源代码分成模块

项目通常从单个CMakeLists.txt文件开始,随着时间的推移,这个文件会逐渐增长。本示例中,我们将演示一种将CMakeLists.txt分割成更小单元的机制。将CMakeLists.txt拆分为模块有几个动机,这些模块可以包含在主CMakeLists.txt或其他模块中:

  • 主CMakeLists.txt更易于阅读。
  • CMake模块可以在其他项目中重用。
  • 与函数相结合,模块可以帮助我们限制变量的作用范围。

本示例中,我们将演示如何定义和包含一个宏,该宏允许我们获得CMake的彩色输出(用于重要的状态消息或警告)。

准备工作

本例中,我们将使用两个文件,主CMakeLists.txt和cmake/colors.cmake:

1
2
3
├── cmake
│ └── colors.cmake
└── CMakeLists.txt

cmake/colors.cmake文件包含彩色输出的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# colorize CMake output
# code adapted from stackoverflow: http://stackoverflow.com/a/19578320
# from post authored by https://stackoverflow.com/users/2556117/fraser
macro(define_colors)
if(WIN32)
# has no effect on WIN32
set(ColourReset "")
set(ColourBold "")
set(Red "")
set(Green "")
set(Yellow "")
set(Blue "")
set(Magenta "")
set(Cyan "")
set(White "")
set(BoldRed "")
set(BoldGreen "")
set(BoldYellow "")
set(BoldBlue "")
set(BoldMagenta "")
set(BoldCyan "")
set(BoldWhite "")
else()
string(ASCII 27 Esc)
set(ColourReset "${Esc}[m")
set(ColourBold "${Esc}[1m")
set(Red "${Esc}[31m")
set(Green "${Esc}[32m")
set(Yellow "${Esc}[33m")
set(Blue "${Esc}[34m")
set(Magenta "${Esc}[35m")
set(Cyan "${Esc}[36m")
set(White "${Esc}[37m")
set(BoldRed "${Esc}[1;31m")
set(BoldGreen "${Esc}[1;32m")
set(BoldYellow "${Esc}[1;33m")
set(BoldBlue "${Esc}[1;34m")
set(BoldMagenta "${Esc}[1;35m")
set(BoldCyan "${Esc}[1;36m")
set(BoldWhite "${Esc}[1;37m")
endif()
endmacro()

具体实施

来看下我们如何使用颜色定义,来生成彩色状态消息:

从一个熟悉的头部开始:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-02 LANGUAGES NONE)

然后,将cmake子目录添加到CMake模块搜索的路径列表中:

1
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

包括colors.cmake模块,调用其中定义的宏:

1
2
include(colors)
define_colors()

最后,打印了不同颜色的信息:

1
2
3
4
5
message(STATUS "This is a normal message")
message(STATUS "${Red}This is a red${ColourReset}")
message(STATUS "${BoldRed}This is a bold red${ColourReset}")
message(STATUS "${Green}This is a green${ColourReset}")
message(STATUS "${BoldMagenta}This is bold${ColourReset}")

工作原理

这个例子中,不需要编译代码,也不需要语言支持,我们已经用LANGUAGES NONE明确了这一点:

1
project(recipe-02 LANGUAGES NONE)

我们定义了define_colors宏,并将其放在cmake/colors.cmake。因为还是希望使用调用宏中定义的变量,来更改消息中的颜色,所以我们选择使用宏而不是函数。我们使用以下行包括宏和调用define_colors:

1
2
include(colors)
define_colors()

我们还需要告诉CMake去哪里查找宏:

1
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

include(colors)命令指示CMake搜索${CMAKE_MODULE_PATH},查找名称为colors.cmake的模块。

例子中,我们没有按以下的方式进行:

1
2
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
include(colors)

而是使用一个显式包含的方式:

1
include(cmake/colors.cmake)

更多信息

推荐的做法是在模块中定义宏或函数,然后调用宏或函数。将包含模块用作函数调用不是很好的方式。除了定义函数和宏以及查找程序、库和路径之外,包含模块不应该做更多的事情。实际的include命令不应该定义或修改变量,其原因是重复的include(可能是偶然的)不应该引入任何不想要的副作用。

编写函数来测试和设置编译器标志

前两个示例中,我们使用了宏。本示例中,将使用一个函数来抽象细节并避免代码重复。我们将实现一个接受编译器标志列表的函数。该函数将尝试用这些标志逐个编译测试代码,并返回编译器理解的第一个标志。这样,我们将了解几个新特性:函数、列表操作、字符串操作,以及检查编译器是否支持相应的标志。

准备工作

按照上一个示例的推荐,我们将在(set_compiler_flag.cmake)模块中定义函数,然后调用函数。该模块包含以下代码,我们将在后面详细讨论:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
include(CheckCCompilerFlag)
include(CheckCXXCompilerFlag)
include(CheckFortranCompilerFlag)
function(set_compiler_flag _result _lang)
# build a list of flags from the arguments
set(_list_of_flags)
# also figure out whether the function
# is required to find a flag
set(_flag_is_required FALSE)
foreach(_arg IN ITEMS ${ARGN})
string(TOUPPER "${_arg}" _arg_uppercase)
if(_arg_uppercase STREQUAL "REQUIRED")
set(_flag_is_required TRUE)
else()
list(APPEND _list_of_flags "${_arg}")
endif()
endforeach()
set(_flag_found FALSE)
# loop over all flags, try to find the first which works
foreach(flag IN ITEMS ${_list_of_flags})
unset(_flag_works CACHE)
if(_lang STREQUAL "C")
check_c_compiler_flag("${flag}" _flag_works)
elseif(_lang STREQUAL "CXX")
check_cxx_compiler_flag("${flag}" _flag_works)
elseif(_lang STREQUAL "Fortran")
check_Fortran_compiler_flag("${flag}" _flag_works)
else()
message(FATAL_ERROR "Unknown language in set_compiler_flag: ${_lang}")
endif()
# if the flag works, use it, and exit
# otherwise try next flag
if(_flag_works)
set(${_result} "${flag}" PARENT_SCOPE)
set(_flag_found TRUE)
break()
endif()
endforeach()
# raise an error if no flag was found
if(_flag_is_required AND NOT _flag_found)
message(FATAL_ERROR "None of the required flags were supported")
endif()
endfunction()

具体实施

展示如何在CMakeLists.txt中使用set_compiler_flag函数:

定义最低CMake版本、项目名称和支持的语言(本例中是C和C++):

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-03 LANGUAGES C CXX)

显示包含set_compiler_flag.cmake:

1
include(set_compiler_flag.cmake)

测试C标志列表:

1
2
3
4
5
6
7
8
9
10
11
set_compiler_flag(
working_compile_flag C REQUIRED
"-foo" # this should fail
"-wrong" # this should fail
"-wrong" # this should fail
"-Wall" # this should work with GNU
"-warn all" # this should work with Intel
"-Minform=inform" # this should work with PGI
"-nope" # this should fail
)
message(STATUS "working C compile flag: ${working_compile_flag}")

测试C++标志列表:

1
2
3
4
5
6
7
set_compiler_flag(
working_compile_flag CXX REQUIRED
"-foo" # this should fail
"-g" # this should work with GNU, Intel, PGI
"/RTCcsu" # this should work with MSVC
)
message(STATUS "working CXX compile flag: ${working_compile_flag}")

现在,我们可以配置项目并验证输出。只显示相关的输出,相应的输出可能会因编译器的不同而有所不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Success
-- working C compile flag: -Wall
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Success
-- working CXX compile flag: -g
-- ...

工作原理

这里使用的模式是:

  • 定义一个函数或宏,并将其放入模块中
  • 包含模块
  • 调用函数或宏

从输出中,可以看到代码检查列表中的每个标志。一旦检查成功,它就打印成功的编译标志。看看set_compiler_flag.cmake模块的内部,这个模块又包含三个模块:

1
2
3
include(CheckCCompilerFlag)
include(CheckCXXCompilerFlag)
include(CheckFortranCompilerFlag)

这都是标准的CMake模块,CMake将在${CMAKE_MODULE_PATH}中找到它们。这些模块分别提供check_c_compiler_flagcheck_cxx_compiler_flagcheck_fortran_compiler_flag宏。然后定义函数:

1
2
3
function(set_compiler_flag _result _lang)
...
endfunction()

set_compiler_flag函数需要两个参数,_result(保存成功编译标志或为空字符串)和_lang(指定语言:C、C++或Fortran)。

我们也能这样调用函数:

1
set_compiler_flag(working_compile_flag C REQUIRED "-Wall" "-warn all")

这里有五个调用参数,但是函数头只需要两个参数。这意味着REQUIRED、-Wall和-warn all将放在${ARGN}中。从${ARGN}开始,我们首先使用foreach构建一个标志列表。同时,从标志列表中过滤出REQUIRED,并使用它来设置_flag_is_required:

1
2
3
4
5
6
7
8
9
10
11
12
13
# build a list of flags from the arguments
set(_list_of_flags)
# also figure out whether the function
# is required to find a flag
set(_flag_is_required FALSE)
foreach(_arg IN ITEMS ${ARGN})
string(TOUPPER "${_arg}" _arg_uppercase)
if(_arg_uppercase STREQUAL "REQUIRED")
set(_flag_is_required TRUE)
else()
list(APPEND _list_of_flags "${_arg}")
endif()
endforeach()

现在,我们将循环${_list_of_flags},尝试每个标志,如果_flag_works被设置为TRUE,我们将_flag_found设置为TRUE,并中止进一步的搜索:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
set(_flag_found FALSE)
# loop over all flags, try to find the first which works
foreach(flag IN ITEMS ${_list_of_flags})
unset(_flag_works CACHE)
if(_lang STREQUAL "C")
check_c_compiler_flag("${flag}" _flag_works)
elseif(_lang STREQUAL "CXX")
check_cxx_compiler_flag("${flag}" _flag_works)
elseif(_lang STREQUAL "Fortran")
check_Fortran_compiler_flag("${flag}" _flag_works)
else()
message(FATAL_ERROR "Unknown language in set_compiler_flag: ${_lang}")
endif()
# if the flag works, use it, and exit
# otherwise try next flag
if(_flag_works)
set(${_result} "${flag}" PARENT_SCOPE)
set(_flag_found TRUE)
break()
endif()
endforeach()

unset(_flag_works CACHE)确保check_*_compiler_flag的结果,不会在使用_flag_works result变量时,使用的是缓存结果。

如果找到了标志,并且_flag_works设置为TRUE,我们就将_result映射到的变量:

1
set(${_result} "${flag}" PARENT_SCOPE)

这需要使用PARENT_SCOPE来完成,因为我们正在修改一个变量,希望打印并在函数体外部使用该变量。请注意,如何使用${_result}语法解引用,从父范围传递的变量_result的值。不管函数的名称是什么,这对于确保工作标志被设置非常有必要。如果没有找到任何标志,并且该标志设置了REQUIRED,那我们将使用一条错误消息停止配置:

1
2
3
4
# raise an error if no flag was found
if(_flag_is_required AND NOT _flag_found)
message(FATAL_ERROR "None of the required flags were supported")
endif()

更多信息

我们也可以使用宏来完成这个任务,而使用函数可以对范围有更多的控制。我们知道函数只能可以修改结果变量。

另外,需要在编译和链接时设置一些标志,方法是为check_<lang>_compiler_flag函数设置CMAKE_REQUIRED_FLAGS

用指定参数定义函数或宏

前面的示例中,我们研究了函数和宏,并使用了位置参数。这个示例中,我们将定义一个带有命名参数的函数。我们将复用第1节中的示例,使用函数和宏重用代码,而不是使用以下代码定义测试:add_catch_test(short 1.5)。

我们将这样调用函数:

1
2
3
4
5
6
7
8
9
add_catch_test(
NAME
short
LABELS
short
cpp_test
COST
1.5
)

准备工作

我们使用第1节中的示例,使用函数和宏重用代码,并保持C++源代码不变,文件树保持不变:

1
2
3
4
5
6
7
8
9
10
11
12
├── cmake
│ └── testing.cmake
├── CMakeLists.txt
├── src
│ ├── CMakeLists.txt
│ ├── main.cpp
│ ├── sum_integers.cpp
│ └── sum_integers.hpp
└── tests
├── catch.hpp
├── CMakeLists.txt
└── test.cpp

具体实施

我们对CMake代码进行一些修改,如下所示:

CMakeLists.txt顶部中只增加了一行,因为我们将包括位于cmake下面的模块:

1
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

保持src/CMakeLists.txt。

tests/CMakeLists.txt中,将add_catch_test函数定义移动到cmake/testing.cmake,并且定义两个测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test sum_integers)
include(testing)
add_catch_test(
NAME
short
LABELS
short
cpp_test
COST
1.5
)
add_catch_test(
NAME
long
LABELS
long
cpp_test
COST
2.5
)

add_catch_test在cmake/testing.cmake中定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
function(add_catch_test)
set(options)
set(oneValueArgs NAME COST)
set(multiValueArgs LABELS DEPENDS REFERENCE_FILES)
cmake_parse_arguments(add_catch_test
"${options}"
"${oneValueArgs}"
"${multiValueArgs}"
${ARGN}
)
message(STATUS "defining a test ...")
message(STATUS " NAME: ${add_catch_test_NAME}")
message(STATUS " LABELS: ${add_catch_test_LABELS}")
message(STATUS " COST: ${add_catch_test_COST}")
message(STATUS " REFERENCE_FILES: ${add_catch_test_REFERENCE_FILES}")
add_test(
NAME
${add_catch_test_NAME}
COMMAND
$<TARGET_FILE:cpp_test>
[${add_catch_test_NAME}] --success --out
${PROJECT_BINARY_DIR}/tests/${add_catch_test_NAME}.log --durations yes
WORKING_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}
)
set_tests_properties(${add_catch_test_NAME}
PROPERTIES
LABELS "${add_catch_test_LABELS}"
)
if(add_catch_test_COST)
set_tests_properties(${add_catch_test_NAME}
PROPERTIES
COST ${add_catch_test_COST}
)
endif()
if(add_catch_test_DEPENDS)
set_tests_properties(${add_catch_test_NAME}
PROPERTIES
DEPENDS ${add_catch_test_DEPENDS}
)
endif()
if(add_catch_test_REFERENCE_FILES)
file(
COPY
${add_catch_test_REFERENCE_FILES}
DESTINATION
${CMAKE_CURRENT_BINARY_DIR}
)
endif()
endfunction()

测试输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- defining a test ...
-- NAME: short
-- LABELS: short;cpp_test
-- COST: 1.5
-- REFERENCE_FILES:
-- defining a test ...
-- NAME: long
-- LABELS: long;cpp_test
-- COST: 2.5
-- REFERENCE_FILES:
-- ...

最后,编译并测试:

1
2
$ cmake --build .
$ ctest

工作原理

示例的特点是其命名参数,因此我们可以将重点放在cmake/testing.cmake模块上。CMake提供cmake_parse_arguments命令,我们使用函数名(add_catch_test)选项(我们的例子中是none)、单值参数(NAME和COST)和多值参数(LABELS、DEPENDS和REFERENCE_FILES)调用该命令:

1
2
3
4
5
6
7
8
9
10
11
12
function(add_catch_test)
set(options)
set(oneValueArgs NAME COST)
set(multiValueArgs LABELS DEPENDS REFERENCE_FILES)
cmake_parse_arguments(add_catch_test
"${options}"
"${oneValueArgs}"
"${multiValueArgs}"
${ARGN}
)
...
endfunction()

cmake_parse_arguments命令解析选项和参数,并在例子中定义如下:

1
2
3
4
5
add_catch_test_NAME
add_catch_test_COST
add_catch_test_LABELS
add_catch_test_DEPENDS
add_catch_test_REFERENCE_FILES

可以查询,并在函数中使用这些变量。这种方法使我们有机会用更健壮的接口和更具有可读的函数/宏调用,来实现函数和宏。

更多信息

选项关键字(本例中我们没有使用)由cmake_parse_arguments定义为TRUE或FALSE。add_catch_test函数,还提供test命令作为一个命名参数,为了更简洁的演示,我们省略了这个参数。

TIPS:cmake_parse_arguments命令在cmake 3.5的版本前中的CMakeParseArguments.cmake定义。因此,可以在CMake/test.cmake顶部的使用include(CMakeParseArguments)命令使此示例能与CMake早期版本一起工作。

重新定义函数和宏

我们已经提到模块包含不应该用作函数调用,因为模块可能被包含多次。本示例中,我们将编写我们自己的“包含保护”机制,如果多次包含一个模块,将触发警告。内置的include_guard命令从3.10版开始可以使用,对于C/C++头文件,它的行为就像#pragma一样。对于当前版本的CMake,我们将演示如何重新定义函数和宏,并且展示如何检查CMake版本,对于低于3.10的版本,我们将使用定制的“包含保护”机制。

准备工作

这个例子中,我们将使用三个文件:

1
2
3
4
5
.
├── cmake
│ ├── custom.cmake
│ └── include_guard.cmake
└── CMakeLists.txt

custom.cmake模块包含以下代码:

1
2
include_guard(GLOBAL)
message(STATUS "custom.cmake is included and processed")

我们稍后会对cmake/include_guard.cmake进行讨论。

具体实施

我们对三个CMake文件的逐步分解:

示例中,我们不会编译任何代码,因此我们的语言要求是NONE:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-05 LANGUAGES NONE)

定义一个include_guard宏,将其放在一个单独的模块中:

1
2
# (re)defines include_guard
include(cmake/include_guard.cmake)

cmake/include_guard.cmake文件包含以下内容(稍后将详细讨论):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
macro(include_guard)
if (CMAKE_VERSION VERSION_LESS "3.10")
# for CMake below 3.10 we define our
# own include_guard(GLOBAL)
message(STATUS "calling our custom include_guard")
# if this macro is called the first time
# we start with an empty list
if(NOT DEFINED included_modules)
set(included_modules)
endif()
if ("${CMAKE_CURRENT_LIST_FILE}" IN_LIST included_modules)
message(WARNING "module ${CMAKE_CURRENT_LIST_FILE} processed more than once")
endif()
list(APPEND included_modules ${CMAKE_CURRENT_LIST_FILE})
else()
# for CMake 3.10 or higher we augment
# the built-in include_guard
message(STATUS "calling the built-in include_guard")
_include_guard(${ARGV})
endif()
endmacro()

主CMakeLists.txt中,我们模拟了两次包含自定义模块的情况:

1
2
include(cmake/custom.cmake)
include(cmake/custom.cmake)

最后,使用以下命令进行配置:

1
2
3
$ mkdir -p build
$ cd build
$ cmake ..

使用CMake 3.10及更高版本的结果如下:

1
2
3
-- calling the built-in include_guard
-- custom.cmake is included and processed
-- calling the built-in include_guard

使用CMake得到3.10以下的结果如下:

1
2
3
4
5
6
7
8
9
10
- calling our custom include_guard
-- custom.cmake is included and processed
-- calling our custom include_guard
CMake Warning at cmake/include_guard.cmake:7 (message):
module
/home/user/example/cmake/custom.cmake
processed more than once
Call Stack (most recent call first):
cmake/custom.cmake:1 (include_guard)
CMakeLists.txt:12 (include)

工作原理

include_guard宏包含两个分支,一个用于CMake低于3.10,另一个用于CMake高于3.10:

1
2
3
4
5
6
7
macro(include_guard)
if (CMAKE_VERSION VERSION_LESS "3.10")
# ...
else()
# ...
endif()
endmacro()

如果CMake版本低于3.10,进入第一个分支,并且内置的include_guard不可用,所以我们自定义了一个:

1
2
3
4
5
6
7
8
9
10
message(STATUS "calling our custom include_guard")
# if this macro is called the first time
# we start with an empty list
if(NOT DEFINED included_modules)
set(included_modules)
endif()
if ("${CMAKE_CURRENT_LIST_FILE}" IN_LIST included_modules)
message(WARNING "module ${CMAKE_CURRENT_LIST_FILE} processed more than once")
endif()
list(APPEND included_modules ${CMAKE_CURRENT_LIST_FILE})

如果第一次调用宏,则included_modules变量没有定义,因此我们将其设置为空列表。然后检查${CMAKE_CURRENT_LIST_FILE}是否是included_modules列表中的元素。如果是,则会发出警告;如果没有,我们将${CMAKE_CURRENT_LIST_FILE}追加到这个列表。CMake输出中,我们可以验证自定义模块的第二个包含确实会导致警告。

CMake 3.10及更高版本的情况有所不同;在这种情况下,存在一个内置的include_guard,我们用自己的宏接收到参数并调用它:

1
2
3
4
5
6
7
8
macro(include_guard)
if (CMAKE_VERSION VERSION_LESS "3.10")
# ...
else()
message(STATUS "calling the built-in include_guard")
_include_guard(${ARGV})
endif()
endmacro()

这里,_include_guard(${ARGV})指向内置的include_guard。本例中,使用自定义消息(“调用内置的include_guard”)进行了扩展。这种模式为我们提供了一种机制,来重新定义自己的或内置的函数和宏,这对于调试或记录日志来说非常有用。

NOTE:这种模式可能很有用,但是应该谨慎使用,因为CMake不会对重新定义的宏或函数进行警告。

使用废弃函数、宏和变量

“废弃”是在不断发展的项目开发过程中一种重要机制,它向开发人员发出信号,表明将来某个函数、宏或变量将被删除或替换。在一段时间内,函数、宏或变量将继续可访问,但会发出警告,最终可能会上升为错误。

准备工作

我们将从以下CMake项目开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-06 LANGUAGES NONE)
macro(custom_include_guard)
if(NOT DEFINED included_modules)
set(included_modules)
endif()
if ("${CMAKE_CURRENT_LIST_FILE}" IN_LIST included_modules)
message(WARNING "module ${CMAKE_CURRENT_LIST_FILE} processed more than once")
endif()
list(APPEND included_modules ${CMAKE_CURRENT_LIST_FILE})
endmacro()
include(cmake/custom.cmake)
message(STATUS "list of all included modules: ${included_modules}")

这段代码定义了一个自定义的”包含保护”机制,包括一个自定义模块(与前一个示例中的模块相同),并打印所有包含模块的列表。对于CMake 3.10或更高版本有内置的include_guard。但是,不能简单地删除custom_include_guard${included_modules},而是使用一个“废弃”警告来弃用宏和变量。某个时候,可以将该警告转换为FATAL_ERROR,使代码停止配置,并迫使开发人员对代码进行修改,切换到内置命令。

具体实施

“废弃”函数、宏和变量的方法如下:

首先,定义一个函数,我们将使用它来弃用一个变量:

1
2
3
4
5
function(deprecate_variable _variable _access)
if(_access STREQUAL "READ_ACCESS")
message(DEPRECATION "variable ${_variable} is deprecated")
endif()
endfunction()

然后,如果CMake的版本大于3.9,我们重新定义custom_include_guard并将variable_watch附加到included_modules中:

1
2
3
4
5
6
7
8
9
if (CMAKE_VERSION VERSION_GREATER "3.9")
# deprecate custom_include_guard
macro(custom_include_guard)
message(DEPRECATION "custom_include_guard is deprecated - use built-in include_guard instead")
_custom_include_guard(${ARGV})
endmacro()
# deprecate variable included_modules
variable_watch(included_modules deprecate_variable)
endif()

CMake3.10以下版本的项目会产生以下结果:

1
2
3
4
5
$ mkdir -p build
$ cd build
$ cmake ..
-- custom.cmake is included and processed
-- list of all included modules: /home/user/example/cmake/custom.cmake

CMake 3.10及以上将产生预期的“废弃”警告:

1
2
3
4
5
6
7
8
9
10
11
12
CMake Deprecation Warning at CMakeLists.txt:26 (message):
custom_include_guard is deprecated - use built-in include_guard instead
Call Stack (most recent call first):
cmake/custom.cmake:1 (custom_include_guard)
CMakeLists.txt:34 (include)
-- custom.cmake is included and processed
CMake Deprecation Warning at CMakeLists.txt:19 (message):
variable included_modules is deprecated
Call Stack (most recent call first):
CMakeLists.txt:9999 (deprecate_variable)
CMakeLists.txt:36 (message)
-- list of all included modules: /home/user/example/cmake/custom.cmake

工作原理

弃用函数或宏相当于重新定义它,如前面的示例所示,并使用DEPRECATION打印消息:

1
2
3
4
macro(somemacro)
message(DEPRECATION "somemacro is deprecated")
_somemacro(${ARGV})
endmacro()

可以通过定义以下变量来实现对变量的弃用:

1
2
3
4
5
function(deprecate_variable _variable _access)
if(_access STREQUAL "READ_ACCESS")
message(DEPRECATION "variable ${_variable} is deprecated")
endif()
endfunction()

然后,这个函数被添加到将要“废弃”的变量上:

1
variable_watch(somevariable deprecate_variable)

如果在本例中${included_modules}是读取 (READ_ACCESS),那么deprecate_variable函数将发出带有DEPRECATION的消息。

语言混合项目

使用C/C++库构建Fortran项目

本示例将展示如何用C系统库和自定义C代码来对接Fortran代码。

准备工作

第7章中,我们把项目结构列为一个树。每个子目录都有一个CMakeLists.txt文件,其中包含与该目录相关的指令。这使我们可以对子目录进行限制中,如这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
.
├── CMakeLists.txt
└── src
├── bt-randomgen-example.f90
├── CMakeLists.txt
├── interfaces
│ ├── CMakeLists.txt
│ ├── interface_backtrace.f90
│ ├── interface_randomgen.f90
│ └── randomgen.c
└── utils
├── CMakeLists.txt
└── util_strings.f90

我们的例子中,src子目录中包括bt-randomgen-example.f90,会将源码编译成可执行文件。另外两个子目录interface和utils包含更多的源代码,这些源代码将被编译成库。

interfaces子目录中的源代码展示了如何包装向后追踪的C系统库。例如,interface_backtrace.f90:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module interface_backtrace
implicit none
interface
function backtrace(buffer, size) result(bt) bind(C, name="backtrace")
use, intrinsic :: iso_c_binding, only: c_int, c_ptr
type(c_ptr) :: buffer
integer(c_int), value :: size
integer(c_int) :: bt
end function
subroutine backtrace_symbols_fd(buffer, size, fd) bind(C, name="backtrace_symbols_fd")
use, intrinsic :: iso_c_binding, only: c_int, c_ptr
type(c_ptr) :: buffer
integer(c_int), value :: size, fd
end subroutine
end interface
end module

上面的例子演示了:

  • 内置iso_c_binding模块,确保Fortran和C类型和函数的互操作性。
  • interface声明,将函数在单独库中绑定到相应的符号上。
  • bind(C)属性,为声明的函数进行命名修饰。

这个子目录还包含两个源文件:

  • randomgen.c:这是一个C源文件,它对外公开了一个函数,使用C标准rand函数在一个区间内生成随机整数。
  • interface_randomgen.f90:它将C函数封装在Fortran可执行文件中使用。

具体实施

我们有4个CMakeLists.txt实例要查看——根目录下1个,子目录下3个。让我们从根目录的CMakeLists.txt开始:

声明一个Fortran和C的混合语言项目:

1
2
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-01 LANGUAGES Fortran C)

CMake将静态库和动态库保存在build目录下的lib目录中。可执行文件保存在bin目录下,Fortran编译模块文件保存在modules目录下:

1
2
3
4
5
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/lib)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/bin)
set(CMAKE_Fortran_MODULE_DIRECTORY
${CMAKE_CURRENT_BINARY_DIR}/modules)

接下来,我们进入第一个子CMakeLists.txt,添加src子目录:

1
add_subdirectory(src)

src/CMakeLists.txt文件添加了两个子目录:

1
2
add_subdirectory(interfaces)
add_subdirectory(utils)

在interfaces子目录中,我们将执行以下操作:

包括FortranCInterface.cmak模块,并验证C和Fortran编译器可以正确地交互:

1
2
include(FortranCInterface)
FortranCInterface_VERIFY()

接下来,我们找到Backtrace系统库,因为我们想在Fortran代码中使用它:
1
find_package(Backtrace REQUIRED)

然后,创建一个共享库目标,其中包含Backtrace包装器、随机数生成器,以及Fortran包装器的源文件:

1
2
3
4
5
6
7
add_library(bt-randomgen-wrap SHARED "")
target_sources(bt-randomgen-wrap
PRIVATE
interface_backtrace.f90
interface_randomgen.f90
randomgen.c
)

我们还为新生成的库目标设置了链接库。使用PUBLIC属性,以便连接到其他目标时,能正确地看到依赖关系:

1
2
3
4
target_link_libraries(bt-randomgen-wrap
PUBLIC
${Backtrace_LIBRARIES}
)

utils子目录中,还有一个CMakeLists.txt,其只有一单行程序:我们创建一个新的库目标,子目录中的源文件将被编译到这个目标库中。并与这个目标没有依赖关系:

1
add_library(utils SHARED util_strings.f90)

回到src/CMakeLists.txt:

使用bt-randomgen-example.f90添加一个可执行目标:

1
add_executable(bt-randomgen-example bt-randomgen-example.f90)

最后,将在子CMakeLists.txt中生成的库目标,并链接到可执行目标:

1
2
3
4
5
target_link_libraries(bt-randomgen-example
PRIVATE
bt-randomgen-wrap
utils
)

工作原理

确定链接了正确库之后,需要保证程序能够正确调用函数。每个编译器在生成机器码时都会执行命名检查。不过,这种操作的约定不是通用的,而是与编译器相关的。FortranCInterface,我们已经在第3章第4节时,检查所选C编译器与Fortran编译器的兼容性。对于当前的目的,命名检查并不是一个真正的问题。Fortran 2003标准提供了可选name参数的函数和子例程定义了bind属性。如果提供了这个参数,编译器将使用程序员指定的名称为这些子例程和函数生成符号。例如,backtrace函数可以从C语言中暴露给Fortran,并保留其命名:

1
function backtrace(buffer, size) result(bt) bind(C, name="backtrace")

更多信息

interface/CMakeLists.txt中的CMake代码还表明,可以使用不同语言的源文件创建库。CMake能够做到以下几点:

  • 列出的源文件中获取目标文件,并识别要使用哪个编译器。
  • 选择适当的链接器,以便构建库(或可执行文件)。

CMake如何决定使用哪个编译器?在project命令时使用参数LANGUAGES指定,这样CMake会检查系统上给定语言编译器。当使用源文件列表添加目标时,CMake将根据文件扩展名选择适当地编译器。因此,以.c结尾的文件使用C编译器编译,而以.f90结尾的文件(如果需要预处理,可以使用.F90)将使用Fortran编译器编译。类似地,对于C++, .cpp或.cxx扩展将触发C++编译器。我们只列出了C/C++和Fortran语言的一些可能的、有效的文件扩展名,但是CMake可以识别更多的扩展名。如果您的项目中的文件扩展名,由于某种原因不在可识别的扩展名之列,该怎么办?源文件属性可以用来告诉CMake在特定的源文件上使用哪个编译器,就像这样:

1
2
3
4
set_source_files_properties(my_source_file.axx
PROPERTIES
LANGUAGE CXX
)

那链接器呢?CMake如何确定目标的链接器语言?对于不混合编程语言的目标很简单:通过生成目标文件的编译器命令调用链接器即可。如果目标混合了多个语言,就像示例中一样,则根据在语言混合中,优先级最高的语言来选择链接器语言。比如,我们的示例中混合了Fortran和C,因此Fortran语言比C语言具有更高的优先级,因此使用Fortran用作链接器语言。当混合使用Fortran和C++时,后者具有更高的优先级,因此C++被用作链接器语言。就像编译器语言一样,我们可以通过目标相应的LINKER_LANGUAGE属性,强制CMake为我们的目标使用特定的链接器语言:

1
2
3
4
set_target_properties(my_target
PROPERTIES
LINKER_LANGUAGE Fortran
)

使用Fortran库构建C/C++项目

第3章第4节,展示了如何检测Fortran编写的BLAS和LAPACK线性代数库,以及如何在C++代码中使用它们。这里,将重新讨论这个方式,但这次的角度有所不同:较少地关注检测外部库,会更深入地讨论混合C++和Fortran的方面,以及名称混乱的问题。

准备工作

本示例中,我们将重用第3章第4节源代码。虽然,我们不会修改源码或头文件,但我们会按照第7章“结构化项目”中,讨论的建议修改项目树结构,并得到以下源代码结构:

1
2
3
4
5
6
7
8
9
10
11
12
.
├── CMakeLists.txt
├── README.md
└── src
├── CMakeLists.txt
├── linear-algebra.cpp
└── math
├── CMakeLists.txt
├── CxxBLAS.cpp
├── CxxBLAS.hpp
├── CxxLAPACK.cpp
└── CxxLAPACK.hpp

这里,收集了BLAS和LAPACK的所有包装器,它们提供了src/math下的数学库了,主要程序为linear-algebra.cpp。因此,所有源都在src子目录下。我们还将CMake代码分割为三个CMakeLists.txt文件,现在来讨论这些文件。

具体实施

这个项目混合了C++(作为该示例的主程序语言)和C(封装Fortran子例程所需的语言)。在根目录下的CMakeLists.txt文件中,我们需要做以下操作:

声明一个混合语言项目,并选择C++标准:

1
2
3
4
5
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
project(recipe-02 LANGUAGES CXX C Fortran)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

使用GNUInstallDirs模块来设置CMake将静态和动态库,以及可执行文件保存的标准目录。我们还指示CMake将Fortran编译的模块文件放在modules目录下:

1
2
3
4
5
6
7
8
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
set(CMAKE_Fortran_MODULE_DIRECTORY ${PROJECT_BINARY_DIR}/modules)

然后,进入下一个子目录:

1
add_subdirectory(src)

子文件src/CMakeLists.txt添加了另一个目录math,其中包含线性代数包装器。在src/math/CMakeLists.txt中,我们需要以下操作:

调用find_package来获取BLAS和LAPACK库的位置:

1
2
find_package(BLAS REQUIRED)
find_package(LAPACK REQUIRED)

包含FortranCInterface.cmake模块,并验证Fortran、C和C++编译器是否兼容:

1
2
include(FortranCInterface)
FortranCInterface_VERIFY(CXX)

我们还需要生成预处理器宏来处理BLAS和LAPACK子例程的名称问题。同样,FortranCInterface通过在当前构建目录中生成一个名为fc_mangl.h的头文件来提供协助:

1
2
3
4
5
FortranCInterface_HEADER(
fc_mangle.h
MACRO_NAMESPACE "FC_"
SYMBOLS DSCAL DGESV
)

接下来,添加了一个库,其中包含BLAS和LAPACK包装器的源代码。我们还指定要找到头文件和库的目录。注意PUBLIC属性,它允许其他依赖于math的目标正确地获得它们的依赖关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
add_library(math "")
target_sources(math
PRIVATE
CxxBLAS.cpp
CxxLAPACK.cpp
)
target_include_directories(math
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_BINARY_DIR}
)
target_link_libraries(math
PUBLIC
${LAPACK_LIBRARIES}
)

回到src/CMakeLists.txt,我们最终添加了一个可执行目标,并将其链接到BLAS/LAPACK包装器的数学库:

1
2
3
4
5
6
7
8
9
add_executable(linear-algebra "")
target_sources(linear-algebra
PRIVATE
linear-algebra.cpp
)
target_link_libraries(linear- algebra
PRIVATE
math
)

工作原理

使用find_package确定了要链接到的库。方法和之前一样,需要确保程序能够正确地调用它们定义的函数。第3章第4节中,我们面临的问题是编译器的名称符号混乱。我们使用FortranCInterface模块来检查所选的C和C++编译器与Fortran编译器的兼容性。我们还使用FortranCInterface_HEADER函数生成带有宏的头文件,以处理Fortran子例程的名称混乱。并通过以下代码实现:

1
2
3
4
5
FortranCInterface_HEADER(
fc_mangle.h
MACRO_NAMESPACE "FC_"
SYMBOLS DSCAL DGESV
)

这个命令将生成fc_mangl.h头文件,其中包含从Fortran编译器推断的名称混乱宏,并将其保存到当前二进制目录CMAKE_CURRENT_BINARY_DIR中。我们小心地将CMAKE_CURRENT_BINARY_DIR设置为数学目标的包含路径。生成的fc_mangle.h如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef FC_HEADER_INCLUDED
#define FC_HEADER_INCLUDED
/* Mangling for Fortran global symbols without underscores. */
#define FC_GLOBAL(name,NAME) name##_
/* Mangling for Fortran global symbols with underscores. */
#define FC_GLOBAL_(name,NAME) name##_
/* Mangling for Fortran module symbols without underscores. */
#define FC_MODULE(mod_name,name, mod_NAME,NAME) __##mod_name##_MOD_##name
/* Mangling for Fortran module symbols with underscores. */
#define FC_MODULE_(mod_name,name, mod_NAME,NAME) __##mod_name##_MOD_##name
/* Mangle some symbols automatically. */
#define DSCAL FC_GLOBAL(dscal, DSCAL)
#define DGESV FC_GLOBAL(dgesv, DGESV)
#endif

本例中的编译器使用下划线进行错误处理。由于Fortran不区分大小写,子例程可能以小写或大写出现,这就说明将这两种情况传递给宏的必要性。注意,CMake还将为隐藏在Fortran模块后面的符号生成宏。

NOTE:现在,BLAS和LAPACK的许多实现都在Fortran子例程附带了一个C的包装层。这些包装器已经标准化,分别称为CBLAS和LAPACKE。

由于已经将源组织成库目标和可执行目标,所以我们应该对目标的PUBLIC、INTERFACE和PRIVATE可见性属性的使用进行评论。与源文件一样,包括目录、编译定义和选项,当与target_link_libraries一起使用时,这些属性的含义是相同的:

  • 使用PRIVATE属性,库将只链接到当前目标,而不链接到使用它的任何其他目标。
  • 使用INTERFACE属性,库将只链接到使用当前目标作为依赖项的目标。
  • 使用PUBLIC属性,库将被链接到当前目标,以及将其作为依赖项使用的任何其他目标。

编写安装程序

安装项目

第一个示例中,将介绍我们的小项目和一些基本概念,这些概念也将在后面的示例中使用。安装文件、库和可执行文件是一项非常基础的任务,但是也可能会带来一些缺陷。我们将带您了解这些问题,并展示如何使用CMake有效地避开这些缺陷。

准备工作

第1章第3节的示例,几乎复用:只添加对UUID库的依赖。这个依赖是有条件的,如果没有找到UUID库,我们将通过预处理程序排除使用UUID库的代码。项目布局如下:

1
2
3
4
5
6
7
8
9
.
├── CMakeLists.txt
├── src
│ ├── CMakeLists.txt
│ ├── hello-world.cpp
│ ├── Message.cpp
│ └── Message.hpp
└── tests
└── CMakeLists.txt

我们已经看到,有三个CMakeLists.txt,一个是主CMakeLists.txt,另一个是位于src目录下的,还有一个是位于test目录下的。

Message.hpp头文件包含以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#pragma once
#include <iosfwd>
#include <string>
class Message
{
public:
Message(const std::string &m) : message_(m) {}
friend std::ostream &operator<<(std::ostream &os, Message &obj)
{
return obj.printObject(os);
}
private:
std::string message_;
std::ostream &printObject(std::ostream &os);
};
std::string getUUID();

Message.cpp中有相应的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include "Message.hpp"
#include <iostream>
#include <string>
#ifdef HAVE_UUID
#include <uuid/uuid.h>
#endif
std::ostream &Message::printObject(std::ostream &os)
{
os << "This is my very nice message: " << std::endl;
os << message_ << std::endl;
os << "...and here is its UUID: " << getUUID();
return os;
}
#ifdef HAVE_UUID
std::string getUUID()
{
uuid_t uuid;
uuid_generate(uuid);
char uuid_str[37];
uuid_unparse_lower(uuid, uuid_str);
uuid_clear(uuid);
std::string uuid_cxx(uuid_str);
return uuid_cxx;
}
#else
std::string getUUID()
{
return "Ooooops, no UUID for you!";
}
#endif
最后,示例hello-world.cpp内容如下:

#include <cstdlib>
#include <iostream>
#include "Message.hpp"
int main()
{
Message say_hello("Hello, CMake World!");
std::cout << say_hello << std::endl;
Message say_goodbye("Goodbye, CMake World");
std::cout << say_goodbye << std::endl;
return EXIT_SUCCESS;
}

具体实施

我们先来看一下主CMakeLists.txt:

声明CMake最低版本,并定义一个C++11项目。请注意,我们已经为我们的项目设置了一个版本,在project中使用VERSION进行指定:

1
2
3
4
5
6
7
8
9
10
11
# CMake 3.6 needed for IMPORTED_TARGET option
# to pkg_search_module
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)
project(recipe-01
LANGUAGES CXX
VERSION 1.0.0
)
# <<< General set up >>>
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

用户可以通过CMAKE_INSTALL_PREFIX变量定义安装目录。CMake会给这个变量设置一个默认值:Windows上的C:\Program Files和Unix上的/usr/local。我们将会打印安装目录的信息:

1
message(STATUS "Project will be installed to ${CMAKE_INSTALL_PREFIX}")

默认情况下,我们更喜欢以Release的方式配置项目。用户可以通过CMAKE_BUILD_TYPE设置此变量,从而改变配置类型,我们将检查是否存在这种情况。如果没有,将设置为默认值:

1
2
3
4
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
message(STATUS "Build type set to ${CMAKE_BUILD_TYPE}")

接下来,告诉CMake在何处构建可执行、静态和动态库目标。便于在用户不打算安装项目的情况下,访问这些构建目标。这里使用标准CMake的GNUInstallDirs.cmake模块。这将确保的项目布局的合理性和可移植性:

1
2
3
4
5
6
7
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

虽然,前面的命令配置了构建目录中输出的位置,但是需要下面的命令来配置可执行程序、库以及安装前缀中包含的文件的位置。它们大致遵循相同的布局,但是我们定义了新的INSTALL_LIBDIRINSTALL_BINDIRINSTALL_INCLUDEDIRINSTALL_CMAKEDIR变量。当然,也可以覆盖这些变量:

1
2
3
4
5
6
7
8
9
10
# Offer the user the choice of overriding the installation directories
set(INSTALL_LIBDIR ${CMAKE_INSTALL_LIBDIR} CACHE PATH "Installation directory for libraries")
set(INSTALL_BINDIR ${CMAKE_INSTALL_BINDIR} CACHE PATH "Installation directory for executables")
set(INSTALL_INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR} CACHE PATH "Installation directory for header files")
if(WIN32 AND NOT CYGWIN)
set(DEF_INSTALL_CMAKEDIR CMake)
else()
set(DEF_INSTALL_CMAKEDIR share/cmake/${PROJECT_NAME})
endif()
set(INSTALL_CMAKEDIR ${DEF_INSTALL_CMAKEDIR} CACHE PATH "Installation directory for CMake files")

报告组件安装的路径:

1
2
3
4
5
6
# Report to user
foreach(p LIB BIN INCLUDE CMAKE)
file(TO_NATIVE_PATH ${CMAKE_INSTALL_PREFIX}/${INSTALL_${p}DIR} _path )
message(STATUS "Installing ${p} components to ${_path}")
unset(_path)
endforeach()

主CMakeLists.txt文件中的最后一个指令添加src子目录,启用测试,并添加tests子目录:

1
2
3
add_subdirectory(src)
enable_testing()
add_subdirectory(tests)

现在我们继续分析src/CMakeLists.txt,其定义了构建的实际目标:

我们的项目依赖于UUID库:

1
2
3
4
5
6
7
8
9
# Search for pkg-config and UUID
find_package(PkgConfig QUIET)
if(PKG_CONFIG_FOUND)
pkg_search_module(UUID uuid IMPORTED_TARGET)
if(TARGET PkgConfig::UUID)
message(STATUS "Found libuuid")
set(UUID_FOUND TRUE)
endif()
endif()

我们希望建立一个动态库,将该目标声明为message-shared:

1
add_library(message-shared SHARED "")

这个目标由target_sources命令指定:

1
2
3
4
target_sources(message-shared
PRIVATE
${CMAKE_CURRENT_LIST_DIR}/Message.cpp
)

我们为目标声明编译时定义和链接库。请注意,所有这些都是PUBLIC,以确保所有依赖的目标将正确继承它们:

1
2
3
4
5
6
7
8
  target_compile_definitions(message-shared
PUBLIC
$<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
)
target_link_libraries(message-shared
PUBLIC
$<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
)

然后设置目标的附加属性:

1
2
3
4
5
6
7
8
9
10
set_target_properties(message-shared
PROPERTIES
POSITION_INDEPENDENT_CODE 1
SOVERSION ${PROJECT_VERSION_MAJOR}
OUTPUT_NAME "message"
DEBUG_POSTFIX "_d"
PUBLIC_HEADER "Message.hpp"
MACOSX_RPATH ON
WINDOWS_EXPORT_ALL_SYMBOLS ON
)

最后,为“Hello, world”程序添加可执行目标:

1
add_executable(hello-world_wDSO hello-world.cpp)

hello-world_wDSO可执行目标,会链接到动态库:

1
2
3
4
target_link_libraries(hello-world_wDSO
PUBLIC
message-shared
)

src/CMakeLists.txt文件中,还包含安装指令。考虑这些之前,我们需要设置可执行文件的RPATH:

使用CMake路径操作,我们可以设置message_RPATH变量。这将为GNU/Linux和macOS设置适当的RPATH:

1
2
3
4
5
6
7
8
RPATH
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
set(_rpath "@loader_path/${_rel}")
else()
set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${INSTALL_LIBDIR}" message_RPATH)

现在,可以使用这个变量来设置可执行目标hello-world_wDSO的RPATH(通过目标属性实现)。我们也可以设置额外的属性,稍后会对此进行更多的讨论:

1
2
3
4
5
6
7
8
set_target_properties(hello-world_wDSO
PROPERTIES
MACOSX_RPATH ON
SKIP_BUILD_RPATH OFF
BUILD_WITH_INSTALL_RPATH OFF
INSTALL_RPATH "${message_RPATH}"
INSTALL_RPATH_USE_LINK_PATH ON
)

终于可以安装库、头文件和可执行文件了!使用CMake提供的install命令来指定安装位置。注意,路径是相对的,我们将在后续进一步讨论这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
install(
TARGETS
message-shared
hello-world_wDSO
ARCHIVE
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
RUNTIME
DESTINATION ${INSTALL_BINDIR}
COMPONENT bin
LIBRARY
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
PUBLIC_HEADER
DESTINATION ${INSTALL_INCLUDEDIR}/message
COMPONENT dev
)

tests目录中的CMakeLists.txt文件包含简单的指令,以确保“Hello, World”可执行文件能够正确运行:

1
2
3
4
add_test(
NAME test_shared
COMMAND $<TARGET_FILE:hello-world_wDSO>
)

现在让我们配置、构建和安装项目,并查看结果。添加安装指令时,CMake就会生成一个名为install的新目标,该目标将运行安装规则:

1
2
3
4
$ mkdir -p build
$ cd build
$ cmake -G"Unix Makefiles" -DCMAKE_INSTALL_PREFIX=$HOME/Software/recipe-01
$ cmake --build . --target install

GNU/Linux构建目录的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
build
├── bin
│ └── hello-world_wDSO
├── CMakeCache.txt
├── CMakeFiles
├── cmake_install.cmake
├── CTestTestfile.cmake
├── install_manifest.txt
├── lib64
│ ├── libmessage.so -> libmessage.so.1
│ └── libmessage.so.1
├── Makefile
├── src
├── Testing
└── tests

另一方面,在安装位置,可以找到如下的目录结构:

1
2
3
4
5
6
7
8
9
$HOME/Software/recipe-01/
├── bin
│ └── hello-world_wDSO
├── include
│ └── message
│ └── Message.hpp
└── lib64
├── libmessage.so -> libmessage.so.1
└── libmessage.so.1

这意味着安装指令中给出的位置,是相对于用户给定的CMAKE_INSTALL_PREFIX路径。

工作原理

这个示例有三个要点我们需要更详细地讨论:

  • 使用GNUInstallDirs.cmake定义目标安装的标准位置
  • 在动态库和可执行目标上设置的属性,特别是RPATH的处理
  • 安装指令

GNUInstallDirs.cmake模块所做的就是定义这样一组变量,这些变量是安装不同类型文件的子目录的名称。在例子中,使用了以下内容:

  • *CMAKE_INSTALL_BINDIR:这将用于定义用户可执行文件所在的子目录,即所选安装目录下的bin目录。
  • CMAKE_INSTALL_LIBDIR:这将扩展到目标代码库(即静态库和动态库)所在的子目录。在64位系统上,它是lib64,而在32位系统上,它只是lib。
  • CMAKE_INSTALL_INCLUDEDIR:最后,我们使用这个变量为C头文件获取正确的子目录,该变量为include。

然而,用户可能希望覆盖这些选项。我们允许在主CMakeLists.txt文件中使用以下方式覆盖选项:

1
2
3
4
5
6
7
8
# Offer the user the choice
of overriding the installation directories
set(INSTALL_LIBDIR ${CMAKE_INSTALL_LIBDIR} CACHE PATH
"Installation directory for libraries")
set(INSTALL_BINDIR ${CMAKE_INSTALL_BINDIR} CACHE PATH
"Installation directory for executables")
set(INSTALL_INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR} CACHE
PATH "Installation directory for header files")

这重新定义了在项目中使用的INSTALL_BINDIRINSTALL_LIBDIRINSTALL_INCLUDEDIR变量。我们还定义了INSTALL_CMAKEDIR变量,但它所扮演的角色将在接下来的几个示例中详细讨论。

在动态库目标上设置的属性,需要设置以下内容:

  • POSITION_INDEPENDENT_CODE 1:设置生成位置无关代码所需的编译器标志。
  • SOVERSION ${PROJECT_VERSION_MAJOR} : 这是动态库提供的应用程序编程接口(API)版本。在设置语义版本之后,将其设置为与项目的主版本一致。CMake目标也有一个版本属性,可以用来指定目标的构建版本。注意,SOVERSION和VERSION有所不同:随着时间的推移,提供相同API的多个构建版本。本例中,我们不关心这种的粒度控制:仅使用SOVERSION属性设置API版本就足够了,CMake将为我们将VERSION设置为相同的值。
  • OUTPUT_NAME "message":这告诉CMake库的名称message,而不是目标message-shared的名称,libmessage.so.1将在构建时生成。从前面给出的构建目录和安装目录的也可以看出,libmessage.so的符号链接也将生成。
  • DEBUG_POSTFIX "_d":这告诉CMake,如果我们以Debug配置构建项目,则将_d后缀添加到生成的动态库。
  • PUBLIC_HEADER "Message.hpp":我们使用这个属性来设置头文件列表(本例中只有一个头文件),声明提供的API函数。这主要用于macOS上的动态库目标,也可以用于其他操作系统和目标。
  • MACOSX_RPATH ON:这将动态库的install_name部分(目录)设置为macOS上的@rpath。
  • WINDOWS_EXPORT_ALL_SYMBOLS ON:这将强制在Windows上编译以导出所有符号。注意,这通常不是一个好的方式,我们将在第2节中展示如何生成导出头文件,以及如何在不同的平台上保证符号的可见性。

现在讨论一下RPATH。我们将hello-world_wDSO可执行文件链接到libmessage.so.1,这意味着在执行时,将加载动态库。因此,有关库位置的信息需要在某个地方进行编码,以便加载程序能够成功地完成其工作。

GNU/Linux上,库的定位需要将路径附加到LD_LIBRARY_PATH环境变量中。注意,这很可能会污染系统中所有应用程序的链接器路径,并可能导致符号冲突。

设置动态对象的RPATH时,应该选择哪个路径?我们需要确保可执行文件总是找到正确的动态库,不管它是在构建树中运行还是在安装树中运行。这需要通过设置hello-world_wDSO目标的RPATH相关属性来实现的,通过$ORIGIN(在GNU/Linux上)变量来查找与可执行文件本身位置相关的路径:

1
2
3
4
5
6
7
8
# Prepare RPATH
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
set(_rpath "@loader_path/${_rel}")
else()
set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${INSTALL_LIBDIR}" message_RPATH)

当设置了message_RPATH变量,目标属性将完成剩下的工作:

1
2
3
4
5
6
7
8
set_target_properties(hello-world_wDSO
PROPERTIES
MACOSX_RPATH ON
SKIP_BUILD_RPATH OFF
BUILD_WITH_INSTALL_RPATH OFF
INSTALL_RPATH "${message_RPATH}"
INSTALL_RPATH_USE_LINK_PATH ON
)

让我们详细研究一下这个命令:

  • SKIP_BUILD_RPATH OFF:告诉CMake生成适当的RPATH,以便能够在构建树中运行可执行文件。
  • UILD_WITH_INSTALL_RPATH OFF:关闭生成可执行目标,使其RPATH调整为与安装树的RPATH相同。在构建树中不运行可执行文件。
  • INSTALL_RPATH "${message_RPATH}":将已安装的可执行目标的RPATH设置为先前的路径。
  • INSTALL_RPATH_USE_LINK_PATH ON:告诉CMake将链接器搜索路径附加到可执行文件的RPATH中。

最后,看一下安装指令。我们需要安装一个可执行文件、一个库和一个头文件。可执行文件和库是构建目标,因此我们使用安装命令的TARGETS选项。可以同时设置多个目标的安装规则:CMake知道它们是什么类型的目标,无论其是可执行程序库、动态库,还是静态库:

1
2
3
4
install(
TARGETS
message-shared
hello-world_wDSO

可执行文件将安装在RUNTIME DESTINATION,将其设置为${INSTALL_BINDIR}。动态库安装到LIBRARY_DESTINATION,将其设置为${INSTALL_LIBDIR}。静态库将安装到ARCHIVE DESTINATION,将其设置为${INSTALL_LIBDIR}:

1
2
3
4
5
6
7
8
9
ARCHIVE
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
RUNTIME
DESTINATION ${INSTALL_BINDIR}
COMPONENT bin
LIBRARY
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib

注意,这里不仅指定了DESTINATION,还指定了COMPONENT。使用cmake —build . —target install安装命令,所有组件会按预期安装完毕。然而,有时只安装其中一些可用的。这就是COMPONENT关键字帮助我们做的事情。例如,当只要求安装库,我们可以执行以下步骤:

1
$ cmake -D COMPONENT=lib -P cmake_install.cmake

自从Message.hpp头文件设置为项目的公共头文件,我们可以使用PUBLIC_HEADER关键字将其与其他目标安装到选择的目的地:${INSTALL_INCLUDEDIR}/message。库用户现在可以包含头文件:#include ,这需要在编译时,使用-I选项将正确的头文件查找路径位置传递给编译器。

安装指令中的各种目标地址会被解释为相对路径,除非使用绝对路径。但是相对于哪里呢?根据不同的安装工具而不同,而CMake可以去计算目标地址的绝对路径。当使用cmake --build . --target install,路径将相对于CMAKE_INSTALL_PREFIX计算。但当使用CPack时,绝对路径将相对于CPACK_PACKAGING_INSTALL_PREFIX计算。CPack的用法将在第11章中介绍。

生成输出头文件

设想一下,当我们的小型库非常受欢迎时,许多人都在使用它。然而,一些客户希望在安装时使用静态库,而另一些客户也注意到所有符号在动态库中都是可见的。最佳方式是规定动态库只公开最小的符号,从而限制代码中定义的对象和函数对外的可见性。我们希望在默认情况下,动态库定义的所有符号都对外隐藏。这将使得项目的贡献者,能够清楚地划分库和外部代码之间的接口,因为他们必须显式地标记所有要在项目外部使用的符号。因此,我们需要完成以下工作:

  • 使用同一组源文件构建动态库和静态库
  • 确保正确分隔动态库中符号的可见性

准备工作

我们仍将使用与前一个示例中基本相同的代码,但是我们需要修改src/CMakeLists.txt和Message.hpp头文件。后者将包括新的、自动生成的头文件messageExport.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#pragma once
#include
#include
#include "messageExport.h"
class message_EXPORT Message
{
public:
Message(const std::string &m) : message_(m) {}
friend std::ostream &operator<<(std::ostream &os, Message &obj)
{
return obj.printObject(os);
}
private:
std::string message_;
std::ostream &printObject(std::ostream &os);
};
std::string getUUID();

Message类的声明中引入了message_EXPORT预处理器指令,这个指令将让编译器生成对库的用户可见的符号。

具体实施

除了项目的名称外,主CMakeLists.txt文件没有改变。首先,看看src子目录中的CMakeLists.txt文件,所有工作实际上都在这里进行。我们将重点展示对之前示例的修改之处:

为消息传递库声明SHARED库目标及其源。注意,编译定义和链接库没有改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
add_library(message-shared SHARED "")
target_sources(message-shared
PRIVATE
${CMAKE_CURRENT_LIST_DIR}/Message.cpp
)
target_compile_definitions(message-shared
PUBLIC
$<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
)
target_link_libraries(message-shared
PUBLIC
$<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
)

设置目标属性。将${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h头文件添加到公共头列表中,作为PUBLIC_HEADER目标属性的参数。CXX_VISIBILITY_PRESET置和VISIBILITY_INLINES_HIDDEN属性将在下一节中讨论:

1
2
3
4
5
6
7
8
9
10
11
set_target_properties(message-shared
PROPERTIES
POSITION_INDEPENDENT_CODE 1
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN 1
SOVERSION ${PROJECT_VERSION_MAJOR}
OUTPUT_NAME "message"
DEBUG_POSTFIX "_d"
PUBLIC_HEADER "Message.hpp;${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
MACOSX_RPATH ON
)

包含GenerateExportHeader.cmake模块并调用generate_export_header函数,这将在构建目录的子目录中生成messageExport.h头文件。我们将稍后会详细讨论这个函数和生成的头文件:

1
2
3
4
5
6
7
8
9
10
11
include(GenerateExportHeader)
generate_export_header(message-shared
BASE_NAME "message"
EXPORT_MACRO_NAME "message_EXPORT"
EXPORT_FILE_NAME "${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
DEPRECATED_MACRO_NAME "message_DEPRECATED"
NO_EXPORT_MACRO_NAME "message_NO_EXPORT"
STATIC_DEFINE "message_STATIC_DEFINE"
NO_DEPRECATED_MACRO_NAME "message_NO_DEPRECATED"
DEFINE_NO_DEPRECATED
)

当要更改符号的可见性(从其默认值-隐藏值)时,都应该包含导出头文件。我们已经在Message.hpp头文件例这样做了,因为想在库中公开一些符号。现在将${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}目录作为message-shared目标的PUBLIC包含目录列出:

1
2
3
4
target_include_directories(message-shared
PUBLIC
${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}
)

现在,可以将注意力转向静态库的生成:

添加一个库目标来生成静态库。将编译与静态库相同的源文件,以获得此动态库目标:

1
2
3
4
5
add_library(message-static STATIC "")
target_sources(message-static
PRIVATE
${CMAKE_CURRENT_LIST_DIR}/Message.cpp
)

设置编译器定义,包含目录和链接库,就像我们为动态库目标所做的一样。但请注意,我们添加了message_STATIC_DEFINE编译时宏定义,为了确保我们的符号可以适当地暴露:

1
2
3
4
5
6
7
8
9
10
11
12
13
target_compile_definitions(message-static
PUBLIC
message_STATIC_DEFINE
$<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
)
target_include_directories(message-static
PUBLIC
${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}
)
target_link_libraries(message-static
PUBLIC
$<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
)

还设置了message-static目标的属性:

1
2
3
4
5
6
7
8
set_target_properties(message-static
PROPERTIES
POSITION_INDEPENDENT_CODE 1
ARCHIVE_OUTPUT_NAME "message"
DEBUG_POSTFIX "_sd"
RELEASE_POSTFIX "_s"
PUBLIC_HEADER "Message.hpp;${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
)

除了链接到消息动态库目标的hello-world_wDSO可执行目标之外,还定义了另一个可执行目标hello-world_wAR,这个链接指向静态库:

1
2
3
4
5
add_executable(hello-world_wAR hello-world.cpp)
target_link_libraries(hello-world_wAR
PUBLIC
message-static
)

安装指令现在多了message-static和hello-world_wAR目标,其他没有改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
install(
TARGETS
message-shared
message-static
hello-world_wDSO
hello-world_wAR
ARCHIVE
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
RUNTIME
DESTINATION ${INSTALL_BINDIR}
COMPONENT bin
LIBRARY
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
PUBLIC_HEADER
DESTINATION ${INSTALL_INCLUDEDIR}/message
COMPONENT dev
)

工作原理

此示例演示了,如何设置动态库的符号可见性。最好的方式是在默认情况下隐藏所有符号,显式地只公开那些需要使用的符号。这需要分为两步实现。首先,需要指示编译器隐藏符号。当然,不同的编译器将有不同的可用选项,并且直接在CMakeLists.txt中设置这些选项并不是是跨平台的。CMake通过在动态库目标上设置两个属性,提供了一种健壮的跨平台方法来设置符号的可见性:

  • CXX_VISIBILITY_PRESET hidden:这将隐藏所有符号,除非显式地标记了其他符号。当使用GNU编译器时,这将为目标添加-fvisibility=hidden标志。
  • VISIBILITY_INLINES_HIDDEN 1:这将隐藏内联函数的符号。如果使用GNU编译器,这对应于-fvisibility-inlines-hidden

Windows上,这都是默认行为。实际上,我们需要在前面的示例中通过设置WINDOWS_EXPORT_ALL_SYMBOLS属性为ON来覆盖它。

如何标记可见的符号?这由预处理器决定,因此需要提供相应的预处理宏,这些宏可以扩展到所选平台上,以便编译器能够理解可见性属性。CMake中有现成的GenerateExportHeader.cmake模块。这个模块定义了generate_export_header函数,我们调用它的过程如下:

1
2
3
4
5
6
7
8
9
10
11
include(GenerateExportHeader)
generate_export_header(message-shared
BASE_NAME "message"
EXPORT_MACRO_NAME "message_EXPORT"
EXPORT_FILE_NAME "${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
DEPRECATED_MACRO_NAME "message_DEPRECATED"
NO_EXPORT_MACRO_NAME "message_NO_EXPORT"
STATIC_DEFINE "message_STATIC_DEFINE"
NO_DEPRECATED_MACRO_NAME "message_NO_DEPRECATED"
DEFINE_NO_DEPRECATED
)

该函数生成messageExport.h头文件,其中包含预处理器所需的宏。根据EXPORT_FILE_NAME选项的请求,在目录${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}中生成该文件。如果该选项为空,则头文件将在当前二进制目录中生成。这个函数的第一个参数是现有的目标(示例中是message- shared),函数的基本调用只需要传递现有目标的名称即可。可选参数,用于细粒度的控制所有生成宏,也可以传递:

  • BASE_NAME:设置生成的头文件和宏的名称。
  • EXPORT_MACRO_NAME:设置导出宏的名称。
  • EXPORT_FILE_NAME:设置导出头文件的名称。
  • DEPRECATED_MACRO_NAME:设置弃用宏的名称。这是用来标记将要废弃的代码,如果客户使用该宏定义,编译器将发出一个将要废弃的警告。
  • NO_EXPORT_MACRO_NAME:设置不导出宏的名字。
  • STATIC_DEFINE:用于定义宏的名称,以便使用相同源编译静态库时使用。
  • NO_DEPRECATED_MACRO_NAME:设置宏的名称,在编译时将“将要废弃”的代码排除在外。
  • DEFINE_NO_DEPRECATED:指示CMake生成预处理器代码,以从编译中排除“将要废弃”的代码。

GNU/Linux上,使用GNU编译器,CMake将生成以下messageExport.h头文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#ifndef message_EXPORT_H
#define message_EXPORT_H
#ifdef message_STATIC_DEFINE
# define message_EXPORT
# define message_NO_EXPORT
#else
# ifndef message_EXPORT
# ifdef message_shared_EXPORTS
/* We are building this library */
# define message_EXPORT __attribute__((visibility("default")))
# else
/* We are using this library */
# define message_EXPORT __attribute__((visibility("default")))
# endif
# endif
# ifndef message_NO_EXPORT
# define message_NO_EXPORT __attribute__((visibility("hidden")))
# endif
#endif
#ifndef message_DEPRECATED
# define message_DEPRECATED __attribute__ ((__deprecated__))
#endif
#ifndef message_DEPRECATED_EXPORT
# define message_DEPRECATED_EXPORT message_EXPORT message_DEPRECATED
#endif
#ifndef message_DEPRECATED_NO_EXPORT
# define message_DEPRECATED_NO_EXPORT message_NO_EXPORT message_DEPRECATED
#endif
#if 1 /* DEFINE_NO_DEPRECATED */
# ifndef message_NO_DEPRECATED
# define message_NO_DEPRECATED
# endif
#endif
#endif

我们可以使用message_EXPORT宏,预先处理用户公开类和函数。弃用可以通过在前面加上message_DEPRECATED宏来实现。

从messageExport.h头文件的内容可以看出,所有符号都应该在静态库中可见,这就是message_STATIC_DEFINE宏起了作用。当声明了目标,我们就将其设置为编译时定义。静态库的其他目标属性如下:

  • ARCHIVE_OUTPUT_NAME “message”:这将确保库文件的名称是message,而不是message-static。
  • DEBUG_POSTFIX “_sd”:这将把给定的后缀附加到库名称中。当目标构建类型为Release时,为静态库添加”_sd”后缀。
  • RELEASE_POSTFIX “_s”:这与前面的属性类似,当目标构建类型为Release时,为静态库添加后缀“_s”。

输出目标

可以假设,消息库在开源社区取得了巨大的成功。人们非常喜欢它,并在自己的项目中使用它将消息打印到屏幕上。用户特别喜欢每个打印的消息都有惟一的标识符。但用户也希望,当他们编译并安装了库,库就能更容易找到。这个示例将展示CMake如何让我们导出目标,以便其他使用CMake的项目可以轻松地获取它们。

准备工作

源代码与之前的示例一致,项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.
├── cmake
│ └── messageConfig.cmake.in
├── CMakeLists.txt
├── src
│ ├── CMakeLists.txt
│ ├── hello- world.cpp
│ ├── Message.cpp
│ └── Message.hpp
└── tests
├── CMakeLists.txt
└── use_target
├── CMakeLists.txt
└── use_message.cpp

注意,cmake子目录中添加了一个messageConfig.cmake.in。这个文件将包含导出的目标,还添加了一个测试来检查项目的安装和导出是否按预期工作。

具体实施

同样,主CMakeLists.txt文件相对于前一个示例来说没有变化。移动到包含我们的源代码的子目录src中:

需要找到UUID库,可以重用之前示例中的代码:

1
2
3
4
5
6
7
8
9
# Search for pkg-config and UUID
find_package(PkgConfig QUIET)
if(PKG_CONFIG_FOUND)
pkg_search_module(UUID uuid IMPORTED_TARGET)
if(TARGET PkgConfig::UUID)
message(STATUS "Found libuuid")
set(UUID_FOUND TRUE)
endif()
endif()

接下来,设置动态库目标并生成导出头文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
add_library(message-shared SHARED "")
include(GenerateExportHeader)
generate_export_header(message-shared
BASE_NAME "message"
EXPORT_MACRO_NAME "message_EXPORT"
EXPORT_FILE_NAME "${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
DEPRECATED_MACRO_NAME "message_DEPRECATED"
NO_EXPORT_MACRO_NAME "message_NO_EXPORT"
STATIC_DEFINE "message_STATIC_DEFINE"
NO_DEPRECATED_MACRO_NAME "message_NO_DEPRECATED"
DEFINE_NO_DEPRECATED
)
target_sources(message-shared
PRIVATE
${CMAKE_CURRENT_LIST_DIR}/Message.cpp
)

为目标设置了PUBLIC和INTERFACE编译定义。注意$生成器表达式的使用:

1
2
3
4
5
6
target_compile_definitions(message-shared
PUBLIC
$<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
INTERFACE
$<INSTALL_INTERFACE:USING_message>
)

链接库和目标属性与前一个示例一样:

1
2
3
4
5
6
7
8
9
10
11
12
target_link_libraries(message-static
PUBLIC
$<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
)
set_target_properties(message-static
PROPERTIES
POSITION_INDEPENDENT_CODE 1
ARCHIVE_OUTPUT_NAME "message"
DEBUG_POSTFIX "_sd"
RELEASE_POSTFIX "_s"
PUBLIC_HEADER "Message.hpp;${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
)

可执行文件的生成,与前一个示例中使用的命令完全相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
add_executable(hello-world_wDSO hello-world.cpp)
target_link_libraries(hello-world_wDSO
PUBLIC
message-shared
)
# Prepare RPATH
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
set(_rpath "@loader_path/${_rel}")
else()
set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${INSTALL_LIBDIR}" message_RPATH)
set_target_properties(hello-world_wDSO
PROPERTIES
MACOSX_RPATH ON
SKIP_BUILD_RPATH OFF
BUILD_WITH_INSTALL_RPATH OFF
INSTALL_RPATH "${message_RPATH}"
INSTALL_RPATH_USE_LINK_PATH ON
)
add_executable(hello-world_wAR hello-world.cpp)
target_link_libraries(hello-world_wAR
PUBLIC
message-static
)

现在,来看看安装规则:

因为CMake可以正确地将每个目标放在正确的地方,所以把目标的安装规则都列在一起。这次,添加了EXPORT关键字,这样CMake将为目标生成一个导出的目标文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
install(
TARGETS
message-shared
message-static
hello-world_wDSO
hello-world_wAR
EXPORT
messageTargets
ARCHIVE
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
RUNTIME
DESTINATION ${INSTALL_BINDIR}
COMPONENT bin
LIBRARY
DESTINATION ${INSTALL_LIBDIR}
COMPONENT lib
PUBLIC_HEADER
DESTINATION ${INSTALL_INCLUDEDIR}/message
COMPONENT dev
)

自动生成的导出目标文件称为messageTargets.cmake,需要显式地指定它的安装规则。这个文件的目标是INSTALL_CMAKEDIR,在主CMakeLists.txt文件中定义:

1
2
3
4
5
6
7
8
9
10
install(
EXPORT
messageTargets
NAMESPACE
"message::"
DESTINATION
${INSTALL_CMAKEDIR}
COMPONENT
dev
)

最后,需要生成正确的CMake配置文件。这些将确保下游项目能够找到消息库导出的目标。为此,首先包括CMakePackageConfigHelpers.cmake标准模块:

1
include(CMakePackageConfigHelpers)

让CMake为我们的库,生成一个包含版本信息的文件:

1
2
3
4
5
write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/messageConfigVersion.cmake
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion
)

使用configure_package_config_file函数,我们生成了实际的CMake配置文件。这是基于模板cmake/messageConfig.cmake.in文件:

1
2
3
4
5
configure_package_config_file(
${PROJECT_SOURCE_DIR}/cmake/messageConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/messageConfig.cmake
INSTALL_DESTINATION ${INSTALL_CMAKEDIR}
)

最后,为这两个自动生成的配置文件设置了安装规则:

1
2
3
4
5
6
7
install(
FILES
${CMAKE_CURRENT_BINARY_DIR}/messageConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/messageConfigVersion.cmake
DESTINATION
${INSTALL_CMAKEDIR}
)

cmake/messageConfig.cmake的内容是什么?该文件的顶部有相关的说明,可以作为用户文档供使用者查看。让我们看看实际的CMake命令:

占位符将使用configure_package_config_file命令进行替换:

1
@PACKAGE_INIT@

包括为目标自动生成的导出文件:

1
include("${CMAKE_CURRENT_LIST_DIR}/messageTargets.cmake")

检查静态库和动态库,以及两个“Hello, World”可执行文件是否带有CMake提供的check_required_components函数:

1
2
3
4
5
6
check_required_components(
"message-shared"
"message-static"
"message-hello-world_wDSO"
"message-hello-world_wAR"
)

检查目标PkgConfig::UUID是否存在。如果没有,我们再次搜索UUID库(只在非Windows操作系统下有效):

1
2
3
4
5
6
if(NOT WIN32)
if(NOT TARGET PkgConfig::UUID)
find_package(PkgConfig REQUIRED QUIET)
pkg_search_module(UUID REQUIRED uuid IMPORTED_TARGET)
endif()
endif()

测试一下:

1
2
3
4
$ mkdir -p build
$ cd build
$ cmake -DCMAKE_INSTALL_PREFIX=$HOME/Software/recipe-03 ..
$ cmake --build . --target install

安装树应该如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$HOME/Software/recipe-03/
├── bin
│ ├── hello-world_wAR
│ └── hello-world_wDSO
├── include
│ └── message
│ ├── messageExport.h
│ └── Message.hpp
├── lib64
│ ├── libmessage_s.a
│ ├── libmessage.so -> libmessage.so.1
│ └── libmessage.so.1
└── share
└── cmake
└── recipe-03
├── messageConfig.cmake
├── messageConfigVersion.cmake
├── messageTargets.cmake
└── messageTargets-release.cmake

出现了一个share子目录,其中包含我们要求CMake自动生成的所有文件。现在开始,消息库的用户可以在他们自己的CMakeLists.txt文件中找到消息库,只要他们设置message_DIR的CMake变量,指向安装树中的share/cmake/message目录:

1
find_package(message 1 CONFIG REQUIRED)

工作原理

这个示例涵盖了很多领域。对于构建系统将要执行的操作,CMake目标是一个非常有用的抽象概念。使用PRIVATE、PUBLIC和INTERFACE关键字,我们可以设置项目中的目标进行交互。在实践中,这允许我们定义目标A的依赖关系,将如何影响目标B(依赖于A)。如果库维护人员提供了适当的CMake配置文件,那么只需很少的CMake命令就可以轻松地解决所有依赖关系。

这个问题可以通过遵循message-static、message-shared、hello-world_wDSO和hello-world_wAR目标概述的模式来解决。我们将单独分析message-shared目标的CMake命令,这里只是进行一般性讨论:

生成目标在项目构建中列出其依赖项。对UUID库的链接是 message-shared的PUBLIC需求,因为它将用于在项目中构建目标和在下游项目中构建目标。编译时宏定义和包含目录需要在PUBLIC级或INTERFACE级目标上进行设置。它们实际上是在项目中构建目标时所需要的,其他的只与下游项目相关。此外,其中一些只有在项目安装之后才会相关联。这里使用了$和$生成器表达式。只有消息库外部的下游目标才需要这些,也就是说,只有在安装了目标之后,它们才会变得可见。我们的例子中,应用如下:

只有在项目中使用了message-shared库,那么$<BUILD_INTERFACE:${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}>才会扩展成${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}

只有在message-shared库在另一个构建树中,作为一个已导出目标,那么$<INSTALL_INTERFACE:${INSTALL_INCLUDEDIR}>将会扩展成${INSTALL_INCLUDEDIR}

描述目标的安装规则,包括生成文件的名称。

描述CMake生成的导出文件的安装规则messageTargets.cmake文件将安装到INSTALL_CMAKEDIR。目标导出文件的安装规则的名称空间选项,将把给定字符串前置到目标的名称中,这有助于避免来自不同项目的目标之间的名称冲突。INSTALL_CMAKEDIR变量是在主CMakeLists.txt文件中设置的:

1
2
3
4
5
6
if(WIN32 AND NOT CYGWIN)
set(DEF_INSTALL_CMAKEDIR CMake)
else()
set(DEF_INSTALL_CMAKEDIR share/cmake/${PROJECT_NAME})
endif()
set(INSTALL_CMAKEDIR ${DEF_INSTALL_CMAKEDIR} CACHE PATH "Installation directory for CMake files")

CMakeLists.txt的最后一部分生成配置文件。包括CMakePackageConfigHelpers.cmake模块,分三步完成:

调用write_basic_package_version_file函数生成一个版本文件包。宏的第一个参数是版本控制文件的路径:messageConfigVersion.cmake。版本格式为Major.Minor.Patch,并使用PROJECT_VERSION指定版本,还可以指定与库的新版本的兼容性。例子中,当库具有相同的主版本时,为了保证兼容性,使用了相同的SameMajorVersion参数。

接下来,配置模板文件messageConfig.cmake.in,该文件位于cmake子目录中。

最后,为新生成的文件设置安装规则。两者都将安装在INSTALL_CMAKEDIR下。

安装超级构建

我们的消息库取得了巨大的成功,许多其他程序员都使用它,并且非常满意。也希望在自己的项目中使用它,但是不确定如何正确地管理依赖关系。可以用自己的代码附带消息库的源代码,但是如果该库已经安装在系统上了应该怎么做呢?第8章,展示了超级构建的场景,但是不确定如何安装这样的项目。本示例将带您了解安装超级构建的安装细节。

准备工作

此示例将针对消息库,构建一个简单的可执行链接。项目布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
├── cmake
│ ├── install_hook.cmake.in
│ └── print_rpath.py
├── CMakeLists.txt
├── external
│ └── upstream
│ ├── CMakeLists.txt
│ └── message
│ └── CMakeLists.txt
└── src
├── CMakeLists.txt
└── use_message.cpp

主CMakeLists.txt文件配合超级构建,external子目录包含处理依赖项的CMake指令。cmake子目录包含一个Python脚本和一个模板CMake脚本。这些将用于安装方面的微调,CMake脚本首先进行配置,然后调用Python脚本打印use_message可执行文件的RPATH:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import shlex
import subprocess
import sys
def main():
patcher = sys.argv[1]
elfobj = sys.argv[2]
tools = {'patchelf': '--print-rpath', 'chrpath': '--list', 'otool': '-L'}
if patcher not in tools.keys():
raise RuntimeError('Unknown tool {}'.format(patcher))
cmd = shlex.split('{:s} {:s} {:s}'.format(patcher, tools[patcher], elfobj))
rpath = subprocess.run(
cmd,
bufsize=1,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True)
print(rpath.stdout)
if __name__ == "__main__":
main()

使用平台原生工具可以轻松地打印RPATH,稍后我们将在本示例中讨论这些工具。

最后,src子目录包含项目的CMakeLists.txt和源文件。use_message.cpp源文件包含以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <cstdlib>
#include <iostream>
#ifdef USING_message
#include <message/Message.hpp>
void messaging()
{
Message say_hello("Hello, World! From a client of yours!");
std::cout << say_hello << std::endl;
Message say_goodbye("Goodbye, World! From a client of yours!");
std::cout << say_goodbye << std::endl;
}
#else
void messaging()
{
std::cout << "Hello, World! From a client of yours!" << std::endl;
std::cout << "Goodbye, World! From a client of yours!" << std::endl;
}
#endif
int main()
{
messaging();
return EXIT_SUCCESS;
}

具体实施

我们将从主CMakeLists.txt文件开始,它用来协调超级构建:

与之前的示例相同。首先声明一个C++11项目,设置了默认安装路径、构建类型、目标的输出目录,以及安装树中组件的布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)
project(recipe-04
LANGUAGES CXX
VERSION 1.0.0
)
# <<< General set up >>>
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
message(STATUS "Build type set to ${CMAKE_BUILD_TYPE}")
message(STATUS "Project will be installed to ${CMAKE_INSTALL_PREFIX}")
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
# Offer the user the choice of overriding the installation directories
set(INSTALL_LIBDIR ${CMAKE_INSTALL_LIBDIR} CACHE PATH "Installation directory for libraries")
set(INSTALL_BINDIR ${CMAKE_INSTALL_BINDIR} CACHE PATH "Installation directory for executables")
set(INSTALL_INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR} CACHE PATH "Installation directory for header files")
if(WIN32 AND NOT CYGWIN)
set(DEF_INSTALL_CMAKEDIR CMake)
else()
set(DEF_INSTALL_CMAKEDIR share/cmake/${PROJECT_NAME})
endif()
set(INSTALL_CMAKEDIR ${DEF_INSTALL_CMAKEDIR} CACHE PATH "Installation directory for CMake files")
# Report to user
foreach(p LIB BIN INCLUDE CMAKE)
file(TO_NATIVE_PATH ${CMAKE_INSTALL_PREFIX}/${INSTALL_${p}DIR} _path )
message(STATUS "Installing ${p} components to ${_path}")
unset(_path)
endforeach()

设置了EP_BASE目录属性,这将为超构建中的子项目设置布局。所有子项目都将在CMAKE_BINARY_DIR的子项目文件夹下生成:

1
set_property(DIRECTORY PROPERTY EP_BASE ${CMAKE_BINARY_DIR}/subprojects)

然后,声明STAGED_INSTALL_PREFIX变量。这个变量指向构建目录下的stage子目录,项目将在构建期间安装在这里。这是一种沙箱安装过程,让我们有机会检查整个超级构建的布局:

1
2
set(STAGED_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/stage)
message(STATUS "${PROJECT_NAME} staged install: ${STAGED_INSTALL_PREFIX}")

添加external/upstream子目录。其中包括使用CMake指令来管理我们的上游依赖关系,在我们的例子中,就是消息库:

1
add_subdirectory(external/upstream)

然后,包含ExternalProject.cmake标准模块:

1
include(ExternalProject)

将自己的项目作为外部项目添加,调用ExternalProject_Add命令。SOURCE_DIR用于指定源位于src子目录中。我们会选择适当的CMake参数来配置我们的项目。这里,使用STAGED_INSTALL_PREFIX作为子项目的安装目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ExternalProject_Add(${PROJECT_NAME}_core
DEPENDS
message_external
SOURCE_DIR
${CMAKE_CURRENT_SOURCE_DIR}/src
CMAKE_ARGS
-DCMAKE_INSTALL_PREFIX=${STAGED_INSTALL_PREFIX}
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
-DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
-DCMAKE_CXX_FLAGS=${CMAKE_CXX_FLAGS}
-DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD}
-DCMAKE_CXX_EXTENSIONS=${CMAKE_CXX_EXTENSIONS}
-DCMAKE_CXX_STANDARD_REQUIRED=${CMAKE_CXX_STANDARD_REQUIRED}
-Dmessage_DIR=${message_DIR}
CMAKE_CACHE_ARGS
-DCMAKE_PREFIX_PATH:PATH=${CMAKE_PREFIX_PATH}
BUILD_ALWAYS
1
)

现在,为use_message添加一个测试,并由recipe-04_core构建。这将运行use_message可执行文件的安装,即位于构建树中的安装:

1
2
3
4
5
6
7
enable_testing()
add_test(
NAME
check_use_message
COMMAND
${STAGED_INSTALL_PREFIX}/${INSTALL_BINDIR}/use_message
)

最后,可以声明安装规则。因为所需要的东西都已经安装在暂存区域中,我们只要将暂存区域的内容复制到安装目录即可:

1
2
3
4
5
6
7
install(
DIRECTORY
${STAGED_INSTALL_PREFIX}/
DESTINATION
.
USE_SOURCE_PERMISSIONS
)

使用SCRIPT参数声明一个附加的安装规则。CMake脚本的install_hook.cmake将被执行,但只在GNU/Linux和macOS上执行。这个脚本将打印已安装的可执行文件的RPATH,并运行它。我们将在下一节详细地讨论这个问题:

1
2
3
4
5
6
7
8
if(UNIX)
set(PRINT_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/cmake/print_rpath.py")
configure_file(cmake/install_hook.cmake.in install_hook.cmake @ONLY)
install(
SCRIPT
${CMAKE_CURRENT_BINARY_DIR}/install_hook.cmake
)
endif()

-Dmessage_DIR=${message_DIR}已作为CMake参数传递给项目,这将正确设置消息库依赖项的位置。message_DIR的值在external/upstream/message目录下的CMakeLists.txt文件中定义。这个文件处理依赖于消息库,让我们看看是如何处理的:

首先,搜索并找到包。用户可能已经在系统的某个地方安装了,并在配置时传递了message_DIR:

1
find_package(message 1 CONFIG QUIET)

如果找到了消息库,我们将向用户报告目标的位置和版本,并添加一个虚拟的message_external目标。这里,需要虚拟目标来正确处理超构建的依赖关系:

1
2
3
4
if(message_FOUND)
get_property(_loc TARGET message::message-shared PROPERTY LOCATION)
message(STATUS "Found message: ${_loc} (found version ${message_VERSION})")
add_library(message_external INTERFACE) # dummy

如果没有找到这个库,我们将把它添加为一个外部项目,从在线Git存储库下载它,然后编译它。安装路径、构建类型和安装目录布局都是由主CMakeLists.txt文件设置,C++编译器和标志也是如此。项目将安装到STAGED_INSTALL_PREFIX下,然后进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
else()
include(ExternalProject)
message(STATUS "Suitable message could not be located, Building message instead.")
ExternalProject_Add(message_external
GIT_REPOSITORY
https://github.com/dev-cafe/message.git
GIT_TAG
master
UPDATE_COMMAND
""
CMAKE_ARGS
-DCMAKE_INSTALL_PREFIX=${STAGED_INSTALL_PREFIX}
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
-DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
CMAKE_CACHE_ARGS
-DCMAKE_CXX_FLAGS:STRING=${CMAKE_CXX_FLAGS}
TEST_AFTER_INSTALL
1
DOWNLOAD_NO_PROGRESS
1
LOG_CONFIGURE
1
LOG_BUILD
1
LOG_INSTALL
1
)

最后,将message_DIR目录进行设置,为指向新构建的messageConfig.cmake文件指明安装路径。注意,这些路径被保存到CMakeCache中:

1
2
3
4
5
6
7
8
9
  if(WIN32 AND NOT CYGWIN)
set(DEF_message_DIR ${STAGED_INSTALL_PREFIX}/CMake)
else()
set(DEF_message_DIR ${STAGED_INSTALL_PREFIX}/share/cmake/message)
endif()
file(TO_NATIVE_PATH "${DEF_message_DIR}" DEF_message_DIR)
set(message_DIR ${DEF_message_DIR}
CACHE PATH "Path to internally built messageConfig.cmake" FORCE)
endif()

我们终于准备好编译我们自己的项目,并成功地将其链接到消息库(无论是系统上已有的消息库,还是新构建的消息库)。由于这是一个超级构建,src子目录下的代码是一个完全独立的CMake项目:

声明一个C++11项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)
project(recipe-04_core
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

尝试找到消息库。超级构建中,正确设置message_DIR:

1
2
3
find_package(message 1 CONFIG REQUIRED)
get_property(_loc TARGET message::message-shared PROPERTY LOCATION)
message(STATUS "Found message: ${_loc} (found version ${message_VERSION})")

添加可执行目标use_message,该目标由use_message.cpp源文件创建,并连接到message::message-shared目标:

1
2
3
4
5
add_executable(use_message use_message.cpp)
target_link_libraries(use_message
PUBLIC
message::message-shared
)

为use_message设置目标属性。再次对RPATH进行设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Prepare RPATH
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
set(_rpath "@loader_path/${_rel}")
else()
set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${CMAKE_INSTALL_LIBDIR}" use_message_RPATH)
set_target_properties(use_message
PROPERTIES
MACOSX_RPATH ON
SKIP_BUILD_RPATH OFF
BUILD_WITH_INSTALL_RPATH OFF
INSTALL_RPATH "${use_message_RPATH}"
INSTALL_RPATH_USE_LINK_PATH ON
)

最后,为use_message目标设置了安装规则:

1
2
3
4
5
6
7
install(
TARGETS
use_message
RUNTIME
DESTINATION ${CMAKE_INSTALL_BINDIR}
COMPONENT bin
)

现在瞧瞧CMake脚本模板install_hook.cmake.in的内容:

CMake脚本在我们的主项目范围之外执行,因此没有定义变量或目标的概念。因此,需要设置变量来保存已安装的use_message可执行文件的完整路径。注意使用@INSTALL_BINDIR@,它将由configure_file解析:

1
set(_executable ${CMAKE_INSTALL_PREFIX}/@INSTALL_BINDIR@/use_message)

需要找到平台本机可执行工具,使用该工具打印已安装的可执行文件的RPATH。我们将搜索chrpath、patchelf和otool。当找到已安装的程序时,向用户提供有用的状态信息,并且退出搜索:

1
2
3
4
5
6
7
8
9
10
11
12
13
set(_patcher)
list(APPEND _patchers chrpath patchelf otool)
foreach(p IN LISTS _patchers)
find_program(${p}_FOUND
NAMES
${p}
)
if(${p}_FOUND)
set(_patcher ${p})
message(STATUS "ELF patching tool ${_patcher} FOUND")
break()
endif()
endforeach()

检查_patcher变量是否为空,这意味着PatchELF工具是否可用。当为空时,我们要进行的操作将会失败,所以会发出一个致命错误,提醒用户需要安装PatchELF工具:

1
2
if(NOT _patcher)
message(FATAL_ERROR "ELF patching tool NOT FOUND!\nPlease install one of chrpath, patchelf or otool")

当PatchELF工具找到了,则继续。我们调用Python脚本print_rpath.py,将_executable变量作为参数传递给execute_process:

1
2
3
4
5
6
7
8
9
10
find_package(PythonInterp REQUIRED QUIET)
execute_process(
COMMAND
${PYTHON_EXECUTABLE} @PRINT_SCRIPT@ "${_patcher}"
"${_executable}"
RESULT_VARIABLE _res
OUTPUT_VARIABLE _out
ERROR_VARIABLE _err
OUTPUT_STRIP_TRAILING_WHITESPACE
)

检查_res变量的返回代码。如果执行成功,将打印_out变量中捕获的标准输出流。否则,打印退出前捕获的标准输出和错误流:

1
2
3
4
5
6
7
8
9
  if(_res EQUAL 0)
message(STATUS "RPATH for ${_executable} is ${_out}")
else()
message(STATUS "Something went wrong!")
message(STATUS "Standard output from print_rpath.py: ${_out}")
message(STATUS "Standard error from print_rpath.py: ${_err}")
message(FATAL_ERROR "${_patcher} could NOT obtain RPATH for ${_executable}")
endif()
endif()

再使用execute_process来运行已安装的use_message可执行目标:

1
2
3
4
5
6
7
execute_process(
COMMAND ${_executable}
RESULT_VARIABLE _res
OUTPUT_VARIABLE _out
ERROR_VARIABLE _err
OUTPUT_STRIP_TRAILING_WHITESPACE
)

最后,向用户报告execute_process的结果:

1
2
3
4
5
6
7
8
if(_res EQUAL 0)
message(STATUS "Running ${_executable}:\n ${_out}")
else()
message(STATUS "Something went wrong!")
message(STATUS "Standard output from running ${_executable}:\n ${_out}")
message(STATUS "Standard error from running ${_executable}:\n ${_err}")
message(FATAL_ERROR "Something went wrong with ${_executable}")
endif()

工作原理

CMake工具箱中,超级构建是非常有用的模式。它通过将复杂的项目划分为更小、更容易管理的子项目来管理它们。此外,可以使用CMake作为构建项目的包管理器。CMake可以搜索依赖项,如果在系统上找不到依赖项,则重新构建它们。这里需要三个CMakeLists.txt文件:

  • 主CMakeLists.txt文件包含项目和依赖项共享的设置,还包括我们自己的项目(作为外部项目)。本例中,我们选择的名称为${PROJECT_NAME}_core;也就是recipe-04_core,因为项目名称recipe-04用于超级构建。
  • 外部CMakeLists.txt文件将尝试查找上游依赖项,并在导入目标和构建目标之间进行切换,这取决于是否找到了依赖项。对于每个依赖项,最好有单独的子目录,其中包含一个CMakeLists.txt文件。
  • 最后,我们项目的CMakeLists.txt文件,可以构建一个独立的CMake项目。在原则上,我们可以自己配置和构建它,而不需要超级构建提供的依赖关系管理工具。

当对消息库的依赖关系未得到满足时,将首先考虑超级构建:

1
2
3
$ mkdir -p build
$ cd build
$ cmake -DCMAKE_INSTALL_PREFIX=$HOME/Software/recipe-04 ..

让CMake查找库,这是我们得到的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- The CXX compiler identification is GNU 7.3.0
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Project will be installed to /home/roberto/Software/recipe-04
-- Build type set to Release
-- Installing LIB components to /home/roberto/Software/recipe-04/lib64
-- Installing BIN components to /home/roberto/Software/recipe-04/bin
-- Installing INCLUDE components to /home/roberto/Software/recipe-04/include
-- Installing CMAKE components to /home/roberto/Software/recipe-04/share/cmake/recipe-04
-- recipe-04 staged install: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build/stage
-- Suitable message could not be located, Building message instead.
-- Configuring done
-- Generating done
-- Build files have been written to: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build

根据指令,CMake报告如下:

安装将分阶段进入构建树。分阶段安装是对实际安装过程进行沙箱化的一种方法。作为开发人员,这对于在运行安装命令之前检查所有库、可执行程序和文件是否安装在正确的位置非常有用。对于用户来说,可在构建目录中给出了相同的结构。这样,即使没有运行正确的安装,我们的项目也可以立即使用。

系统上没有找到合适的消息库。然后,CMake将运行在构建项目之前构建库所提供的命令,以满足这种依赖性。

如果库已经位于系统的已知位置,我们可以将-Dmessage_DIR选项传递给CMake:

1
$ cmake -DCMAKE_INSTALL_PREFIX=$HOME/Software/use_message -Dmessage_DIR=$HOME/Software/message/share/cmake/message ..

事实上,这个库已经找到并导入。我们对自己的项目进行建造操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- The CXX compiler identification is GNU 7.3.0
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Project will be installed to /home/roberto/Software/recipe-04
-- Build type set to Release
-- Installing LIB components to /home/roberto/Software/recipe-04/lib64
-- Installing BIN components to /home/roberto/Software/recipe-04/bin
-- Installing INCLUDE components to /home/roberto/Software/recipe-04/include
-- Installing CMAKE components to /home/roberto/Software/recipe-04/share/cmake/recipe-04
-- recipe-04 staged install: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build/stage
-- Checking for one of the modules 'uuid'
-- Found message: /home/roberto/Software/message/lib64/libmessage.so.1 (found version 1.0.0)
-- Configuring done
-- Generating done
-- Build files have been written to: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build

项目的最终安装规则是,将安装文件复制到CMAKE_INSTALL_PREFIX:

1
2
3
4
5
6
7
install(
DIRECTORY
${STAGED_INSTALL_PREFIX}/
DESTINATION
.
USE_SOURCE_PERMISSIONS
)

注意使用.而不是绝对路径${CMAKE_INSTALL_PREFIX},这样CPack工具就可以正确理解该规则。

recipe-04_core项目构建一个简单的可执行目标,该目标链接到消息动态库。正如本章前几节所讨论,为了让可执行文件正确运行,需要正确设置RPATH。本章的第1节展示了,如何在CMake的帮助下实现这一点,同样的模式在CMakeLists.txt中被重用,用于创建use_message的可执行目标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
set(_rpath "@loader_path/${_rel}")
else()
set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${CMAKE_INSTALL_LIBDIR}" use_message_RPATH)
set_target_properties(use_message
PROPERTIES
MACOSX_RPATH ON
SKIP_BUILD_RPATH OFF
BUILD_WITH_INSTALL_RPATH OFF
INSTALL_RPATH "${use_message_RPATH}"
INSTALL_RPATH_USE_LINK_PATH ON
)

为了检查这是否合适,可以使用本机工具打印已安装的可执行文件的RPATH。我们将对该工具的调用,封装到Python脚本中,并将其进一步封装到CMake脚本中。最后,使用SCRIPT关键字将CMake脚本作为安装规则调用:

1
2
3
4
5
6
7
8
if(UNIX)
set(PRINT_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/cmake/print_rpath.py")
configure_file(cmake/install_hook.cmake.in install_hook.cmake @ONLY)
install(
SCRIPT
${CMAKE_CURRENT_BINARY_DIR}/install_hook.cmake
)
endif()

脚本是在安装最后进行执行:

1
$ cmake --build build --target install

GNU/Linux系统上,我们将看到以下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Install the project...
-- Install configuration: "Release"
-- Installing: /home/roberto/Software/recipe-04/.
-- Installing: /home/roberto/Software/recipe-04/./lib64
-- Installing: /home/roberto/Software/recipe-04/./lib64/libmessage.so
-- Installing: /home/roberto/Software/recipe-04/./lib64/libmessage_s.a
-- Installing: /home/roberto/Software/recipe-04/./lib64/libmessage.so.1
-- Installing: /home/roberto/Software/recipe-04/./include
-- Installing: /home/roberto/Software/recipe-04/./include/message
-- Installing: /home/roberto/Software/recipe-04/./include/message/Message.hpp
-- Installing: /home/roberto/Software/recipe-04/./include/message/messageExport.h
-- Installing: /home/roberto/Software/recipe-04/./share
-- Installing: /home/roberto/Software/recipe-04/./share/cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageTargets-release.cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageConfigVersion.cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageConfig.cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageTargets.cmake
-- Installing: /home/roberto/Software/recipe-04/./bin
-- Installing: /home/roberto/Software/recipe-04/./bin/hello-world_wAR
-- Installing: /home/roberto/Software/recipe-04/./bin/use_message
-- Installing: /home/roberto/Software/recipe-04/./bin/hello-world_wDSO
-- ELF patching tool chrpath FOUND
-- RPATH for /home/roberto/Software/recipe-04/bin/use_message is /home/roberto/Software/recipe-04/bin/use_message: RUNPATH=$ORIGIN/../lib64:/home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build/stage/lib64:/nix/store/di389pfcw2krnmh8nmkn55d1rnzmba37-CMake-Cookbook/lib64:/nix/store/di389pfcw2krnmh8nmkn55d1rnzmba37-CMake-Cookbook/lib:/nix/store/mjs2b8mmid86lvbzibzdlz8w5yrjgcnf-util-linux-2.31.1/lib:/nix/store/2kcrj1ksd2a14bm5sky182fv2xwfhfap-glibc-2.26-131/lib:/nix/store/4zd34747fz0ggzzasy4icgn3lmy89pra-gcc-7.3.0-lib/lib
-- Running /home/roberto/Software/recipe-04/bin/use_message:
This is my very nice message:
Hello, World! From a client of yours!
...and here is its UUID: a8014bf7-5dfa-45e2-8408-12e9a5941825
This is my very nice message:
Goodbye, World! From a client of yours!
...and here is its UUID: ac971ef4-7606-460f-9144-1ad96f713647