Modern CMake by Example

引言

本次 Tutorial 分享的是 GNU Make 和 CMake 的基本用法,主要内容是 CMake。以一个不断演进的样例程序为例,讲解在项目编写过程中针对什么需求可以用到 CMake 的什么特性,帮助刚加入实验室的新同学快速了解 CMake。

知识点总结

0_helloworld

Makefile 基本格式:

name: dependencies
commands

例如:

hello: main.cpp
$(CXX) -o hello main.cpp # CXX 是 Make 的内置变量
echo "OK"

构建 & 运行命令:

$ make hello
$ ./hello

1_helloworld

变量赋值:

CC := clang
CXX := clang++ # 可通过 make CXX=g++ 形式覆盖

objects := main.o

使用变量:

hello: $(objects)
$(CXX) -o $@ $(objects) # $@ 是自动变量,表示 target 名

main.o: main.cpp
$(CXX) -c main.cpp

2_ask_for_answer

Make 可以自动推断 .o 文件需要用什么命令从什么文件编译:

objects := main.o answer.o

answer: $(objects)
$(CXX) -o $@ $(objects)

main.o: answer.hpp
answer.o: answer.hpp

4_switch_to_cmake

CMakeLists.txt 基本格式:

cmake_minimum_required(VERSION 3.9)
project(answer)

add_executable(answer main.cpp answer.cpp)

生成 & 构建 & 运行命令:

cmake -B build      # 生成构建目录,-B 指定生成的构建系统代码放在 build 目录
cmake --build build # 执行构建
./build/answer # 运行 answer 程序

5_split_library

项目中可以复用的部分可以拆成 library:

add_library(libanswer STATIC answer.cpp)

STATIC 表示 libanswer 是个静态库。

使用(链接)library:

add_executable(answer main.cpp)
target_link_libraries(answer libanswer)

6_subdirectory

功能独立的模块可以放到单独的子目录:

.
├── answer
│ ├── answer.cpp
│ ├── CMakeLists.txt
│ └── include
│ └── answer
│ └── answer.hpp
├── CMakeLists.txt
└── main.cpp
# CMakeLists.txt
add_subdirectory(answer)

add_executable(answer_app main.cpp)
target_link_libraries(answer_app libanswer) # libanswer 在 answer 子目录中定义
# answer/CMakeLists.txt
add_library(libanswer STATIC answer.cpp)
target_include_directories(libanswer PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)

CMAKE_CURRENT_SOURCE_DIR 是 CMake 内置变量,表示当前 CMakeLists.txt 文件所在目录,此处其实可以省略。

target_include_directoriesPUBLIC 参数表示这个包含目录是 libanswer 的公开接口一部分,链接 libanswer 的 target 可以 #include 该目录中的文件。

7_use_libcurl

系统中安装的第三方库可以通过 find_package 找到,像之前的 libanswer 一样链接:

find_package(CURL REQUIRED)
target_link_libraries(libanswer PRIVATE CURL::libcurl)

REQUIRED 表示 CURL 是必须的依赖,如果没有找到,会报错。

PRIVATE 表示“链接 CURL::libcurl”是 libanswer 的私有内容,不应对使用 libanswer 的 target 产生影响,注意和 PUBLIC 的区别。

CURLCURL::libcurl 是约定的名字,其它第三方库的包名和 library 名可在网上查。

可以链接同一项目中其它子目录中定义的 library:

