什么是Modern CMake

Modern CMake Is About Targets And Properties

现代CMake中,应该避免使用老式风格的写法,避免使用if和全局生效的command,而是使用针对target的command,也就是,避免使用非target_开头的command,使用之前,考虑一下这样做是否合适,如果有target_开头的同名command,那基本上应该使用target_开头的。

基础使用

基础概念

  • command

    • CMaked的command是指function或者macro,他们的调用方式都是一样的,只是定义方式和内部变量作用域不同;
    • 调用方式如下,一般一个command以普通参数开头,后面跟若干个OPTION和OPTION相关的arg,中间以空格隔开:

      1. command(arg1
      2. OPTION1 arg2 arg3
      3. OPTION2 arg4
      4. )
    • cmake的一个command常常具有多种作用,根据传参方式的不同,起到几乎完全不同的作用,一般通过某个或某些 OPTION 是否存在,或者是否在特定位置出现,来决定command的作用,具体规则需要参考command的文档(这违背了single responsibility principal,是CMake让我难受的点之一);

  • variable
    • 设置:set/unset,set(FOO "hello, there")
    • 展开:${FOO}
    • 打印:message(“FOO: ${FOO}”)
    • CMake有非常多预定义变量,一些cmake module也会引入自己定义的变量
  • generator expression

    • 表达式: 例如$ ,INSTALL_INTERFACE是generator expression名称,冒号是名称和参数之间的分隔符,include是generator expression的参数,如果有多个参数,则它们以逗号分隔
    • generator expression有时候是必要的,因为有些信息是无法在编写CMakeLists.txt时就确定的,在后面介绍导出CMake工程的方式时,会提到使用INSTALL_INTERFACEBUILD_INTERFACE的必要性

      准备命令

  • cmake_minimum_required(VERSION 3.10)

    • 指定cmake执行的最低版本,同时也是在指定cmake执行的兼容级别,cmake会以兼容3.10的方式执行,即使我们使用的是cmake 3.23,而且部分command的行为已经发生了变化,cmake依然会以兼容3.10的方式来执行,而不是以3.23的方式来执行。
  • project(SquareRoot VERSION 1.0)

    • 指定project名称、版本号

      基础命令

  • add_executable()

    • 创建一个可执行文件target,这个command同时可以指定target源码文件,此外源码还可以通过target_sources()追加
  • add_library()
    • 类似add_executable,创建一个库文件target
  • target_include_directories()
    • 为target指定头文件目录,这里指定的目录在编译时会被-I包含进来;
  • target_link_libraries()
    • 为target指定链接依赖,链接生成target文件时,这里指定的依赖会被链接进来;
  • target_compile_definations()
    • 相当于指定编译时的-D参数
  • target_compile_features()
    • 这个command主要是指定C++编译时要用到的编译器feature),可以指定要启用C++14,C++17,也可以指定要用到C++17中的某项feature,让CMake来为我们添加必要的编译参数,并在检测到当前编译器不支持该feature时报错
    • 这个命令存在的目的是抹平不同编译器的差异,当我们要调整某项语言feature的时候,应该通过这个命令设置,而不是通过下面的target_compile_options()针对当前机器上的编译器设置,这样可以获得更好的可移植性
  • target_compile_options()
    • 这个command用于指定在编译时传给编译器的参数,如果项目需要支持多平台,可能需要加些判断编译器的代码,因为不同编译器支持的参数可能有一些差异
  • 更多命令,参考这里

    CMake中的依赖

    基础概念

    CMake中,有usage requirement和build specification的概念,要理解它们才能理解CMake的Target间的依赖规则。
    现代CMake也是面向对象的、以target为中心的CMake,target相当于Object,target property相当于member property,而以target_开头的command,以及用于修改Target属性的command,就相当于member function了。

