Learning CMake - 实例阐述如何构建 CMake 工程

目录

  • 前言
  • CMake 工程目录结构
  • 添加库
    • 依赖项
    • 命名空间
    • 头文件
    • 编译选项
    • 包含路径
    • C++ 代码
  • 添加安装过程
    • 生成 Version.h
    • 安装头文件
    • 导出并安装 Targets
    • 生成并安装 Targets.cmake
    • 生成并安装 Config.cmake 和 ConfigVersion.cmake
    • 在构建路径生成 Targets.cmake (可选)
  • 添加 samples
  • 添加 tests

前言

CMake 是什么以及它的重要性无需多言,现在可以说大部分开源 C++ 工程都是用 CMake 来组织的。随着版本的不断更新,CMake 功能变得越来越丰富、严谨,同时也变得更加复杂。由于 3.1 版本后添加了大量新特性,很多原先常见的做法变成不推荐(比如include_directories),甚至诞生了 “Modern CMake” 的概念,倡导全面使用新的、更加优雅的方法代替之前老旧的用法。

看到这篇文章的人想必早就不是那个只会 mkdir build; cd build; cmake ..; make; make install 的层面了。我们在开发 C++ 项目时,难免需要自己从无到有构建 CMake 工程,这在新手眼中往往比较有难度,对于 CMake 不够了解的话,别说自己写 CMakeLists 了,想看懂别人写好的 CMakeLists 都难。况且很多人对 CMake 一知半解,只见过以前老的 CMake 用法,便习惯于用老方法解决一切问题,但事实上 Modern CMake 提出的新方法不但能更好地代替老方法,还能处理很多老方法难以解决的问题,所以要现在学 CMake 的话,可以直接看 Modern CMake,忽略老的那一套。

从个人经验来看,学习 CMake 的坑还是比较多的,主要是遵循 Modern CMake 的开源库目前还不是很多(但未来一定是趋势),不容易找到高质量例子。另外感觉官方帮助文档不够好,很多命令缺乏示例或者变量的详细说明,而且不容易查到特定命令是哪个版本引入的。

官方提供了一个非常详细的教程示例,虽然不能面面俱到地介绍 CMake,甚至有的地方没有解释清楚为什么那么做,但看下来之后至少能够知道 CMake 工程该怎么组织,还能了解到很多细小但很重要的点,推荐想认真学习 CMake 的同学花几个小时跟着它一步步走下来。
另外推荐一个介绍 Modern CMake 的很不错的系列博文:An Introduction to Modern CMake。可以说,我自己学习 CMake 最重要的资料来源就是这个系列博文及它所引用的其它资源。

本文的目的是通过一个具体的例子,告诉你如何基于 CMake 构建一个供其它工程使用的 C++ 库,在本文中你可以了解到:

  • 如何组织 CMake 工程目录
  • 如何添加库 (library) 以及相应的使用示例 (sample)
  • 如何添加单元测试 (tests)
  • 如何为自己的包 (package) 添加安装过程 (installation)

补充一点:有的同学可能分不清包和库的概念,举个例子,OpenCV 我们虽然总是叫它“库”,但对于 CMake 而言它是一个包,其中包含 core、calib3d、videoio、imgcodecs、highgui 等众多的库(模组)。我们整个工程在安装的时候是一个包,但其中可以通过 add_library 包含很多库。在使用一个第三方“库”的时候,我们通常用 find_package 来查找包而非直接通过 find_library 查找库,因为前者会获得更多的包配置信息(依赖关系、包含路径、版本等)。

需要注意的是,本文添加 Tests 环节使用的 FetchContent 模块是 CMake 3.11 才引入的,而 Ubuntu18.04 中用 apt 安装的 CMake 为 3.10,如果你的 CMake 版本不够,请自行升级(或者不使用 FetchContent 功能)。

内容略多,需要点耐心一步步看下去,最好先前有一定的 CMake 使用经验。

CMake 工程目录结构

一个库工程通常需要有:

  • 头文件 (headers): 存放类和函数的接口,供其它工程包含
  • 源文件 (sources): 存放函数实现和全局变量(函数实现和全局变量也可以放头文件中,但这不是本文需要讨论的),编译成二进制文件供其它工程链接
  • 示例 (samples): 提供库的使用示例
  • 测试 (tests): 提供库功能的单元测试

有时出于 samples 或者 tests 的需要,还会加入测试数据 (data)。

本文的示例工程叫做 MyPackage,里面包含一个叫做 MyLib 的库,基于 OpenCV 和 Eigen3 分别实现了 getArea() 函数用于计算 cv::MatEigen::MatrixXd 矩阵面积(元素数量),源码地址:Github/Gitee。其文件结构为:

