Skip to content

门泊吴船亦已谋

现代 CMake 项目的正确姿势

本文总结了现代 CMake 项目实践中的一些经验。

这里贴一个比较完善的模板项目。

Modern CMake Template

指定 CMake 版本

首先需要在根目录的 CMakeLists.txt 中指定项目所需的 CMake 版本。 下面这个例子指定项目所需的 CMake 最低版本为 3.15。

cmake_minimum_required(VERSION 3.15)

在 3.12 之后,CMake 还支持指定最高版本。 例如下面的例子不仅指定了最低版本为 3.15,还指定了最高为 3.21。

cmake_minimum_required(VERSION 3.15...3.21)

不过 CMake 大版本中的小版本应该都是向下兼容的,所以要求不高的情况下也可以仅指定最低版本。

Project

我觉得没什么好说的,可以直接看示例。

project(
  hello
  VERSION 0.0.1
  DESCRIPTION "A simple hello world"
  LANGUAGES CXX)

Target

一个基于 CMake 的构建系统是由若干个逻辑上的 Target 组织起来的。 每个 Target 对应到一个可执行文件或一个类库,又或者是包含一些自定义命令的自定义 Target。

Executable

一个 executable target 最后构建的产物是一个可执行文件。

add_executable(hello src/hello.cpp include/hello.h)

上面这个例子中,hello 既是目标名,也是最终构建出的可执行文件的名称。 而紧随其后的 hello.cpphello.h 则是它的源文件和头文件。 实际上头文件并不需要添加在目标中,因为在编译目标时,CMake 只是把这些文件一起传递给编译器,编译器只编译源文件,这些头文件会被直接忽略。 但是有个问题是某些 IDE 会读取目标中的文件,如果不把头文件一起添加进去,它们可能不会被 IDE 识别。 因此仍然建议把头文件添加进目标中。

Library

一个 library target 最后构建的产物是一个类库。

add_library(hello src/hello.cpp include/hello.h)

同样的,hello 既是目标名,也是编译出的类库不含扩展名和 lib 前缀的文件名。 至于扩展名是什么,取决于库是动态库还是静态库,当然也跟操作系统有关。

如果不手动指定,默认是静态库。 如果想修改默认行为,可以使用 CMake 提供的 BUILD_SHARED_LIBS 全局变量。 不过我们也可以显式指定静态库,这样就不会受到 BUILD_SHARED_LIBS 影响。

add_library(hello STATIC src/hello.cpp include/hello.h)

对于普通的类库,STATIC 可以换成 SHAREDMODULE。 前两个分别是静态链接和动态链接。 MODULE 则是不会在构建时链接,但是运行时可能通过类似 dlopen 的函数加载。

特别的,我们可以用 INTERFACE 指定一个 header-only 的类库,它不需要编译。

更特别的,我们可以用 ALIAS 为一个 library target 添加别名。 常见的情况是我们会用它来给目标套上一个命名空间。

自动添加文件

问题来了,源文件很多我不想一个一个手写怎么办? CMake 提供了一个命令,可以找出某个目录下的所有源文件,扔进一个变量里。 那么我们上面的例子就可以写成这样。

aux_source_directory(src SOURCE_LIST)
add_executable(hello ${SOURCE_LIST} include/hello.h)

那么问题又来了,头文件怎么办? 很遗憾 CMake 没有找头文件的命令。 那我们只能使用 file 命令手动找了。 最后可以写成这样。

