CMake速记
主要参考书籍《Modern CMake for C++》,以及《CMake Best Practices》,使用CMake版本3.25.
建议先看第一本,再看第二本。
使用
一般而言,cmake的使用方式非常简单。在命令行下使用
cmake -B <build tree> -S <source tree>
cmake --build <build tree>
其中build tree
即build结果文件夹,可以直接用build
,source tree
则是源代码目录,一般就是.
.
上面第一步是构建准备(配置阶段+生成阶段),第二步是真正的构建(包括compile/link/test/package)。
准备阶段
可以通过-G
指定生成器,通过cmake --help
可以看到可用的生成器列表,unix下习惯使用Makefile
或者Ninja
这两种生成器;windows下则习惯使用Visual Studio的各种版本IDE工程文件。
可以通过-C
指定脚本文件,来预填充缓存信息;或者使用-D k=v
直接在命令行里面指定参数;
-U
与-D
的含义相反,是用来删除变量的,这两个参数都可以多次使用。
--log-level=<level>
用来指定日志等级,可以通过CMAKE_MESSAGE_LOG_LEVEL
来永久保留设置;
--trace
跟踪模式,类似断点调试;
开发人员可以提供CMakePresets.json
文件,用来预置相关选项,使用的时候通过--preset=<preset>
来指定预设文件。
构建之后,可以通过-L
列出已填充的变量,-LA
会额外显示出高级变量,-LH
会额外显示变量的帮助信息;注意通过-D
指定的自定义变量,这里是看不到的;
构建阶段
需要传入的参数可以放在命令末尾。例如-j
或者--parallel
来指定并发构建数目。
先清理再构建:--clean-first
.
多配置生成器,可以通过--config <cfg>
来指定配置,包括Debug, Release, MinSizeRel 或 RelWithDebInfo
,默认是Debug.
可以通过-v
参数,打印更多信息。
安装
类似make install
的效果,在cmake中是cmake --install <dir> [options]
.
如果是多配置生成器,使用--config <cfg>
来指定配置,一般是Release
。
单个组件安装,则通过--component <comp>
来指定组件的名字。
unix系统可以指定安装目录的默认权限,格式为--default-directory-permissions <permissions>
,默认权限是755.
其他
cmake还提供了一些跨平台命令,使用cmake -E
来执行,或者使用cmake -P
来运行脚本。后者也可以用Python之类的完成,但是简单的任务可以考虑直接用cmake脚本来完成(后缀就是cmake),不过说实话cmake作为一门脚本语言真的很烂。
语法
基础
cmake的语法有点像bash,比如#
和${}
,不过这只是粗略看来,实际上坑很多。
注释:#
,但是也可以是[[]]
,这个其实是多行字符串的表达方式,类似python中的三引号。两个方括号之间可以加任意数量的=
,只要最后两边对称。在左括号之前可以有一个#
前缀:
#[==[
message("hello world")
#]==]
这样,在第1行前面再加一个#
就可以取消注释,比较方便调试大段代码(不过有IDE的时候这个功能实际上没什么用)。
上文的message
是一个指令,习惯上用snake_case
,不区分大小写。指令调用不是表达式,不能作为另外一个指令的参数。
双引号参数也能跨越多行,这点和大部分语言并不相同。甚至于,可以不带引号,这个是不推荐的使用方式。
变量
变量通过set
和unset
来赋值/取消赋值:
set("test" "TRUE")
message("test is ${test}")
unset("test")
引用变量是${}
,比较蛋疼的是,这个引用其实是一种替换,从内到外进行替换,所以这里
set("test1" "xxx")
set("n" "1")
message("${test${n}}")
结果是"xxx".
除了普通变量,还有环境变量$ENV{}
和缓存变量$CACHE{}
。前者比较容易理解,后者是在build tree上下文中共享的变量,当普通变量不存在时,会尝试获取缓存变量,可以理解为服务中存放在redis中的变量。
运行cmake脚本时,类似其他脚本,也可以传入参数。脚本中通过${CMAKE_ARGV<n>}
来引用,通过${CMAKE_ARGV}
获取变量个数。
环境变量的设置比较复杂:
set(CACHE{var} value CACHE BOOL "something desc" FORCE)
BOOL那里是变量类型,也可以是FILEPATH
(路径)/STRING
(字符串)/INTERNAL
(一行字符串)。FORCE关键字用于覆盖已有缓存,不加的话不会覆盖已有的,类似redis中的setnx
。
作用域
主要两个作用域:
- 函数作用域:
function
的自定义函数,比较类似一般编程语言; - 目录作用域:
add_subdirectory
嵌套其他目录中的CMakeLists.txt
文件;
当创建嵌套作用域时,cmake会将当前作用域变量的副本复制到嵌套作用域,嵌套作用域执行完毕后,副本会被删除。有点类似函数调用的传值,特别注意的是,如果cmake找不到普通变量,就会尝试找缓存变量;而后者永远是传引用的(还是理解为redis中的key比较简单)。
可以通过set(k v PARENT_SCOPE)
强行修改上一级作用域中的变量,有点类似传入引用,但是这个并不会修改本作用域的变量,比较坑。
显然,环境变量和缓存变量的作用域都是全局的。
列表
这是cmake中唯一内置的数据结构,表现形式是分号分割的字符串:
set(myList "a;b;c;d")
message(${myList})
这个结果是"abcd",因为后面不带引号传递变量时,会自动解包。
可以通过list
指令来进行常用的列表操作,包括:
list(LENGTH myList len) # myList的长度赋予变量len
list(GET myList 0 value) # 索引从0开始
list(JOIN <list> <glue> <out-var>) # 用分隔符连接字符串
list(SUBLIST <list> <begin> <length> <out-var>) # 子数组
list(FIND <list> <value> <out-var>) # 查找索引
list(APPEND <list> [<element>...]) # 追加数据
list(FILTER <list> {INCLUDE | EXCLUDE} REGEX <regex>) # 正则过滤
list(INSERT <list> <index> [<element>...]) # 插入元素
list(POP_BACK <list> [<out-var>...]) # 弹出尾部元素
list(POP_FRONT <list> [<out-var>...]) # 弹出头部元素
list(PREPEND <list> [<element>...]) # 前端增加元素
list(REMOVE_ITEM <list> <value>...) # 移除元素
list(REMOVE_AT <list> <index>...) # 按索引移除元素
list(REMOVE_DUPLICATES <list>) # 去重
list(TRANSFORM <list> <ACTION> [...]) # 变换
list(REVERSE <list>) # 翻转
list(SORT <list> [...]) # 排序
条件语句
if(<condition>)
<commands>
elseif(<condition>)
<commands>
endif()
类似其他语言,不过<condition>
判断布尔量的方式比较特别:
- 如果condition是引号变量或者方括号变量,则仅当字符串为
ON
,Y
,YES
或者TRUE
时,对应bool值true,其他字符串均为false;如果是数字,则非0数字皆为true;- 如果condition是不带引号的变量,仅当字符串为空,
OFF
,NO
,FALSE
,N
,IGNORE
,NOTFOUND
或者以-NOTFOUND
结尾的字符串时,对应false,其余字符串均对应true;数字的判断逻辑同上;强烈建议不要使用第二个逻辑,即将所有参数都加上引号。
逻辑操作符:AND
, OR
, NOT
,类似python.
判断变量是否已经定义:if(DEFINED <xxx>)
,也可以判断CACHE和ENV变量。
数值比较操作符:EQUAL, LESS, LESS_EQUAL, GREATER和GREATER_EQUAL,如果用数值和字符串作比较,可以和以数值作为前缀的字符串比较,其他情况都返回false.
可以比较版本号,操作符是在上述操作符的基础上增加VERSION_
前缀。
字符串直接比较,在上述操作符前增加STR
前缀。
可以用MATCHES
做正则匹配,匹配的组在CMAKE_MATCH_<N>
变量里。
可以用in_LIST<var>
判断值是否在列表中。
可以用command<command-name>
判断指令是否可用。
可以用target<target-name>
判断target是否存在,用test<test-name>
判断测试是否存在,用POLICY<policy-id>
判断cmake策略是否存在。
可以用EXISTS<path>
判断文件/目录是否存在;用<file1>IS_NEWER_THAN<file2>
判断哪个文件更新;用IS_DIRECTORY
,IS_SYMLINK
和IS_ABSOLUTE
判断路径信息。
循环语句
比较类似其他语句,包括:
while(<condition>)
<commands>
endwhile()
foreach(i range 0 10 1) # 迭代范围是闭区间,与python不同
<commands>
endforeach()
foreach(item in LISTS myList ITEMS xx) # xx是追加在myList后面的迭代值
<commands>
endforeach()
压缩列表:
可以用来模拟map,由于cmake仅支持list不支持map,所以只能用两个list的索引匹配来映射map. 3.17之后可以用以下语法来同时遍历两个列表:
foreach(num IN ZIP_LISTS LIST1 LIST2)
message("left is ${num_0}, right is ${num_1}")
endforeach()
# 或者可以用两个变量:
foreach(key value IN ZIP_LISTS LISTS1 LIST2)
message("left is ${key}, right is ${value}")
endforeach()
需要注意的是,如果两个LIST的长度不一样,短列表对应的变量是不存在的。
宏和函数
即自定义指令,概念比较类似C语言中的宏和函数。大家都知道C语言中的宏是不推荐使用的,显然这里也一样。
function(myFunc arg1 arg2)
<commands>
endfunction()
函数的内置变量包括:
• CMAKE_CURRENT_FUNCTION: 当前函数的名字
• CMAKE_CURRENT_FUNCTION_LIST_DIR: 当前函数对应的文件夹
• CMAKE_CURRENT_FUNCTION_LIST_FILE: 当前函数对应的文件
• CMAKE_CURRENT_FUNCTION_LIST_LINE:当前行数
类似bash,参数也可以通过$ARG<n>
来访问,通过${ARGV}
获取完整的参数列表。除了动态参数的函数,其他情况下不建议使用这个方式来传参。
编程范式
和C语言一样,函数要先声明才能引用,所以正常来说必须这样:
cmake_minimum_required(VERSION 3.20.0)
project(test)
function(f1)
endfunction()
function(f2)
endfunction()
f1()
f2()
如果想要先写主程序再写代码,可以使用marco技巧:
cmake_minimum_required(VERSION 3.20.0)
project(test)
macro(main)
f1()
f2()
endmacro()
function(f1)
endfunction()
function(f2)
endfunction()
main()
由于宏只是替换,所以可以在宏里面访问全局变量。
实用命令
message命令
message是最常用的,可以增加额外的mode参数,即:message(<mode> "text")
mode包括:
• FATAL_ERROR: 将停止处理和生成。
• SEND_ERROR: 将继续处理,但跳过生成。
• WARNING: 继续处理。
• AUTHOR_WARNING: CMake 警告。继续处理。
• DEPRECATION: 若 启 用 了 CMAKE_ERROR_DEPRECATED
或 CMAKE_WARN_DEPRECATED
变量,将做出相应处理。
• NOTICE 或省略模式 (默认): 将向 stderr 输出一条消息,以吸引用户的注意。
• STATUS: 将继续处理,建议用于用户的主要消息。
• VERBOSE: 将继续处理,用于通常不是很有必要的更详细的信息。
• DEBUG: 将继续处理,并包含在项目出现问题时可能有用的详细信息。
• TRACE: 将继续处理,并建议在项目开发期间打印消息。通常,在发布项目之前,将这些类型
的消息删除。
换句话说,这是一个log工具,可以选择日志等级。cmake
指令的–log-context
参数可以打印message对应的上下文(函数名等),用来调试。
如果想要不同层级之间的缩进,可以用list(APPEND CMAKE_MESSAGE_INDENT " ")
,这样打印起来更加直观。
include命令
类似C语言的include,命令格式:
include(<file|module> [OPTIONAL] [RESULT_VAR <var>])
如果使用了optional,可以增加一个变量返回是否include成功(成功返回路径,失败返回NOTFOUND)。
文件默认从当前工作目录解析相对路径,也可以用${CMAKE_CURRENT_LIST_DIR}/<filename>.cmake
指定绝对路径。
注意include不会创建单独的作用域,修改该文件中的变量会影响调用作用域。
include_guard命令
防止被重复include,放在最前面。可选模式:include_guard([DIRECTORY|GLOBAL])
.
顾名思义,DIRECTORY保护当前目录及其以下,GLOBAL保护整个构建流程。
file命令
类似python中的open
,内置的文件读写功能。
execute_process命令
启动子进程。
注意这里不再保证跨平台可用,需要提示用户安装对应的依赖。
构建项目
文中推荐的项目结构如上图。
设置C++标准:
set_property(TARGET <target> PROPERTY CXX_STANDARD <standard>) #设置标准版本,也可以用target_compile_features配置
set(CMAKE_CXX_STANDARD_REQUIRED ON) # 强制打开标准检测
set(CMAKE_CXX_EXTENSIONS OFF) # 关闭非标特性
禁止内构建:
if(PROJECT_SOURCE_DIR STREQUAL PROJECT_BINARY_DIR)
message(FATAL_ERROR "In-source builds are not allowed")
endif()
生成器表达式,一种邪恶的语法:
$<IF:condition,true_string,false_string>
$<IF:codition,true_string,>
$<condition:true_string>
$<expression:arg1,arg2>
编译配置
首先是目标:
- add_executable: 创建可执行文件
- add_library:创建库,包括三种不同的库,如果不设置的话,需要在cmake运行时传入
BUILD_SHARED_LIBS
参数;注意库名称需要全局唯一,习惯上用ALIAS
配合命名空间来保证唯一性; - add_custom_target: 自定义目标,执行脚本之类的任务;
目标配置常用指令:
-
target_compile_features(): 需要具有特定功能的编译器来编译此目标,例如
cxx_std_17
表示17标准,修饰符PUBLIC/INTERFACE适用于头文件也需要新标准特性的场景;可以使用生成器表达式来添加不同编译器的不同选项;一般使用PRIVATE
传递; -
target_sources(): 向已定义的目标添加源,只能手动添加文件列表,没有特别方便的办法;
-
target_include_directories(): 设置预处理器的包含路径,用来给预处理器解析
#include<>
或#include ""
中指定的header;有一个system参数用来标记文件夹是否标准的系统目录; -
target_compile_definitions(): 设置预处理定义,即C中的
#define
定义,可以通过cmake脚本注入数据;也可以通过configure_file
将配置文件生成为头文件; -
target_compile_options(): 特定于编译器的选项,一般是打开各种优化配置;默认的有debug和release模式;
-
target_precompile_headers(): 预编译头文件;
-
set_target_properties():配置目标属性;
target_sources
在添加源文件时,一般使用PRIVATE
修饰符;PUBLIC
/INTERFACE
一般给库目标使用。前者会把源文件附加到依赖当前库的目标上(一般不需要,相当于对外暴露实现,一般只需要暴露头文件);后者更特殊,一般原来添加纯头文件库;
对应的,target_include_directories
一般使用PUBLIC
修饰符,除非是纯粹的头文件才使用INTERFACE;
链接配置
链接的配置其实只有target_link_libraries
。
编译生成的ELF文件是独立的,需要通过链接器进行整合,从而重定位.data, .text等区段。有以下几种类型的库:
- 静态库(.lib/.a),最简单的,使用
add_library(<name> STATIC [<sources> …])
来添加目标; - 动态库(.so/.dll),将上面的
STATIC
替换成SHARED
即可; - 模块库,一种特殊的动态库,可以通过在代码中使用
LoadLibrary
或者dlopen/dlsym
动态加载的库,将上面的STATIC
替换成MODULE
即可; - 对象库,关键字替换为
OBJECT
即可,这种库不会生成真正的库,仅用来分离代码模块。因此不会进行链接,仅有编译过程;
特别注意,所有依赖动态库或者模块库的,在链接的配置里要加上位置无关代码标志:
set_target_properties(dependency_target
PROPERTIES POSITION_INDEPENDENT_CODE
ON)
否则在运行时会出现一些问题。
动态库习惯上需要在目标上设置构建版本和API版本,如:
set_target_properties(
target
PROPERTIES VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR}
)
这样最后创建的so文件会使用版本号作为后缀。
如果是debug版本,可以额外加上一个后缀d:
set_target_properties(
target
PROPERTIES DEBUG_POSTFIX d)
符号可见性问题:
gcc/clang默认头文件中所有符号可见,但是vs默认所有符号都不可见,不过可以强制使用CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS
将其转为一致。
更好的方法是将CXX_VISIBILITY_PRESET
设为HIDDEN
,然后使用generate_export_header
宏进行显式的导出:
add_library(hello SHARED)
set_property(TARGET hello PROPERTYCXX_VISIBILITY_PRESET "hidden")
set_property(TARGET hello PROPERTYVISIBILITY_INLINES_HIDDEN TRUE)
include(GenerateExportHeader)
generate_export_header(hello EXPORT_MACRO_NAME HELLO_EXPORT EXPORT_FILE_NAME export/hello/export_hello.hpp)
target_include_directories(hello PUBLIC "${CMAKE_CURRENT_BINARY_DIR}/export")
在代码里需要使用一个明确的标记:
#include "hello/export_hello.hpp"
class HELLO_EXPORT Hello{
};
显然这里#include
的文件是cmake创建的,所以在写这行代码的时候可能还不存在…
命名冲突问题:
在链接二进制文件或者静态库时,命名经常会冲突,此时链接器会直接报错,简单的处理办法是使用C++的命名空间。
如果链接器提示未定义符号,多半是链接依赖的顺序错了。
如果有循环依赖,可以在链接时重复添加库。
管理依赖
主要介绍find_package指令的使用。
安装配置
安装目标
在CMakeLists.txt
里面增加:
install(TAREGETS target_name)
然后使用cmake --install ./build --prefix /path
来将目标构件安装到系统中。
即使不加--prefix
选项,cmake也知道要把生成构件安装到哪里,默认*nix下,安装目录是:
目标类型 | GNUInstallDirs | 默认位置 | 备注 |
---|---|---|---|
RUNTIME | ${CMAKE_INSTALL_BINDIR} | bin | 可执行文件和dll |
LIBRARY | ${CMAKE_INSTALL_LIBDIR} | lib | 动态库 |
ARCHIVE | ${CMAKE_INSTALL_LIBDIR} | lib | 静态库 |
PRIVATE_HEADER | ${CMAKE_INSTALL_INCLUDEDIR} | include | 私有头文件 |
PUBLIC_HEADER | ${CMAKE_INSTALL_INCLUDEDIR} | include | 公用头文件 |
可以在install参数中增加<TARGET_TYPE> DESTINATION
来修改默认位置。
安装文件和目录
类似的,有install(FILES <TYPE>/<DESTINATION>)
和install(DIRECTORY <DESTINATION>)
来安装文件或者目录。
对于文件,有类似的TYPE预定义和默认安装目录,一般只需要指定类型就行;当然也可以直接指定目录名称。
还有一种install(PROGRAMES...)
,专门用来安装二进制文件的,可以使用PERMISSIONS
设置权限。
单文件install时,可以使用RENAME参数重命名文件。
安装文件夹时,可以使用FILES_MATCHING
来进行通配符(PATTERN
)或者正则(REGEX
)过滤,尾部还可以加上EXCLUDE
用来表示排除文件。
配置导出
如果想要一个库被使用者发现,需要导出包。cmake目前主要使用Config-file package
来供库使用者使用find_package
寻找。
包配置文件习惯上命名为<projectname>-config.cmake
或者<ProjectName>Config.cmake
,注意大小写习惯。该文件里面的内容就是指示头文件和库文件的位置。
还有一个可选的包版本文件,命名为<projectname>-config-version.cmake
或<ProjectName>ConfigVersion.cmake
。
find_package
默认寻找路径是<CMAKE_PREFIX_PATH>/cmake
,所以导出包时也要放到对应的位置。总的来说分为两步:
# 定义导出路径变量(相对路径),并缓存
set(ch4_ex05_lib_INSTALL_CMAKEDIR cmake CACHE PATH "Installation directory for config-file package cmake files")
# 定义导出名称ch4_ex05_lib_export,并指明头文件目录
install(TARGETS ch4_ex05_lib
EXPORT ch4_ex05_lib_export
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
# 导出,使用上文定义的导出名称,指明文件名、命名空间和导出路径
install(EXPORT ch4_ex05_lib_export
FILE ch4_ex05_lib-config.cmake
NAMESPACE ch4_ex05_lib::
DESTINATION ${ch4_ex05_lib_INSTALL_CMAKEDIR}
)
最后,还要生成版本文件:
# Defines write_basic_package_version_file
include(CMakePackageConfigHelpers)
# 与cmake project的主版本号一致
write_basic_package_version_file(
"ch4_ex05_lib-config-version.cmake"
COMPATIBILITY SameMajorVersion
)
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/ch4_ex05_lib-config-version.cmake"
DESTINATION "${ch4_ex05_lib_INSTALL_CMAKEDIR}"
)
打包
使用cpack打包,这个没啥好说的。就是include(CPack)
之后,配置一些打包信息,然后跑CPack命令指定格式进行打包。
在进行部署或者提供deb
/rpm
包时,很有用。
依赖管理
包管理
前面已经介绍了find_package
,除此之外,cmake还提供了find_file
/file_path
/find_library
和find_program
来查找各种需要的文件。
这一系列的指令都有较为复杂的默认行为,通常情况下,使用系统包管理器(*nix)安装的头文件和库,都可以自动找到而无需额外配置。
但是系统包管理器会污染全局库版本,所以更好的办法是使用第三方的包管理器。这里主要推荐了conan和vcpkg这两个包管理工具。前者和cmake融合的比较好,可以直接在cmake中使用,后者则更加独立一些。
windows下编程更推荐vcpkg,使用清单模式用起来很像npm。只需要在cmake命令增加
-DCMAKE_TOOLCHAIN_FILE=[vcpkg root]/scripts/buildsystems/vcpkg.cmake
参数,即可自动下载vcpkg.json
中的依赖。
如果需要其他toolchain配置,则通过-DVCPKG_CHAINLOAD_TOOLCHAIN_FILE
追加。
源码集成
相较于上面的包方案,C/C++更习惯使用源码集成的方案,也就是所谓的供应商模式。这个方案在其他语言里实际上不是那么流行(除了golang,但是go编译很快)。
cmake提供ExternalProject
和FetchContent
两个模块,用来抓取源码,一般推荐使用后者。
include(FetchContent)
# declare where to get si from
FetchContent_Declare(
SI
GIT_REPOSITORY https://github.com/bernedom/si.git
GIT_TAG 5f4b9a5924a8b3509baec07525fda9ad926adcec) # 2.3.0
# populate si to make it available
FetchContent_MakeAvailable(si)
这就OK了,也可以使用FetchContent_Populate
手动控制拉取的模块各个目录放在哪里:
如果第三方库不是基于cmake的,例如使用了autotools或者automake,那就需要使用ExternalProject
,该命令的使用较为复杂,这里不再记录。
文档生成
其实就是用add_custom_target
跑doxygen
来生成文档,这里不做太多记录。
测试
通过ctest可以生成二进制文件进行测试,需要配合各种测试框架进行使用。
cmake还支持一些静态代码分析工具、消杀工具的集成。
自定义任务
主要讲述add_custom_command
如何配置自定义目标使用,以及使用execute_process
调用其他进程。
其他
在《cmake best practices》里面还有很多实用的内容,比如将cmake代码作为单独的项目进行维护,以便复用;如何维护cmake代码,进行cmake性能分析,以及将非cmake项目进行迁移。这些知识偏向于实践,可以在需要的时候再进行查阅。
一般简单项目,只需要知道上面的知识就能够很好的把握了。