CMake Tips

Cmake

为什么需要构建系统(Makefile)

  • 当更新了hello.cpp时只会重新编译hello.o,而不需要把main.o也重新编译一遍

  • 能够自动并行地发起对hello.cpp和main.cpp的编译,加快编译速度(make -j)

  • 用通配符批量生成构建规则,避免针对每个.cpp和.o重复写 g++ 命令(%.o: %.cpp)

但坏处也很明显:

  1. make 在 Unix 类系统上是通用的,但在 Windows 则不然。
  2. 需要准确地指明每个项目之间的依赖关系,有头文件时特别头疼。
  3. make 的语法非常简单,不像 shell 或 python 可以做很多判断等。
  4. 不同的编译器有不同的 flag 规则,为 g++ 准备的参数可能对 MSVC 不适用。

构建系统的构建系统(CMake)

为了解决 make 的以上问题,跨平台的 CMake 应运而生!

只需要写一份 CMakeLists.txt,他就能够在调用时生成当前系统所支持的构建系统。

CMake 可以自动检测源文件和头文件之间的依赖关系,导出到 Makefile 里。

CMake 具有相对高级的语法,内置的函数能够处理 configure,install 等常见需求。

CMake 3.x (现代)相比 2.x (古代)有所不同:

现代 CMake 和古代 CMake 相比,使用更方便,功能更强大。

CMake 可以自动检测当前的编译器需要添加哪些 flag。比如 OpenMP,只需要在 CMakeLists.txt 中指明 target_link_libraries(a.out OpenMP::OpenMP_CXX) 即可。

CMake 官方文档

CMake “菜谱”

C++ 核心开发规范

CMake 的命令行调用

现代 CMake 提供了更方便的 -B 和 --build 指令,不同平台,统一命令:

1
2
3
4
5
6
#  第一步称为配置阶段(configure),这时只检测环境并生成构建规则
cmake -B build # 在源码目录用 -B 直接创建 build 目录并生成 build/Makefile

# 第二步称为构建阶段(build),这时才实际调用编译器来编译代码
cmake --build build -j4 # 自动调用本地的构建系统在 build 里构建,即:make -C build -j4
sudo cmake --build build --target install # 调用本地的构建系统执行 install 这个目标

cmake -B build 免去了先创建 build 目录再切换进去再指定源码目录的麻烦。 cmake --build build 统一了不同平台(Linux 上会调用 make,Windows 上调用 devenv.exe)。

配置阶段可以通过 -D 设置缓存变量。第二次配置时,之前的 -D 添加仍然会被保留。

1
2
cmake -B build -DCMAKE_BUILD_TYPE=Release #  设置构建模式为发布模式(开启全部优化)
cmake -B build # 第二次配置时没有 -D 参数,但是之前的 -D 设置的变量都会被保留

-G 选项:指定要用的生成器。Linux 系统上的 CMake 默认用是 Unix Makefiles 生成器;Windows 系统默认是 Visual Studio 2019 生成器;MacOS 系统默认是 Xcode 生成器。 可以用 -G 参数改用别的生成器,例如 cmake -GNinja 会生成 Ninja 这个构建系统的构建规则。Ninja 是一个高性能,跨平台的构建系统,Linux、Windows、MacOS 上都可以用。Ninja 则是专为性能优化的构建系统,他和 CMake 结合都是行业标准了。

性能上:Ninja > Makefile > MSBuild。

1
cmake -GNinja -B build

读取当前目录的 CMakeLists.txt,并在 build 文件夹下生成 build/Makefile:

cmake -Bbuild

让 make 读取 build/Makefile,并开始构建 a.out:

make -C build

以下命令和上一个等价,但更跨平台:

cmake --build build

执行生成的 build/a.out.

示例:

hello.cpp

1
2
3
4
5
#include <cstdio>

void hello() {
printf("Hello, world\n");
}

main.cpp

1
2
3
4
5
6
7
8
#include <cstdio>

void hello();

int main() {
hello();
return 0;
}

多种编译组织方式

普通编译

1
2
3
4
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_executable(a.out main.cpp hello.cpp)

静态库

除了 add_executable 可以生成可执行文件外,还可以通过 add_library 生成库文件

生成静态库 hellolib.a。

要在某个可执行文件中使用该库,只需要:

target_link_libraries(a.out PUBLIC hellolib) # 为 a.out 链接刚刚制作的库 hellolib.a

1
2
3
4
5
6
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_library(hellolib STATIC hello.cpp)
add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC hellolib)

动态库

生成静态库 hellolib.so

1
2
3
4
5
6
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_library(hellolib SHARED hello.cpp)
add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC hellolib)

