关于 CMake 的内容可能比较多,计划是分成以下几部分:

  • Modern CMake 的基本使用
  • CMake 基本语法与变量
  • CMake 语法结构(条件,循环,函数,模块等)
  • CMake 依赖管理(作为库的使用者)
  • CMake 库的开发(作为库的开发者)

CMake 命令速查

完全CMake风格的四步命令:构建+编译+测试+安装

1
2
3
4
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/path/to/install/
cmake --build build
cmake --build build --target test
cmake --build build --target install

上述命令完全不依赖具体平台。

经典Linux风格的四步命令:构建+编译+测试+安装

1
2
3
4
5
6
mkdir build
cd build
cmake ..
make -j8
ctest
make install

这里需要依赖make命令,主要命令都在build/中进行。

Windows平台使用MinGW风格的工具链,对应的四步命令:构建+编译+测试+安装

1
2
3
4
cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/path/to/install/
cmake --build build -j8
cmake --build build --target test
cmake --build build --target install

Windows平台使用默认的VS2019的工具链,对应的四步命令:构建+编译+测试+安装

1
2
3
4
cmake -S . -B build -G "Visual Studio 16 2019" -DCMAKE_INSTALL_PREFIX=/path/to/install/
cmake --build build --config Release
cmake --build build --target test
cmake --build build --target install

注意对于MSVC,在构建时指定模式是无效的,需要在编译时指定模式--config Release

CMake 介绍

CMake 是一个跨平台的构建工具,是 make 和 Makefile 的上层工具,它的目的是跨平台,可以根据同一个 CMakeLists,自动产生对应平台上的 Makefile 或其它的等价产物,并简化手写 Makefile 时的巨大工作量。

make 的执行依赖于对 Makefile 的解析,同样的,CMake 的执行也依赖于对 CMakeLists 的解析,我们的工作从手写 Makefile 变成了手写 CMakeLists。 Makefile 的语法是非常接近编译命令本身的,而经过 CMake 的封装,在 CMakeLists 中使用的语法已经相当贴近自然语言了。

严格来说,CMake 的语法其实更复杂,早期的语法(2.x)非常混乱,使用也不规范。 但是好消息是,CMake 的现代语法已经越来越合理了,完全基于面向对象的思想: 3.x 被称为 Modern CMake,3.12+被称为 More Modern CMake。 我们需要使用足够高的 CMake 版本来支持现代的语法,本文将使用的 CMake 版本为 3.23.0,并且要求 CMake 版本至少为 3.15。

极简的 CMakeLists 示例

这里给出一个单文件的项目,只有一个源文件 hello.cpp

hello.cpp
1
2
3
4
5
6
#include <iostream>

int main(){
std::cout<<"hello,world\n";
return 0;
}

项目结构为

1
2
3
|-build
hello.cpp
CMakeLists.txt

最基本的 CMakeLists 如下

1
2
3
4
5
6
7
8
9
10
cmake_minimum_required(VERSION 3.15 FATAL_ERROR)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

project(Demo VERSION 0.1 LANGUAGES CXX)

add_executable(test)
target_sources(test PRIVATE hello.cpp)

逐行解释它们的含义

  1. cmake_minimum_required 写在 CMakeLists 的第一行,表示这个 CMakeLists 需要的最低版本的 CMake,FATAL_ERROR表示,如果达不到最低版本要求就报致命错误,停止执行(CMake 的语法变化非常大,因此有必要声明一下最低的版本要求)
  2. project(Demo VERSION 0.1) Demo 是项目的名称,0.1 是项目的版本号,LANGUAGES CXX表明项目使用的语言为 C++。对于 C/C++这个语句可以省略,因为默认语言就是 C 和 C++,注意如果只写 CXX 是不支持 C 文件的,需要写明LANGUAGES C CXX,对于 Fortran 等其它语言不可省略
  3. set(CMAKE_CXX_STANDARD 17) 设置使用的 C++标准为 C++17(一个整数,例如 11,17)
  4. set(CMAKE_CXX_STANDARD_REQUIRED ON) 强制要求必须达到相应的 C++标准
  5. set(CMAKE_CXX_EXTENSIONS OFF) 设置不接受编译器提供的 C++扩展(便于跨编译器使用)
  6. add_executable(test) 添加一个可执行文件的 target,名称为 test
  7. target_sources(test PRIVATE hello.cpp) 给名称为 test 的 target 私有地添加源文件 hello.cpp,其中 PRIVATE 的作用见后文

当然这几行并不都是必须的,最简单的形式只需要三行

1
2
3
cmake_minimum_required(VERSION 3.15 FATAL_ERROR)
project(Demo VERSION 0.1)
add_executable(test hello.cpp)

注意:CMake 从前到后执行 CMakeLists,部分设置对出现的顺序是非常敏感的,例如对编译器的设定必须出现在 project 语句之前,否则无效。建议将下面三个语句放在 CMakeLists.txt 的开头部分,最好在project()之前,CMake 会对编译器进行版本检测

1
2
3
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

CMake 使用

CLI 操作

CMake 在命令行中的使用主要分成以下几步:

  1. 建立构建目录,例如项目根目录下的 build 子目录
  2. 生成构建系统(比如 make 工具对应的 Makefile),在这一步可以附加命令行参数-D <var>=<value>传递一些变量的定义,空格可省略
  3. 执行构建(比如 make),编译生成目标文件
  4. 执行测试、安装或打包等后续任务

