一个.cpp文件时,g++ main.cpp -o app就够了;等到工程变成「一个静态库 + 两个可执行文件 + 一套测试 + 一个第三方依赖」,手写编译命令就会迅速失控——你开始记不住该编哪些文件、按什么顺序链、哪些-I路径给谁。构建系统要解决的就是这件事,而 CMake 是 C++ 世界事实上的标准。这篇的目标很简单:给你一份能直接抄走的多目标工程模板,并把现代 CMake 唯一必须搞懂的概念——PUBLIC / PRIVATE / INTERFACE 的传播语义——讲透。
1. 引子:为什么手写编译命令必然失控
三个具体问题:
- 多文件编译:改一个
.cpp只需要重编它自己,其余复用已有目标文件——这是靠「时间戳 + 依赖图」实现的,手写命令做不到。 - 依赖管理:
app依赖mathlib,mathlib依赖fmt。谁先编、谁链谁、头文件路径给谁——这是一张有向图,不是一条命令行。 - 跨平台:Linux 用
g++、macOS 用clang++、Windows 用 MSVC,编译选项、库后缀、可执行文件后缀全都不同。构建系统把「我要什么」和「这台机器上怎么做」拆开。
官方文档:translation phases(翻译阶段)——标准把「一个 .cpp 编译成一个翻译单元」定义在第 8 阶段,这是「多文件独立编译 + 最后链接」的理论基础。
CMake 不是编译器,它是构建系统的生成器:你写CMakeLists.txt描述工程结构,CMake 生成 Makefile / Ninja 文件 / Visual Studio 工程,再交给真正的构建工具去跑。
官方文档:CMake 官方教程——跟着做一遍,比读十篇博客有用。
2. 现代 CMake 的核心理念:以 target 为中心
这是全文最重要的一句话:
不要问「这个目录下要加什么编译选项」,要问「这个 target 需要什么」。
老式写法用全局命令,一次调用影响后面所有target:
| 老写法(全局,已不推荐) | 现代写法(target 版) | 老写法的问题 |
|---|---|---|
include_directories(include) | target_include_directories(tgt PUBLIC include) | 污染目录下所有 target,且无法表达「这个路径该不该传给消费者」 |
link_libraries(fmt) | target_link_libraries(tgt PRIVATE fmt) | 同理,链接依赖变得不可追踪 |
add_definitions(-DFOO) | target_compile_definitions(tgt PRIVATE FOO) | 宏会泄漏给无关 target |
手改CMAKE_CXX_FLAGS | target_compile_features/target_compile_options | 全局改标准容易互相打架,且无法按 target 区分 |
差别不只是「风格」,而是依赖关系能不能被表达和检查:target 版的写法让「谁需要什么」直接写在图里,CMake 可以据此算出正确的编译顺序、正确的-I和正确的链接行;全局写法只能一股脑塞给所有人。
官方文档:cmake-buildsystem(7):目标与依赖图
3. 工程目录结构
先看一个真实可用的多目标工程长什么样:
myproj/ ├── CMakeLists.txt # 顶层:只做全局配置 + 组织子目录,不写具体编译细节 ├── mathlib/ │ ├── CMakeLists.txt # 静态库目标 mathlib │ ├── include/ │ │ └── mathlib/ │ │ └── mathlib.h ← 公开头文件:消费者 #include "mathlib/mathlib.h" │ └── src/ │ ├── mathlib.cpp ← 实现 │ └── internal.h ← 私有头文件:只有 mathlib 自己能看见 ├── src/ │ ├── CMakeLists.txt # 可执行目标 greet │ └── main.cpp ├── tests/ │ ├── CMakeLists.txt # 测试目标 test_mathlib │ └── test_mathlib.cpp └── build/ # 构建目录(不进版本库!)要点:公开头文件放include/,私有实现头放src/。这个物理隔离不是洁癖——它是 PUBLIC / PRIVATE 能生效的前提:一旦实现头文件和公开头文件混在一起,消费者就总能顺着-I摸到你的内部实现,接口边界立刻失守。
4. 最小可用模板:顶层 CMakeLists.txt
cmake_minimum_required(VERSION3.16)# 3.16 起 target_link_libraries 的传播语义才足够稳定project(myproj VERSION0.1.0 LANGUAGES CXX)# C++ 标准:全局只声明「最低要求」,具体由 target_compile_features 传播set(CMAKE_CXX_STANDARD17)set(CMAKE_CXX_STANDARD_REQUIRED ON)set(CMAKE_CXX_EXTENSIONS OFF)# 用 -std=c++17,而不是 gnu++17# 单配置生成器(Unix Makefiles / Ninja)下,不设 BUILD_TYPE 就是「无优化、无调试信息」if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)set(CMAKE_BUILD_TYPE Debug CACHE STRING"构建类型"FORCE)endif()add_subdirectory(mathlib)add_subdirectory(src)enable_testing()add_subdirectory(tests)四个必需元素各自的职责:cmake_minimum_required声明最低版本(决定可用的命令与策略默认值)、project声明工程名与语言、add_subdirectory把子目录挂进依赖图、enable_testing打开ctest支持。
CMAKE_CXX_EXTENSIONS OFF值得单独记一下:不关掉它,GCC/Clang 会用-gnu++17,你可能在不经意间用上 GNU 扩展,换到 MSVC 就编不过。
官方文档:Core Guidelines P.2:Write in ISO Standard C++——「只用标准 C++」这条规则落到构建脚本上,就是
CMAKE_CXX_EXTENSIONS OFF。
官方文档:cmake_minimum_required、project
5. 静态库目标:PUBLIC / PRIVATE 的传播语义
这是现代 CMake 最容易搞混、也最值得花时间理解的概念。先看库的CMakeLists.txt:
add_library(mathlib STATIC src/mathlib.cpp)# 显式列出源文件,不要用 file(GLOB)target_include_directories(mathlib PUBLIC${CMAKE_CURRENT_SOURCE_DIR}/include# 消费者也要能 #includePRIVATE${CMAKE_CURRENT_SOURCE_DIR}/src)# 私有实现头,不外传# 把「我至少需要 C++17」这件事传播给消费者,而不是硬编码 -std=c++17target_compile_features(mathlib PUBLIC cxx_std_17)# 警告选项只加给这个 target,不污染整个工程target_compile_options(mathlib PRIVATE-Wall-Wextra)三个关键字的语义,用一张图理解最直观:
依赖是怎么沿 target 传播的 ═══════════════════════════════════════════════════════════════════════ ① PRIVATE:只有我自己用,消费者看不见 app ──链接──▶ mathlib ├── PRIVATE include 路径 ──▶ mathlib 自己编译时用 └── ✗ 不传播 ──▶ app 的编译命令里没有这条 -I ② INTERFACE:我自己不用,但消费者必须继承 app ──链接──▶ headeronly(纯头文件库) └── INTERFACE include 路径 ──▶ 只加到 app 的 -I 上 ③ PUBLIC:自己要用,消费者也要继承 (= PRIVATE + INTERFACE) app ──链接──▶ mathlib ├── 先用在自己身上 └── PUBLIC include 路径 ──▶ 同时加到 app 的 -I 上 (并且继续向下传) 传播方向:mathlib ──▶ 它的消费者(app、test_mathlib)──▶ 消费者的消费者 链接关系是「向下游传递属性」,不是「向上游查找」换成决策表:
| 你想表达的意思 | 该用哪个关键字 | 典型写法 |
|---|---|---|
| 这是我自己的实现细节,别人不该知道 | PRIVATE | target_include_directories(mathlib PRIVATE src) |
| 这是我的公开接口,用我的人必须能看到 | PUBLIC | target_include_directories(mathlib PUBLIC include) |
| 我只是个纯头文件库,本身不需要编译 | INTERFACE | add_library(hdr INTERFACE)+target_include_directories(hdr INTERFACE include) |
| 可执行文件链接库(终点,不向下传) | PRIVATE | target_link_libraries(greet PRIVATE mathlib) |
| 我依赖的第三方库也是我接口的一部分 | PUBLIC | target_link_libraries(mathlib PUBLIC fmt::fmt) |
一句话记忆法:PRIVATE 是「我的事」,INTERFACE 是「用我的人的事」,PUBLIC 是「两边都有的事」。判断标准只有一个问题:「消费者需不需要知道这件事?」
官方文档:target_include_directories、target_link_libraries(仔细看 PUBLIC/PRIVATE/INTERFACE 一节)
6. 可执行目标与测试目标
src/CMakeLists.txt:
add_executable(greet main.cpp)# greet 是终点(没人链接它),所以用 PRIVATEtarget_link_libraries(greet PRIVATE mathlib)target_compile_options(greet PRIVATE-Wall-Wextra)tests/CMakeLists.txt:
add_executable(test_mathlib test_mathlib.cpp)target_link_libraries(test_mathlib PRIVATE mathlib)# 注册到 ctest:跑 ctest 时会执行它,返回非 0 即判定失败add_test(NAME mathlib_basic COMMAND test_mathlib)三个目标对应的源码各跑一次,确认语义没写错:
// src/main.cpp — g++ -std=c++17 -Wall -O2 src/main.cpp -o greet#include<iostream>intmain(){std::cout<<"hello from cmake\n";}hello from cmake// src/main.cpp — g++ -std=c++17 -Wall -O2 src/main.cpp -o greet// 真实工程里 add/sub 由静态库 mathlib 提供(#include "mathlib/mathlib.h");// 这里为了让示例能独立编译运行,直接把实现放在同一个文件里。#include<iostream>namespacemathlib{intadd(inta,intb){returna+b;}intsub(inta,intb){returna-b;}}// namespace mathlibintmain(){std::cout<<"add(2, 3) = "<<mathlib::add(2,3)<<'\n';std::cout<<"sub(2, 3) = "<<mathlib::sub(2,3)<<'\n';}add(2, 3) = 5 sub(2, 3) = -1// tests/test_mathlib.cpp — ctest 会执行它,返回非 0 即判定失败#include<iostream>// 真实工程里改为 #include "mathlib/mathlib.h" 并链接 mathlib 目标;// 这里为了让示例能独立编译运行,直接给出等价实现。constexprintadd(inta,intb){returna+b;}intmain(){intfailures=0;if(add(2,3)!=5){std::cout<<"[FAIL] add(2, 3) 应为 5\n";++failures;}if(add(-1,1)!=0){std::cout<<"[FAIL] add(-1, 1) 应为 0\n";++failures;}if(failures==0){std::cout<<"[PASS] 2 个用例全部通过\n";}else{std::cout<<"[FAIL] 有 "<<failures<<" 个用例失败\n";}returnfailures==0?0:1;}[PASS] 2 个用例全部通过测试目标的价值在于它把「库被正确导出」这件事也一并验证了:如果mathlib的 include 路径被误写成PRIVATE,test_mathlib.cpp会因为找不到mathlib/mathlib.h而编译失败——错误在构建期就暴露,而不是等到下游用户投诉。
官方文档:add_test、enable_testing
7. 构建类型:CMAKE_BUILD_TYPE到底改了什么
# 配置(只跑一次)+ 构建(每次改动后跑)cmake-S.-Bbuild-DCMAKE_BUILD_TYPE=Debug cmake--buildbuild-j# 之后想换构建类型,改配置即可;不要手动 rm -rf buildcmake-S.-Bbuild-DCMAKE_BUILD_TYPE=Release各档位对应的默认编译选项(GCC 风格):
| 构建类型 | 优化 | 调试信息 | 额外宏 | 适用场景 |
|---|---|---|---|---|
| 不设置 | 无(-O0) | 无 | 无 | 不推荐:既没优化也没调试信息,纯属自找麻烦 |
Debug | 无(-O0) | 有(-g) | 无 | 日常开发、断点跟踪、断言生效 |
Release | 有(-O3) | 无 | -DNDEBUG | 发布产物 |
RelWithDebInfo | 有(-O2) | 有(-g) | -DNDEBUG | 线上抓栈、性能分析(推荐给压测) |
MinSizeRel | 体积优先(-Os) | 无 | -DNDEBUG | 嵌入式 / 体积敏感 |
两个容易踩的点:
NDEBUG是Release系列自动加上的,所以assert在 Release 下会整体消失——这正是断言里绝不能放副作用的原因(详见《assert 与 static_assert:把假设写进代码》)。- 多配置生成器(Visual Studio、Ninja Multi-Config)会忽略
CMAKE_BUILD_TYPE,改用cmake --build build --config Release。写跨平台脚本时这里必须区别对待。
用一个程序直观验证宏差异:
// src/which_build.cpp — 观察 CMAKE_BUILD_TYPE 带来的宏差异#include<iostream>intmain(){#ifdefNDEBUGstd::cout<<"构建倾向:Release 系列(已定义 NDEBUG,assert 会消失)\n";#elsestd::cout<<"构建倾向:Debug(未定义 NDEBUG,assert 生效)\n";#endif}构建倾向:Debug(未定义 NDEBUG,assert 生效)上面这次运行没有传-DNDEBUG,所以走的是Debug分支;同一个可执行文件在-DCMAKE_BUILD_TYPE=Release的构建目录里跑,就会打印另一行。
官方文档:CMAKE_BUILD_TYPE——只看这篇,别信「Release 就是 -O2」这种以讹传讹的说法。
8. 别用file(GLOB)收集源码
这条是 CMake 官方文档里明确写着的建议,却也是最经典的坑:
# 反例,不要这么写:新增 .cpp 文件不会触发重新配置file(GLOB SRC_FILES"src/*.cpp")add_executable(app${SRC_FILES})原因:file(GLOB)是在配置阶段执行的,结果被缓存进构建目录。当你新建一个src/extra.cpp再跑cmake --build build,CMake不会重跑配置(它只会检查CMakeLists.txt有没有变),于是新文件根本不会进入编译列表。表现形式极其迷惑:代码明明写了,函数却「未定义」,重启 IDE 又好了。
正确做法是显式列出源文件:
add_executable(app main.cpp extra.cpp)# 新增文件时手动加一行,改动会让 CMake 自动重跑配置多写一行换来的是可预测性:文件列表的变化永远经过你手,构建结果不会因为「缓存忘了刷新」而漂移。(CONFIGURE_DEPENDS选项可以缓解,但它靠每次构建时遍历目录来判断,既慢又不完全可靠,不如直接列出来。)
官方文档:file(GLOB) 的官方说明——原文写着「We do not recommend using GLOB to collect a list of source files」。
9.find_package:引入第三方库(点到为止)
标准库之外的依赖,现代 CMake 的统一入口是find_package+ 命名空间化的 imported target:
find_package(Threads REQUIRED)# 编译器自带的线程库,几乎总能用target_link_libraries(greet PRIVATE Threads::Threads)find_package(fmt CONFIG REQUIRED)# 第三方库提供的 config 包target_link_libraries(greet PRIVATE fmt::fmt)# 直接用 fmt::fmt,不要自己拼 -I / -l关键点:fmt::fmt这样的「命名空间化 target」会把该库需要的 include 路径、编译选项、传递依赖一起带过来,你不用关心它装在哪。这就是 target 为中心的好处——第三方库也遵守同一套传播规则。
REQUIRED表示找不到就报错停止配置(比默默继续、最后在链接期炸掉好得多)。CONFIG表示使用库自己安装的*Config.cmake,是现代库的推荐方式。
官方文档:find_package
10. 常用编译选项怎么加才对
# 只加给一个 target,不污染别人target_compile_options(mathlib PRIVATE-Wall-Wextra)# 跨编译器的情况:MSVC 不认识 -Wall / -Wextratarget_compile_options(mathlib PRIVATE $<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-Wall;-Wextra>$<$<CXX_COMPILER_ID:MSVC>:/W4>)# 「我需要 C++17 的哪个特性」——用 feature 名,而不是硬编码 -std=target_compile_features(mathlib PUBLIC cxx_std_17)# 需要某个具体特性时写具体名字,消费者的标准会被自动抬到满足它target_compile_features(greet PRIVATE cxx_std_17)$<...>是生成器表达式(generator expression),它在生成阶段(而不是配置阶段)求值,所以能根据实际编译器/构建类型切换选项。target_compile_features比硬编码-std=c++17更好,因为它是声明式的:库说「我至少要 C++17」,CMake 负责把消费者的标准抬到够用,而不是让两边互相覆盖。
官方文档:target_compile_features、生成器表达式
每个cxx_std_17这类 feature 名都对应一组标准库/语言要求的特性测试宏(feature-test macro)。想知道某个特性名字覆盖了什么,或者想在自己的头文件里用__cpp_*宏做条件编译,看这两篇:
官方文档:feature-test macros、编译器特性支持表
11. 实测:真跑一遍上面的多目标工程
上面这些CMakeLists.txt不是示意——把「静态库 + 可执行文件 + 测试」三个 target 放进一个工程真构建一遍,日志长这样(Compiler Explorer 的 CMake 工程,gcc 13.2):
-- The CXX compiler identification is GNU 13.2.0 -- Configuring done (0.3s) -- Generating done (0.0s) -- Build files have been written to: /app/build [ 16%] Building CXX object CMakeFiles/mathlib.dir/mathlib.cpp.o [ 33%] Linking CXX static library libmathlib.a [ 33%] Built target mathlib [ 50%] Building CXX object CMakeFiles/greet.dir/main.cpp.o [ 66%] Linking CXX executable greet add(2, 3) = 5 ← 构建完直接跑 greet 的输出 [ 83%] Building CXX object CMakeFiles/test_mathlib.dir/test_mathlib.cpp.o [100%] Linking CXX executable test_mathlib [100%] Built target test_mathlib说明:在线沙箱的文件是扁平的,所以这里把
add_subdirectory的目录结构拍平成单个CMakeLists.txt,add_library/add_executable/add_test的语义完全一致。
值得盯着看的是构建顺序:CMake 自己算出了依赖——先编mathlib,再编依赖它的greet和test_mathlib,最后各自链接。这就是第 1 节说的「依赖图」:你只声明依赖,
顺序交给它。
12. 延伸阅读
- CMake 官方教程:从最小工程推到完整多目标工程,官方维护,跟着敲最省事
- cmake-buildsystem(7):target、属性、传播语义的权威定义,PUBLIC/PRIVATE 讲不清时回这里
- target_link_libraries:传播语义的逐条说明
- CMAKE_BUILD_TYPE:各档默认选项与「多配置生成器会忽略它」的说明
- cppreference:编译器特性支持表:用
target_compile_features之前,先确认目标编译器真的支持这个特性 - Compiler Explorer:想确认 CMake 生成的选项到底会产出什么代码,把选项抄进 godbolt 看一眼
13. 一句话总结
现代 CMake 只有一条主线:以 target 为中心——add_library/add_executable定义 target,target_include_directories/target_link_libraries/target_compile_features用 PUBLIC(我用、消费者也用)、PRIVATE(只有我用)、INTERFACE(只有消费者用)声明依赖怎么传播;源码显式列出不用file(GLOB),构建类型用-DCMAKE_BUILD_TYPE指定并记住 Release 会自动带上NDEBUG。把这几条做对,多目标工程的结构就不会失控。