Last Updated on 2024年2月18日
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, 你看到了一个Fancy的功能, 用后却发现很坑, 但你只要能忍住诱惑不使用它们, 只用自己熟悉的部分, 完全可以在有限的范围内正常实现所有功能. 而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来使用;"${var_name}"
大部分时候和${var_name}
一致, 但对于列表, 它可以用于生成;
分割的字符串- CMake中所有argument有三类解读方法, 具体怎么用是由func/macro来决定的.
- 传入值作为var_name使用, 也就是说, 函数会在内部必要的位置通过
${...}
来获取对应的值, 例如, list相关的API就要求传入var_name, 所以不支持literal形式的list - 传入值作为value使用
- 既可以作为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 的 PREFIX 意思是install prefix, 在搜索时, 会自动在prefix下的子目录搜索, 如
<prefix>/include, <prefix>/bin, <prefix>/cmake
Export, Install and find_package
CMake中, 当我们说到Packging时, 是说打包出deb/whl/rpm等release给最终用户的文件. Export/Install/find_package则是一组相关性更紧密的功能.
在CMake生态中, install 主要有两个场景
- 安装后的文件不会再被其他CMake项目引用, 例如, 安装binary或runtime, 这类安装一般只需要把相关文件拷贝到正确的位置即可.
- 安装后的文件可能会被其他CMake项目引用, 例如, 安装library, 这类安装除了需要拷贝文件, 往往还需要创建额外的一些cmake辅助文件, 以便于其他项目快捷的依赖它.
鉴于第2个应用场景是第1个的超集, 这里只说明它.
首先简单说明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之后会有什么效果,并没有统一的规定, 不过一般而言
- 它们都需要创建一些target,变量,以供引用方使用.
FindXXX.cmake
需要完全手写XXXConfig.cmake
- 有更好的调用框架,
find_package
会预定义一组${CMAKE_FIND_PACKAGE_NAME}
开头的变量名,用于作为输入值和返回值, 例如, 如果在XXXConfig.cmake中设置${CMAKE_FIND_PACKAGE_NAME}_NOT_FOUND_MESSAGE
了,那么find_package就会认为导入失败,进一步XXX_FOUND
会自动由find_pacakge
设置 - Target创建相关的代码可以由CMake自动生成,而不用手写.
- 有更好的调用框架,
在开发中,FindXXX.cmake
一定是完全手写的, 一般会存在维护滞后的问题, 它仅用于当XXX自身不是用CMake构建的场景. 对于使用CMake的项目,它创建的Install总应该是Config Package.
Install
在用户侧, 用户只能感知到COMPONENT, 当用户find_package时, 可以指定一系列COMPOENT, 这些COMPONENT将使得相应的target被导入,相关的变量被设置等.
而对于Dev侧, 我们则需要完成用户的这种请求, 也就是说, 当find_package找到我们提供的XXXConfig.cmake时,主要逻辑是:
- 分析用户给出的COMPONENT和COMPONENT_REQUIRED,看是否能满足.
- 将这些COMPOENT映射到对应的EXPORT-SET文件,并将对应的EXPORT-SET文件include
- 设置一些额外的变量,参数, 如更新module path, 创建一些XXX_INCLUDE_DIRECTORY变量等
install是一系列完全不同的指令,第一个Keyword将指定其工作模式,常用的有
install(TARGETS <target>... [...])
:将target相关的内容输出到安装目录,它的功能最复杂, 因为通常一个target会有涉及多种可安装的属性,例如, 可能包含头文件(PUBLIC HEADER),可执行文件(RUNTIME),静态库(ARCHIVE),动态库(LIBRARY),配置文件(FILE_SET)等,甚至可以包括include目录这种基本参数.install({FILES | PROGRAMS} <file>... [...])
: 将文件输出到安装目录,PROGRAM的区别是会自动赋予+x权限install(DIRECTORY <dir>... [...])
: 将目录输出到安装目录install(EXPORT <export-set> [...])
: 将export-set对应的{export-set}.cmake
输出到安装目录
这里强调一下 COMPONENT 和 EXPORT-SET:
- COMPONENT: 每个install command 都可以指定 COMPONENT, 这个COMPONENT 将决定相关的install指令是否被执行.
# cmake --install ${BUILD_DIR}
for install_command in CMakeLists.txt:
if not install_command.component.exclude_from_all():
install_command.execute()
- EXPORT-SET:
- 每个
install(TARGETS)
都可以额外指定一个EXPORT-SET install(EXPORT ${EXPORT_SET_NAME})
将会创建一个默认名为${EXPORT_SET_NAME}.cmake
的文件, 它包含了相应SET内的所有TARGET
- 每个
- export-set是开发者一侧内部组织文件使用的, 而component则是用户能感知到的.
注意, install(EXPORT) 自身也可以指定COMPOENT, 这个COMPONENT可以和
${EXPORT_SET_NAME}
对应的各个install指令不同. 这可能导致一些bug, 例如,install(TARGETS foo EXPORT my_set COMPONENT comp0)
和install(EXPORT my_set COMPONENT comp1)
,如果只安装了comp1却没有安装comp0,那么将导致my_set.cmake
中的部分指令找不到相应的文件.
Tricks
XXXConfig.cmake
和XXXConfigVersion.cmake
都需要手动创建,并通过install(FILES)
安装到最终目录- CMakePackageConfigHelpers中的write_basic_package_version_file可以辅助生成
XXXConfigVersion.cmake
,生成后再通过install(FILE)
将其拷贝到安装目录即可
- CMakePackageConfigHelpers中的write_basic_package_version_file可以辅助生成
- install export-set时, 所有能用相对路径表示的值都会自动被修正为相对安装目录的路径.
- library 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.export()
指令可以直接在build目录生成{export-set}.cmake
文件, 进一步的,如果我们把install相关的文件也创建在build目录中, 那我们就可以把build目录近似改造成"安装目录"-DXXX_DIR=...
是辅助查找Config Package的常用手段
Traps
- XXXConfig.cmake导入的Package会有隐式的Cache行为, 只有第一次调用会真的执行导入.当XXXConfig.cmake被删除后, 才会尝试搜索新的文件重新导入.
- FindXXX.cmake导入的Package也会有隐式Cache行为, 不过必须要完全重新Configure才能检测到文件的缺失.
- 无论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.
-
CMake的一次Configure中, 只能使用一个toolchain, 也就是说, 不存在Bazel中host-toolchain和target-toolchain的区别, 只有target-toolchain
-
Shared Library 和 Module Libray 的区别是, 后者不用于链接, 而是在运行期dlopen打开
-
Version:
- Major, 不兼容的变动
- Minor, 兼容 + new feature
- Patch, 兼容 + bugfix
-
SOVERSION一般仅对应Major, 它决定了生成lib的SONAME, VERSION则决定了生成文件的完整版本.
-
Do NOT use macro when possible
-
子目录中的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阶段处理.