CMake 会产生很多对使用者没有意义的杂项文件(包括一些缓存和中间文件),这可能会污染整个项目,因此建议使用单独的构建目录(通常命名为 build),和项目中的源文件隔离开,比如在项目根目录下新建 build 子目录,或者在 XX 项目的同级目录下新建 XX-build 目录,用于存放 CMake 产生的各种文件。

CMake 99%的错误都是缓存文件错误,可以通过直接删除 build 文件夹,重新生成编译解决。

第一种用法是传统方式

  1. 建立构建目录(build 子目录): mkdir build,进入 build 子目录: cd build
  2. 生成构建系统(Makefile): cmake ..,注意 cmake 需要指定的目录是项目的 CMakeLists 所在的位置,由于我们处于 build 子目录中,因此使用..也就是上一级目录。
  3. 执行构建(使用 make 编译): make,注意此时我们仍然处在 build 子目录中
1
2
3
4
mkdir build
cd build
cmake ..
make

第二种用法是 modern cmake 推荐的,完全使用 CMake 自己的命令

  1. 生成构建系统: cmake -S . -B build
    • -S后接的位置是查找 CMakeLists 的位置,-S选项可以整体缺省,默认会使用当前目录
    • -B后接的位置是生成的构建系统存放的位置,也就是 build 子目录(如果不存在,则会自动创建)
  2. 执行构建: cmake --build build,在 build 子目录下执行构建,或者可以进入 build 目录后,使用cmake --build .,效果一样
1
2
cmake -B build
cmake --build build

注:我们可以在 CMakeLists 中使用下列选项要求在 build 文件夹内仍然保持相应的文件夹结构

1
set_property(GLOBAL PROPERTY USE_FOLDERS ON)

CMake 输出信息

单独使用cmake不加任何路径或参数的话,会给出如下的提示信息

1
2
3
4
5
Usage

cmake [options] <path-to-source>
cmake [options] <path-to-existing-build>
cmake [options] -S <path-to-source> -B <path-to-build>

CMake 在生成构建系统的阶段可能输出如下形式的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cmake  -B build
-- The C compiler identification is GNU 11.2.0
-- The CXX compiler identification is GNU 11.2.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /home/username/software/gcc/usr/local/bin/gcc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /home/username/software/gcc/usr/local/bin/g++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/username/code/Demo/build

CMake 在执行构建系统的阶段可能输出如下形式的信息,包括百分比的编译进度提示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cmake --build build
[ 10%] Building CXX object source/test1/CMakeFiles/test1.dir/test1.cpp.o
[ 20%] Linking CXX executable /home/username/code/Demo/bin/test1
[ 20%] Built target test1
[ 30%] Building CXX object source/test2/static_fun/CMakeFiles/static_fun.dir/static_fun.cpp.o
[ 40%] Linking CXX static library /home/username/code/Demo/lib/libstatic_fun.a
[ 40%] Built target static_fun
[ 50%] Building CXX object source/test2/CMakeFiles/test2.dir/test2.cpp.o
[ 60%] Linking CXX executable /home/username/code/Demo/bin/test2
[ 60%] Built target test2
[ 70%] Building CXX object source/test3/shared_fun/CMakeFiles/shared_fun.dir/shared_fun.cpp.o
[ 80%] Linking CXX shared library /home/username/code/Demo/lib/libshared_fun.so
[ 80%] Built target shared_fun
[ 90%] Building CXX object source/test3/CMakeFiles/test3.dir/test3.cpp.o
[100%] Linking CXX executable /home/username/code/Demo/bin/test3
[100%] Built target test3

指定构建系统

CMake 支持生成的构建系统并非局限于 Makefile,可以使用cmake --help查看当前状态支持的构建系统(工具链),例如在 Windows 下,可能有下列构建系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  Visual Studio 17 2022        = Generates Visual Studio 2022 project files.
Use -A option to specify architecture.
* Visual Studio 16 2019 = Generates Visual Studio 2019 project files.
Use -A option to specify architecture.
...
Borland Makefiles = Generates Borland makefiles.
NMake Makefiles = Generates NMake makefiles.
NMake Makefiles JOM = Generates JOM makefiles.
MSYS Makefiles = Generates MSYS makefiles.
MinGW Makefiles = Generates a make file for use with
mingw32-make.
Green Hills MULTI = Generates Green Hills MULTI files
(experimental, work-in-progress).
Unix Makefiles = Generates standard UNIX makefiles.
Ninja = Generates build.ninja files.
Ninja Multi-Config = Generates build-<Config>.ninja files.
Watcom WMake = Generates Watcom WMake makefiles.
...

其中的*代表默认选择的构建系统,我们可以在生成构建系统的时候,使用-G <generator-name>指定一个构建系统,例如对于 windows,可以指定使用 Mingw 而非默认的 VS,对于 Linux 默认的选项就是 Unix Makefiles。

1
2
cmake .. -G "MinGW Makefiles" # 传统的方式
cmake -Bbuild -G "MinGW Makefiles" # 现代的方式

在生成构建系统时,CMake 可能会试图检查编译环境和编译器是否满足要求,对于版本较新的编译器,则会直接跳过检查。

设置变量

在生成构建系统时可以同时给 CMake 传递一些变量,这些变量的值可能触发 CMakeLists 内部不同的执行方式,例如

1
2
3
cmake -S . -B build -DTYPE=1
# or
cmake -S . -B build -D TYPE=1

这里-D后面接不接空格均可,如果手动定义的变量在 CMake 内部没有用到,则会警告一下,提示可能输错了参数。

CMake项目中最常见的变量包括:

  • CMAKE_BUILD_TYPE:设置编译类型 Debug/Release
  • CMAKE_INSTALL_PREFIX:设置安装目录前缀
  • BUILD_TESTING:是否开启测试(默认开启)

例如

1
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/path/to/install/ -DBUILD_TESTING=OFF

这些变量在命令行中输入非常麻烦,但其实只需要设置一次,CMake 会把它们缓存下来(即 CMake 的缓存变量),下一次生成构建系统时无需重复输入。(也支持再次在命令行中使用-D来修改)

指定编译器

除了构建系统,我们还可以指定编译使用的编译器,在Linux系统中很可能同时存在多个不同版本的gcc/g++,clang/clang++,我们可以明确告诉CMake使用哪些编译器:在构建时定义CMAKE_C_COMPILERCMAKE_CXX_COMPILER变量,例如

1
/usr/bin/cmake -DCMAKE_C_COMPILER=/usr/bin/gcc -DCMAKE_CXX_COMPILER=/usr/bin/g++ -S . -B build -G Ninja

对于Linux系统,还可以直接设置CCCXX环境变量,CMake也会识别并使用对应的编译器

1
2
export CC=/usr/bin/gcc
export CXX=/usr/bin/g++

多核编译加速

在编译时,我们可以添加选项让它进行多核编译加速,例如-j8选项(或者等价的--parallel 8),注意是在编译时传递选项,并且不同的构建系统对此的支持不一样:似乎VS 的 MSBuild 不支持多核,Makefile 支持,但是效果上没有 Ninja 好。

1
2
3
4
5
6
7
8
# (1)
make -j8

# (2)
cmake --build build -j8

# (3)
cmake --build build --parallel 4

显示执行细节

我们可能希望显示 CMake 在编译时内部执行的具体指令,有以下几种方法可以实现(虽然变量名含有 MAKEFILE,但是同样支持 MSVC)

  • 生成构建系统时定义CMAKE_VERBOSE_MAKEFILE变量为真(直接在 CMakeLists 中设置也一样)
  • 编译时添加参数-v--verbose,即
1
2
3
cmake -Bbuild -DCMAKE_VERBOSE_MAKEFILE=ON

cmake --build build -v

如果在生成构建系统时使用--trace命令,则会显示生成构建系统时的 CMake 调用命令的细节,比如 CMake 执行到了 CMakeLists 的哪一行,输出内容非常繁琐,建议重定向到文件中,用于调试 CMake 自身

1
cmake -Bbuild -GNinja --trace-redirect=tmp.txt

支持代码提示

对于 clangd 等静态检查或代码提示工具,需要 CMake 提供 compile_commands.json,这通常放置在 build/内,内容大致为如下形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{
"directory": "D:/codeRoot/Demo2/build/src",
"command": "G:\\mingw64\\bin\\c++.exe @CMakeFiles/Main1.dir/includes_CXX.rsp -O3 -DNDEBUG -std=c++17 -o CMakeFiles\\Main1.dir\\main.cpp.obj -c D:\\codeRoot\\Demo2\\src\\main.cpp",
"file": "D:/codeRoot/Demo2/src/main.cpp",
"output": "src/CMakeFiles/Main1.dir/main.cpp.obj"
},
{
"directory": "D:/codeRoot/Demo2/build/src",
"command": "G:\\mingw64\\bin\\c++.exe @CMakeFiles/Main2.dir/includes_CXX.rsp -O3 -DNDEBUG -std=c++17 -o CMakeFiles\\Main2.dir\\main.cpp.obj -c D:\\codeRoot\\Demo2\\src\\main.cpp",
"file": "D:/codeRoot/Demo2/src/main.cpp",
"output": "src/CMakeFiles/Main2.dir/main.cpp.obj"
}
]

CMake 很可能不会自动生成它,这会导致代码提示工具找不到头文件,可以使用下列选项开启(VScode 的 CMakeTools 插件会帮助开启这个选项)

1
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

注意:根据CMake官方文档,这个选项只对 Makefile Generators 和 Ninja Generators 有效,其他情况例如MSVC会忽略这个选项。

GUI 操作

在 windows 下载 CMake 的同时会附带 cmake-gui.exe,支持直接在图形界面进行简单操作:

  • 第一步,添加源代码目录(即包含项目 CMakeLists.txt 的目录);添加构建目录,通常选择源代码目录下的build子目录,如果目录不存在会提示创建。
  • 第二步,点击Configure,此时可以选择 CMake 使用的工具链,包括生成器,编译器,构建类型等;此时在 GUI 界面会用红色显示新添加的、被更新的缓存变量(可以直接通过 GUI 修改),再次点击Configure则红色消失。(再次确认是 GUI 特有的过程,命令行操作不包括这一步)
  • 第三步,点击Generate,生成构建系统(在 build 子目录中产生大量文件)。
  • 第四步,对于使用 VS 构建的项目,可以点击Open Project直接打开 VS 项目(.sln项目文件);其它情形下,需要手动进入项目,然后进行编译。

在 Linux 上除了 cmake 外也有 ccmake,它支持在命令行界面的简单可视化操作。

CMakeLists 基本语法