对象库

1
2
3
4
5
6
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_library(hellolib OBJECT hello.cpp)
add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC hellolib)

对象库类似于静态库,但不生成 .a 文件,只由 CMake 记住该库生成了哪些对象文件。

对象库是 CMake 自创的,绕开了编译器和操作系统的各种繁琐规则,保证了跨平台统一性。

对象库可保证不会自动剔除没引用到的对象文件。而静态库会自动剔除。

动态库中链接静态库

让静态库编译时也生成位置无关的代码(PIC),这样才能装在动态库里。

1
2
3
4
5
6
7
8
add_library(otherlib STATIC otherlib.cpp)
set_property(TARGET otherlib PROPERTY POSITION_INDEPENDENT_CODE ON)

add_library(mylib SHARED mylib.cpp)
target_link_libraries(mylib PUBLIC otherlib)

add_executable(main main.cpp)
target_link_libraries(main PUBLIC mylib)
  • PUBLIC 表示所有依赖lib的target都自动绑定了该头文件路径

  • PRIVATE 表示该头文件路径仅对本target有效

  • INTERFACE 表示该头文件路径仅对依赖该lib的target生效

PUBLIC = PRIVATE + INTERFACE

子模块

有工程文件:

1
2
3
4
5
6
7
8
├─ hellolib
│ ├─ CMakeLists.txt
│ ├─ hello.cpp
│ └─ hello.h
├─ CMakeLists.txt
├─ main.cpp
├─ r.md
└─ run.sh

cmake文件:

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_subdirectory(hellolib)

add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC hellolib)

子目录下,cmake文件:

1
add_library(hellolib STATIC hello.cpp)

add_subdirectory 添加子目录,子目录也包含一个 CMakeLists.txt,其中定义的库在 add_subdirectory 之后就可以在主目录的文件中使用。

如果指定头文件搜索目录:

1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_subdirectory(hellolib)

add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC hellolib)
target_include_directories(a.out PUBLIC hellolib)

这样甚至可以用 <hello.h> 来引用这个头文件了,因为通过 target_include_directories 指定的路径会被视为与系统路径等价。这里指定了 hellolib 文件夹名称。

target_link_libraries(a.out PUBLIC hellolib) 中的 hellolib 为子模块的库名。

但是如果有 b.out 也引用了 hellolib:

1
2
target_include_directories(a.out PUBLIC hellolib)
target_include_directories(b.out PUBLIC hellolib)

要重复指定路径吗?

其实只需要在 hellolib 文件夹下的 cmakelists.txt 中指定 hellolib (库名) 的文件路径,设为 PUBLIC ,让使用到 hellolib 的可执行文件自动添加搜索路径即可。

1
2
add_library(hellolib STATIC hello.cpp)
target_include_directories(hellolib PUBLIC .)

此时主目录下的 cmakelists.txt :

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_subdirectory(hellolib)

add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC hellolib)

不需要 target_include_directories 设置了。

其他选项

1
2
3
4
5
6
7
8
9
10
target_include_directories(myapp PUBLIC /usr/include/eigen3)  # 添加头文件搜索目录
target_link_libraries(myapp PUBLIC hellolib) # 添加要链接的库

target_add_definitions(myapp PUBLIC MY_MACRO=1) # 添加一个宏定义
target_add_definitions(myapp PUBLIC -DMY_MACRO=1) # 与上一个等价

target_compile_options(myapp PUBLIC -fopenmp) # 添加编译器命令行选项
# 当使用了 PUBLIC ,链接了 myapp 的文件编译会自动加上 -fopenmp

target_sources(myapp PUBLIC hello.cpp other.cpp) # 添加要编译的源文件

以及可以通过下列指令(不推荐使用),把选项加到所有接下来的目标去:

1
2
3
4
include_directories(/opt/cuda/include)  # 添加头文件搜索目录到 cmakelists.txt 下文定义的所有目标文件
link_directories(/opt/cuda) # 添加库文件的搜索路径到 cmakelists.txt 下文定义的所有目标文件
add_definitions(MY_MACRO=1) # 添加一个宏定义到 cmakelists.txt 下文定义的所有目标文件
add_compile_options(-fopenmp) # 添加编译器命令行选项到 cmakelists.txt 下文定义的所有目标文件

第三方库

纯头文件库