❯ tree MyPackage
MyPackage # 工程目录
├── CMakeLists.txt # root(main) CMakeLists
├── Config.cmake.in # 包配置信息,用于生成 MyPackageConfig.cmake
├── Version.cmake.in # 包版本信息,用于生成 Version.h
├── data # 数据文件夹
│   └── lena.jpg # 供示例/测试使用的数据
├── include # 存放安装用的头文件
│   └── MyPackage # 这里加了一层包名称文件夹
│       └── MyFuncs.h # 需要安装的头文件
├── readme.md # 工程使用说明
├── samples # 示例
│   ├── CMakeLists.txt # 配置示例的 CMakeLists
│   └── GetArea.cpp # 示例的源码
├── src # 包的源码
│   └── MyFuncs.cpp # 库函数的实现
└── tests # 测试
    ├── CMakeLists.txt # 配置测试的 CMakeLists
    └── test_myfuncs.cpp # 库函数单元测试

6 directories, 11 files

CMake 配置脚本主要保存在 CMakeLists.txt 中,其中 root CMakeLists(顶层/主 CMakeLists)是 CMake 运行的入口,里面会存放 CMake 版本要求、工程信息等内容,并通过 add_subdirectory() 管理下层 CMakeLists。

工程目录结构没有硬性的规定,就我个人而言,如果只是写一个很简单的不需要输出库(不用安装)的程序,而且源文件(头文件和 cpp)又很少,我比较倾向于把源文件都放在根目录下。但对于需要发布、安装的库来说,一个清晰、符合大家惯例的目录结构能够使工程更易于使用和维护,这对于开源软件和日常工作来说是很重要的。

关于 include 目录,有的人会直接把头文件放在这个目录下,但我的建议是在里面建一层包名称文件夹,然后把这个包需要安装的头文件放在这层文件夹中,如上面目录结构所示。因为在安装的时候 include 文件夹的内容通常会被放到 /usr/local/include 文件夹中,试想一下,如果每个库都把自己的头文件直接塞到系统 include 目录,这个目录会多么混乱。所以,最好的做法是用额外一层文件夹将本工程的头文件包起来。

看到这里,也许有人会说,就算工程目录中头文件直接放到 include 目录中,安装时也可以在安装目录创建文件夹将头文件组织起来,比如工程目录下 include/MyHeader.h 通过 CMake 脚本是可以安装到 /usr/local/include/MyPackage/MyHeader.h 的。但这种做法违反了另一个惯例:Build Interface 应当与 Install Interface 保持一致。Build Interface 指的是工程(库、示例、测试)在编译时的环境配置,而 Install Interface 指的是库在安装后对于其用户的环境配置,参见 CMake Tutorial: Adding Export Configuration (Step 11)。比如:

target_include_directories(MyLib PUBLIC
    "$"
    "$")

假设本工程源码目录为 /home/Me/MyPackage,库安装路径为 /usr/local,那么按照上述 CMake 配置,MyLib 在编译时的包含路径(同时也是 samples 和 tests 的包含路径)为 /home/Me/MyPackage/include,安装时的包含路径(即对于 MyLib 的外部使用者而言)为 /usr/local/include。回到之前头文件直接放在 include/MyHeader.h 而安装到 /usr/local/include/MyPackage/MyHeader.h 的情况,sample 中包含该头文件是 #include "MyHeader.h",而库的外部使用者却是 #include ,这就是所谓的 Build Interface 与 Install Interface 不一致。

上面是一些关于目录结构的设计和原因,为了建立这样一个工程,我们首先创建各个文件夹,然后在根目录下创建 CMakeLists.txt 文件,添加如下内容:

cmake_minimum_required(VERSION 3.11) # 设定 CMake 所需的最小版本

# project configuration
project(MyPackage # 工程名称
    LANGUAGES CXX # 工程的语言
    VERSION 1.0.0) # 工程版本号
message(STATUS "MyPackage (CMake project sample)")
message(STATUS "Version: ${PROJECT_VERSION}")

if(NOT CMAKE_BUILD_TYPE) # 设定默认编译类型
    # CACHE ... FORCE 是为了将设定的变量加入 CMakeCache,方便后面在 CMake GUI 中查看和修改
    set(CMAKE_BUILD_TYPE "release" CACHE STRING "Build type" FORCE)
endif()
message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")
option(BUILD_SHARED_LIBS "Build using shared libs" ON) # 动态库 or 静态库
message(STATUS "Build shared libs: ${BUILD_SHARED_LIBS}")

这里想吐槽的一点是,很多人(包括很多知名的开源工程)喜欢直接在 CMakeLists 中加入:

set(CMAKE_BUILD_TYPE release)
set(CMAKE_CXX_FLAGS "-O3")