准备语法

  • 支持#开头的单行注释,支持#[[开头,]]结尾的多行注释
  • CMakeLists 的内置命令对大小写不敏感(建议全小写),但是其它部分例如变量名、文件名和编译选项等,显然都是大小写敏感的!
  • 跨平台是 CMake 的卖点,但仍然避免不了字符编码的麻烦,建议尽量使用 ASCII 码,也就是纯英文脚本,换行符可以兼容 LF 和 CRLF
  • 在 CMake 中的路径分隔符总是应当使用/,因为 CMake 会对字符串中的\转义,CMake 在对接 VS 时会自动处理路径分隔符的替换问题

在较复杂的项目中,我们可以在不同的子目录下使用多个 CMakeLists.txt,在根目录下的 CMakeLists.txt 是最顶级的,例如可以使用add_subdirectory(source)命令,进入 source 文件夹,然后自动执行 source 目录下的 CMakeLists.txt,执行完毕后返回上一级,还可以继续前往其它子目录执行相应的 CMakeLists.txt。

编译模式

CMake 支持四种编译模式,可以通过修改 CMAKE_BUILD_TYPE 来指定编译模式(默认的编译模式值为空,似乎相当于 Debug 模式)

  1. Release 模式: ​​-O3 -DNDEBUG​​,发布模式,较高的优化,没有调试信息
  2. Debug 模式: ​​-g​​,调试模式,附带调试信息
  3. MinSizeRel 模式: ​​-Os -DNDEBUG,(较少见)多用于嵌入式,侧重于优化文件的体积 ​​(Release 侧重于优化运行速度)
  4. RelWithDebInfo 模式: ​-O2 -g -DNDEBUG,(较少见)在 Release 模式的基础上,加入一些调试信息

在使用 cmake 时往往需要指定编译模式,在讨论之前我们需要对构建系统进行分类:

  • single configuration generator: Unix-Makefiles, MinGW Makefiles, Ninja ...
  • multi-configuration generator: Visual Studio, Ninja Multi-Config ...

对于 single configuration generator,我们需要使用 CMAKE_BUILD_TYPE 进行设置,可以在 CMakeLists.txt 中设置,也可以在命令行参数中设置(命令行参数的优先级通常更高,除非在 CMakeLists.txt 修改缓存变量时使用 FORCE),总之是在生成构建系统时指定模式

1
2
3
cmake -B build -DCMAKE_BUILD_TYPE=Release

cmake --build build

对于 multi-configuration generator,CMAKE_BUILD_TYPE 似乎是无效的(不会报错),我们需要在编译时指定模式

1
2
3
cmake -B build

cmake --build build --config Debug

对于 Visual Studio,如果编译时不使用--config指定,则默认 Debug 模式。

通常在 CMakeLists.txt 的开头部分会添加下面的语句,确保默认情况下使用 Release 模式

1
2
3
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()

注意这里设置的是同名的普通变量而非缓存变量(当然也可以使用缓存变量实现),此时 CMakeCache.txt 记录的 CMAKE_BUILD_TYPE 仍然是空。

输出目录

默认情况下,CMake 会把得到的可执行文件和库文件等仍然存放在 build 目录中,但是我们通常希望把得到的可执行文件和库文件放在 bin 和 lib 目录下,可以使用如下的 set 命令,指定不同编译模式下不同产物的输出目录

1
2
3
4
5
6
7
8
9
10
# 设置不同模式下,编译后的输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${PROJECT_SOURCE_DIR}/bin")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${PROJECT_SOURCE_DIR}/bin")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${PROJECT_SOURCE_DIR}/bin")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_DEBUG "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_RELEASE "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE "${PROJECT_SOURCE_DIR}/lib")

其中的路径变量PROJECT_SOURCE_DIR是 CMake 的内置变量,代表项目的根目录,也就是最近一次使用project(...)命令的 CMakeLists.txt 所在的目录。(对于含有子项目的情形,可能有多个project(...)出现)

这一组命令的效果与编译环境有关:

  • 对于 Linux,非常简单直观:可执行文件被放在 bin;静态库(.a)和动态库(.so)都被放在 lib
  • 对于 windows(MSVC),非常繁琐:可执行文件(.exe)和动态库的主要部分(.dll)被放在 bin;静态库(.lib)和动态库的辅助部分(.lib)被放在 lib
  • 对于 windows(Mingw),效果也类似:可执行文件(.exe)和动态库的主要部分(.dll)被放在 bin;静态库(.a)和动态库的辅助部分(.dll.a)被放在 lib

这是由于 windows 和 Linux 在对待动态库时有着不同的处理,windows 的动态库主要部分需要被放置在可执行文件同一目录。(辅助部分只是编译时需要,运行时不需要)

同时还要注意的是,关于动态库:

  • 对于 Linux,CMake 可能在构建编译(不包括安装时)时自动在可执行文件中写入rpath参数,确保程序可以找到动态库
  • 对于 windows,不支持rpath类似的参数,需要修改 PATH 环境变量或者将动态库放在指定位置,否则可执行文件只能找到同一目录下的动态库(不是当前目录)

总的来说 windows 平台对动态库很不友好:既有导出符号的问题,还有查找路径的问题。

还需要补充一下关于 MSVC 的特殊处理,由于我们调整了输出文件的位置,需要进行以下设置来确保 VS 的调试器正常工作,即对每个 target 修改相关属性

1
2
3
4
if(MSVC)
set_target_properties(demo PROPERTIES
VS_DEBUGGER_WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}/bin")
endif()