最友好的一类库莫过于纯头文件库了,这里是一些好用的 header-only 库:

  1. nothings/stb - 大名鼎鼎的 stb_image 系列,涵盖图像,声音,字体等,只需单头文件!

  2. Neargye/magic_enum - 枚举类型的反射,如枚举转字符串等(实现方式很巧妙)

  3. g-truc/glm - 模仿 GLSL 语法的数学矢量/矩阵库(附带一些常用函数,随机数生成等)

  4. Tencent/rapidjson - 单纯的 JSON 库,甚至没依赖 STL(可定制性高,工程美学经典)

  5. ericniebler/range-v3 - C++20 ranges 库就是受到他启发(完全是头文件组成)

  6. fmtlib/fmt - 格式化库,提供 std::format 的替代品(需要 -DFMT_HEADER_ONLY)

  7. gabime/spdlog - 能适配控制台,安卓等多后端的日志库(和 fmt 冲突!)

只需要把他们的 include 目录或头文件下载下来,然后 include_directories(spdlog/include) 即可。

缺点:函数直接实现在头文件里,没有提前编译,从而需要重复编译同样内容,编译时间长。

1
2
3
4
5
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_executable(a.out main.cpp)
target_include_directories(a.out PUBLIC glm/include)

git clone git@github.com:g-truc/glm.git --depth=1 # depth=1 更快,不想做贡献的话,这样就好

子模块

第二友好的方式则是作为 CMake 子模块引入,也就是通过 add_subdirectory。

方法就是把那个项目(以fmt为例)的源码放到你工程的根目录:

这些库能够很好地支持作为子模块引入:

  1. fmtlib/fmt - 格式化库,提供 std::format 的替代品

  2. gabime/spdlog - 能适配控制台,安卓等多后端的日志库

  3. ericniebler/range-v3 - C++20 ranges 库就是受到他启发

  4. g-truc/glm - 模仿 GLSL 语法的数学矢量/矩阵库

  5. abseil/abseil-cpp - 旨在补充标准库没有的常用功能

  6. bombela/backward-cpp - 实现了 C++ 的堆栈回溯便于调试

  7. google/googletest - 谷歌单元测试框架

  8. google/benchmark - 谷歌性能评估框架

  9. glfw/glfw - OpenGL 窗口和上下文管理

  10. libigl/libigl - 各种图形学算法大合集

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_subdirectory(fmt)

add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC fmt)

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

预安装--可以解决子模块方式中可能会出现的菱形依赖的问题。

使用 apt 或者 pacman 或者 yum 安装。

可以通过 find_package 命令寻找系统中的包/库:

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_executable(a.out main.cpp)

find_package(fmt REQUIRED)
target_link_libraries(a.out PUBLIC fmt::fmt)

为什么是 fmt::fmt 而不是简单的 fmt?

现代 CMake 认为一个 (package) 可以提供多个,又称组件 (components),比如 TBB 这个包,就包含了 tbb, tbbmalloc, tbbmalloc_proxy 这三个组件。

因此为避免冲突,每个包都享有一个独立的名字空间,以 :: 的分割(和 C++ 还挺像的)。

你可以指定要用哪几个组件:

1
2
3
find_package(TBB REQUIRED COMPONENTS tbb tbbmalloc REQUIRED)

target_link_libraries(myexec PUBLIC TBB::tbb TBB::tbbmalloc)

像Qt5 这种项目,find_package就需要指定出需要的组件的名称。

Windows 上找不到 Qt5 包怎么办?手动指定 Qt5_DIR。

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

set(Qt5_DIR C:/Qt/Qt5.14.2/msvc2019_64/lib/cmake)

find_package(Qt5 REQUIRED COMPONENTS Widgets Gui REQUIRED)
target_link_libraries(main PUBLIC Qt5::Widgets Qt5::Gui)

或者在命令行编译cmake时,指定 -DQt5-DIR 参数。

Windows 则没有自带的包管理器。因此可以用跨平台的 vcpkg:https://github.com/microsoft/vcpkg

使用方法:下载 vcpkg 的源码,放到你的项目根目录,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
> cd vcpkg

> .\bootstrap-vcpkg.bat

> .\vcpkg integrate install

> .\vcpkg install fmt:x64-windows

> cd ..

> cmake -Bbuild -DCMAKE_TOOLCHAIN_FILE="%CD%/vcpkg/scripts/buildsystems/vcpkg.cmake"


多源文件

GLOB_RECURSE 能自动包含所有子文件夹下的文件,注意先将所有源代码文件统一组织到 src 文件夹下,将build目录与src目录分开。

1
2
3
add_executable(main)
file(GLOB_RECURSE sources CONFIGURE_DEPENDS *.cpp *.h)
target_sources(main PUBLIC ${sources})

CONFIGURE_DEPENDS:自动更新新加的文件。

1
2
3
4
add_executable(main)
aux_source_directory(. sources)
aux_source_directory(mylib sources)
target_sources(main PUBLIC ${sources})

