写 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_STANDARD); - API(命令/函数)= 操作对象的成员函数(
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 解释器里的普通存储。很多人刚开始分不清这两个,其实抓住一点就行:变量是“脚本里的变量”,属性是“某个对象身上的元数据”。
比如你想限制项目用 C++17,可以写:
set(CMAKE_CXX_STANDARD 17) # 变量:全局默认值 set_target_properties(core PROPERTIES CXX_STANDARD 17) # 属性:只对 core 生效区别在于:CMAKE_CXX_STANDARD是 CMake 提供给所有目标的默认值,但它是“全局的”。如果项目里有多个 target,有的要 C++17,有的要 C++11,就必须用属性来精确控制。而且属性的优先级更高:目标属性会覆盖变量默认值。
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表示不使用-std=gnu++17这类带的扩展;POSITION_INDEPENDENT_CODE:生成位置无关代码,做动态库时通常需要置ON;VERSION/SOVERSION:动态库版本号,Linux 上会生成libxxx.so.1.2.3这类文件;DEBUG_POSTFIX:Debug 配置下给产物加个后缀,比如mylib_d.dll,避免和 Release 混在一起;EXCLUDE_FROM_ALL:如果设为 ON,这个目标不会在默认cmake --build时构建,必须显式指定。
属性优先级顺序我总结下来是:命令行-D缓存变量 > 目标属性 > 目录属性 > 全局变量。也就是说,你可以通过cmake -DCMAKE_BUILD_TYPE=Release从外部注入,也可以在 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_DIR:build 目录;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 需要 C++17 编译特性,如果你依赖 core,最好也按 C++17 来编译”。假如某个依赖者只支持到 C++11,CMake 在配置阶段就能检测出来报错,而不是等编译时报一堆看不懂的模板错误。所以我的习惯是新项目尽量用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_file
file是一个能力极强的大命令,平时最常见的用法是递归收集源码:
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_VERSION@"CMake 会自动把@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.cmake(Config 模式)。如果写成:
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/macro
CMake 的命令语言虽然不像正经编程语言那么爽,但基本控制流是齐全的。
条件判断要特别注意真假语义:
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}"),由于字符串变成了"OFF",CMake 反而会按“非空字符串”视为真。所以,判断变量是否存在/是否为真时,直接用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/include;app:可执行文件,依赖core,同时也依赖第三方库(用find_package举例,比如找 Qt 或 OpenCV,这里我随便选一个不存在的演示包名,但逻辑是一样的);- 要求 C++17,可执行文件名最终叫
my_app。
4.2 顶层 CMakeLists.txt
cmake_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 ) # 给库设置 C++17,作为 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 Makefiles):
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release 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_ALL |
CMake 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设为 ON,Ninja 和 Makefile 生成器用--verbose基本都能看到。
第四招:善用cmake-gui的组视图。有些项目会提供大量缓存变量,命令行里难查,cmake-gui打开后可以搜索、可以直接改缓存值,还能看到哪些变量是高级选项。
第五招:区分“配置阶段问题”和“构建阶段问题”。CMake 的报错特别会误导人。比如“找不到符号”很可能是配置阶段target_link_libraries写错,但报错发生在链接阶段,让你以为要查源码。思路要清晰:配置阶段报错查 CMakeLists 和变量,构建阶段报错查编译命令和链接顺序。
6. 最后分享一点自己的体会
这些年写 CMake,我最大的一个转变是:从“命令记忆者”变成“模型理解者”。以前遇到问题总是去搜“CMake 设置 C++17 的命令是什么”,现在我会先想:我要改的是哪个目标的哪个属性?这个目标和其他目标是什么关系?这个配置是 PRIVATE 还是 PUBLIC?一旦脑里有这个模型,所有命令不过是你表达意图的工具而已。
还有一个小技巧,其实非常简单但很多人没用起来——CMake 自带的文档是最好的手册:
cmake --help-command target_link_libraries cmake --help-property OUTPUT_NAME cmake --help-variable CMAKE_BUILD_TYPE在终端里随时能查,比翻网页快,而且是当前版本的准确文档。把这三类帮助命令用熟了,你基本可以丢掉收藏夹里那些过时的教程了。至少对我来说,从“能用 CMake”到“敢写 CMake 项目”,靠的就是这个模型转变和支持文档的随手可查。