Last Updated on 2023年4月9日
A Note for CMake
CMake可以说是目前C++项目的标准构建系统, 尽管它有很多不足, 但是它已经成功的替换掉了autoconf这一代的构建工具. 除非有足够的理由, 在选择构建系统时, CMake总是应当第一优先考虑.
我熟悉的构建系统只有CMake和Bazel, 事实上, 如果能满足若干客观条件的话, 我更愿意使用Bazel, 不过这篇主要记录的是CMake, 所以还是以CMake为主. 在我看来, CMake主要的优缺点如下:
Pros:
- Imperative: 可以把CMake当做一个脚本语言来阅读, 这更符合大家的编程习惯.
- Widely-used: 你只要大致会使用CMake, 那么世界上的大部分项目都可以被你使用了.
- Easy-at-beginning: 上手成本很低, 简单的binary和library都很容易被描述出来, 对新手友好.
Cons:
- Too many traps: 你必须要非常熟悉CMake, 才能写出稳定可靠的CMake脚本, 否则, 处处都有坑你的陷阱. reddit上曾有一个评论, 我很赞同, 大意是: "C++的Trap是那种带有致命诱惑的Trap, 但你只要能忍住诱惑不使用它们, 完全可以在有限的范围内正常实现所有功能. 而CMake的Trap则就是无处不在的Trap, 你不避开它们, 就会写出难以维护, 甚至错误的代码, 进而陷入深渊"
- Shit DSL: CMake的DSL已经是出了名的差, 数据类型成迷, 函数传参方式成迷, 变量字符串混用等等, 这都导致阅读/编写时有很大的心智负担.
- Shit API Design: 由于要保证完整的前向兼容性, 所有历史包袱都需要保留下来, 这就导致API的行为风格非常混乱, 例如, 有的API需要传值, 而有的则需要传变量, 而有的变量和值均可.
- Poor deps management: 尽管现在已经有了CPM这样的项目可以更好的管理第三方依赖, 不过总的来说, CMake中使用第三方依赖仍然不如Bazel逻辑清晰.
- No structural target: Target的组织, 可见性, 依赖关系, 仍然需要开发者手动控制, 相比Bazel仍然显得贫瘠.
- Poor docs: CMake的文档真的非常难以看懂, 例如, 几乎所有的文档都没有example. 在Professional CMake出版之前, 完全没有易用实用的Reference存在, 各类流言及技巧只能在互联网的角落中查找.
最后, Professional CMake是我建议的唯一CMake指南, 团购价格还是很便宜的.
- 如果你是新人, 那么只看Part I 即可
- 如果你是高级用户, 那么这本书既可以作为手册, 也可以作为教程.
Tech Notes
CMake内只有一种expression: call-expr
, 也就是形如foo(arg0 arg1 arg2)
这样的表达, 所有的func/macro都支持任意数量的参数, positional的参数靠前, 所有非positional的参数由函数自行进行解读. 传参的效果相当于set(param0 arg0)
- 所有argument实质上都是string.
- CMAKE有一个Scoped symbol table, 用于存储每个scope内的 str -> str 映射. Cached Var 逻辑上是一个优先级最低的 symbol table, 因为它位于最外层
${var_name}
是一个文本替换操作, 它发生在command执行之前, 替换后的内容作为无引号的string来使用.- CMake中所有argument有三类解读方法, 具体怎么用是由func/macro来决定的.
- 传入值作为var_name使用, 也就是说, 函数会在内部必要的位置通过
${...}
来获取对应的值, 例如, list相关的API就要求传入var_name, 所以不支持literal形式的list - 传入值作为value使用, 也就是说, 调用方要么使用literal, 要么需要手动进行
${...}
展开 - 既可以作为value, 也可以作为var_name, 但是var_name优先, 例如
foo(my_v)
, 如果存在my_v
这个var_name存在时, 则函数内会通过${my_v}
取其值, 否则将作为一个字符串"my_v"处理.
- 传入值作为var_name使用, 也就是说, 函数会在内部必要的位置通过
- 在CMAKE脚本中, 空格一般是作为argument的分隔符使用, List是指以
;
作为分隔符的string, 除非被""抑制, 否则;
和空格等价 - List/math等相关的API仅仅是特殊的string操作函数.
- func/macro实质上只支持position传值, kwarg实际是通过特殊的字符串parsing来实现的.
find_xxx()
find系列的指令都有隐式的cache行为, 也就是说, 用同样的OutputVar和同样的target进行查找时, 后续的查找会自动使用先前找到的值.
- find_file: 用于查找
*.h
头文件 - find_library: 用于查找
*.a
,*.so
的库文件 - find_program: 用于查找可执行文件
- find_package: 用于查找特定的
*.cmake
文件, 并自动触发相关的调用
find_xxx的搜索路径规则异常复杂, 我们通常只需要知道CMAKE_PREFIX_PATH > HINT > PATH
这三个就够了, 除非搜索的结果总是不符合预期, 一般不用过于关注搜索路径的问题.
CMAKE_PREFIX_PATH 的 REFIX 意思是install prefix, 在搜索时, 会自动在prefix下的子目录搜索, 如
<prefix>/include, <prefix>/bin, <prefix>/cmake
Export, Install and find_package
CMake中, 当我们说到Packging时, 是说打包出deb/whl/rpm等release给最终用户的文件. Export/Install/find_package则是一组相关性更紧密的功能.
首先简单说明find_package
. find_package
有两种工作模式, 一种是查找名为Find{PkgName}.cmake
的文件, 这样查找到的package称为 Module Package, 另一种是查找名为{PkgName}Config.cmake
的文件, 被称为是 Config Package. 无论哪种模式, 找到对应的文件后, 都会将相应的cmake文件按include()
的逻辑调用一遍, 它的逻辑大致如下.
marco find_package(...):
if file_found(...):
do_some_pre_processing(...)
include_found_cmake_file()
从实现上说,{PkgName}Config.cmake
或Find{PkgName}.cmake
的内容都是由供应方手动编写的, 在被find_package执行include之后会有什么效果,并没有统一的规定,不过一般而言
FindXXX.cmake
需要手动设置XXX_FOUND
,XXX_INCLUDE_PATH
,XXX_LIBRARY_PATH
等变量,以供调用方使用XXXConfig.cmake
- 有更好的调用框架,
find_package
会预定义一组${CMAKE_FIND_PACKAGE_NAME}
开头的变量名,用于作为输入值和返回值,例如, 如果在XXXConfig.cmake中设置${CMAKE_FIND_PACKAGE_NAME}_NOT_FOUND_MESSAGE了,那么find_package就会认为导入失败,进一步XXX_FOUND
会自动由find_pacakge
设置 - 一般需要定义若干
AA::BB::CC
风格的Imported target
,这些target
应当自包含, 头文件/include_dir/link_dir等都应该已经设置好 - 一般也会输出一些
XXX_INCLUDE_PATH
之类的值,以供调用方使用 - 一般会自动把一些目录添加到CMAKE_MODULE_PATH中,以导入一些新的module.
- 有更好的调用框架,
在开发中,FindXXX.cmake
一定是完全手写的, 一般会存在维护滞后的问题. 而Config Package 则只需要手写部分核心逻辑, CMake可以自动生成Imported target相关的代码, 简化编写的难度.
Install
install是一系列完全不同的指令,第一个Keyword将指定其工作模式,常用的有
- install(TARGETS
… […]) :将target相关的内容输出到安装目录,它的功能最复杂 - install({FILES | PROGRAMS}
… […]): 将文件输出到安装目录,PROGRAM的区别是会自动赋予+x权限 - install(DIRECTORY
… […]): 将目录输出到安装目录 - install(EXPORT
[…]): 将export-set对应的 {export-set}.cmake
输出到安装目录
名词:
- COMPONENT: 仅对
cmake --install
有意义, 主要用于过滤install.- 每一个install指令都可以指定所属COMPONENT,如果COMPONENT被排除,那么就相当于对应的
install(...)
指令不存在一样. cmake --install
默认会安装所有没有标记EXCLUDE_FROM_ALL
的COMPONENTcmake --install {build_dir} --component comp
会安装特定的component(多个component似乎需要重复调用)
- 每一个install指令都可以指定所属COMPONENT,如果COMPONENT被排除,那么就相当于对应的
- NAMESPACE: 仅对
install(TARGETS)
有效, 安装的target
名字会被修改, 将会被添加NAMESPACE指定的前缀. - export-set:
install(TARGETS)
可以通过EXPORT
参数指定自己所属的export-set
install(EXPORT)
会将export-set
相关的所有target都导出到名为{export-set}.cmake
的一个文件中,直接include该文件就能使用所有的导出target
- 从实现上说,COMPONENT,NAMESPACE,export-set是完全正交的三个功能,但是一般而言,都是将这三者绑定起来使用.
- COMPONENT一般会输出到
{proj}_{comp}.cmake
中, 当COMPONENT被过滤时,相应的{proj}_{comp}.cmake
也不会被创建 - COMPONENT相关的target都保存在
{proj}::{comp}::
的NAMESPACE中,逻辑更加清晰
- COMPONENT一般会输出到
Tricks:
XXXConfig.cmake
和XXXConfigVersion.cmake
都需要手动创建,并通过install(FILES)
安装到最终目录- install过程中, 所有能用相对路径表示的值都会自动被修正为相对安装目录的路径.
- target可以设置PUBLIC_HEADER属性,来自动安装相应的头文件.
- 安装目录最好使用
include(GNUInstallDirs)
生成,通过${CMAKE_INSTALL_INCLUDEDIR}
读取 - target的所有属性都会被导出到安装产物中,比较重要的就是
include_directory
, 它一般是一个绝对路径,且对安装后的产物一般没有意义. 对于可能被安装的library,最好通过过$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/somewhere>
来为其添加include_dir,这样可以使得相关的路径只在编译时可用,在安装时则不会添加相关的路径. - set(CMAKE_INSTALL_RPATH $ORIGIN $ORIGIN/${relDir}) 可以自动为后续创建的target修改安装后的rpath,也就是说, build和install会自动使用不同的rpath.
- CMakePackageConfigHelpers中的write_basic_package_version_file可以辅助生成
XXXConfigVersion.cmake
,生成后再通过install(FILE)
将其拷贝到安装目录即可 export()
指令可以直接在build目录生成{export-set}.cmake
文件, 进一步的,如果我们把install相关的文件也创建在build目录中, 那我们就可以把build目录近似改造成"安装目录"-DXXX_DIR=...
是辅助查找Config Package的常用手段
Traps:
- XXXConfig.cmake导入的Package会有隐式的Cache行为, 只有第一次调用会真的执行导入.当XXXConfig.cmake被删除后, 才会尝试搜索新的文件重新导入.
- FindXXX.cmake导入的Package也会有隐式Cache行为, 不过必须要完全重新Configure才能检测到文件的缺失.
- Config Package的COMPONENT REQUIRED OPTIONAL字段需要由
XXXConfig.cmake
处理, 这一般是很麻烦的一套逻辑 - 无论Module还是Config,都要遵循0或全的规则,若导入过程中失败,应当完全恢复到未执行find_package的状态.
Others
-
CMAKE会自动handle linking logic, 例如 A PRIVATE link B, 且A是一个静态库, 那么当任何target链接A时, 最终的linker指令中仍然会链接B, 保证symbol正确, 但是如果A是动态库, 则target链接A时, 不会链接B。
-
CMAKE会自动解决环形链接, 例如target_link_libraries(A PUBLIC B) target_link_libraries(B PUBLIC A), 当外界链接A或者B时, 会自动被展开为A, B, A, B的链接顺序, 从而通过double pass 来避免symbol miss, 当出现成环的链接关系时, 优先使用object library可能会更好。
-
CMake的一次Configure中, 只能使用一个toolchain, 也就是说, 不存在Bazel中host-toolchain和target-toolchain的区别, 只有target-toolchain
-
Shared Library 和 Module Libray 的区别是, 后者不用于链接, 而是在运行期dlopen打开
-
Version:
- Major, 不兼容的变动
- Minor, 兼容 + new feature
- Patch, 兼容 + bugfix
-
SOVERSION一般仅对应Major, SONAME同样, 一般是 MyLib.MAJOR
-
Do NOT use macro
-
子目录中的Project()调用没有实质性影响, 它的主要功能在于定义projectName_BINARAY_DIR这个变量, 并更新PROJECT_SOURCE_DIR到正确值
-
camke文件可以用if(DEFINED xxxx)这样的header-guard, include_guard()是一个类似于
pragma once
的语法糖 -
CMAKE_CURRENT_FUNCTION_LIST_DIR, CMAKE_CURRENT_LIST_DIR, PROJECT_SOURCE_DIR是比较稳定的几个路径.
-
不要使用env变量
-
Cache Var 仅在不存在时才会被创建, 这导致重复Configure时可能存在问题.
- 尽管可以Force修改Cache Var, 但是尽量不要这么做
-
避免任何形式的重名: Cache-Var 和 Var 重名, Var 和 Literal 重名等
-
Cast to Bool:
- Literal优先, 也就是说, 无论是否带括号, 无论是否大小写, 都按Literal处理, 例如 if(TRUE)和if("Yes")都永远为真, 注意
""
空字符串是一个作为falsy解读的Literal - 不带引号的 someVar 总是当做变量名处理
${someVar} not in {false_set}
, 也就是说, someVar默认为true, 必要时才为false - 带引号的 "someString"总是当做字符串处理,
"someString" in {true_set}
, 也就是说, "someString"默认为false, 必要时才为true - 注意: if(ENV{some_var})总是false, 它并不会被当做变量来处理
- Literal优先, 也就是说, 无论是否带括号, 无论是否大小写, 都按Literal处理, 例如 if(TRUE)和if("Yes")都永远为真, 注意
-
cmake_minimum_required(VERSION 3.1) 不仅会限制最低版本, 还会让高版本的CMAKE按指定的版本限制执行。
- 如果可能, 应当使用尽可能高的minimum required
-
$<>
这样的代码称为Generator Expression, 它是在Generation阶段才被eval的. 因为有很多值需要再Generation阶段才能获得, 这些值通常是和平台/Generator强相关的, 例如, 对于MSVC和Apple, Debug/Release类型并不能在Configure阶段获得, 相关的控制必须在Generation阶段处理.
Default Template
Top
# disable library visibility by default
CMAKE_<LANG>_VISIBILITY_PRESET hidden
CMAKE_VISIBILITY_INLINES_HIDDEN ON
# add local module search path
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
toolchain
# a toolchain file should support being included multiple times
CMAKE_SYSTEM_NAME # target system name
CMAKE_CROSSCOMPILING # this will be set to true when CMAKE_SYSTEM_NAME setted
CMAKE_SYSTEM_PROCESSOR
CMAKE_SYSTEM_VERSION
# CMAKE_HOST_SYSTEM_NAME will be set automatically
CMAKE_<LANG>_COMPILER
CMAKE_<LANG>_COMPILER_ID # usually auto infered
CMAKE_<LANG>_COMPILER_VERSION # usually auto inffered
CMAKE_<LANG>_COMPILER_EXTERNAL_TOOLCHAI # usually auto inferd , tools like ld, strip, objdump
# extra flags
CMAKE_<LANG>_FLAGS_INIT,
CMAKE_<LANG>_FLAGS_<CONFIG>_INIT,
CMAKE_<TARGETTYPE>_LINKER_FLAGS_INIT
CMAKE_<TARGETTYPE>_LINKER_FLAGS_<CONFIG>_INIT
# sysroot: a copy (full or patrial) of target platform os , libs, headers third patry files will be found there
CMAKE_SYSROOT # prefer to set CMAKE_SYSROOT, if it is setted find_xxx will not need to use CMAKE_FIND_ROOT_PATH.
CMAKE_CROSSCOMPILING_EMULATOR # Optional, set to emulator to run target progmram
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) # program should run on host
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) # lib and include should be related to target
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)