1. 从一份“能跑就行”的CMakeLists说起如果你写过C++,大概率经历过这样的场景:项目初期随手写了个CMakeLists.txt,cmake . && make一把过,心里美滋滋。三个月后要加个新模块,或者换台机器编译,突然就报了一堆找不到头文件的错误,你盯着那几十行配置,完全想不起来当初为什么这么写。更糟的是,同事拉下代码后编译失败,你只能回一句“我这边是好的”。这不是CMake的问题,是绝大多数人从来没把CMakeLists.txt当成一份需要认真设计的“工程文件”来对待。它被当成了临时脚本,能跑就完事。但一个项目只要超过两个人协作、超过三个源文件目录、或者需要跨平台构建,这份文件的组织方式就直接决定了整个团队的开发效率。我见过太多项目,源码写得干净漂亮,CMakeLists.txt却像一团乱麻:路径硬编码、变量满天飞、include_directories全局污染、target_link_libraries顺序全靠试。每次加新功能,改构建脚本的时间比写业务代码还长。这篇文章就是想把我在多个中小型C++项目里踩过的坑和总结出来的写法,系统地聊一遍。目标很明确:让你写出来的CMakeLists.txt,三个月后自己还能看懂,同事拉下来就能编,换平台不用大改。内容会从最基础的结构设计讲起,逐步深入到目标(target)的拆分、依赖管理、条件编译、安装导出这些实际工作中绕不开的环节。每个部分我都会解释“为什么这么写”,而不只是“这么写能跑”。适合已经用过CMake但没系统整理过写法的开发者,也适合刚开始接触C++工程构建的同学。2. 先想清楚目录结构,再动手写第一行2.1 为什么目录结构决定了CMakeLists的写法很多人写CMakeLists.txt的顺序是:先写代码,代码放不下了再想怎么组织目录,最后补构建脚本。这个顺序是反的。CMake的核心理念是“以目标为中心”,而目标的物理边界就是目录结构。你目录怎么分,直接决定了CMakeLists.txt里要写几个add_executable、几个add_library、target_include_directories里要填哪些路径。一个典型的、适合中小型项目的目录结构大概长这样:project_root/ ├── CMakeLists.txt # 顶层,只负责全局设置和添加子目录 ├── cmake/ # 自定义Find模块或宏 │ └── FindSomeLib.cmake ├── src/ │ ├── CMakeLists.txt # 主程序目标 │ ├── main.cpp │ └── core/ │ ├── CMakeLists.txt # 核心库目标 │ ├── include/ │ │ └── core/ │ │ └── engine.h │ └── engine.cpp ├── third_party/ # 第三方源码依赖 │ └── ... └── tests/ ├── CMakeLists.txt └── test_engine.cpp这个结构的关键在于:每个逻辑上独立的模块拥有自己的目录和自己的CMakeLists.txt。src/core是一个库,它对外暴露的头文件放在include/core/下,源文件放在同级。这样target_include_directories只需要写include这一个路径,使用方通过#include "core/engine.h"来引用,命名空间和目录结构天然对齐。我见过不少项目把所有.cpp和.h全堆在src/下,然后CMakeLists.txt里用file(GLOB ...)一把抓。这种写法在文件少的时候很爽,但一旦文件多了,编译单元之间的依赖关系就完全失控了。更致命的是,file(GLOB)不会自动感知新增文件,你加了新.cpp之后必须重新跑一次cmake,否则新文件根本不会被编译。这个坑我踩过不止一次,调试半天才发现是构建脚本没刷新。2.2 顶层CMakeLists只做三件事顶层CMakeLists.txt最容易写臃肿。很多人把编译器选项、头文件路径、库依赖全塞在顶层,导致所有子目标都被迫继承一堆可能根本用不上的配置。正确的做法是让顶层文件保持极简,只做三件事:第一,声明CMake最低版本和项目信息。cmake_minimum_required(VERSION 3.16)这行不是摆设,它决定了你能用哪些CMake特性。3.16是一个比较稳妥的底线,支持了target_link_options、target_link_directories等现代写法。项目声明用project(MyProject VERSION 1.0.0 LANGUAGES CXX),把版本号写进去,后面安装导出时会用到。第二,设置全局编译标准和选项。比如set(CMAKE_CXX_STANDARD 17)和set(CMAKE_CXX_STANDARD_REQUIRED ON)。这里有个细节:CMAKE_CXX_STANDARD是全局变量,但更推荐用target_compile_features在具体目标上声明,因为不同目标可能需要不同的标准。全局设置适合作为默认值,具体目标可以覆盖。第三,用add_subdirectory()引入子目录。顺序很重要,被依赖的库要先添加。比如core库被src主程序依赖,那add_subdirectory(src/core)必须写在add_subdirectory(src)之前。CMake在处理target_link_libraries时,如果目标还没定义,会直接报错。cmake_minimum_required(VERSION 3.16) project(MyProject VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) endif() add_subdirectory(src/core) add_subdirectory(src) add_subdirectory(tests)CMAKE_CXX_EXTENSIONS OFF这行值得单独说。它禁用了编译器对C++标准的扩展(比如GCC的-std=gnu++17),强制使用严格标准(-std=c++17)。这样做的好处是代码可移植性更好,不会不小心用了某个编译器特有的扩展。代价是某些依赖GNU扩展的第三方库可能需要单独处理,但整体上利大于弊。2.3 子目录CMakeLists的职责边界子目录的CMakeLists.txt只关心一件事:定义自己这个目标,并声明它需要什么、暴露什么。它不应该关心谁会用自己,也不应该去修改全局变量。以src/core/CMakeLists.txt为例:add_library(core STATIC engine.cpp ) target_include_directories(core PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> $<INSTALL_INTERFACE:include> ) target_compile_features(core PUBLIC cxx_std_17)这里有几个关键点。PUBLIC和PRIVATE的区别是CMake现代写法的核心:PUBLIC表示这个包含路径既用于编译core自己,也传递给链接core的目标;PRIVATE表示只用于编译自己。头文件目录用PUBLIC,因为使用方需要找到core/engine.h。而如果core内部用了某个第三方库的头文件,但不想暴露给使用方,那就用PRIVATE。$<BUILD_INTERFACE:...>和$<INSTALL_INTERFACE:...>是生成器表达式,分别对应“构建时”和“安装后”两种场景。构建时头文件在源码目录的include下,安装后头文件会被放到include目录下。这样写一次,两种场景都覆盖了,不需要在安装时再手动调整。target_compile_features(core PUBLIC cxx_std_17)比全局设置CMAKE_CXX_STANDARD更精细。它告诉CMake:编译core需要C++17,而且任何链接core的目标也至少需要C++17。如果某个使用方只声明了C++14,CMake会自动把它提升到C++17,避免标准不匹配导致的编译错误。3. 目标拆分:什么时候该建库,什么时候该合并3.1 库的粒度控制:太细和太粗都是坑目标拆分的第一个决策是:哪些代码应该独立成库?我的经验法则是:如果一个模块有明确的对外接口、可以被独立测试、或者会被多个可执行文件复用,就拆成库。反之,如果只是主程序内部的一个辅助函数集合,没必要单独建库。拆得太细的典型症状是:一个项目里有十几个小库,每个库只有一两个源文件,target_link_libraries写了一长串,链接顺序稍微不对就报未定义符号。这种“碎片化”的构建结构维护成本很高,每次加一个新功能都要想“这个应该放哪个库”。拆得太粗的症状则是:所有代码都在一个巨大的add_library里,改一行代码要重新编译整个库,测试也没法单独跑。更麻烦的是,不同模块的编译选项无法差异化设置,比如某个模块需要开-O3优化,另一个模块需要开调试符号,混在一起就没法处理。一个比较舒服的粒度是:按业务领域或功能层次划分。比如一个图像处理项目,可以分成image_io(读写)、image_filter(滤波)、image_analysis(分析)三个库,每个库有清晰的职责边界。image_filter依赖image_io的数据结构,但image_io不依赖image_filter,依赖关系是单向的、无环的。3.2 可执行文件与库的依赖方向一个常见的错误是让库去依赖可执行文件。比如把main.cpp里的某些函数抽出来放到库里,但库又引用了main里定义的全局变量。这种循环依赖在CMake里会直接导致链接失败,而且从设计上就是有问题的。正确的依赖方向永远是:可执行文件依赖库,库之间单向依赖,底层库不依赖上层库。如果发现库需要用到可执行文件里的东西,那说明这个“东西”应该被下沉到更底层的库里,或者通过接口(抽象基类、回调函数)来解耦。在CMakeLists.txt里,这个方向体现为target_link_libraries的调用顺序。src/CMakeLists.txt里写:add_executable(myapp main.cpp) target_link_libraries(myapp PRIVATE core)core库不需要知道myapp的存在,它只声明自己需要什么。如果core依赖了某个第三方库,比如fmt,那就在core的CMakeLists.txt里写target_link_libraries(core PUBLIC fmt::fmt)。myapp链接core时,会自动继承fmt的依赖,不需要在myapp里再写一遍。这就是PUBLIC传播的作用。3.3 接口库:只有头文件的模块怎么处理有些模块只有头文件,比如模板库、纯虚接口定义、常量定义。这种情况下用add_library(xxx INTERFACE)来创建接口库。接口库不产生编译产物,只用来传递使用要求(包含路径、编译特性、依赖库)。add_library(common_headers INTERFACE) target_include_directories(common_headers INTERFACE $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> $<INSTALL_INTERFACE:include> ) target_compile_features(common_headers INTERFACE cxx_std_17)接口库的好处是,使用方链接它时不会产生任何实际的链接动作,只是继承了一组配置。这在组织纯头文件工具库时非常方便。我通常会把跨模块共用的类型定义、错误码、日志宏放在一个接口库里,所有其他库都链接它,保证编译选项一致。4. 依赖管理:find_package、FetchContent和手写Find模块4.1 find_package的两种模式与常见翻车场景find_package是CMake管理外部依赖的主要方式,它有两种模式:MODULE模式和CONFIG模式。MODULE模式查找的是FindXXX.cmake文件,通常由CMake自带或项目自己提供;CONFIG模式查找的是XXXConfig.cmake或xxx-config.cmake文件,由库的安装过程生成。优先用CONFIG模式,因为它是库作者官方维护的,能准确反映库的依赖关系和编译要求。写法是:find_package(fmt CONFIG REQUIRED) target_link_libraries(core PUBLIC fmt::fmt)REQUIRED表示找不到就直接报错,避免后续出现莫名其妙的编译失败。fmt::fmt是库导出的目标名,链接它比手动写include_directories和target_link_libraries靠谱得多,因为库作者已经把包含路径、编译定义、依赖库都封装好了。常见的翻车场景是:库安装了,但find_package找不到。原因通常是CMAKE_PREFIX_PATH没设置对。如果库装在非标准路径(比如/opt/some_lib),需要在配置时指定-DCMAKE_PREFIX_PATH=/opt/some_lib。另一个原因是库只提供了MODULE模式的FindXXX.cmake,但你的find_package默认先找CONFIG模式,找不到就报错。这时候可以显式指定MODULE模式:find_package(XXX MODULE REQUIRED)。4.2 FetchContent:把依赖源码直接拉进来编译如果某个依赖没有提供CMake配置文件,或者你希望锁定版本、避免环境差异,可以用FetchContent把源码拉下来一起编译。这在依赖较小、编译不复杂的情况下很好用。include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git GIT_TAG release-1.12.1 ) FetchContent_MakeAvailable(googletest)FetchContent_MakeAvailable会下载源码并执行它的CMakeLists.txt,把定义的目标引入当前项目。之后就可以直接target_link_libraries(myapp PRIVATE gtest_main)。用FetchContent要注意几点。第一,它会增加配置阶段的耗时,因为要下载和编译依赖。第二,如果依赖本身也用了FetchContent拉取其他库,可能会产生冲突。第三,网络不稳定时下载会失败,可以考虑配合本地缓存或镜像。我一般只在依赖较小(比如测试框架、单头文件库)时用这个方案,大型依赖还是优先用系统包管理器或预编译库。4.3 手写Find模块的适用场景与模板有些库既没有CMake配置,也不适合用FetchContent(比如已经预编译好的二进制库),这时候就需要手写FindXXX.cmake。手写模块的核心是找到头文件路径和库文件路径,然后定义一个导入目标(IMPORTED target)。# cmake/FindMyLib.cmake find_path(MyLib_INCLUDE_DIR NAMES mylib.h PATHS /usr/local/include /opt/mylib/include ) find_library(MyLib_LIBRARY NAMES mylib PATHS /usr/local/lib /opt/mylib/lib ) include(FindPackageHandleStandardArgs) find_package_handle_standard_args(MyLib REQUIRED_VARS MyLib_LIBRARY MyLib_INCLUDE_DIR ) if(MyLib_FOUND AND NOT TARGET MyLib::MyLib) add_library(MyLib::MyLib UNKNOWN IMPORTED) set_target_properties(MyLib::MyLib PROPERTIES IMPORTED_LOCATION "${MyLib_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${MyLib_INCLUDE_DIR}" ) endif()这个模板的关键是最后定义的IMPORTED目标。有了它,使用方就可以统一用target_link_libraries(xxx PRIVATE MyLib::MyLib)来链接,不需要关心底层是库文件还是接口库。find_package_handle_standard_args负责检查必需变量是否都找到了,并设置MyLib_FOUND变量。手写Find模块的维护成本不低,因为不同平台的库路径、库名可能不同。如果库本身提供了CMake支持,优先用官方的。只有在确实没有其他选择时才自己写,而且要把搜索路径写成可配置的变量,方便在不同环境下覆盖。5. 编译选项与条件编译:别让全局设置污染所有目标5.1 target_compile_options与add_compile_options的选择add_compile_options是全局的,会影响之后定义的所有目标。target_compile_options是目标级别的,只影响指定目标。现代CMake写法强烈推荐后者,因为全局选项很容易在不经意间影响到第三方库的编译。比如你想给自己的代码开-Wall -Wextra,但第三方库可能有一堆警告,开了之后编译输出全是噪音。用target_compile_options就能精确控制:target_compile_options(core PRIVATE -Wall -Wextra -Wpedantic)PRIVATE表示这些选项只用于编译core自己,不会传递给链接它的目标。如果某个选项需要传递给使用方(比如某个宏定义影响了头文件的行为),那就用PUBLIC或INTERFACE。对于MSVC,警告选项的写法不同,需要条件判断:if(MSVC) target_compile_options(core PRIVATE /W4) else() target_compile_options(core PRIVATE -Wall -Wextra -Wpedantic) endif()这种平台相关的选项,我习惯封装成一个函数或宏,放在cmake/目录下,避免在每个CMakeLists.txt里重复写。5.2 生成器表达式:按构建类型差异化配置生成器表达式是CMake里比较高级但非常实用的特性。它允许你在配置阶段写“条件”,在生成阶段才求值。最常见的用途是按构建类型(Debug/Release)设置不同的编译选项。target_compile_options(core PRIVATE $<$<CONFIG:Debug>:-g -O0> $<$<CONFIG:Release>:-O3 -DNDEBUG> )$<$<CONFIG:Debug>:...>的意思是:如果当前构建类型是Debug,就展开为后面的选项。这样一份CMakeLists.txt就能同时支持多种构建类型,不需要为每种类型写一套配置。另一个常见用途是区分编译器和平台:target_compile_definitions(core PRIVATE $<$<CXX_COMPILER_ID:GNU>:MYLIB_GCC> $<$<CXX_COMPILER_ID:Clang>:MYLIB_CLANG> $<$<CXX_COMPILER_ID:MSVC>:MYLIB_MSVC> )生成器表达式的嵌套可能看起来有点绕,但用多了会发现它比写一堆if判断要清晰得多,因为配置是声明式的,而不是命令式的。5.3 编译定义与配置头文件target_compile_definitions用来传递宏定义,比如版本号、功能开关。但直接在命令行定义宏有个问题:字符串类型的值需要转义,写起来很麻烦。更好的做法是用configure_file生成一个配置头文件。# CMakeLists.txt configure_file( "${CMAKE_CURRENT_SOURCE_DIR}/config.h.in" "${CMAKE_CURRENT_BINARY_DIR}/config.h" ) target_include_directories(core PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")config.h.in里写:#define MYLIB_VERSION "@PROJECT_VERSION@" #define MYLIB_BUILD_TYPE "@CMAKE_BUILD_TYPE@"configure_file会把@变量@替换成CMake里的实际值,生成config.h到构建目录。这样代码里只需要#include "config.h"就能拿到版本信息,不需要在编译命令行里传一堆-D。这个技巧在需要把构建信息嵌入程序时特别有用,比如--version命令输出版本号和构建时间。6. 安装与导出:让别人也能用你的库6.1 install命令的基本用法与路径变量写库的最终目的是给别人用。install命令负责把编译产物、头文件、配置文件复制到指定位置。基本用法:include(GNUInstallDirs) install(TARGETS core EXPORT MyProjectTargets LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} ) install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} )GNUInstallDirs模块提供了CMAKE_INSTALL_LIBDIR、CMAKE_INSTALL_INCLUDEDIR等变量,它们会根据平台自动选择lib还是lib64、include还是其他路径。不要硬编码lib和include,否则在有些发行版上会装错位置。EXPORT MyProjectTargets这个参数很关键,它把安装的目标记录到一个导出集合里,后面用来生成配置文件。INCLUDES DESTINATION告诉CMake,安装后的头文件在哪个目录下,这样使用方通过target_link_libraries链接时能自动找到头文件。6.2 导出配置文件:让find_package能找到你的库安装完库之后,别人怎么用find_package找到它?需要生成两个文件:MyProjectConfig.cmake和MyProjectConfigVersion.cmake。install(EXPORT MyProjectTargets FILE MyProjectTargets.cmake NAMESPACE MyProject:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyProject ) include(CMakePackageConfigHelpers) configure_package_config_file( "${CMAKE_CURRENT_SOURCE_DIR}/cmake/MyProjectConfig.cmake.in" "${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfig.cmake" INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyProject ) write_basic_package_version_file( "${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfigVersion.cmake" VERSION ${PROJECT_VERSION} COMPATIBILITY SameMajorVersion ) install(FILES "${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfig.cmake" "${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfigVersion.cmake" DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyProject )MyProjectConfig.cmake.in的内容很简单:@PACKAGE_INIT@ include("${CMAKE_CURRENT_LIST_DIR}/MyProjectTargets.cmake") check_required_components(MyProject)@PACKAGE_INIT@会被替换成一些初始化代码,check_required_components检查使用方是否请求了不存在的组件。write_basic_package_version_file生成版本文件,SameMajorVersion表示主版本号相同就兼容,这是比较常用的策略。做完这些之后,别人安装你的库到系统路径,然后find_package(MyProject REQUIRED)就能找到,并且可以用MyProject::core这样的命名空间目标来链接。整个流程看起来步骤多,但都是模板化的,写一次之后复制到其他项目改改名字就行。6.3 安装时的常见坑与检查清单安装环节有几个容易忽略的细节。第一,install(TARGETS)里的INCLUDES DESTINATION必须写,否则使用方链接后找不到头文件。第二,install(DIRECTORY include/ ...)末尾的斜杠很重要,include/表示复制目录内容,include表示复制目录本身。第三,如果库有依赖,MyProjectConfig.cmake.in里需要用find_dependency声明:include(CMakeFindDependencyMacro) find_dependency(fmt)这样使用方在find_package(MyProject)时,会自动触发find_package(fmt),不需要手动再找一遍。如果漏了这一步,使用方链接时会报找不到fmt::fmt目标。安装完成后,我习惯用一个简单的测试项目验证:新建一个空目录,写一个最小的CMakeLists.txt,用find_package找到刚安装的库,编译一个调用库函数的程序。如果能跑通,说明安装导出没问题。这个验证步骤花不了几分钟,但能避免把有问题的安装配置交给别人。7. 调试与排错:CMake报错时怎么快速定位7.1 读懂CMake的错误信息CMake的报错信息通常分三段:错误类型、出错位置、具体描述。最常见的错误是“Target not found”和“Cannot find source file”。前者通常是target_link_libraries里写了一个还没定义的目标名,检查add_subdirectory的顺序和add_library/add_executable的拼写。后者是add_library或add_executable里列出的源文件路径不对,注意相对路径是相对于当前CMakeLists.txt所在目录。另一个高频错误是“CMake Error: The current CMakeCache.txt directory ... is different”。这通常是因为把构建目录从一台机器复制到了另一台,或者移动了源码目录。解决办法很简单:删掉构建目录重新配置。我一般会在构建目录名里加上平台和构建类型,比如build-linux-debug、build-win-release,避免混淆。7.2 用message和cmake_print_variables调试当配置逻辑比较复杂时,光看报错信息不够,需要主动打印变量值。message(STATUS "VAR=${VAR}")是最基本的调试手段。CMake 3.15之后还提供了cmake_print_variables:include(CMakePrintHelpers) cmake_print_variables(CMAKE_CXX_COMPILER CMAKE_BUILD_TYPE PROJECT_VERSION)它会以更清晰的格式打印变量名和值。我通常在find_package之后打印一下找到的库路径和版本,确认找到的是预期的那个,而不是系统里另一个同名但版本不同的库。7.3 构建目录的清理策略CMake的构建目录里缓存了大量配置信息,有时候改了CMakeLists.txt但行为没变,就是因为缓存没刷新。最彻底的清理是直接删掉整个构建目录重新来。但每次都全量编译太耗时,可以只删CMakeCache.txt和CMakeFiles/目录,保留编译产物。不过这种做法有时会留下不一致的状态,我一般只在确认是缓存问题时才这么干。更优雅的方式是用cmake --fresh(CMake 3.24+),它会清除缓存但保留构建目录结构。如果版本不够,就手动删CMakeCache.txt。另外,ccache配合CMake能大幅减少重复编译的时间,配置方法是set(CMAKE_CXX_COMPILER_LAUNCHER ccache),放在顶层CMakeLists.txt里。这个对日常开发效率提升很明显,尤其是频繁切换分支的时候。8. 一些让CMakeLists更可维护的实践习惯8.1 变量命名与作用域管理CMake的变量作用域规则和普通编程语言不太一样。在add_subdirectory里设置的变量,默认只在当前目录和子目录可见,父目录看不到。这个特性可以用来隔离配置,但也容易造成困惑。我的习惯是:全局变量用CMAKE_前缀或项目名前缀,局部变量用_前缀或全小写,一眼就能看出作用范围。比如MYPROJECT_BUILD_TESTS是全局选项,_core_sources是core目录内部的源文件列表。避免用SRCS、LIBS这种太通用的名字,因为不同目录下可能重复定义,导致意外的覆盖。8.2 注释与版本记录CMakeLists.txt里的注释不是给别人看的,是给三个月后的自己看的。我习惯在每个非显而易见的配置旁边写一行注释,说明“为什么这么写”。比如:# 必须用PUBLIC,因为engine.h里包含了fmt的头文件 target_link_libraries(core PUBLIC fmt::fmt)这种注释在排查问题时能省很多时间。另外,如果某个配置是为了绕过某个已知问题,把问题描述和参考链接写在注释里,避免以后有人“优化”掉。8.3 从简单开始,按需演进最后想说的是,不要一开始就追求“完美”的CMake结构。项目初期,一个顶层CMakeLists.txt加几个add_executable完全够用。等到代码量上来了、协作人数多了、需要跨平台了,再逐步拆分目标、引入安装导出。过度设计比设计不足更让人头疼,因为复杂的构建脚本本身就是维护负担。我自己的节奏是:单文件项目就一个CMakeLists.txt;超过五个源文件就按目录拆库;需要给别人用时再加安装导出;有多个可执行文件时引入tests和tools子目录。每一步演进都有明确的触发条件,而不是提前把框架搭好等着填代码。写CMakeLists.txt这件事,说到底是在写一份“构建契约”:它告诉编译器怎么把你的代码变成可执行文件,告诉协作者怎么在你的项目上工作,告诉未来的自己当初为什么这么设计。把这份契约写清楚,后面的事情会顺很多。
1. 从一份“能跑就行”的CMakeLists说起如果你写过C++,大概率经历过这样的场景:项目初期随手写了个CMakeLists.txt,cmake . && make一把过,心里美滋滋。三个月后要加个新模块,或者换台机器编译,突然就报了一堆找不到头文件的错误,你盯着那几十行配置,完全想不起来当初为什么这么写。更糟的是,同事拉下代码后编译失败,你只能回一句“我这边是好的”。这不是CMake的问题,是绝大多数人从来没把CMakeLists.txt当成一份需要认真设计的“工程文件”来对待。它被当成了临时脚本,能跑就完事。但一个项目只要超过两个人协作、超过三个源文件目录、或者需要跨平台构建,这份文件的组织方式就直接决定了整个团队的开发效率。我见过太多项目,源码写得干净漂亮,CMakeLists.txt却像一团乱麻:路径硬编码、变量满天飞、include_directories全局污染、target_link_libraries顺序全靠试。每次加新功能,改构建脚本的时间比写业务代码还长。这篇文章就是想把我在多个中小型C++项目里踩过的坑和总结出来的写法,系统地聊一遍。目标很明确:让你写出来的CMakeLists.txt,三个月后自己还能看懂,同事拉下来就能编,换平台不用大改。内容会从最基础的结构设计讲起,逐步深入到目标(target)的拆分、依赖管理、条件编译、安装导出这些实际工作中绕不开的环节。每个部分我都会解释“为什么这么写”,而不只是“这么写能跑”。适合已经用过CMake但没系统整理过写法的开发者,也适合刚开始接触C++工程构建的同学。2. 先想清楚目录结构,再动手写第一行2.1 为什么目录结构决定了CMakeLists的写法很多人写CMakeLists.txt的顺序是:先写代码,代码放不下了再想怎么组织目录,最后补构建脚本。这个顺序是反的。CMake的核心理念是“以目标为中心”,而目标的物理边界就是目录结构。你目录怎么分,直接决定了CMakeLists.txt里要写几个add_executable、几个add_library、target_include_directories里要填哪些路径。一个典型的、适合中小型项目的目录结构大概长这样:project_root/ ├── CMakeLists.txt # 顶层,只负责全局设置和添加子目录 ├── cmake/ # 自定义Find模块或宏 │ └── FindSomeLib.cmake ├── src/ │ ├── CMakeLists.txt # 主程序目标 │ ├── main.cpp │ └── core/ │ ├── CMakeLists.txt # 核心库目标 │ ├── include/ │ │ └── core/ │ │ └── engine.h │ └── engine.cpp ├── third_party/ # 第三方源码依赖 │ └── ... └── tests/ ├── CMakeLists.txt └── test_engine.cpp这个结构的关键在于:每个逻辑上独立的模块拥有自己的目录和自己的CMakeLists.txt。src/core是一个库,它对外暴露的头文件放在include/core/下,源文件放在同级。这样target_include_directories只需要写include这一个路径,使用方通过#include "core/engine.h"来引用,命名空间和目录结构天然对齐。我见过不少项目把所有.cpp和.h全堆在src/下,然后CMakeLists.txt里用file(GLOB ...)一把抓。这种写法在文件少的时候很爽,但一旦文件多了,编译单元之间的依赖关系就完全失控了。更致命的是,file(GLOB)不会自动感知新增文件,你加了新.cpp之后必须重新跑一次cmake,否则新文件根本不会被编译。这个坑我踩过不止一次,调试半天才发现是构建脚本没刷新。2.2 顶层CMakeLists只做三件事顶层CMakeLists.txt最容易写臃肿。很多人把编译器选项、头文件路径、库依赖全塞在顶层,导致所有子目标都被迫继承一堆可能根本用不上的配置。正确的做法是让顶层文件保持极简,只做三件事:第一,声明CMake最低版本和项目信息。cmake_minimum_required(VERSION 3.16)这行不是摆设,它决定了你能用哪些CMake特性。3.16是一个比较稳妥的底线,支持了target_link_options、target_link_directories等现代写法。项目声明用project(MyProject VERSION 1.0.0 LANGUAGES CXX),把版本号写进去,后面安装导出时会用到。第二,设置全局编译标准和选项。比如set(CMAKE_CXX_STANDARD 17)和set(CMAKE_CXX_STANDARD_REQUIRED ON)。这里有个细节:CMAKE_CXX_STANDARD是全局变量,但更推荐用target_compile_features在具体目标上声明,因为不同目标可能需要不同的标准。全局设置适合作为默认值,具体目标可以覆盖。第三,用add_subdirectory()引入子目录。顺序很重要,被依赖的库要先添加。比如core库被src主程序依赖,那add_subdirectory(src/core)必须写在add_subdirectory(src)之前。CMake在处理target_link_libraries时,如果目标还没定义,会直接报错。cmake_minimum_required(VERSION 3.16) project(MyProject VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) endif() add_subdirectory(src/core) add_subdirectory(src) add_subdirectory(tests)CMAKE_CXX_EXTENSIONS OFF这行值得单独说。它禁用了编译器对C++标准的扩展(比如GCC的-std=gnu++17),强制使用严格标准(-std=c++17)。这样做的好处是代码可移植性更好,不会不小心用了某个编译器特有的扩展。代价是某些依赖GNU扩展的第三方库可能需要单独处理,但整体上利大于弊。2.3 子目录CMakeLists的职责边界子目录的CMakeLists.txt只关心一件事:定义自己这个目标,并声明它需要什么、暴露什么。它不应该关心谁会用自己,也不应该去修改全局变量。以src/core/CMakeLists.txt为例:add_library(core STATIC engine.cpp ) target_include_directories(core PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> $<INSTALL_INTERFACE:include> ) target_compile_features(core PUBLIC cxx_std_17)这里有几个关键点。PUBLIC和PRIVATE的区别是CMake现代写法的核心:PUBLIC表示这个包含路径既用于编译core自己,也传递给链接core的目标;PRIVATE表示只用于编译自己。头文件目录用PUBLIC,因为使用方需要找到core/engine.h。而如果core内部用了某个第三方库的头文件,但不想暴露给使用方,那就用PRIVATE。$<BUILD_INTERFACE:...>和$<INSTALL_INTERFACE:...>是生成器表达式,分别对应“构建时”和“安装后”两种场景。构建时头文件在源码目录的include下,安装后头文件会被放到include目录下。这样写一次,两种场景都覆盖了,不需要在安装时再手动调整。target_compile_features(core PUBLIC cxx_std_17)比全局设置CMAKE_CXX_STANDARD更精细。它告诉CMake:编译core需要C++17,而且任何链接core的目标也至少需要C++17。如果某个使用方只声明了C++14,CMake会自动把它提升到C++17,避免标准不匹配导致的编译错误。3. 目标拆分:什么时候该建库,什么时候该合并3.1 库的粒度控制:太细和太粗都是坑目标拆分的第一个决策是:哪些代码应该独立成库?我的经验法则是:如果一个模块有明确的对外接口、可以被独立测试、或者会被多个可执行文件复用,就拆成库。反之,如果只是主程序内部的一个辅助函数集合,没必要单独建库。拆得太细的典型症状是:一个项目里有十几个小库,每个库只有一两个源文件,target_link_libraries写了一长串,链接顺序稍微不对就报未定义符号。这种“碎片化”的构建结构维护成本很高,每次加一个新功能都要想“这个应该放哪个库”。拆得太粗的症状则是:所有代码都在一个巨大的add_library里,改一行代码要重新编译整个库,测试也没法单独跑。更麻烦的是,不同模块的编译选项无法差异化设置,比如某个模块需要开-O3优化,另一个模块需要开调试符号,混在一起就没法处理。一个比较舒服的粒度是:按业务领域或功能层次划分。比如一个图像处理项目,可以分成image_io(读写)、image_filter(滤波)、image_analysis(分析)三个库,每个库有清晰的职责边界。image_filter依赖image_io的数据结构,但image_io不依赖image_filter,依赖关系是单向的、无环的。3.2 可执行文件与库的依赖方向一个常见的错误是让库去依赖可执行文件。比如把main.cpp里的某些函数抽出来放到库里,但库又引用了main里定义的全局变量。这种循环依赖在CMake里会直接导致链接失败,而且从设计上就是有问题的。正确的依赖方向永远是:可执行文件依赖库,库之间单向依赖,底层库不依赖上层库。如果发现库需要用到可执行文件里的东西,那说明这个“东西”应该被下沉到更底层的库里,或者通过接口(抽象基类、回调函数)来解耦。在CMakeLists.txt里,这个方向体现为target_link_libraries的调用顺序。src/CMakeLists.txt里写:add_executable(myapp main.cpp) target_link_libraries(myapp PRIVATE core)core库不需要知道myapp的存在,它只声明自己需要什么。如果core依赖了某个第三方库,比如fmt,那就在core的CMakeLists.txt里写target_link_libraries(core PUBLIC fmt::fmt)。myapp链接core时,会自动继承fmt的依赖,不需要在myapp里再写一遍。这就是PUBLIC传播的作用。3.3 接口库:只有头文件的模块怎么处理有些模块只有头文件,比如模板库、纯虚接口定义、常量定义。这种情况下用add_library(xxx INTERFACE)来创建接口库。接口库不产生编译产物,只用来传递使用要求(包含路径、编译特性、依赖库)。add_library(common_headers INTERFACE) target_include_directories(common_headers INTERFACE $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> $<INSTALL_INTERFACE:include> ) target_compile_features(common_headers INTERFACE cxx_std_17)接口库的好处是,使用方链接它时不会产生任何实际的链接动作,只是继承了一组配置。这在组织纯头文件工具库时非常方便。我通常会把跨模块共用的类型定义、错误码、日志宏放在一个接口库里,所有其他库都链接它,保证编译选项一致。4. 依赖管理:find_package、FetchContent和手写Find模块4.1 find_package的两种模式与常见翻车场景find_package是CMake管理外部依赖的主要方式,它有两种模式:MODULE模式和CONFIG模式。MODULE模式查找的是FindXXX.cmake文件,通常由CMake自带或项目自己提供;CONFIG模式查找的是XXXConfig.cmake或xxx-config.cmake文件,由库的安装过程生成。优先用CONFIG模式,因为它是库作者官方维护的,能准确反映库的依赖关系和编译要求。写法是:find_package(fmt CONFIG REQUIRED) target_link_libraries(core PUBLIC fmt::fmt)REQUIRED表示找不到就直接报错,避免后续出现莫名其妙的编译失败。fmt::fmt是库导出的目标名,链接它比手动写include_directories和target_link_libraries靠谱得多,因为库作者已经把包含路径、编译定义、依赖库都封装好了。常见的翻车场景是:库安装了,但find_package找不到。原因通常是CMAKE_PREFIX_PATH没设置对。如果库装在非标准路径(比如/opt/some_lib),需要在配置时指定-DCMAKE_PREFIX_PATH=/opt/some_lib。另一个原因是库只提供了MODULE模式的FindXXX.cmake,但你的find_package默认先找CONFIG模式,找不到就报错。这时候可以显式指定MODULE模式:find_package(XXX MODULE REQUIRED)。4.2 FetchContent:把依赖源码直接拉进来编译如果某个依赖没有提供CMake配置文件,或者你希望锁定版本、避免环境差异,可以用FetchContent把源码拉下来一起编译。这在依赖较小、编译不复杂的情况下很好用。include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git GIT_TAG release-1.12.1 ) FetchContent_MakeAvailable(googletest)FetchContent_MakeAvailable会下载源码并执行它的CMakeLists.txt,把定义的目标引入当前项目。之后就可以直接target_link_libraries(myapp PRIVATE gtest_main)。用FetchContent要注意几点。第一,它会增加配置阶段的耗时,因为要下载和编译依赖。第二,如果依赖本身也用了FetchContent拉取其他库,可能会产生冲突。第三,网络不稳定时下载会失败,可以考虑配合本地缓存或镜像。我一般只在依赖较小(比如测试框架、单头文件库)时用这个方案,大型依赖还是优先用系统包管理器或预编译库。4.3 手写Find模块的适用场景与模板有些库既没有CMake配置,也不适合用FetchContent(比如已经预编译好的二进制库),这时候就需要手写FindXXX.cmake。手写模块的核心是找到头文件路径和库文件路径,然后定义一个导入目标(IMPORTED target)。# cmake/FindMyLib.cmake find_path(MyLib_INCLUDE_DIR NAMES mylib.h PATHS /usr/local/include /opt/mylib/include ) find_library(MyLib_LIBRARY NAMES mylib PATHS /usr/local/lib /opt/mylib/lib ) include(FindPackageHandleStandardArgs) find_package_handle_standard_args(MyLib REQUIRED_VARS MyLib_LIBRARY MyLib_INCLUDE_DIR ) if(MyLib_FOUND AND NOT TARGET MyLib::MyLib) add_library(MyLib::MyLib UNKNOWN IMPORTED) set_target_properties(MyLib::MyLib PROPERTIES IMPORTED_LOCATION "${MyLib_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${MyLib_INCLUDE_DIR}" ) endif()这个模板的关键是最后定义的IMPORTED目标。有了它,使用方就可以统一用target_link_libraries(xxx PRIVATE MyLib::MyLib)来链接,不需要关心底层是库文件还是接口库。find_package_handle_standard_args负责检查必需变量是否都找到了,并设置MyLib_FOUND变量。手写Find模块的维护成本不低,因为不同平台的库路径、库名可能不同。如果库本身提供了CMake支持,优先用官方的。只有在确实没有其他选择时才自己写,而且要把搜索路径写成可配置的变量,方便在不同环境下覆盖。5. 编译选项与条件编译:别让全局设置污染所有目标5.1 target_compile_options与add_compile_options的选择add_compile_options是全局的,会影响之后定义的所有目标。target_compile_options是目标级别的,只影响指定目标。现代CMake写法强烈推荐后者,因为全局选项很容易在不经意间影响到第三方库的编译。比如你想给自己的代码开-Wall -Wextra,但第三方库可能有一堆警告,开了之后编译输出全是噪音。用target_compile_options就能精确控制:target_compile_options(core PRIVATE -Wall -Wextra -Wpedantic)PRIVATE表示这些选项只用于编译core自己,不会传递给链接它的目标。如果某个选项需要传递给使用方(比如某个宏定义影响了头文件的行为),那就用PUBLIC或INTERFACE。对于MSVC,警告选项的写法不同,需要条件判断:if(MSVC) target_compile_options(core PRIVATE /W4) else() target_compile_options(core PRIVATE -Wall -Wextra -Wpedantic) endif()这种平台相关的选项,我习惯封装成一个函数或宏,放在cmake/目录下,避免在每个CMakeLists.txt里重复写。5.2 生成器表达式:按构建类型差异化配置生成器表达式是CMake里比较高级但非常实用的特性。它允许你在配置阶段写“条件”,在生成阶段才求值。最常见的用途是按构建类型(Debug/Release)设置不同的编译选项。target_compile_options(core PRIVATE $<$<CONFIG:Debug>:-g -O0> $<$<CONFIG:Release>:-O3 -DNDEBUG> )$<$<CONFIG:Debug>:...>的意思是:如果当前构建类型是Debug,就展开为后面的选项。这样一份CMakeLists.txt就能同时支持多种构建类型,不需要为每种类型写一套配置。另一个常见用途是区分编译器和平台:target_compile_definitions(core PRIVATE $<$<CXX_COMPILER_ID:GNU>:MYLIB_GCC> $<$<CXX_COMPILER_ID:Clang>:MYLIB_CLANG> $<$<CXX_COMPILER_ID:MSVC>:MYLIB_MSVC> )生成器表达式的嵌套可能看起来有点绕,但用多了会发现它比写一堆if判断要清晰得多,因为配置是声明式的,而不是命令式的。5.3 编译定义与配置头文件target_compile_definitions用来传递宏定义,比如版本号、功能开关。但直接在命令行定义宏有个问题:字符串类型的值需要转义,写起来很麻烦。更好的做法是用configure_file生成一个配置头文件。# CMakeLists.txt configure_file( "${CMAKE_CURRENT_SOURCE_DIR}/config.h.in" "${CMAKE_CURRENT_BINARY_DIR}/config.h" ) target_include_directories(core PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")config.h.in里写:#define MYLIB_VERSION "@PROJECT_VERSION@" #define MYLIB_BUILD_TYPE "@CMAKE_BUILD_TYPE@"configure_file会把@变量@替换成CMake里的实际值,生成config.h到构建目录。这样代码里只需要#include "config.h"就能拿到版本信息,不需要在编译命令行里传一堆-D。这个技巧在需要把构建信息嵌入程序时特别有用,比如--version命令输出版本号和构建时间。6. 安装与导出:让别人也能用你的库6.1 install命令的基本用法与路径变量写库的最终目的是给别人用。install命令负责把编译产物、头文件、配置文件复制到指定位置。基本用法:include(GNUInstallDirs) install(TARGETS core EXPORT MyProjectTargets LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} ) install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} )GNUInstallDirs模块提供了CMAKE_INSTALL_LIBDIR、CMAKE_INSTALL_INCLUDEDIR等变量,它们会根据平台自动选择lib还是lib64、include还是其他路径。不要硬编码lib和include,否则在有些发行版上会装错位置。EXPORT MyProjectTargets这个参数很关键,它把安装的目标记录到一个导出集合里,后面用来生成配置文件。INCLUDES DESTINATION告诉CMake,安装后的头文件在哪个目录下,这样使用方通过target_link_libraries链接时能自动找到头文件。6.2 导出配置文件:让find_package能找到你的库安装完库之后,别人怎么用find_package找到它?需要生成两个文件:MyProjectConfig.cmake和MyProjectConfigVersion.cmake。install(EXPORT MyProjectTargets FILE MyProjectTargets.cmake NAMESPACE MyProject:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyProject ) include(CMakePackageConfigHelpers) configure_package_config_file( "${CMAKE_CURRENT_SOURCE_DIR}/cmake/MyProjectConfig.cmake.in" "${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfig.cmake" INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyProject ) write_basic_package_version_file( "${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfigVersion.cmake" VERSION ${PROJECT_VERSION} COMPATIBILITY SameMajorVersion ) install(FILES "${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfig.cmake" "${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfigVersion.cmake" DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyProject )MyProjectConfig.cmake.in的内容很简单:@PACKAGE_INIT@ include("${CMAKE_CURRENT_LIST_DIR}/MyProjectTargets.cmake") check_required_components(MyProject)@PACKAGE_INIT@会被替换成一些初始化代码,check_required_components检查使用方是否请求了不存在的组件。write_basic_package_version_file生成版本文件,SameMajorVersion表示主版本号相同就兼容,这是比较常用的策略。做完这些之后,别人安装你的库到系统路径,然后find_package(MyProject REQUIRED)就能找到,并且可以用MyProject::core这样的命名空间目标来链接。整个流程看起来步骤多,但都是模板化的,写一次之后复制到其他项目改改名字就行。6.3 安装时的常见坑与检查清单安装环节有几个容易忽略的细节。第一,install(TARGETS)里的INCLUDES DESTINATION必须写,否则使用方链接后找不到头文件。第二,install(DIRECTORY include/ ...)末尾的斜杠很重要,include/表示复制目录内容,include表示复制目录本身。第三,如果库有依赖,MyProjectConfig.cmake.in里需要用find_dependency声明:include(CMakeFindDependencyMacro) find_dependency(fmt)这样使用方在find_package(MyProject)时,会自动触发find_package(fmt),不需要手动再找一遍。如果漏了这一步,使用方链接时会报找不到fmt::fmt目标。安装完成后,我习惯用一个简单的测试项目验证:新建一个空目录,写一个最小的CMakeLists.txt,用find_package找到刚安装的库,编译一个调用库函数的程序。如果能跑通,说明安装导出没问题。这个验证步骤花不了几分钟,但能避免把有问题的安装配置交给别人。7. 调试与排错:CMake报错时怎么快速定位7.1 读懂CMake的错误信息CMake的报错信息通常分三段:错误类型、出错位置、具体描述。最常见的错误是“Target not found”和“Cannot find source file”。前者通常是target_link_libraries里写了一个还没定义的目标名,检查add_subdirectory的顺序和add_library/add_executable的拼写。后者是add_library或add_executable里列出的源文件路径不对,注意相对路径是相对于当前CMakeLists.txt所在目录。另一个高频错误是“CMake Error: The current CMakeCache.txt directory ... is different”。这通常是因为把构建目录从一台机器复制到了另一台,或者移动了源码目录。解决办法很简单:删掉构建目录重新配置。我一般会在构建目录名里加上平台和构建类型,比如build-linux-debug、build-win-release,避免混淆。7.2 用message和cmake_print_variables调试当配置逻辑比较复杂时,光看报错信息不够,需要主动打印变量值。message(STATUS "VAR=${VAR}")是最基本的调试手段。CMake 3.15之后还提供了cmake_print_variables:include(CMakePrintHelpers) cmake_print_variables(CMAKE_CXX_COMPILER CMAKE_BUILD_TYPE PROJECT_VERSION)它会以更清晰的格式打印变量名和值。我通常在find_package之后打印一下找到的库路径和版本,确认找到的是预期的那个,而不是系统里另一个同名但版本不同的库。7.3 构建目录的清理策略CMake的构建目录里缓存了大量配置信息,有时候改了CMakeLists.txt但行为没变,就是因为缓存没刷新。最彻底的清理是直接删掉整个构建目录重新来。但每次都全量编译太耗时,可以只删CMakeCache.txt和CMakeFiles/目录,保留编译产物。不过这种做法有时会留下不一致的状态,我一般只在确认是缓存问题时才这么干。更优雅的方式是用cmake --fresh(CMake 3.24+),它会清除缓存但保留构建目录结构。如果版本不够,就手动删CMakeCache.txt。另外,ccache配合CMake能大幅减少重复编译的时间,配置方法是set(CMAKE_CXX_COMPILER_LAUNCHER ccache),放在顶层CMakeLists.txt里。这个对日常开发效率提升很明显,尤其是频繁切换分支的时候。8. 一些让CMakeLists更可维护的实践习惯8.1 变量命名与作用域管理CMake的变量作用域规则和普通编程语言不太一样。在add_subdirectory里设置的变量,默认只在当前目录和子目录可见,父目录看不到。这个特性可以用来隔离配置,但也容易造成困惑。我的习惯是:全局变量用CMAKE_前缀或项目名前缀,局部变量用_前缀或全小写,一眼就能看出作用范围。比如MYPROJECT_BUILD_TESTS是全局选项,_core_sources是core目录内部的源文件列表。避免用SRCS、LIBS这种太通用的名字,因为不同目录下可能重复定义,导致意外的覆盖。8.2 注释与版本记录CMakeLists.txt里的注释不是给别人看的,是给三个月后的自己看的。我习惯在每个非显而易见的配置旁边写一行注释,说明“为什么这么写”。比如:# 必须用PUBLIC,因为engine.h里包含了fmt的头文件 target_link_libraries(core PUBLIC fmt::fmt)这种注释在排查问题时能省很多时间。另外,如果某个配置是为了绕过某个已知问题,把问题描述和参考链接写在注释里,避免以后有人“优化”掉。8.3 从简单开始,按需演进最后想说的是,不要一开始就追求“完美”的CMake结构。项目初期,一个顶层CMakeLists.txt加几个add_executable完全够用。等到代码量上来了、协作人数多了、需要跨平台了,再逐步拆分目标、引入安装导出。过度设计比设计不足更让人头疼,因为复杂的构建脚本本身就是维护负担。我自己的节奏是:单文件项目就一个CMakeLists.txt;超过五个源文件就按目录拆库;需要给别人用时再加安装导出;有多个可执行文件时引入tests和tools子目录。每一步演进都有明确的触发条件,而不是提前把框架搭好等着填代码。写CMakeLists.txt这件事,说到底是在写一份“构建契约”:它告诉编译器怎么把你的代码变成可执行文件,告诉协作者怎么在你的项目上工作,告诉未来的自己当初为什么这么设计。把这份契约写清楚,后面的事情会顺很多。