A note for cmake

Last Updated on 2024年2月18日

A Note for CMake

CMake可以说是目前C++项目的标准构建系统, 尽管它有很多不足, 但是它已经成功的替换掉了autoconf这一代的构建工具. 除非有足够的理由, 在选择构建系统时, CMake总是应当第一优先考虑.

我熟悉的构建系统只有CMake和Bazel, 事实上, 如果能满足若干客观条件的话, 我更愿意使用Bazel, 不过这篇主要记录的是CMake, 所以还是以CMake为主. 在我看来, CMake主要的优缺点如下:

Pros:

  1. Imperative: 可以把CMake当做一个脚本语言来阅读, 这更符合大家的编程习惯.
  2. Widely-used: 你只要大致会使用CMake, 那么世界上的大部分项目都可以被你使用了.
  3. Easy-at-beginning: 上手成本很低, 简单的binary和library都很容易被描述出来, 对新手友好.

Cons:

  1. Too many traps: 你必须要非常熟悉CMake, 才能写出稳定可靠的CMake脚本, 否则, 处处都有坑你的陷阱. reddit上曾有一个评论, 我很赞同, 大意是: "C++的Trap是那种带有致命诱惑的Trap, 你看到了一个Fancy的功能, 用后却发现很坑, 但你只要能忍住诱惑不使用它们, 只用自己熟悉的部分, 完全可以在有限的范围内正常实现所有功能. 而CMake的Trap则就是无处不在的Trap, 你不避开它们, 就会写出难以维护, 甚至错误的代码, 进而陷入深渊"
  2. Shit DSL: CMake的DSL已经是出了名的差, 数据类型成迷, 函数传参方式成迷, 变量字符串混用等等, 这都导致阅读/编写时有很大的心智负担.
  3. Shit API Design: 由于要保证完整的前向兼容性, 所有历史包袱都需要保留下来, 这就导致API的行为风格非常混乱, 例如, 有的API需要传值, 而有的则需要传变量, 而有的变量和值均可.
  4. Poor deps management: 尽管现在已经有了CPM这样的项目可以更好的管理第三方依赖, 不过总的来说, CMake中使用第三方依赖仍然不如Bazel逻辑清晰.
  5. No structural target: Target的组织, 可见性, 依赖关系, 仍然需要开发者手动控制, 相比Bazel仍然显得贫瘠.
  6. Poor docs: CMake的文档真的非常难以看懂, 例如, 几乎所有的文档都没有example. 在Professional CMake出版之前, 完全没有易用实用的Reference存在.

最后, Professional CMake是我建议的唯一CMake指南, 团购价格还是很便宜的.

  1. 如果你是新人, 那么只看Part I 即可
  2. 如果你是高级用户, 那么这本书既可以作为手册, 也可以作为教程.

Tech Notes

CMake内只有一种expression: call-expr, 也就是形如foo(arg0 arg1 arg2)这样的表达, 所有的func/macro都支持任意数量的参数, positional的参数靠前, 所有非positional的参数由函数自行进行解读. 传参的效果相当于set(param0 arg0)

  1. 所有argument实质上都是string.
  2. CMAKE有一个Scoped symbol table, 用于存储每个scope内的 str -> str 映射. Cached Var 逻辑上是一个优先级最低的 symbol table, 因为它位于最外层
  3. ${var_name}是一个文本替换操作, 它发生在command执行之前, 替换后的内容作为无引号的string来使用;
  4. "${var_name}"大部分时候和${var_name}一致, 但对于列表, 它可以用于生成;分割的字符串
  5. 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"处理.
  6. 在CMAKE脚本中, 空格一般是作为argument的分隔符使用, List是指以;作为分隔符的string, 除非被""抑制, 否则;和空格等价
  7. List/math等相关的API仅仅是特殊的string操作函数.
  8. func/macro实质上只支持position传值, kwarg实际是通过特殊的字符串parsing来实现的.

find_xxx()

find系列的指令都有隐式的cache行为, 也就是说, 用同样的OutputVar和同样的target进行查找时, 后续的查找会自动使用先前找到的值.

  1. find_file: 用于查找*.h头文件
  2. find_library: 用于查找*.a, *.so的库文件
  3. find_program: 用于查找可执行文件
  4. 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 主要有两个场景

  1. 安装后的文件不会再被其他CMake项目引用, 例如, 安装binary或runtime, 这类安装一般只需要把相关文件拷贝到正确的位置即可.
  2. 安装后的文件可能会被其他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.cmakeFind{PkgName}.cmake的内容都是由供应方提供的, 在被find_package执行include之后会有什么效果,并没有统一的规定, 不过一般而言

  1. 它们都需要创建一些target,变量,以供引用方使用.
  2. FindXXX.cmake需要完全手写
  3. 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时,主要逻辑是:

  1. 分析用户给出的COMPONENT和COMPONENT_REQUIRED,看是否能满足.
  2. 将这些COMPOENT映射到对应的EXPORT-SET文件,并将对应的EXPORT-SET文件include
  3. 设置一些额外的变量,参数, 如更新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.cmakeXXXConfigVersion.cmake都需要手动创建,并通过install(FILES)安装到最终目录
    • CMakePackageConfigHelpers中的write_basic_package_version_file可以辅助生成XXXConfigVersion.cmake,生成后再通过install(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

  1. XXXConfig.cmake导入的Package会有隐式的Cache行为, 只有第一次调用会真的执行导入.当XXXConfig.cmake被删除后, 才会尝试搜索新的文件重新导入.
  2. FindXXX.cmake导入的Package也会有隐式Cache行为, 不过必须要完全重新Configure才能检测到文件的缺失.
  3. 无论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, 它并不会被当做变量来处理
  • cmake_minimum_required(VERSION 3.1) 不仅会限制最低版本, 还会让高版本的CMAKE按指定的版本限制执行。

    • 如果可能, 应当使用尽可能高的minimum required
  • $<>这样的代码称为Generator Expression, 它是在Generation阶段才被eval的. 因为有很多值需要再Generation阶段才能获得, 这些值通常是和平台/Generator强相关的, 例如, 对于MSVC和Apple, Debug/Release类型并不能在Configure阶段获得, 相关的控制必须在Generation阶段处理.