
1. 为什么项目里突然绕不开 CMake先说个我最近实际遇到的场景接手一个老 C 项目Windows 上用 Visual Studio 开发Linux 上跑自动化编译macOS 上还有同事在改 UI。原来那套 .sln 加 .vcxproj 的维护方式在 Windows 本地用着还行但一涉及命令行打包、CI 流水线、跨平台依赖立刻乱成一片。后来花了一周把构建系统整体迁到 CMake整个过程踩了不少坑也积累了很多值得记录的经验。CMake 是一个开源的、跨平台的构建系统生成器。它不直接编译代码而是根据 CMakeLists.txt 里的描述生成对应平台的原生构建文件——Windows 上生成 Visual Studio 工程Linux 和 macOS 上生成 Makefile 或 Ninja 文件。这么一层抽象的“中间层”让同一套构建逻辑可以在不同平台、不同编译器下复用。这篇内容适合谁三类人第一类是刚接触 C 构建、想知道 CMake 到底怎么用的新手第二类是已经在用 CMake 但总在路径、输出目录、预编译头这些细节上反复折腾的开发者第三类是想把老项目迁移到 CMake、需要一套可落地方案的工程师。我尽量把每个操作背后的“为什么”也讲清楚不然光抄命令换个场景还是不会灵活用。2. 下载安装与版本选择别小看这一步2.1 三大平台的安装方式CMake 的安装在不同系统上差异不小我用实际命令走一遍。Windows 上最省事的方式是去官网下载安装包选择Windows x64 Installer装的时候务必勾选“Add CMake to the system PATH for all users”。这一步很多人漏掉装完在命令行里敲cmake --version提示找不到命令又回来折腾环境变量纯属浪费时间。如果公司内网不方便访问官网也可以用包管理器比如用 winget 执行winget install Kitware.CMake这个命令会自动装最新稳定版并配置好 PATH比手动下载清爽很多。Linux 上根据发行版选择包管理器。Ubuntu 和 Debian 系sudo apt update sudo apt install cmake但这里有个坑Ubuntu 官方源里的 CMake 版本往往偏老。比如 Ubuntu 20.04 默认源里是 3.16而很多新特性比如后面要讲的target_precompile_headers在 3.16 里虽然能用但稳定性一般3.20 以上才完善。如果项目依赖新特性建议直接从官网下载预编译的二进制包或者用 pip 方式安装pip install cmakepip 装的 CMake 版本很新并且会自动进入 Python 环境的 bin 目录适合在虚拟环境里管理。macOS 上最简单brew install cmake安装完成后在任何目录执行cmake --version能看到类似cmake version 3.28.1的输出就算成功。这里强调一下版本号并不是越大越好但也不能太老。实际项目里我遇到过 CMake 3.10 无法解析某些新语法而 3.20 以上则游刃有余的情况。强烈建议新项目至少使用 3.16 以上最好 3.20 以上。2.2 版本选择与升级避坑CMake 版本策略上有个“最小心版本”习惯在 CMakeLists.txt 开头写cmake_minimum_required(VERSION 3.20)如果本机 CMake 版本低于它会直接报错并停止避免运行到一半发现某些命令不支持。升级 CMake 时注意一点CMake 生成的缓存文件CMakeCache.txt与版本有一定耦合性。跨大版本升级后最稳妥的做法是删掉整个 build 目录重新配置。我吃过一次亏从 3.16 升到 3.24 后没删缓存结果编译时用了旧的生成器路径怎么都对不上浪费了一个下午。后来养成习惯升级 CMake 或更换编译器后必删 build 目录。3. 生成 VS 工程与相对路径的写法3.1 最小 CMakeLists.txt 与生成器指定先用一个最典型的结构讲解假设项目目录如下project_root/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── utils.cpp │ └── utils.h └── build/根目录的 CMakeLists.txt 至少需要这些内容cmake_minimum_required(VERSION 3.20) project(MyProject LANGUAGES CXX) add_executable(my_app src/main.cpp src/utils.cpp ) target_include_directories(my_app PRIVATE src)然后执行配置命令。在项目根目录下cmake -G Visual Studio 17 2022 -A x64 -S . -B build这段话解释一下-S .指定源码根目录为当前目录-B build指定构建目录-G指定生成器-A指定平台架构。执行后CMake 会在 build 目录下生成MyProject.sln和一堆.vcxproj文件直接用 Visual Studio 打开这个 sln 就能编译。有人想生成其他版本 VS 工程需要先查一下本机装了哪些生成器。命令行执行cmake --help输出的列表里会有Visual Studio 17 2022、Visual Studio 16 2019、Unix Makefiles、Ninja等根据实际安装选择即可。3.2 工程里使用相对路径的两种关键写法“cmake 生成的 VS 工程使用相对路径的写法”是很多人搜索的高频问题。这里有一个核心认知CMake 生成的.vcxproj内部绝大多数字符串已经尽量用相对路径表达但默认策略并不总是符合预期。真正需要手动干预的是两类场景。第一类外部依赖的路径。比如把第三方库放在项目根目录之外的某个公共位置这时 CMakeLists.txt 里如果写成target_include_directories(my_app PRIVATE C:/dev/libs/opencv/include)生成的 VS 工程里会把这段绝对路径写死换一台机器或者目录结构改变VS 打开工程就会报找不到头文件。正确做法是把外部路径定义为变量相对源码根目录拼接set(THIRD_PARTY_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../libs) target_include_directories(my_app PRIVATE ${THIRD_PARTY_DIR}/opencv/include)此时生成的工程文件里会以..\..\libs\opencv\include的形式相对引用整个工程拷到任意目录只要相对结构不变就能直接编译。第二类调试工作目录。默认生成的 VS 工程调试时的“工作目录”是$(ProjectDir)也就是工程文件所在目录。如果你希望运行程序时的工作目录在项目根目录或某个资源目录在 CMakeLists.txt 里可以设置set_target_properties(my_app PROPERTIES VS_DEBUGGER_WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} )这里用${CMAKE_CURRENT_SOURCE_DIR}而不是硬编码路径就是为了保证工程即使被复制到别处调试工作目录也会跟着源码位置走。另外还有一个很隐蔽但很实用的点CMake 在生成 VS 工程时默认会按“相对路径”引用项目内的源文件。但如果你通过file(GLOB ...)方式收集源文件CMake 有时会因为缓存变量里的路径格式问题在 vcxproj 中出现混合风格的路径。解决方式也很简单统一用cmake_path或file(REAL_PATH ...)做一次规范化file(GLOB_RECURSE MY_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp)CONFIGURE_DEPENDS这个参数值得加上它能让 CMake 在重新构建时自动检测新增源文件不需要手动重新执行 cmake 命令。3.3 实际体会相对路径问题说起来不难但踩坑的人特别多。核心原因在于很多人觉得“只要我本地能编过就行”忽略了工程文件会流动到其他人手里。我现在的习惯是CMakeLists.txt 里禁止出现任何硬编码绝对路径所有除系统路径之外的地址一律通过源码根目录的变量拼接。这条原则坚持下来项目交付后别人拉下来配一下就编省了很多沟通成本。4. 输出路径去掉 Debug 的配置思路4.1 为什么 VS 默认输出目录带 Debug用 Visual Studio 生成器时CMake 默认的输出目录策略与 VS 原生工程一致即区分配置类型Debug 版本输出到build/DebugRelease 输出到build/Release。这个行为对开发调试是友好的因为不同配置的产物互不干扰。但问题也随之而来。有些场景比如需要统一打包脚本希望所有可执行文件都输出到同一个bin目录不再按 Debug 或 Release 分文件夹。还有的场景项目里集成了外部后处理工具它硬编码了输出路径必须让可执行程序放到指定位置。这时候就需要修改输出路径。4.2 三条关键变量的设置CMake 提供了三个核心变量分别控制可执行文件、静态库/导入库、动态库的输出目录set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)解释一下各自管什么CMAKE_RUNTIME_OUTPUT_DIRECTORY可执行文件.exe、.dll的输出目录。Windows 上动态库的 .dll 也归它管。CMAKE_LIBRARY_OUTPUT_DIRECTORY非 Windows 平台上的动态库.so、.dylib输出目录。CMAKE_ARCHIVE_OUTPUT_DIRECTORY静态库.lib、.a及 Windows 上 DLL 配套的导入库.lib的输出目录。设置之后Debug 和 Release 的产物都会直接输出到build/bin和build/lib不会再自动添加配置子目录。如果你希望连中间文件.obj也不放 Debug/Release 子目录那通常不建议这么做因为多配置生成器在并行编译时容易发生目标文件冲突。我一般只改最终产物目录中间文件目录保持默认分配置结构安全第一。4.3 按配置区分但又统一前缀的写法有些项目需要不同配置仍然是不同目录但目录名不要叫 Debug 和 Release比如叫 dev 和 prod。这时可以用生成器表达式set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin/$CONFIG)$CONFIG是 CMake 的生成器表达式在编译时会展开为实际配置名的小写形式。这可以让你既保持分目录结构又能自定义目录命名。同理如果你想统一到bin/dev、bin/prod可以先判断配置再映射set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin/$IF:$CONFIG:Debug,dev,prod)这种写法在 CI 流水线里比较常用因为打包脚本可以只关注bin/dev这种固定路径不用管当前是哪个配置。4.4 一个完整的示例把前面讲的串起来给一个实际的 CMakeLists.txt 片段cmake_minimum_required(VERSION 3.20) project(MyApp LANGUAGES CXX) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) add_executable(my_app src/main.cpp) add_library(my_core STATIC src/core.cpp) target_link_libraries(my_app PRIVATE my_core)构建完成后my_app.exe一定在build/bin/my_app.exemy_core.lib在build/lib/my_core.lib。我在多个项目里验证过这个写法Windows、Linux、macOS 三个平台行为一致。5. 预编译头PCH的正确打开方式5.1 为什么需要预编译头C 项目一旦上了规模头文件的反复解析是一种巨大的性能损耗。一个 main.cpp 可能间接包含了数万行头文件每次编译都要重新解析一遍耗时蹭蹭上涨。预编译头Precompiled Header的思路是把一组不会变动的公共头文件比如标准库、第三方库的头文件预先编译成中间格式之后每个源文件编译时直接复用不用再逐个头文件解析一遍。Visual Studio 里的默认实践是stdafx.h或pch.h加pch.cpp的组合。CMake 3.16 之后正式引入了跨 IDE 的target_precompile_headers命令我们不再需要手动创建 pch.cpp 并配置编译选项CMake 会自动处理。5.2 基础用法与实际效果假设项目里有一个pch.h文件内容如下#pragma once #include string #include vector #include memory #include iostream在 CMakeLists.txt 中启用预编译头add_executable(my_app src/main.cpp src/utils.cpp) target_precompile_headers(my_app PRIVATE [[pch.h]] string vector )这里有个语法细节[[pch.h]]表示项目内自定义头文件string和vector表示系统头文件。CMake 会根据编译器自动处理这两者的引入方式MSVC 用/FIGCC 和 Clang 用-include。实际编译提速效果非常可观。我之前一个项目公共头文件很多没开 PCH 前全量编译需要 7 分钟开启后直接降到 2 分 40 秒左右增量编译更是从 2 分钟降到 30 秒。这个优化在模块多的项目里尤其明显。5.3 跨 target 共享预编译头如果项目里有多个可执行文件或动态库它们如果包含相同的公共头文件每个 target 各自维护一份 PCH 是浪费的。CMake 允许通过target_precompile_headers的 INTERFACE 传播机制实现共享。做法是创建一个接口库add_library(common_pch INTERFACE) target_precompile_headers(common_pch INTERFACE [[pch.h]] string vector ) target_link_libraries(my_app PRIVATE common_pch) target_link_libraries(my_tool PRIVATE common_pch)任何链接了common_pch的目标都会自动应用相同的预编译头配置。注意这里 INTERFACE 库本身不参与编译只是一个配置传播点。这个写法避免了在每个 target 里重复写一大串头文件列表后续想往公共 PCH 加一个头文件只需改一处。5.4 常见坑与注意事项预编译头虽然省时但用不好会带来隐蔽的编译问题。最大的坑是“头文件变更后编译结果不更新”。有人改了pch.h里的某个头文件重新编译时发现部分源文件没重新编译链接后行为异常。原因是 CMake 依赖头文件依赖扫描逻辑而target_precompile_headers里用[[pch.h]]定义的项目头文件如果它又被其他源文件以不同形式 include可能不会触发关联重建。解决方法是保证 pch.h 没有被条件 include 或宏改变内容同时养成改 PCH 后全量重建的习惯。第二个坑是 MSVC 与 GCC 对 PCH 的支持方式不同。MSVC 的/Yu和/Yc组合要求所有源文件都统一包含同一个 PCH 文件如果有某个源文件写的是#include string而不是#include pch.h在 MSVC 上会报 C1010 错误。解决方法是设置编译选项强制所有文件先包含 PCHif(MSVC) target_compile_options(my_app PRIVATE /FIpch.h) endif()第三个坑CMake 的target_precompile_headers在 GCC 上依赖-include机制但如果源文件中有宏定义影响了头文件内容PCH 可能会编译生成两个不同版本的中间文件导致 “PCH 使用冲突”。遇到这种问题最直接的排查方法是临时关闭 PCH确认代码本身没有宏污染再逐步开启。6. 在 CMake 中执行 bash 命令的正确姿势6.1 execute_process 与 add_custom_command 的区别很多人想在 CMake 构建过程中执行外部命令比如调用脚本生成版本信息或是在编译前拉取一些资源。CMake 提供了两个主要入口但它们执行的时机完全不同用错场景会导致命令不执行或执行时机不对。execute_process在“配置阶段”执行也就是说运行cmake -B build的那一刻它就会运行之后编译过程再也不会触发。适合做代码生成、环境探测、版本号获取这类只需要执行一次的工作。add_custom_command则在“构建阶段”执行也就是每次执行cmake --build build时根据设定的依赖条件决定是否运行。适合做编译前、编译后需要反复触发的工作。举一个实际的场景我需要在编译前执行一个 bash 脚本生成version.h文件内容根据 git 提交号动态变化。这个需求必须用add_custom_command因为每次代码提交后需要重新生成。6.2 在 Windows 上找到 bash“cmake 执行 bash 命令”这个热搜词背后反映的是很多人在 Windows 环境下用 CMake 调 bash 脚本时找不到 bash 的尴尬。Windows 本身没有原生的 bash除非装了 Git Bash、MSYS2 或 WSL。我在项目里的处理方式是先探测 bash 路径再传给 CMakefind_program(BASH_EXECUTABLE bash PATHS C:/Program Files/Git/bin C:/msys64/usr/bin) if(NOT BASH_EXECUTABLE) message(FATAL_ERROR bash not found) endif()find_program会在 PATH 和列出的额外路径中查找 bash.exe找到后赋值给BASH_EXECUTABLE变量。如果用户装的是 Git Bash那么路径通常是C:/Program Files/Git/bin/bash.exeCMake 用这个路径执行命令时要注意脚本内部如果包含 Linux 路径格式bash 自己会处理但 Windows 风格路径在脚本里要注意转义。6.3 完整示例编译前生成版本头文件项目结构project_root/ ├── CMakeLists.txt ├── scripts/ │ └── gen_version.sh └── src/ └── main.cppscripts/gen_version.sh内容#!/bin/bash VERSION_STR$(git describe --tags --always 2/dev/null || echo unknown) echo #define APP_VERSION \${VERSION_STR}\ $1CMakeLists.txt 中find_program(BASH_EXECUTABLE bash PATHS C:/Program Files/Git/bin) set(VERSION_HEADER ${CMAKE_CURRENT_BINARY_DIR}/generated/version.h) add_custom_command( OUTPUT ${VERSION_HEADER} COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen_version.sh ${VERSION_HEADER} DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen_version.sh COMMENT Generating version.h ) add_executable(my_app src/main.cpp ${VERSION_HEADER}) target_include_directories(my_app PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated)这段 CMake 的核心逻辑add_custom_command声明了version.h由脚本生成OUTPUT指定生成文件COMMAND指定执行命令DEPENDS说明只要脚本有改动就要重新生成。把它加到add_executable的源文件里CMake 会自动建立依赖关系编译前先运行脚本。这个模式有很强的扩展性它可以用来生成协议代码、拉取子模块、打包资源等。我自己在多个项目里都用这个模式基本能解决 90% 的“构建前要跑脚本”需求。6.4 配置阶段执行命令的场景如果你确实需要在执行cmake -B build时就探测环境并输出结果这才用execute_process。常见的用例是获取系统信息execute_process( COMMAND ${BASH_EXECUTABLE} -c echo hello OUTPUT_VARIABLE BASH_OUTPUT OUTPUT_STRIP_TRAILING_WHITESPACE ) message(STATUS bash output: ${BASH_OUTPUT})注意execute_process的COMMAND不会经过 shell 解析如果命令里要写管道、重定向这些必须显式通过 bash 的-c参数来执行。这一点很关键很多人在 Windows 上写execute_process(COMMAND echo hello | grep h)然后莫名其妙得到错误结果就是因为 CMake 不会把|当成 shell 管道。7. 常见问题与排查技巧实录7.1 典型问题速查表我整理了实际项目里高频出现的 CMake 问题按症状-原因-解决方式的格式列出方便大家直接对号入座。问题现象根本原因解决方案cmake --version提示找不到命令安装时未添加 PATH重新安装并勾选“Add to PATH”或手动追加系统环境变量生成的 VS 工程引用绝对路径换目录失效CMakeLists 中硬编码了路径用${CMAKE_CURRENT_SOURCE_DIR}等变量拼接相对路径可执行文件总输出到 Debug/Release 子目录未设置输出目录变量设置CMAKE_RUNTIME_OUTPUT_DIRECTORY、CMAKE_LIBRARY_OUTPUT_DIRECTORY、CMAKE_ARCHIVE_OUTPUT_DIRECTORYPCH 开启后部分文件报 C1010MSVC 要求所有源文件都包含 PCH加编译选项/FIpch.h强制包含execute_process中的管道命令无效CMake 不解析 shell 语法用 bash 的-c参数传递完整命令修改 CMakeLists.txt 后重新构建部分配置不生效缓存未刷新删除 build 目录重新配置或删除 CMakeCache.txt 后重跑 cmake新增源文件后没有参与编译file(GLOB)未加CONFIGURE_DEPENDS在file(GLOB)加CONFIGURE_DEPENDS参数或改为显式列出源文件7.2 排查思路与个人避坑经验遇到 CMake 相关问题时我建议按三层思路排查。第一层看配置日志。执行cmake -B build时输出的 message 信息是最直接线索如果配置阶段就报错CMake 会非常明确地指出哪一行 CMakeLists.txt 出了问题。养成在关键分支加message(STATUS ...)的习惯能大大缩短排查时间。第二层看生成文件。如果配置成功但编译出问题去 build 目录下打开.vcxproj或 Makefile搜索关键路径确认 CMake 最终生成了什么。很多时候问题在于变量展开和转义生成文件里一看便知。第三层用--trace追踪。CMake 3.25 后支持--trace参数cmake -B build --trace它会打印所有 CMakeLists.txt 中的执行细节和变量值。我排查过一个诡异问题某个变量在 A 处设置了值到 B 处却变成空字符串用--trace一眼就看到是作用域的问题——变量在子目录的 CMakeLists.txt 里被set(... CACHE)覆盖了。这种问题靠肉眼看是看不出来的。7.3 关于路径中有空格的大坑Windows 上项目路径如果包含空格比如C:/My Projects/AppCMake 生成 VS 工程一般没问题但add_custom_command里直接写带空格路径就会出问题。因为 CMake 会把COMMAND里的参数按空格拆分成多个参数传给外部程序后路径被截断。解决办法是给路径加引号但 CMake 里的引号处理也有讲究。最稳的方式是用变量把路径包起来再通过COMMAND_EXPAND_LISTS或直接合并成一个参数set(SCRIPT_PATH ${CMAKE_CURRENT_SOURCE_DIR}/scripts/my script.sh) add_custom_command( OUTPUT ${VERSION_HEADER} COMMAND ${BASH_EXECUTABLE} ${SCRIPT_PATH} ${VERSION_HEADER} ... )这里${SCRIPT_PATH}两边的双引号是关键它能让 CMake 把整个路径当作一个参数传给 bash。如果是 Linux 和 macOS路径分隔符是/问题不明显但 Windows 上空格太常见这个习惯建议从一开始就养成。7.4 从 VS 原生工程迁移到 CMake 时的兼容性最后讲一个迁移场景里的典型问题。从.vcxproj迁移到 CMake 后原来工程的一些全局宏定义、预处理器设置如果没同步到 CMakeLists编译时会报一堆莫名其妙的错误。常见的缺失项包括add_definitions(-D_WIN32_WINNT0x0601) add_definitions(-D_CRT_SECURE_NO_WARNINGS) add_compile_options(/EHsc) add_compile_options(/utf-8)_WIN32_WINNT控制 Windows SDK 版本不设可能会让某些系统 API 不可用_CRT_SECURE_NO_WARNINGS关掉 MSVC 对strcpy这些函数的弃用警告/EHsc启用 C 异常处理/utf-8解决源码文件编码问题这个在中文项目里尤其重要不设的话源文件里的中文字符串可能乱码。迁移后建议全量编译一次逐个补齐这些定义。注意_WIN32_WINNT的值要按目标系统的版本设置乱设也会出问题。8. 写在最后把 CMake 当“唯一入口”来维护我在多个项目里实践下来的体会是CMake 最核心的价值不是“生成一个能编译的工程”而是让它成为整个项目构建行为的唯一事实来源。所有源文件、编译选项、依赖关系、生成步骤都集中写在 CMakeLists.txt 里平台相关的差异通过条件判断和生成器表达式处理。这样无论是本地开发、CI 流水线还是同事之间的协作看到的是同一套逻辑不会出现“我这能编你那儿编不过”的扯皮。最后分享一个小技巧在项目根目录放一个CMakePresets.json把常用的生成器、构建类型、缓存变量都固化下来团队成员可以共用同一套配置而不需要在命令行里敲一长串参数{ version: 3, configurePresets: [ { name: default, generator: Visual Studio 17 2022, architecture: x64, binaryDir: ${sourceDir}/build } ] }配置好后成员只需要执行cmake --preset default然后cmake --build build就可以完成构建。这个玩法能省下大量解释“怎么配”的口舌也减少因为个人配置差异导致的低级错误。至于更多进阶用法比如用 CPack 打包、用 CTest 跑测试、用 CMake 的调试器等等后续可以再单独写一篇展开先把上面这些基本功夯实项目构建就已经能打扫得很干净了。