简介:面向C++初学者与需要重构构建体系的开发者,这份CMakeLists管理大型工程实例以分层目录组织一个可运行示例,演示最小版本声明、子目录拆分、目标属性设置、外部依赖查找、测试集成与安装配置等关键环节,帮助理解如何用统一构建脚本管理多模块工程并提升跨平台可维护性。压缩包共10个文件,包含5个txt说明与构建配置、3个cpp源文件和2个h头文件,整体约5KB,体量轻量,便于快速解压阅读和按模块对照源码与配置。已有1423人学习下载。通过可直接运行的示例结构,读者能观察公共模块、输入输出模块与顶层构建之间的组织方式,掌握目标编译选项、链接库、条件选项和测试安装的基本写法,也可将其作为小型项目的CMake骨架,在实践中查漏补缺并逐步扩展。
1. 从一台干净的机器开始:大型工程的 CMakeLists 为什么先卡在版本上
接手一个几十个子目录的 C++ 大型工程,第一件事往往不是读代码,而是把 CMakeLists 这套构建约定在本机跑起来。我见过太多次,有人把源码拉下来就直接cmake .,结果要么卡在CMake 3.20 or higher is required,要么让 CMake 默认挑了一个没人维护的生成器,编到一半内存爆掉、链接阶段满屏 undefined reference。大型工程的 CMakeLists 不是一份配置文件,它规定了编译器、语言标准、依赖来源、产物布局,是一条从源码到可执行文件的完整流程。装不对,后面每改一行代码都得先为构建环境做无关的修补。这套搭建流程适合手上有多个模块、多个第三方依赖的 C++ 项目的人,也适合刚接手别人工程、想让构建过程重新变得可以重复操作的人。
2. 装工具链:CMake 3.20 与 C++ 编译器的最小可用组合
版本这块先定一个基线,后面所有写法都围绕它展开。cmake_minimum_required(VERSION 3.20)是我给大型工程划的线:C++23 标准支持从 3.20 开始,ctest --test-dir也是 3.20 才有的,cmake -S -B分离源码目录和构建目录从 3.13 就稳定了。再往上,3.23 加了FILE_SET HEADERS,头文件安装不用手写 install(FILES);3.24 加了cmake --fresh,清理缓存不用再rm -rf build;3.25 才允许target_link_libraries里写SYSTEM。我的习惯是本地和 CI 都锁在 3.24 及以上,3.20 只作为最低兼容线写进cmake_minimum_required,这样别人用老版本也不会直接配置失败。
2.1 Linux 上三条安装路径的取舍
发行版仓库里的 cmake 通常落后两三年,Ubuntu 22.04 自带的是 3.22,跑得动大部分工程,但一旦工程里用了FILE_SET就废了。三条路径我按场景分:临时验证用 apt 够用;长期开发用官方二进制包解压到/opt;要改 CMake 自身行为或者需要特定编译选项时,才源码编译。
# 路径一:发行版仓库,胜在无脑,版本最老 sudo apt update && sudo apt install -y cmake ninja-build build-essential # 路径二:官方二进制包,解压到 /opt,不动系统目录 tar -xzf cmake-3.28.3-linux-x86_64.tar.gz sudo mv cmake-3.28.3-linux-x86_64 /opt/cmake-3.28.3 # 用 update-alternatives 管版本,优先级数字大的赢 sudo update-alternatives --install /usr/local/bin/cmake cmake /opt/cmake-3.28.3/bin/cmake 100 sudo update-alternatives --install /usr/local/bin/ctest ctest /opt/cmake-3.28.3/bin/ctest 100 # 路径三:源码编译,bootstrap 阶段要带 OpenSSL,否则 https 拉依赖会失败 ./bootstrap --parallel=8 -- -DCMAKE_USE_OPENSSL=ON make -j8 && sudo make installupdate-alternatives --install的最后一个数字是优先级,不是版本号,写错了会出现「我明明装了 3.28,cmake --version还是 3.22」这种情况。ctest和cpack要单独注册,因为它们是被 CMake 一起安装的三个独立可执行文件,只链cmake会导致ctest --test-dir报参数不认识。源码编译这条路径我一般不推荐给团队用,bootstrap 阶段依赖多、耗时长,二进制包能解决 95% 的场景。
提示:装完之后
hash -r清一下 shell 的命令缓存,bash 会记住旧的可执行文件路径,不清的话新装的 cmake 要重开终端才生效。
2.2 Windows 与 macOS:编译器和生成器要配对
Windows 上的问题从来不是 CMake 本身装不上,而是编译器找不到。装 VS Build Tools 时只勾「使用 C++ 的桌面开发」,CMake 装官方安装包并勾选「Add CMake to the system PATH」,生成器选 Ninja 或者 Visual Studio 17 2022。用 Ninja 就必须在 Developer Command Prompt 里启动,或者在普通终端里先执行vcvars64.bat,否则配置阶段会直接报CMAKE_CXX_COMPILER not set。
# winget 装 CMake 和 Ninja,版本以源里为准 winget install --id Kitware.CMake -e winget install --id Ninja-build.Ninja -e # 关键一步:让当前终端拿到 MSVC 环境变量 & "C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat" cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=DebugmacOS 就三步:xcode-select --install装命令行工具拿 clang,brew install cmake ninja ccache,然后把CMAKE_CXX_COMPILER_LAUNCHER=ccache写进预设。Homebrew 装的 cmake 版本一般很新,反而要注意别在脚本里写死 3.28 这种具体小版本,团队里用 Intel 芯片和 Apple Silicon 的人拿到的路径不一样,写死路径的脚本换台机器就崩。
2.3 装完先别急着构建:四条自检命令
新手最容易跳过的是自检,直接进配置阶段,然后被一堆无关的报错带偏。我固定跑四条:
# 1) 版本对不对 cmake --version # 2) 系统里到底有几个 cmake,PATH 顺序对不对 which -a cmake # 3) 生成器列表里有没有我打算用的那个 cmake --help | sed -n '/Generators/,/^$/p' # 4) 工具链能力探测,看它认不认我选的编译器和架构 cmake -E capabilities | head -40which -a cmake这条最值得养成习惯,它把 PATH 里所有同名可执行文件按顺序列出来,第一个就是实际生效的。我踩过一次坑:/usr/bin/cmake是 3.22,/usr/local/bin/cmake是 3.28,而 PATH 里/usr/bin在前,结果工程配置文件里写了新语法,本机死活配置不过,查了四十分钟才发现是这个问题。cmake -E capabilities输出的是 JSON,里面有generators、fileApi、version几个字段,CI 镜像里想知道这个 cmake 支不支持 Ninja Multi-Config,看这个比翻文档快。
3. 顶层 CMakeLists 骨架:把大型工程的目录边界先划清
大型工程崩掉,十有八九不是某个模块写错,而是顶层骨架没立住:源文件用 GLOB 全量收集、产物散落在源码树里、构建类型靠命令行临时传、测试开关写在某个子目录的 CMakeLists 里。这些问题的共同点是「第一次配置能过,第二次增量构建就出问题」。骨架这活儿只能一开始就做对,后面再补的代价是重排目录结构。
3.1 一份可以直接抄的顶层骨架
下面这份顶层CMakeLists.txt是我在多个工程里收敛出来的,屏蔽了项目名,其余部分可以直接用:
cmake_minimum_required(VERSION 3.20) project(BigProj VERSION 1.2.0 DESCRIPTION "多模块 C++ 工程骨架" LANGUAGES CXX) # 单配置生成器(Makefile / Ninja)必须显式指定构建类型 # 多配置生成器(VS / Xcode / Ninja Multi-Config)下 CMAKE_BUILD_TYPE 为空是正常的 if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "构建类型" FORCE) set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS Debug Release RelWithDebInfo MinSizeRel) endif() set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 编译器不支持就报错,别偷偷降级 set(CMAKE_CXX_EXTENSIONS OFF) # 关掉 gnu++17 扩展,保证跨编译器一致 # 生成 compile_commands.json,clangd / CLion 靠它做跳转,不开后面很麻烦 set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # 产物集中到 build/bin 和 build/lib,别污染源码目录 set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) option(BIGPROJ_BUILD_TESTS "构建单元测试" ON) option(BIGPROJ_ENABLE_WERROR "把编译警告当错误" OFF) add_subdirectory(libs) add_subdirectory(src) if(BIGPROJ_BUILD_TESTS) enable_testing() add_subdirectory(tests) endif()几个参数值得单独说。CMAKE_CXX_EXTENSIONS OFF很多人不开,结果是 GCC 下能编过、MSVC 下报一片,原因就是 gnu++17 里那些非标准扩展;CMAKE_EXPORT_COMPILE_COMMANDS只对 Makefile 和 Ninja 生成器有效,VS 生成器下不产出这个文件,所以在 Windows 上要给 clangd 用,得配 Ninja;CMAKE_RUNTIME_OUTPUT_DIRECTORY在多配置生成器下会被自动追加/Debug、/Release子目录,这点别以为是写错了。
3.2 add_subdirectory 的边界:什么时候该拆,什么时候不该拆
拆目录的判断标准只有一条:能不能用一个 target 表达。一个子目录对应一个add_library或add_executable,这个子目录就该独立;反过来,五个子目录互相 include 头文件、共享同一批源文件,硬拆只会让target_include_directories越写越长。
# src/core/CMakeLists.txt:显式列源文件,别用 GLOB add_library(bigproj_core STATIC engine.cpp scheduler.cpp pipeline.cpp ) target_include_directories(bigproj_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include # 别人用我也需要 PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src # 只有我自己用 ) target_link_libraries(bigproj_core PUBLIC fmt::fmt PRIVATE Threads::Threads ) # src/app/CMakeLists.txt:可执行文件只负责组装 add_executable(bigproj_app main.cpp) target_link_libraries(bigproj_app PRIVATE bigproj_core)file(GLOB)收集源文件的坑是经典问题:新增一个.cpp,构建系统不会重新跑配置阶段,新文件直接被忽略,得手动重新配置才生效。3.12 之后可以用file(GLOB_RECURSE ... CONFIGURE_DEPENDS)缓解,但它会在每次构建时做一次目录扫描,大型工程下拖慢增量构建很明显。我的做法是:业务模块全部显式列文件,只有在生成代码这类目录数量会变的场景才用CONFIGURE_DEPENDS。
3.3 option 与 cache 变量:把构建开关收进一个口子
大型工程里最怕的是「某人本地加了个开关没提交」。option()定义的变量默认会进 CMakeCache.txt,值会持久化,第二次配置时不传-D也不会丢失,这正是我们要的效果:开关集中声明,通过预设传值。
# CMakePresets.json(version 2 对应 CMake 3.20,升级到 3 需要 3.21+) { "version": 2, "configurePresets": [ { "name": "dev-ninja", "generator": "Ninja", "binaryDir": "${sourceDir}/build/dev", "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo", "BIGPROJ_BUILD_TESTS": "ON", "BIGPROJ_ENABLE_WERROR": "OFF", "CMAKE_CXX_COMPILER_LAUNCHER": "ccache" } } ], "buildPresets": [ { "name": "dev-ninja", "configurePreset": "dev-ninja" } ] }用起来就一句cmake --preset dev-ninja && cmake --build --preset dev-ninja -j 8。预设的好处是把「我本地是这么编的」变成可提交的文件,新人拉下来不用问人就知道该跑哪条命令。注意CMAKE_CXX_COMPILER_LAUNCHER只在 3.21 之前叫这个名字的生成器生效范围有限,且没有装 ccache 的机器上会导致构建直接失败,所以我一般把它单独放一个ci-ccache预设,不进默认预设。
4. 依赖与编译参数:find_package、FetchContent 和 PUBLIC/PRIVATE
依赖管理是大型工程从「能编」到「能长期编」的分水岭。依赖来源含糊,三个月后换台机器就编不过;编译参数传递写反,改一个头文件能触发全量重编。这一章的两个主题,一个管外部,一个管内部。
4.1 find_package 的三种命中方式
find_package有两种模式:MODULE 模式找FindXXX.cmake,CONFIG 模式找XXXConfig.cmake或xxx-config.cmake。默认是先 MODULE 后 CONFIG,行为不确定,所以我一般显式写成 CONFIG,失败信息更准确。
# 优先使用 CONFIG 模式,版本和组件都写清楚 find_package(OpenSSL 3.0 REQUIRED COMPONENTS Crypto SSL) find_package(fmt 9 REQUIRED CONFIG) find_package(Threads REQUIRED) # 找不到时打印实际搜索过的路径,排错用 # 命令行加:-DCMAKE_FIND_DEBUG_MODE=ON 或 cmake --debug-findCONFIG 模式的搜索顺序是:<PackageName>_DIR指定的目录 →CMAKE_PREFIX_PATH下各目录的lib/cmake/<name>→ 系统默认路径。实际排错时最有效的参数是-Dfmt_DIR=/opt/fmt/lib/cmake/fmt,直接告诉它配置文件在哪,别去猜。
如果是 vcpkg 或 conan 管的依赖,用工具链文件注入,不要手动改 CMAKE_PREFIX_PATH:
# vcpkg:工具链文件必须在第一次配置时传,中途改要清缓存 cmake -S . -B build -G Ninja \ -DCMAKE_TOOLCHAIN_FILE=/opt/vcpkg/scripts/buildsystems/vcpkg.cmake \ -DVCPKG_TARGET_TRIPLET=x64-linuxCMAKE_TOOLCHAIN_FILE只在第一次配置时生效,已经生成过 CMakeCache.txt 的目录再传会被忽略,这也是很多人「传了工具链却没用」的原因。
4.2 FetchContent 锁版本与离线兜底
第三方库没进系统包管理器时,FetchContent比手工下载解压干净得多。声明时要写死 tag 或 commit。
include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY <你的内网镜像地址> GIT_TAG v1.14.0 # 必须是 tag 或 commit,写 main 等于没锁 GIT_SHALLOW TRUE # 只拉一个 commit,省时间 EXCLUDE_FROM_ALL # 不把它的 install 目标带进主工程 ) set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) FetchContent_MakeAvailable(googletest)GIT_SHALLOW TRUE在指定 tag 时能明显减少拉取体积,但如果 tag 指向的不是分支顶端,某些旧版本 CMake 会退回全量拉取,这个现象不是报错,只是慢,容易被误判成网络问题。离线构建是我最看重的一点:FetchContent支持用FETCHCONTENT_SOURCE_DIR_<大写名字>指向本地已有的源码目录,CI 里把依赖提前下好,构建时完全断网也能过。
# 完全离线:指向预先下载好的源码,禁止任何网络请求 cmake -S . -B build \ -DFETCHCONTENT_SOURCE_DIR_GOOGLETEST=/opt/deps/googletest \ -DFETCHCONTENT_FULLY_DISCONNECTED=ON3.24 之后还可以用FIND_PACKAGE_ARGS,让FetchContent先尝试find_package,系统里装了就复用,没有才下载。这个组合在有内网包管理的团队里特别合适。
4.3 PUBLIC / PRIVATE / INTERFACE 传什么
这三个关键字决定依赖怎么传递给下游 target,写反了会出现两类症状:链接期找不到符号,或者改一个私有头文件触发整个工程重编。
| 关键字 | 自己编译用 | 下游会继承 | 典型场景 |
|---|---|---|---|
| PRIVATE | 是 | 否 | 内部实现依赖、私有头文件目录、内部告警选项 |
| PUBLIC | 是 | 是 | 公开头文件目录、公开依赖的库 |
| INTERFACE | 否 | 是 | 纯头文件库、纯接口 target |
add_library(bigproj_core STATIC engine.cpp) target_include_directories(bigproj_core PUBLIC include # 使用者 include 时也需要这个路径 PRIVATE src # 只有 engine.cpp 自己用 ) target_link_libraries(bigproj_core PUBLIC fmt::fmt # 公开头文件里出现了 fmt 的类型 PRIVATE Threads::Threads # 线程只在实现里用 ) target_compile_options(bigproj_core PRIVATE $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra -Wpedantic> $<$<CXX_COMPILER_ID:MSVC>:/W4> $<$<CONFIG:Debug>:-O0 -g3> )生成器表达式$<$<CXX_COMPILER_ID:GNU,Clang>:...>的好处是一份 CMakeLists 同时兼容 GCC、Clang 和 MSVC,不用写 if-else 分支。$<$<CONFIG:Debug>:-O0 -g3>依赖构建类型,在多配置生成器下也成立,比if(CMAKE_BUILD_TYPE STREQUAL Debug)可靠得多。
5. 构建翻车排查:五个报错的现象、原因和救法
5.1 版本够了却报版本不够
现象:cmake_minimum_required(VERSION 3.24)的工程在本机报CMake 3.24 or higher is required. You are running version 3.22.1,但which cmake指向的明明是 3.24。
原因:PATH 里存在多个 cmake,shell 缓存或者/usr/bin优先级更高,实际执行的不是你以为的那个。
解决:hash -r清缓存,which -a cmake看全部路径,用update-alternatives --config cmake手动切换,或者把/usr/local/bin提到 PATH 最前面。CI 镜像里更稳的做法是容器里只保留一份。
5.2 改了 CMakeLists 但构建行为没变
现象:加了target_compile_options里的-Werror,重新cmake --build完全没反应。
原因:要么是没重新跑配置阶段(直接 build 不会重读 CMakeLists 逻辑的某些修改),要么是构建类型没传对——多配置生成器下-DCMAKE_BUILD_TYPE=Debug根本不生效。
解决:单配置生成器重新跑cmake -S . -B build;多配置生成器改用cmake --build build --config Debug;彻底没头绪就cmake --fresh -S . -B build(3.24+),或者直接删构建目录,但删之前先看一眼 CMakeCache.txt 里_DIR类变量能不能复用。
5.3 Could not find a package configuration file
现象:Could not find a package configuration file provided by "fmt" with any of the following names: fmtConfig.cmake, fmt-config.cmake。
原因:库装了,但装在非标准前缀(比如/opt/fmt),CMake 默认不搜;或者只装了运行时包,没装 dev 包。
解决:先确认配置文件真的存在,find / -name "fmtConfig.cmake" 2>/dev/null;然后-Dfmt_DIR=<那个目录>或-DCMAKE_PREFIX_PATH=/opt/fmt;Debian 系确认libfmt-dev装了,-dev包才带 cmake 配置文件。用cmake --debug-find能看到完整搜索路径。
5.4 链接期 undefined reference 与头文件找不到
现象:编译通过,链接时报某个符号 undefined;或者下游 target 报fatal error: xxx.h: No such file or directory。
原因:target_include_directories写了 PRIVATE,下游拿不到头文件路径;target_link_libraries里静态库顺序不对,或者依赖该写 PUBLIC 的写成了 PRIVATE。
解决:把公开头文件目录改成 PUBLIC;依赖出现在自己公开头文件里的,链接关系必须是 PUBLIC。静态库顺序问题在改用 target 之后基本不会出现,因为 CMake 会自己算依赖拓扑,还在手写-l顺序的工程该迁移了。另外file(GLOB)加进来但没加进 target 的源文件也会产生这类报错,cmake --graphviz=deps.dot输出来看一眼依赖图比读日志快。
5.5 CI 上 OOM 或者编译时间失控
现象:本地-j 8编得好好的,CI 上-j全开直接被 OOM Killer 干掉,构建日志停在某个.cpp编译一半。
原因:cmake --build build -j不带参数会按 CPU 核心数全开,每个编译进程还要吃模板实例化的内存,8 核 16G 的机器很容易被打满。
解决:-j显式给一个小于核数的值,CI 里按内存算,一条经验公式是每 GB 内存最多给 1 个编译进程;再加上 ccache,把CMAKE_CXX_COMPILER_LAUNCHER=ccache写进 CI 预设,命中率上去之后编译时间能降一大半。
注意:排查阶段优先用
cmake --trace-expand把所有变量展开后打印执行到的行,输出很长但能精准定位是哪一行配置出的问题,比在message()里到处埋点高效。
6. 装完怎么验证:一条命令链从配置走到 ctest
验证的核心思路是:能不能在一台干净机器上,不靠手工干预,从零跑出一条完整的命令链。我固定跑这四步,任何一步失败都说明环境还没准备好。
# 1) 配置:显式指定生成器、构建类型、安装前缀 cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_INSTALL_PREFIX=/tmp/stage \ -DBIGPROJ_BUILD_TESTS=ON # 2) 构建:-j 按内存给,别直接全开 cmake --build build -j 6 # 3) 测试:失败用例直接打印输出,不用再跑一遍 ctest --test-dir build --output-on-failure # 4) 安装:验证 install(TARGETS ...) 的目标和目录都写对了 cmake --install build第一步能过,说明工具链、生成器、依赖查找都没问题;第二步能过,说明 target 的源文件列表、include 目录、链接关系是对的;第三步能过,说明enable_testing()和add_test()的位置没写错——很多工程把enable_testing()写在顶层却把add_test放在子目录的if分支里,ctest -N会显示 0 个测试却不报错。第四步最容易暴露问题,install(TARGETS ...)里RUNTIME DESTINATION、LIBRARY DESTINATION、ARCHIVE DESTINATION少写一个,Linux 上可能看不出,Windows 上运行时 DLL 就找不到。
再补一个我每次都会做的小验证,能提前发现一堆隐性配置问题:
# 看构建系统实际生成了哪些 target,避免名字冲突或漏加 cmake --build build --target help | head -30 # 确认编译数据库生成了,IDE 能不能正常跳转靠它 test -f build/compile_commands.json && echo "compile db ok" # 列出注册的测试,数量对不上就是 add_test 位置有问题 ctest --test-dir build -N| 检查项 | 合格标准 | 不合格时的典型表现 |
|---|---|---|
| 配置阶段 | 无 warning,缓存变量与预设一致 | 反复提示重新配置、构建类型为空 |
| 构建阶段 | 增量构建只重编改动文件 | 改一个头文件触发全量重编 |
| 测试阶段 | 测试数量与用例数一致 | ctest -N显示 0 tests |
| 安装阶段 | /tmp/stage下目录结构完整 | 缺 include 目录或运行库找不到 |
从那以后我每次接手一个新工程,都强制先跑一遍cmake --fresh配置加ctest,确认这条链在干净机器上能走通,才动手改第一行业务代码——这套习惯帮我提前拦下过好几次「本地能编、CI 编不过」的问题。上面这套目录骨架、预设文件和排查清单,在配套的 CMakeLists 大型工程实例里都能找到对应文件,照着顺序走一遍就够了。希望帮到你。
本文还有配套的精品资源,点击获取