【08】CMake入门

8 阅读10分钟

参考资料:

【现代C++: CMake简明教程】www.bilibili.com/video/BV1xa…

【CMake 保姆级教程【C/C++】】 www.bilibili.com/video/BV14s…

初次使用

  1. 安装 cmake:sudo apt install cmake
  2. 检查安装结果:cmake --version
  3. 创建一个 cpp 文件,并写源码。假设这个文件名为:hello.cpp
  4. 在 cpp 项目目录下创建一个空文本文件,名字写成:CMakeLists.txt
  5. 在这个 txt 文件里写以下内容:
# 指定最低的cmake工具版本号
cmake_minimum_required(VERSION 3.20)

# 指定本工程的工程名字
project(my_project)

# 指定编译结果文件名 以及用什么文件来编译
add_executable(hello_app hello.cpp)
  1. 运行 cmake:cmake --build build
  2. 运行编译后的程序,验证编译成功(注意在 build 文件夹下):./build/hellp_app

构建指令说明

对于构建:一般来说是分成两步的,先用 CMake 生成 makefile,再用 make 构建。

所以传统 CMake 的操作流程为:

  • mkdir build; cd build; cmake ..; make

但是这四条命令太繁琐了,有更加现代的语法进行替换:

  1. cmake -B build:CMake 帮你创建 build 目录并构建 makefile
  2. cmake --build build:CMake 帮你调用 make 进行构建

多文件编译

做法

如果是多文件,那么 add_executable(输出文件名 源文件名列表)

这里的源文件名列表,需要把所有的源代码文件列出来,你可以采用空格 ``进行分隔也可以采用分号 ; 进行分隔。

(这和 g++ *.cpp -o my_app 有异曲同工之妙)

但是手写所有的文件名太累了,所以可以采用文件搜索功能!

注意点:不要使用文件搜索aux_source_directoryfile(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

  1. 设置变量: set(变量名 变量内容):CMake 的变量默认都是字符串,且和 Shell 类似,需要使用 ${val_name} 来访问
  1. 变量合并:可以这样把 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 表示设置宏,这条命令和直接写入到文件中效果等价

文件搜索(一般不建议使用)

  1. aux_source_directory(<dir> variable):把指定路径下搜索到的源文件存储到变量里(自动检索 .c 和 .cpp 这两类文件)
  2. file(GLOB/GLOB_RECURES 变量名 要找的目录和文件类型)
    1. GLOB:只找当前目录
    2. GLOB_RECURES:递归搜索
    3. 可以使用通配符
  1. 宏:${PROJECT_SOURCE_DIR}:这个宏指代的是 整个项目最上层的 CMakeLists.txt 所在的路径,在指定路径时,可以使用这个宏变量来辅助
  2. 宏:${CMAKE_CURRENT_SOURCE_DIR}当前的 CMakeLists.txt 所在的路径

指定头文件路径

  1. 可以使用 tree 命令先查看项目结构,然后推荐的项目结构可能如下:
.
├── CMakeLists.txt
├── build
├── include
│   └── head.h
└── src
    ├── a.cpp
    ├── b.cpp
    ├── c.cpp
    └── main.cpp
  1. 此时由于 main.cpp 和 .h 文件不在同一级目录,直接编译会显示找不到头文件
  2. include_directories(headpath):用这条命令设置头文件路径

库相关

文件后缀

基础概念:文件名

平台动态库静态库
Linuxlibxxx.solibxxx.a
Winlibxxx.dlllibxxx.lib

制作库相关命令

首先,由于是制作 库 而不是可执行程序,

所以,不需要指定可执行文件的输出路径,

也不需要指定输出可执行文件,

也就是不需要写 add_executable

此外,还需要注意的是,不要把 main.cpp 加入到源文件列表里!

因为库文件就是没有主函数的!

(比如说,可以把 main.cpp 移动到 src 文件夹之外)

  1. 制作动态库(共享库):

add_library(库的名称 SHARED 源文件列表)

  1. 制作静态库命令:

add_library(库的名称 STATIC 源文件列表)

  1. 执行 cmakemake 命令之后,就会生成库文件了
  2. 库在发布时,必须同时将 生成的库文件和相应的头文件 一起发布!

库的现代写法

注意点:不要使用全局命令,现代 CMake 只有 target_...

include_directorieslink_libraries全局生效的。如果你的主工程下有 A 和 B 两个程序,A 需要链接静态库 XYZ,B 不需要。如果你用了 link_libraries(XYZ),B 也会被强行塞进这个静态库,导致最终编译出的二进制文件极其臃肿!

CMake 生成动态/静态库

  1. file():用于文件搜索
  2. add_library(库名称 STATIC ${SRC}):生成静态库
  3. add_library(库名称 SHARED ${SRC}):生成动态库
  4. ${LIBRARY_OUTPUT_PATH}:库文件输出路径的宏
  5. target_include_directories(库名称 权限 文件夹):指定依赖头文件的路径

因为要求有库名称,所以指定头文件的指令写在生成库的指令之后

此外注意:库在发布时,必须同时将 生成的库文件和相应的头文件 一起发布!

调用库

调用动态库步骤:

  1. 引入头文件
  2. 声明库目录
  3. 生成可执行程序
  4. 链接库(链接写在生成之后!)

实例 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)

理解性补充

  1. 不论生成的库还是可执行程序,调用库的方法都是一样的,都是在 add 语句之后使用
    1. target_link_libraries(当前目标 权限 ... 依赖目标) # 库依赖列表
    2. target_include_directories(...) # 头文件目录列表
    3. (可选) target_compile_features(当前目标 权限 语言特性)
  1. 不论是调用静态库还是动态库,CMake指令都是一样的
    1. 说明:现代 CMake 最爽的点就在于,虽然在底层,链接动态库和静态库的方法不同,生成可执行程序和库的方法也不同。但是 CMake 上层帮你屏蔽掉了这一切的不同,你写 CMake 就当作是一样的。
  1. 绝大多数情况下,生成静态库和动态库,就是只有一个 STATIC 和 SHARED 不一样
    1. 你可以利用变量来控制它,比如通常项目的标准写法:
      # 用户可以通过 -DBUILD_SHARED_LIBS=ON/OFF 来切换

add_library(MyLib src.cpp)

    1. 在 Linux/macOS 下,你改个 SHARED 就完事了。在 Windows 下,如果你的代码没有做符号导出处理,直接改成 SHARED 可能会导致生成的库无法使用。但这是一个源代码层面的问题,而不是 CMake 指令的问题。
  1. 你想生成可执行程序还是库,就看你用哪条 add 指令
    1. 生成程序:add_executable(当前目标 源码列表)
    2. 生成库:add_library(当前目标 源码列表)
    3. 注意源码列表的 main.cpp 和 main() 函数必须出现在程序中,而不能出现在库中
  1. 一个现代化实例:对于语言标准的确定
    1. 传统做法:set(CMAKE_CXX_STANDARD ...) 这是全局变量写法,多层级时可能会覆盖别的标准设置
    2. 现代写法:target_compile_options(... -std=c++11) 这是目标级编译选项,它是附在目标上的,不是全局污染。
    3. 现代最佳实践:target_compile_features(mylib PUBLIC cxx_std_11) 这样表明最低要求 C++11,实际上可能会采用更高标准,但是一定保证满足你用到的语言特性

消息打印

语法:message([消息等级] "消息内容") 可以打印变量

消息等级参数:

  • 不写:重要消息
  • STATUS:非重要消息(只有这个是标准输出)
  • WARNING:警告,但是会继续执行
  • AUTHOR_WARNING:警告(dev),但是会继续执行
  • SEND_ERROR:错误,会继续执行,但是会跳过生成步骤
  • FATAL_ERROR:错误,终止所有处理过程

字符串操作

  1. 字符串拼接:set(变量名 ${拼接变量1} ${拼接变量2} ...) # 同理可以使用分号 ; 分隔(也可以直接写字符串,不一定必须是变量)
  2. 追加:list(APPEND 变量名 要追加到后面的变量列表)
  3. 移除:list(REMOVE_ITEM 变量名 要删除的变量列表) (要完全匹配!!!)
  4. 获取元素个数:list(LENGTH 要计算长度的变量列表 接收长度变量名)
  5. 获取指定索引元素:list(GET 变量列表 索引值 接收数据的变量名)
  6. 用指定连接符连接:list(JOIN 变量列表 分隔符 合并后的变量名)
  7. 查找元素索引:list(FIND 变量列表 查找元素 输出变量名) # 没有就返回-1
  8. 插入若干元素:list(INSERT 变量列表 插入位置索引 插入值列表)
  9. 插入头部:list(REPEND 变量列表 插入的元素列表)
  10. 移除最后元素:list(POP_BACK 变量列表 [存弹出的变量名])
  11. 移除头部元素:list(POP_FRONT 变量列表 [存弹出的变量名])
  12. 移除指定索引元素:list(REMOVE_AT 变量列表 索引列表)
  13. 移除重复元素:list(REMOVE_DUPLICATES 变量列表)
  14. 列表翻转:list(REVERSE 变量列表)
  15. 列表排序:list(SORT 变量列表 一些可选参数)

自定义宏

这里的自定义宏是写在 CMakeLists.txt 的,并且在程序源码里,可以用

#ifdef 来读取这些宏是否被定义。

定义宏语法:add_definitions(-D宏名字) # 可以定义多个,都是 -D 宏名字 并用空格分开就行