# CMakeLists.txt
add_subdirectory(answer)
add_subdirectory(curl_wrapper)
add_subdirectory(wolfram)
# answer/CMakeLists.txt
add_library(libanswer STATIC answer.cpp)
target_include_directories(libanswer PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(libanswer PRIVATE wolfram)
# wolfram/CMakeLists.txt
add_library(wolfram STATIC alpha.cpp)
target_include_directories(wolfram PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(wolfram PRIVATE curl_wrapper)
# curl_wrapper/CMakeLists.txt
find_package(CURL REQUIRED)
add_library(curl_wrapper STATIC curl_wrapper.cpp)
target_include_directories(curl_wrapper
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(curl_wrapper PRIVATE CURL::libcurl)

搞清楚项目各模块间的依赖关系很重要:

9_cache_string

Cache 变量

私密的 App ID、API Key 等不应该直接放在代码里,应该做成可配置的项,从外部传入。除此之外还可通过可配置的变量来控制程序的特性、行为等。在 CMake 中,通过 cache 变量实现:

set(WOLFRAM_APPID "" CACHE STRING "WolframAlpha APPID")

set 第一个参数是变量名,第二个参数是默认值,第三个参数 CACHE 表示是 cache 变量,第四个参数是变量类型,第五个参数是变量描述。

BOOL 类型的 cache 变量还有另一种写法:

set(ENABLE_CACHE OFF CACHE BOOL "Enable request cache")
option(ENABLE_CACHE "Enable request cache" OFF) # 和上面基本等价

Cache 变量的值可在命令行调用 cmake 时通过 -D 传入:

cmake -B build -DWOLFRAM_APPID=xxx

也可用 ccmake 在 TUI 中修改:

target_compile_definitions

要让 C++ 代码能够拿到 CMake 中的变量,可添加编译时宏定义:

target_compile_definitions(libanswer PRIVATE WOLFRAM_APPID="${WOLFRAM_APPID}")

这会给 C++ 代码提供一个 WOLFRAM_APPID 宏。

10_interface_library

Header-only 的库可以添加为 INTERFACE 类型的 library:

add_library(libanswer INTERFACE)
target_include_directories(libanswer INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_compile_definitions(libanswer INTERFACE WOLFRAM_APPID="${WOLFRAM_APPID}")
target_link_libraries(libanswer INTERFACE wolfram)

通过 target_xxxINTERFACE library 添加属性都要用 INTERFACE

11_target_compile_features

可以针对 target 要求编译 feature(即指定要使用 C/C++ 的什么特性):

target_compile_features(libanswer INTERFACE cxx_std_20)

和直接设置 CMAKE_CXX_STANDARD 的区别:

  1. CMAKE_CXX_STANDARD 会应用于所有能看到这个变量的 target,而 target_compile_features 只应用于单个 target
  2. target_compile_features 可以指定更细粒度的 C++ 特性,例如 cxx_auto_typecxx_lambda 等。

12_testing

CTest

要使用 CTest 运行 CMake 项目的测试程序,需要在 CMakeLists.txt 添加一些内容:

# CMakeLists.txt
cmake_minimum_required(VERSION 3.14) # 提高了 CMake 版本要求
project(answer)

if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME)
include(CTest)
endif()
# answer/CMakeLists.txt
if(BUILD_TESTING)
add_subdirectory(tests)
endif()
# answer/tests/CMakeLists.txt
add_executable(test_some_func test_some_func.cpp)
add_test(NAME answer.test_some_func COMMAND test_some_func)

BUILD_TESTINGinclude(CTest) 之后添加的一个 cache 变量,默认 ON,可通过 -D 参数修改。

在命令行运行所有 answer 模块的测试程序:

$ ctest --test-dir build -R "^answer."
FetchContent

除了使用 find_package 找到系统中安装的第三方库,也可通过 CMake 3.11 新增的 FetchContent 功能下载使用第三方库:

include(FetchContent)

FetchContent_Declare(
catch2 # 建议使用全小写
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.0.0-preview3)

FetchContent_MakeAvailable(catch2)

target_link_libraries(test_some_func} PRIVATE Catch2::Catch2WithMain)

FetchContent_MakeAvailable 要求 CMake 3.14,如果要支持更旧版本,或者需要更细粒度的控制,可以使用如下替代:

FetchContent_GetProperties(catch2)
if(NOT catch2_POPULATED)
FetchContent_Populate(catch2)
add_subdirectory(${catch2_SOURCE_DIR} ${catch2_BINARY_DIR})
endif()
Macro & Function

当需要多次重复同一段 CMake 脚本时,可以定义宏或函数:

macro(answer_add_test TEST_NAME)
add_executable(${TEST_NAME} ${ARGN}) # ${ARGN} 类似于 C/C++ 中的 __VA_ARGS__
add_test(NAME answer.${TEST_NAME} COMMAND ${TEST_NAME})
target_link_libraries(${TEST_NAME} PRIVATE libanswer)
target_link_libraries(${TEST_NAME} PRIVATE Catch2::Catch2WithMain)
endmacro()

answer_add_test(test_check_the_answer test_check_the_answer.cpp)
answer_add_test(test_another_function test_another_function.cpp)

宏和函数的区别与 C/C++ 中的宏和函数的区别相似。

13_back_to_makefile

调用 CMake 命令往往需要传很多参数,并且 CMake 生成、CMake 构建、CTest 的命令都不太相同,要获得比较统一的使用体验,可以在外面包一层 Make:

WOLFRAM_APPID :=

.PHONY: build configure run test clean

build: configure
cmake --build build

configure:
cmake -B build -DWOLFRAM_APPID=${WOLFRAM_APPID}

run:
./build/answer_app

test:
ctest --test-dir build -R "^answer."

clean:
rm -rf build

从而方便在命令行调用:

$ make build WOLFRAM_APPID=xxx
$ make test
$ make run
$ make clean

补充 1

CMake 是代码,应该格式化,格式化工具:https://github.com/cheshirekow/cmake_format

补充 2

糜老师要求补充:Linux 内核是可以修改并替换的,只需要下载内核源码,然后:

$ make defconfig # 有各种 xxxconfig
$ make menuconfig # TUI 界面修改 config
$ make # 构建 Linux 内核
$ make modules_install # 安装内核模块
$ make install # 安装内核

直播时的 Q&A

由于直播时候录屏里没有录到腾讯会议的聊天记录窗口,导致看回放时看不到原问题,这里列出一下。

CMake 是怎么找到 .cpp 文件依赖的头文件的?

它没有自己去找 .cpp 文件的依赖,而是在生成的 Makefile 里面,使用 GCC 等编译器的 -M 参数生成 .d 依赖文件,这个在其它使用 Make 管理构建的项目里应该也是常见用法。

模块依赖图是人工画的还是自动生成的?

是人工画的,用的是 VSCode 插件 Draw.io Integration

CMakeLists.txt 里定义变量并检查要求不为空,会导致 VSCode 乱报错吗?

VSCode 装了 CMake Tools 插件的情况下,修改 CMakeLists.txt 后它会自动执行 CMake Configure,如果有要求非空的 cache 变量,它会报错,这时候直接去修改 build/CMakeCache.txt,给对应 cache 变量赋值即可。

也可以手动运行一次 cmake 命令,用 -D 参数传入变量,此后只要不删除 build/CMakeCache.txt,就不会再报错。

FetchContent 每次编译都要重新编译一遍依赖库吗?

不会,cmake --build build 是会增量编译的。另外只要不删除 build/_deps,即使删了 build/CMakeCache.txt,之后重新 configure 也不会重新下载依赖。

能来个 VSCode 插件推荐列表吗?

我自己在用的:

点击展开
  • Better C++ Syntax
  • Bookmarks
  • C/C++
  • C/C++ GNU Global(适合阅读修改 Linux 内核用)
  • CMake
  • CMake Tools
  • cmake-format
  • Code Runner
  • crates
  • Diff
  • Draw.io Integration
  • Error Lens
  • Even Better TOML
  • GitHub Copilot
  • GitLens
  • Hex Editor
  • hexdump for VSCode
  • Include Autocomplete
  • LaTeX Workshop
  • Live Preview
  • Markdown All in One
  • Markdown Preview Enhanced
  • Marp for VS Code
  • Rainbow CSV
  • Remote - SSH
  • Run on Save
  • rust-analyzer
  • SVG Viewer
  • Tabnine
  • Task Explorer
  • Todo Tree
  • WakaTime

扩展阅读

Makefile

CMake