如何使用 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.txt
subprj/include/subprj/module.h
subprj/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
和函数。
参考: