对于 C/C++的开发者而言,当涉及到复杂的第三方依赖时,工程的管理往往会变得十分棘手,尤其是还需要支持跨平台开发时。 CMake 做为跨平台的编译流程管理工具,为第三方依赖查找和引入,编译系统创建,程序测试以及安装都提供了成熟的解决方案。 编写一次 CMakeLists.txt 文件,执行同样的命令,在不同系统上都可以完成可执行程序或者链接库的创建。 在熟悉 CMake 后,这种编译体验我认为勉强能赶上 Rust, Go 这些现代语言的一半,还有一半则是差在包管理上,这方面暂且不提。 当然,如果只是做做算法题,完全不需要用到 CMake 这样复杂的工具,简单使用 gcc, clang 就可以满足需求了。
CMake 和 C++一样,随着多年的发展,其设计也得到了许多改进,并且和旧版本相比产生了重要的差异,从而有了现代 CMake 的说法。 传统的 CMake 使用方式也没有什么问题,但就和现代 C++一样,现代的 CMake 使用方式在一些概念上更清晰,对开发者也更友好,更不容易出错。
# 一个现代CMake工程的简单例子
cmake_minimum_required(VERSION 3.12)
project(myproj)
find_package(Poco REQUIRED COMPONENTS Net Util)
add_executable(MyEXE)
target_source(MyEXE PRIVATE "main.cpp")
target_link_library(MyEXE PRIVATE Poco::Net Poco::Util)
target_compile_definition(MyEXE PRIVATE std_cxx_14)
Target 和围绕 Target 的配置
一个 C/C++工程通常都是为了生成可执行程序或者链接库,在现代 CMake 里他们被统称为target
,创建命令分别是add_library()
和add_executable()
。
其中链接库的类型又分为很多种,最常用的就是SHARED
以及STATIC
,在命令中加入关键词进行声明:add_library(MyLib SHARED)
,第一个参数为target
的名称,后续的配置都需要用到这个名字。
在CMakeLists.txt
中可以有多个target
,相关配置大多围绕这些 target 进行。比如指定target
的源文件:
target_source(MyLib PRVIATE "main.cpp" "func.cpp")
在 CMake 中,PRIVATE
关键词用于描述参数的“应用范围”,此外还有INTERFACE
和PUBLIC
两种可能的值,在下一小节会对他们进行详细介绍,此处可以暂时无视。
将一个已有的项目改造为 CMake 工程时,通常会有较多的源文件,可以使用 CMake 的file
命令进行遍历拿到全部的源文件:
file(GLOB_RECURSE SRCS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
命令第一个参数GLOB_RECURSE
表明递归的查找子文件夹,第二个参数SRCS
则是存储结果的变量名,第三个参数为目标文件的匹配模式,找到符合条件的 cpp 文件后,他们的路径会以字符串数组的形式保存在 SRCS 变量中,使用方式如下:
target_source(MyLib PRIVATE ${SRCS})
除了源码,配置target
时通常还需要指定头文件目录:
target_include_directories(MyLib PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include/)
编译时需要的语言特性:
target_compile_features(MyLib PRIVATE std_cxx_14)
以及编译时的宏定义:
target_compile_definitions(MyLib PRIVATE LogLevel=3)
如果你有一些参数想直接传给底层的编译器(比如 gcc, clang, cl),可以使用
target_compile_options(MyLib PRIVATE -Werror -Wall -Wextra)
上面通过target_source
这些target_*
形式的命令进行的配置都是只对指定 target 有效的。
而在传统 CMake 中,这些配置通常都是以全局变量的形式定义,比如使用include_directories()
、set_cxx_flags()
等命令,传统方式的问题是灵活度低,当存在多个 target 时无法进行分别配置,导致某个 target 的属性意外遭到污染,因此现代 CMake 基于 target 的配置方式就和引入了 namespace 一样,管理起来更省心。
Build Specification 和 Usage Requirement
软件开发中依赖是十分常见的,C/C++通过 include 头文件的方式引入依赖,在动态或静态链接后可以调用依赖实现。 一个可执行程序可能会依赖链接库,链接库也同样可能依赖其他的链接库。 此时一个棘手的问题是,使用者如何知道使用这些外部依赖库需要什么条件? 比方说,其头文件的代码可能需要开启编译器 C++17 的支持、依赖存在许多动态链接库时可能只需要链接其中的一小部分、有哪些间接依赖需要安装、间接依赖的版本要求是什么……
对于这些问题,最简单粗暴的解决方案即文字说明,依赖库的作者可以在某个 README、网站、甚至在头文件里说明使用要求,但这种方式效率显然是很低下的。
CMake 提供的解决方案是,在对 target 进行配置时,可以规定配置的类型,分为 build specification 和 usage requirement 两类,会影响配置的应用范围。
Build specification 类型的配置仅在编译的时候需要满足,通过PRIVATE
关键字声明;
Usage requirement 类型的配置则是在使用时需要满足,即在其他项目里,使用本项目已编译好的 target 时需要满足,这种类型的配置使用INTERFACE
关键词声明。
在实际工程中,有很多配置在编译时以及被使用时都需要被满足的,这种配置通过PUBLIC
关键词进行声明。
下面来看一个例子,我们编写了一个 library,在编译时静态链接了 Boost,在我们的实现文件中使用了 c++14 的特性,并用到了 Boost 的头文件和函数。 随后我们对外发布了这个库,其中有头文件和预编译好的动态链接库。 尽管我们的实现代码里用了 C++14,但在对外提供的头文件中只用到 C++03 的语法,也没有引入任何 Boost 的代码。 这种情况下,当其他工程在使用我们的 library 时,其使用的编译器不需要开启 C++14 的支持,开发环境下也不需要安装 Boost。我们 library 的 CMake 配置中可以这么写:
target_compile_features(MyLib PRIVATE cxx_std_14)
target_link_libraries(MyLib PRIVATE Boost::Format)
此处用 PRIVATE 说明 c++14 的支持只在编译时需要用到,Boost 库的链接也仅在编译时需要。 但如果我们对外提供的头文件中也使用了 C++14,那么就需要使用 PUBLIC 修饰,改为:
target_compile_features(MyLib PUBLIC cxx_std_14)
target_link_libraries(MyLib PRIVATE Boost::Format)
当 library 是 header-only 时,我们的工程是不需要单独编译的,因此也就没有 build specification,通过INTERFACE
修饰配置即可
target_compile_features(MyLib INTERFACE cxx_std_14)
需要注意的是,Usage requirement 类型的配置,即通过INTERFACE
或是PUBLIC
修饰的配置是会传递的,比如 LibA 依赖 LibB 后,会继承 LibB 的 usage requirement,此后 LibC 依赖 LibB 时,LibA 和 libB 的 usage requirement 都会继承下来,这在存在多级依赖时是非常有用的。
现在的一个问题是,我们写好的这些 target, 还有他们的PRIVATE
, INTERFACE
以及PUBLIC
属性,使用者如何才能知道呢?
寻找和使用链接库
对于使用者而言,一大问题是如何找到依赖以及了解如何使用依赖。 C/C++标准没有规范库的安装位置和安装形式,通过 CMake 提供的方案寻找依赖,不光可以定位到头文件目录和链接库路径,还能够获取到库的 usage requirement。
在 CMake 中寻找第三方库的命令为find_package
,其背后的工作方式有两种,一种基于 Config File 的查找,另一种则是基于 Find File 的查找。
在执行find_package
时,实际上 CMake 都是在找这两类文件,找到后从中获取关于库的信息。
通过 Config file 找到依赖
Config File 是依赖的开发者提供的 cmake 脚本,通常会随预编译好的二进制一起发布,供下游的使用者使用。 在 Config file 里,会对库里包含的 target 进行描述,说明版本信息以及头文件路径、链接库路径、编译选项等 usage requirement。
CMake 对 Config file 的命名是有规定的,对于find_package(ABC)
这样一条命令,CMake 只会去寻找ABCConfig.cmake
或是abc-config.cmake
。
CMake 默认寻找的路径和平台有关,在 Linux 下寻找路径包括/usr/lib/cmake
以及/usr/lib/local/cmake
,在这两个路径下可以发现大量的 Config File,一般在安装某个库时,其自带的 Config file 会被放到这里来。
在 Windows 下没有安装库的规范,也因此没有这样的目录,库可能被安装在各种奇奇怪怪的地方。
此外,在 Linux 下,库也可能没有被安装在上述这些默认位置,在这些情况下,CMake 也提供了解决方案,对于find_package(Abc)
命令,如果 CMake 没有找到 Config file,使用者可以提供Abc_DIR
变量,CMake 会到Abc_DIR
指向的路径寻找 Config file。
通过 Find file 找到依赖
Config file 看似十分美好,由开发者编写 CMake 脚本,使用者只要能找到 Config file 即可获取到库的 usage requirement。 但现实是,并不是所有的开发者都使用 CMake,很多库并没有提供供 CMake 使用的 Config file,但此时我们还可以使用 Find file。
对于find_package(ABC)
命令,如果 CMake 没有找到 Config file,他还会去试着寻找FindABC.cmake
。Find file 在功能上和 Config file 相同,区别在于 Find file 是由其他人编写的,而非库的开发者。
如果你使用的某个库没有提供 Config file,你可以去网上搜搜 Find file 或者自己写一个,然后加入到你的 CMake 工程中。
一个好消息是 CMake 官方为我们写好了很多 Find file,在CMake Documentation这一页面可以看到,OpenGL,OpenMP,SDL 这些知名的库官方都为我们写好了 Find 脚本,因此直接调用 find_package 命令即可。 但由于库的安装位置并不是固定的,这些 Find 脚本不一定能找到库,此时根据 CMake 报错的提示设置对应变量即可,通常是需要提供安装路径,这样就可以通过 Find file 获取到库的 usage requirement。 不论是 Config file 还是 Find file,其目的都不只是找到库这么简单,而是告诉 CMake 如何使用这个库。
坏消息是有更大部分库 CMake 官方也没有提供 Find file,这时候就要自己写了或者靠搜索了,写好后放到本项目的目录下,修改CMAKE_MODULE_PATH
这个 CMAKE 变量:
list(INSERT CMAKE_MODULE_PATH 0 ${CMAKE_SOURCE_DIR}/cmake)
这样${CMAKE_SOURCE_DIR}/cmake
目录下的 Find file 就可以被 CMake 找到了。
不过一个新的问题是,Config file 以及 Find file 究竟要怎么写?
Imported Target
在 C/C++工程里,对于依赖,我们最基本的要求就是知道他们的链接库路径和头文件目录,通过 CMake 的find_library
和find_path
两个命令就可以完成任务:
find_library(MPI_LIBRARY
NAMES mpi
HINTS "${CMAKE_PREFIX_PATH}/lib" ${MPI_LIB_PATH}
# 如果默认路径没找到libmpi.so,还会去MPI_LIB_PATH找,下游使用者可以设置这个变量值
)
find_path(MPI_INCLUDE_DIR
NAMES mpi.h
PATHS "${CMAKE_PREFIX_PATH}/include" ${MPI_INCLUDE_PATH}
# 如果默认路径没找到mpi.h,还会去MPI_INCLUDE_PATH找,下游使用者可以设置这个变量值
)
于是在早期 CMake 时代,依赖的开发者在 cmake 脚本里通过全局变量来声明这两个东西。
比如名为 Abc 的库,其开发者在他的 cmake 脚本里会创建Abc_INCLUDE_DIRS
和Abc_LIBRARIES
两个变量供下游使用者使用。
这种命令尽管不是官方强制要求的,但大家都遵守了这个习惯,到了今天,很多库为了兼容旧 CMake 的使用方式,仍然提供这样的全局变量。
在现代 CMake 中,cmake 脚本提供一个 target 显然会更好,因为 target 具备属性,我们不光是要找到库,还需要了解库的使用方式,使用 target 除了头文件目录和链接库路径,我们还可以拿到更多关于库的信息。
因此现代 CMake 提供了一种特别的 target,Imported Target,创建命令为add_library(Abc STATIC IMPORTRED)
,用于表示在项目外部已经存在、无需编译的依赖,命令的第二个参数用于说明类型,比如是静态库或动态库等。
对于 Imported Target 的名字,似乎开发者们都喜欢使用 namespace 的方式,比如Boost::Format
、Boost::Asio
等。
同样的,对于一个 CMake 脚本,可以有多个 Imported Target。
我们可以像对待普通 target 一样,对 Imported Target 调用target_link_libraries
等命令来说明他的 usage requirement。
但其实还有另一种配置方式,上文提到过可以通过PRIVATE
, INTERFACE
, PUBLIC
用于修饰 target 属性,这实际上可看作是一种语法糖。
在 CMake 中,target 的大多属性都有对应的 private 以及 interface 两个版本的变量。
比如通过target_include_directories
命令配置头文件目录时,当使用PRIVATE
修饰时,值被写入 target 的 INCLUDE_DIRECTORIES
变量;使用INTERFACE
修饰时,值写入INTERFACE_INCLUDE_DIRECTORIES
变量;而使用PUBLIC
时,则会写入两个变量。
在 CMake 中,我们可以不使用 target 命令,而是直接使用set_target_properties
修改这些值的变量。
对于 Imported Target,当库已经事先编译好时,我们需要通过一个特殊的变量,IMPORTED_LOCATION
,来指明动态链接库的具体位置。
这个变量就可以通过set_target_properties
进行设置,在实际生产环境下,由于存在 Release 以及 Debug 环境的区别,IMPORTED_LOCATION
实际上也存在多个版本,比如IMPORTED_LOCATION_RELEASE
以及IMPORTED_LOCATION_DEBUG
,都进行设置后,在对应的环境下,CMake 会根据这些变量为下游使用者选择正确的链接库。
# spdlog库的Imported Target
set_target_properties(spdlog::spdlog PROPERTIES
IMPORTED_LINK_INTERFACE_LANGUAGES_RELEASE "CXX"
IMPORTED_LOCATION_RELEASE "${_IMPORT_PREFIX}/lib/spdlog/spdlog.lib"
)
使用 Imported Target 的另一个好处是,我们在引入一个依赖时只需要 link 其 Imported Target,不再需要手动加入其头文件目录了。因为依赖的头文件目录已经在其 target 的INTERFACE
属性里了,而INTERFACE
属性是可传递的,于是:
find_package(spdlog REQUIRED)
add_executable(MyEXE)
target_source(MyExe "main.cpp")
target_link_libraries(MyExe SPDLog::spdlog)
无需target_include_directories
,spdlog 的头文件目录自动会加进来。
find_package 的处理
回到find_package
这个命令,这个命令可以指定很多参数,比如指定版本,指定具体的模块等等。
以 SFML 多媒体库为例,其包含了 network 模块,audio 模块,graphic 模块等等,但我很多时候只用到 graphic 模块,那么其他的模块对应的链接库不需要被链接,于是 CMake 脚本可以这么写
# 要求大版本号为2的SFML库的graphic模块
find_package(SFML 2 COMPONENTS graphics REQUIRED)
# SFML提供的target名字为sfml-graphics
target_link_libraries(MyEXE PRIVATE sfml-graphics)
对于find_package
命令,这些版本、模块等参数在 Config file 或是 Find file 中显然是需要处理的,在版本不匹配,模块不存在的情况下应该对下游使用者进行提示。
这一方面 CMake 官方也为依赖开发者做了考虑,提供了FindPackageHandleStandardArgs
这个模块,在 CMake 脚本中 include 此模块后,就可以使用find_package_handle_standard_args
命令,来告知 CMake 如何获取当前 package 的版本变量,如何知道是否找到了库,比如下面针对 RapidJSON 的 cmake 脚本:
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(RapidJSON
REQUIRED_VARS RapidJSON_INCLUDE_DIR
VERSION_VAR RapidJSON_VERSION
)
这段脚本声明了当前库的版本值应该从RapidJSON_VERSION
这个变量拿,而RapidJSON_INCLUDE_DIR
这个变量可以用于表明有没有找到库。
在执行这段脚本时,CMake 先去判断RapidJSON_INCLUDE_DIR
这个变量是否为空,如果为空说明没找到库,CMake 会直接对下游使用者报错提示;如果此变量不为空,并且下游使用者在调用find_package
时传入了版本号,CMake 则会从RapidJSON_VERSION
变量中取值进行对比,如果版本不满足也报错提示。
使用 CMake 来编译
CMake 生成好编译环境后,底层的 make, ninja, MSBuild 编译命令都是不一样的,但 CMake 提供了一个统一的方法进行编译:
cmake --build .
使用--build
flag,CMake 就会调用底层的编译命令,在跨平台时十分方便。
对于 Visual Studio,其 Debug 和 Release 环境是基于 configuration 的,因此CMAKE_BUILD_TYPE
变量无效,需要在 build 时指定:
cmake --build . --config Release
CMake 的缺陷
CMake 的缺陷是很明显的,入门成本很高,其语法的设计也很糟糕,find_package
这些函数不会返回结果,而是对全局变量或是 target 产生副作用,函数的行为不查阅文档是很难预测的。
并且在 CMake 中,变量,target,字符串的区分不明确,很容易让人感到迷惑,不知道什么时候应该使用${}
去读取值。
此外,官方网站上的教程也十分落后,尽管可用,但并没有使用现代 CMake 方式创建工程。 推荐看本文最后给的资料而不是官网上的 Tutorial。
之后有空了再介绍 Config file 的具体创建方式、库的 install 还有基于 ctest 的测试,不过希望在我更新之前就能有更好的替代工具诞生吧。