参考资料:
【现代C++: CMake简明教程】www.bilibili.com/video/BV1xa…
【CMake 保姆级教程【C/C++】】 www.bilibili.com/video/BV14s…
初次使用
- 安装 cmake:
sudo apt install cmake - 检查安装结果:
cmake --version - 创建一个 cpp 文件,并写源码。假设这个文件名为:
hello.cpp - 在 cpp 项目目录下创建一个空文本文件,名字写成:
CMakeLists.txt - 在这个 txt 文件里写以下内容:
# 指定最低的cmake工具版本号
cmake_minimum_required(VERSION 3.20)
# 指定本工程的工程名字
project(my_project)
# 指定编译结果文件名 以及用什么文件来编译
add_executable(hello_app hello.cpp)
- 运行 cmake:
cmake --build build - 运行编译后的程序,验证编译成功(注意在 build 文件夹下):
./build/hellp_app
构建指令说明
对于构建:一般来说是分成两步的,先用 CMake 生成 makefile,再用 make 构建。
所以传统 CMake 的操作流程为:
mkdir build; cd build; cmake ..; make
但是这四条命令太繁琐了,有更加现代的语法进行替换:
cmake -B build:CMake 帮你创建 build 目录并构建 makefilecmake --build build:CMake 帮你调用 make 进行构建
多文件编译
做法
如果是多文件,那么 add_executable(输出文件名 源文件名列表)
这里的源文件名列表,需要把所有的源代码文件列出来,你可以采用空格 ``进行分隔也可以采用分号 ; 进行分隔。
(这和 g++ *.cpp -o my_app 有异曲同工之妙)
但是手写所有的文件名太累了,所以可以采用文件搜索功能!
注意点:不要使用文件搜索aux_source_directory 和 file(GLOB)
CMake 的运行机制是:只有当 CMakeLists.txt 被修改时,它才会在下次 make 时重新生成构建规则。如果你用 GLOB 自动搜索,当你在文件夹里新建了一个 test.cpp,由于 CMakeLists.txt 文件的内容没有发生任何改变,CMake 根本不知道多了一个文件! 你点编译,新文件完全不会被编进去,这种幽灵 Bug 会让你排查到崩溃。
现代做法: 像写强类型语言一样,老老实实地把所有的源文件显式地写出来:set(SRC main.cpp a.cpp b.cpp)。哪怕有 20 个文件,也给我一行行写清楚!这是工业界最基本的严谨。
编译命令
首先,由于 CMakeLists.txt 这个文件名是被强制要求的,所以 CMake 永远会对着这个文件名去运行,所以你不需要把这个名字作为参数输入。
然后,假设你当前的工作文件夹下就有这个文件,你可以直接使用 cmake . 来进行编译。
但是这会使得所有的编译过程文件全都出现在当前文件夹下,所以你还可以这样:
mkdir build 接着 cd build 最后 cmake .. 就是告诉 CMake 在哪个目录下找这个文件。
最后要运行 make 来编译(前面只是使用 CMake 生成 Makefile)
CMake 语言:变量
set
- 设置变量:
set(变量名 变量内容):CMake 的变量默认都是字符串,且和 Shell 类似,需要使用${val_name}来访问
- 变量合并:可以这样把 b 变量的内容追加到 a 中:
set(a a b)
实例:
# 设置变量,注意后面的多个文件名会合并成一个字符串!
set(SRC_LIST main.cpp a.cpp b.cpp c.cpp) # 也可以用;分隔
add_executable(myApp ${SRC_LIST}) # 别忘了${}
# 通过设置 宏变量 来指定C++标准,可以是11/14/17/20...
set(CMAKE_CXX_STANDARD 11)
对于设置 C++标准,你还可以这样:
cmake CMakeLists.txt -DCMAKE_CXX_STANDARD=11
请注意,这里的 -D 表示设置宏,这条命令和直接写入到文件中效果等价
文件搜索(一般不建议使用)
aux_source_directory(<dir> variable):把指定路径下搜索到的源文件存储到变量里(自动检索 .c 和 .cpp 这两类文件)file(GLOB/GLOB_RECURES 变量名 要找的目录和文件类型):
-
- GLOB:只找当前目录
- GLOB_RECURES:递归搜索
- 可以使用通配符
- 宏:
${PROJECT_SOURCE_DIR}:这个宏指代的是 整个项目最上层的 CMakeLists.txt 所在的路径,在指定路径时,可以使用这个宏变量来辅助 - 宏:
${CMAKE_CURRENT_SOURCE_DIR}:当前的 CMakeLists.txt 所在的路径
指定头文件路径
- 可以使用
tree命令先查看项目结构,然后推荐的项目结构可能如下:
.
├── CMakeLists.txt
├── build
├── include
│ └── head.h
└── src
├── a.cpp
├── b.cpp
├── c.cpp
└── main.cpp
- 此时由于 main.cpp 和 .h 文件不在同一级目录,直接编译会显示找不到头文件
include_directories(headpath):用这条命令设置头文件路径
库相关
文件后缀
基础概念:文件名
| 平台 | 动态库 | 静态库 |
|---|---|---|
| Linux | libxxx.so | libxxx.a |
| Win | libxxx.dll | libxxx.lib |
制作库相关命令
首先,由于是制作 库 而不是可执行程序,
所以,不需要指定可执行文件的输出路径,
也不需要指定输出可执行文件,
也就是不需要写 add_executable
此外,还需要注意的是,不要把 main.cpp 加入到源文件列表里!
因为库文件就是没有主函数的!
(比如说,可以把 main.cpp 移动到 src 文件夹之外)
- 制作动态库(共享库):
add_library(库的名称 SHARED 源文件列表)
- 制作静态库命令:
add_library(库的名称 STATIC 源文件列表)
- 执行
cmake和make命令之后,就会生成库文件了 - 库在发布时,必须同时将 生成的库文件和相应的头文件 一起发布!
库的现代写法
注意点:不要使用全局命令,现代 CMake 只有 target_...
include_directories 和 link_libraries 是全局生效的。如果你的主工程下有 A 和 B 两个程序,A 需要链接静态库 XYZ,B 不需要。如果你用了 link_libraries(XYZ),B 也会被强行塞进这个静态库,导致最终编译出的二进制文件极其臃肿!
CMake 生成动态/静态库
file():用于文件搜索add_library(库名称 STATIC ${SRC}):生成静态库add_library(库名称 SHARED ${SRC}):生成动态库${LIBRARY_OUTPUT_PATH}:库文件输出路径的宏target_include_directories(库名称 权限 文件夹):指定依赖头文件的路径
因为要求有库名称,所以指定头文件的指令写在生成库的指令之后
此外注意:库在发布时,必须同时将 生成的库文件和相应的头文件 一起发布!
调用库
调用动态库步骤:
- 引入头文件
- 声明库目录
- 生成可执行程序
- 链接库(链接写在生成之后!)
实例 1:生成一个库
add_library(MyLib
src/MyLib.cpp
src/Helper.cpp
)
# 1. 头文件路径
# PUBLIC: 因为 MyLib.h 里 include 了第三方库的头文件,用户使用 MyLib 时也需要看到第三方库的头文件。
# PRIVATE: 仅仅在 .cpp 里用到的内部头文件目录。
target_include_directories(MyLib
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> # 给外部使用者看的头文件
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src # 内部实现用的头文件
${CMAKE_CURRENT_SOURCE_DIR}/3rdparty # 内部依赖,不想暴露给用户
)
# 2. 链接库
target_link_libraries(MyLib
PUBLIC Boost::boost # 头文件里用了 boost,必须 PUBLIC
PRIVATE OpenSSL::SSL # 仅 .cpp 里用了 ssl,隐藏实现细节,降低下游依赖
)
实例 2:生成可执行程序
add_executable(MyApp main.cpp)
# 头文件路径 (通常 PRIVATE,除非这个 exe 导出符号给插件用,但这很少见)
target_include_directories(MyApp PRIVATE ${PROJECT_SOURCE_DIR}/include)
# 链接库 (PRIVATE 即可,因为没人会链接 MyApp)
target_link_libraries(MyApp PRIVATE MyLib)
理解性补充
- 不论生成的库还是可执行程序,调用库的方法都是一样的,都是在 add 语句之后使用
-
target_link_libraries(当前目标 权限 ... 依赖目标)# 库依赖列表target_include_directories(...)# 头文件目录列表- (可选)
target_compile_features(当前目标 权限 语言特性)
- 不论是调用静态库还是动态库,CMake指令都是一样的
-
- 说明:现代 CMake 最爽的点就在于,虽然在底层,链接动态库和静态库的方法不同,生成可执行程序和库的方法也不同。但是 CMake 上层帮你屏蔽掉了这一切的不同,你写 CMake 就当作是一样的。
- 绝大多数情况下,生成静态库和动态库,就是只有一个 STATIC 和 SHARED 不一样
-
- 你可以利用变量来控制它,比如通常项目的标准写法:
# 用户可以通过 -DBUILD_SHARED_LIBS=ON/OFF 来切换
- 你可以利用变量来控制它,比如通常项目的标准写法:
add_library(MyLib src.cpp)
-
- 在 Linux/macOS 下,你改个 SHARED 就完事了。在 Windows 下,如果你的代码没有做符号导出处理,直接改成 SHARED 可能会导致生成的库无法使用。但这是一个源代码层面的问题,而不是 CMake 指令的问题。
- 你想生成可执行程序还是库,就看你用哪条 add 指令
-
- 生成程序:
add_executable(当前目标 源码列表) - 生成库:
add_library(当前目标 源码列表) - 注意源码列表的 main.cpp 和 main() 函数必须出现在程序中,而不能出现在库中
- 生成程序:
- 一个现代化实例:对于语言标准的确定
-
- 传统做法:
set(CMAKE_CXX_STANDARD ...)这是全局变量写法,多层级时可能会覆盖别的标准设置 - 现代写法:
target_compile_options(... -std=c++11)这是目标级编译选项,它是附在目标上的,不是全局污染。 - 现代最佳实践:
target_compile_features(mylib PUBLIC cxx_std_11)这样表明最低要求 C++11,实际上可能会采用更高标准,但是一定保证满足你用到的语言特性
- 传统做法:
消息打印
语法:message([消息等级] "消息内容") 可以打印变量
消息等级参数:
- 不写:重要消息
STATUS:非重要消息(只有这个是标准输出)WARNING:警告,但是会继续执行AUTHOR_WARNING:警告(dev),但是会继续执行SEND_ERROR:错误,会继续执行,但是会跳过生成步骤FATAL_ERROR:错误,终止所有处理过程
字符串操作
- 字符串拼接:
set(变量名 ${拼接变量1} ${拼接变量2} ...)# 同理可以使用分号;分隔(也可以直接写字符串,不一定必须是变量) - 追加:
list(APPEND 变量名 要追加到后面的变量列表) - 移除:
list(REMOVE_ITEM 变量名 要删除的变量列表)(要完全匹配!!!) - 获取元素个数:
list(LENGTH 要计算长度的变量列表 接收长度变量名) - 获取指定索引元素:
list(GET 变量列表 索引值 接收数据的变量名) - 用指定连接符连接:
list(JOIN 变量列表 分隔符 合并后的变量名) - 查找元素索引:
list(FIND 变量列表 查找元素 输出变量名)# 没有就返回-1 - 插入若干元素:
list(INSERT 变量列表 插入位置索引 插入值列表) - 插入头部:
list(REPEND 变量列表 插入的元素列表) - 移除最后元素:
list(POP_BACK 变量列表 [存弹出的变量名]) - 移除头部元素:
list(POP_FRONT 变量列表 [存弹出的变量名]) - 移除指定索引元素:
list(REMOVE_AT 变量列表 索引列表) - 移除重复元素:
list(REMOVE_DUPLICATES 变量列表) - 列表翻转:
list(REVERSE 变量列表) - 列表排序:
list(SORT 变量列表 一些可选参数)
自定义宏
这里的自定义宏是写在 CMakeLists.txt 的,并且在程序源码里,可以用
#ifdef 来读取这些宏是否被定义。
定义宏语法:add_definitions(-D宏名字) # 可以定义多个,都是 -D 宏名字 并用空格分开就行
嵌套 CMake
嵌套规则
- 最核心的语法是,增加子节点(子目录):
add_subdirectory(子文件夹名) - 项目的根目录底下有一个 CMakeLists.txt 编译项目的时候只需要编译这一个就行了,让这个一个去调用各个子层级的 CMakeLists.txt
- 对于子层级,一般是,一个库(或一个 mian 项目,也就是要生成一个可执行程序的目录)底下有一个单独的 CMakeLists.txt 用来单独编译这个库(或程序)
- 父级的 CMakeLists.txt 中定义的变量可以被子层级调用,反之不行
实例
0) 项目树
项目结构如下,包含两个库 labc 和 lxyz,以及生成两个可执行程序 test1 和 test2:
.
├── CMakeLists.txt
├── include
│ ├── labc.h
│ └── lxyz.h
├── labc
│ ├── CMakeLists.txt
│ ├── a.cpp
│ ├── b.cpp
│ └── c.cpp
├── lxyz
│ ├── CMakeLists.txt
│ ├── x.cpp
│ ├── y.cpp
│ └── z.cpp
├── test1
│ ├── CMakeLists.txt
│ └── main.cpp
└── test2
├── CMakeLists.txt
└── main.cpp
1) 根目录 CMake 内容
对于主目录下的 CMake 文件,内容如下:
# 1. CMake 最小版本限制
cmake_minimum_required(VERSION 3.20)
# 2. 项目名称
project(myApp)
message("当前项目路径: ${PROJECT_SOURCE_DIR}")
# 定义变量用于后续操作(主要是为了被子文件调用)
# 1 设置静态库编译后的输出路径为./lib
set(LIB_PATH ${PROJECT_SOURCE_DIR}/lib)
# 2 设置可执行程序编译后的输出路径为./bin
set(EXEC_PATH ${PROJECT_SOURCE_DIR}/bin)
# 3 设置头文件的查找路径(要和你的实际路径保持一致!)
set(HEAD_PATH ${PROJECT_SOURCE_DIR}/include)
# 4 所调用的库的名字,也用变量保存(后续被子文件调用)
set(LABC_LIB labc)
set(LXYZ_LIB lxyz)
# 5 要构建的可执行程序的名字,也用变量保存(后续被子文件调用)
set(APP_NAME_1 myApp1)
set(APP_NAME_2 myApp2)
# 给当前节点增加子节点! 传入的是子文件夹的名字!
add_subdirectory(labc)
add_subdirectory(lxyz)
add_subdirectory(test1)
add_subdirectory(test2)
以后可以根据需要新增内容。
2) 库目录 CMake 内容
cmake_minimum_required(VERSION 3.20)
project(labc)
# 搜索源文件到变量SRC
# 由于这个cmake文件和源码放在同一目录所以用./
aux_source_directory(./ SRC)
# 指定头文件路径,这里调用了父节点变量
include_directories(${HEAD_PATH})
# 设置生成的库文件输出位置,采用宏变量和父节点变量
set(LIBRARY_OUTPUT_PATH ${LIB_PATH})
# 这是个库,设置生成库的名字和源文件,名字用父节点变量
add_library(${LABC_LIB} STATIC ${SRC})
对于 lxyz,只需要修改第 2 行的 labc 和第 12 行就行 LABC_LIB 就可以了。
3) 子项目 CMake 内容
cmake_minimum_required(VERSION 3.20)
project(test1)
# 搜索源代码路径,存入变量SRC
aux_source_directory(./ SRC)
# 指定头文件路径,用父节点变量
include_directories(${HEAD_PATH})
# 想要调用库,只有库的名字不一定找得到,所以还需要库路径
# - 指定库文件路径,用父节点变量
link_directories(${LIB_PATH})
# - 指定依赖库名字,用父节点变量
link_libraries(${LABC_LIB})
link_libraries(${LXYZ_LIB})
# 设置生成可执行文件的路径(宏变量),用父节点变量
set(EXECUTABLE_OUTPUT_PATH ${EXEC_PATH})
# 指定生成可执行程序
add_executable(${APP_NAME_1} ${SRC})
对于 test2,只需要修改第 2 行的 test1 和第 18 行的 APP_NAME_1 就可以了。
4) 构建命令
在项目根目录执行
cmake -B buildcmake --build build
CMake 与源文件交互
当你阅读源码的时候会经常看到这个!
也就是让 C 程序能够读取到 CMake 的变量
(实际的生产环境中,可以先用 CMake 读取到 Linux 的环境变量,然后再传给 C 程序源码)
举例:
- 先创建一个
config.h.in文件,里面写入 CMake 需要传出的变量:
#define CMAKE_CXX_STANDARD ${CMAKE_CXX_STANDARD}
// 定义一个宏,然后用 ${var_name} 来捕获外界的变量
- 再在
CMakeLists.txt中写:
# 这里第二行是计划被捕获出去的变量(宏)
set(CMAKE_CXX_STANDARD 11) # 希望使用C++ 11标准
set(CMAKE_CXX_STANDARD_REQUIRED True) # 强制使用希望的标准
# 把config.h.in中捕获的变量都传给config.h
configure_file(config.h.in config.h)
# 请注意,我们不需要手动创建config.h
# 这个文件将会由CMake自动生成
- 最后在 C 程序源码里写:
#include <iostream>
// 下面这行导入一开始会报错,文件找不到
#include "config.h"
// 因为你没有使用cmake构建的话,这个文件是不会被生成的!
int main()
{
std::cout << "CMAKE_CXX_STANDARD" << std::endl;
// 会打印出 11
}
- 构建命令
cmake -B build
cmake --build build
条件编译
条件编译的基本流程
通过传入不同的参数来编译不同的文件
- 用
option定义变量 - 在子
CMakeLists.txt中根据变量是ON还是OFF,来修改SRC(源文件)以及target_complie_definitions - 修改源文件,修改成根据变量选择代码
- 执行构建命令时
-D<变量>=ON/OFF来进行条件编译
一般来说,ON 代表多编译一些东西,OFF 代表少编译或者不编译一些东西
继承权限说明
PUBLIC:本目标 需要 用,依赖这个目标的其他目标 也需要INTERFACE:本目标 不需要 ,但是依赖本目标的其他目标 需要PRIVATE:本目标 需要 ,依赖这个目标的其他目标 不需要
表格:
| 关键字 | 对于 本目标 | 对于依赖本目标的 其他目标 |
|---|---|---|
PUBLIC | 需要 | 需要 |
INTERFACE | 不需要 | 需要 |
PRIVATE | 需要 | 不需要 |
条件编译举例
- 比如说你有一个库,在这个库下的(子级的) CMakeLists.txt 里,内容或许为:
# 下面这行option是指,如果不指定,那么默认USE_CATTWO参数为ON
option(USE_CATTWO "Use cat two" ON)
# 有的时候更建议默认为OFF
if(USE_CATTWO) # 如果USE_CATTWO为ON
set(SRC cat.cpp dog.cpp cattwo.cpp)
else()
set(SRC cat.cpp dog.cpp) # 少一个cpp文件
endif()
# 源文件定义为SRC,前面因为条件编译,所以SRC内容是根据条件不同的
add_library(AnimalLib SRC)
# 这行add就会生成一个名为AnimalLib的库
if(USE_CATTWO)
target_compile_definition(AnimalLib PRIVATE "USE_CATTWO")
# 这个编译时的宏定义是私有的,也就是只对AnimalLib这一个库生效
endif()
- 同理的,在 C 源码中可以这样
#ifdef USE_CATTWO // 取自13行的宏定义
#include "cattwo.h"
// 对应了cattwo.cpp
#endif
- 编译时指定开关
cmake -B build -DUSE_CATTWO=OFF # 注意这里指定了宏为关闭
cmake --build build
补充
关于你提到的“函数、循环、作用域”到底要不要补习:
绝对不需要!坚决跳过!
除非你是去给 OpenCV 或者 Linux 内核写那种包含几百个子模块的极其庞大的底层构建脚本,否则你根本用不到 CMake 的 function 和 foreach。
在端侧 AI 部署的真实场景里,CMake 的核心作用只有一个:把源代码、头文件和第三方依赖库精准地捏合在一起,编译出二进制文件。 把有限的精力集中在目标(Target)的构建上,不要去学 CMake 那极其难用的脚本编程语法。
毒药 1:滥用 aux_source_directory 和 file(GLOB)
- 笔记写道: 可以用这两个命令自动检索
.cpp文件,不用手写,非常方便。 - 工业界真相:绝对禁止使用!
- 为什么极其危险? CMake 的运行机制是:只有当
CMakeLists.txt被修改时,它才会在下次make时重新生成构建规则。如果你用GLOB自动搜索,当你在文件夹里新建了一个test.cpp,由于CMakeLists.txt文件的内容没有发生任何改变,CMake 根本不知道多了一个文件! 你点编译,新文件完全不会被编进去,这种幽灵 Bug 会让你排查到崩溃。 - 现代做法: 像写强类型语言一样,老老实实地把所有的源文件显式地写出来:
set(SRC main.cpp a.cpp b.cpp)。哪怕有 20 个文件,也给我一行行写清楚!这是工业界最基本的严谨。
毒药 2:使用全局污染指令 include_directories 和 link_libraries
- 笔记写道: 静态库的链接写在
add_executable之前用link_libraries,动态库写在之后用target_link_libraries。 - 工业界真相:现代 CMake 只有
target_...,忘掉全局指令! - 为什么极其危险?
include_directories和link_libraries是全局生效的。如果你的主工程下有 A 和 B 两个程序,A 需要链接静态库 XYZ,B 不需要。如果你用了link_libraries(XYZ),B 也会被强行塞进这个静态库,导致最终编译出的二进制文件极其臃肿! - 现代做法(Target-Centric 目标驱动):
无论静态库还是动态库,无论头文件还是宏定义,一律加前缀 target_ ,并配合你笔记里那个极好的 PUBLIC/PRIVATE/INTERFACE 权限控制表!
-
- 指定头文件:
target_include_directories(目标名 PRIVATE 路径) - 链接库:
target_link_libraries(目标名 PRIVATE 库名)
- 指定头文件:
这样,所有的依赖就像背包一样,只挂在特定的“目标”身上,绝不污染全局。
第三方库的导入
你的笔记里写了怎么自己制作静态库和动态库,这很好。但残酷的现实是:在端侧部署中,你 90% 的时间是在调用别人写好的超大型库(比如 OpenCV、Eigen 矩阵库、NCNN 推理框架)。
你不可能去源码里一个个找他们的 .so 和 .h 文件路径,你需要学习 CMake 最强大的魔法:find_package。
补充知识点:如何使用 find_package
- 核心逻辑:
只要你的电脑上安装了 OpenCV,你只需要在 CMake 里极其优雅地喊一句:
find_package(OpenCV REQUIRED)
CMake 就会自动翻出 OpenCV 的所有头文件路径和动态库所在位置,并把它们打包进 ${OpenCV_INCLUDE_DIRS} 和 ${OpenCV_LIBS} 这两个变量里。
- 终极连招:添加第三方库的标准做法
add_executable(my_video_app main.cpp)
target_include_directories(my_video_app PRIVATE ${OpenCV_INCLUDE_DIRS})
target_link_libraries(my_video_app PRIVATE ${OpenCV_LIBS})
就这短短三行,你的程序瞬间就拥有了处理图像和视频流的能力!这就是你未来最常写的 CMake 结构。