如何使用 CMake

CMake 配置构建

流程

1
2
3
4
5
6
# 创建 build 文件夹并进行配置(configure)
cmake -B build
# 根据生成的构建工具进行构建(build)(统一了不同平台的不同构建工具的命令)
cmake --build build -j4
# 安装(可以类比 make install, 构建 install 目标)
sudo cmake --build build --target install

配置阶段的参数

-G 指定生成器,CMake 可以生成不同类型的构建系统(比如 Makefile MSBuild,所以可以跨平台) cmake -B build -G "Unix Makefiles"

-D 指定配置变量,配置后会保存在build/CMakeCache.txt中,下次配置仍会保留之前设置的值(删除缓存信息可以只删除CMakeCache.txt而非整个build)。

1
2
3
cmake -B build -DCMAKE_INSTALL_PREFIX=/opt/myapp    # 设置安装路径
cmake -B build -DCMAKE_BUILD_TYPE=Release # 设置构建模式为发布模式
cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON # 设置生成 compile_commands.json 文件

CMake 文件

模板

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
cmake_minimum_required(VERSION 3.15)

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

project(prj LANGUAGES C CXX)

if (PROJECT_BINARY_DIR STREQUAL PROJECT_SOURCE_DIR)
message(WARNING "The binary directory of CMake cannot be the same as source directory!")
endif()

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

if (WIN32)
add_definitions(-DNOMINMAX -D_USE_MATH_DEFINES)
endif()

if (NOT MSVC)
find_program(CCACHE_PROGRAM ccache)
if (CCACHE_PROGRAM)
message(STATUS "Found CCache: ${CCACHE_PROGRAM}")
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE ${CCACHE_PROGRAM})
set_property(GLOBAL PROPERTY RULE_LAUNCH_LINK ${CCACHE_PROGRAM})
endif()
endif()

生成可执行程序

1
2
3
4
5
6
7
8
9
10
add_executable(main main.cpp hello.cpp)

# 先指定可执行程序,后添加
add_executable(main)
target_sources(main PUBLIC main.cpp hello.cpp)

# 使用 GLOB 根据扩展名批量查找,替换成 GLOB_RECURSE 则会包含所有子文件夹中的匹配,CONFIGURE_DEPENDS 保证增减文件后自动更新变量
add_executable(main)
file(GLOB sources CONFIGURE_DEPENDS *.cpp *.h)
target_sources(main PUBLIC ${sources})

生成库

1
2
3
4
5
6
7
8
# 静态库
add_library(mylib STATIC mylib.cpp)

# 动态库
add_library(mylib SHARED mylib.cpp)

# OBJ库
add_library(mylib OBJECT mylib.cpp)

项目配置变量

CMAKE_BUILD_TYPE: 构建类型

  • Debug 调试模式,生成调试信息
  • Release 发布模式,优化程度最高
  • MinSizeRel 最小体积发布,生成的文件比 Release 更小
  • RelWithDebInfo 带调试信息发布

与 project 相关的变量 project(helloprj)

  • PROJECT_SOURCE_DIR 若无project,向上一级找,找到最近的调用project的 CMakeLists.txt 所在的源码目录;也就是找到字意的项目目录,从子模块里直接获得项目最外层目录的路径
  • CMAKE_CURRENT_SOURCE_DIR 当前 CMakeLists.txt 所在的源码目录。
  • CMAKE_SOURCE_DIR 最外层 CMakeLists.txt 的源码根目录,不建议使用,若项目作为别人的子项目则会直接代表调用项目的根目录。
  • PROJECT_BINARY_DIRPROJECT_SOURCE_DIR对应,是二进制产物路径。
  • CMAKE_BINARY_DIRCMAKE_SOURCE_DIR对应,是二进制产物路径。
  • PROJECT_NAME 当前项目名
  • CMAKE_PROJECT_NAME 根项目项目名

C++ 一些要求的配置

1
2
3
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) # 设置是否启用 GCC 特有的功能,关闭以兼容其他编译器

target的相关描述

target的一些属性也有相应的全局变量,改变全局变量相当于改变了各个属性的初始默认值。

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
31
# 设置target的属性
add_executable(main main.cpp)
set_property(TARGET main PROPERTY CXX_STANDARD 17) # 设置 C++ 标准
set_property(TARGET main PROPERTY CXX_STANDARD_REQUIRED ON) # 编译器不支持则报错
set_property(TARGET main PROPERTY WIN32_EXECUTABLE ON) # 在 Windows 系统中,运行时不启动控制台窗口
set_property(TARGET main PROPERTY LINK_WHAT_YOU_USE ON) # 告诉编译器不要自动剔除没有引用符号的链接库
set_property(TARGET main PROPERTY LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib) # 设置动态链接库的输出路径
set_property(TARGET main PROPERTY ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib) # 设置静态链接库的输出路径
set_property(TARGET main PROPERTY RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/bin) # 设置可执行文件的输出路径

