系列文章目录
文章目录
前言
用 CMake 来构建 C/C++ 项目是业内的主流做法。最近,我们的项目代码做了一些拆分和合并:引入其他仓库代码,并且将公共部分拆分以供多个仓库同时使用。为此,就得修改项目中的 CMake 以满足需求。
在做这件事情时,过程是相当痛苦的,修改的难度超过了我的预期。这份痛苦的回忆,让我陷入了沉思:这 CMake 咋这么不好使,是我的使用姿势不对吗?CMake 的最佳实践是啥?
在经过一番搜索和学习,我开始了解 Modern CMake 的一些用法与理念,它主张放弃传统的基于变量的方法,而采用基于 target 的结构化模式,使其成为一个更可维护、更直观、更易集成、更具意义的方案。
在这里对自己的学习做一个总结,主要内容包括:
- 介绍 Modern CMake 的基础语法与工具,让你对 CMake 能做些啥有更清楚的认识
- 介绍 Modern CMake 理念与最佳实践,并给出具体实例
CMake 基础
这部分内容是 An Introduction to Modern CMake 的总结,并不会讲的非常详细,希望通过几句话来高度总结各个用法,旨在了解 CMake 有哪能力,如果对某些部分感兴趣请大家自行查阅具体内容。
1. Modern CMake 介绍
- 为什么需要一个好的构建系统?如果你有以下需求,那么使用 CMake 可以从中获益
- 你想避免硬编码路径
- 你需要在多台电脑上建立一个软件包。
- 你想使用CI(持续集成)。
- 你需要支持不同的操作系统(甚至可能只是Unix的不同版本)。
- 你想支持多个编译器
- 你想使用IDE,但也许并不是所有时间都想使用。
- 你想要更好地组织程序的结构
- 你想使用别人的库
- 你想使用工具,比如Clang-Tidy,来帮助你编写代码。
- 你想使用调试器
- 为什么一定是 CMake?其他工具不行吗?
- 各个 IDE 都支持 CMake,使用 CMake 可以有更大程度的方便, save you life
- 目前很多 C/C++ 开源项目都在使用 CMake,通过 find CMake 或者 CMake config 很容易将它们集成至你的项目中
- 使用 CMake 来管理发布,让别人更容易使用你的库
- 更多理由请参考 19 reasons why CMake is actually awesome
- 为什么要用现代 CMake?
- 通常认为 3.1+ 版本是现代 CMake。CMake 是向下兼容的,可以放心使用
- 至少应该使用你的编译器出来之后的 CMake 版本,因为它需要知道该版本的编译标志等等
- 高版本 CMake 有更多特性,可以节省数百行和数小时的 CMakeLists.txt 编写工作,长运来看更容易维护
安装 CMake
- All
- Pip (official, sometimes delayed slightly)
- Anaconda / Conda-Forge
- Windows
- Scoop
- MSYS2
- Download binary (official)
- MacOS
- Homebrew
- MacPorts
- Download binary (official)
- Linux
- Snapcraft (official)
- APT repository (Ubuntu/Debian only) (official)
- Download binary (official)
运行 CMake
-
经典用法
~/package $ mkdir build ~/package $ cd build ~/package/build $ cmake .. ~/package/build $ make
-
新版本可以简单一点
~/package $ cmake -S . -B build ~/package $ cmake --build build
-
安装命令
#From the build directory (pick one) ~/package/build $ make install ~/package/build $ cmake --build . --target install ~/package/build $ cmake --install . # CMake 3.15+ only #From the source directory (pick one) ~/package $ cmake --build build --target install ~/package $ cmake --install build # CMake 3.15+ only
-
推荐使用
--build
用法- 通过
-v
显示执行的构建命令:cmake --build build -v
- 通过
--target
来选择目标:cmake --build --target install
- 通过
-
设置
CC
和CXX
环境变量来选择 C/C++ 编译器~/package/build $ CC=clang CXX=clang++ cmake ..
-
选择不同的工具进行构建
- make 通常是默认的,通过 cmake --help 查看支持的构建器
cmake -G"My Tool"
设置构建器,例如cmake -S . -B buildXcode -G"Xcode"
-
通过
-D
来设置选项,例如cmake -S . -B build -DCMAKE_INSTALL_PREFIX=dist
-
--trace
打印 CMake configure 阶段的输出,例如cmake -S . -B build --trace
Do’s and Don’ts
-
不好的 CMake 的用法
- 不要使用全局函数,例如
link_directories
,include_libraries
,add_definitions
等,请你忘记它们 - 不要滥用
PUBLIC
,除非有依赖传递,否则请你使用PRIVATE
替换PUBLIC
- 不要使用 GLOB 来添加文件
- 不要直接链接文件,而是链接目标
- 链接时千万不要跳过 PUBLIC/PRIVATE,这会导致未来的链接都没有关键字
- 不要使用全局函数,例如
-
良好的 CMake 用法
- 把 CMake 视作代码,保持它的整洁和可读性
- 围绕 target 构建你的 CMake。将需要的信息打包在 target 里,然后链接那个目标
- 导出你的接口
- 写 Config.cmake 文件,这是一个库作者应该做的,可以方便别人使用你的库
- 使用 ALAS 目标,以保持使用一致性
- 将常用的功能提取成函数或者宏,通常函数更好
- 使用小写的函数名,全大写是变量
- 使用 cmake_policy 或者 range of versions
2. 基础语法
-
最低版本
cmake_minimum_required(VERSION 3.1)
指定最低版本cmake_minimum_required(VERSION 3.7...3.18)
CMake 3.12+ 后,可以指定版本范围
-
Project
VERSION
指定版本,并设置一系列变量,例如MyProject_VERSION
等DESCRIPTION
项目的描述LANGUAGES
支持C/CXX/Fortran/ASM/CUDA(3.8+)/CSharp(3.8+)/SWIFT(3.15+)
,C/C++
为默认值
project(MyProject VERSION 1.0 DESCRIPTION "Very nice project" LANGUAGES CXX)
-
生成可执行文件
add_executable(one two.cpp three.h)
-
生成库
- 库类型包括 STATIC、SHARED、MODULE、OBJECT 等。如果没有指定库类型,那么
BUILD_SHARED_LIBS
的值将决定编译 STATIC 或者 SHARED - 有些库并不需要编译,例如 header only 库。基于 target 的思想,我们可以将它们打包在一种叫 INTERFACE 的库中。
add_library(one STATIC two.cpp three.h)
- 库类型包括 STATIC、SHARED、MODULE、OBJECT 等。如果没有指定库类型,那么
-
给 target 添加属性
- target_include_directories 为 target 添加 include 路径
- PUBLIC 对于可执行文件而言意义不大,对于库来说,它让 CMake 知道链接这个目标的目标也需要这个 include 目录。也就是 “我自己要用,其他链接我的也要用”,具有传递性
- PRIVATE 表示 “我自己用,其他人不用”
- INTERFACE 表示 “我自己不用,其他链接我的要用”
- target 的属性包括 include 文件夹、需要链接的库、编译选项、宏定义等,这些都可以通过
target_link_libraries
进行传递
# PUBLIC 表示外部也需要这个 include 目录 target_include_directories(one PUBLIC include) add_library(another STATIC another.cpp another.h) # 由于具有传递性,another 可以连接 one 的 include 目录 target_link_libraries(another PUBLIC one)
变量与缓存
- 局部变量
set(MY_VARIABLE "value")
设置局部变量,其作用域为当前文件夹,以及add_subdirectory
进入的文件夹set(MY_VARIABLE "value" PARENT_SCOPE)
将作用域设置为父目录,通常用在子目录向父目录传递信息set(MY_LIST "one" "two")
会在变量值中间加入 “;”,等价于set(MY_LIST "one;two")
- 缓存变量
- 通过
set(MY_CACHE_VARIABLE "VALUE" CACHE STRING "Description")
设置缓存变量 - 缓存变量将持久存在于
CMakeCache.txt
中 - 通过
cmake -DXXX
命令传递的参数为缓存变量
- 通过
关于局部变量与缓冲变量的示例,请参考 cmake-变量和全局变量缓存
- 环境变量
set(ENV{variable_name} value)
设置环境变量$ENV{variable_name}
获取环境变量
- 属性
- 属性有点像变量,但它依附在某个 target 或者文件、目录上。许多属性的初始值来自于
CMAKE_
开头的变量,例如设置CMAKE_CXX_STANDARD
,将会设置 target 的CXX_STANDARD
属性初始值。 set_property
用于设置属性,get_property
用于获取属性。参看 cmake-properties 查阅有哪些属性
- 属性有点像变量,但它依附在某个 target 或者文件、目录上。许多属性的初始值来自于
用 CMake 编程
-
控制流
- 利用
if
语句来控制程序流 if
语句中支持的关键字包括:- 一元判断:
NOT
、‘TARGET’、EXISTS
、DEFINED
等 - 二元判断:
STRQUAL
,AND
,OR
,MATCH
,VERSION_LESS
,VERSION_LESS_EQUAL
- 一元判断:
if(variable) # If variable is `ON`, `YES`, `TRUE`, `Y`, or non zero number else() # If variable is `0`, `OFF`, `NO`, `FALSE`, `N`, `IGNORE`, `NOTFOUND`, `""`, or ends in `- NOTFOUND` endif() # If variable does not expand to one of the above, CMake will expand it then try again if(NOT TARGET libA OR EXISTS "test.xml") # If libA or test.xml exist endif()
- 利用
-
生成器表达式, generator-expressions
- CMake 大多数命令在 configure 阶段就被执行了,而生成器表达可以在 build 阶段,或者 install 阶段被执行
target_include_directories(MyTarget PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> $<INSTALL_INTERFACE:include> )
-
宏与函数
- 它们的区别:函数里头的变量作用域为该函数,而宏没有作用域
- 推荐用函数,因为它不会 “泄露” 变量。即使想要从函数中传递某些变量给外面,可以主动通过
PARENT_SCOPE
来设置
-
参数。可以通过
cmake_parse_arguments
来解析函数参数
与你的代码进行交互
- 从 CMake 中获取信息,并生成代码。
configure_file
命令可以实现这个。 - 读取文件,例如从
version.h
中获取版本信息。file
命令可以实现这个。
如何组织你的工程
- project
- .gitignore
- README.md
- LICENCE.md
- CMakeLists.txt
- cmake
- FindSomeLib.cmake
- something_else.cmake
- include
- project
- lib.hpp
- src
- CMakeLists.txt
- lib.cpp
- apps
- CMakeLists.txt
- app.cpp
- tests
- CMakeLists.txt
- testlib.cpp
- docs
- CMakeLists.txt
- extern
- googletest
- scripts
- helper.py
运行其他程序
- 在 configure 阶段运行命令
execute_process
在 configure 阶段运行命令
find_package(Git QUIET) if(GIT_FOUND AND EXISTS "${PROJECT_SOURCE_DIR}/.git") execute_process(COMMAND ${GIT_EXECUTABLE} submodule update --init --recursive WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} RESULT_VARIABLE GIT_SUBMOD_RESULT) if(NOT GIT_SUBMOD_RESULT EQUAL "0") message(FATAL_ERROR "git submodule update --init failed with ${GIT_SUBMOD_RESULT}, please checkout submodules") endif() endif()
- 在 build 阶段运行命令
find_package(PythonInterp REQUIRED) add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/include/Generated.hpp" COMMAND "${PYTHON_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/scripts/GenerateHeader.py" --argument DEPENDS some_target) add_custom_target(generate_header ALL DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/include/Generated.hpp") install(FILES ${CMAKE_CURRENT_BINARY_DIR}/include/Generated.hpp DESTINATION include)
cmake -E <mode>
运行内置的一些命令,例如解压、复制等,具体参看 Run a Command-Line Tool
一个简单的示例
3. 添加特性
C++11 及以后
- CMake 3.8+: Meta compiler features
target_compile_features(myTarget PUBLIC cxx_std_11)
开启 c++11 特性,当然你也可以选择cxx_std_14
和cxx_std_17
set_target_properties(myTarget PROPERTIES CXX_EXTENSIONS OFF)
,关闭扩展特性
- CMake 3.1+: 全局和属性设置
- 全局变量设置
set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF)
- 属性设置
set_target_properties(myTarget PROPERTIES CXX_STANDARD 11 CXX_STANDARD_REQUIRED YES CXX_EXTENSIONS NO )
琐琐碎碎
-
地址无关代码(Position independent code; PIC),CMake 会自动将
-fPIC
添加到 SHARED 和 MODULE 库中,当然你也可以显示指定- 作用于全局变量,
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
- 或者作用于 target,
set_target_properties(lib1 PROPERTIES POSITION_INDEPENDENT_CODE ON)
- 作用于全局变量,
-
小型库
- 如果你需要
dl
库,那么这么写最简单target_link_libraries(libA PRIVATE ${CMAKE_DL_LIBS})
- 有些库就没有这么方便了,例如
m
,pthread
等,但你可以这么写find_library(MATH_LIBRARY m) if(MATH_LIBRARY) target_link_libraries(MyTarget PUBLIC ${MATH_LIBRARY}) endif()
- 如果你需要
-
Interprocedural optimization,运行时优化(link time optimization),即
-flto
- CMake 3.9+ 支持设置全局变量开启
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)
- CMake 3.8+ 支持设置属性
set_target_properties(myTarget PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON)
- 通过
check_ipo_supported
来检查当前版本是否支持 LTOinclude(CheckIPOSupported) check_ipo_supported(RESULT result) if(result) set_target_properties(foo PROPERTIES INTERPROCEDURAL_OPTIMIZATION TRUE) endif()
- CMake 3.9+ 支持设置全局变量开启
-
在 CMake 中可以配合其他工具
-
CMake Modules 非常有用,简单介绍一些常用的
- CMakeDependentOption,根据一组变量来设置选项,例如
include(CMakeDependentOption) cmake_dependent_option(BUILD_TESTS "Build your tests" ON "VAL1;VAL2" OFF)
- CMakePrintHelpers,用于打印属性或变量
- CheckCXXCompilerFlag,用于判断是否支持某个编译选项
- WriteCompilerDetectionHeader,与 CheckCXXCompilerFlag 类似,但它更强大。它可以找到编译器支持的特性列表,并生成 C++ 头文件,让你知道哪些特性可用
- try_compile/try_run,可以让尝试编译(运行)一些代码,这可以让你获得系统能力的信息
- CMakeDependentOption,根据一组变量来设置选项,例如
-
调试 CMake 代码
- 打印永远是调试的最简单的办法
message(STATUS "MY_VARIABLE=${MY_VARIABLE}") include(CMakePrintHelpers) cmake_print_variables(MY_VARIABLE) cmake_print_properties( TARGETS my_target PROPERTIES POSITION_INDEPENDENT_CODE)
--trace-source="filename"
让你观察 CMake 文件到底发生了什么,例如:cmake -S . -B build --trace-source=CMakeLists.txt
- 打印永远是调试的最简单的办法
4. 引入其他项目
Submodule
- 通过
git submodule add ../../owner/repo.git extern/repo
将 extern 添加为子仓库 - 可以在 CMake 中内置子仓库更新命令
find_package(Git QUIET) if(GIT_FOUND AND EXISTS "${PROJECT_SOURCE_DIR}/.git") # Update submodules as needed option(GIT_SUBMODULE "Check submodules during build" ON) if(GIT_SUBMODULE) message(STATUS "Submodule update") execute_process(COMMAND ${GIT_EXECUTABLE} submodule update --init --recursive WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} RESULT_VARIABLE GIT_SUBMOD_RESULT) if(NOT GIT_SUBMOD_RESULT EQUAL "0") message(FATAL_ERROR "git submodule update --init failed with ${GIT_SUBMOD_RESULT}, please checkout submodules") endif() endif() endif() if(NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/repo/CMakeLists.txt") message(FATAL_ERROR "The submodules were not downloaded! GIT_SUBMODULE was turned off or failed. Please update submodules and try again.") endif()
DownloadProject
- 在 CMake 3.11 以前,主要下载命令都是在 build 阶段完成的(例如
ExternalProject_Add
)。这就导致你无法使用add_subdirectory
。 - CMake 3.11之前,如果想要在 configure 阶段下载,参考 Crascit/DownloadProject
Fetch(CMake 3.11+)
- CMake 3.11 后,你可以使用 FetchContent 在 configure 阶段下载文件或者项目
5. 测试
- 通常不在子目录时编译测试代码
if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME AND BUILD_TESTING) add_subdirectory(tests) endif()
- 通过
add_test(NAME TestName COMMAND TargetName)
注册测试
GoogleTest
- 推荐通过以 git submodule 的形式引入 GoogleTest
- 可以通过作者提供的
AddGoogleTest
工具引入 GoogleTest - 通过
FetchContent
引入 GoogleTest
Catch
- Catch 是个 header only 库,如果直接将它的 include 引入你的仓库中,你需要做一些准备,以便使用
# Prepare "Catch" library for other executables set(CATCH_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/extern/catch) add_library(Catch2::Catch IMPORTED INTERFACE) set_property(Catch2::Catch PROPERTY INTERFACE_INCLUDE_DIRECTORIES "${CATCH_INCLUDE_DIR}")
- 通过
ExternalProject
、FetchContent
或者 git submodule 引入 Catch,那么直接add_subdirectory
即可
6. Exporting and Installing
- 关于 Installing,参考 CMake之install方法的使用
- 关于 Exporting,可以将 build directories 信息导出,以供其他工程使用(注意与 Installing 的区别:Installing 导出的是安装目录的信息)
- 关于 Package ,没怎么用过,似乎是将你需要的内容进行打包
7. 寻找库
介绍了引入 CUDA、OpenMP、Boost、MPI、ROOT、Minuit2 等库的标准姿势