用 aux_source_directory,可以自动搜集需要的文件后缀名。

CMake 参数选项

构建的类型

Debug: -O0 -g

Release: -O3 -DNDEBUG

MinSizeRel: -Os -DNDEBUG

RelWithDebInfo: -O2 -g -DNDEBUG

初始化项目信息

1
2
3
4
5
6
7
8
9
10
11
cmake_minimum_required(VERSION 3.15)
project(hellocmake)

message("PROJECT_NAME: ${PROJECT_NAME}")
message("PROJECT_SOURCE_DIR: ${PROJECT_SOURCE_DIR}")
message("PROJECT_BINARY_DIR: ${PROJECT_BINARY_DIR}")
message("CMAKE_CURRENT_SOURCE_DIR: ${CMAKE_CURRENT_SOURCE_DIR}")
message("CMAKE_CURRENT_BINARY_DIR: ${CMAKE_CURRENT_BINARY_DIR}")
add_executable(main main.cpp)

add_subdirectory(mylib)

PROJECT_SOURCE_DIR:当前项目源码路径(存放main.cpp的地方) PROJECT_BINARY_DIR:当前项目输出路径(存放main.exe的地方) CMAKE_SOURCE_DIR:根项目源码路径(存放main.cpp的地方) CMAKE_BINARY_DIR:根项目输出路径(存放main.exe的地方) PROJECT_IS_TOP_LEVEL:BOOL类型,表示当前项目是否是(最顶层的)根项目 PROJECT_NAME:当前项目名 CMAKE_PROJECT_NAME:根项目的项目名

利用 PROJECT_SOURCE_DIR 可以实现从子模块里直接获得项目最外层目录的路径。 不建议用 CMAKE_SOURCE_DIR,那样会让你的项目无法被人作为子模块使用。

LANGUAGES 字段支持的语言包括:

C:C语言 CXX:C++语言 ASM:汇编语言 Fortran:老年人的编程语言 CUDA:英伟达的 CUDA(3.8 版本新增) OBJC:苹果的 Objective-C(3.16 版本新增) OBJCXX:苹果的 Objective-C++(3.16 版本新增) ISPC:一种因特尔的自动 SIMD 编程语言(3.18 版本新增)

如果不指定 LANGUAGES,默认为 C 和 CXX。

使用CMAKE_CXX_STANDARD 变量,而不是使用 options 指定 -std=c++17。这样跨平台方便。

若设置了项目名,会自动设置另外 <项目名>_SOURCE_DIR 等变量。

对象的属性

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

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

windows 中 dll 的搜索路径在 exe 的同目录下,以及环境变量中。而 Linux 中,可执行文件会有RPATH属性字段,自动指向它链接到的 .so 库文件。

编译时消息

message 指令可以在编译时输出字符串内容。

message(STATUS “...”) 表示信息类型是状态信息,有 -- 前缀;

message(WARNING “...”) 表示是警告信息;

message(AUTHOR_WARNING “...”) 表示是仅仅给项目作者看的警告信息;

message(FATAL_ERROR “...”) 表示是错误信息,会终止 CMake 的运行;

message(SEND_ERROR “...”) 表示是错误信息,但之后的语句仍继续执行;

CMake缓存

CMake 第一遍需要检测编译器和 C++ 特性等比较耗时,检测完会把结果存储到缓存中,这样第二遍运行 cmake -B build 时就可以直接用缓存的值。

有时候外部的情况有所更新,这时候 CMake 里缓存的却是旧的值,会导致一系列问题。

最简单的办法就是删除 build 文件夹。

若不想从头重新编译,可以只删除 build/CMakeCache.txt 这个文件。

CMake跨平台

根据不同操作系统,定义宏:

1
2
3
4
5
6
7
8
9
10
11
add_executable(main)
file(GLOB sources CONFIGURE_DEPENDS *.cpp *.h)
target_sources(main PUBLIC ${sources})

if (WIN32)
target_compile_definitions(main PUBLIC MY_NAME="Bill Gates")
elseif (UNIX AND NOT APPLE)
target_compile_definitions(main PUBLIC MY_NAME="Linus Torvalds")
elseif (APPLE)
target_compile_definitions(main PUBLIC MY_NAME="Steve Jobs")
endif()

使用生成器表达式可以写成:

1
2
3
4
5
6
7
8
9
add_executable(main)
file(GLOB sources CONFIGURE_DEPENDS *.cpp *.h)
target_sources(main PUBLIC ${sources})

