A note for cmake

Last Updated on 2023年4月9日

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, 但你只要能忍住诱惑不使用它们, 完全可以在有限的范围内正常实现所有功能. 而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. 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"处理.
  5. 在CMAKE脚本中, 空格一般是作为argument的分隔符使用, List是指以;作为分隔符的string, 除非被""抑制, 否则;和空格等价
  6. List/math等相关的API仅仅是特殊的string操作函数.
  7. 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 的 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.cmakeFind{PkgName}.cmake的内容都是由供应方手动编写的, 在被find_package执行include之后会有什么效果,并没有统一的规定,不过一般而言

  1. FindXXX.cmake需要手动设置XXX_FOUND,XXX_INCLUDE_PATH,XXX_LIBRARY_PATH等变量,以供调用方使用
  2. 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的COMPONENT
    • cmake --install {build_dir} --component comp会安装特定的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中,逻辑更加清晰

Tricks:

  • XXXConfig.cmakeXXXConfigVersion.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:

  1. XXXConfig.cmake导入的Package会有隐式的Cache行为, 只有第一次调用会真的执行导入.当XXXConfig.cmake被删除后, 才会尝试搜索新的文件重新导入.
  2. FindXXX.cmake导入的Package也会有隐式Cache行为, 不过必须要完全重新Configure才能检测到文件的缺失.
  3. Config Package的COMPONENT REQUIRED OPTIONAL字段需要由XXXConfig.cmake处理, 这一般是很麻烦的一套逻辑
  4. 无论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, 它并不会被当做变量来处理
  • 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)