file(GLOB HEADER_LIST include/*.h)
aux_source_directory(src SOURCE_LIST)
add_executable(hello ${SOURCE_LIST} ${HEADER_LIST})

但是需要注意,根据文档,CMake 不提倡使用 GLOB 或者 aux_source_directory 来自动添加文件。 因为在新增或删除文件时,CMakeLists.txt 没有发生改变,于是 CMake 也就无法自动重新生成构建系统。 没错,也就是说,正确的做法是像最开始那样手动添加每一个文件。 是的,这非常的离谱。 不过有些 IDE 会自动处理这些问题,勇敢无畏的程序员也可以很轻松地手动重新生成。 所以我认为这也不是太大的问题。

指定头文件目录

对于这个目标,我们可以指定它的头文件目录。 这个目录会在编译的时候被用到。

target_include_directories(hello PUBLIC include)

提一下 PUBLICPRIVATEINTERFACE 的区别。 PUBLIC 的目录会被暴露给链接到该目标的其他目标,对于 executable target 而言 PUBLIC 是没有意义的。 PRIVATE 则是该目标的私有头文件目录。 INTERFACE 则是会暴露给其他目标,但不会用于该目标的编译,一般只用于 header-only 的类库。 具体来说, PUBLICPRIVATE 产生 INCLUDE_DIRECTORIES 属性,该属性最终传递给编译器;PUBLICINTERFACE 会产生 INTERFACE_INCLUDE_DIRECTORIES 属性,最终传递给依赖该目标的其他目标。

链接类库

这个功能非常重要,链接一个类库,就可以得到它的 PUBLIC 头文件,在编译后也会链接到这个类库。 链接也分为 PUBLICPRIVATE,区别在于链接是否会传递。

target_link_libraries(other_target PRIVATE hello)

寻找外部库

我们在需要一些外部库的时候,可能会使用系统包管理来直接安装,比如直接用 apt 安装 glm。 这些库或者发行版的维护者可能会提供 CMake 支持。 这个时候我们只需要使用 find_package 命令就能把这些外部库导入成一个目标了。

find_package(glm REQUIRED)
target_link_libraries(hello PRIVATE glm::glm)

安装

那么对于自己实现的可执行文件或类库,我们也可以使用 CMake 来配置安装。

首先我们要修改一下头文件目录的指定。 因为头文件目录的相对路径在构建与安装中是不同的。 于是我们使用 BUILD_INTERFACEINSTALL_INTERFACE 来区分对待构建与安装情况下的头文件目录。 有一点类似条件编译,构造时我们使用 CMAKE_CURRENT_SOURCE_DIR 变量找到头文件目录,安装时我们直接相对于安装前缀找到头文件目录的路径。

target_include_directories(
  hello PUBLIC
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
  $<INSTALL_INTERFACE:include>)

然后我们安装类库编译出来的二进制文件。

其中 EXPORT 是用于支持 find_package 的。 它必须写在其他的选项之前。 但是它不会直接安装具体的 export 文件,我们需要再写一个 install(EXPORT) 命令来完成它,具体见后文。

install(
  TARGETS hello
  EXPORT helloConfig
  ARCHIVE
  LIBRARY
  RUNTIME)

ARCHIVE 基本上是静态库,默认安装在 lib 中;LIBRARY 基本上是动态库,默认安装在 lib 中;RUNTIME 基本上是可执行文件,默认安装在 bin 中。

需要注意的是,头文件我们需要手动把头文件的目录安装到指定路径下,一般是 include。

install(DIRECTORY include DESTINATION include)

接下来我们安装 export 文件,这样就能支持其他项目使用 find_package 导入我们的目标了。

install(
  EXPORT helloConfig
  DESTINATION lib/cmake/hello
  NAMESPACE hello::
  FILE helloConfig.cmake)

不过要注意一下,这个命令给我们的目标加上了命名空间,所以导入的目标名应该是 hello::hello。 另外,有一些奇妙的流派可能会更喜欢用下划线而不是冒号。

最后我们创建并安装包的版本文件。

include(CMakePackageConfigHelpers)

write_basic_package_version_file(
  helloConfigVersion.cmake
  COMPATIBILITY AnyNewerVersion)

install(
  FILES "${PROJECT_BINARY_DIR}/helloConfigVersion.cmake"
  DESTINATION lib/cmake/hello)

对于 write_basic_package_version_file 命令,如果不指定 VERSION 参数,默认会使用 PROJECT_VERSION,但是如果项目的版本也没有指定,CMake 就会报错。

另外提一嘴,如果需要安装的库使用 find_package 依赖了系统中其他外部库,这个时候就比较复杂了。 我们就不能简单的直接导出最终的 helloConfig.cmake,需要先导出一个 helloTargets.cmake。 还要写一个 helloConfig.cmake.in 文件,在里面导入依赖,并引入这个 helloTargets.cmake。 使用 configure_file 命令根据 helloConfig.cmake.in 文件生成最终的 helloConfig.cmake,才可以安装它。 这个东西非常折磨,虽然说我们可以不使用系统提供的库,但是有时候可怜的程序员会陷入纠结,比如 boost。 有的外部库还提供了一些清奇的思路,比如 vulkan 就是在程序运行时动态加载的。

下载外部库

之前说的都是把库安装到系统中,但是库的版本不好控制。 于是我们考虑直接把库的指定版本下载下来。 这是 CMake 原生支持的比较没那么折磨但是其实也很折磨的导入外部库的一种方式了。

比如导入 Google Benchmark 就可以像这样。

FetchContent_Declare(
  googlebenchmark
  GIT_REPOSITORY https://github.com/google/benchmark.git
  GIT_TAG v1.6.0)
set(BENCHMARK_ENABLE_TESTING OFF CACHE BOOL "" FORCE)
set(BENCHMARK_ENABLE_INSTALL OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googlebenchmark)

需要注意就是这样下载下来经常需要把测试和安装关掉,不然你会很痛苦。

接下来正常链接就可以了。

add_executable(Benches BenchSimple.cpp)
target_link_libraries(Benches hello benchmark::benchmark_main)

参考资料

Modern CMake

CMake Reference Documentation