这里设置的就是 VS 使用调试器调试时的工作目录,尤其在我们修改了输出目录时,如果可执行文件需要进行文件读写,就需要关注启动时的工作目录。 在 3.27 的版本可以直接使用 CMAKE_VS_DEBUGGER_WORKING_DIRECTORY 进行全局设置。

注: 我们可以使用下列选项要求在 build 文件夹内仍然保持相应的文件夹结构

1
set_property(GLOBAL PROPERTY USE_FOLDERS ON)

构建目标

在 modern cmake 的语法中,总是以面向对象的思路来编写 CMakeLists: 把生成的可执行文件,生成的静态动态库都统一成构建目标(target),围绕着 target 使用形如target_xxx的命令添加源文件,添加依赖关系,添加属性等等。

target 大致有以下的类型:

  1. 可执行文件 target:使用源文件 test.cpp 编译得到可执行文件 test
1
2
3
4
5
add_executable(test)
target_sources(test PRIVATE test.cpp)

# or
add_executable(test PRIVATE test.cpp)
  1. 静态库 target:使用源文件 static_fun.cpp 编译得到静态库 static_fun
1
2
3
4
5
add_library(static_fun STATIC)
target_sources(static_fun PRIVATE static_fun.cpp)

# ${PROJECT_SOURCE_DIR}/include是编译时和使用时都需要使用的头文件搜索路径,见下文
target_include_directories(static_fun PUBLIC ${PROJECT_SOURCE_DIR}/include)
  1. 动态库 target:使用源文件 shared_fun.cpp 编译得到动态库 shared_fun
1
2
3
4
5
add_library(shared_fun SHARED)
target_sources(shared_fun PRIVATE shared_fun.cpp)

# ${PROJECT_SOURCE_DIR}/include是编译时和使用时都需要使用的头文件搜索路径,见下文
target_include_directories(shared_fun PUBLIC ${PROJECT_SOURCE_DIR}/include)
  1. CMake 还允许一些特殊的库,比如由.o 文件组成的 OBJECT 库(主要为了节约编译时间),或者仅仅由头文件组成的 INTERFACE 库(header-only),见下文

注:

  • add_library可以缺省STATIC|SHARED参数,此时默认为STATIC全部生成静态库,但是也可以通过指定BUILD_SHARED_LIBS为真,修改默认值为SHARED全部生成动态库
  • 动态库目标会默认启动代码与位置无关的选项(POSITION_INDEPENDENT_CODE),相当于 GCC 的-fPIC选项,对于静态库则不会自动启用
  • 支持对目标起一个别名,这通常是为了增加命名空间前缀,在被链接时和导入的第三方依赖的命名风格保持一致,例如
1
2
add_library(demo SHARED)
add_library(Demo::demo ALIAS demo)

设置目标属性(一)

对于一个 target,我们引入以下两个概念

  1. build-requirements: 为了正确编译这个 target 我们需要的一切
  2. usage-requirements: 其它的 target 为了正确使用当前 target 所需要的一切,会自动被它的使用方 target 获取,而 build-requirements 则不会传播给别的 target。

target 通常需要设置如下属性:

  • 源文件
  • 头文件搜索路径
  • 链接的库
  • 库搜索路径
  • 宏定义
  • 编译选项
  • 链接选项
  • 其它编译特点,例如指定 C++标准

我们使用如下的三个修饰符来区分

  • PRIVATE: 私有的,只是 build-requirements,不是 usage-requirements
  • INTERFACE: 接口化的,只是 usage-requirements,不是 build-requirements
  • PUBLIC: 公开的,既是 usage-requirements,又是 build-requirements

对于一个 target,具体有如下的命令选项,这里的 PRIVATE 换成 INTERFACE 和 PUBLIC 均可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 源文件
target_sources(<target> PRIVATE <source-file>...)
# 头文件搜索路径
target_include_directories(<target> PRIVATE <include-search-dir>...)
# 预处理的宏定义
target_compile_definitions(<target> PRIVATE <macro-definitions>...)
# 编译选项
target_compile_options(<target> PRIVATE <compile-option>...)
# 链接相关的库
target_link_libraries(<target> PRIVATE <dependency>...)
# 库搜索路径
target_link_directories(<target> PRIVATE <linker-search-dir>...)
# 链接选项
target_link_options(<target> PRIVATE <linker-option>...)
# 其它编译特点,例如指定C++标准
target_compile_features(<target> PRIVATE <feature>...)

注意:

  • CMake 中涉及到路径时,默认是相对路径,也可以是绝对路径,并且建议对路径统一使用/分隔符。
  • target_link_libraries既支持链接到 CMake 的 target,也支持连接到一个已经存在的库文件中(只要找得到)
  • 建议总是加上这些修饰符,虽然有时候省略也是合法的语法,但不是 modern cmake 推荐的用法。

设置目标属性(二)

除了上述的target_xxx命令,还有两个命令可以直接访问和修改 target 的属性。 属性其实有很多(参考官网文档), 当前已经设置的所有属性都可以访问和修改,并不局限于正在处理的 CMakeLists.txt。

1
2
3
4
5
get_target_property(<VAR> target property)

set_target_properties(target1 target2 ...
PROPERTIES prop1 value1
prop2 value2 ...)

例如

1
2
set_target_properties(demo PROPERTIES INTERFACE_INCLUDE_DIRECTORIES src/include)
set_target_properties(demo PROPERTIES INCLUDE_DIRECTORIES src/include)