嵌套 CMake

嵌套规则

  1. 最核心的语法是,增加子节点(子目录):add_subdirectory(子文件夹名)
  2. 项目的根目录底下有一个 CMakeLists.txt 编译项目的时候只需要编译这一个就行了,让这个一个去调用各个子层级的 CMakeLists.txt
  3. 对于子层级,一般是,一个库(或一个 mian 项目,也就是要生成一个可执行程序的目录)底下有一个单独的 CMakeLists.txt 用来单独编译这个库(或程序)
  4. 父级的 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) 构建命令

在项目根目录执行

  1. cmake -B build
  2. cmake --build build

CMake 与源文件交互

当你阅读源码的时候会经常看到这个!

也就是让 C 程序能够读取到 CMake 的变量

(实际的生产环境中,可以先用 CMake 读取到 Linux 的环境变量,然后再传给 C 程序源码)

举例:

  1. 先创建一个 config.h.in 文件,里面写入 CMake 需要传出的变量:
#define CMAKE_CXX_STANDARD ${CMAKE_CXX_STANDARD}
// 定义一个宏,然后用 ${var_name} 来捕获外界的变量
  1. 再在 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自动生成
  1. 最后在 C 程序源码里写:
#include <iostream>
// 下面这行导入一开始会报错,文件找不到
#include "config.h"
// 因为你没有使用cmake构建的话,这个文件是不会被生成的!

int main()
{
    std::cout << "CMAKE_CXX_STANDARD" << std::endl;
    // 会打印出 11
}
  1. 构建命令
cmake -B build
cmake --build build

条件编译

条件编译的基本流程

通过传入不同的参数来编译不同的文件

  1. option 定义变量
  2. 在子 CMakeLists.txt 中根据变量是 ON 还是 OFF,来修改 SRC(源文件) 以及 target_complie_definitions
  3. 修改源文件,修改成根据变量选择代码
  4. 执行构建命令时 -D<变量>=ON/OFF 来进行条件编译

一般来说,ON 代表多编译一些东西,OFF 代表少编译或者不编译一些东西

继承权限说明

  1. PUBLIC:本目标 需要 用,依赖这个目标的其他目标 也需要
  2. INTERFACE:本目标 不需要 ,但是依赖本目标的其他目标 需要
  3. PRIVATE:本目标 需要 ,依赖这个目标的其他目标 不需要

表格:

关键字对于 本目标对于依赖本目标的 其他目标
PUBLIC需要需要
INTERFACE不需要需要
PRIVATE需要不需要

条件编译举例

  1. 比如说你有一个库,在这个库下的(子级的) 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()
  1. 同理的,在 C 源码中可以这样
#ifdef USE_CATTWO	// 取自13行的宏定义
    #include "cattwo.h"	
    // 对应了cattwo.cpp
#endif
  1. 编译时指定开关
cmake -B build -DUSE_CATTWO=OFF # 注意这里指定了宏为关闭
cmake --build build

补充

关于你提到的“函数、循环、作用域”到底要不要补习:

绝对不需要!坚决跳过!

除非你是去给 OpenCV 或者 Linux 内核写那种包含几百个子模块的极其庞大的底层构建脚本,否则你根本用不到 CMake 的 functionforeach

在端侧 AI 部署的真实场景里,CMake 的核心作用只有一个:把源代码、头文件和第三方依赖库精准地捏合在一起,编译出二进制文件。 把有限的精力集中在目标(Target)的构建上,不要去学 CMake 那极其难用的脚本编程语法。

毒药 1:滥用 aux_source_directoryfile(GLOB)

  • 笔记写道: 可以用这两个命令自动检索 .cpp 文件,不用手写,非常方便。
  • 工业界真相:绝对禁止使用!
  • 为什么极其危险? CMake 的运行机制是:只有当 CMakeLists.txt 被修改时,它才会在下次 make 时重新生成构建规则。如果你用 GLOB 自动搜索,当你在文件夹里新建了一个 test.cpp由于 CMakeLists.txt 文件的内容没有发生任何改变,CMake 根本不知道多了一个文件! 你点编译,新文件完全不会被编进去,这种幽灵 Bug 会让你排查到崩溃。
  • 现代做法: 像写强类型语言一样,老老实实地把所有的源文件显式地写出来:set(SRC main.cpp a.cpp b.cpp)。哪怕有 20 个文件,也给我一行行写清楚!这是工业界最基本的严谨。

毒药 2:使用全局污染指令 include_directorieslink_libraries

  • 笔记写道: 静态库的链接写在 add_executable 之前用 link_libraries,动态库写在之后用 target_link_libraries
  • 工业界真相:现代 CMake 只有 target_... ,忘掉全局指令!
  • 为什么极其危险? include_directorieslink_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 结构。