按:

这个系列与 cmake 有关的记录也好,笔记也好,成书也好,目前暂且归属在 cmake-hello 标记之下。

源码请访问:

https://github.com/hedzr/study-cmake
请注意源码仍在跟随我的笔记内容的迭代而更新中。

我们做了辣么多的准备工作了,早就想要介入具体项目的编写工作了。

z11-m1

以前我们做了 z01 到 z04 等一组以传统 cmake 风格编写的具体项目,现在我们将要开始名为 z11-m1 的新的子项目,它包含至少一个 library Target,以及使用这个 library 的相应的 executables。

首先我们总览一下 z11-m1 的目录结构:

image-20201124152515280

在截图里没有展现上级目录(即 study-cmake 的根目录),因为现在我们只关心下级子目录中的这些子项目应该怎么布局。

你已经看到了我们的 z11-m1 包含 app, app-auto, libs/sm-lib 这几个子目录,并且全都有一个 CMakeLists.txt 负责添加和管理各自的下级子目录。

z11-m1/CMakeLists.txt

z11-m1/CMakeLists.txt 的内容是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cmake_minimum_required(VERSION 3.5..3.19)
set(CMAKE_SCRIPTS "../cmake")
set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/${CMAKE_SCRIPTS}/modules;${CMAKE_SOURCE_DIR}/${CMAKE_SCRIPTS};${CMAKE_SOURCE_DIR};${CMAKE_SOURCE_DIR}/..;${CMAKE_MODULE_PATH}")
include(prerequisites)
include(version-def)
include(add-policies)     # ${CMAKE_SOURCE_DIR}/${CMAKE_SCRIPTS}/
include(detect-systems)
#include(target-dirs)
include(utils)


project(z11-m1)

include(cxx-standard-def)

add_subdirectory(libs/sm-lib)
add_subdirectory(app-auto)
# add_subdirectory(app)

这些代码不复杂,为了能够不依靠 study-cmake 的顶级 CMakeLists.txt 所提供的基本环境也能独立编译 app/ 子项目,所以 z11-m1/CMakeLists.txt 冗余了相应的初始化结构,注意在设置 CMAKE_MODULE_PATH 时有矫正 CMAKE_SCRIPTS 的值以保证能够搜索到父目录中的 cmake/ 文件夹。

z11-m1/CMakeLists.txt 的真正目的只是包含 libs/sm-lib 和 app-auto 子项目。

z11-m1/libs/sm-lib

现在我们要编写一个 library,且令其具有 Modern CMake 的风格,不但时一个具有自我意识的 Target,而且能够提供自己的定位信息,可以很容易地嵌入到一个 cmake hosted 的构建主机环境中。

定义 project

首先还是看看 CMakeLists.txt 的内容,其开始部分如下:

1
2
cmake_minimum_required(VERSION 3.5)
project(sm-lib VERSION 1.0.0 LANGUAGES CXX)

查找 package (依赖库)

1
2
3
4
5
6
7
8
9
10
11
find_package(ZLIB REQUIRED)

#if ( ZLIB_FOUND )
#    include_directories( ${ZLIB_INCLUDE_DIRS} )
#    target_link_libraries( YourProject ${ZLIB_LIBRARIES} )
#endif( ZLIB_FOUND )

#if (ZLIB_FOUND)
#    debug_print_value(ZLIB_INCLUDE_DIRS)
#    debug_print_value(ZLIB_LIBRARIES)
#endif ()

我们会查找 zlib package 的安装信息(在大多数系统中它应该都是有效的,如果没有,你可能需要 apt install zlib1-dev 这样的命令去安装它)。

像 zlib 这样的 package 并不需要做到 Modern CMake 风格兼容,因为它实在是太常见了,而且它被作为各种发行版的 bundle 组成元素之一的历史甚至远超过 cmake。所以我们需要 find_package(ZLIB REQUIRED) 指令去查找 zlib 的安装位置。并且我们也需要将查找的结果加入到 Target Properties 中。

更多关于查找 package 的内容在后面章节专门讨论。

定义 sm-lib Target

1
2
3
4
5
6
7
8
9
10
11
12
13
add_library(${PROJECT_NAME})
add_library(libs::${PROJECT_NAME} ALIAS ${PROJECT_NAME})

target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_11)
target_sources(${PROJECT_NAME} PRIVATE src/${PROJECT_NAME}.cc)
target_link_libraries(${PROJECT_NAME} PRIVATE ${ZLIB_LIBRARIES})
target_include_directories(${PROJECT_NAME}
        PUBLIC
          $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
          $<INSTALL_INTERFACE:include>
        PRIVATE
          ${ZLIB_INCLUDE_DIRS}
        )

