【CMake】第1期 从 CMake 初识到库的构建
开头
OK了,前面我们花了4期把MySQL的基础知识学完了,今天我们来学习一个全新的板块——CMake。这是啥你可能都没听过,废话不多说,我们一起来学习。
一.初识CMake
1.基本概念
如上图,一个最简单的代码,现在我们想要运行(预处理-编译-汇编-链接-可执行文件),我们只需要
能跑,但一旦项目变大,四个问题立刻出现:
- 平台差异:Linux 用
g++、Windows 用cl.exe、macOS 用clang++,参数不一样; - 增量构建:改一个文件,哪些
.o要重编?手写命令做不到; - 依赖管理:用到第三方库,头文件在哪、库在哪、要链哪些?
- IDE/工具:VS Code、CLion、clangd 怎么知道你的源文件列表和编译参数?
CMake 是目前 C/C++ 领域最主流的跨平台构建系统生成工具,也是现代 C++ 项目的事实标准。它不直接编译代码,而是根据一份统一的CMakeLists.txt配置文件,生成对应平台的原生构建脚本(Linux 的 Makefile、Windows 的 VS 工程、macOS 的 Xcode 工程、跨平台的 Ninja 文件),最终由原生工具完成编译链接
2.三大阶段 ——>三棵树
在写第一行 CMake 之前,你必须知道这三个阶段:
① 配置 Configure 读 CMakeLists.txt,探测编译器,写缓存 ↓ ② 生成 Generate 把 target + 属性翻译成 build.ninja / Makefile ↓ ③ 构建 Build 调用 make,编译、链接1. 源树(Source Tree)
- 定义:存放项目全部源代码、手写的
CMakeLists.txt构建脚本、资源文件、配置文档的目录,是开发者全程维护的 “原材料目录”。 - 核心特点:所有内容均为人工编写维护,构建过程不会向其中写入任何文件。
- 示例:
~/demo/,目录内包含.cpp/.h源码、顶层CMakeLists.txt、README 等项目原生文件。
2. 构建树(Build Tree)
- 定义:CMake 配置 + 编译过程中,所有自动生成的中间产物的存放目录,包括缓存文件、生成的构建脚本(Makefile/VS 工程文件)、目标文件(
.o/.obj)、临时库、未安装的可执行程序等 - 核心特点:全部为自动生成的文件,无人工维护内容,可随时删除重建
- 示例:
~/demo/build/,目录内会生成CMakeCache.txt、CMakeFiles目录、Makefile、编译产出的可执行文件等
3. 安装树(Install Tree)
- 定义:执行
cmake --install后,最终交付的成品文件(可执行程序、动态 / 静态库、公开头文件、配置文件、文档等)按照规范结构安装到的目录,是项目的 “最终交付目录” - 核心特点:仅保留运行 / 使用项目必需的文件,剔除了所有编译中间产物
- 示例:Linux 系统默认安装路径
/usr/local/;也可自定义本地安装路径,如~/demo/install/
3.源内 / 源外构建
1. 源内构建
定义:直接在源树的根目录执行 CMake 配置,让所有构建生成的文件与源代码混在同一个目录中,是一种过时的构建方式
操作方法
cd ~/demo # 直接进入源码根目录 cmake . # 在源码根目录执行配置,生成文件直接散落在源码目录中 make # 编译,产物也存放在源码目录中- 核心缺陷
- 污染源树:源码目录会混入大量自动生成的文件,无法直观区分 “手写代码” 和 “生成文件”,目录结构混乱。
- 清理成本高:想要清空编译产物重新构建,需要手动删除大量零散的生成文件,极易误删源码文件。
- 无法多配置并行:不能同时存在 Debug、Release 两种构建版本,切换编译类型必须清空全部内容、重新配置编译。
2. 源外构建
定义:将构建树与源树完全分离,所有自动生成的构建、编译产物都放在独立的目录(通常命名为
build)操作方法
两种写法效果完全一致:
# 写法1:现代 CMake 标准写法(截图中的方式) cmake -S . -B build # -S 指定源目录,-B 指定构建目录 cmake --build build # 执行编译 # 写法2:传统手动进入目录的写法 mkdir build && cd build cmake .. make- 核心优势
- 源树保持纯净:源码目录永远只有人工维护的文件,结构清晰;Git 仅需忽略
build/一个目录即可。 - 支持多配置并行:可以同时创建
build_debug、build_release等多个构建目录,分别配置不同的编译选项、编译类型,互不干扰、无需重复配置。
cmake -S . -B build_debug -DCMAKE_BUILD_TYPE=Debug cmake -S . -B build_release -DCMAKE_BUILD_TYPE=Release- 重置成本极低:想要彻底清空构建、解决缓存异常问题,只需
rm -rf build删除整个构建目录,完全不会影响源码,相当于 “一键重来”。 - 安全性更高:不会因为构建脚本的问题意外修改、覆盖源码文件,更适合大型多人协作项目
二.构建一个可执行文件
1.示例
前面我们写了一个简单的main.cpp,我们现在用 CMake 的语法来构建
首先就是创建一个
CMakeLists.txt文件(注意大小写)编写
CMakeLists.txt构建
make并执行
2.拆解三个函数
(1)cmake_minimum_required()
cmake_minimum_required(VERSION 3.18)语法:cmake_minimum_required(VERSION <min>[...<max>])
| 参数 | 含义 |
|---|---|
VERSION 3.20 | 这个工程至少需要 CMake 3.18 |
...<max>(可选) | 版本范围上界,极少用 |
它不只是"版本检查":它还会把 CMake 的 **策略(policy)**锁定到 3.18 那一套。CMake 每几个版本会改默认行为(比如if()的比较、install的默认路径),policy 机制保证老工程不会突然坏掉。你写VERSION 3.18,等于告诉 CMake:“请用 3.18 的规矩理解我。”
为什么必须第一行:后面所有命令的行为都受它影响。
常见坑:
- 写
VERSION 2.8:CMake 4.x 已经不支持 3.5 以下,直接报错; - 不写:用最新策略,老写法可能报
CMP00xx警告甚至错误; - 写太高(3.30)而队友是 3.22:配置直接失败。团队取交集。
(2)project()
project(hello LANGUAGES CXX)语法:
project(<name> [VERSION <major>[.<minor>[.<patch>[.<tweak>]]]] [DESCRIPTION <text>] [HOMEPAGE_URL <url>] [LANGUAGES <language>...])| 参数 | 含义 | 不写会怎样 |
|---|---|---|
<name> | 项目名,必填 | — |
VERSION | 版本号1.1.1 | 没有PROJECT_VERSION,后面install/CPack用不了 |
DESCRIPTION | 描述 | 只影响打包元数据 |
HOMEPAGE_URL | 主页 | 同上 |
LANGUAGES | 启用哪些语言 | 默认 C 和 CXX;只写CXX可以少探测一个 C 编译器,配置更快 |
它偷偷生成了一堆变量(后面会反复用):
${PROJECT_NAME} # hello ${PROJECT_VERSION} # 1.1.1 ${PROJECT_VERSION_MAJOR} # 1 ${PROJECT_SOURCE_DIR} # 本 project() 所在目录 ${PROJECT_BINARY_DIR} # 对应的构建目录容易混的两个:
CMAKE_SOURCE_DIR:最顶层的源目录(整个工程的老大);PROJECT_SOURCE_DIR:最近一次project()所在目录。
(3)add_excutable()
add_executable(main main.cpp)语法:
add_executable(<name> [WIN32] [MACOSX_BUNDLE] [EXCLUDE_FROM_ALL] [<source>...])| 参数 | 含义 | 说明 |
|---|---|---|
<name> | 目标名 | 全局唯一,也是cmake --build build --target hello的名字 |
<source>... | 源文件列表 | 相对路径相对于当前 CMakeLists 所在目录,不是相对 build/ |
WIN32 | Windows GUI 程序 | 入口是WinMain而不是main,控制台程序不要写 |
MACOSX_BUNDLE | macOS.app包 | 跨平台项目才用 |
EXCLUDE_FROM_ALL | 默认不构建 | 只有--target hello才编,适合示例/工具 |
它创建的不只是一个文件,而是一个 target——这是 CMake 的核心概念
3.构建树
跑完cmake -S . -B build后,build/构建目录里会多出:
- makefile
- 本质:CMake 生成的原生构建脚本。
- 作用:Linux下 CMake 默认生成 Makefile 构建系统,后续执行
make或cmake --build build时,就是读取这个文件,按照里面定义的编译、链接、清理规则来执行编译
补充:如果是 Windows + Visual Studio 环境,这里对应的会是.sln解决方案和.vcxproj工程文件
- CMakeCache.txt
- 本质:CMake 的变量缓存文件,是构建目录里最核心的文件之一
- 作用:
- 第一次配置时,CMake 会把检测到的编译器路径、编译选项、用户自定义变量(比如
CMAKE_BUILD_TYPE、CMAKE_INSTALL_PREFIX)、第三方库的查找结果,全部缓存到这个文件里。 - 后续重新执行
cmake配置时,会直接读取这份缓存,不需要重新做编译器检测、变量计算,大幅加快配置速度。 - 你修改
CMakeLists.txt后重新配置,CMake 会基于这份缓存做增量更新。
- 第一次配置时,CMake 会把检测到的编译器路径、编译选项、用户自定义变量(比如
- 常见用法:想彻底重置配置、清空所有缓存时,删除这个文件(或直接删整个 build 目录)即可
- cmake_install.cmake
- 本质:安装阶段的执行脚本。
- 作用:里面写好了所有安装规则 —— 哪些可执行文件 / 库要复制到安装目录、公开头文件放哪、权限如何设置等。当你执行
cmake --install build时,CMake 就是运行这个脚本,完成最终的文件部署
- CMakeFiles 目录
- 本质:CMake 的内部工作目录,存放所有中间产物、临时文件、日志和编译中间件。
- 里面常见的内容:
- 每个编译目标(比如你的
main)对应的子目录,存放.o目标文件、依赖关系记录、编译参数快照。 - 编译器检测日志、配置过程日志(排错时会用到)。
- CMake 内部用的临时脚本、进度标记文件。
- 每个编译目标(比如你的
- 这个目录全程由 CMake 自动管理,一般不需要手动修改或查看
三.构建一个库
1.示例
如上图,现在我们有两个 .c 文件,我们如何生成可执行呢???
最简单的方式就是:
可以运行,但是:
- 别的项目想复用
math.cpp?只能复制源码 - 改
main.cpp会连math.cpp一起重编(大项目里很致命) - 没法区分"哪些头文件是公开的、哪些是实现细节"
解决方法就是把math.cpp变成库,让main来进行链接
2.相关函数
(1)add_library() :创建库
完整语法:
add_library(<name> [STATIC | SHARED | MODULE] [EXCLUDE_FROM_ALL] [<source>...])| 参数 | 含义 | 细节 |
|---|---|---|
<name> | 库的逻辑名 | 全局唯一;产物名自动加前缀/后缀:math→libmath.a(Linux)/math.lib(Windows) |
STATIC | 静态库 | 链接时把代码复制进可执行文件;产物.a/.lib |
SHARED | 动态库 | 运行时加载;产物.so/.dll |
MODULE | 插件 | 不被链接,只给dlopen用;产物也是.so |
EXCLUDE_FROM_ALL | 默认不构建 | 只有显式指定 target 才编 |
<source>... | 源文件 | 相对当前 CMakeLists 所在目录 |
不写类型会怎样?默认是STATIC,但如果全局set(BUILD_SHARED_LIBS ON),默认变成SHARED。建议永远显式写类型,别依赖默认值
(2)target_include_directories():设置头文件路径
完整语法**:
target_include_directories(<target> [SYSTEM] [AFTER|BEFORE] <INTERFACE|PUBLIC|PRIVATE> <dir>... [<INTERFACE|PUBLIC|PRIVATE> <dir>...] ...)| 参数 | 作用 | 大白话 |
|---|---|---|
<target> | 给哪个目标加 | 必须是add_library/add_executable创建过的 |
PUBLIC | 自己编译要用,使用者也要用 | “我的头文件在 include/,用我的人也得加这个路径” |
PRIVATE | 只有自己用 | “实现细节,别人不需要知道” |
INTERFACE | 自己不用,只给使用者 | header-only 库:自己不编译,但使用者要加路径 |
<dir>... | 路径 | 相对路径相对于当前 CMakeLists 所在目录 |
这一行其实往 target 身上写了两个属性:
target_include_directories(math PUBLIC include) │ ├─► INCLUDE_DIRECTORIES = include (自己编译用) └─► INTERFACE_INCLUDE_DIRECTORIES = include (别人用我时用)PRIVATE:只写第一个;INTERFACE:只写第二个;PUBLIC:两个都写。
(3)target_link_directories():设置库的路径
完整语法:
target_link_directories(<target> [BEFORE] <INTERFACE|PUBLIC|PRIVATE> <dir>... [<INTERFACE|PUBLIC|PRIVATE> <dir>...] ...)| 参数 | 作用 | 大白话 |
|---|---|---|
<target> | 给哪个目标加 | 必须是add_library/add_executable创建过的 |
PUBLIC | 自己链接要用,使用者也要用 | “我依赖的库文件在 lib/,用我的人也得加这个搜索路径” |
PRIVATE | 只有自己用 | “实现细节,我自己找库用的路径,别人不需要知道” |
INTERFACE | 自己不用,只给使用者 | header-only 库:自己不编译链接,但使用者要加这个路径 |
<dir>... | 库文件搜索路径 | 相对路径相对于当前 CMakeLists 所在目录,等价于 GCC 的-L参数 |
== 注意==:现代 CMake 实践中,99% 的场景都不需要、也不推荐使用target_link_directories(以及更老旧的全局link_directories),因为库文件的搜索路径这个属性,库本身就携带了
(4)target_link_libraries():链接库
完整语法
target_link_libraries(<target> <PRIVATE|PUBLIC|INTERFACE> <item>...)| 参数 | 作用 | 写进哪个属性 |
|---|---|---|
<target> | 给谁链接 | — |
PRIVATE | 只有我自己链接 | LINK_LIBRARIES |
PUBLIC | 我链接,用我的人也要链接 | LINK_LIBRARIES+INTERFACE_LINK_LIBRARIES |
INTERFACE | 我不链接,用我的人要链接 | INTERFACE_LINK_LIBRARIES |
<item>... | 库名 / target 名 / 全路径 | 推荐写 target 名 |
3. 代码展示
为了代码更加规范,我们进行了分层管理,所以CMakeLists.txt可以这样写:
四.CMake目标、属性、属性传递机制
现代 CMake 的设计核心是**「以目标为中心,用属性描述构建规则,通过链接关系自动传递使用要求」**
PUBLIC / PRIVATE / INTERFACE不是特殊魔法,本质是控制你设置的构建规则,写入目标的「自用属性表」还是「对外接口属性表」,进而决定这条规则会不会传递给链接它的下游目标
1.Target 目标 / 属性
目标是现代 CMake 最基本的构建单元,也是所有构建规则的容器。
- 创建方式:通过
add_executable()创建可执行文件目标,通过add_library()创建库目标。 - 本质:一个独立的「属性集合」。头文件路径、编译选项、依赖库、链接路径等所有构建信息,全部作为属性挂在目标上,互不干扰
每个目标的属性,逻辑上都拆成两半,对应两种使用场景。这就是你教程里说的「属性分两半」的核心。
| 属性分类 | 作用 | 给谁用 |
|---|---|---|
| 自用属性 | 编译 / 链接这个目标自身时生效的规则 | 给自己构建用 |
| 接口属性(使用要求) | 别人链接我这个目标时,必须带上的规则 | 给所有依赖我的下游目标用 |
几乎所有构建规则都有对应的「自用版」和「接口版」属性,成对出现:
| 规则类型 | 自用属性名 | 接口属性名(INTERFACE_前缀) | 对应设置命令 |
|---|---|---|---|
| 头文件搜索路径 | INCLUDE_DIRECTORIES | INTERFACE_INCLUDE_DIRECTORIES | target_include_directories |
| 编译选项 | COMPILE_OPTIONS | INTERFACE_COMPILE_OPTIONS | target_compile_options |
| 宏定义 | COMPILE_DEFINITIONS | INTERFACE_COMPILE_DEFINITIONS | target_compile_definitions |
| 依赖库 | LINK_LIBRARIES | INTERFACE_LINK_LIBRARIES | target_link_libraries |
| 库搜索路径 | LINK_DIRECTORIES | INTERFACE_LINK_DIRECTORIES | target_link_directories |
所有target_xxx系列命令,底层都是在给这两类属性赋值。三个关键字PUBLIC / PRIVATE / INTERFACE,就是用来控制:你设置的内容,写到哪张表里
2.相关函数
(1)set_target_properties()
- 语法格式
set_target_properties(target1 target2 ... PROPERTIES prop1 value1 prop2 value2 ...)- 核心特性
- 支持多目标 + 多属性批量设置,
PROPERTIES是固定关键字,其后紧跟「属性名 - 属性值」键值对。 - 覆盖式赋值:若属性已存在,新值会直接覆盖旧值;如果需要追加属性内容,应使用
set_property(TARGET ... APPEND ...),不能用此命令。 - 仅能设置可写属性,CMake 自动维护的只读属性(如
LOCATION)无法通过此命令修改
- 高频常用目标属性
| 属性名 | 功能说明 | 适用目标 |
|---|---|---|
OUTPUT_NAME | 替换目标默认输出的文件名 | 可执行文件、库 |
CXX_STANDARD | 指定 C++ 标准版本(11/14/17/20/23) | 所有编译目标 |
CXX_STANDARD_REQUIRED | 强制要求编译器支持指定 C++ 标准(ON/OFF) | 所有编译目标 |
POSITION_INDEPENDENT_CODE | 生成位置无关代码(即-fPIC,动态库通常设为 ON) | 库目标 |
VERSION | 库完整版本号(如1.2.3) | 动态库 |
SOVERSION | 库 API 兼容主版本号(如1) | 动态库 |
RUNTIME_OUTPUT_DIRECTORY | 可执行文件输出目录 | 可执行文件 |
LIBRARY_OUTPUT_DIRECTORY | 动态库输出目录 | 动态库 |
ARCHIVE_OUTPUT_DIRECTORY | 静态库输出目录 | 静态库 |
COMPILE_DEFINITIONS | 编译宏定义(对应-D参数) | 所有编译目标 |
INCLUDE_DIRECTORIES | 头文件搜索路径(对应-I参数) | 所有编译目标 |
LINK_LIBRARIES | 链接依赖库列表 | 所有编译目标 |
(2)get_target_property()
- 语法格式
get_target_property(<输出变量名> <目标名> <属性名>)- 核心行为
- 只能读取单个目标的单个属性,结果存入指定变量。
- 返回规则:
- 目标不存在:CMake 直接报错终止。
- 目标存在且属性已设置:变量值为属性的实际内容。
- 目标存在但属性未设置:变量会被赋值为
<输出变量名>-NOTFOUND(注意不是空值,是变量名拼接-NOTFOUND后缀)
(3)set_property()
- 语法格式
set_property(TARGET <目标名> [APPEND] prop1 value1)- 核心行为
- 向目标追加单条属性值,或者修改某一条属性
3.属性传递机制
这是现代 CMake 最核心的机制,也是为什么「只写target_link_libraries就能自动带头文件路径」的根本原因
传递的核心规则
当目标 A 链接目标 B 时(target_link_libraries(A PRIVATE/PUBLIC B)):
CMake 会自动把 B 的所有接口属性(INTERFACE_ 开头的),追加到 A 的自用属性中。大白话:库的作者把「用我这个库必须带什么规则」打包在自己的接口属性里;谁链接我,我就自动把这些规则塞给谁的自用规则里,使用者不用手动写
完整示例
第一步:给 math 库设置公开头文件路径
add_library(math src/math.cpp) target_include_directories(math PUBLIC include)执行后 math 的两张表:
- 自用表
INCLUDE_DIRECTORIES=include(math 自己编译时用) - 接口表
INTERFACE_INCLUDE_DIRECTORIES=include(给所有链接 math 的目标用)
第二步:hello 可执行文件链接 math
add_executable(hello main.cpp) target_link_libraries(hello PRIVATE math)此时 CMake 自动完成传递:
把 math 的INTERFACE_INCLUDE_DIRECTORIES(也就是include),追加到 hello 的INCLUDE_DIRECTORIES里。
最终结果:main 从来没写过头文件路径,但编译时自动就能找到 math 的头文件。这就是属性传递
链式传递
如果依赖有多层,传递会沿着链接链自动传播:
- 如果 B 对 C 的依赖是
PUBLIC:C 的接口属性 → B 的接口属性 → A 的自用属性,A 能完整继承 C 的所有使用要求。 - 如果 B 对 C 的依赖是
PRIVATE:C 的属性只进到 B 的自用表,不会进入 B 的接口表,A 完全感知不到 C 的存在。
这也是「内部实现依赖要写 PRIVATE」的根本原因:不会把内部依赖泄漏给下游
4.PUBLIC / PRIVATE / INTERFACE 的本质
三个关键字唯一的作用,就是控制你设置的规则,写入目标的哪张属性表。规则完全统一,对所有target_xxx命令都生效。
我们以target_link_libraries(math XXX fmt)为例(给 math 库添加 fmt 依赖):
| 关键字 | 写入自用表LINK_LIBRARIES(math 自己链接用) | 写入接口表INTERFACE_LINK_LIBRARIES(下游用) | 大白话解释 |
|---|---|---|---|
PRIVATE | ✅ 是 | ❌ 否 | fmt 只是我 math 内部实现用的,我自己链接就行,用我的人不需要知道 |
PUBLIC | ✅ 是 | ✅ 是 | fmt 不仅我内部用,我公开的头文件里也用到了 fmt,用我的人必须也链接 fmt |
INTERFACE | ❌ 否 | ✅ 是 | 我自己不用编译(比如纯头文件库),但所有用我的人都必须链接 fmt |
- 只在 .cpp 实现里用,公开头文件完全没出现→ 选
PRIVATE
比如 math 库内部用 fmt 打日志,但 math 的头文件里没有任何 fmt 的类型、函数,用户用 math 不需要知道 fmt 的存在。 - 在公开 .h 头文件里用到了(返回值、函数参数、继承、宏定义等) → 选
PUBLIC
比如 math 的头文件里,函数参数用了 fmt 的类型,那么用户包含 math 头文件时,必须能找到 fmt 的头文件,必须公开传递。 - 纯头文件库(没有 .cpp,自身不编译)→ 所有属性都用
INTERFACE
PUBLIC 泄漏
# math 内部用了 fmt,但只在 .cpp 里用 target_link_libraries(math PUBLIC fmt) # ❌ 错 target_link_libraries(math PRIVATE fmt) # ✅ 对写PUBLIC会让所有链接math的人都被迫去找fmt,增加编译时间,多余的头文件、宏也可能和下游代码产生命名冲突
结尾
OK了,这一期的CMake初识就到这里了,我们下期继续。如果对你的学习有所帮助,感谢你的点赞、收藏、关注,我主页里有更好康的呦!!
往期回顾
- 【MySQL】第4期 用户管理与 C 语言连接编程详解
- 【MySQL】第3期 索引、事务与视图系统详解
- 【MySQL】 第2期 查询与函数系统详解:从基础 CRUD 到复合多表查询