
做 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 都内置或深度支持 CMakeVisual Studio 2019 之后甚至可以直接打开 CMakeLists.txt 当工程用CLion 更是默认就把 CMake 当作一等公民。第三CMake 的语法虽然经常被吐槽但经过 3.0 以来的几次大版本迭代现代 CMake 的写法已经比早期规范、友好很多。这里想特别说明一下不要因为网上有人吐槽CMake 语法难用就抵触它。早期版本留下了不少反人类的写法但如果我们只使用现代推荐的 target 体系把每个目标可执行程序、静态库、动态库的依赖关系理清楚整个 CMakeLists.txt 是可以写得非常清爽的。后面第三部分我会专门讲 target 体系。2. 安装、升级与第一个 Hello CMake2.1 各平台安装和升级方法先讲安装因为很多新手第一个拦路虎不是语法而是CMake 装不上或者版本太老。Windows 上最直接的方式是去官网下 .msi 安装包安装时记得勾选把 CMake 加入系统 PATH否则命令行里敲cmake会提示找不到命令。如果你用包管理器也可以这样choco install cmake --installargs ADD_CMAKE_TO_PATHSystemLinux 上用包管理器安装最省事sudo apt install cmake但这里要留个心眼Ubuntu 这种长期支持发行版自带的 CMake 版本一般偏旧。比如 Ubuntu 20.04 自带的还是 3.16 系列而有些新特性比如预编译头文件见后面的 4.3从 3.16 才开始支持更晚一点的功能就没有了。想用新版的话可以装 Kitware 官方 APT 源也可以直接用 pippip 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 里的变量本质上都是字符串定义用setset(MY_VAR hello) message(STATUS MY_VAR ${MY_VAR})变量有作用域的概念。函数内部set出来的变量默认只在函数内可见想传给上一层要用PARENT_SCOPEset(MY_VAR hello PARENT_SCOPE)。还有一个容易混淆的概念叫缓存变量它会被写入 CMakeCache.txt跨多次配置保持存在。用set加CACHE关键字定义set(BUILD_SHARED_LIBS ON CACHE BOOL Build shared libraries)缓存变量最常用的场景是和命令行-D参数配合。比如cmake -DBUILD_SHARED_LIBSOFF ...就能在配置阶段覆盖缓存值。这也是很多库开开关的实现原理。你在 CMakeLists.txt 里写option(BUILD_EXAMPLES ON ...)本质上也生成一个 BOOL 类型的缓存变量用户构建时可以通过-DBUILD_EXAMPLESOFF来关闭例子。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_MACRO1)不是说旧语法一定不对而是 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()循环常用在批量处理源文件或者一次性配置多个 targetforeach(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 vectorCMake 会自动让每个源文件在编译前强制包含 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 ToolkitLinux 下用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_ARCHITECTURES72这类参数避免架构不匹配导致的奇怪报错。5.2 CMake 的日志级别怎么控制CMake 从 3.15 开始支持--log-level参数可以控制 message() 命令输出的最小级别。这个功能在排查为什么某个变量没有按预期设置时特别有用。常见的用法cmake --log-levelVERBOSE -S . -B build日志级别从低到高是 ERROR、WARNING、NOTICE、STATUS、VERBOSE、DEBUG、TRACE。平时默认是 NOTICE所以像message(STATUS ...)这样写的信息默认能看到但如果你用message(VERBOSE ...)默认就被过滤掉了必须把日志级别调到 VERBOSE 或更细才显示。这个机制很适合临时调试在 CMakeLists.txt 里写一堆message(VERBOSE xxx ${xxx})平时不刷屏排查问题时加一个--log-levelVERBOSE就能看到全部中间变量。也可以直接在 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 插件使用跳转和补全会准确很多算是顺手就能捡到的福利。