接下来我们通过 add_library(${PROJECT_NAME}) 声明名为 sm-lib 的 Library Target。

请注意下一语句 add_library(libs::${PROJECT_NAME} ALIAS ${PROJECT_NAME}) 为 sm-lib Library Target 建立一个别名 libs::sm-lib。这个别名很重要,它符合我们建立的 libs/sm-lib 文件夹结构,因为 app-auto 子模块将会通过此别名直接引用 sm-lib。理论上说,内部的依赖关系是可以直接使用 sm-lib 这个 Target 名字来解决的,但我们使用 libs:: 名字空间可以具有更好的可读性。

接下来的 target_xxx 语句负责为 sm-lib Library Target 设定CXX标准、源文件、链入依赖库以及包含文件搜索路径:

  • ${ZLIB_LIBRARIES}${ZLIB_INCLUDE_DIRS}find_package(ZLIB) 的结果。

    • ${ZLIB_LIBRARIES} 作为链接依赖库,对使用者透明,所以采用了 PRIVATE 限定。
    • 同样道理,${ZLIB_INCLUDE_DIRS}target_include_directories 中也是被 PRIVATE 所修饰的。
  • 采用 PRIVATE 限定的源文件是因为 sm-lib 的 cpp 文件对于使用者来说是无意义的。sm-lib 编译后只有头文件和 .a 文件将被分发给使用者。

  • 将被公布给类库使用者的头文件,也即采用 PUBLIC 宣告的有:

    • $<INSTALL_INTERFACE:include> 是一个 generator expression,它意味着 install-tree 中的include 目录会被包含在头文件搜索路径中。

    • $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> 是一个 generator expression,它意味着 build-tree 中的 sm-lib/include 文件夹,将被包含在头文件搜索路径中。

    • 为了能够在两种场景下都能正确地完成头文件搜索并正确编译 sm-lib 库,所以我们需要同时声明以上两条记录。

      其中 $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> 将会在库作者编译 app-auto 时起作用,这时实际上是一种内部库引用关系。

      $<INSTALL_INTERFACE:include> 是提供给 sm-lib 的使用者的,因为对于使用者来说,sm-lib 的头文件将会被安装在 ${CMAKE_INSTALL_PREFIX}/include/sm-libinclude 之所以不带有 CMAKE_INSTALL_PREFIX 前缀,是为了让 cmake 能够根据实际情况解决前缀,即所谓的 Relocatable Package

加入测试节

1
2
3
4
if (BUILD_TESTING)
    message("tests enabled")
    add_subdirectory(tests)
endif ()

我们会在一个子目录 tests 中编写测试用例(子项目)。

具体的内容交由 Testing 章节详解。

由于 CTest 要求 add_test 应该是基于一个 executable 才能附着,所以在 sm-lib 上我们并不能直接通过 add_test 的方式为其建立测试 Target。

取而代之的是,我们是在 app-auto 模块中建立相应的 test target 来对 sm-lib 做单元测试的。

加入安装方案

现在加入安装脚本用于将 sm-lib 安装到目标位置。

首先我们要了解的是 CMAKE_INSTALL_PREFIX 是所谓的 install-tree 目标位置。一个 Library 的公开头文件将被安装到 <prefix>/include,库文件(.a)将被安装到 <prefix>/lib (对于不同的发行版不同的处理器架构,也可能安装到 <prefix>/lib64 等等位置)。另外如果你想要,还可以提供 man 手册,附加文件等等。

install 指令

install 是一个规则组,可用于指定安装时刻将会被运行的规则。

这些规则包括;

1
2
3
4
5
6
install(TARGETS <target>... [...])
install({FILES | PROGRAMS} <file>... [...])
install(DIRECTORY <dir>... [...])
install(SCRIPT <file> [...])
install(CODE <code> [...])
install(EXPORT <export-name> [...])

它们的精确语法已经使用细节可以查看官网文档。

我们的实例

我们为 sm-lib 这样安排其安装方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
install(TARGETS ${PROJECT_NAME}
        EXPORT ${PROJECT_NAME}Targets
        LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
        ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
        )

install(EXPORT ${PROJECT_NAME}Targets
        FILE ${PROJECT_NAME}Targets.cmake
        NAMESPACE ${PROJECT_NAME}::
        DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME})

install(DIRECTORY include/${PROJECT_NAME}
        DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
        )

install(FILES
        ${CMAKE_CURRENT_LIST_DIR}/cmake/${PROJECT_NAME}Config.cmake
        DESTINATION
        ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME})