这就等于是强迫使用这个工程的人只能以 release 模式编译,除非手动修改 CMakeLists,但我们对待 CMake 脚本应该像对待 C++ 代码一样,不应该把一些本可以在外部配置的选项写死在代码里

举个例子,如果你的程序要从磁盘读取一个文件作为输入,那么使用 argv 传入文件名就比把文件名字符串写死在 cpp 代码中要好,因为当你要换另一个文件时,前者只需要把新的文件名作为参数重新执行一遍现有程序,而后者却要修改源码重新编译。这一点在简单的测试程序中或许不算什么,但对于发布给别人的工程,却是专业度的体现。而且在 Git 版本管理中,尤其应避免这种无意义的源码修改。

直接在 CMakeLists 中指定编译类型的人往往只是想在执行 cmake 时省去输入 -DCMAKE_BUILD_TYPE=release 的“麻烦”,这其实可以用前面我写的方法实现:判断 CMAKE_BUILD_TYPE 是否被指定,若未指定则将其设为 release 并写入 CMakeCache。这样即能够默认使用 release 模式,又支持外部手动指定 debug 模式,还方便在执行 cmake 后,通过 cmake-gui 或者 ccmake 修改编译选项。

添加库

MyPackage 中只有一个库 MyLib,创建 include/MyPackage/MyFuncs.hsrc/MyFuncs.cpp 作为 MyLib 的源文件,然后在顶层 CMakeLists.txt 中添加:

# packages
# 查找库的依赖项
find_package(OpenCV COMPONENTS core REQUIRED)
find_package(Eigen3 REQUIRED)

# main library
set(PROJECT_MAIN_LIB "MyLib") # 用变量保存库的名称
file(GLOB_RECURSE HEADERS_FOR_IDE # 获取头文件列表
    "${CMAKE_SOURCE_DIR}/include/*.h"
    "${CMAKE_SOURCE_DIR}/include/*.hpp")
add_library(${PROJECT_MAIN_LIB} # 添加 MyLib 库
    ${HEADERS_FOR_IDE}
    src/MyFuncs.cpp)
add_library(Mine::MyLib ALIAS ${PROJECT_MAIN_LIB}) # 给 MyLib 库添加别名

target_compile_features(${PROJECT_MAIN_LIB} PRIVATE cxx_std_11) # 设置 C++ 标准
target_compile_options(${PROJECT_MAIN_LIB} PRIVATE # 设置警告选项
    "-Wall;-Wextra;-Wformat=2;-Wunused;-Wshadow")
target_include_directories(${PROJECT_MAIN_LIB} PUBLIC # 设置包含路径
    "$"
    "$")
target_link_libraries(${PROJECT_MAIN_LIB} PUBLIC # 设置链接库
    opencv_core
    Eigen3::Eigen)

首先要说明的是,我的一些做法是不符合某些人定义的 “Modern CMake” 准则的,比如 Effective Modern CMake (EMC) 中建议尽量避免自定义变量的使用,但他给的理由 “Keep things simple” 并不能让我信服,反而我觉得使用变量能够方便某些全局修改,变量名还能更直观地体现其内容的含义,所以我倾向于用变量来保存库的名称。

EMC 还建议完全弃用 file(GLOB) 命令,因为如果添加了源文件但没有修改 CMakeLists(依赖 GLOB 自动获取源文件变化),构建系统将不知道应该通知 CMake 去重新生成(如果不手动执行 CMake,Makefile 将无法自动更新),但我觉得头文件列表可以用 file(GLOB) 来获取,因为它们本来就不影响 Makefile。

依赖项

MyLib 依赖 OpenCV 和 Eigen3 两个包。对于 OpenCV,很多教程甚至目前最新的 OpenCV 4.5 官方教程给出 CMake 中的用法是:

cmake_minimum_required(VERSION 2.8)
project( DisplayImage )
find_package( OpenCV REQUIRED )
include_directories( ${OpenCV_INCLUDE_DIRS} ) # 包含路径
add_executable( DisplayImage DisplayImage.cpp )
target_link_libraries( DisplayImage ${OpenCV_LIBS} ) # 链接库

问题是,首先我们不应该使用 include_directories 命令,取而代之应该用 target_include_directories,因为前者作用域是整个目录层级,这会导致同一层级下的其它目标 (targets) 被挂上不需要的依赖项,类似的 link_directorieslink_librariesadd_compile_options 也应当弃用。

其次,目前 CMake 已经能够从 target_link_libraries 被链接的库中自动获得其包含路径、编译选项 (compile options)、编译特性 (compile features) 等属性,并传递给链接它们的对象,所以对于外部依赖库,target_include_directories 也不需要了。

最后,${OpenCV_LIBS} 包含全部 OpenCV 模组,把它作为链接库的参数会导致目标被链接上许多根本不需要的依赖项,这一做法在个人测试工程中还好,但对于需要发布的项目,还是建议按需求链接必要的模组(比如本实例中 MyLib 只链接了 opencv_core)。

或许你已经注意到了,target_include_directoriestarget_link_librariestarget_compile_featurestarget_compile_options 等命令都有一个作用域的选项,即 PRIVATE、PUBLIC 或是 INTERFACE,它们的效果分别是:

  • PRIVATE: 设置仅对自身有效,不会传递给链接到自己的对象
  • PUBLIC: 设置对自身和链接到自己的对象都有效
  • INTERFACE: 设置仅对链接到自己的对象有效

这里的“对象”指的是 CMake 中的 Target。

命名空间

也许有同学会有疑问:为什么 target_link_libraries 中 OpenCV 直接写的 opencv_core(即链接到 libopencv_core.so),而 Eigen3 却是写的 Eigen3::Eigen,这两个冒号是啥?
这里的 Eigen3:: 就是命名空间(类似 C++),而 Eigen3::Eigen 是一个被 find_package 导入的 cmake 对象,参见 CMake Tutorial 中的描述:

A NAMESPACE with double-colons is specified when exporting the targets for installation. This convention of double-colons gives CMake a hint that the name is an IMPORTED target when it is used by downstreams with the target_link_libraries() command. This way, CMake can issue a diagnostic if the package providing it has not yet been found.

大概的意思是,当一个对象被输出 (export) 用于安装时,可以加上命名空间(惯例而非强制)。这样做的好处是,当这个对象的使用者调用 target_link_libraries 链接它时,命名空间表明它是一个输入对象 (imported target),如果该对象没有被找到,CMake 系统能够更容易地定位问题。

在这个例子里,如果前面忘了加 find_package(Eigen3 REQUIRED),执行 cmake 时就会报如下错误:

CMake Error at CMakeLists.txt:25 (add_library):
  Target "MyLib" links to target "Eigen3::Eigen" but the target was not
  found.  Perhaps a find_package() call is missing for an IMPORTED target, or
  an ALIAS target is missing?

根据这个错误信息,我们就能够很容易地发现问题所在。
但如果忘记加的是 find_package(OpenCV COMPONENTS core REQUIRED) 呢?你会发现执行 cmake 不会报错,而执行 make 时会报:

In file included from /home/Projects/LearningCMake/src/MyFuncs.cpp:1:
/home/Projects/LearningCMake/include/MyPackage/MyFuncs.h:5:10: fatal error: opencv2/opencv.hpp: No such file or directory
    5 | #include 
      |          ^~~~~~~~~~~~~~~~~~~~
compilation terminated.

此时你可能就会怀疑是不是自己 C++ 代码哪里写错了,从而浪费时间在排查 C++ 代码中。

显而易见,我们给用于输出的库加上命名空间是很有用的(为什么 OpenCV 就不用命名空间呢?或许是尾大不掉,一时调整不过来吧 )。

库的输出和安装本文后面会讲,这里先说一下:

add_library(Mine::MyLib ALIAS ${PROJECT_MAIN_LIB})

别人在用我们安装好的库时(Install Interface),链接库会使用命名空间,即 target_link_libraries(xxx Mine::MyLib)。为了遵循 Install Interface 与 Build Interface 一致性的惯例,我们在库工程内部的 samples 和 tests 中也要使用命名空间来链接 MyLib,所以我们用上面这句命令给 MyLib 库添加了一个别名库 Mine::MyLib。

头文件

正如前面所说,头文件在编译时不会生成 obj 文件,所以不会影响 Makefile。这意味着我们在 add_library 或者 add_executable 的时候其实完全不需要加入头文件(加了也不会对编译产生任何影响)。我这里加入头文件完全是为了 IDE (我用的是 QtCreator) 中显示更友好而已,所以头文件列表变量就起名为 HEADERS_FOR_IDE。
不加头文件(左)与加入头文件(右)后,QtCreator 中项目列表显示效果:
Learning CMake - 实例阐述如何构建 CMake 工程_第1张图片

编译选项

C++ 11 及之后的标准加入了很多实用的新特性,我们需要在 CMakeLists 中手动指定使用这些标准,以前的做法是:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11")

这样做最大的问题是会导致 CMake 系统无法获知 C++ 标准的设置,因为该设置被直接传递给了编译器。 CMAKE_CXX_FLAGS 本应是由 CMake 系统自动管理的变量,我们不应该手动去设置,CMake 3.1 后的一种方法是:

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED true)

更好的做法是使用 target_compile_features,将设置限定在 targets 层面:

target_compile_features(${PROJECT_MAIN_LIB} PRIVATE cxx_std_11)

其它的编译选项,如额外错误警告、指令集支持等,通过 target_compile_options 添加:

target_compile_options(${PROJECT_MAIN_LIB} PRIVATE
    "-Wall;-Wextra;-Wformat=2;-Wunused;-Wshadow")

错误警告能够帮助我们在 C++ 开发中规避很多低级错误,非常建议开启,但我们不希望这些设置被传递给 MyLib 的使用者,所以将它们设为 PRIVATE 属性。

包含路径

为了让 cpp 能够找到头文件,我们需要给 MyLib 添加包含路径,同时设为 PUBLIC 属性,这样链接了 MyLib 的对象也能够找到 MyLib 的头文件:

target_include_directories(${PROJECT_MAIN_LIB} PUBLIC
    "$"
    "$")

$ 在编译时会被解析成 Str,在安装时被解析成空,$ 则相反。如此设置是因为对于 MyLib 而言,编译时的包含路径和安装后的包含路径是不同的。这里 BUILD_INTERFACE 还加了一个 ${PROJECT_BINARY_DIR},是因为后面 Version.h 文件是由 CMake 动态生成的,放在编译目录。

C++ 代码

作为示例,MyLib 库提供 getArea() 函数,用于计算矩阵(cv::Mat 或者 Eigen::Matrix)面积。由于示例代码是面向 Linux 系统的,所以库函数不需要加 __declspec(dllexport)

// MyFuncs.h
#ifndef ___MYLIB_MYFUNCS_H
#define ___MYLIB_MYFUNCS_H

#include 
#include 

namespace mine {

int getArea(const cv::Mat& image);

int getArea(const Eigen::MatrixXd& matrix);

}

#endif // ___MYLIB_MYFUNCS_H
// MyFuncs.cpp
#include "MyPackage/MyFuncs.h"

namespace mine {

int getArea(const cv::Mat& image)
{
    return image.rows * image.cols;
}

int getArea(const Eigen::MatrixXd& matrix)
{
    return matrix.rows() * matrix.cols();
}

}

添加安装过程

完成了上面添加库的步骤后,虽然能够编译出库二进制文件(.so/.a),但想让用户通过 find_package 找到并使用我们的库,安装过程至关重要,具体需要完成:

  1. 生成 Version.h 文件;
  2. 导出对象到 Target.cmake 文件;
  3. 生成 Config.cmake 文件;
  4. 安装相关文件(头文件、二进制文件、cmake 文件)到目标路径

这里列出的只是安装过程所做的事情,并不是 CMake 执行步骤,有的 CMake 指令会同时完成多个操作。安装过程通常写在 root CMakeLists 中。

生成 Version.h

每个正式发布的包都有一个版本号,版本号通常为 “a.b.c” 的形式,其中 a 为主版本号(Major),b 为次版本号(Minor),c 为补丁号(Patch)。版本号一方面需要让 CMake 系统知道(find_package 的时候能够指定版本要求),另一方面还要让用户能够在 C++ 代码中获知。所以,我们将版本号写在 CMakeLists 里面(如前面的 project 参数),然后让 CMake 系统自动生成 Version.h 头文件来存放版本信息。

在本示例工程 MyPackage 中,首先我们在 root CMakeLists 中设定了版本号:

# project configuration
project(MyPackage
    LANGUAGES CXX
    VERSION 1.0.0)

这条语句后,CMake 会自动设定三个版本变量:MyPackage_VERSION_MAJORMyPackage_VERSION_MINORMyPackage_VERSION_PATCH,值分别为 1、0、0。

然后在根目录中添加 Version.cmake.in 文件,内容为:

#ifndef ___MYLIB_VERSION_H
#define ___MYLIB_VERSION_H

#define MyPackage_VERSION_MAJOR @MyPackage_VERSION_MAJOR@
#define MyPackage_VERSION_MINOR @MyPackage_VERSION_MINOR@
#define MyPackage_VERSION_PATCH @MyPackage_VERSION_PATCH@

#endif // ___MYLIB_VERSION_H

.in 文件为 CMake 配置文件,表示后续将被转化为别的文件。对于将被转化为头文件的 .in 文件,@...@ 表示由 CMake 系统中的变量值替换。

在 root CMakeLists 中加入:

configure_file(Version.cmake.in MyPackage/Version.h)

这条语句就是将 Version.cmake.in 文件转化为 Version.h(完成变量替换),并存放在 ${PROJECT_BINARY_DIR}/MyPackage 文件夹中,所以前面的包含路径加入了 ${PROJECT_BINARY_DIR},就是为了让 tests 和 samples 中能够和其它头文件一样的方式包含 Version.h:

#include 

安装头文件

# install headers
install(DIRECTORY include # 安装文件夹
    DESTINATION ${CMAKE_INSTALL_PREFIX} # 目标路径,默认 /usr/local
    FILES_MATCHING # 匹配模板
    PATTERN "*.h"
    PATTERN "*.hpp")
