做 C++ 开发这些年,构建工具我基本全用过来了。最早在 Windows 上写业务代码,Visual Studio 的 .sln 工程文件拖拖拽拽就把文件加进去了,很省心;后来项目要迁到 Linux、macOS 上编译,才发现 .sln 根本带不过去,手写的 Makefile 又因为缩进和依赖关系改得头皮发麻。最后把团队项目统一迁到 CMake 上,这才算是把"一套代码、多处构建"这件事真正理顺了。
这篇文章把 CMake 的核心知识从头到尾梳理一遍,内容不多不少,正好覆盖日常开发里最常用的那部分:它到底解决什么问题、怎么安装升级、CMakeLists.txt 怎么上手、几个高频配置怎么写,以及那些动不动就蹦出来的报错怎么排查。刚接触 CMake 的人可以照着敲一遍,用过一段时间但一直在抄模板的人,也能从里面找到一些平时忽略的细节。
1. 从"为什么要用"说起,CMake 到底解决了什么问题
1.1 构建系统的痛点:一份代码要跑在多个平台
先看一个再常见不过的场景:你在 Windows 上用 Visual Studio 建了个工程,main.cpp 里#include <iostream>,点击运行,一切正常。这时候同事跟你说,Linux 服务器上也要编译一份。你把代码打包发过去,对方一看:没有 .sln,没有 .vcxproj,怎么办?要么手动敲 g++ 命令,要么现场写 Makefile。如果项目有几十个源文件、分了十几个目录,手动维护依赖这件事很快就会失控。
Makefile 本身没有问题,但它有几个天然的短板:语法比较晦涩,tab 和空格一个不留神就报错;平台相关命令没法自动适配;没有内置的"生成 IDE 工程文件"能力。autotools 那套工具链又太重,学习成本高,而且不是所有人都愿意接触 m4 宏那套东西。这时候就需要一个"中间人":你用一套统一的脚本描述项目结构,它负责根据当前平台和工具链,生成对应的构建文件——在 Linux 上生成 Makefile,在 Windows 上生成 Visual Studio 解决方案,在 macOS 上生成 Xcode 工程。这个中间人,就是 CMake。
1.2 CMake 的运作模式:先配置、再生成
CMake 的核心设计是两阶段模型。第一阶段是"配置阶段",CMake 读取 CMakeLists.txt,检查工具链、编译器、依赖库,把结果写入 CMakeCache.txt;第二阶段是"生成阶段",根据配置结果生成实际的构建文件。日常命令就两条:
cmake -S . -B build cmake --build build-S .指定源码目录,-B build指定构建目录,这也就是常说的 out-of-source 构建。我见过不少新手直接把构建文件生成到源码目录里,导致源码树里混进一堆 CMakeFiles、CMakeCache.txt,既不美观,切换构建类型时还会互相污染。从一开始就养成-B build的习惯,能省去后面一大半清理工作。
配置阶段和生成阶段分开有什么好处?最关键的一点是可以保留多套构建目录。同一个源码目录,可以同时存在build-release、build-debug、build-windows这样的目录,每套目录对应不同的编译选项和工具链,互不干扰。这在 CI(持续集成)环境里尤其有用,我下面讲实操时还会再提到。
1.3 为什么生态里最终流行的是 CMake
坦白说,市面上做这件事的工具不止 CMake 一个,后来出现的 Meson、Bazel、xmake 各有特色,但 CMake 目前的生态地位依然很难撼动。原因有几个。第一,它几乎是所有主流 C/C++ 库的标准配置方式,你在 GitHub 上下载第三方库,大概率都会看到 CMakeLists.txt,会 CMake 就等于会安装、集成大部分开源库。第二,主流 IDE 都内置或深度支持 CMake,Visual Studio 2019 之后甚至可以直接打开 CMakeLists.txt 当工程用,CLion 更是默认就把 CMake 当作一等公民。第三,CMake 的语法虽然经常被吐槽,但经过 3.0 以来的几次大版本迭代,现代 CMake 的写法已经比早期规范、友好很多。
这里想特别说明一下:不要因为网上有人吐槽"CMake 语法难用"就抵触它。早期版本留下了不少反人类的写法,但如果我们只使用现代推荐的 target 体系,把每个目标(可执行程序、静态库、动态库)的依赖关系理清楚,整个 CMakeLists.txt 是可以写得非常清爽的。后面第三部分我会专门讲 target 体系。
2. 安装、升级与第一个 Hello CMake
2.1 各平台安装和升级方法
先讲安装,因为很多新手第一个拦路虎不是语法,而是"CMake 装不上"或者"版本太老"。Windows 上最直接的方式是去官网下 .msi 安装包,安装时记得勾选把 CMake 加入系统 PATH,否则命令行里敲cmake会提示找不到命令。如果你用包管理器,也可以这样:
choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'Linux 上用包管理器安装最省事:
sudo apt install cmake但这里要留个心眼:Ubuntu 这种长期支持发行版自带的 CMake 版本一般偏旧。比如 Ubuntu 20.04 自带的还是 3.16 系列,而有些新特性(比如预编译头文件,见后面的 4.3)从 3.16 才开始支持,更晚一点的功能就没有了。想用新版的话,可以装 Kitware 官方 APT 源,也可以直接用 pip:
pip install cmake是的,PyPI 上有 CMake 的二进制发行包,装完cmake --version就能看到新版,对没有 root 权限的服务器特别友好。macOS 用户最省心,brew install cmake和brew upgrade cmake两步搞定。如果你的环境比较特殊,比如 Windows 7 32 位,新版 CMake 对老系统的支持已经逐步收窄,那就需要去官网找对应的旧版本安装包,装完记得手动把 CMake 的 bin 目录加进 PATH。
2.2 版本选择不容忽视
版本问题值得单独拿出来说,因为 CMakeLists.txt 里第一行通常就是cmake_minimum_required,它直接决定了后面的语法能不能用。我习惯把最低版本设成 3.16,原因很现实:这个版本支持了 target_precompile_headers,也支持了基本的文件集功能,是近几年最常用特性的一个分水岭,同时主流的 Ubuntu 20.04、VS 2019 自带的 CMake 都能满足。如果你的项目要兼容更老的环境,那把它降到 3.10 也不是不行,但就要放弃预编译头、日志等级这些便利。
升级 CMake 本身一般不会破坏现有项目,但会改变一些默认策略(policy)。CMake 的 policy 机制会在升级后给出 warning,哪怕不处理,大多数情况下也能正常编译,不过我还是建议大家定期看一遍这些 warning,早处理早安心。检查版本的命令很简单:
cmake --version如果和 CMakeLists.txt 里要求的最低版本对不上,配置阶段就会直接报错,这种报错通常很直白,按提示升级即可。
2.3 五步跑通第一个可执行程序
准备工作做完,我们写一个最简例子。目录结构很简单:
hello/ ├── CMakeLists.txt └── main.cppmain.cpp 长这样:
#include <iostream> int main() { std::cout << "Hello CMake" << std::endl; return 0; }CMakeLists.txt 只需要三行:
cmake_minimum_required(VERSION 3.16) project(hello LANGUAGES CXX) add_executable(hello main.cpp)然后执行:
cd hello cmake -S . -B build cmake --build build第一条命令会生成 build 目录和构建系统文件,第二条命令真正调用编译器产出可执行文件。在 Linux/macOS 上,产物在build/hello;在 Windows 的 Visual Studio 生成器下,产物一般在build/Debug/hello.exe或build/Release/hello.exe,这取决于你选择的配置。如果一切正常,运行它就会打印Hello CMake。
如果你在网上搜教程,还会看到cmake . && make这种写法,它是早期 CMake 时代遗留下来的习惯,等价于"配置并构建"。这种写法会把构建生成的文件直接写进源码目录,我个人不推荐。现代写法把源码目录和构建目录显式分开,既不会弄脏源码,也方便随时清掉 build 目录重新配置。
这个例子虽然简单,但已经包含了 CMakeLists.txt 里最重要的三件事:声明最低版本、声明工程信息、定义构建目标。后面的所有内容,都是在这三句话的基础上不断加东西。
3. CMakeLists.txt 的核心语法,抓重点高效上手
3.1 变量、作用域与缓存变量
CMake 里的变量本质上都是字符串,定义用set:
set(MY_VAR "hello") message(STATUS "MY_VAR = ${MY_VAR}")变量有作用域的概念。函数内部set出来的变量默认只在函数内可见,想传给上一层要用PARENT_SCOPE:set(MY_VAR "hello" PARENT_SCOPE)。还有一个容易混淆的概念叫"缓存变量",它会被写入 CMakeCache.txt,跨多次配置保持存在。用set加CACHE关键字定义:
set(BUILD_SHARED_LIBS ON CACHE BOOL "Build shared libraries")缓存变量最常用的场景是和命令行-D参数配合。比如cmake -DBUILD_SHARED_LIBS=OFF ...,就能在配置阶段覆盖缓存值。这也是很多库"开开关"的实现原理。你在 CMakeLists.txt 里写option(BUILD_EXAMPLES ON ...),本质上也生成一个 BOOL 类型的缓存变量,用户构建时可以通过-DBUILD_EXAMPLES=OFF来关闭例子。
3.2 现代 CMake 的 target 体系
如果说只记一个重点,那就是理解 target。target 是 CMake 里的构建目标,可以是可执行文件(add_executable),也可以是库(add_library)。现代 CMake 的核心理念,就是把编译选项、头文件路径、宏定义、依赖关系都挂在 target 上,而不是全局乱撒。
add_library(mylib STATIC src/lib.cpp) target_include_directories(mylib PUBLIC include) target_compile_definitions(mylib PRIVATE MYLIB_BUILDING) target_link_libraries(app PRIVATE mylib)这里PUBLIC、PRIVATE、INTERFACE三个关键字比较关键。简单理解:PRIVATE表示只对当前 target 生效;INTERFACE表示只对链接了这个 target 的外部使用方生效;PUBLIC等于两者之和,翻译成人话就是"我自己要用,也透传给链接我的人"。用头文件路径举例:mylib 的公共头文件目录用 PUBLIC 传递,app 链接 mylib 后自然就能找到头文件;而 mylib 内部编译时独有的宏,用 PRIVATE 就够了,不该泄漏给 app。
这种风格的好处是依赖关系显式化。以前旧写法把include_directories写在全局,所有 target 都会继承,一旦项目变大,你根本说不清谁依赖了谁。target 体系则把关系绑定得非常清楚,这也是新 CMake 项目统一推荐的标准写法。
3.3 新旧语法并存时怎么选
网上搜 CMake 教程,难免会看到一些旧式写法,比如:
include_directories(include) add_compile_options(-Wall) add_definitions(-DMY_MACRO)这些命令的确能用,但会把选项加到全局所有 target 上,容易造成"改一处影响一片"。新工程或者持续维护的工程,我更推荐统一使用 target 版本:
add_library(mylib STATIC ...) target_include_directories(mylib PUBLIC include) target_compile_options(mylib PRIVATE -Wall) target_compile_definitions(mylib PRIVATE MY_MACRO=1)不是说旧语法一定不对,而是 target 语法更利于长期维护。如果你接手的项目里已经写了旧语法,迁移的时候不用一步到位,可以在新增代码模块时用新语法,后续再慢慢收拢。总之一句话:能挂到 target 上的,就不要放全局。
3.4 条件、循环与函数
CMake 写多了总会遇到条件编译和多目录组织,语法并不复杂:
if(CMAKE_SYSTEM_NAME STREQUAL "Windows") target_compile_definitions(app PRIVATE WIN32_LEAN_AND_MEAN) elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") target_link_libraries(app PRIVATE pthread) endif()循环常用在批量处理源文件或者一次性配置多个 target:
foreach(module core ui network) add_library(${module} STATIC src/${module}.cpp) endforeach()函数可以把重复逻辑收拢起来:
function(create_module name) add_library(${name} STATIC src/${name}.cpp) target_include_directories(${name} PUBLIC include) endfunction() create_module(core) create_module(ui)注意函数里的name参数,调用时传入的名称会变成函数内的变量${name},这个和 Shell 脚本有点像。还有一个特殊变量ARGN表示额外传入的未命名参数,处理可变长参数时会用到。
4. 高频配置实战,拿来即用的代码段
4.1 输出路径去掉 Debug 子目录
先明确一个问题:为什么 VS 生成器下,默认会把可执行文件放到build/Debug/xxx.exe而不是build/xxx.exe?因为 Visual Studio 是多配置生成器,一个构建目录要同时容纳 Debug、Release、RelWithDebInfo 等多套配置,为了避免产物互相覆盖,CMake 会在输出路径后追加配置名子目录。
很多人不喜欢这个子目录,比如想把产物直接丢到项目根目录的bin/下面。方法是用针对配置的变量,把每种配置的输出目录单独指定:
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${CMAKE_BINARY_DIR}/bin) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE ${CMAKE_BINARY_DIR}/bin)CMAKE_RUNTIME_OUTPUT_DIRECTORY是全局默认,CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG这类带配置后缀的变量可以覆盖对应配置的默认行为。如果静态库和动态库也要控制输出,记得同时设置CMAKE_ARCHIVE_OUTPUT_DIRECTORY_*和CMAKE_LIBRARY_OUTPUT_DIRECTORY_*。只设置不带配置后缀的版本,在 VS 生成器下还是会看到 Debug/Release 子目录,这一点我最早踩过坑,所以特意标出来。更精细的做法是直接用set_target_properties针对单个 target 设置同名属性,适合多 target 工程里只想调整某一个输出的场景。
4.2 生成的 VS 工程怎么保持相对路径
关于"CMake 生成的 VS 工程使用相对路径"这个问题,我在实际工作中遇到的最常见场景是:整个工程目录要提交到版本库,或者换一台机器后希望构建目录能整体挪动。这时候如果生成的工程文件里出现了C:/Users/xxx/...这样的绝对路径,别人一 clone 下来就编译不过。
需要先澄清一点:CMake 生成的 .vcxproj 文件,对源码文件的引用默认就是相对路径(相对于 .vcxproj 所在位置),所以你会发现新工程文件里ClCompile Include="..\src\main.cpp"这种写法是正常的。真正让路径变成"绝对路径"的,通常是两种情况:一是你在 CMakeLists.txt 里硬编码了绝对路径,二是某些外部依赖库在配置阶段通过find_package返回了绝对路径,你把它写进了 target 的属性里。
解决办法也很直接:不要在 CMakeLists.txt 里写死C:/...这样的路径。项目内路径统一用 CMake 提供的目录变量派生,比如:
target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include )这样生成的工程里,引用路径会以相对形式出现。对于外部依赖库,尽量用find_package拿到的导入目标(IMPORTED target)直接链接,而不是自己拼路径。如果确实需要对输出目录做相对化,可以把输出目录设置成以构建目录为基准的相对路径,例如:
set_target_properties(app PROPERTIES RUNTIME_OUTPUT_DIRECTORY "bin" )CMake 对相对路径的输出目录会基于当前二进制目录解析,最终生成的 VS 工程里也会呈现相对路径,整个构建目录移到别的机器上只要相对结构不变,产物位置就不会乱。
4.3 预编译头的正确配置方式
预编译头(PCH)是 C++ 加速编译的利器,尤其是那些包含了一大堆 STL 头文件和第三方头文件的大项目。CMake 从 3.16 开始正式支持target_precompile_headers,写法非常简洁:
target_precompile_headers(app PRIVATE pch.h )pch.h 里正常写要预编译的头文件:
#pragma once #include <iostream> #include <string> #include <vector>CMake 会自动让每个源文件在编译前强制包含 pch.h,省去你手工在源文件里加#include "pch.h"的麻烦。这里要注意几个细节:第一,3.16 以下版本不支持这个命令,如果 CMake 版本不够,会直接报错;第二,PRIVATE表示只给当前 target 用,库类型的 target 如果想让链接方也共享预编译头,需要额外用INTERFACE处理,但要小心 ABI 层面的坑,我一般只对可执行文件用;第三,不同编译器的机制不同,MSVC 和 GCC/Clang 都能支持,但如果你想在同一个 target 里混用 C 和 C++ 文件,C 文件不会被预编译 C++ 头文件影响,这是预期行为,不用慌。
老版本 CMake 没有这条命令时,常见方案是写一个 pch.cmake 脚本去手工设置编译参数,不同编译器逻辑还不一样,维护成本很高。所以我的建议很直接:能用新版 CMake 就尽量用,这个特性省下的时间远比你升版本的代价大。
4.4 在 CMake 里执行 bash 命令
CMake 本身并不排斥执行外部命令,但要看清楚两种时机。第一种是配置阶段执行,用execute_process,典型用途是"CMake 配置时跑一下脚本取版本号":
execute_process( COMMAND bash -c "git rev-parse --short HEAD" OUTPUT_VARIABLE GIT_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE ) message(STATUS "Current commit: ${GIT_COMMIT}")注意,这段代码在每次执行cmake配置时都会跑一次。如果脚本里有耗时操作,会明显拖慢配置速度,所以只适合放轻量任务。
第二种是构建阶段执行,用add_custom_command或add_custom_target,这才是"构建流程里执行 bash 命令"的正确姿势。比如我想在链接完成后,把产物复制到部署目录:
add_custom_command(TARGET app POST_BUILD COMMAND bash -c "cp $<TARGET_FILE:app> ${CMAKE_BINARY_DIR}/deploy/" COMMENT "Copying app to deploy directory" )如果目标是要跑代码生成器、生成文档这类独立任务,用add_custom_target更合适:
add_custom_target(codegen COMMAND bash -c "python3 generator.py" WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/tools )运行方式变成了cmake --build build --target codegen。这里顺便提一句:如果只是复制文件、删除目录这类常规操作,优先用 CMake 自带命令cmake -E copy_directory、cmake -E remove_directory,而不是 bash,这样在 Windows 上不用装 Git Bash 也能跑,跨平台性更好。非要执行脚本时,建议用if(UNIX)包一层,明确告诉读者这段只在 Unix-like 系统生效。
5. 常见报错排查与一条实用速查
5.1 CMake 找不到 CUDA 编译器怎么办
"CMake Error: CMAKE_CUDA_COMPILER not set, after enabling language CUDA" 这个报错在涉及到 CUDA 的项目里非常典型。报错字面意思很直接:你开启了 CUDA 语言支持,但 CMake 找不到可用的 CUDA 编译器(通常就是 nvcc)。
排查步骤按顺序来。首先确认机器上装了 CUDA Toolkit,Linux 下用nvcc --version看输出;如果提示找不到 nvcc,说明没装或者 PATH 里没有,需要先安装 CUDA Toolkit 并把/usr/local/cuda/bin加入 PATH。第二步回到 CMake 配置命令,显式指定编译器路径:
cmake -S . -B build -DCMAKE_CUDA_COMPILER=/usr/local/cuda/bin/nvcc第三步,检查项目里是否真的开启了 CUDA 语言,两种写法都可能触发报错:
project(MyProject LANGUAGES CXX CUDA) # 或者 enable_language(CUDA)另外还有一个容易忽略的点:CMake 对 CUDA 语言的支持从 3.9 开始,早期版本在 CUDA 相关报错上往往很含糊。如果配置环境比较老,先cmake --version确认版本,再决定是否升级。最后提醒一句:Windows 上如果安装了多个 CUDA 版本,建议在 CMake 配置时指定-DCMAKE_CUDA_ARCHITECTURES=72这类参数,避免架构不匹配导致的奇怪报错。
5.2 CMake 的日志级别怎么控制
CMake 从 3.15 开始支持--log-level参数,可以控制 message() 命令输出的最小级别。这个功能在排查"为什么某个变量没有按预期设置"时特别有用。常见的用法:
cmake --log-level=VERBOSE -S . -B build日志级别从低到高是 ERROR、WARNING、NOTICE、STATUS、VERBOSE、DEBUG、TRACE。平时默认是 NOTICE,所以像message(STATUS "...")这样写的信息默认能看到;但如果你用message(VERBOSE "..."),默认就被过滤掉了,必须把日志级别调到 VERBOSE 或更细才显示。这个机制很适合临时调试:在 CMakeLists.txt 里写一堆message(VERBOSE "xxx = ${xxx}"),平时不刷屏,排查问题时加一个--log-level=VERBOSE就能看到全部中间变量。
也可以直接在 CMakeLists.txt 里通过变量设置默认级别:
set(CMAKE_MESSAGE_LOG_LEVEL VERBOSE)不过我建议只在调试时这么做,调试完就删掉,避免污染项目配置。
5.3 高频报错速查
整理几个我在实际使用中遇到频率最高的报错,做成速查表,方便大家搜索对照。
| 报错信息 | 常见原因 | 解决思路 |
|---|---|---|
| The source directory ... does not contain CMakeLists.txt | -S指定的目录不是源码根目录 | 检查路径;确认 CMakeLists.txt 文件名拼写 |
| No CMAKE_CXX_COMPILER could be found | 没装编译器或编译器不在 PATH | 安装 GCC/Clang/MSVC;用-DCMAKE_CXX_COMPILER指定 |
| CMakeCache.txt 里的配置和当前命令冲突 | 换了编译器、改了源码路径,但 build 目录复用 | 删掉整个 build 目录重新配置 |
| 找不到某个 find_package 的包 | 依赖库未安装或未配置路径 | 安装库;用CMAKE_PREFIX_PATH指向库安装目录 |
| 编译时提示"无法打开包含文件" | include 路径没配对 | 检查target_include_directories;确认路径是基于源码目录的相对路径 |
这里重点说一下删 build 目录这个操作,它几乎能解决一半诡异的 CMake 问题,但同时也是双刃剑:删了之后所有缓存变量都丢了,如果项目里有一些手工指定的路径,重配时也要一并补上。所以我会在配置命令稳定之后,用cmake -B build反复调整,而不是每次都不动脑子全删。真到了要删的程度,记得先看一眼 CMakeCache.txt 里有没有你需要保留的特殊配置。
最后说点个人体会。用 CMake 这几年,我最大的感受是:它的门槛主要来自"语法太自由",同一个需求网上能搜到七八种写法,新手根本分不清哪个是推荐实践。比如有人还用着include_directories配合link_directories的老一套,有人已经把项目迁到了target_sources加文件集的新写法。我的建议是,新工程直接盯住现代 CMake 的 target 体系,老工程也不要急着推倒重来,先把cmake_minimum_required尽量往上升,之后再一个个 target 迁移。另外,不管项目多小,都请保持源码目录和构建目录分离,这是 CMake 项目最值得养成的好习惯。最后再分享一个小技巧:在 CMakeLists.txt 里加上set(CMAKE_EXPORT_COMPILE_COMMANDS ON),构建后会在 build 目录生成 compile_commands.json,配合 clangd、vim 或者 VS Code 的 C/C++ 插件使用,跳转和补全会准确很多,算是顺手就能捡到的福利。