对于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关键词用于描述参数的“应用范围”,此外还有INTERFACEPUBLIC两种可能的值,在下一小节会对他们进行详细介绍,此处可以暂时无视。

将一个已有的项目改造为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_libraryfind_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_DIRSAbc_LIBRARIES两个变量供下游使用者使用。 这种命令尽管不是官方强制要求的,但大家都遵守了这个习惯,到了今天,很多库为了兼容旧CMake的使用方式,仍然提供这样的全局变量。

在现代CMake中,cmake脚本提供一个target显然会更好,因为target具备属性,我们不光是要找到库,还需要了解库的使用方式,使用target除了头文件目录和链接库路径,我们还可以拿到更多关于库的信息。

因此现代CMake提供了一种特别的target,Imported Target,创建命令为add_library(Abc STATIC IMPORTRED),用于表示在项目外部已经存在、无需编译的依赖,命令的第二个参数用于说明类型,比如是静态库或动态库等。 对于Imported Target的名字,似乎开发者们都喜欢使用namespace的方式,比如Boost::FormatBoost::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 .

使用--buildflag,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的测试,不过希望在我更新之前就能有更好的替代工具诞生吧。

参考资料