# install version info header
install(FILES "${PROJECT_BINARY_DIR}/MyPackage/Version.h" # 安装文件
    DESTINATION include/MyPackage) #目标路径,前面的 ${CMAKE_INSTALL_PREFIX} 已省略

安装步骤里最重要的就是 install 命令,根据第一个参数(DIRECTORY、FILES、TARGETS…)的不同,install 实现的功能差别较大。这里分别将 source 路径的 include 文件夹和 build 路径的 Version.h 一起安装(即拷贝)到 ${CMAKE_INSTALL_PREFIX}/include/MyPackage 文件夹中。

导出并安装 Targets

# install and export target
install(TARGETS ${PROJECT_MAIN_LIB}
    EXPORT ${PROJECT_NAME}Targets
    ARCHIVE DESTINATION lib
    LIBRARY DESTINATION lib
    RUNTIME DESTINATION bin)

将 MyLib 相关信息导出为 MyLibTargets,并将其库文件安装到 ${CMAKE_INSTALL_PREFIX}/lib,这里 RUNTIME 选项在 linux 上其实不需要设置(Windows 上 dll 存放位置)。另外,如果前面设定了被安装对象的 PUBLIC_HEADER 属性,也可以在这里指定头文件的安装路径,从而免去上一步头文件安装。

生成并安装 Targets.cmake

# generate and install Targets.cmake
install(EXPORT ${PROJECT_NAME}Targets
    FILE ${PROJECT_NAME}Targets.cmake
    NAMESPACE Mine::
    DESTINATION lib/cmake/${PROJECT_NAME})

这一步将前面导出的 MyLibTargets 信息写入到 MyLibTargets.cmake 文件中,并安装到 ${CMAKE_INSTALL_PREFIX}/lib/cmake/MyPackage 文件夹中。可以看到,正是这一步给 MyLib 加上了命名空间 Mine,所以对于导入 MyLib 的用户而言,需要链接的是 Mine::MyLib。要注意的是,在执行安装命令(make install)前,MyLibTargets.cmake 被存放在 ${PROJECT_BINARY_DIR}/CMakeFiles/Export/lib/cmake/MyPackage 文件夹中,这一点后面还会提到。

生成并安装 Config.cmake 和 ConfigVersion.cmake

重点来了!find_package 命令需要依靠 xxxConfig.cmake 或者 Findxxx.cmake 文件查找一个包,前者由包本身提供,当包不提供 Config.cmake 时,包的使用者需要自己编写 Findxxx.cmake 脚本来查找(比如 Boost 不提供 BoostConfig.cmake,所以 CMake 官方提供了 FindBoost.cmake)。这一步我们需要为自己的包生成 MyPackageConfig.cmake。与 Version.h 类似,先创建 Config.cmake.in 文件:

@PACKAGE_INIT@ # 包初始化信息,在 build 路径和 install 路径将作不同的替换

include(CMakeFindDependencyMacro)

find_dependency(OpenCV COMPONENTS core REQUIRED)
find_dependency(Eigen3 REQUIRED)

include("${CMAKE_CURRENT_LIST_DIR}/MyPackageTargets.cmake") # 加入 Targets 信息
if(TARGET Mine::MyLib)
    message(STATUS "Found MyPackage: (version ${MyPackage_VERSION}) ${CMAKE_CURRENT_LIST_DIR}")
endif()

这里使用了 CMakeFindDependencyMacro 外部工具来查找 MyPackage 的依赖项,这样用户调用 find_package(MyPackage) 的时候就相当于同时调用了 find_package(OpenCV)find_package(Eigen3)。后面的 message 能够提示用户 MyPackage 的路径与版本信息。

然后在 root CMakeLists 中加入:

# generate and install Config.cmake & ConfigVersion.cmake
include(CMakePackageConfigHelpers)
configure_package_config_file(${CMAKE_SOURCE_DIR}/Config.cmake.in
    ${PROJECT_NAME}Config.cmake
    INSTALL_DESTINATION "lib/cmake/${PROJECT_NAME}"
    NO_SET_AND_CHECK_MACRO
    NO_CHECK_REQUIRED_COMPONENTS_MACRO)
write_basic_package_version_file(
    ${PROJECT_NAME}ConfigVersion.cmake
    VERSION ${PACKAGE_VERSION}
    COMPATIBILITY AnyNewerVersion)
install(FILES
    "${PROJECT_BINARY_DIR}/${PROJECT_NAME}Config.cmake"
    "${PROJECT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake"
    DESTINATION "lib/cmake/${PROJECT_NAME}")

