news 2026/9/26 4:04:49

CMake 入门:从单文件到多目标工程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CMake 入门:从单文件到多目标工程

一个.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_FLAGStarget_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)──▶ 消费者的消费者 链接关系是「向下游传递属性」,不是「向上游查找」

换成决策表:

你想表达的意思该用哪个关键字典型写法
这是我自己的实现细节,别人不该知道PRIVATEtarget_include_directories(mathlib PRIVATE src)
这是我的公开接口,用我的人必须能看到PUBLICtarget_include_directories(mathlib PUBLIC include)
我只是个纯头文件库,本身不需要编译INTERFACEadd_library(hdr INTERFACE)+target_include_directories(hdr INTERFACE include)
可执行文件链接库(终点,不向下传)PRIVATEtarget_link_libraries(greet PRIVATE mathlib)
我依赖的第三方库也是我接口的一部分PUBLICtarget_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。把这几条做对,多目标工程的结构就不会失控。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 4:04:25

拿下开源生图第一,千问Qwen-Image-2.1把生图卷出新高度

视觉生成模块仅7B&#xff0c;生图、改图、透明素材装进了同一个模型。 AI 写代码的能力经常能让开发者「双手离开键盘」&#xff0c;但对于设计师而言&#xff0c;AI 生成的图片距离能交稿总是差了好几步。 在图像设计的工作流中&#xff0c;人物需要抠图、素材需要改字、各…

作者头像 李华
网站建设 2026/9/26 4:03:44

C语言学习--回顾(05)

&#xff08;第五篇&#xff09; 目录 &#xff08;第五篇&#xff09; 2.6while循环 {1}补充内容 2.7 for循环 2.8 do while循环 2.9 break与continue​编辑 2.10嵌套循环 2.11 goto语句 2.12 随机数生成 2.6while循环 &#xff08;a&#xff09;while与if 的差别在于…

作者头像 李华
网站建设 2026/9/26 4:03:17

Python 自制文件下载器

1. 项目概述当你文件下载很慢时&#xff0c;如何不用别的工具&#xff0c;自己用趁手的工具自行搭建一个用 Python 编写一个功能完整的文件下载器。2. 环境准备本项目基于 Python 3.8 及以上版本开发&#xff0c;无需安装任何第三方依赖。建议使用虚拟环境隔离项目&#xff0c;…

作者头像 李华
网站建设 2026/9/26 4:02:58

一人公司如何用WorkBuddy搭建自动化工作流:Skill与Agent实战指南

1. 一人公司的效率困局与 WorkBuddy 的破局思路一个人干一家公司的活&#xff0c;最怕的不是没活干&#xff0c;而是活太杂。早上写文案&#xff0c;中午剪视频&#xff0c;下午回客户消息&#xff0c;晚上还要整理数据报表&#xff0c;中间穿插着发票、合同、选题、排期。每切…

作者头像 李华