简介:这份 CMakeLists 管理大型工程实例面向已具备 C++ 基础、希望掌握跨平台构建组织的开发者,重点解决多目录工程中目标划分、依赖查找与构建配置分散的问题。压缩包共 10 个文件,约 5KB,以 5 个 txt 配置说明、3 个 cpp 源文件和 2 个 h 头文件组成,示例虽小,却把根目录、公共模块与输入输出模块的构建关系完整串联起来。内容围绕 add_subdirectory 的子目录组织、可执行目标与库目标属性、find_package 外部依赖链接、CTest 测试接入、install 安装规则以及 option 可选编译开关展开,便于对照理解大型项目从源码分层到构建产出的组织方式。目录中还保留了 release、debug 等构建配置线索,可帮助读者观察不同构建类型下编译选项与产物的差异。已有 1423 人学习,适合作为梳理 CMake 工程结构、排查依赖与编译选项配置的入门参考样例。
1. 为什么大型工程最后都回到 CMakeLists:一份工程骨架能省下的返工
接手一个十几万行的 C++ 工程,第一眼看的不是源码,是根目录那个 CMakeLists。我见过太多项目在 5 个源文件时随手写两行add_executable,涨到 300 个文件时开始file(GLOB_RECURSE),再涨到多平台发布时,构建脚本已经变成没人敢改的黑匣子——改一行,某个平台的 CI 红三天。这份 CMakeLists 管理大型工程的实例资源,本质就是一套已经分好层、定好可见性、写死依赖边界和目标导出规则的骨架,把「库怎么拆、头文件谁能看见、第三方依赖从哪来、装出去别人怎么 find_package」这些迟早要面对的问题提前摆平。它适合正在把 demo 级工程往多人协作规模上推的 C++ 开发者,也适合接手遗留工程、准备做一次构建系统重构的人。往下我会按目录分层、依赖取舍、选项隔离、避坑、导出安装的顺序,把它拆成能直接照抄的结构。
2. 目录分层与根 CMakeLists:把十几个模块编成一棵可裁剪的 target 树
2.1 先定目录,再写 CMake,顺序反了必然返工
大型工程构建系统烂掉的起点,几乎都是「目录按文件类型分」——所有 .h 塞 include,所有 .cpp 塞 src,然后 CMake 里靠一堆include_directories(../../include)互相捞。这种结构在小规模下能跑,一旦模块超过 8 个,头文件搜索路径就会变成玄学:某个 cpp 能编过,只是因为它恰好间接包含了另一个模块的私有头。
这份骨架采用的是按业务模块分层、每个模块自带 include/src 的布局,逻辑上等价于下面这样:
bigproj/ ├── CMakeLists.txt # 唯一的顶层入口,只做策略和装配 ├── CMakePresets.json # 开发者本机的配置入口,不进构建逻辑 ├── cmake/ │ ├── bigprojConfig.cmake.in # 导出给下游的包配置模板 │ └── CompilerWarnings.cmake # 可复用的选项模块 ├── libs/ │ ├── core/ # 基础类型、错误码,被所有人依赖 │ │ ├── CMakeLists.txt │ │ ├── include/proj/core/ # 对外头文件,路径带模块前缀防重名 │ │ └── src/ │ ├── net/ │ └── codec/ ├── apps/ │ └── cli/ # 可执行文件只做装配,不写业务 ├── tests/ │ └── CMakeLists.txt └── third_party/ # 只有确实要改源码的依赖才放这里关键点不在目录名,在于每个目录对应恰好一个 CMake target。libs/net产出proj_net,apps/cli产出proj_cli,依赖关系写成 target 之间的链接关系,而不是目录之间的相对路径。这样做的好处是可增量、可裁剪:想只编 core 和它的测试,cmake --build build --target proj_core就够,不会牵动整个工程;想给某个模块单独开一组告警等级,也只影响那一个 target。
我一般要求团队里所有#include都写成#include "proj/net/session.h"这种带模块前缀的全路径形式,禁止../相对包含。前缀看起来啰嗦,但它把「头文件属于哪个模块」这件事写死在了源码里,后面拆库、抽子工程时不用回头改 include。这条规矩配合每个 target 的 PUBLIC include 目录,效果是:谁都看得见该看的,看不见不该看的。
2.2 根 CMakeLists 只做三件事:定标准、定策略、装配
顶层文件是整棵构建树的宪法,它不该出现任何具体源文件。这份骨架的根 CMakeLists 控制在 40 行以内,把所有子目录共享的东西一次定死,避免每个子目录各写一份set(CMAKE_CXX_STANDARD 17)然后版本不一致。
cmake_minimum_required(VERSION 3.20) project(bigproj VERSION 0.3.0 DESCRIPTION "large scale c++ project skeleton" LANGUAGES CXX) # 语言标准统一在顶层设定,子目录禁止再改 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) # 关掉 gnu++17,避免依赖编译器扩展 # 产物集中输出,避免每个子目录各生成一份 bin/lib 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) # 单配置生成器(Makefile/Ninja)默认给一个带调试信息的发布配置 if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "" FORCE) endif() # 生成 compile_commands.json,给 clangd / clang-tidy / 静态分析用 set(CMAKE_EXPORT_COMPILE_COMMANDS ON) option(BIGPROJ_BUILD_APPS "build applications" ON) option(BIGPROJ_BUILD_TESTS "build unit tests" ON) # 全局编译约束挂在一个 INTERFACE target 上,谁链接谁生效 add_library(proj_options INTERFACE) target_compile_features(proj_options INTERFACE cxx_std_17) add_subdirectory(libs/core) add_subdirectory(libs/net) add_subdirectory(libs/codec) if(BIGPROJ_BUILD_APPS) add_subdirectory(apps/cli) endif() if(BIGPROJ_BUILD_TESTS) enable_testing() add_subdirectory(tests) endif()几个参数值得单独说。CMAKE_CXX_EXTENSIONS OFF是血泪经验:默认的gnu++17会让 GCC 放开一些非标准语法,本地编得欢,换到 MSVC 直接炸,而错误信息往往指不到真正原因。CMAKE_EXPORT_COMPILE_COMMANDS一定要开,大型工程里 IDE 的跳转和补全基本靠它,不开就只能靠 grep。RelWithDebInfo作为默认,是因为纯 Release 出问题时没有符号栈,纯 Debug 又慢到没法在真实数据集上测。
用option()而不是直接add_subdirectory,目的是让 CI 能只编测试不编应用。大型工程全量构建动辄几十分钟,能裁掉一半目标是日常提效最直接的手段。注意 option 的默认值我给的是 ON,本地开发舒服;CI 里我会显式传-DBIGPROJ_BUILD_APPS=OFF,让脚本行为可预期,不依赖默认值。
2.3 子目录范式:一个库一个 target,可见性靠 PRIVATE/PUBLIC 说话
子目录最容易犯的错是「库对外暴露了不该暴露的头文件路径」。传统写法include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src)会把私有实现目录灌给所有下游,短期没感觉,等到重构目录时才发现整个工程都在依赖某个私有头。
现代写法用target_include_directories配合作用域关键字,把「接口」和「实现」分开:
# libs/net/CMakeLists.txt add_library(proj_net STATIC src/session.cpp src/endpoint.cpp ) # BUILD_INTERFACE 保证只在构建树里生效,安装后由 INSTALL_INTERFACE 接管 target_include_directories(proj_net PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> $<INSTALL_INTERFACE:include> PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) # core 的类型出现在 net 的公开头文件里,所以必须是 PUBLIC target_link_libraries(proj_net PUBLIC proj_core PRIVATE proj_options) # 传递编译特性,下游不需要再设标准 target_compile_features(proj_net PUBLIC cxx_std_17)PUBLIC和PRIVATE的判定标准只有一条:这个依赖有没有出现在我这个 target 对外暴露的头文件里。proj_net的session.h里如果有proj::core::Error成员,那 core 就是 PUBLIC,下游链接 net 时自动拿到 core 的 include 路径和链接关系;如果 core 只在.cpp里用,改成 PRIVATE,下游就看不见它,链接时也不会被错误地要求额外链接——这是大型工程里最常见的一类「链接错误其实来自可见性写错」的问题。
$<BUILD_INTERFACE:...>和$<INSTALL_INTERFACE:...>这对生成器表达式不能省。前者的意思是「在构建树里用绝对路径」,后者是「安装出去之后用相对于安装前缀的路径」。只写绝对路径时,导出给别人用的包配置里会带上一串开发机的路径,换台机器就找不到头文件。
提示:
proj_options作为 PRIVATE 链接的 INTERFACE 库,编译选项依然会作用到proj_net的源文件上,但不会传递给下游。这就是把全局开关收敛到一个 INTERFACE target 的价值。
3. 依赖管理:find_package、FetchContent、vendor 三条路的取舍
大型工程的依赖问题分两类:一类是系统或团队统一提供的,比如 OpenSSL、zlib;另一类是要跟着工程一起编的,比如只有头文件的工具库、需要打补丁的第三方源码。这两种混着处理,最后一定是构建脚本里一堆特判。
3.1 三条路的边界,用一张表说清楚
| 方式 | 适用场景 | 优点 | 代价 |
|---|---|---|---|
find_package(CONFIG) | 系统包管理器、团队预编译 SDK | 构建快,版本由环境统一管控 | 依赖机器状态,换环境容易找不到 |
FetchContent | 需要固定版本、需要参与同一套编译选项 | 版本锁死,源码级可调 | 首次配置要拉代码,网络受限时难受 |
third_party/源码内置 | 需要改源码、打补丁、或依赖已停止维护 | 完全可控,离线可构建 | 仓库变大,升级要手工合并 |
我的习惯是:能在 CI 镜像里预装并且版本稳定的,走find_package;必须精确控制版本的,走FetchContent并在配置里写死 tag;只有确实要改代码的才放进third_party/。最忌讳的是三种方式在同一个依赖上混用,比如 CMake 里find_package(OpenSSL REQUIRED),同时某个子目录又自带一份 OpenSSL 源码,最后链接了两个版本的符号,崩溃现场极难定位。
3.2 find_package 的 CONFIG 模式:为什么 MODULE 模式总在你机器上成功
find_package(fmt)有两种模式:MODULE 模式找Findfmt.cmake,CONFIG 模式找包自己安装的fmtConfig.cmake。默认会先找 MODULE 再找 CONFIG,这个顺序是很多「本地能编、CI 挂掉」的根源——本地某个路径下恰好有个FindXXX.cmake,写死了库位置,CI 上没有。
大型工程里我一般强制走 CONFIG 模式并关掉模块回退:
# 明确要求 CONFIG 模式,避免 CMake 自带的 Find 模块抢先命中 find_package(fmt 9 REQUIRED CONFIG) # 依赖没找到时给出人话提示,别让报错停在 link 阶段 if(NOT fmt_FOUND) message(FATAL_ERROR "fmt not found. Set fmt_DIR to the directory containing fmtConfig.cmake") endif() target_link_libraries(proj_cli PRIVATE fmt::fmt)参数上要注意两点。第一,9是主版本下限,CONFIG 模式下包自己会做兼容性判断,写太死(比如9.1.0 EXACT)会让升级变成灾难。第二,REQUIRED加上之后的if(NOT ..._FOUND)看起来重复,但前者是在find_package当场 fatal,后者能让你在中间插定位提示——我通常在排查期保留它,稳定后删掉。
另外,包名的大小写和 target 名不一定一致。fmt包提供的 target 是fmt::fmt,OpenSSL提供的是OpenSSL::SSL,写target_link_libraries(app fmt)会因为找不到 target 直接失败。判断方法:看安装目录lib/cmake/<pkg>/下的*Targets.cmake,里面的add_library(... IMPORTED)就是准确名字。
顺带说一个排查技巧:配置阶段加--debug-find会打印所有搜索路径,一眼就能看出 CMake 到底在哪些目录里找过、为什么没找到。这个参数在大型工程里救过我很多次。
cmake -S . -B build -G Ninja --debug-find 2>&1 | grep -i "fmt"这条命令只看和 fmt 相关的搜索过程,输出量可控。如果发现它在翻/usr/lib/cmake而你期望的是自定义 SDK 路径,那就该通过-Dfmt_DIR=...或者CMAKE_PREFIX_PATH显式指定,而不是往CMAKE_MODULE_PATH里塞路径碰运气。
3.3 FetchContent 落地:锁版本,并且关掉对方的无关目标
FetchContent用错的方式很统一:不写 tag,默认拉默认分支;拉下来之后对方的BUILD_TESTING、示例程序、文档目标全都进了你的构建图,cmake --build .编一堆你根本不需要的东西。
正确的写法是先声明、再在MakeAvailable之前把对方的开关关掉:
include(FetchContent) # 依赖源码统一落到 build 目录,不污染源码树 set(FETCHCONTENT_BASE_DIR ${CMAKE_BINARY_DIR}/_deps CACHE PATH "" FORCE) FetchContent_Declare( fmt GIT_REPOSITORY https://example.invalid/third_party/fmt.git # 换内网镜像 GIT_TAG v10.1.1 # 永远锁到 tag 或 commit,不要用 master GIT_SHALLOW TRUE # 只拉最近一次提交,省时间 GIT_PROGRESS TRUE ) # 必须在 MakeAvailable 之前设置,之后设置无效 set(FMT_TEST OFF CACHE BOOL "" FORCE) set(FMT_DOC OFF CACHE BOOL "" FORCE) set(FMT_INSTALL OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(fmt)GIT_SHALLOW TRUE在锁 tag 时是安全的,但如果锁的是某个 commit hash,浅克隆可能拉不到,这时候要关掉它。GIT_PROGRESS TRUE是给首次配置用的,能看见下载进度,不然卡在配置阶段完全没输出,很容易被误判成死循环。
FMT_INSTALL OFF这个开关容易被忽略。开启时对方会注册一堆 install 规则,导致你cmake --install的时候把 fmt 的头文件也塞进自己的安装前缀,最后包内容混乱,甚至覆盖系统里的同名文件。凡是 FetchContent 拉进来的依赖,除了确实要被安装的,一律关掉 install 和测试。
还有一个细节:FetchContent_Declare里的变量名(这里是fmt)需要和对方project()名一致才能让fmt::fmt这个 target 正常出现。名字写错时不会报错,只会在链接阶段说 target 不存在,这类错误我见过好几次,每次都要重新想一遍。
4. 编译选项与预编译头:Debug/Release 与三平台不再打架
4.1 用生成器表达式隔离选项,别用 add_definitions
老工程里常见的写法是add_definitions(-DDEBUG -O2)或者set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall")。这两种写法的问题在于它们是全局且无条件的:Debug 和 Release 拿同一套优化参数,MSVC 收到-Wall直接报参数非法。
现代写法把选项挂到 INTERFACE target 上,用生成器表达式按编译器、构建类型、语言分流:
# cmake/CompilerWarnings.cmake,被顶层 include target_compile_options(proj_options INTERFACE # 告警:逐条写,别在表达式里塞列表 $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall> $<$<CXX_COMPILER_ID:GNU,Clang>:-Wextra> $<$<CXX_COMPILER_ID:GNU,Clang>:-Wpedantic> $<$<CXX_COMPILER_ID:MSVC>:/W4> $<$<CXX_COMPILER_ID:MSVC>:/permissive-> # 优化:只在 GCC/Clang 上按配置分流,MSVC 由 CMake 默认 flags 处理 $<$<AND:$<CXX_COMPILER_ID:GNU,Clang>,$<CONFIG:Debug>>:-O0> $<$<AND:$<CXX_COMPILER_ID:GNU,Clang>,$<CONFIG:Debug>>:-g3> $<$<AND:$<CXX_COMPILER_ID:GNU,Clang>,$<CONFIG:Release>>:-O2> )这段代码里有个坑值得单独强调:不要在生成器表达式内部用分号写列表。$<$<CXX_COMPILER_ID:GNU>:-Wall;-Wextra>在不同 CMake 版本和不同命令下的展开行为不一致,有的版本会把它当成一个含分号的参数传给编译器,编译器报「unrecognized option '-Wall;-Wextra'」。逐条写是最稳的,多敲几行换来的是跨平台一致。
-g3和-O0分开写而不是合并成-O0 -g3,是因为在列表上下文里合并需要引号,引号又会改变展开方式。这类细节没写在文档显眼位置,只能靠踩。
注意:
target_compile_options加的是编译选项,宏定义用target_compile_definitions,两者不要混用。-DXXX写在 compile_options 里在部分生成器下能过,但依赖关系不会被正确追踪,改宏值不会触发重编。
4.2 预编译头:收益真实,但边界很窄
头文件多的模块开 PCH 收益明显。十几个标准库头文件被上百个 cpp 包含时,PCH 能把编译时间压掉三到五成。这份骨架的做法是只在库 target 上开,而且只放稳定头文件:
# 放在 add_library 之后 target_precompile_headers(proj_net PRIVATE <vector> <string> <memory> <unordered_map> )参数上只有一点要记:PRIVATE是必须的。用PUBLIC或INTERFACE会把 PCH 强加给所有下游,下游如果用的是不同编译器或不同标准,直接报错,而且错误信息通常指向一个莫名其妙的位置。
边界在于「稳定」这两个字。我见过把项目自己的公共头文件塞进 PCH 的写法,结果改一行头文件,整个模块几百个 cpp 全部重编,比不开 PCH 还慢。判断标准很简单:这个头文件一周内改过吗?改过就别放。
另外 PCH 和生成器表达式配合时有个限制:target_precompile_headers支持生成器表达式,但表达式里不能依赖只在某个配置下存在的头文件路径,否则另一个配置生成 PCH 时会失败。跨平台工程里更稳的做法是按COMPILE_LANGUAGE:CXX过滤,把 C 源文件排除掉——混合语言工程里 PCH 报错十次有八次是这个原因。
4.3 平台分支的收敛写法:一个 INTERFACE target 收口
平台相关的东西如果散落在各个子目录,最后会变成每个 CMakeLists 里都有if(WIN32)。这份骨架的做法是建一个proj_platform的 INTERFACE target,把所有平台差异收在一处:
add_library(proj_platform INTERFACE) target_compile_definitions(proj_platform INTERFACE $<$<PLATFORM_ID:Windows>:PROJ_PLATFORM_WINDOWS> $<$<PLATFORM_ID:Linux>:PROJ_PLATFORM_LINUX> $<$<PLATFORM_ID:Darwin>:PROJ_PLATFORM_MACOS>) target_link_libraries(proj_platform INTERFACE $<$<PLATFORM_ID:Linux>:pthread> $<$<PLATFORM_ID:Windows>:ws2_32>) # 需要平台差异的 target 直接链接它 target_link_libraries(proj_net PRIVATE proj_platform)这样做的实际收益是:新增一个平台时只改一个文件,不用 grep 整个工程找if(WIN32)。PLATFORM_ID比WIN32、UNIX这些老变量更精确,尤其在交叉编译和 MSYS 环境下,WIN32的判断经常和预期不符。
还有一点,target_link_libraries里写裸库名pthread而不是Threads::Threads,在大多数 Linux 工具链下没问题,但更规范的做法是用find_package(Threads REQUIRED)再链接Threads::Threads,它会自动处理-pthread编译选项。大型工程里这个差别会在静态链接和交叉编译时暴露出来。
4.4 构建期的验证手段:别等链接失败才发现问题
改完构建脚本一定要做两件事验证:一是看 target 图,二是看实际编译命令。前者用cmake --graphviz,后者靠compile_commands.json。
# 生成依赖图,用 dot 渲染成图片检查有没有意料之外的依赖 cmake -S . -B build -G Ninja --graphviz=build/deps.dot dot -Tpng build/deps.dot -o build/deps.png # 从 compile_commands.json 里抽一个文件的真实编译命令 python3 -c " import json db = json.load(open('build/compile_commands.json')) for e in db: if 'session.cpp' in e['file']: print(e['command']) "第一段的价值在于可视化。大型工程里经常出现「测试 target 意外依赖了 GUI 库」这种问题,图上一眼就能看出来,靠读 CMakeLists 得读半天。第二段用来确认选项真的生效了——我曾经过一次-O2没加上去,原因是那条生成器表达式写在了add_subdirectory之前,target 还没创建,选项挂到了空处,cmake不报任何错。
5. 避坑与排查:五类翻车记录,每条都有现场
5.1 链接与可见性:报错在链接器,根因在 CMakeLists
现象一:本地增量编译正常,CI 全量构建报 undefined reference。
原因是某个库的依赖写成了 PRIVATE,但它自己的公开头文件里用到了那个依赖的类型。增量编译时,之前的构建缓存里恰好有那个符号,全量构建从零开始就暴露出问题。
解决方式是把该依赖改成 PUBLIC,或者更彻底一点:在公开头文件里用前置声明代替直接包含,把依赖藏到实现里。我倾向后者,因为它同时减少了编译期耦合。改完之后用cmake --build build --clean-first做一次全量验证,别看增量结果。
现象二:Windows 上链接报重复符号,Linux 正常。
原因通常是某个头文件里定义了非 inline 的全局变量或函数,被多个编译单元包含。MSVC 对__declspec(dllexport)的处理和 GCC 的默认可见性不同,静态库场景下 GCC 会合并弱符号,MSVC 直接报错。
解决方式:头文件里的定义全部加inline(C++17 起变量也支持 inline),或者移到 .cpp 里做声明加定义分离。CMake 层面可以顺手打开-fvisibility=hidden(GCC/Clang)和导出宏,把符号可见性显式化。
target_compile_options(proj_net PRIVATE $<$<CXX_COMPILER_ID:GNU,Clang>:-fvisibility=hidden> $<$<CXX_COMPILER_ID:GNU,Clang>:-fvisibility-inlines-hidden>)这个选项会让默认符号不可见,逼着你把该导出的接口写清楚。代价是第一次加的时候大概率编不过,但编过之后符号冲突类问题基本绝迹。
5.2 缓存与配置:九成的「改了没用」都是缓存
现象三:改了 CMakeLists 里的选项,重新构建完全没变化。
原因是 CMakeCache.txt 里存着旧值,尤其是通过option()定义的变量和通过CACHE ... FORCE设置的变量。前者在缓存里已经有值后,option()的默认值不再生效;后者如果写在了if分支里,分支条件变化后也不会重算。
解决方式分两步。定位阶段看缓存里的实际值:
grep -i "BIGPROJ_BUILD_APPS\|CMAKE_BUILD_TYPE" build/CMakeCache.txt确认是缓存问题后,最省事的做法是rm -rf build重新配置,最稳妥的做法是cmake -S . -B build -DBIGPROJ_BUILD_APPS=OFF显式覆盖。我一般不在脚本里依赖默认值,所有关键开关都显式传,这样行为和文档一致,换个人跑结果也一样。
现象四:CMakePresets 换了个 preset,编译器没跟着变。
原因是工具链相关的变量(CMAKE_CXX_COMPILER、CMAKE_TOOLCHAIN_FILE)只在第一次配置时生效,后续切换 preset 时如果复用同一个 build 目录,这些变量不会被重新读取。
解决方式:每个 preset 配独立的binaryDir,比如build/debug-ninja、build/release-ninja。这一点在大型工程里是硬要求,多个 preset 共用一个 build 目录,最后一定会出现各种诡异的「配置和实际不符」。
5.3 安装与导出:能编过不等于能交付
现象五:cmake --install成功,下游find_package失败,报找不到 target。
原因是导出时没有正确生成 Config 文件,或者 target 的 include 目录用了BUILD_INTERFACE却没配INSTALL_INTERFACE。安装出去的 Targets 文件里记录的是开发机绝对路径,下游自然找不到。
排查顺序固定:先看安装前缀下有没有lib/cmake/<pkg>/<pkg>Config.cmake和<pkg>Targets.cmake,再看 Config 文件里set(<pkg>_INCLUDE_DIRS ...)的值是不是绝对路径。是的话回到 CMakeLists,把 include 目录补上$<INSTALL_INTERFACE:include>,重新安装。
另一个隐蔽原因是命名空间不一致。install(EXPORT ... NAMESPACE bigproj::)生成的 target 名是bigproj::proj_core,如果下游写的是proj_core,链接会失败但提示很模糊。这类问题只能靠约定:导出命名空间和工程名统一,写进团队的构建规范里。
6. 导出与安装:让下游一条 find_package 就能用,顺手固化一个提速习惯
把工程装成别人能find_package(bigproj CONFIG)的包,最小闭环只需要四步,缺一步都会在下游暴露问题。
include(GNUInstallDirs) include(CMakePackageConfigHelpers) # 1. 安装 target,并把 include 目录一起导出 install(TARGETS proj_core proj_net EXPORT bigprojTargets ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) # 2. 安装头文件,路径要和 INSTALL_INTERFACE 对齐 install(DIRECTORY libs/core/include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) # 3. 导出 Targets 文件,命名空间必须和下游写法一致 install(EXPORT bigprojTargets FILE bigprojTargets.cmake NAMESPACE bigproj:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/bigproj) # 4. 生成 Config 和版本文件 configure_package_config_file( cmake/bigprojConfig.cmake.in ${CMAKE_CURRENT_BINARY_DIR}/bigprojConfig.cmake INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/bigproj) write_basic_package_version_file( ${CMAKE_CURRENT_BINARY_DIR}/bigprojConfigVersion.cmake VERSION ${PROJECT_VERSION} COMPATIBILITY SameMajorVersion) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/bigprojConfig.cmake ${CMAKE_CURRENT_BINARY_DIR}/bigprojConfigVersion.cmake DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/bigproj)COMPATIBILITY SameMajorVersion的意思是下游写find_package(bigproj 0.3)时,只有主版本一致才接受——对还在快速迭代的库来说,这比AnyNewerVersion安全得多,避免下游无声无息地拿到一个 ABI 不兼容的版本。验证方式是装到一个临时前缀再写个三行的下游工程去 find,别只在自己工程里试:
cmake --install build --prefix /tmp/bigproj-prefix cmake -S downstream -B /tmp/ds-build -DCMAKE_PREFIX_PATH=/tmp/bigproj-prefix最后是一个习惯。大型工程配置一次就要几分钟,我后来强制所有 preset 都带上 ccache 和 Ninja:
| 配置项 | 值 | 理由 |
|---|---|---|
CMAKE_CXX_COMPILER_LAUNCHER | ccache | 切分支重编时命中率高,尤其是头文件小改动 |
| 生成器 | Ninja | 比 Makefile 在大 target 图上快,输出也更干净 |
CMAKE_EXPORT_COMPILE_COMMANDS | ON | clangd 和静态分析都依赖它 |
binaryDir | 每个 preset 独立 | 避免工具链变量残留 |
这几个设置在CMakePresets.json里写一次,全组共享,比让每个人记命令靠谱。有一次我在 CI 上忘了带 launcher,全量构建从 6 分钟涨到 19 分钟,查了半天以为是依赖拉取变慢,最后发现只是 ccache 没挂上。从那以后我每次新增 preset,都会在配置日志里 grep 一遍CMAKE_CXX_COMPILER_LAUNCHER,确认它真的生效再往下走。希望这份骨架和这些踩过的坑,能帮你在自己的工程里少返工几轮。
本文还有配套的精品资源,点击获取