CMakePackageConfigHelpers 工具中 configure_package_config_file 函数用于将 Config.cmake.in 文件转化为 MyPackageConfig.cmake,同时指定其安装路径(以便安装时完成变量替换)。write_basic_package_version_file 函数则根据 CMakeLists 中的包版本信息生成 MyPackageConfigVersion.cmake,从而让用户 find_package 的时候获取。最后,将生成的两个 .cmake 文件安装到目标路径 ${CMAKE_INSTALL_PREFIX}/lib/cmake/MyPackage

在构建路径生成 Targets.cmake (可选)

作为包的使用者,通常的做法是将包编译安装后再用,但有的人喜欢编译后不安装,直接在构建路径使用(在用户工程中指定 MyPackage_DIR 为 MyPackage 的 ${PROJECT_BINARY_DIR})。为了实现这一点,还有一个额外步骤。

前面已经提到了,在执行安装命令前,MyLibTargets.cmake 被存放在 ${PROJECT_BINARY_DIR}/CMakeFiles/... 文件夹中,MyPackageConfig.cmake 被存放在 ${PROJECT_BINARY_DIR} 中,安装后两者都被放在 ${CMAKE_INSTALL_PREFIX}/lib/cmake/MyPackage 中,而 MyPackageConfig.cmake 中有一句:

include("${CMAKE_CURRENT_LIST_DIR}/MyPackageTargets.cmake")

所以对于安装路径,find_package 能够通过 MyPackageConfig.cmake 正常找到 MyLibTargets.cmake。但在构建路径,由于两者不在同一路径,无法找到 MyLibTargets.cmake!因此,我们需要在 ${PROJECT_BINARY_DIR} 中再次生成 MyLibTargets.cmake:

# export Targets.cmake to build directory
export(TARGETS ${PROJECT_MAIN_LIB}
    NAMESPACE Mine::
    FILE ${PROJECT_NAME}Targets.cmake)

添加 samples

为了让 root CMakeLists 显得简洁一些,samples 中的项目我们用单独的一层 CMakeLists 来管理,即在 samples 文件夹中添加 CMakeLists.txt,然后通过 add_subdirectory 的方式加入到工程中。考虑到有时候用户并不想编译 samples,可以加入选项来控制,在 root CMakeLists.txt 中添加:

# samples
option(BUILD_SAMPLES "Build sample executables" ON)
if(BUILD_SAMPLES)
    add_subdirectory(samples)
endif()

samples/CMakeLists.txt 的内容为:

find_package(OpenCV COMPONENTS imgcodecs REQUIRED)

add_executable(GetArea GetArea.cpp)
target_link_libraries(GetArea PRIVATE Mine::MyLib opencv_imgcodecs)

这里重新针对 OpenCV 做了一次 find_package,因为 imgcodecs 模块是 samples 额外依赖的,MyLib 本身并不需要。

samples/GetArea.cpp 内容为:

#include 
#include 
#include 

using namespace std;
using namespace cv;

int main(int argc, char* argv[])
{
    if (argc < 2) {
        cout << "Usage: GetArea " << endl;
        return 0;
    }
    cout << "MyPackage version: "
         << MyPackage_VERSION_MAJOR << "."
         << MyPackage_VERSION_MINOR << "."
         << MyPackage_VERSION_PATCH << endl;
    Mat image = imread(argv[1]);
    if (image.empty()) {
        cout << "Cannot load image from " << argv[1] << endl;
        return -1;
    }
    cout << "Area of image " << argv[1] << " is "
         << mine::getArea(image) << endl;

    Eigen::MatrixXd matrix = Eigen::MatrixXd::Random(10, 20);
    cout << "Area of random matrix is "
         << mine::getArea(matrix) << endl;

    return 0;
}

代码很简单,首先输出库版本信息,然后从命令行输入的路径读取图片(可使用 data 路径下提供的图片),并调用 getArea() 函数。需注意的是,samples 属于 Build Interface,这边的 MyFuncs.h 和 Version.h 看起来都属于 MyPackage 文件夹,但实际上一个来自于源码目录,一个来自于构建目录。

添加 tests

tests 指的是单元测试,即测试库的各项功能是否正常,对于一个比较规范的库,原则上应当给每个库函数都添加单元测试。

CMake 本身是提供单元测试功能的(CTest),参见官方教程,但我更倾向于使用更强大的 GoogleTest。GoogleTest 建议直接将源码跟随自己的工程一起编译,而不是将其编译成二进制(.so/.a)后再给工程使用,因为不同的工程可能会给 GoogleTest 设置不同的编译选项,参见 Why is it recommended to include googletest source files。在自己的工程中加入 GoogleTest 方法不止一种,可以用 find_package 查找系统安装的 GoogleTest,也可以借助 git submodule 将其加入到源码目录。本文介绍的方法(来源)是利用 CMake 的 FetchContent 功能将 GoogleTest 源码加入到构建目录,能够在执行 cmake 的时候从远程地址获取内容。