既然是面向对象的,那就要有创建对象的方式,CMake中,addlibrary和add_executable就用于声明并定义一个target。
target有很多property,每个property都有名字,可以在文档中查到,当target被另一个target依赖时,某些property需要自动被另一个target继承,某些则不希望被继承,这些希望被另一个target继承的property值,被称作usage requirement,所有`INTERFACE
`都属于usage requirement,而不希望被继承的property值,被称作build specification,所有不以INTERFACE开头的property,都是build specification。
一旦有了对象,我们就可以使用target
开头的command来为对象设置属性,通过这些target_开头的command设置属性时,可以将属性指定为三类:PUBLIC, PRIVATE, INTERFACE,下面介绍它们的差异:target有一个COMPILE_FEATURES属性,是用来控制target编译时需要的语言feature的,可以使用target_compile_features来为target的这个属性追加值,追加的同时,可以指定要追加的属性值是:

  • PUBLIC
    • property希望被依赖方继承
    • 实际上会将追加的属性值同时追加到COMPILE_FEATURES和INTERFACE_COMPILE_FEATURES
  • PRIVATE
    • property不希望被依赖方继承
    • 实际上会将追加的属性值追加到COMPILE_FEATURES
  • INTERFACE
    • 这种property很特殊,实际上只有不需要构建的target,也就是header only library才会用到,它的效果是当前target构建时不需要这个property(如果不构建自然就不需要了),但需要被依赖方继承
    • 实际上会将追加的属性值加到INTERFACE_COMPILE_FEATURES

下面是一个例子:

  1. target_compile_features(foo
  2. PUBLIC
  3. cxx_strong_enums
  4. PRIVATE
  5. cxx_lambdas
  6. cxx_range_for
  7. )

上面的CMake代码中,我们实际上在修改foo这个target的property:

  • 将cxx_strong_enums添加到 COMPILE_FEATURES 和 INTERFACE_COMPILE_FEATURES
  • 将cxx_lambdas、cxx_range_for添加到 COMPILE_FEATURES

声明依赖

CMake的targetlink_libraries()是CMake声明target间依赖的方式,当我们声明TrgetA依赖TargetB后,TargetB的所有usage requirement,也就是INTERFACE开头的property都会被TargetA所继承,这样我们就不必考虑TargetB的头文件在哪里、TargetB是否又依赖了其他Target了。

导入

如果target在当前项目中,可以直接通过target_link_libraries()声明依赖,如果我们要依赖的target不是在当前项目中定义的,那我们就需要首先导入target。CMake中,导入target的方式可以分为如下几种:

add_library() IMPORTED

这是最简单的导入方式,如果我们手上有一个编译好的库文件和配套的头文件,就可以使用这种方式将库导入为target,然后我们就可以通过依赖这个导入的target来依赖这个库文件了。

下面介绍导入流程:

  • 前提:
    • 我们要构建一个可执行文件myexe,该可执行文件依赖一个编译好的静态库:mylib.a
    • 库的头文件位于 ./includes/mylib/
    • 库文件位于 ./libs/mylib.a
  • 引入步骤:
    • 将头文件添加到搜索目录中:

target_include_directories(myexe ${CMAKE_CURRENT_SOURCE_DIR}/includes/mylib)

  • 定义要导入的库target:

add_library(mylib STATIC IMPORTED)

  • 为要导入的库指定库文件路径:

set_target_properties(mylib PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/libs/mylib.a)

  • 定义库依赖:

target_link_libraries(myexe mylib)

add_subdirectory()

这是一种很流行的依赖基于CMake构建的开源项目的方式,即直接将项目源码下载下来到当前项目的一个子目录下,然后通过add_subdirectory()将子目录添加到当前项目,这样这个子目录下的项目中所定义的所有target都对当前项目可用了。
子目录下如果调用了cmake_minimum_required,而且和主项目不一样怎么办?

find_package()

如果一个项目支持使用CMake导入,那么当我们完成这个项目的安装后,在项目的安装路径下会产生一个CMake脚本文件,只要将这个脚本文件路径添加到CMake module path,就可以通过find_package()将这个项目的任意target导入到当前项目,然后就可以通过target_link_libraries依赖该target。

CMake项目安装

安装,指的是将CMake构建后的产物拷贝到用户指定的目录。

install()指令用于指定安装规则。这个指令有多种形式,这里介绍两个:

Install Targets

  1. install(TARGETS mylib EXPORT mylibTargets
  2. LIBRARY DESTINATION lib
  3. ARCHIVE DESTINATION lib
  4. RUNTIME DESTINATION bin
  5. INCLUDES DESTINATION include)

install(TARGETS)开头的的install指令用于指定target的安装规则,常用的可安装的target输出分如下几种:

  • ARCHIVE
    • 静态库文件
  • LIBRARY
    • 动态库(dll除外,见下面RUNTIME)
  • RUNTIME
    • 可执行文件、dll文件

要特别说明一下上面的 INCLUDES DESTINATION include,看起来这也是在指定target输出安装规则,但INCLUDES比较特殊,command文档里对它有专门的规定。上面这句代码的意思是,将指定的目录添加到INTERFACE_INCLUDE_DIRECTORIEStarget property中,这样下面介绍的install(EXPORT)就能够使用这个target property了。不过,我们还需要另外想办法将头文件目录真正的安装(copy)到这里指定的目录中。

install EXPORT

  1. install(EXPORT mylibTargets
  2. FILE mylibTargets.cmake
  3. NAMESPACE MYLIB::
  4. DESTINATION lib/cmake/mylib)

install EXPORT形式用于生成一个CMake文件,可以通过这个CMake文件来引入当前项目target。
上面的例子里:
EXPORT mylibTargets,定义了EXPORT名称,通过这个名称和上面的install target关联起来;
FILE mylibTargets.cmake,定义了导出的cmake config文件的名称;
NAMESPACE MYLIB::,定义了被导出的target的名称前缀,import被导出的target时,需要添加这个前缀;
DESTINATION lib/cmake/mylib,表示要将导出的cmake文件置于lib/cmake/mylib。

支持安装的项目必须支持relocatable

cmake导出的.cmake配置文件是根据target property生成的,包括头文件路径也是。项目install时,一般会将public头文件安装到一个指定位置,在其他项目引用这个install之后的项目时,应该包含install目录的头文件,而不是项目源码目录中的头文件。这就要求在编写cmake代码时,根据项目被引用的方式,指定不同的头文件路径。

注意下面的[$<BUILD_INTERFACE:...>](https://cmake.org/cmake/help/latest/manual/cmake-generator-expressions.7.html#genex:BUILD_INTERFACE)[$<INSTALL_INTERFACE:...>](https://cmake.org/cmake/help/latest/manual/cmake-generator-expressions.7.html#genex:INSTALL_INTERFACE)

  1. target_include_directories(MathFunctions
  2. PUBLIC
  3. "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>"
  4. "$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>"
  5. )

BUILD_INTERFACE中的内容,会在当前项目源码直接被包含在其他cmake构建中时被启用,这里指定的路径是绝对路径;
INSTALL_INTERFACE中的内容,会在当前项目被install到某个目录,这个install目录又被其他cmake构建引用时被启用,这里指定的路径必须是一个相对install目录的相对路径(cmake中其他路径都要求是绝对路径,只有这里要求是相对路径)。

支持find_package()

前面提到,使用find_package()导入一个已经安装的项目的前提是这个项目支持通过这种方式导入,这里介绍为了做到这一点,项目需要怎么做。

find_package() command有两种模式,这里只介绍其中一种,即config mode
任何一个支持cmake的c++项目都应该提供config mode配置文件,支持通过config mode来导入,如果项目不支持cmake,才需要有项目之外的开发者为项目编写module mode配置文件。CMake作为事实上的业界标准,任何一个项目都应该支持使用CMake config mode导入。

config mode下,cmake会寻找一个名为<projectName>Config.cmake,或者名为<projectName>ConfigVersion.cmake(如果指定了version)的文件,并执行该文件。这两个文件的职责如下:

  • Config.cmake文件的职责是,检查find_package()要求的targets是否都可以找到,然后通过include()引入前面通过install()命令安装的对应target的cmake配置文件;
  • ConfigVersion.cmake文件的职责是,检查find_package()要求的Version和当前安装的工程是否匹配,若检查后的结果是匹配,cmake就会继续加载前面的Config.cmake文件来引入工程。

提供这两个文件的方式如下:

  • include(CMakePackageConfigHelpers)
    • CMakePackageConfigHelpers提供了两个command:
      • configure_package_config_file()用于创建Config.cmake文件;
      • write_basic_package_version_file()用于创建ConfigVersion.cmake文件;

CMake交叉编译(Android)

CMake交叉编译时,如果目标平台支持cmake(也就是提供了cmake toolchain配置文件),那么只要在命令行中简单指定一下cmake toolchain文件即可,Android端的具体操作参考如下:
https://developer.android.com/ndk/guides/cmake#file
cmake关于交叉编译的说明如下,就不展开了:
https://cmake.org/cmake/help/latest/manual/cmake-toolchains.7.html#cross-compiling

参考自以下资料

CMake官方文档
CMake 官方guide: Importing and Exporting Guide
Deep CMake For Library Authors
Effective CMake
CGold