这里其实分别等价于使用修饰符INTERFACEPRIVATE添加头文件目录

1
2
target_include_directories(demo INTERFACE src/include)
target_include_directories(demo PRIVATE src/include)

如果设置修饰符为PUBLIC,则等于同时使用上述两个命令。

从这个角度,target_xxx系列命令可以看作一组命令语法糖,实质上是在修改 CMake 内部维持的关于指定 target 的变量,包括

1
2
3
4
5
6
7
INCLUDE_DIRECTORIES
INTERFACE_INCLUDE_DIRECTORIES

SOURCES
INTERFACE_SOURCES

...

常见的 property 设置还有:

  • 对于动态库,通常生成的是与位置无关的代码(相当于-fPIC),但是如果动态库需要链接一个静态库,我们就需要让静态库也具有这个性质,可以如下设置
1
set_target_properties(demo PROPERTIES POSITION_INDEPENDENT_CODE ON)
  • 对于 GCC,可能存在自动剔除链接静态库中未使用部分的行为,这个行为受到下面属性的控制。(目前只支持 Linux 上的,不支持时会忽略)
1
set_target_properties(demo PROPERTIES LINK_WHAT_YOU_USE OFF)
  • 如下的命令也可以设置属性,不过只能设置单个属性而非批量设置,例如
1
set_property(TARGET demo PROPERTY POSITION_INDEPENDENT_CODE ON)

添加源文件

对一个 target 添加源文件时,可以依次添加每一个源文件

1
2
add_executable(demo)
target_sources(demo PRIVATE main.cpp a.cpp b.cpp)

但是这样太过繁琐,一个很常见的需求是添加指定目录下的所有*.cpp文件,此时可以使用下面的命令,先搜索所有匹配的源文件存储到 SRCS,然后再添加到 target。(注意相对路径问题)