target_compile_definitions(main PUBLIC
$<$<PLATFORM_ID:Windows>:MY_NAME="Bill Gates">
$<$<PLATFORM_ID:Linux>:MY_NAME="Linus Torvalds">
$<$<PLATFORM_ID:Darwin>:MY_NAME="Steve Jobs">
)

CXX_COMPILER_ID 可以判断编译器类型:

1
2
3
4
5
6
7
8
add_executable(main)
file(GLOB sources CONFIGURE_DEPENDS *.cpp *.h)
target_sources(main PUBLIC ${sources})

target_compile_definitions(main PUBLIC
$<$<CXX_COMPILER_ID:GNU,Clang>:MY_NAME="Open-source">
$<$<CXX_COMPILER_ID:MSVC,NVIDIA>:MY_NAME="Commercial">
)

变量与判断

父模块里定义的变量,会传递给子模块。但是子模块里定义的变量,不会传递给父模块。

可以使用 ${xx} 访问的是局部变量,$ENV{xx} 访问系统的环境变量,用 $CACHE{xx} 来访问缓存里的 xx 变量。

if (DEFINED ENV{xx}) 可以判断某环境变量是否存在。if (xx) 可以判断某变量是否存在且不为空字符串。因为空字符串等价于 FALSE。

CCache 编译加速缓存

1
2
3
4
5
6
7
8
9
cmake_minimum_required(VERSION 3.15)
project(hellocmake)

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()

gcc 编译使用方法,把 gcc -c main.cpp -o main 换成 ccache gcc -c main.cpp -o main 即可。

CMake 项目管理模块化指南

目录组织格式:

  • 项目名/include/项目名/模块名.h
  • 项目名/src/模块名.cpp

CMakeLists.txt 中写:

  • target_include_directories(项目名 PUBLIC include)

源码文件中写:

  • #include <项目名/模块名.h>
  • 项目名::函数名();

头文件(项目名/include/项目名/模块名.h)中写:

1
2
3
4
#pragma once
namespace 项目名 {
void 函数名();
}

实现文件(项目名/src/模块名.cpp)中写:

1
2
3
4
#include <项目名/模块名.h>
namespace 项目名 {
void 函数名() { 函数实现 }
}

示例

示例组织方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
├── biology
│   ├── CMakeLists.txt
│   ├── include
│   │   └── biology
│   │   ├── Animal.h
│   │   └── Carer.h
│   └── src
│   ├── Animal.cpp
│   └── Carer.cpp
├── cmake
│   └── MyUsefulFuncs.cmake
├── CMakeLists.txt
└── pybmain
├── CMakeLists.txt
├── include
│   └── pybmain
│   └── myutils.h
└── src
└── main.cpp

大型的项目,往往会划分为几个子项目。即只有一个子项目,也建议创建一个子目录,方便以后追加新的子项目。

上面的例子中,在根目录下,创建了两个子项目 biology 和 pybmain,他们分别在各自的目录下有自己的 CMakeLists.txt。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cmake_minimum_required(VERSION 3.18)

