ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

【CMake】第1期 从 CMake 初识到库的构建

【CMake】第1期 从 CMake 初识到库的构建

开头

OK了,前面我们花了4期把MySQL的基础知识学完了,今天我们来学习一个全新的板块——CMake。这是啥你可能都没听过,废话不多说,我们一起来学习。

一.初识CMake

1.基本概念


如上图,一个最简单的代码,现在我们想要运行(预处理-编译-汇编-链接-可执行文件),我们只需要


能跑,但一旦项目变大,四个问题立刻出现:

  1. 平台差异:Linux 用g++、Windows 用cl.exe、macOS 用clang++,参数不一样;
  2. 增量构建:改一个文件,哪些.o要重编?手写命令做不到;
  3. 依赖管理:用到第三方库,头文件在哪、库在哪、要链哪些?
  4. 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 # 编译,产物也存放在源码目录中
  • 核心缺陷
  1. 污染源树:源码目录会混入大量自动生成的文件,无法直观区分 “手写代码” 和 “生成文件”,目录结构混乱。
  2. 清理成本高:想要清空编译产物重新构建,需要手动删除大量零散的生成文件,极易误删源码文件。
  3. 无法多配置并行:不能同时存在 Debug、Release 两种构建版本,切换编译类型必须清空全部内容、重新配置编译。

2. 源外构建

  • 定义:将构建树与源树完全分离,所有自动生成的构建、编译产物都放在独立的目录(通常命名为build)

  • 操作方法
    两种写法效果完全一致:

# 写法1:现代 CMake 标准写法(截图中的方式) cmake -S . -B build # -S 指定源目录,-B 指定构建目录 cmake --build build # 执行编译 # 写法2:传统手动进入目录的写法 mkdir build && cd build cmake .. make
  • 核心优势
  1. 源树保持纯净:源码目录永远只有人工维护的文件,结构清晰;Git 仅需忽略build/一个目录即可。
  2. 支持多配置并行:可以同时创建build_debug、build_release等多个构建目录,分别配置不同的编译选项、编译类型,互不干扰、无需重复配置。
cmake -S . -B build_debug -DCMAKE_BUILD_TYPE=Debug cmake -S . -B build_release -DCMAKE_BUILD_TYPE=Release
  1. 重置成本极低:想要彻底清空构建、解决缓存异常问题,只需rm -rf build删除整个构建目录,完全不会影响源码,相当于 “一键重来”。
  2. 安全性更高:不会因为构建脚本的问题意外修改、覆盖源码文件,更适合大型多人协作项目

二.构建一个可执行文件

1.示例

前面我们写了一个简单的main.cpp,我们现在用 CMake 的语法来构建

  1. 首先就是创建一个CMakeLists.txt文件(注意大小写)

  2. 编写CMakeLists.txt

  3. 构建

  4. 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/
WIN32Windows GUI 程序入口是WinMain而不是main,控制台程序不要写
MACOSX_BUNDLEmacOS.app包跨平台项目才用
EXCLUDE_FROM_ALL默认不构建只有--target hello才编,适合示例/工具

它创建的不只是一个文件,而是一个 target——这是 CMake 的核心概念

3.构建树

跑完cmake -S . -B build后,build/构建目录里会多出:

  1. makefile
  • 本质:CMake 生成的原生构建脚本。
  • 作用:Linux下 CMake 默认生成 Makefile 构建系统,后续执行make或cmake --build build时,就是读取这个文件,按照里面定义的编译、链接、清理规则来执行编译

补充:如果是 Windows + Visual Studio 环境,这里对应的会是.sln解决方案和.vcxproj工程文件


  1. CMakeCache.txt
  • 本质:CMake 的变量缓存文件,是构建目录里最核心的文件之一
  • 作用:
    • 第一次配置时,CMake 会把检测到的编译器路径、编译选项、用户自定义变量(比如CMAKE_BUILD_TYPE、CMAKE_INSTALL_PREFIX)、第三方库的查找结果,全部缓存到这个文件里。
    • 后续重新执行cmake配置时,会直接读取这份缓存,不需要重新做编译器检测、变量计算,大幅加快配置速度。
    • 你修改CMakeLists.txt后重新配置,CMake 会基于这份缓存做增量更新。
  • 常见用法:想彻底重置配置、清空所有缓存时,删除这个文件(或直接删整个 build 目录)即可

  1. cmake_install.cmake
  • 本质:安装阶段的执行脚本。
  • 作用:里面写好了所有安装规则 —— 哪些可执行文件 / 库要复制到安装目录、公开头文件放哪、权限如何设置等。当你执行cmake --install build时,CMake 就是运行这个脚本,完成最终的文件部署

  1. 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 最基本的构建单元,也是所有构建规则的容器。

  1. 创建方式:通过add_executable()创建可执行文件目标,通过add_library()创建库目标。
  2. 本质:一个独立的「属性集合」。头文件路径、编译选项、依赖库、链接路径等所有构建信息,全部作为属性挂在目标上,互不干扰

每个目标的属性,逻辑上都拆成两半,对应两种使用场景。这就是你教程里说的「属性分两半」的核心。

属性分类作用给谁用
自用属性编译 / 链接这个目标自身时生效的规则给自己构建用
接口属性(使用要求)别人链接我这个目标时,必须带上的规则给所有依赖我的下游目标用

几乎所有构建规则都有对应的「自用版」和「接口版」属性,成对出现:

规则类型自用属性名接口属性名(INTERFACE_前缀)对应设置命令
头文件搜索路径INCLUDE_DIRECTORIESINTERFACE_INCLUDE_DIRECTORIEStarget_include_directories
编译选项COMPILE_OPTIONSINTERFACE_COMPILE_OPTIONStarget_compile_options
宏定义COMPILE_DEFINITIONSINTERFACE_COMPILE_DEFINITIONStarget_compile_definitions
依赖库LINK_LIBRARIESINTERFACE_LINK_LIBRARIEStarget_link_libraries
库搜索路径LINK_DIRECTORIESINTERFACE_LINK_DIRECTORIEStarget_link_directories

所有target_xxx系列命令,底层都是在给这两类属性赋值。三个关键字PUBLIC / PRIVATE / INTERFACE,就是用来控制:你设置的内容,写到哪张表里

2.相关函数

(1)set_target_properties()

  1. 语法格式
set_target_properties(target1 target2 ... PROPERTIES prop1 value1 prop2 value2 ...)
  1. 核心特性
  • 支持多目标 + 多属性批量设置,PROPERTIES是固定关键字,其后紧跟「属性名 - 属性值」键值对。
  • 覆盖式赋值:若属性已存在,新值会直接覆盖旧值;如果需要追加属性内容,应使用set_property(TARGET ... APPEND ...),不能用此命令。
  • 仅能设置可写属性,CMake 自动维护的只读属性(如LOCATION)无法通过此命令修改
  1. 高频常用目标属性
属性名功能说明适用目标
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()

  1. 语法格式
get_target_property(<输出变量名> <目标名> <属性名>)
  1. 核心行为
  • 只能读取单个目标的单个属性,结果存入指定变量。
  • 返回规则:
    1. 目标不存在:CMake 直接报错终止。
    2. 目标存在且属性已设置:变量值为属性的实际内容。
    3. 目标存在但属性未设置:变量会被赋值为<输出变量名>-NOTFOUND(注意不是空值,是变量名拼接-NOTFOUND后缀)

(3)set_property()

  1. 语法格式
set_property(TARGET <目标名> [APPEND] prop1 value1)
  1. 核心行为
  • 向目标追加单条属性值,或者修改某一条属性

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初识就到这里了,我们下期继续。如果对你的学习有所帮助,感谢你的点赞、收藏、关注,我主页里有更好康的呦!!

往期回顾

  1. 【MySQL】第4期 用户管理与 C 语言连接编程详解
  2. 【MySQL】第3期 索引、事务与视图系统详解
  3. 【MySQL】 第2期 查询与函数系统详解:从基础 CRUD 到复合多表查询
返回列表