首先在 root CMakeLists 中加入:

# tests
include(CTest) # GoogleTest 需要 CTest 支持
if(BUILD_TESTING) # CTest 会自动加入 BUILD_TESTING 选项
    option(INSTALL_GTEST "" OFF) # 不安装 GoogleTest
    # fetch GoogleTest from gitee
    include(FetchContent) # FetchContent 功能支持
    FetchContent_Declare( # 声明 FetchContent 任务
        googletest # 任务名称
        GIT_REPOSITORY  https://gitee.com/york_chen/googletest.git # 远程地址,这里随便找了一个码云镜像
        GIT_TAG         release-1.10.0) # 可以指定想获取的版本
    if(NOT googletest_POPULATED) # 判断 FetchContent 任务是否已经执行过
        FetchContent_Populate(googletest) # 执行 FetchContent 任务
        add_subdirectory(${googletest_SOURCE_DIR} ${googletest_BINARY_DIR}) # 将 GoogleTest 作为子工程加入到当前工程
    endif()

    include(GoogleTest) # 启用 GoogleTest 支持
    add_subdirectory(tests) # 加入 tests
endif()

CTest 模块会自动给工程加入 BUILD_TESTING 开关,并默认开启。第一次执行 cmake 命令时,FetchContent 任务将 GoogleTest 源码下载到构建路径,同时设置相关变量(${xxx_SOURCE_DIR}${xxx_BINARY_DIR} 等),我们将 GoogleTest 源码作为子工程加入进来,然后就能够使用其测试功能,并随 MyPackage 一起编译 GoogleTest 了。

有两个需注意的地方:1. 用 CTest 做单元测试需要在 CMakeLists 中加入 enable_testing() 来启用测试,GoogleTest 的 CMakeLists 已经调用了这个命令,所以我们不需要再自己添加;2. GoogleTest 默认开启安装,也就是说我们的工程在 make install 的时候会把 GoogleTest 的相关文件也安装到目标路径,这通常是我们不希望的,所以要在包含 GoogleTest 前加入 option(INSTALL_GTEST "" OFF) 来关闭其安装。

接下来就是添加我们的测试内容,在 tests/CMakeLists.txt 中加入:

find_package(OpenCV COMPONENTS imgcodecs REQUIRED) # tests 额外需要的库和模块

include(CMakeParseArguments) # 用于解析函数参数

function(package_add_test) # 添加测试的函数
    set(options)
    set(oneValueArgs TARGET WORKING_DIRECTORY)
    set(multiValueArgs FILES LIBRARIES)
    cmake_parse_arguments(_TEST "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})
    if(NOT _TEST_TARGET)
        message(FATAL_ERROR "Target name not provided!")
    endif(NOT _TEST_TARGET)
    add_executable(${_TEST_TARGET} ${_TEST_FILES})
    target_link_libraries(${_TEST_TARGET} gtest gmock gtest_main ${_TEST_LIBRARIES})
    gtest_discover_tests(${_TEST_TARGET}
        WORKING_DIRECTORY ${_TEST_WORKING_DIRECTORY}
        PROPERTIES VS_DEBUGGER_WORKING_DIRECTORY "${_TEST_WORKING_DIRECTORY}")
endfunction(package_add_test)

# 调用函数来添加测试
package_add_test(TARGET test_myfuncs # 测试名称
    FILES test_myfuncs.cpp # 源文件
    LIBRARIES Mine::MyLib opencv_imgcodecs # 依赖库
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) # 工作路径

考虑到每个测试除了共同依赖 GoogltTest 相关库(gtest、gmoke、gtest_main),还分别有自己的源文件、依赖库、工作路径,为了方便添加测试,这里利用 CMakeParseArguments 模块写了一个 package_add_test 函数(虽然示例工程只包含一个测试)。GoogleTest 具体的使用方法就不介绍了,测试代码如下:

// tests/test_myfuncs.cpp
#include "gtest/gtest.h"
#include 

namespace {

TEST(MyFuncs, GetAreaCv)
{
    cv::Mat image = cv::imread("data/lena.jpg");
    ASSERT_FALSE(image.empty());
    const int rows = image.rows;
    const int cols = image.cols;
    EXPECT_EQ(mine::getArea(image), rows * cols);
}

TEST(MyFuncs, GetAreaEigen)
{
    const int rows = 20;
    const int cols = 30;
    Eigen::MatrixXd mat = Eigen::MatrixXd::Zero(rows, cols);
    EXPECT_EQ(mine::getArea(mat), rows * cols);
}

}

编译完成后执行 ctest -vv 即可进行各项测试。

至此,一个相对完整的 CMake 工程就编写完成了。

你可能感兴趣的:(编程杂事,c++,cmake)