现代 CMake 项目的正确姿势
本文总结了现代 CMake 项目实践中的一些经验。
这里贴一个比较完善的模板项目。
指定 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.cpp
和 hello.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
可以换成 SHARED
和 MODULE
。
前两个分别是静态链接和动态链接。
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)
提一下 PUBLIC
、PRIVATE
和 INTERFACE
的区别。
PUBLIC
的目录会被暴露给链接到该目标的其他目标,对于 executable target 而言 PUBLIC
是没有意义的。
PRIVATE
则是该目标的私有头文件目录。
INTERFACE
则是会暴露给其他目标,但不会用于该目标的编译,一般只用于 header-only 的类库。
具体来说, PUBLIC
和 PRIVATE
产生 INCLUDE_DIRECTORIES
属性,该属性最终传递给编译器;PUBLIC
和 INTERFACE
会产生 INTERFACE_INCLUDE_DIRECTORIES
属性,最终传递给依赖该目标的其他目标。
链接类库
这个功能非常重要,链接一个类库,就可以得到它的 PUBLIC
头文件,在编译后也会链接到这个类库。
链接也分为 PUBLIC
和 PRIVATE
,区别在于链接是否会传递。
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_INTERFACE
与 INSTALL_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)