为什么要学 CMake
如果只是写一个很小的 C++ 程序,我们当然可以直接使用编译器:
g++ main.cpp -o hello
这条命令直观、简单,也足够完成第一个程序。但当项目稍微变大之后,问题就会慢慢出现:源文件变多了,头文件需要组织了,编译选项需要统一了,还可能要链接第三方库。这个时候,如果仍然把所有命令都手写在终端里,项目会很快变得难以维护。
CMake 的意义就在这里。它并不是编译器,也不是构建工具本身,而是一个用来描述项目如何被构建的工具。我们把项目结构、编译标准、目标文件和依赖关系写进 CMakeLists.txt,然后由 CMake 去生成具体平台上的构建文件。
在 Linux 上,它可以生成 Makefile 或 Ninja 文件;在 Windows 上,它可以生成 Visual Studio 工程;在 macOS 上,它也可以配合 Xcode 或 Ninja 使用。也就是说,CMake 试图解决的是一个工程问题:如何用一份相对统一的描述,让项目在不同环境下都能被构建。
一个最小项目
我们先从最小的项目开始。目录结构如下:
hello-cmake/
├── CMakeLists.txt
└── main.cpp
main.cpp 可以很简单:
#include <iostream>
int main() {
std::cout << "Hello, CMake!" << std::endl;
return 0;
}
接下来写 CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(hello_cmake LANGUAGES CXX)
add_executable(hello main.cpp)
这就是一个最小可用的 CMake 项目。它只有三行核心内容,但每一行都有自己的职责。
cmake_minimum_required 用来声明项目需要的最低 CMake 版本。这个版本不是随便写的,它会影响 CMake 使用哪些默认策略。对于新项目来说,写一个相对现代的版本,可以避免很多历史兼容行为带来的困惑。
project 用来声明项目名称和使用的语言。这里的项目名是 hello_cmake,语言是 CXX,也就是 C++。
add_executable 用来声明一个可执行目标。它的意思是:我要生成一个名为 hello 的可执行程序,它由 main.cpp 编译而来。
这里最重要的词是目标。现代 CMake 的核心不是「对一堆文件执行命令」,而是「描述一个个 target」。可执行文件是 target,库也是 target。以后我们添加编译选项、头文件目录、链接库,最好都围绕 target 来写。
构建项目
在项目根目录下,不建议直接把构建产物生成在源码目录里。更常见的做法是创建一个单独的 build 目录:
cmake -S . -B build
cmake --build build
第一条命令叫配置阶段。
cmake -S . -B build
其中 -S . 表示源码目录是当前目录,-B build 表示构建目录是 build。CMake 会读取 CMakeLists.txt,检查编译器和环境,然后在 build 目录里生成真正的构建文件。
第二条命令叫构建阶段。
cmake --build build
这里有两个地方都出现了 build,但含义不一样。--build 是 CMake 的参数,表示「我要开始构建项目」;最后那个 build 是构建目录的名字,也就是上一条命令中 -B build 创建出来的目录。
所以 cmake --build build 可以读成:让 CMake 去构建 build 这个目录里的项目。它会调用刚才生成的构建系统,真正开始编译代码。这样写的好处是比较跨平台。你不用关心底层到底是 Make、Ninja 还是 Visual Studio,统一交给 CMake 调用即可。
构建完成后,可执行文件一般会出现在 build 目录下。不同平台和生成器的路径可能稍有差异,但大体上可以这样运行:
./build/hello
在 Windows 上,如果使用 Visual Studio 生成器,产物可能会在 build/Debug/hello.exe 或 build/Release/hello.exe 中。
有些教程里还会看到另一种写法:
mkdir build
cd build
cmake ..
cmake --build .
这里的 cmake .. 和前面的 cmake -S . -B build 本质上是在做同一件事:配置项目。区别只是当前所在的位置不同。进入 build 目录后,.. 表示源码目录在上一级;而 cmake -S . -B build 是站在项目根目录,直接把源码目录和构建目录都说清楚。
如果项目比较大,还可以让构建过程并行执行:
cmake --build build -j 8
或者写得更明确一点:
cmake --build build --parallel 8
这里的 8 表示最多同时使用 8 个并行任务。需要注意的是,常见写法通常不是单独执行 cmake -j 8;-j 是给构建阶段用的,而不是给配置阶段用的。如果你直接使用 Makefile,也可能会看到 make -j8,它的意思也是并行构建。
指定 C++ 标准
真实项目通常需要指定 C++ 标准。例如我们希望使用 C++20,可以这样写:
cmake_minimum_required(VERSION 3.20)
project(hello_cmake LANGUAGES CXX)
add_executable(hello main.cpp)
target_compile_features(hello PRIVATE cxx_std_20)
这里使用的是 target_compile_features,而不是直接设置全局变量。它的意思是:hello 这个目标需要 C++20。
PRIVATE 表示这个要求只作用于 hello 自己。对于一个可执行文件来说,这通常就是我们想要的。如果以后写的是库,就会遇到 PUBLIC 和 INTERFACE,它们用来表达依赖是否会传递给使用这个库的人。
这也是现代 CMake 的一个重要习惯:优先给 target 设置属性,而不是到处设置全局变量。
当项目不止一个文件
真实项目通常不会只有一个 main.cpp。即使功能很小,我们也会开始把声明、实现和入口拆开。这个拆分本身并不复杂,但它会把 CMake 的作用显露出来:它不只是帮我们执行一条编译命令,而是在说明这些文件如何一起组成一个目标。
Note
.hpp 是 C++ 项目中常见的头文件后缀,通常用来表示「这里面放的是 C++ 的声明」。它和 .h 在编译器眼里没有本质区别,更多是一种命名习惯。有人喜欢用 .h 同时写 C 和 C++ 头文件,也有人用 .hpp 来提醒自己这是 C++ 头文件。
现在我们把项目稍微扩展一下:
hello-cmake/
├── CMakeLists.txt
├── include/
│ └── greeter.hpp
└── src/
├── greeter.cpp
└── main.cpp
头文件 include/greeter.hpp:
#pragma once
#include <string>
std::string make_greeting(const std::string& name);
实现文件 src/greeter.cpp:
#include "greeter.hpp"
std::string make_greeting(const std::string& name) {
return "Hello, " + name + "!";
}
入口文件 src/main.cpp:
#include <iostream>
#include "greeter.hpp"
int main() {
std::cout << make_greeting("CMake") << std::endl;
return 0;
}
此时的 CMakeLists.txt 可以写成:
cmake_minimum_required(VERSION 3.20)
project(hello_cmake LANGUAGES CXX)
add_executable(hello
src/main.cpp
src/greeter.cpp
)
target_compile_features(hello PRIVATE cxx_std_20)
target_include_directories(hello PRIVATE include)
target_include_directories 用来告诉编译器去哪里找头文件。因为我们的 greeter.hpp 放在 include/ 目录下,所以需要把这个目录加入头文件搜索路径。
这里仍然使用 PRIVATE。因为 hello 是一个可执行程序,它不会被其他 target 链接和复用,所以头文件目录没有必要向外传递。
加一点编译警告
一个比较好的习惯是尽早打开编译警告,这经常能在程序真正出错之前提醒我们。
不过,不同编译器的警告选项不完全一样。可以先写一个简单的判断:
if(MSVC)
target_compile_options(hello PRIVATE /W4)
else()
target_compile_options(hello PRIVATE -Wall -Wextra -Wpedantic)
endif()
MSVC 表示当前使用的是 Microsoft Visual C++ 编译器。Windows 上如果使用 Visual Studio,一般会走这个分支。/W4 是 MSVC 的警告等级选项,表示打开较高等级的编译警告。其他编译器,例如 GCC 或 Clang,则使用 -Wall -Wextra -Wpedantic:
-Wall打开常见警告。-Wextra再补充一些额外警告。-Wpedantic会提醒不太符合语言标准的写法。
此时完整的 CMakeLists.txt 可以是:
cmake_minimum_required(VERSION 3.20)
project(hello_cmake LANGUAGES CXX)
add_executable(hello
src/main.cpp
src/greeter.cpp
)
target_compile_features(hello PRIVATE cxx_std_20)
target_include_directories(hello PRIVATE include)
if(MSVC)
target_compile_options(hello PRIVATE /W4)
else()
target_compile_options(hello PRIVATE -Wall -Wextra -Wpedantic)
endif()
Debug 和 Release
很多初学者会困惑:CMake 怎么切换 Debug 和 Release?
这取决于使用的生成器。对于 Makefile 或 Ninja 这种单配置生成器,通常在配置阶段指定:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build
如果要构建 Release:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
但 Visual Studio 这种多配置生成器不一样。它可以在同一个构建目录里同时支持 Debug 和 Release,因此构建时指定配置:
cmake -S . -B build
cmake --build build --config Debug
或者:
cmake --build build --config Release
所以如果在不同教程里看到不同写法,不一定是谁错了,很可能只是使用的生成器不同。
FAQs
刚开始学 CMake 时,很容易把它当成脚本语言来堆命令。CMake 当然有脚本能力,但现代 CMake 更推荐围绕 target 描述关系。你应该尽量思考:这个编译选项属于哪个目标?这个头文件目录应该传递给谁?这个库要链接到哪个目标上?
另一个容易忽略的地方,是在源码目录里直接构建。这样会产生一堆临时文件,和源代码混在一起,很难清理。使用 cmake -S . -B build 可以让源码目录保持干净。
还有一个习惯,是少用会影响全局的命令。例如到处写 include_directories、add_compile_options。这些命令会影响后续很多目标,项目一大就很难判断某个选项到底从哪里来的。对于新项目来说,优先使用 target_include_directories、target_compile_options、target_link_libraries。
最后,不必急着把 CMakeLists.txt 写得很复杂。刚开始学习时,不需要一上来就研究 toolchain、install、package、FetchContent。先把「一个 target 如何被构建」理解清楚,比什么都重要。
回到 target
写到这里,其实这篇文章就做了一件事:从一个最小 C++ 程序出发,把它慢慢整理成一个 CMake 项目。
过程中出现了几个命令:
cmake_minimum_required:声明最低 CMake 版本project:声明项目名称和语言add_executable:添加可执行目标target_compile_features:指定目标需要的 C++ 标准target_include_directories:指定目标的头文件搜索路径target_compile_options:指定目标的编译选项
但这些命令并不是最值得背下来的部分。更重要的是它们背后的共同方向:CMake 的核心是 target。
当你开始把可执行文件、库、头文件目录、编译选项和依赖关系都看成 target 之间的关系时,CMake 就会清楚很多。它要解决的不是某一次编译命令怎么写,而是这个项目应该怎样被稳定地构建。
对初学者来说,先掌握到这里就够了。以后项目变大,可能还会遇到库的拆分、第三方依赖、安装规则和测试配置,但这些内容都可以慢慢加。先把一个最小项目写清楚,知道每个文件为什么出现在 CMakeLists.txt 里,已经是一个很好的起点。