1
2
3
file(GLOB SRCS src/*.cpp include/*.h)

target_sources(XXX PRIVATE ${SRCS})

这里其实没必要添加头文件,只是便于 VS 项目的生成,因为如果不添加头文件,则生成 VS 项目时会把头文件直接排除出去。

可以使用GLOB_RECURSE进行递归搜索,此时的*可以匹配到子文件中的src/test/a.cpp,但是GLOB不可以

1
2
3
file(GLOB_RECURSE SRCS src/*.cpp include/*.h)

target_sources(XXX PRIVATE ${SRCS})

建议开启的选项是CONFIGURE_DEPENDS,因为 file 命令的查找通常只在生成时,而编译时往往会跳过,如果我们此时增减或重命名源文件,与缓存中的不一致会导致编译出错,使用这个选项会让编译时再次执行 file 命令进行校对,如果得到的结果不变则跳过,否则会提示GLOB mismatch!,然后重新生成构建系统。

1
file(GLOB_RECURSE SRCS CONFIGURE_DEPENDS src/*.cpp include/*.h)

这样就组成了一个简单的三行 target

1
2
3
file(GLOB_RECURSE SRCS CONFIGURE_DEPENDS src/*.cpp include/*.h)
add_executable(Demo)
target_sources(Demo PRIVATE ${SRCS})

一个替代的命令是下面的aux_source_directory,它可以自动搜集添加当前目录下的源文件(根据项目使用的语言确定源文件),然后存储到变量中,例如

1
2
3
4
add_executable(Demo)
aux_source_directory(. SRCS)
aux_source_directory(test SRCS)
target_sources(Demo ${SRCS})

但是这个命令并不支持等价于CONFIGURE_DEPENDS的选项,因此也存在前文中的问题。

编译结果后缀

我们可以通过给不同编译模式下的结果加上不同的后缀来区分它们,最常见的是_d后缀,代表 Debug 模式下的库文件。

1
2
3
4
# libfunc
set(CMAKE_DEBUG_POSTFIX "_d") # libfunc_d (debug)
set(CMAKE_MINSIZEREL_POSTFIX "_m") # libfunc_m (minsizerel)
set(CMAKE_RELWITHDEBINFO_POSTFIX "_rd") # libfunc_rd (relwithdebinfo)

对于不指定编译模式或者 Release 模式下的结果,通常不会加后缀。

注意这里的后缀设置是全局的,会自动设置到所有的库文件目标中,但是对于可执行文件目标并不会自动生效,如果希望可执行文件也带上后缀,需要单独设置 target 属性

1
2
3
4
5
set_target_properties(demo
PROPERTIES
DEBUG_POSTFIX ${CMAKE_DEBUG_POSTFIX}
MINSIZEREL_POSTFIX ${CMAKE_MINSIZEREL_POSTFIX}
RELWITHDEBINFO_POSTFIX ${CMAKE_RELWITHDEBINFO_POSTFIX})

常用片段模板

在项目根目录的 CMakeLists.txt 开头部分,最好在project()之前,直接使用下面的模板设置一些必要选项

1
2
3
4
5
6
7
8
9
10
cmake_minimum_required(VERSION 3.15 FATAL_ERROR)

if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

如下片段直接禁止在源码目录下生成构建系统(这会污染整个项目),可以避免很多误操作

1
2
3
if(PROJECT_BINARY_DIR STREQUAL PROJECT_SOURCE_DIR)
message(FATAL_ERROR "The binary directory cannot be the same as source directory")
endif()

设置本地编译的输出目录

1
2
3
4
5
6
7
8
9
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${PROJECT_SOURCE_DIR}/bin")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${PROJECT_SOURCE_DIR}/bin")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${PROJECT_SOURCE_DIR}/bin")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_DEBUG "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_RELEASE "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE "${PROJECT_SOURCE_DIR}/lib")

输出一些信息,包括构建系统的具体细节,编译参数等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
message(STATUS ">> system = ${CMAKE_SYSTEM_NAME}")
message(STATUS ">> generator = ${CMAKE_GENERATOR}")
message(STATUS ">> build_type = ${CMAKE_BUILD_TYPE}")
message(STATUS ">> c_compiler_id = ${CMAKE_C_COMPILER_ID}(${CMAKE_C_COMPILER_VERSION})")
message(STATUS ">> cxx_compiler_id = ${CMAKE_CXX_COMPILER_ID}(${CMAKE_CXX_COMPILER_VERSION})")

message(STATUS ">> c_compiler = ${CMAKE_C_COMPILER}")
message(STATUS ">> cxx_compiler = ${CMAKE_CXX_COMPILER}")

message(STATUS ">> c_flags = " ${CMAKE_C_FLAGS})
message(STATUS ">> c_flags_debug = " ${CMAKE_C_FLAGS_DEBUG})
message(STATUS ">> c_flags_release = " ${CMAKE_C_FLAGS_RELEASE})

message(STATUS ">> cxx_flags = " ${CMAKE_CXX_FLAGS})
message(STATUS ">> cxx_flags_debug = " ${CMAKE_CXX_FLAGS_DEBUG})
message(STATUS ">> cxx_flags_release = " ${CMAKE_CXX_FLAGS_RELEASE})

message(STATUS ">> linker = ${CMAKE_LINKER}")
message(STATUS ">> linker flag = ${CMAKE_EXE_LINKER_FLAGS}")

对于 QT 项目在编译时要进行额外的处理,CMake 需要开启/关闭对应的几个选项,可以使用下面两个函数进行简单的封装

1
2
3
4
5
6
7
8
9
10
11
function(My_QtBegin)
set(CMAKE_AUTOMOC ON PARENT_SCOPE)
set(CMAKE_AUTOUIC ON PARENT_SCOPE)
set(CMAKE_AUTORCC ON PARENT_SCOPE)
endfunction()

function(My_QtEnd)
set(CMAKE_AUTOMOC OFF PARENT_SCOPE)
set(CMAKE_AUTOUIC OFF PARENT_SCOPE)
set(CMAKE_AUTORCC OFF PARENT_SCOPE)
endfunction()

有时候我们需要对编译器的版本提出明确的要求,可以使用下面的片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang") # Clang
if(CMAKE_CXX_COMPILER_VERSION VERSION_LESS "10")
message(WARNING "The version of clang (${CMAKE_CXX_COMPILER_VERSION} < 10) is too low")
endif()
elseif ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU") # GCC
if(CMAKE_CXX_COMPILER_VERSION VERSION_LESS "10")
message(WARNING "The version of gcc (${CMAKE_CXX_COMPILER_VERSION} < 10) is too low")
endif()
elseif ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "MSVC") # Visual Studio C++
if(CMAKE_CXX_COMPILER_VERSION VERSION_LESS "19.26")
message(WARNING "The version of MSVC (${CMAKE_CXX_COMPILER_VERSION} < 1926 / 2019 16.6) is too low")
endif()
else()
message(WARNING "Unknown CMAKE_CXX_COMPILER_ID : ${CMAKE_CXX_COMPILER_ID}")
endif()

特殊目标

除了最常见的可执行文件以及静态库/动态库,CMake 还提供了几个特殊的 target 类型,下面简要介绍。

INTERFACE 库

INTERFACE 库指的是没有编译产物,即通常的 header-only 库,此时对它不需要执行编译,参考官方文档

可以如下创建 INTERFACE 库

1
2
add_library(Demo INTERFACE)
target_link_directories(Demo INTERFACE include)

注意,前文中的 target 属性只有INTERFACE部分才会对 INTERFACE 库生效。

OBJECT 库

这个库相当于一堆.o文件的集合,并不会进一步打包到一起得到静态库,通常用于节约编译时间。 OBJECT 库还可以避免静态库的一个问题,即链接时对于没用到的部分进行的自动剔除。

可以如下创建 OBJECT 库

1
2
add_library(Demo OBJECT)
target_sources(Demo PRIVATE a.cpp)

注意,OBJECT 库被链接时,如果是 build-requirements,会把相应的.o文件直接拿过来一起编译;如果是 usage-requirements,则视作 INTERFACE 库。

IMPORTED 目标

通常标记从第三方库中导入的 target 为 IMPORTED 库,作为使用者应当视其为不可修改的。 链接到 IMPORTED 库可以自动继承它的 usage-requirements。

IMPORTED标记最好不要单独使用,而是作为库的标记。下面的语句通常在第三方库的导入过程中出现

1
2
3
add_library(Demo STATIC IMPORTED)

add_library(Demo SHARED IMPORTED)

进阶

自定义目标

除了通常的可执行文件和库可以作为目标,CMake也支持将任意的命令通过add_custom_target()命令打包为一个特殊的工具目标(utility target), 例如生成代码、执行脚本或清理临时文件的命令操作。 add_custom_target既可以设置命令的具体内容,也可以设置执行命令时的工作目录,便于跨平台进行统一操作。

例如我们可以在一个普通的可执行文件目标后面,将运行这个可执行文件的命令设置为工具目标

1
2
3
4
5
6
add_executable(test main.cpp)

add_custom_target(run
COMMAND $<TARGET_FILE:test>
WORKING_DIRECTORY ${CMAKE_PROJECT_DIR}
)

这里添加了一个名为run的工具目标,效果就是运行test,通过下面的命令显式构建它

1
cmake --build build --target run

默认情况下,CMake并不会主动去构建工具目标(即使--target all也会将其忽略),但是我们也可以加上可选参数ALL,例如

1
2
3
4
5
6
add_executable(test main.cpp)

add_custom_target(run ALL
COMMAND $<TARGET_FILE:test>
WORKING_DIRECTORY ${CMAKE_PROJECT_DIR}
)

这会导致使用--target all构造所有目标时也执行它。

下面提供的例子是清理临时文件

1
2
3
4
add_custom_target(clean_temp_files
COMMAND ${CMAKE_COMMAND} -E remove_directory ${CMAKE_BINARY_DIR}/temp_files
COMMENT "Cleaning temporary files"
)

如果这里需要执行的命令过于复杂,我们还可以将命令通过add_custom_command()进一步抽象出自定义命令。 这里的自定义目标和自定义命令都可以进一步嵌入CMake构建系统中,形成更复杂的依赖关系,或者在指定行为下自动触发。这些暂不讨论。

CMake 多文件项目示例

有了上述知识,我们就可以写出一个较复杂的多文件项目的 CMakeLists,这里暂不涉及对第三方库的依赖,不涉及一些编译的复杂逻辑,不涉及安装。项目在 Linux 平台上进行。

Demo 项目的文件结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|-bin
|-build
|-include
|-static_fun
|-shared_fun
|-lib
|-source
|-test1
test1.cpp
CMakeLists.txt(2)
|-test2
|-static_fun
static_fun.cpp
CMakeLists.txt(5)
test2.cpp
CMakeLists.txt(3)
|-test3
|-shared_fun
shared_fun.cpp
CMakeLists.txt(6)
test3.cpp
CMakeLists.txt(4)
CMakeLists.txt(1)
CMakeLists.txt(0)

Demo 项目包括:

  • 单独生成可执行文件 test1
  • 首先生成静态库 static_fun,然后生成可执行文件 test2,test2 调用静态库 static_fun
  • 首先生成动态库 shared_fun,然后生成可执行文件 test3,test3 调用动态库 shared_fun

两个库的头文件分别为

1
2
3
4
5
// static_fun.h
void static_function();

// shared_fun.h
void shared_function();

两个库的源文件依次为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// static_fun.cpp
#include "static_fun.h"
#include <cstdio>

void static_function(){
printf("this is static_function\n");
return;
}

// shared_fun.cpp
#include "shared_fun.h"
#include <cstdio>

void shared_function(){
printf("this is shared_function\n");
return;
}

三个可执行文件的源文件依次为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// test1.cpp
#include <cstdio>

int main(){
printf("这是单文件测试\nhello,world\n");
return 0;
}

// test2.cpp
#include "static_fun.h"
#include <cstdio>

int main(){
printf("这是main函数, 调用静态库测试\n");
static_function();

return 0;
}

// test3.cpp
#include "shared_fun.h"
#include <cstdio>

int main(){
printf("这是main函数, 调用动态库测试\n");
shared_function();

return 0;
}

项目根目录下的 CMakeLists.txt 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# CMakeLists(0)

cmake_minimum_required(VERSION 3.10)

project(Demo VERSION 0.1)

# 设置不同模式下,编译后的输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${PROJECT_SOURCE_DIR}/bin")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${PROJECT_SOURCE_DIR}/bin")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${PROJECT_SOURCE_DIR}/bin")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_DEBUG "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_RELEASE "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG "${PROJECT_SOURCE_DIR}/lib")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE "${PROJECT_SOURCE_DIR}/lib")

add_subdirectory(source)

各级子目录下的 CMakeLists.txt 依次为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# CMakeLists(1)
add_subdirectory(test1)
add_subdirectory(test2)
add_subdirectory(test3)

# CMakeLists(2)
add_executable(test1)
target_sources(test1 PRIVATE test1.cpp)

# CMakeLists(3)
add_subdirectory(static_fun)
add_executable(test2)
target_sources(test2 PRIVATE test2.cpp)
target_link_libraries(test2 PUBLIC static_fun)

# CMakeLists(4)
add_subdirectory(shared_fun)
add_executable(test3)
target_sources(test3 PRIVATE test3.cpp)
target_link_libraries(test3 PUBLIC shared_fun)

# CMakeLists(5)
add_library(static_fun STATIC)
target_sources(static_fun PRIVATE static_fun.cpp)
target_include_directories(static_fun PUBLIC ${PROJECT_SOURCE_DIR}/include)

# CMakeLists(6)
add_library(shared_fun SHARED)
target_sources(shared_fun PRIVATE shared_fun.cpp)
target_include_directories(shared_fun PUBLIC ${PROJECT_SOURCE_DIR}/include)

需要注意一下进入各个子目录的先后顺序,否则会链接到不存在的 target。

生成构建系统,编译完成后,我们会得到两个库和三个可执行文件,分别存放在 bin 和 lib 目录

1
2
3
4
5
6
7
|-bin
test1
test2
test3
|-lib
libshared_fun.so
libstatic_fun.a