
写 CMake 这么多年我最大的感受是这玩意儿不是难是“散”。今天你搜一下怎么加头文件目录明天查一下怎么链接库后天再翻一下怎么设置 C 标准每次都能用但每次都是“照着抄”一旦出问题就抓瞎。直到我把 CMake 的底层逻辑拆成三条主线——目标Target、属性Property和API命令/函数之后之前所有零散的知识点一下就串起来了。说白了CMake 就是一个“构建对象系统”目标是被构建的对象属性是挂在对象上的配置数据API 是操作这些对象的函数。这篇文章我就用这套思路把 CMake 的三大核心彻底讲透并把平时用到最多的重点函数一个一个拆开讲。这篇文章适合谁写过几行 CMake 但总觉得是“玄学”或者想从 Makefile 转过来又或者想在 VS、CLion、VSCode 里把 CMake 项目玩明白的人。看完之后你至少不会再怕 CMakeLists.txt 了——它是可以“写”出来的不是“试”出来的。1. 一切从目标开始把 CMake 当成对象系统来看1.1 什么是“目标”为什么它是第一核心在 CMake 的世界里目标Target就是指最终要产出的东西一个可执行文件、一个静态库、一个动态库甚至是一个“纯粹的接口”——这个东西不产出文件但能给下游目标传递配置信息。为什么要先理解目标因为现代 CMake所谓 Modern CMake的所有最佳实践都是围绕目标来组织的。传统写法是到处设置全局变量比如include_directories()、add_definitions()这些都是“全局命令”一旦项目变大到处可见、相互覆盖根本分不清是给谁用的。而目标化之后所有配置都挂在具体目标上构建和链接时 CMake 会自动处理依赖关系不会互相污染。你可以在心里把 CMake 类比成一套面向对象系统目标 对象实例app、core这些名字就是对象名属性Property 对象的成员变量如OUTPUT_NAME、CXX_STANDARDAPI命令/函数 操作对象的成员函数target_link_libraries就是“给这个对象设定链接关系”的方法。一旦把 CMake 理解成“对象系统”很多东西就不需要死记了。你会自然追问我要创建哪些对象每个对象的属性是什么对象之间的关系怎么建立这恰恰就是写一个 CMakeLists.txt 的全部工作。1.2 创建目标的四种方式CMake 里创建目标的命令不多但每个都有明确使用场景# 1. 可执行文件 add_executable(app main.cpp) # 2. 库STATIC 静态库 / SHARED 动态库 / OBJECT 对象库 add_library(core STATIC core.cpp core_algo.cpp) # 3. 接口库不产出文件只传递配置 add_library(core_iface INTERFACE) # 4. 自定义目标不编译代码只执行命令 add_custom_target(generate_something COMMAND ${CMAKE_COMMAND} -E echo hello)这里重点说几个容易踩坑的点静态库还是动态库早期我会习惯性选SHARED觉得动态库省空间、更新方便。但如果你只是内部拆模块静态库构建更简单也避免了一大堆“动态库找不到符号”“运行时 dll 路径不对”的坑。真要做插件系统或者多个可执行文件共享同一份代码再上SHARED不迟。接口库是非常好用的工具。比如你有一个纯头文件库不产.a也不产.so但希望下游目标自动带上头文件路径和编译宏那add_library(foobar INTERFACE)就是正解。很多find_package找到的第三方库本质就是导入目标IMPORTED你只需要target_link_libraries就能拿到全部配置。别名目标ALIAS也值得记住add_library(core::core ALIAS core)这样你就可以用core::core这种带命名空间的写法来引用目标跟第三方库如OpenCV::opencv_world风格统一。这个写法的好处是目标名一看就知道属于哪个模块不容易冲突。需要留意的是别名目标不能安装、也不能导出它只是构建系统里的一个“引用别名”。1.3 目标之间的依赖关系PRIVATE / PUBLIC / INTERFACE 怎么选目标建好之后最重要的事情就是建立依赖关系。CMake 提供了一系列target_*命令其中最常用的是这三个target_include_directories(core PUBLIC include) target_compile_definitions(core PRIVATE CORE_BUILD) target_link_libraries(app PRIVATE core)这里最关键的语义是可见性PRIVATE只对当前目标自己生效传递不到下游PUBLIC对当前目标和所有下游目标都生效INTERFACE只对下游目标生效当前目标自己用不到。你可以这样理解假设core是库app是依赖它的可执行文件。如果core的公共头文件里需要include/目录那core自己编译时需要app在 include 头文件时也需要——所以用PUBLIC如果core的.cpp文件里用了某个宏但公共头文件没有引用那只需要PRIVATE如果某个头文件目录只有app需要core自己都不碰比如外部接入层那可以用INTERFACE。这个选择会影响很多问题。我见过大量项目“头文件找不到”“链接符号冲突”根源都是这个可见性选错了。比如只写了target_include_directories(core include) # 没写可见性默认走目录全局设置不是的这种情况会走旧版本的目录级 include 传递逻辑CMake 会报 warning新版直接建议改成显式可见性。所以从一开始就养成习惯每一个 target_xxx 命令都显式写 PUBLIC / PRIVATE / INTERFACE不要省略。2. 属性目标的“参数表”和“状态位”2.1 属性体系和变量有什么区别CMake 里的属性Property是附着在某个实体上的数据变量Variable是 CMake 解释器里的普通存储。很多人刚开始分不清这两个其实抓住一点就行变量是“脚本里的变量”属性是“某个对象身上的元数据”。比如你想限制项目用 C17可以写set(CMAKE_CXX_STANDARD 17) # 变量全局默认值 set_target_properties(core PROPERTIES CXX_STANDARD 17) # 属性只对 core 生效区别在于CMAKE_CXX_STANDARD是 CMake 提供给所有目标的默认值但它是“全局的”。如果项目里有多个 target有的要 C17有的要 C11就必须用属性来精确控制。而且属性的优先级更高目标属性会覆盖变量默认值。CMake 的属性体系遍布各个层级属性类型典型属性/场景使用场景目录属性INCLUDE_DIRECTORIES、COMPILE_OPTIONS对整个目录统一设置编译选项目标属性OUTPUT_NAME、CXX_STANDARD、POSITION_INDEPENDENT_CODE对某个可执行文件/库设置源文件属性COMPILE_FLAGS、HEADER_FILE_ONLY对单个 .cpp/.c 文件设置测试属性TIMEOUT、ENVIRONMENT配合 add_test 使用全局属性DEBUG_CONFIGURATIONS全局生效2.2 读写属性的两个核心命令读属性用get_property和get_target_property写属性用set_property和set_target_properties# 设置多个属性 set_target_properties(app PROPERTIES OUTPUT_NAME myapp CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON POSITION_INDEPENDENT_CODE ON ) # 读取属性 get_target_property(out_name app OUTPUT_NAME) message(STATUS app 的 OUTPUT_NAME 是: ${out_name})还有一个比较通用的写法set_property(TARGET app PROPERTY OUTPUT_NAME myapp) get_property(out_name TARGET app PROPERTY OUTPUT_NAME)在 CMake 里set_target_properties更像“批量设置语法糖”set_property更通用。平时用set_target_properties就够了但如果你在写函数而且要动态处理“某个目标是否存在、属性是否存在”这类逻辑get_property配合if(DEFINED ...)更合适。2.3 我常用的几个目标属性和优先级经验用久了你会发现真正高频的目标属性也就那么几个OUTPUT_NAME改最终产物文件名。比如add_executable(app main.cpp)之后我希望可执行文件叫myapp.exe而不是app.exe就靠它CXX_STANDARD/CXX_STANDARD_REQUIRED/CXX_EXTENSIONS控制 C 标准。CXX_STANDARD_REQUIRED ON表示编译器必须支持不能用扩展将就CXX_EXTENSIONS OFF表示不使用-stdgnu17这类带的扩展POSITION_INDEPENDENT_CODE生成位置无关代码做动态库时通常需要置ONVERSION/SOVERSION动态库版本号Linux 上会生成libxxx.so.1.2.3这类文件DEBUG_POSTFIXDebug 配置下给产物加个后缀比如mylib_d.dll避免和 Release 混在一起EXCLUDE_FROM_ALL如果设为 ON这个目标不会在默认cmake --build时构建必须显式指定。属性优先级顺序我总结下来是命令行-D缓存变量 目标属性 目录属性 全局变量。也就是说你可以通过cmake -DCMAKE_BUILD_TYPERelease从外部注入也可以在 CMakeLists 里用set_target_properties针对性覆盖。但注意如果同一个属性在set_target_properties里和set(CMAKE_... )里都出现目标属性会赢。所以如果你发现“我明明定义了 CMAKE_CXX_STANDARD 怎么没生效”先看看是不是某个 target 上挂了不同的属性。3. API 解析那些真正高频使用的 CMake 命令CMake 的“API”不是一个像 RESTful API 那种网络接口而是它暴露给开发者的命令语言。这一节我把日常工程里出现频率最高的命令按功能分组逐个拆解并附上注意事项。3.1 项目骨架cmake_minimum_required 与 project任何 CMakeLists.txt 的前两行几乎都是固定的cmake_minimum_required(VERSION 3.16...3.28) project(myapp VERSION 1.2.3 LANGUAGES C CXX)cmake_minimum_required现在推荐写成“版本范围”格式比如3.16...3.28表示最低要求 3.16测试过的最新版本上限 3.28。这个写法可以避免旧版 CMake 用新 policy也能在 3.28 以下版本尽量使用新行为。project()不只是给项目起个名字。它会帮我们自动定义一堆非常有用的变量比如PROJECT_NAME项目名PROJECT_SOURCE_DIR源码根目录PROJECT_BINARY_DIRbuild 目录PROJECT_VERSION版本号如果 VERSION 参数写了。LANGUAGES C CXX可以显式声明项目用到哪些语言避免 CMake 去自动探测用不到的 Fortran、ASM 等。3.2 目标构建add_library、add_executable、target_xxx 系列这是 CMake 里最核心的“操作函数”。常见的写法上面已经写过了这里重点讲几个细节target_compile_features比set_target_properties(... CXX_STANDARD ...)更有表达力target_compile_features(core PUBLIC cxx_std_17)这行命令更“声明式”它告诉下游目标“core 需要 C17 编译特性如果你依赖 core最好也按 C17 来编译”。假如某个依赖者只支持到 C11CMake 在配置阶段就能检测出来报错而不是等编译时报一堆看不懂的模板错误。所以我的习惯是新项目尽量用target_compile_features来控制语言标准而不是直接写死CXX_STANDARD。另外target_sources()值得单独提。早期 CMake 里源码列表是写死在add_executable里的但如果你有多个模块用target_sources往目标上动态追加源文件更方便尤其在配合条件编译时add_executable(app main.cpp) if(WIN32) target_sources(app PRIVATE windows_utils.cpp) else() target_sources(app PRIVATE posix_utils.cpp) endif()3.3 文件操作file 与 configure_filefile是一个能力极强的大命令平时最常见的用法是递归收集源码file(GLOB_RECURSE CORE_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp )这里一定要用CONFIGURE_DEPENDS选项CMake 3.12。不加的话GLOB 结果只在 CMake 配置阶段生成之后你新增一个.cpp文件再执行cmake --build时不会自动包含新文件——很多新手遇到“源码加进去了但没编译”就是这个原因。CONFIGURE_DEPENDS会让 CMake 在构建时检查源码目录有没有变化基本解决了增量新增文件的问题。不过我现在的项目如果不是特别复杂还是更倾向显式列出源文件。GLOB 虽然省事但会让构建系统对“源文件集合”的感知变得隐式。尤其是团队协作时别人新增一个文件后如果忘记重新配置很容易出现“本地能编译、别人那报链接错误”。显式列表虽然写起来啰嗦但胜在确定性强。configure_file则是用来生成配置头文件的好工具configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/config.h.in ${CMAKE_CURRENT_BINARY_DIR}/config.h )比如在config.h.in里写#define PROJECT_VERSION PROJECT_VERSIONCMake 会自动把PROJECT_VERSION替换成1.2.3。常见的一个坑是生成的头文件默认输出到 build 目录而你的源码可能直接#include config.h结果找不到。所以要么把 build 目录加到 include path要么把输出路径直接指定到源码目录不太推荐会污染源码树。3.4 查找外部依赖find_package 的两种模式find_package是 CMake 里最让人迷惑的命令之一因为它有 Module 模式和 Config 模式。find_package(OpenCV REQUIRED)这个命令执行时CMake 会先在 CMAKE_MODULE_PATH 里找FindOpenCV.cmake找不到就去找OpenCVConfig.cmakeConfig 模式。如果写成find_package(OpenCV REQUIRED NO_MODULE)那就直接跳过 Module 模式。对使用者来说核心体验是如果这个包提供了 CMake 目标现在主流第三方库基本都提供find_package之后你就可以target_link_libraries(app PRIVATE OpenCV::core)非常优雅头文件路径、链接库、编译宏全帮你配好了。前提是你调用的名字和目标名要匹配很多项目的问题就出在“我 find_package 成功了但链接不到目标”——先去查一下这个包导出目标的名字可以在 cmake 输出里用message(STATUS targets: ${XXX_LIBRARIES})或查阅包文档。find_package的REQUIRED尽量带上。不写的话找不到包 CMake 也不会停之后你用的变量全为空很可能在链接阶段报一些莫名其妙的错误排查时间翻倍。带了REQUIRED配置阶段直接报错定位问题快得多。3.5 安装和导出install 与 EXPORT很多人写完 CMakeLists.txt 就跑cmake --build从不关心安装但对真正要发布库的项目来说install和EXPORT是必备技能install(TARGETS core EXPORT coreTargets ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin ) install(EXPORT coreTargets FILE coreTargets.cmake NAMESPACE mylib:: DESTINATION lib/cmake/core )这段代码干了两件事安装 core 库的产物同时生成一个coreTargets.cmake文件。下游项目通过find_package(core)就能拿到mylib::core目标——这就是现代 CMake 对外提供依赖的“标准姿势”。install(TARGETS ... EXPORT ...)里的EXPORT关键字非常容易漏漏了之后发现下游根本搜不到目标原因就在这里。3.6 条件与循环if、foreach、function/macroCMake 的命令语言虽然不像正经编程语言那么爽但基本控制流是齐全的。条件判断要特别注意真假语义if(WIN32) # Windows 分支 elseif(UNIX AND NOT APPLE) # Linux 分支 elseif(APPLE) # macOS 分支 endif()这里很多新手常踩坑if(${VAR})和if(VAR)在 CMake 里不一样。如果VAR的值是字符串OFF、NO、FALSE、0、空字符串if(VAR)会判定为假但如果写if(${VAR})由于字符串变成了OFFCMake 反而会按“非空字符串”视为真。所以判断变量是否存在/是否为真时直接用if(VAR)或if(DEFINED VAR)别画蛇添足加引号。foreach最常用的是遍历源文件列表set(MODULES core ui net) foreach(mod IN LISTS MODULES) add_subdirectory(${mod}) endforeach()function()和macro()的区别有点绕function 里的变量有局部作用域macro 是文本替换。写类似“传入列表参数并修改外部变量”这种逻辑时两者行为差异很大。我的建议是优先用 function逻辑更清晰。3.7 生成器表达式CMake 的“表达式语言”最后这个必须重点讲因为它是 CMake API 里最不像普通命令、但极其强大的部分。生成器表达式Generator Expression是$...包裹的表达式它不是在配置阶段求值的而是在生成构建系统generate阶段求值的。这意味着它可以区分不同的构建配置、不同的编译器、不同的目标平台。最常见的应用target_compile_options(app PRIVATE $$CONFIG:Debug:-g -O0 $$CONFIG:Release:-O3 )这里$CONFIG:Debug是一个条件表达式如果当前构建类型是 Debug就展开为1而$$CONFIG:Debug:-g -O0的意思是条件为真时输出-g -O0为假时输出空字符串。再比如你给库设置“仅对外暴露的接口头文件路径”时会这样写target_include_directories(core PUBLIC $BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include $INSTALL_INTERFACE:include )BUILD_INTERFACE是编译本工程时用的路径INSTALL_INTERFACE是安装后被下游引用时用的路径。这样同一个库在本地编译和安装使用两种场景下头文件路径都能正确解析。生成器表达式很难调试因为你没法用message()直接打印它的求值结果。我的经验是先在其他 IDE 里生成构建系统看实际命令或者用cmake --build build --verbose看最终传给编译器的参数。另外CMake 3.15 起可以用cmake_language(EVAL CODE ...)做无中生有的动态代码生成但日常维护中尽量少用可读性差。4. 一份完整 CMakeLists.txt 的搭建过程讲了这么多理论现在我把一个真实项目从零写出来把上面所有概念串一遍。4.1 项目需求假设我有这样一个工程myapp/ ├── CMakeLists.txt ├── core/ │ ├── CMakeLists.txt │ ├── include/core/core.h │ └── src/core.cpp └── app/ ├── CMakeLists.txt └── main.cppcore静态库提供add(int, int)函数对外头文件在core/includeapp可执行文件依赖core同时也依赖第三方库用find_package举例比如找 Qt 或 OpenCV这里我随便选一个不存在的演示包名但逻辑是一样的要求 C17可执行文件名最终叫my_app。4.2 顶层 CMakeLists.txtcmake_minimum_required(VERSION 3.16...3.28) project(myapp VERSION 1.0.0 LANGUAGES CXX) add_subdirectory(core) add_subdirectory(app) # 一个全局选项演示不参与编译只用来控制流程 option(MYAPP_ENABLE_TEST Enable test targets OFF)顶层文件职责很轻配置项目信息添加子目录定义全局选项。不要把具体编译参数写在这里尽量下沉到各个子目标避免全局污染。4.3 core 子目录# core/CMakeLists.txt add_library(core STATIC src/core.cpp ) # 给库设置 C17作为 PUBLIC 特性传递给下游 target_compile_features(core PUBLIC cxx_std_17) # 对外头文件路径自己和依赖者都需要 target_include_directories(core PUBLIC $BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include $INSTALL_INTERFACE:include ) # 内部编译宏仅 core 自己需要 target_compile_definitions(core PRIVATE CORE_BUILD) # 设置产物名 set_target_properties(core PROPERTIES OUTPUT_NAME core POSITION_INDEPENDENT_CODE ON ) install(TARGETS core EXPORT coreTargets ARCHIVE DESTINATION lib LIBRARY DESTINATION lib ) install(DIRECTORY include/ DESTINATION include) install(EXPORT coreTargets FILE coreTargets.cmake NAMESPACE mylib:: DESTINATION lib/cmake/core )注意core.h里如果#include别的头文件那么这些额外依赖也要在target_include_directories里体现。宁可多设置一层 PUBLIC也不要漏。4.4 app 子目录# app/CMakeLists.txt add_executable(app main.cpp ) target_link_libraries(app PRIVATE core) # 假设依赖一个第三方库 find_package(SomeLib REQUIRED) target_link_libraries(app PRIVATE SomeLib::SomeLib) # 再把可执行文件重命名 set_target_properties(app PROPERTIES OUTPUT_NAME my_app ) install(TARGETS app RUNTIME DESTINATION bin)4.5 构建与调试在项目根目录执行cmake -S . -B build -G Visual Studio 17 2022 -A x64 cmake --build build --config Release如果你用的是单配置生成器如 Ninja 或 Unix Makefilescmake -S . -B build -DCMAKE_BUILD_TYPERelease cmake --build build两种生成器的行为差异是很多“编译成功但没有 exe”问题的来源VS 是多配置生成器产物会在build/app/Release/my_app.exe这种带配置名的子目录里Makefile 生成器则直接输出到目标对应的目录。检查构建结果时我习惯先看这些# 查看构建缓存里的关键变量 grep -E CMAKE_BUILD_TYPE|CMAKE_CXX_STANDARD build/CMakeCache.txt # 看某个目标到底有没有被创建 cmake --build build --target help--target help在 Makefile 生成器下很好用能列出当前工程的所有目标。如果你发现目标没在里面那问题一定出在 CMakeLists 的目标创建逻辑上。5. 常见问题与排查技巧实录5.1 高频问题速查表我在实际带项目和逛论坛的过程中经常碰到下面这些高频问题整理成一张表给你直接对着排查现象根因解决办法make: *** No targets specified and no makefile found在源码目录直接执行了 make没有先 cmake 生成 Makefile先cmake -S . -B build再到 build 里构建CMake 编译成功但 VS 里“没有 exe”多配置生成器下产物在 Debug/Release 子目录或目标没被默认构建到build/app/Release/下找或检查EXCLUDE_FROM_ALLCMake 3.13 or higher is required. You are running version 3.10.2系统 CMake 版本过旧升级 CMake或降低cmake_minimum_required版本No rule to make target ...目标名拼错、或target_link_libraries引用了不存在的目标检查目标名用--target help列出所有目标头文件找不到target_include_directories没写对或写成了 PRIVATE确认可见性路径写CMAKE_CURRENT_SOURCE_DIR拼接链接时大量“未定义的引用”库链接顺序问题或target_link_libraries缺失把被依赖的库写在依赖者之后或直接target_link_libraries链接明明改了源码文件重新构建却没变化file(GLOB)没加CONFIGURE_DEPENDS加CONFIGURE_DEPENDS或改成显式源文件列表Java 警告“源发行版 17 需要目标发行版 17”这不是 CMake 的问题是 Java/JDK 编译选项不一致去改 IDE 的 Java compiler level 或 Maven/Gradle 配置5.2 独家排查技巧第一招多打message(STATUS)日志。别怕在 CMakeLists 里临时加几行message(STATUS core sources: ${CORE_SOURCES}) message(STATUS current dir: ${CMAKE_CURRENT_SOURCE_DIR})配置阶段就能看到所有变量的真实值比瞎猜快得多。排查完记得删掉或者用if(CMAKE_VERBOSE_MAKEFILE)包起来。第二招用--trace跟踪 CMake 执行。如果你不清楚某一行配置到底是哪来的跑cmake --trace --trace-expand -S . -B build它会把 CMakeLists 里的每一行执行情况、变量展开结果都打出来。输出很啰嗦但定位“某个变量为什么是某某值”特别管用。第三招看真实编译命令。构建时感觉参数不对直接cmake --build build --verbose这样能看到真正传给编译器的完整命令行。比如头文件路径、宏定义、编译标准一目了然。注意 VS 生成器要加/v或者把CMAKE_VERBOSE_MAKEFILE设为 ONNinja 和 Makefile 生成器用--verbose基本都能看到。第四招善用cmake-gui的组视图。有些项目会提供大量缓存变量命令行里难查cmake-gui打开后可以搜索、可以直接改缓存值还能看到哪些变量是高级选项。第五招区分“配置阶段问题”和“构建阶段问题”。CMake 的报错特别会误导人。比如“找不到符号”很可能是配置阶段target_link_libraries写错但报错发生在链接阶段让你以为要查源码。思路要清晰配置阶段报错查 CMakeLists 和变量构建阶段报错查编译命令和链接顺序。6. 最后分享一点自己的体会这些年写 CMake我最大的一个转变是从“命令记忆者”变成“模型理解者”。以前遇到问题总是去搜“CMake 设置 C17 的命令是什么”现在我会先想我要改的是哪个目标的哪个属性这个目标和其他目标是什么关系这个配置是 PRIVATE 还是 PUBLIC一旦脑里有这个模型所有命令不过是你表达意图的工具而已。还有一个小技巧其实非常简单但很多人没用起来——CMake 自带的文档是最好的手册cmake --help-command target_link_libraries cmake --help-property OUTPUT_NAME cmake --help-variable CMAKE_BUILD_TYPE在终端里随时能查比翻网页快而且是当前版本的准确文档。把这三类帮助命令用熟了你基本可以丢掉收藏夹里那些过时的教程了。至少对我来说从“能用 CMake”到“敢写 CMake 项目”靠的就是这个模型转变和支持文档的随手可查。