理解我们编写的安装规则集也很容易:

  • 第一条命令定义了 sm-lib 库 这个 Target 具有一个控制文件,名为 sm-libTargets.cmake 。此外我们也指定 install-tree 与目标机文件夹的相对关系,在这里实际上我们指明了 install-tree 的根目录应该等价于 ${CMAKE_INSTALL_LIBDIR}。与此同时,我们也指明了库的构建结果将被安装到何处:

    • ARCHIVE DESTINATION 表示静态库的安装位置,另外,动态库都会具有一个 import lib 用于链接目的,也会被安装到该位置
    • LIBRARY DESTINATION 表示动态库,例如 .so, .dylib, .dll 等,的安装位置
    • 动态库或静态库都是在构建中生成的,它们都是本 Target 的构建结果

    你可以指定不同的 install-tree 根目录,例如 /temp/x86 之类,这是被允许的,尽管它不符合 GNU 路径规范,可能影响到兼容性。

    注意 CMAKE_INSTALL_DIR 不加限定时,我们都认为它具有默认值 /usr/local

  • 第二条命令描述了我们的 sm-libTargets.cmake 应该被安装到 install-tree 中何处被找到。当前的配置指定了会安装到 ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME},典型的真实路径常常是 /usr/local/cmake/sm-lib/sm-libTargets.cmake

    此外也指明了应该使用 “sm-lib” 作为其名字空间。

    因此,库的使用者通过 cmake 引用 sm-lib 时,将会采用 sm-lib::sm-lib 这个全限定名。具体用法请参考 app 项目中的引用语句。

  • 第三条命令表示说我们需要安装 sm-lib 的公开头文件到 install-tree 的头文件目录中。此处的 include/${PROJECT_NAME} 代表着 source-tree 中的 sm-lib 文件夹中的 include/sm-lib 子目录。

  • 第四条命令负责将 source-tree 中的 sm-libConfig.cmake 复制到 install-tree 中。sm-libConfig.cmake 是我们作为库作者需要维护和编写的一个控制文件,其中包含在目标机上我们的库被安装在什么地方,应该被如何找到。

由于没有涉及到文档构建,man 手册构建,以及项目附加文件的安装问题,因为当前只需要上面列举的四条 install 指令就足以完成库的安装与分发了。

我们没有给出关于 executable 的 installing 实例,但它并不比 library Target 的 install 脚本更难,因而当前限于精力就暂且搁置了。

你可能已经注意到在 internal-library 和 external-library 的引用名称上,我们采用了不同的名字空间:在 internal-library 引用时,我们使用 libs::sm-lib 名字,请查阅 定义 sm-lib Target 章节;在 external-library 引用时,我们使用 sm-lib::sm-lib 名字。这样做有好处也有坏处。但你可以自行决定你的命名策略。

我们的 sm-libConfig.cmake

在 libs/sm-lib/cmake 子目录中,我们准备了 sm-libConfig.cmake 控制文件:

1
2
3
4
5
6
7
include(CMakeFindDependencyMacro)
find_dependency(ZLIB REQUIRED)

## Import the targets
if (NOT TARGET sm-lib::sm-lib)
  include("${CMAKE_CURRENT_LIST_DIR}/sm-libTargets.cmake")
endif ()

对于一个静态库来说,需要做的事并不多,我们只需要解决内部对 ZLIB 的依赖即可。

这个文件在第四条 install 指令规则中被定义为从 source-tree 复制到 install-tree。

Testing 相关 - CTest

利用 CTest 进行单元测试

CTest 是 CMake 的内建工具。

你可以在命令行直接执行 ctest --help。但在一个项目工程中,更便利的方法是:

1
cmake --build build/ --target test

简介

enable_testing 为当前目录及其所有子目录启用测试能力。所谓的测试能力是由 CTest module 提供的。如果你执行了 include(CTest) 指令,则 enable_testing 已经被自动执行了,此时可以通过一个 OPTION 变量 BUILD_TESTING 来判定启用与否。不过反过来一样成立,如果不加以额外设置,在 enable_testing() 之后,CTest 就已经处于已载入就绪的状态。

1
2
3
4
5
6
include(CTest)
if(BUILD_TESTING)
  # ... CMake code to create tests ...
  message("tests enabled")
  add_subdirectory(tests)
endif()

一旦启用了测试能力之后,我们可以通过 add_test 指令来添加测试命令行。add_test 指令的语法为:

1
2
3
4
add_test(NAME <name> COMMAND <command> [<arg>...]
         [CONFIGURATIONS <config>...]
         [WORKING_DIRECTORY <dir>]
         [COMMAND_EXPAND_LISTS])

我们可以这样编写实例:

1
2
3
add_test(NAME mytest
         COMMAND testDriver --config $<CONFIGURATION>
                            --exe $<TARGET_FILE:myexe>)

或者当我们有一个 executable 时为其附加一个test:

1
2
3
4
5
6
7
8
9
10
11
project(app)
add_executable(app main.cc)

enable_testing()
add_subdirectory(tests)

# in tests/CMakeLists.txt

project(app_test)
add_executable(app_test test.cc)
add_test(app_test app)

ctest提供了丰富的命令行参数。其中一些内容将在以后的示例中探讨。要获得完整的列表,需要使用ctest --help来查看。命令cmake --help-manual ctest会将向屏幕输出完整的ctest手册。*

study-cmake 中的 shortcut

在 study-cmake 项目中,我们简化了和 testing 相关的内容,因此对于 z11-m1/app-auto 子模块来说,我们可以很简单地在其 CMakeLists.txt 加入如下指令来使能 CTest:

1
2
3
4
5
6
7
8
project(sm-app-auto
        VERSION 1.0.0
        LANGUAGES CXX)
add_executable(sm-app-auto main.cc)
...
if (BUILD_TESTING)
    add_unit_test(${PROJECT_NAME} tests sm_lib_app_tests)
endif ()

然后我们在 z11-m1/app-auto 中加入 tests 子目录和相应的测试代码,测试代码被挂接在 sm_lib_app_tests 这个 Target 中形成了一个 executable,相应的 tests/CMakeLists.txt 为:

1
2
3
4
5
6
7
8
9
10
11
cmake_minimum_required(VERSION 3.5)
project(sm_lib_app_tests)

add_executable(${PROJECT_NAME}
        main.cc
        )

target_link_libraries(${PROJECT_NAME}
        PRIVATE
        libs::sm-lib
        )

你可以注意到 tests 子目录没有任何特殊的 CTest 内容,它就是一个简单的 executable Target 而已。

所有的工作都隐藏在 app-auto 中的 add_unit_test(${PROJECT_NAME} tests sm_lib_app_tests) 指令里。

然后我们构建整个项目成功之后,CTest 测试流程将被自动调用而无需人工介入,你也不必关心什么样的命令行才能启动 ctest。

背景

这一切有赖于这些预先配置的内容:

cmake/prerequisites.cmake

在 prerequisites.cmake 中我们有一组 CTest 初始化指令:

1
2
3
4
5
6
# for testing
set(ENV{CTEST_OUTPUT_ON_FAILURE} 1)
set_property(GLOBAL PROPERTY UNIT_TEST_TARGETS)
mark_as_advanced(UNIT_TEST_TARGETS)
enable_testing()
# include(CTest)
cmake/utils.cmake

在 utils.cmake 中我们定义了两个宏指令:add_unit_testapply_all_unit_tests

1
add_unit_test(target target_dirname target_test)

add_unit_test 有三个参数,target_test 是测试用例的 Target 名字,也就是 “sm_lib_app_tests”,target 是上级 executable 的 Target 名字,也就是 “sm-app-auto”,我们用了 ${PROJECT-NAME} 来隐藏该硬编码的字符串,target_dirname 是 tests 子目录的名字。

add_unit_test 会将 target_dirname 所指示的子目录通过 add_subdirectory 指令加载进来,然后用 add_test 指令将 target_test 挂接到 target 之上,然后注册 target_test 的名字。

CMakeLists.txt

在 source-tree 根目录的 CMakeLists.txt 的末尾,我们有一条指令调用:

1
2
# invoke CTest unittests automatically.
apply_all_unit_tests(all_tests)

通过 add_unit_test 注册的所有 target_test(s) 在此时被编制为 CTest 命令行,交给 POST_BUILD 去执行,从而达到了自动执行单元测试用例的效果。

小结

我们的这一套装备不算复杂,但很有效地解决了初学者面对 CTest 时可能遇到的各种千奇百怪的问题。不过,等到你迈过这个阶段时,还是有必要去研究 CTest 相关文档,做到深入理解和自定义也不困难。另外一点,项目变得大型了之后,每次 build 时都执行全部测试用例,那也是相当浪费生命的,此时你需要掌握 ctest 命令行的能力,以便能够有选择地执行部分用例,或者在 build 时禁用单元测试运行。

对于我们的 shortcut 来说,注释掉 apply_all_unit_tests 指令行就能达到禁用的目的了。

其它测试工具

CDash

CDash 是 CMake 提供的与 CTest 配套的工具,它是一个web 服务器,CTest 的结果可以传递给它之后在 web 页面上显示一个 Dashboard,可视化效果较佳。

不过你需要 docker 安装它并运行才行。或者你可以使用 CMake 提供的在线 CDash 服务:https://my.cdash.org

CMake Cookbook 中介绍了有关内容,请自行搜索并研究。

其它测试工具

Catch2

Google Test

Boost Test

这些都是有规模的测试工具

🔚

留下评论