如何使用 CMake
CMake 配置构建
流程
1 | |

配置阶段的参数
-G 指定生成器,CMake 可以生成不同类型的构建系统(比如 Makefile MSBuild,所以可以跨平台) cmake -B build -G "Unix Makefiles"
-D 指定配置变量,配置后会保存在build/CMakeCache.txt中,下次配置仍会保留之前设置的值(删除缓存信息可以只删除CMakeCache.txt而非整个build)。
1 | |
CMake 文件
模板
1 | |
生成可执行程序
1 | |
生成库
1 | |
项目配置变量
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_DIR与PROJECT_SOURCE_DIR对应,是二进制产物路径。CMAKE_BINARY_DIR与CMAKE_SOURCE_DIR对应,是二进制产物路径。PROJECT_NAME当前项目名CMAKE_PROJECT_NAME根项目项目名
C++ 一些要求的配置
1 | |
target的相关描述
target的一些属性也有相应的全局变量,改变全局变量相当于改变了各个属性的初始默认值。
1 | |
使用针对target的选项(头文件搜索目录等),避免添加到所有target
1 | |
第三方库引入方法
作为纯头文件引入 target_include_directories
适用于那些只有头文件的库,例如一些轻量级的模板库。这些库不需要编译,因为它们的实现代码都在头文件中,通常是通过模板或者宏等方式实现功能。比如C++的标准模板库就是纯头文件。
以fmt库为例,该库介绍说明可以通过纯头文件引入,此时只需要项目中的include文件夹。

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

在CMakeLists.txt中通过target_include_directories引入第三方库头文件目录。
1 | |
但是直接引入头文件,函数实现在头文件里,没有提前编译,每次需要重复编译同样的内容,编译时间长。
作为子模块引入 add_subdirectory
这种方式将第三方库的源代码直接包含到项目中,第三方库通常有自己的CMakeLists.txt文件,通过add_subdirectory指令,可以将这个库的构建过程集成到主项目的构建过程中。
以fmt库为例,这个开源库可以直接将该项目作为用户项目的子项目引入,直接clone源码,目录结构如下:

在CMakeLists.txt中通过add_subdirectory引入第三方库的项目子目录,再通过target_link_libraries链接第三方项目库。
1 | |
FetchContent
FetchContent 是 CMake 的一个模块,可以在配置阶段获取外部依赖库,允许配置步骤使用这些内容进行 add_subdirectory()、include() 或 file() 操作。
这与上文所述基本相同,都是直接将第三方项目引入,但通过 FetchContent 可以直接将依赖项目写在 CMakeLists.txt 中,在配置阶段从远程库中下载依赖项目,而无需手动下载。
- FetchContent_Declare
FetchContent_Declare() 函数用于指定如何获取外部项目,比如仓库地址等。
1 | |
- FetchContent_MakeAvailable
FetchContent_MakeAvailable命令确保依赖项已经被获取。在获取时,它还会将它们添加到主构建中,以便主构建可以使用这些项目的目标等。
1 | |
具体使用步骤:
包含
FetchContent模块1
include(FetchContent)声明外部项目
1
2
3
4
5FetchContent_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 | |
引用系统中安装的第三方库 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 | |
此时由于头文件等已经在系统查找路径中(比如/usr/include),可以直接在文件中导入相关的头文件,此时,项目结构如下:

在CMakeLists.txt中则需要先find_package找到fmt包,再通过target_link_libraries链接第三方项目库。
1 | |
在CMake中,一个项目可以包含多个库。CMake允许一个包(package)提供多个库,这些库也被称为组件(components)。因此,在使用target_link_libraries指令链接库时,应采用包名::组件名的格式。
例如,在上文中提到的fmt::fmt,其中fmt是包名,第二个fmt是该包提供的一个组件名。再比如,TBB这个包,就包含了tbb、tbbmalloc和tbbmalloc_proxy这三个组件。当需要链接这些组件时,可以分别使用TBB::tbb、TBB::tbbmalloc和TBB::tbbmalloc_proxy。
find_package时可以指定必要的组件:
1 | |
CMake 项目结构
一个典型的 C++ 项目可以采用以下结构:
1 | |
在这个结构中,项目根目录包含了一个 CMakeLists.txt 文件以及多个子项目文件夹。
一个子项目作为可执行文件,负责与用户交互,其他子项目则作为不同的库文件,编写实际的业务逻辑。可执行文件仅作为入口,所有的功能实现都在库文件中,这样的分离使得代码逻辑的库也能被其他软件组合和复用。
另外,根项目还可能包含其他文件夹,比如上面的cmake文件夹用于存放CMake配置脚本或工具函数。
每个子项目的组织格式为:
subprj/CMakeLists.txtsubprj/include/subprj/module.hsubprj/src/module.cpp
根项目 CMakeLists.txt
在根项目中的 CMakeLists.txt 中,我们进行基本的 C++ 版本设置等选项,并使用 project 命令初始化项目。
之后,通过 add_subdirectory 将子项目逐一添加到根项目中,这样根项目就能够调用子项目中的 CMakeLists.txt 文件。
1 | |
子项目 CMakeLists.txt
根项目的 CMakeLists.txt 主要负责全局配置,而子项目中的 CMakeLists.txt 则只关注该子项目自身的设置,如头文件目录、需要链接的库等。
子项目中,通常会使用 add_library 或 add_executable 来生成target,并配置target的选项,如链接的库和包含的头文件等。
在链接库时,由于 PUBLIC 的传播作用,某项目链接了其他项目的库后,也可以自动包含相应的头文件。
1 | |
在上面的例子中,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 | |
而如果没有namespace,我在两个子库中都一个同名的类,如果某个文件同时导入这两个头文件,也会有重复定义的编译错误;
如果有相同的函数名,引入两个头文件,编译该单元时没有错误,但要链接时无法确定是哪一个函数实现,出现链接错误。
所以将每个子项目使用不同的namespace先进行包裹,“把自己先圈起来”,防止和未知的头文件或库发生冲突,namespace相当于延长了标识符,人为地划定模块,防止标识符冲突。
1 | |
另外,如果要在头文件中写函数定义,需要使用inline或static修饰,它们可以使函数定义限制在该编译单元里,防止不同项目都引入函数定义后,链接时出现重复定义。
虽然在头文件中会有namespace,但解决不了这个问题,因为每个导入该头文件的都是原封不动的将头文件复制过来,相当于这个函数的全名(namespace::func)在多个文件里都被编译了一遍,那多个编译单元链接时,这个函数名字还是有多个意思(定义),从而造成冲突。
1 | |
从上面可以总结的是:在同一个编译单元中,可以有同名的声明(函数声明、类声明、变量声明),但定义都只能有一个(多次导入同一头文件造成的类冲突;导入不同头文件造成的类冲突)。
在多个编译单元中,依旧可以有同名的声明,复制于同一个头文件的不同编译单元的类定义是可行的(每个编译单元中会有自己的类副本),但函数定义和变量定义不行,但函数和变量的定义只能有一个副本(使用static和inline)。
一个典型的头文件如下:
1 | |
子项目源文件
一般源文件和相应的头文件成对出现,在源文件中,include相应的头文件,并在namespace中进行头文件的实现。
1 | |
每个cpp文件是一个编译单元,一般在写新功能时,会新建一对头文件和源文件,视为一个模块。
上面提到的namespace可以为每个模块搞成一块命名空间,但一般将每个子项目作为分隔不同命名空间的尺度就可以,项目内需要人为的保证每个小模块不会发生重复定义。
如果一个模块的头文件中仅仅声明了其他模块中的类,而没有直接使用或解引用该类的成员(例如调用成员函数或访问成员变量),那么头文件中不需要包含该类对应的头文件,而只需提供一个前向声明。例如,可以使用 struct ClassName; 或 class ClassName; 来声明类。只有在实际需要使用该类成员的实现文件(如 .cpp 文件)中,才需要包含完整的头文件。
1 | |
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 | |
比如说在.cmake文件中写一些常用的函数或宏等:
1 | |
macro 和 function:
- **
macro**:相当于将代码直接粘贴到调用者的位置。 - **
function**:创建了一个闭包,它优先访问定义者的作用域。
include 和 add_subdirectory
- **
include**:相当于将代码直接粘贴到调用者的作用域中。 - **
add_subdirectory**:会在子目录中创建一个新的作用域。
可以类比 C++ 中的#define和函数。
参考: