简介:面向C++开发者与初学者的CMakeLists大型工程管理示例,聚焦多目录项目如何统一配置、组织源码、链接依赖并生成跨平台构建文件,适合具备C++基础、希望从单文件编译过渡到工程化构建的读者。压缩包共10个文件,以5个txt构建脚本与说明、3个cpp源文件和2个h头文件为主,借助common、io等模块划分展示子目录CMakeLists的协作方式,整体约5KB。已有1423人学习下载。示例围绕项目初始化、源文件收集、目标属性设置、外部依赖查找、CTest测试集成、install安装规则和option可配置开关展开,读者可据此理解大型工程中构建脚本的层次划分、目标组织与可维护性设计,并参考多模块目录结构改造自己的C++项目,减少在不同平台上重复配置构建环境的时间,也可作为课程设计或小型库拆分练习的参考模板。
1. 一份顶层 CMakeLists 只写 40 行,却决定了后面 800 行怎么烂
某公司的跨平台 C++ 客户端,最早的构建脚本是一份 800 多行的 CMakeLists.txt:所有.cpp塞进一个add_executable,编译选项用全局set一把梭,第三方库路径写死绝对路径。结果是改一个头文件全量重编,加一个模块要在三个平台的脚本里各改一遍,新人 clone 下来第一句问的是「先装什么」。这份 CMakeLists 管理大型工程实例要解决的正是这件事:把一棵源码树拆成一棵目标树,每个模块一个库、一份 CMakeLists.txt,编译选项、头文件路径、依赖关系全部挂在 target 上,而不是散落在全局变量里。它适合两类人——手上工程已经过了「三五个文件」阶段、编译时间开始让人摸鱼的 C++ 开发者,以及想搞明白现代 CMake 的 target 体系怎么落到多模块、多平台、多配置构建里的人。
2. 工程骨架与顶层 CMakeLists:三棵树分开,配置收口
2.1 目录分层的底线:源码树、构建树、安装树互不污染
大型工程翻车,十有八九从目录结构开始。我见过最典型的一种:在源码根目录直接cmake .,生成的CMakeCache.txt、CMakeFiles/和.cpp混在一起,后来想换编译器,缓存里的旧路径怎么都清不干净,最后只能把整个目录重新拷一遍。这不是玄学,是 CMake 的缓存机制决定的——CMakeCache.txt里存的是绝对路径和上一次探测到的编译器信息,换了环境不清缓存,它就会拿旧结果继续用。
我一般会把目录压成三层,每层职责单一:
atlas/ ├── CMakeLists.txt # 顶层,只做全局决策 ├── CMakePresets.json # 把「怎么编」写进仓库 ├── cmake/ │ ├── AtlasConfig.cmake.in # 导出给外部 find_package 用 │ └── CompilerWarnings.cmake # 编译警告选项,被 interface target 引用 ├── src/ │ ├── CMakeLists.txt # 只负责 add_subdirectory │ ├── core/ │ │ ├── CMakeLists.txt │ │ ├── include/atlas/core/ # 对外头文件 │ │ └── src/ # 私有实现 │ ├── net/ │ └── app/ ├── tests/ └── third_party/关键约定有三个。第一,include/和src/在模块内部再分一层,公开头文件放include/<项目名>/<模块名>/,这样安装出去之后头文件引用是#include <atlas/core/buffer.h>,不会和系统里别的buffer.h撞名。第二,构建目录永远在源码树之外,build/debug、build/release各管各的,切换配置不需要删缓存。第三,cmake/目录放所有被include()的辅助脚本和配置模板,顶层和子目录都能引用,路径统一用${CMAKE_CURRENT_SOURCE_DIR}或${PROJECT_SOURCE_DIR}拼接,不写相对路径../..。
这套结构带来的直接好处是增量构建可控。模块之间的依赖在 CMake 的 target 图里是显式的,改了core只会重编依赖它的目标,而不会像单目标工程那样整棵树重来。工程一旦上到十万行量级,这个差别就是「泡杯咖啡」和「摸鱼半小时」的区别。
2.2 顶层 CMakeLists.txt 逐段拆解
顶层文件我建议控制在 40 到 60 行,只放三类东西:版本与工程声明、全局默认值、子目录入口。凡是能挂到 target 上的,一律不在顶层写。
cmake_minimum_required(VERSION 3.20) project(Atlas VERSION 1.4.0 DESCRIPTION "跨平台客户端" LANGUAGES CXX) # 全局默认值:不写 FORCE,允许子目录或命令行覆盖 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) # 三处产物统一落到构建树,避免每个子目录各生成一个 bin/ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) # 单配置生成器(Ninja、Makefiles)才需要默认 build type if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Debug CACHE STRING "构建类型" FORCE) endif() # 给 clangd / clang-tidy 生成 compile_commands.json set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # 硬性拦截源码内构建 if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR) message(FATAL_ERROR "禁止在源码目录内构建:请 mkdir build && cd build") endif() add_subdirectory(src) add_subdirectory(tests)逐段说清楚。cmake_minimum_required(VERSION 3.20)不是随便填的,3.20 之后cmake_path、$<TARGET_RUNTIME_DLLS>和CMakePresets的 v3 才稳定可用,低于这个版本,后面那些写法要么报错要么行为不一致。project()里带VERSION的好处是子目录可以直接用${PROJECT_VERSION}写进版本号和安装路径,不用手动同步。LANGUAGES CXX显式声明只启用 C++,能省掉一段 C 编译器探测时间,也能避免某些环境里 C 编译器缺失导致的配置失败。
CMAKE_CXX_EXTENSIONS OFF这一行容易被忽略,但它是可移植性的分水岭。默认开着-std=gnu++17,GNU 扩展语法能编过,换到 MSVC 或 Clang 严格模式就炸。关掉之后统一用-std=c++17,跨平台踩坑能少一半。
CMAKE_ARCHIVE_OUTPUT_DIRECTORY三个变量解决的是产物散落问题。默认情况下,每个子目录都会在自己的构建目录下生成libxxx.a或xxx.exe,跑起来之后你根本找不到可执行文件在哪。统一到${CMAKE_BINARY_DIR}/lib和bin之后,脚本和 CI 里的路径就是固定的。
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)这个判断是血泪经验。Ninja 和 Makefiles 属于单配置生成器,没有 build type 概念,不设默认值就是空字符串,编译出来既没优化也没调试信息。而 Visual Studio 和 Xcode 是多配置生成器,CMAKE_BUILD_TYPE在它们那里根本不生效,设了反而误导人。加NOT CMAKE_CONFIGURATION_TYPES就是告诉 CMake:只有在单配置生成器下才补默认值。
注意:
CMAKE_BUILD_TYPE不要用set(... FORCE)写死在顶层。一旦写死,命令行传-DCMAKE_BUILD_TYPE=Release会被覆盖,CI 想切发布构建就切不动。
CMAKE_EXPORT_COMPILE_COMMANDS ON会生成compile_commands.json,clangd 靠它做补全和跳转,clang-tidy 靠它做静态检查。没有这个文件,编辑器里满屏红线,很多时候不是代码有问题,是它不知道你的 include 路径。
最后那个源码内构建的拦截,看着像多此一举,实际救过很多次。有人图省事在根目录敲cmake .,跑完发现源码目录里多了一堆东西,git status一片红,再想清干净就得对着.gitignore一个个删。
2.3 编译选项收口到一个 interface target
全局set(CMAKE_CXX_FLAGS "-Wall -Wextra")是最常见的错误示范。它会把警告选项强行塞给所有目标,包括第三方源码,结果编译日志里几千条来自third_party的警告,真正的业务代码警告被淹了。正确做法是定义一个 INTERFACE 库,谁想用谁链接。
# cmake/CompilerWarnings.cmake add_library(atlas_warnings INTERFACE) target_compile_options(atlas_warnings INTERFACE $<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>: -Wall -Wextra -Wpedantic -Wshadow -Wnon-virtual-dtor -Wcast-align -Wunused> $<$<CXX_COMPILER_ID:MSVC>: /W4 /permissive- /Zc:__cplusplus>) # 第三方代码不想要警告,可以链一个「反警告」目标把它压掉 add_library(atlas_warnings_off INTERFACE) target_compile_options(atlas_warnings_off INTERFACE $<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-w> $<$<CXX_COMPILER_ID:MSVC>:/w>)这里用了生成器表达式$<$<CXX_COMPILER_ID:...>:...>,含义是:只有当编译器 ID 匹配时才展开后面的选项列表。这样一份脚本在 GCC、Clang、MSVC 下都能用,不需要在顶层写if(WIN32)分支。/Zc:__cplusplus是 MSVC 的补丁,不打开的话__cplusplus宏永远返回199711L,用if constexpr做特性检测会直接判错。
INTERFACE库的特殊之处在于它不产生任何编译产物,只携带「使用要求」。链接它的目标会继承它的target_compile_options、target_compile_definitions、target_include_directories。这种写法把「编译选项」当成一种可以传递的依赖,而不是全局状态,模块可以按需选择开不开警告,第三方目录可以单独关掉。
-Wshadow和-Wnon-virtual-dtor这两个选项值得单独说。前者抓变量遮蔽,大型工程里for (auto& item : items)嵌套两层很容易写出同名变量,编译器不报,运行时逻辑悄悄错。后者抓「基类有虚函数但析构函数不是虚的」,这个错误在跨模块传unique_ptr时会导致析构不完整,属于典型的「编译通过、运行崩溃、查一天」的问题。
3. 模块化目标设计:一个模块一个库,依赖显式传递
3.1 从 add_executable 到 add_library
单体工程的写法是把所有.cpp列进一个add_executable,模块化改造的第一步就是把它拆开。拆分粒度不用太细,我一般按「职责边界」切:能被单独测试的、有独立头文件集合的、编译时间占比高的,各自成库。
# src/net/CMakeLists.txt add_library(atlas_net STATIC src/socket.cpp src/reactor.cpp src/endpoint.cpp) target_include_directories(atlas_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(atlas_net PUBLIC atlas_core PRIVATE atlas_log Threads::Threads) target_compile_definitions(atlas_net PRIVATE ATLAS_NET_BUILDING) target_link_libraries(atlas_net PRIVATE atlas_warnings)add_library(... STATIC ...)里的源文件是显式列出来的,不用file(GLOB ...)。这是刻意的:GLOB 在配置阶段求值一次,之后新增文件不会触发重新配置,你必须手动重跑 cmake 才能发现新文件没编进去。团队里一旦有人踩过这个坑,就会理解为什么老手看到file(GLOB就皱眉。如果实在嫌列文件烦,file(GLOB ... CONFIGURE_DEPENDS)能用,但它每次构建都要扫目录,工程大了会拖慢构建启动,我一般只在原型阶段用。
target_include_directories里的PUBLIC和PRIVATE是这套体系的核心。PUBLIC表示这个路径既用于编译本目标,也传递给链接本目标的下游;PRIVATE只用于本目标。include/目录放的是对外头文件,所以是PUBLIC;src/里放的是内部实现头文件,下游不该看到,所以是PRIVATE。这个区分做对了,下游就不可能#include到你的内部实现,模块边界是编译器帮你守的,不是靠 code review。
target_link_libraries里PUBLIC atlas_core的含义是:atlas_net的公开头文件里用到了atlas_core的类型,所以任何链接atlas_net的目标也必须链接atlas_core。PRIVATE atlas_log则是纯内部使用,日志只在.cpp里出现,下游不用管。Threads::Threads是 CMake 内置的导入目标,比手动写-pthread靠谱,它会根据平台自动展开成正确的链接选项,Windows 上就是空的。
target_compile_definitions(... PRIVATE ATLAS_NET_BUILDING)配合头文件里的导出宏使用,Windows 下动态库需要__declspec(dllexport)/__declspec(dllimport)区分编译侧和消费侧,这个宏就是开关。
3.2 PUBLIC / PRIVATE / INTERFACE 到底怎么选
这三个关键字是初学者最容易用反的地方,用反的后果不是编译报错,而是「本地能编、下游编不过」或者「头文件泄漏」。我总结了一条判断规则,实测够用:
| 场景 | 关键字 | 判断依据 |
|---|---|---|
下游#include本目标的头文件时需要这个路径 | PUBLIC | 头文件出现在include/下 |
只有本目标的.cpp需要 | PRIVATE | 实现细节 |
| 本目标自己不用,但下游必须用 | INTERFACE | 纯头文件库或编译选项 |
| 链接的库的类型出现在公开头文件的函数签名里 | PUBLIC | 下游编译时需要它的头文件 |
链接的库只在.cpp内部调用 | PRIVATE | 下游不需要感知 |
举个具体的:atlas_net的公开接口里有一个bool send(const atlas::core::Buffer&),那么atlas_core必须是PUBLIC,因为下游编译atlas_net的头文件时必须能看到Buffer的完整定义(或者至少是前向声明)。反过来,日志模块只在socket.cpp里打日志,公开头文件里一个字都没提,那就应该是PRIVATE。
用反的典型症状是这样的:某个模块把依赖写成了PRIVATE,本地全量构建能过,因为源文件都在一起编;下游单独链接这个库时报fatal error: xxx.h: No such file or directory,或者链接期报undefined reference。这类问题在单仓库里不容易暴露,一旦拆成多个仓库或者对外发 SDK,就集中爆发。
还有一个容易忽略的点:target_link_libraries的顺序在静态链接下是有意义的,但 CMake 会根据 target 图自动处理重复和顺序,前提是你用的是 target 名而不是裸的-lxxx。写成target_link_libraries(app PRIVATE -lssl)就退回到手工时代了,跨平台、依赖传递全丢了。能用find_package拿到的导入目标,一律用导入目标。
3.3 第三方依赖的两种接法:find_package 与 FetchContent
第三方库的处理方式决定了工程的可复现性。我的原则是:系统级、体积大、编译慢的(比如 OpenSSL、Qt)走find_package,由构建环境预装;轻量、纯头文件、版本敏感的(比如 JSON、fmt)走FetchContent,锁定版本随工程一起编。
# 系统预装依赖 find_package(Threads REQUIRED) find_package(fmt 9 REQUIRED) # 源码内置依赖,版本锁死 include(FetchContent) set(FETCHCONTENT_BASE_DIR ${CMAKE_BINARY_DIR}/_deps) FetchContent_Declare( tinyjson GIT_REPOSITORY "${ATLAS_THIRD_PARTY_MIRROR}/tinyjson.git" GIT_TAG "v1.0.0" # 固定 tag,禁止用分支名 GIT_SHALLOW TRUE EXCLUDE_FROM_ALL) FetchContent_MakeAvailable(tinyjson) # 第三方目标统一压掉警告 target_link_libraries(tinyjson INTERFACE atlas_warnings_off)find_package(fmt 9 REQUIRED)里的9是最低版本要求,REQUIRED表示找不到就直接配置失败,而不是悄悄跳过、等到链接期才报错。配置阶段失败比链接阶段失败好排查十倍,这是「尽早失败」原则在构建系统里的体现。
FetchContent_Declare的GIT_TAG必须是 tag 或 commit hash,不能是main。用分支名的后果是:今天编过,明天上游推了个提交,同样的代码编不过了,而且你完全不知道为什么。GIT_SHALLOW TRUE只拉最近一次提交,能显著减少首次配置时间。EXCLUDE_FROM_ALL表示这些第三方目标不参与默认构建,只有被链接时才编,能省掉一堆用不上的子项目。
ATLAS_THIRD_PARTY_MIRROR这个变量不在脚本里写死,而是从 preset 或命令行传入。这样公司内网和外部开发者的仓库地址可以不同,脚本本身不用改。target_link_libraries(tinyjson INTERFACE atlas_warnings_off)这一行是给第三方目标「反向注入」警告关闭选项——因为tinyjson是 INTERFACE 库,往里链一个 INTERFACE 目标,效果就是所有使用tinyjson的地方都继承-w。这招对付那些警告满天飞的第三方头文件特别管用。
4. 构建产物、安装导出与预设:让别人也能 find_package 你的工程
4.1 库类型选择与产物目录
静态库还是动态库,不是拍脑袋决定的。工程内部模块之间,我默认用STATIC:链接期就能发现符号缺失,运行时没有 DLL 搜索路径的破事,调试也简单。只有两种情况用SHARED:一是需要热更新或者插件机制,二是多个进程要共享同一份状态。
# 由顶层选项控制,默认静态 option(ATLAS_BUILD_SHARED "构建动态库" OFF) if(ATLAS_BUILD_SHARED) set(ATLAS_LIB_TYPE SHARED) else() set(ATLAS_LIB_TYPE STATIC) endif() add_library(atlas_core ${ATLAS_LIB_TYPE} src/buffer.cpp src/string_util.cpp) # 动态库需要导出符号 if(ATLAS_BUILD_SHARED) target_compile_definitions(atlas_core PUBLIC ATLAS_CORE_SHARED PRIVATE ATLAS_CORE_BUILDING) endif()option()定义的是布尔缓存变量,用户可以在命令行用-DATLAS_BUILD_SHARED=ON覆盖,也可以在 CMake GUI 里勾选。ATLAS_LIB_TYPE是个中间变量,避免在多个add_library里重复写if。
导出宏这块要说明白:ATLAS_CORE_BUILDING只在编译atlas_core自己时定义,头文件里据此展开成__declspec(dllexport);ATLAS_CORE_SHARED是PUBLIC,会传递给所有链接它的目标,头文件据此展开成__declspec(dllimport)。GCC/Clang 下这两个宏展开为空,所以不影响 Linux 构建。
产物目录在顶层已经统一到build/lib和build/bin,但多配置生成器需要额外处理。Visual Studio 会把 Debug 和 Release 的产物放在不同子目录,如果想让它们也统一,得用 per-config 变量:
# 多配置生成器下按配置分目录 foreach(cfg DEBUG RELEASE RELWITHDEBINFO MINSIZEREL) string(TOUPPER ${cfg} CFG_UPPER) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_${CFG_UPPER} ${CMAKE_BINARY_DIR}/bin/${cfg}) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_${CFG_UPPER} ${CMAKE_BINARY_DIR}/lib/${cfg}) endforeach()这段循环给CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG、_RELEASE等变量赋值,CMake 在多配置生成器下会优先读这些 per-config 变量。单配置生成器下这段代码不起作用,也不会有副作用,属于可以无脑加上的兼容层。
4.2 install 与 export:把工程变成可被 find_package 的包
工程只给自己用的时候,install可以不写。但只要有一个下游项目要链接你的库,导出配置就必须做,否则对方只能手动写include_directories和link_directories,退回手工时代。
include(GNUInstallDirs) include(CMakePackageConfigHelpers) install(TARGETS atlas_core atlas_net EXPORT AtlasTargets RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) install(DIRECTORY src/core/include/atlas DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) install(EXPORT AtlasTargets FILE AtlasTargets.cmake NAMESPACE Atlas:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/Atlas) configure_package_config_file( ${CMAKE_CURRENT_SOURCE_DIR}/cmake/AtlasConfig.cmake.in ${CMAKE_CURRENT_BINARY_DIR}/AtlasConfig.cmake INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/Atlas) write_basic_package_version_file( ${CMAKE_CURRENT_BINARY_DIR}/AtlasConfigVersion.cmake VERSION ${PROJECT_VERSION} COMPATIBILITY SameMajorVersion) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/AtlasConfig.cmake ${CMAKE_CURRENT_BINARY_DIR}/AtlasConfigVersion.cmake DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/Atlas)install(TARGETS ... EXPORT AtlasTargets)里的EXPORT参数是关键,它把这几个目标登记进一个导出集合,后面install(EXPORT ...)再把这个集合写成 CMake 的导入脚本。NAMESPACE Atlas::意味着下游用的时候写Atlas::atlas_core,带命名空间能有效避免和别的库撞名。
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}这一行容易被漏,它的作用是往导出目标的INTERFACE_INCLUDE_DIRECTORIES里追加安装后的头文件路径。漏了这行,下游find_package(Atlas)成功后编译仍然报找不到头文件。
GNUInstallDirs提供CMAKE_INSTALL_LIBDIR、CMAKE_INSTALL_BINDIR这些平台相关变量。Linux 下是lib,Debian 系 64 位可能是lib/x86_64-linux-gnu,Windows 下是空。自己写死lib在某些发行版上会被打包工具拒收。
write_basic_package_version_file的COMPATIBILITY SameMajorVersion表示主版本号相同就认为兼容,1.4.0 的包能满足find_package(Atlas 1.2)的请求。这个策略适合还在演进中的库;接口稳定的库可以用SameMinorVersion,更严格。
下游使用就变成三行:
find_package(Atlas 1.0 REQUIRED) add_executable(demo main.cpp) target_link_libraries(demo PRIVATE Atlas::atlas_net)Atlas::atlas_net会自动带上头文件路径、编译定义、以及它依赖的Atlas::atlas_core,这就是导出配置的价值。
4.3 CMakePresets.json:把「怎么编」写进仓库
README 里写「先装依赖,然后cmake .. -DCMAKE_BUILD_TYPE=Release -G Ninja」的时代该结束了。这套命令每个人都会抄错一个参数,而且换了机器就失效。CMakePresets.json把配置固化进仓库,新人 clone 下来只需要cmake --preset debug。
{ "version": 6, "configurePresets": [ { "name": "base", "hidden": true, "generator": "Ninja", "binaryDir": "${sourceDir}/build/${presetName}", "cacheVariables": { "CMAKE_CXX_STANDARD": "17", "ATLAS_THIRD_PARTY_MIRROR": "https://your-mirror.local/git" } }, { "name": "debug", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "ATLAS_BUILD_SHARED": "OFF" } }, { "name": "release", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "ATLAS_BUILD_SHARED": "ON" } } ], "buildPresets": [ { "name": "debug", "configurePreset": "debug" }, { "name": "release", "configurePreset": "release" } ], "testPresets": [ { "name": "debug", "configurePreset": "debug", "output": { "outputOnFailure": true } } ] }"hidden": true的 preset 不能直接用,只能被继承,适合放公共配置。"binaryDir": "${sourceDir}/build/${presetName}"让每个 preset 有独立的构建目录,debug 和 release 互不干扰,也不用手动删缓存。
"version": 6对应 CMake 3.25 及以上。版本号不是随便填的,填高了老版本 CMake 直接拒读,填低了很多字段用不了。团队里如果有人用系统自带的 CMake 3.16,那就老老实实降到"version": 3,或者统一要求用新版本。
cacheVariables里的值等价于命令行-D参数,但会写进CMakeCache.txt,所以只放配置项,不要放每次都要变的东西。ATLAS_THIRD_PARTY_MIRROR放这里是合适的,它在整个构建生命周期里不变。
日常命令就变成三条:
cmake --preset debug # 配置 cmake --build --preset debug # 构建 ctest --preset debug # 测试三条命令覆盖了从零到跑通测试的全流程,CI 脚本可以直接复用,不需要再写一套参数。这套东西最大的价值不是省打字,是消除「我这里能编你那里编不过」的环境差异。
5. 常见问题排查:配置、链接、增量构建的三类翻车现场
5.1 配置阶段:找不到包和缓存污染
踩坑一:find_package报 Could not find a package configuration file。
现象很直白:配置阶段直接失败,提示Could not find a package configuration file provided by "fmt",但系统里明明装了。
原因通常有三个。一是find_package默认先找FindXXX.cmake模块,找不到才走 config 模式,而很多库只提供 config 文件,名字和包名大小写还不一致(fmt的配置文件叫fmt-config.cmake,包名是fmt,但有些库是FooConfig.cmake配foo)。二是库装在非标准前缀,比如/opt/fmt,CMake 不知道去那找。三是多版本并存,找到了旧版本但版本号不满足。
解决分三步。先用cmake --preset debug --debug-find看它到底扫了哪些路径,日志里会列得清清楚楚;然后在 preset 里加CMAKE_PREFIX_PATH,指向库的安装前缀;最后确认版本约束写的是最低版本而不是精确版本。如果是自己编的库,安装时带上--prefix和CMAKE_INSTALL_PREFIX对齐,别一半装/usr/local一半装/opt。
cmake --preset debug \ -DCMAKE_PREFIX_PATH="/opt/fmt;/opt/tinyjson" \ --debug-find--debug-find输出的日志很长,但值得看一遍。它会告诉你每个包在哪个路径下被找到、哪个路径被跳过、跳过的原因是什么。
踩坑二:改了 CMakeLists 但行为没变。
现象:明明改了target_compile_options,重新构建后编译命令还是老样子。
原因基本可以锁定CMakeCache.txt。缓存里存了上一次的变量值,某些变量一旦进了缓存,后续set()除非带FORCE或者用CACHE重新声明,否则不会覆盖。更隐蔽的是文件路径缓存,比如FETCHCONTENT_BASE_DIR变了,但旧目录还在,CMake 会继续用旧的。
解决:遇到「改了不生效」,先删构建目录重来,别浪费时间猜。想保留构建缓存的场景,用cmake --fresh(3.24 起支持)清掉缓存但保留构建目录结构。我一般把「删 build 重配」当成排查第一步,成本三十秒,能排除掉一半的玄学问题。
5.2 链接阶段:undefined reference 与符号重复
踩坑三:undefined reference to 'xxx',但源文件明明编了。
现象:编译全部通过,链接期报某个函数未定义,而且这个函数就在工程里的某个模块。
原因按出现频率排:一是依赖写成了PRIVATE,但公开头文件里用了对方的类型,导致下游没链接到那个库;二是静态库顺序问题,A 依赖 B,但链接命令行里 A 在 B 前面,链接器一遍扫过不回看;三是条件编译,某个.cpp在特定平台被if(WIN32)排除了,但头文件里的声明还在。
排查方法很直接:先看链接命令行,cmake --build --preset debug --verbose会把完整的链接命令打出来,确认目标库在不在里面、顺序对不对。再看符号,nm -C libatlas_net.a | grep 函数名,确认符号到底在不在静态库里、是T(已定义)还是U(未定义)。如果符号在库里但链接不上,那就是顺序或者--as-needed的问题;如果符号根本不在库里,回去查源文件有没有被加进add_library。
undefined reference里最坑的一种是「模板实例化缺失」。模板定义在.cpp里,头文件只有声明,下游用的时候编译器找不到定义,只能假定它在别处,链接期就炸。模板要么全放头文件,要么在.cpp里显式实例化,没有第三条路。
踩坑四:符号重复定义,multiple definition of 'xxx'。
现象:链接期报某个函数或变量被定义了多次,通常发生在头文件里的全局变量或非 inline 函数上。
原因:头文件里写了int g_counter = 0;或者void helper() { ... },每个包含它的.cpp都生成一份定义,链接器看到多份就报错。C++17 之前这是常见错误,之后inline变量能解决一部分,但老代码里还是经常见到。
解决:头文件里只放声明,定义放.cpp;确实要在头文件里定义的,加inline(C++17)或者放进匿名命名空间(仅限内部使用)。类内定义的成员函数默认是 inline 的,所以类定义放头文件没问题,问题出在自由函数和全局变量上。
5.3 增量构建与 CI:构建系统自己的坑
踩坑五:CI 上构建时间越来越长,最后超时。
现象:本地构建五分钟,CI 上跑二十分钟还超时,日志里看不到明显错误。
原因通常是缓存目录没管好。FetchContent默认把依赖下载到build/_deps,如果 CI 每次都从零开始,每个依赖都要重新 clone 重新编。另一个常见原因是ccache没配或者配置了但没命中,编译缓存形同虚设。
解决:CI 里缓存build/_deps和~/.cache/ccache两个目录,key 用CMakePresets.json的哈希,preset 变了缓存自然失效。FetchContent加FETCHCONTENT_BASE_DIR指向一个固定路径,别让它跟着构建目录漂。ccache要在 configure 阶段就设好CMAKE_CXX_COMPILER_LAUNCHER,构建中途设没用。
find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM) set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") endif()这段放顶层,find_program找不到就静默跳过,不会影响没装 ccache 的开发机。CMAKE_CXX_COMPILER_LAUNCHER是 CMake 3.4 起的标准机制,比手动包一层编译器脚本干净得多。
还有一个 CI 专属的坑:多配置生成器下ctest需要指定-C Debug,不指定会报找不到测试可执行文件。testPresets里加"configuration": "Debug"能免掉这个。
6. 二次开发:把这套骨架迁到自己工程的三步走
迁移不要一次到位,那必然翻车。我一般分三步,每步都能独立验证、独立回滚。
第一步,先加一个 INTERFACE 目标把编译选项收口,其他什么都不动。这一步风险最低,只影响编译选项,不改目标结构。验证方法是构建一次,对比compile_commands.json里前后命令行,确认警告选项确实加上了、第三方目录确实没被波及。这一步做完,你会发现编译日志干净很多,真正的警告浮出来了。
第二步,从最大的那个add_executable里往外拆库。拆的顺序有讲究:先拆依赖最少的(通常是工具函数、数据结构),再拆中间的(网络、存储),最后拆主程序。每拆一个就构建一次、跑一次测试,别攒着一起拆。拆的过程中会暴露一批「头文件路径写死」「依赖没声明」的历史欠账,这些欠账早暴露早好。
| 迁移前 | 迁移后 | 收益 |
|---|---|---|
| 一个 add_executable 列 200 个源文件 | 6 个库 + 1 个可执行文件 | 改一个模块只重编该模块 |
全局set(CMAKE_CXX_FLAGS ...) | INTERFACE 目标携带选项 | 第三方代码不再刷警告 |
| 头文件路径写相对路径 | target_include_directories+ PUBLIC/PRIVATE | 模块边界由编译器保证 |
| README 里写构建命令 | CMakePresets.json | 新人三条命令跑通 |
手动-lxxx链接 | find_package+ 导入目标 | 跨平台不用改脚本 |
第三步,加install和导出配置。这一步只有在工程需要对外发库或者被其他仓库依赖时才做,纯应用工程可以跳过。做的时候先只导出一个最小的库,用cmake --install build/debug --prefix /tmp/atlas-test装到一个临时目录,再新建一个空工程find_package试一下能不能链接成功。这个冒烟测试一定要做,导出配置的错误在自家工程里是发现不了的,因为自家工程直接add_subdirectory就绕过了导出路径。
改造过程中有个技巧值得单独说:给每个模块加一个BUILD_TESTING保护的测试目标,用add_subdirectory挂在模块旁边。
# src/net/CMakeLists.txt 末尾 if(BUILD_TESTING) add_executable(atlas_net_test tests/reactor_test.cpp) target_link_libraries(atlas_net_test PRIVATE atlas_net GTest::gtest_main) include(GoogleTest) gtest_discover_tests(atlas_net_test) endif()BUILD_TESTING是include(CTest)自动定义的选项,默认 ON,发布构建时用-DBUILD_TESTING=OFF关掉,测试代码不参与编译。gtest_discover_tests在构建后自动扫描测试用例并注册到 ctest,不用手动维护测试列表。
我以前做迁移总想一步到位,把目录结构、target 划分、导出配置一次性改完,结果一次提交改了四十多个文件,构建挂了三天没找到原因,最后靠git bisect定位到是一个模块的PUBLIC写成了PRIVATE。从那以后我每接一个 C++ 工程,第一件事都是先看它的 target 图和依赖方向,而不是先读源码;每次改造都强制走一遍「拆一个、编一次、测一次」的节奏。希望帮到你。
本文还有配套的精品资源,点击获取