# 批量设置
add_executable(main main.cpp)
set_target_properties(main PROPERTIES
CXX_STANDARD 17 # 设置 C++ 标准
CXX_STANDARD_REQUIRED ON # 编译器不支持则报错
WIN32_EXECUTABLE ON # 在 Windows 系统中,运行时不启动控制台窗口
LINK_WHAT_YOU_USE ON # 告诉编译器不要自动剔除没有引用符号的链接库
LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib # 设置动态链接库的输出路径
ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib # 设置静态链接库的输出路径
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/bin # 设置可执行文件的输出路径
)

# 设置相应的全局变量
set(CMAKE_CXX_STANDARD 17) # 设置 C++ 标准
set(CMAKE_CXX_STANDARD_REQUIRED ON) # 编译器不支持则报错
set(CMAKE_WIN32_EXECUTABLE ON) # 在 Windows 系统中,运行时不启动控制台窗口
set(CMAKE_LINK_WHAT_YOU_USE ON) # 告诉编译器不要自动剔除没有引用符号的链接库
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib) # 设置动态链接库的输出路径
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib) # 设置静态链接库的输出路径
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/bin) # 设置可执行文件的输出路径
add_executable(main main.cpp)

使用针对target的选项(头文件搜索目录等),避免添加到所有target

1
2
3
4
5
6
7
8
9
10
11
target_sources(myapp PUBLIC hello.cpp other.cpp)    # 添加源文件 
target_include_directories(myapp PUBLIC include) # 添加头文件搜索目录
target_link_libraries(myapp PUBLIC hellolib) # 添加链接库
target_add_definitions(myapp PUBLIC -DMY_MACRO=1) # 添加宏定义 MY_MACRO=1
target_compile_options(myapp PUBLIC -fopenmp) # 添加编译选项

# 避免使用
include_directories(include) # 添加头文件搜索目录
link_directories(/opt/cuda) # 添加链接库搜索目录
add_definitions(MY_MACRO=1) # 添加宏定义 MY_MACRO=1
add_compile_options(-fopenmp) # 添加编译选项

第三方库引入方法

作为纯头文件引入 target_include_directories

适用于那些只有头文件的库,例如一些轻量级的模板库。这些库不需要编译,因为它们的实现代码都在头文件中,通常是通过模板或者宏等方式实现功能。比如C++的标准模板库就是纯头文件。

fmt库为例,该库介绍说明可以通过纯头文件引入,此时只需要项目中的include文件夹。

按照要求,在纯头文件引入时需要定义FMT_HEADER_ONLY,此时项目结构如下:

CMakeLists.txt中通过target_include_directories引入第三方库头文件目录。

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.30)
project(prj)

set(CMAKE_CXX_STANDARD 20)

add_executable(prj main.cpp)
target_include_directories(prj PUBLIC include)

但是直接引入头文件,函数实现在头文件里,没有提前编译,每次需要重复编译同样的内容,编译时间长。

作为子模块引入 add_subdirectory

这种方式将第三方库的源代码直接包含到项目中,第三方库通常有自己的CMakeLists.txt文件,通过add_subdirectory指令,可以将这个库的构建过程集成到主项目的构建过程中。

fmt库为例,这个开源库可以直接将该项目作为用户项目的子项目引入,直接clone源码,目录结构如下:

CMakeLists.txt中通过add_subdirectory引入第三方库的项目子目录,再通过target_link_libraries链接第三方项目库。

1
2
3
4
5
6
7
8
9
cmake_minimum_required(VERSION 3.30)
project(prj)

set(CMAKE_CXX_STANDARD 20)

add_subdirectory(fmt)

add_executable(prj main.cpp)
target_link_libraries(prj fmt::fmt)
FetchContent

FetchContent 是 CMake 的一个模块,可以在配置阶段获取外部依赖库,允许配置步骤使用这些内容进行 add_subdirectory()include()file() 操作。
这与上文所述基本相同,都是直接将第三方项目引入,但通过 FetchContent 可以直接将依赖项目写在 CMakeLists.txt 中,在配置阶段从远程库中下载依赖项目,而无需手动下载。

  • FetchContent_Declare

FetchContent_Declare() 函数用于指定如何获取外部项目,比如仓库地址等。

1
2
3
4
5
6
7
8
FetchContent_Declare(
<name>
<contentOptions>...
[EXCLUDE_FROM_ALL]
[SYSTEM]
[OVERRIDE_FIND_PACKAGE |
FIND_PACKAGE_ARGS args...]
)
  • FetchContent_MakeAvailable

FetchContent_MakeAvailable命令确保依赖项已经被获取。在获取时,它还会将它们添加到主构建中,以便主构建可以使用这些项目的目标等。

1
FetchContent_MakeAvailable(googletest)

具体使用步骤:

  • 包含FetchContent模块

    1
    include(FetchContent)
  • 声明外部项目

    1
    2
    3
    4
    5
    FetchContent_Declare(
    fmt
    GIT_REPOSITORY https://github.com/fmtlib/fmt.git
    GIT_TAG 11.1.1
    )

    这一步声明了一个外部项目fmt,并指定了其下载和配置的详细信息(此时并不会立即下载或配置项目)。

  • 确保外部项目可用

    1
    FetchContent_MakeAvailable(fmt)

    这一步确保声明的外部项目fmt已经被下载、配置、构建,并且可以使用。

  • 链接第三方库

    1
    target_link_libraries(prj PRIVATE fmt::fmt)

    这一步使用 target_link_libraries 将第三方库 fmt 链接到项目中。

使用该种途径时,项目如下,可以发现build文件夹中的_deps文件夹存放了获取的第三方项目,在main.cpp中可以直接使用。

CMakeLists.txt中通过FetchContent引入第三方项目,再通过target_link_libraries链接第三方项目库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cmake_minimum_required(VERSION 3.30)
project(prj)

set(CMAKE_CXX_STANDARD 20)

include(FetchContent)
FetchContent_Declare(fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 11.1.1)
FetchContent_MakeAvailable(fmt)

add_executable(prj main.cpp)

target_link_libraries(prj PUBLIC fmt::fmt)

引用系统中安装的第三方库 find_package

在存在菱形依赖的情况下,即项目A依赖于B和C,而B和C又同时依赖于D,使用子模块引用(add_subdirectory),可能会导致D被定义两遍,从而引发错误。
而通过find_package使用系统预安装的库则可以有效避免这个问题。当使用find_package查找库时,CMake会记录已经找到的库。因此,即使多个模块依赖同一个库,find_package也只会引入一次。例如,当找到库B和D时,再找C时不会将D重复引入。

不同操作系统可以通过各自的包管理器来安装所需的库。以Ubuntu为例,可以使用apt包管理器来安装库。比如安装fmt库:

1
sudo apt install libfmt-dev

此时由于头文件等已经在系统查找路径中(比如/usr/include),可以直接在文件中导入相关的头文件,此时,项目结构如下:

CMakeLists.txt中则需要先find_package找到fmt包,再通过target_link_libraries链接第三方项目库。

1
2
3
4
5
6
7
8
9
cmake_minimum_required(VERSION 3.30)
project(prj)

set(CMAKE_CXX_STANDARD 20)

find_package(fmt)

add_executable(prj main.cpp)
target_link_libraries(prj fmt::fmt)

在CMake中,一个项目可以包含多个库。CMake允许一个包(package)提供多个库,这些库也被称为组件(components)。因此,在使用target_link_libraries指令链接库时,应采用包名::组件名的格式。
例如,在上文中提到的fmt::fmt,其中fmt是包名,第二个fmt是该包提供的一个组件名。再比如,TBB这个包,就包含了tbbtbbmalloctbbmalloc_proxy这三个组件。当需要链接这些组件时,可以分别使用TBB::tbbTBB::tbbmallocTBB::tbbmalloc_proxy

find_package时可以指定必要的组件:

1
2
find_package(TBB REQUIRED COMPONENTS tbb tbbmalloc REQUIRED)
target_link_libraries(myexec PUBLIC TBB::tbb TBB::tbbmalloc)

CMake 项目结构

一个典型的 C++ 项目可以采用以下结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
prj
├── CMakeLists.txt
├── cmake
│ └── MyFuncs.cmake
├── subprj1
│ ├── CMakeLists.txt
│ ├── include
│ │ └── subprj1
│ │ └── Animal.h
│ └── src
│ └── Animal.cpp
├── ...
└── mainprj
├── CMakeLists.txt
├── include
│ └── mainprj
│ └── utils.h
└── src
└── main.cpp

在这个结构中,项目根目录包含了一个 CMakeLists.txt 文件以及多个子项目文件夹。
一个子项目作为可执行文件,负责与用户交互,其他子项目则作为不同的库文件,编写实际的业务逻辑。可执行文件仅作为入口,所有的功能实现都在库文件中,这样的分离使得代码逻辑的库也能被其他软件组合和复用。
另外,根项目还可能包含其他文件夹,比如上面的cmake文件夹用于存放CMake配置脚本或工具函数。

每个子项目的组织格式为:

  • subprj/CMakeLists.txt
  • subprj/include/subprj/module.h
  • subprj/src/module.cpp

根项目 CMakeLists.txt

在根项目中的 CMakeLists.txt 中,我们进行基本的 C++ 版本设置等选项,并使用 project 命令初始化项目。
之后,通过 add_subdirectory 将子项目逐一添加到根项目中,这样根项目就能够调用子项目中的 CMakeLists.txt 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
cmake_minimum_required(VERSION 3.30)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake;${CMAKE_MODULE_PATH}")

project(prj LANGUAGES CXX)

include(MyFuncs)

add_subdirectory(subprj1)
add_subdirectory(mainprj)

子项目 CMakeLists.txt

根项目的 CMakeLists.txt 主要负责全局配置,而子项目中的 CMakeLists.txt 则只关注该子项目自身的设置,如头文件目录、需要链接的库等。

子项目中,通常会使用 add_libraryadd_executable 来生成target,并配置target的选项,如链接的库和包含的头文件等。
在链接库时,由于 PUBLIC 的传播作用,某项目链接了其他项目的库后,也可以自动包含相应的头文件。

1
2
3
4
file(GLOB_RECURSE srcs CONFIGURE_DEPENDS src/*.cpp include/*.h)
add_executable(mainprj ${srcs})
target_include_directories(mainprj PUBLIC include)
target_link_libraries(mainprj PUBLIC subprj1)

在上面的例子中,mainprj 是一个可执行子项目,它通过 add_executable 生成可执行文件,并链接了其他子项目 subprj1
在子项目中,可以使用GLOB_RECRUSE来获取文件夹中所有的.h文件和.cpp文件,只是编译的话只需要.cpp文件,将.h也写出可以使头文件也被纳入IDE的项目资源浏览器,比如在头文件中引用头文件也可以使用<>写法搜索得到。

在上面的例子中,mainprj 是一个可执行子项目,它通过 add_executable 生成可执行文件,并链接了其他子项目 subprj1

在子项目中,可以使用 GLOB_RECURSE 来获取文件夹中所有的 .cpp 文件和 .h 文件。虽然编译时只需要 .cpp 文件,但将 .h 文件也一并列出可以使头文件被纳入 IDE 的项目资源浏览器。比如在头文件中引用其他头文件时,也可以使用 <> 写法,可以直接跳转到目标头文件。

子项目头文件

子项目头文件的例子如下,每个头文件使用#pragma once,防止重复导入;之后将代码使用namespace subprj{}包裹,这样如果两个子库有相同标识符在使用时也不会出现冲突。

如果没有#pragma once,在头文件中定义了一个类,在实现文件中重复导入两次则会造成重复定义的编译错误,#pragma once可以保证一个编译单元中不会因为某个头文件出现重复定义。

1
2
3
//  如果下面的头文件没有防重复导入,则会出现错误
# include <subprj1/Animal.h>
# include <subprj1/Animal.h>

而如果没有namespace,我在两个子库中都一个同名的类,如果某个文件同时导入这两个头文件,也会有重复定义的编译错误;
如果有相同的函数名,引入两个头文件,编译该单元时没有错误,但要链接时无法确定是哪一个函数实现,出现链接错误。
所以将每个子项目使用不同的namespace先进行包裹,“把自己先圈起来”,防止和未知的头文件或库发生冲突,namespace相当于延长了标识符,人为地划定模块,防止标识符冲突。

1
2
3
// 虽然是不同的头文件,但有相同的类标识符,一起复制过来的话就会重复定义,使用namespace即可隔离
# include "subprj1/example.h"
# include "subprj2/example.h"

另外,如果要在头文件中写函数定义,需要使用inlinestatic修饰,它们可以使函数定义限制在该编译单元里,防止不同项目都引入函数定义后,链接时出现重复定义。
虽然在头文件中会有namespace,但解决不了这个问题,因为每个导入该头文件的都是原封不动的将头文件复制过来,相当于这个函数的全名(namespace::func)在多个文件里都被编译了一遍,那多个编译单元链接时,这个函数名字还是有多个意思(定义),从而造成冲突。

1
2
3
4
5
// 如果没有修饰,两个cpp编译后进行链接时,utils中的函数就会有多个定义造成冲突
// a.cpp
# include <subprj1/utils.h>
// b.cpp
# include <subprj1/utils.h>

从上面可以总结的是:在同一个编译单元中,可以有同名的声明(函数声明、类声明、变量声明),但定义都只能有一个(多次导入同一头文件造成的类冲突;导入不同头文件造成的类冲突)。
在多个编译单元中,依旧可以有同名的声明,复制于同一个头文件的不同编译单元的类定义是可行的(每个编译单元中会有自己的类副本),但函数定义和变量定义不行,但函数和变量的定义只能有一个副本(使用staticinline)。

一个典型的头文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// prj/subprj1/include/subprj1/Animal.h
#pragma once

namespace subprj1 {
struct Animal {
virtual void speak() const = 0;

virtual ~Animal() = default;
};

struct Dog final : Animal {
void speak() const override;
};

struct Cat final : Animal {
void speak() const override;
};
}

子项目源文件

一般源文件和相应的头文件成对出现,在源文件中,include相应的头文件,并在namespace中进行头文件的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
// prj/subprj1/src/Animal.cpp
# include <subprj1/Animal.h>
# include <iostream>

namespace subprj1 {
void Cat::speak() const {
std::cout << "Cat::speak" << std::endl;
}

void Dog::speak() const {
std::cout << "Dog::speak" << std::endl;
}
}

每个cpp文件是一个编译单元,一般在写新功能时,会新建一对头文件和源文件,视为一个模块。
上面提到的namespace可以为每个模块搞成一块命名空间,但一般将每个子项目作为分隔不同命名空间的尺度就可以,项目内需要人为的保证每个小模块不会发生重复定义。

如果一个模块的头文件中仅仅声明了其他模块中的类,而没有直接使用或解引用该类的成员(例如调用成员函数或访问成员变量),那么头文件中不需要包含该类对应的头文件,而只需提供一个前向声明。例如,可以使用 struct ClassName; 或 class ClassName; 来声明类。只有在实际需要使用该类成员的实现文件(如 .cpp 文件)中,才需要包含完整的头文件。

1
2
3
4
5
6
7
8
9
10
// Animal类在其他头文件定义,但该头文件中无需引用
// 我们只需要声明一下Animal是一个`struct`而不是一个函数之类的,因为此处并没有解引用Animal类
#pragma once

namespace subprj1 {
struct Animal;
struct Another {
void use(Animal *a) const;
};
}

cmake/ 文件夹

与 C/C++ 中的 #include 类似,CMake 也有一个 include 命令。使用 include(XXX) 时,CMake 会在 CMAKE_MODULE_PATH 列表中的所有路径下查找名为 XXX.cmake 的文件。
通过这种方式,可以将一些常用的函数、宏或变量写在独立的 XXX.cmake 文件中,存放在项目的 cmake/ 文件夹中。然后在需要使用这些功能的地方,通过 include 引入相应的 .cmake 文件,从而实现代码的复用和模块化管理。

前面的根项目CMakeLists.txt中,以下部分就是设置CMAKE_MODULE_PATH以及include相应的.cmake

1
2
set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake;${CMAKE_MODULE_PATH}")
include(MyFuncs)

比如说在.cmake文件中写一些常用的函数或宏等:

1
2
3
4
5
6
7
8
9
10
11
# 用法:my_add_target(prj EXECUTABLE)
# 通过该宏可以直接简化子项目生成target的CMake代码
macro (my_add_target name type)
file(GLOB_RECURSE srcs CONFIGURE_DEPENDS src/*.cpp src/*.h)
if ("${type}" MATCHES "EXECUTABLE")
add_executable(${name} ${srcs})
else()
add_library(${name} ${type} ${srcs})
endif()
target_include_directories(${name} PUBLIC include)
endmacro()

macro 和 function

  • **macro**:相当于将代码直接粘贴到调用者的位置。
  • **function**:创建了一个闭包,它优先访问定义者的作用域。

include 和 add_subdirectory

  • **include**:相当于将代码直接粘贴到调用者的作用域中。
  • **add_subdirectory**:会在子目录中创建一个新的作用域。

可以类比 C++ 中的#define和函数。

参考:

  1. 小彭老师的并行课
  2. CMake Tutorial
  3. FetchContent

如何使用 CMake
https://kaysonyu.github.io/2024/12/How_to_use_CMake/
Author
Yu Kang
Posted on
December 6, 2024
Licensed under