if (NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
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(CppCMakeDemo LANGUAGES CXX)

include(MyUsefulFuncs)

add_subdirectory(pybmain)
add_subdirectory(biology)

在根项目的 CMakeLists.txt 中,设置了默认的构建模式,设置了统一的 C++ 版本等各种选项。

然后通过 project 命令初始化了根项目。随后通过 add_subdirectory 把两个子项目 pybmain 和 biology 添加进来,这会调用 pybmain/CMakeLists.txt 和 biology/CMakeLists.txt。

1
2
3
file(GLOB_RECURSE srcs CONFIGURE_DEPENDS src/*.cpp include/*.h)
add_library(biology STATIC ${srcs})
target_include_directories(biology PUBLIC include)

子项目的 CMakeLists.txt 就干净许多,只是创建了 biology 这个静态库对象,并通过 GLOB_RECRUSE 为他批量添加了所有位于 src 和 include 下源码和头文件。

根项目的 CMakeLists.txt 负责处理全局有效的设定。而子项目的 CMakeLists.txt 则仅考虑该子项目自身的设定,比如他的头文件目录,要链接的库等等。

这里给 biology 设置了头文件搜索路径 include。因为子项目的 CMakeLists.txt 里指定的路径都是相对路径,所以这里指定 include 实际上是:根/biology/include。

使用 PUBLIC 修饰符,这是为了让链接 biology 的 pybmain 也能够共享 根/biology/include 这个头文件搜索路径。

这里我们给 biology 批量添加了 src/*.cpp 下的全部源码文件。

明明只有 *.cpp 需要编译,为什么还添加了 include/*.h?为了头文件也能被纳入 Vs Code 的项目资源浏览器,方便编辑。

因为子项目的 CMakeLists.txt 里指定的路径都是相对路径,所以这里指定 src 实际上是:根/biology/src。

GLOB_RECURSE 相比 GLOB ,GLOB_RECURSE 会对 *.cpp 进行嵌套目录的搜索匹配。

CONFIGURE_DEPENDS 选项则是用于自动检测目录更新。如果不添加,每次添加新文件后,需要重新运行 cmake -B build 才能更新项目的符号链接之类的。如果添加了,运行 cmake --build 时检测到目录有新文件了,CMake 会自动帮你重新运行 cmake -B build 更新。

头文件和源文件组织

头文件和源文件应保持一一对应的关系。

通常每个头文件都有一个对应的源文件,两个文件名字应当相同,只有后缀名不一样。头文件中包含函数和类的声明,源文件则包含他们的实现。

如果是一个类,则文件名应和类名相同,方便查找(Animal.cpp)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#Animal.h

#pragma once

#include <ostream>

namespace biology {

struct Animal {
virtual void speak(std::ostream &os) const = 0;
virtual ~Animal() = default;
};

struct Cat : Animal {
virtual void speak(std::ostream &os) const override;
};

struct Dog : Animal {
virtual void speak(std::ostream &os) const override;
};
}
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <biology/Animal.h>

namespace biology {

void Cat::speak(std::ostream &os) const {
os << "Meow~";
}

void Dog::speak(std::ostream &os) const {
os << "Wang!";
}

}

头文件中函数定义

有时会直接把实现直接写在头文件里,这时可以没有与之对应的源文件,只有一个头文件。

注意:在头文件里直接实现函数时,要加 static 或 inline 关键字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# pybmain/include/pybmain/myutils.h

#pragma once

#include <string>
#include <cctype>

namespace pybmain {

static std::string alluppercase(std::string s) {
std::string ret;
for (char c: s) {
ret.push_back(std::toupper(c));
}
return ret;
}

}

添加新功能

添加一个新功能模块时,同时添加同名的源文件和头文件。头文件中的声明和源文件中的实现一一对应。

如果新模块中用到了其他模块的类或函数,则需要在新模块的头文件和源文件中都导入其他模块的头文件。

注意不论是项目自己的头文件还是外部的系统的头文件,请全部统一采用 <项目名/模块名.h> 的格式。不要用 “模块名.h” 这种相对路径的格式,避免模块名和系统已有头文件名冲突。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# biology/include/biology/Carer.h
#pragma once

#include <string>

namespace biology {

struct Animal;

struct Carer {
std::string care(Animal *a) const;
};

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# biology/src/Carer.cpp

#include <biology/Carer.h>
#include <biology/Animal.h>
#include <sstream>

namespace biology {

std::string Carer::care(Animal *a) const {
std::ostringstream ss;
a->speak(ss);
return ss.str();
}

}

注意上面的 struct Animal; 写法,并没有导入头文件。

如果模块 Carer 的头文件 Carer.h 虽然引用了其他模块中的 Animal 类,但是他里面并没有解引用 Animal,只有源文件 Carer.cpp 解引用了 Animal。那么这个头文件是不需要导入 Animal.h 的,只需要一个前置声明 struct Animal,只有实际调用了 Animal 成员函数的源文件需要导入 Animal.h。

这样做的好处是,可以加快编译速度,防止循环引用。

在声明和定义外面都套一层名字空间,例如此处子项目名是 biology,那就使用 biology::Animal。避免暴露全局的 Animal。

这是因为万一有个“不拘一格”的第三方库也暴露个全局的 Animal,两个符号就会发生冲突。

由于类符号都具有 weak 属性,链接器会随机选择一个覆盖掉,非常危险!

如果一个子项目依赖另一个子项目,则需要链接他。

让 pybmain 链接上 biology:

1
target_link_libraries(pybmain PUBLIC biology)

由于 PUBLIC 属性具有传染性,根/biology/include 现在也加入 pybmain 的头文件搜索路径了,因此 pybmain 里可以 #include 到 biology 的头文件。

同理如果又有一个 target_link_libraries(zxxpig PUBLIC pybmain) 那么 zxxpig 也有 pybmain 和 biology 的所有头文件搜索路径了。

CMake 也有 include 功能

和 C/C++ 的 #include 一样,CMake 也有一个 include 命令。

你写 include(XXX) 时,则他会在 CMAKE_MODULE_PATH 这个列表中的所有路径下查找 XXX.cmake 这个文件。那么在 XXX.cmake 里,就可以写一些你常用的函数,宏,变量等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 一个 xxx.cmake 文件示例

macro (my_add_target name type)
# 用法: my_add_target(pybmain EXECUTABLE)
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()

set(SOME_USEFUL_GLOBAL_VAR ON)
set(ANOTHER_USEFUL_GLOBAL_VAR OFF)

导入方式是在 CMakeLists.txt 中添加:

1
2
3
4
5
6
set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake;${CMAKE_MODULE_PATH}")

include(MyUsefulFuncs)
# 对应于目录中以下文件
# ├── cmake
# │   └── MyUsefulFuncs.cmake

macro 和 function 的区别

macro 相当于直接把代码粘贴过去,直接访问调用者的作用域。这里写的相对路径 include 和 src,是基于调用者所在路径

function 则是会创建一个闭包,优先访问定义者的作用域。这里写的相对路径 include 和 src,则是基于定义者所在路径

include 和 add_subdirectory 的区别

include 相当于直接把代码粘贴过去,直接访问调用者的作用域。这里创建的变量和外面共享,直接 set(key val) 则调用者也有 ${key} 这个变量了。

add_subdirectory 这类 function 中,则是基于定义者所在路径,优先访问定义者的作用域。这里需要在在被包含的子项目中 set(key val PARENT_SCOPE) ,才能修改到外面的变量。

第三方库依赖

find_package

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
find_package(OpenCV)
查找名为 OpenCV 的包,找不到不报错,事后可以通过 ${OpenCV_FOUND} 查询是否找到。

find_package(OpenCV QUIET)
查找名为 OpenCV 的包,找不到不报错,也不打印任何信息。

find_package(OpenCV REQUIRED) # 最常见用法
查找名为 OpenCV 的包,找不到就报错(并终止 cmake 进程,不再继续往下执行)。

find_package(OpenCV REQUIRED COMPONENTS core videoio)
查找名为 OpenCV 的包,找不到就报错,且必须具有 OpenCV::core OpenCV::videoio 这两个组件,如果没有这两个组件也会报错。

find_package(OpenCV REQUIRED OPTIONAL_COMPONENTS core videoio)
查找名为 OpenCV 的包,找不到就报错,可具有 OpenCV::core OpenCV::videoio 这两个组件,没有这两组件不会报错,通过 ${OpenCV_core_FOUND} 查询是否找到 core 组件。

find_package(OpenCV 2.0.1 REQUIRED)
查找版本在 2.0.1 以上的 OpenCV 包(version >= 2.0.1

find_package(OpenCV 2.0.1 EXACT REQUIRED)
查找版本刚好为 2.0.1 OpenCV 包(version == 2.0.1

find_package(OpenCV) 实际上是在找一个名为 OpenCVConfig.cmake 的文件。

出于历史兼容性考虑,除了 OpenCVConfig.cmake 以外 OpenCV-config.cmake 这个文件名也会被 CMake 识别到。

同理,find_package(Qt5) 则是会去找名为 Qt5Config.cmake 的文件。

Qt5Config.cmake 是你安装 Qt5 时,随 libQt5Core.so 等实际的库文件,一起装到你的系统中去的。以 Arch Linux 系统为例:包配置文件位于 /usr/lib/cmake/Qt5/Qt5Config.cmake。实际的动态库文件位于 /usr/lib/libQt5Core.so。

因此 find_package 并不是直接去找具体的动态库文件和头文件(例如 libQt5Core.so)。而是去找包配置文件(例如Qt5Config.cmake),这个配置文件里包含了包的具体信息,包括动态库文件的位置,头文件的目录,链接时需要开启的编译选项等等。

而且某些库都具有多个子动态库,例如 Qt 就有 libQt5Core.so、libQt5Widgets.so、libQt5Network.so。因此 CMake 要求所有第三方库作者统一包装成一个 Qt5Config.cmake 文件包含所有相关信息(类似于 nodejs 的 package.json)。

包配置文件由第三方库的作者(Qt的开发团队)提供,在这个库安装时(Qt的安装程序或apt install等)会自动放到 /usr/lib/cmake/XXX/XXXConfig.cmake 这个路径(其中XXX是包名),供 CMake 用户找到并了解该包的具体信息。

/usr/lib/cmake 这个位置是 CMake 和第三方库作者约定俗成的,由第三方库的安装程序负责把包配置文件放到这里。如果第三方库的作者比较懒,没提供 CMake 支持(由安装程序提供XXXConfig.cmake),那么得用另外的一套方法(FindXXX.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
例如 64 位的 Linux 系统,find_package(Qt5 REQUIRED) 会依次搜索:
/usr/lib/cmake/Qt5/Qt5Config.cmake
/usr/lib/x86_64-linux-gnu/cmake/Qt5/Qt5Config.cmake
/usr/share/cmake/Qt5/Qt5Config.cmake
/usr/lib/Qt5/Qt5Config.cmake
/usr/lib/x86_64-linux-gnu/Qt5/Qt5Config.cmake
/usr/share/Qt5/Qt5Config.cmake
/usr/Qt5/lib/cmake/Qt5/Qt5Config.cmake
/usr/Qt5/lib/x86_64-linux-gnu/cmake/Qt5/Qt5Config.cmake
/usr/Qt5/share/cmake/Qt5/Qt5Config.cmake
/usr/Qt5/lib/Qt5/Qt5Config.cmake
/usr/Qt5/lib/x86_64-linux-gnu/Qt5/Qt5Config.cmake
/usr/Qt5/share/Qt5/Qt5Config.cmake

例如 64 位的 Windows 系统,find_package(Qt5 REQUIRED) 会依次搜索:
C:/Program Files/Qt5Config.cmake
C:/Program Files/cmake/Qt5Config.cmake
C:/Program Files/Qt5/Qt5Config.cmake
C:/Program Files/Qt5/cmake/Qt5Config.cmake
C:/Program Files/Qt5/lib/cmake/Qt5/Qt5Config.cmake
C:/Program Files/Qt5/lib/x86_64-windows-gnu/cmake/Qt5/Qt5Config.cmake
C:/Program Files/Qt5/share/cmake/Qt5/Qt5Config.cmake
C:/Program Files/Qt5/lib/Qt5/Qt5Config.cmake
C:/Program Files/Qt5/lib/x86_64-windows-gnu/Qt5/Qt5Config.cmake
C:/Program Files/Qt5/share/Qt5/Qt5Config.cmake

find_package找到配置文件

手动指定一个变量告诉配置文件在哪儿

  • 可以是普通变量 ${Qt5_DIR}

    1
    2
    3
    # 项目的 CMakeLists.txt 最开头写一行
    # 一定要加在最前面!!!
    set(Qt5_DIR ”D:/Qt5.12.1/msvc2017/lib/cmake/Qt5”)
  • 可以是环境变量 $ENV{Qt5_DIR},添加一个环境变量 Qt5_DIR 值为 D:/Qt5.12.1/msvc2017/lib/cmake/Qt5,在Linux中,可以export Qt5_DIR="/opt/Qt5.12.1/lib/cmake/Qt5"

  • 可以通过命令行 -DQt5_DIR=”C:/Program Files/Qt5.12.1/lib/cmake/Qt5” 设置

没有配置文件?

CMake 提供了一些 Find 配置:

1
2
/usr/share/cmake/Modules/FindCUDAToolkit.cmake
/usr/share/cmake/Modules/FindPython.cmake

github 上也有开源的FindXXX.cmake 配置文件。

使用方法

1
2
3
4
5
6
7
8
9
10
11
# 旧版本:
find_package(XXX)
if (NOT XXX_FOUND)
message(FATAL_ERROR “XXX not found”)
endif()
target_include_directories(yourapp ${XXX_INCLUDE_DIRS})
target_link_libraries(yourapp ${XXX_LIBRARIES})

# 现代:
find_package(XXX REQUIRED COMPONENTS xxx)
target_link_libraries(yourapp XXX::xxx)

大部分第三方库都需要提前安装好,然后再 find_package 找到他,然后才能链接。也有少数第三方库为了方便,还支持作为子项目加到项目中来,这种就不需要 :: 语法。

1
2
3
4
5
6
find_package(spdlog REQUIRED)
target_link_libraries(yourapp PUBLIC spdlog::spdlog)

# 添加源码到子目录下的方式
add_subdirectory(spdlog) # 需要下载好源码放到你的根目录下
target_link_libraries(yourapp PUBLIC spdlog)

Unix 软件从源码安装的通用方法

1
2
3
4
5
6
7
8
9
## Makefile 构建系统:
./configure --prefix=/usr --with-some-options # 生成 Makefile(这个 configure 脚本由 Autoconf 生成)
make -j 8 # 8 核心编译,生成 libtest.so
sudo make install # 安装,拷贝到 /usr/lib/libtest.so

## CMake 构建系统:
cmake -B build -DCMAKE_INSTALL_PREFIX=/usr -DWITH_SOME_OPTIONS=ON # 生成 Makefile
cmake --build build --parallel 8 # 8 核心编译,生成 libtest.so
sudo cmake --build build --target install # 安装,拷贝到 /usr/lib/libtest.so

注:如果 -DCMAKE_INSTALL_PREFIX=/usr/local 则会拷贝到 /usr/local/lib/libtest.so


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!