【C/C++进阶】CMake学习笔记

本篇文章包含的内容

  • 一、CMake简介
  • 二、使用CMake构建工程
    • [2.1 一个最简单的CMake脚本](#2.1 一个最简单的CMake脚本)
    • [2.2 使用变量和宏](#2.2 使用变量和宏)
    • [2.3 文件搜索](#2.3 文件搜索)
  • 三、使用CMake制作和使用库文件
    • [3.1 静态库和动态库](#3.1 静态库和动态库)
    • [3.2 字符串操作](#3.2 字符串操作)
    • [3.3 CMake制作库文件](#3.3 CMake制作库文件)
    • [3.4 CMake使用库文件](#3.4 CMake使用库文件)
      • [3.4.1 使用`link_libraries`链接](#3.4.1 使用link_libraries链接)
      • [3.4.2 使用`target_link_libraries`链接](#3.4.2 使用target_link_libraries链接)
  • 四、CMake命令进阶
    • [4.1 CMake自定义宏](#4.1 CMake自定义宏)
    • [4.2 CMake脚本嵌套](#4.2 CMake脚本嵌套)
    • [4.3 其他的`list`命令](#4.3 其他的list命令)

前言

博主在学习STM32和使用新版Vitis开发Zynq SoC的过程中,多次看到CMake的身影。CMake本身是在Makefile的基础上发展起来的强大的项目构建工具,多个厂家都力荐使用CMake构建工程。作为C/C++的使用者而言,学习编译工具和项目构建工具也非常重要。本篇文章记录了学习CMake时的学习笔记,欢迎大家留言探讨,共同进步。


一、CMake简介

CMake是生成Makefile、Ninja等编译脚本的工具,同时也是强大的项目构建工具。CMake的主要目的是为了解决Makefile跨平台兼容性极差的问题。既然Makefile和操作系统强相关,那么我们就可以通过CMake来生成Makefile文件。

可以使用下面的命令检查系统中是否安装了CMake,并添加到环境变量中。博主的CMake是在winlibs中安装GCC+MinGw-w64时自带的。

bash 复制代码
cmake --version

但是本文章不在windows环境下运行。在学习阶段还是建议使用Linux系统。所以博主在Ubuntu 20.04 WSL环境下运行。

在Ubuntu 20.04下使用cmake,首先请确保cmake和GNU编译器已经正确安装:

bash 复制代码
sudo apt update
sudo apt upgrade

sudo apt install build-essential
sudo apt install cmake

二、使用CMake构建工程

2.1 一个最简单的CMake脚本

假设我们的工程结构如下所示。hello.cpp中打印hello world!并调用func.cpp中的函数print_message()

  • hello.cpp
cpp 复制代码
#include <iostream>
#include "func.h"

using namespace std;

int main() {
    cout << "hello world!" << endl;

    print_message();

    return 0;
}
  • func.h
cpp 复制代码
#ifndef __FUNC_H_
#define __FUNC_H_

#include <iostream>

void print_message(void);

#endif
  • func.cpp
cpp 复制代码
#include "func.h"

void print_message(void) {
    std::cout << "msg from func.cpp" << std::endl;
}

第一步,首先在工程的根目录下建立CMakeLists.txt文件。注意文件名区分大小写,且不能出错。

第二步,在工程的根目录下建立build文件夹,之后可以使用cd命令进入该文件夹,使用cmake ..命令编译工程。这时cmake编译生成的中间文件就会自动存放在build文件夹下,保证根目录的整洁。

接下来就可以在CMakeLists.txt编写脚本了。

设置CMake使用的最小版本。这个版本必须西小于等于当前的CMake版本,例如这里我的CMake版本是3.16.3。

clike 复制代码
cmake_minimum_required(VERSION 3.16)

设置工程信息。除了设置工程的名称之外,还可以设置工程的版本、使用的编程语言等信息。

clike 复制代码
project(cmake_test_prj)

为工程添加可执行文件。第一个参数是可执行文件的名称hello,工程的可执行文件默认生成在CMakeLists.txt的同级目录下;之后的参数是需要编译的文件,只需要写.c或者.cpp的源文件即可,头文件不参与编译,所以在这里不需要指定。

clike 复制代码
add_executable(hello src/func.cpp src/hello.cpp)

如果可执行文件需要的头文件是由用户定义的,则我们需要额外指定头文件的目录。注意这里仅仅需要指定目录即可

clike 复制代码
include_directories(inc)

完成这些,我们的CMakeLists.txt文件应该如下所示:

clike 复制代码
cmake_minimum_required(VERSION 3.16)
project(cmake_test_prj)

include_directories(inc)
add_executable(hello src/func.cpp src/hello.cpp)

之后使用cd目录进入build目录,执行命令(..参数是CMakeLists.txt文件所在的目录,这里是上级目录):

bash 复制代码
cmake ..

之后执行下面的命令运行生成的Makefile文件进行编译(之后在不清除缓存的前提下可以在修改CMakeLists.txt后直接执行make命令,跳过cmake ..,默认自动生成一次Makefile脚本)。可以看到在build目录下生成了名为 hello 的可执行文件。使用./hello命令即可运行该文件。

bash 复制代码
make

2.2 使用变量和宏

使用set命令定义一个变量,并使用${}的格式使用这个变量的内容。CMake的变量定义时默认都是字符串类型。变量的名字可以是大写,也可以是小写,但是习惯上通常将变量记为大写来加以区分,增加CMakeLists.txt文件的可读性

clike 复制代码
set(SRC src/hello.cpp src/func.cpp)

add_executable(hello ${SRC})

宏是CMake预先定义的变量名,通过设置这些变量名,可以方便的控制程序的编译选项和配置。例如通过修改宏CMAKE_CXX_STANDARD可以更改编译使用的C/C++标准;使用EXECUTABLE_OUTPUT_PATH 修改可执行文件的生成路径:

clike 复制代码
# 使用 C/C++ 11 标准
set(CMAKE_CXX_STANDARD 11)
set(EXECUTABLE_OUTPUT_PATH <path>)

对于内置的宏,我们也可以直接通过${}的方式调用。下面展示两个宏,并说明它们的异同:

  • PROJECT_SOURCE_DIR:执行cmake ..命令的参数,以绝对路径存储;
  • CMAKE_SOURCE_DIR:当前运行的CMakeLists.txt文件的路径,以绝对路径存储。所以在这里这两个路径存储的值是一样的。

我们可以使用message()命令打印所需要的信息,检查他们的值:

clike 复制代码
message(PROJECT_SOURCE_DIR : ${PROJECT_SOURCE_DIR})
message(CMAKE_SOURCE_DIR : ${CMAKE_SOURCE_DIR})

2.3 文件搜索

此时我们的CMakeLists.txt文件是这样的:

clike 复制代码
cmake_minimum_required(VERSION 3.16)
project(cmake_test_prj)

set(SRC src/hello.cpp src/func.cpp)
include_directories(inc)

add_executable(hello ${SRC})

但是它明显没有解决当源文件过多时需要手写源文件的问题,并且虽然脚本中关于路径的设置都是从CMakeLists.txt文件所在的目录开始表示的,但是这样的路径的引用并不规范,某种程度上也不是很安全。

使用下面的方法找出src目录下所有的源文件,并存储在变量SRC中。

clike 复制代码
aux_source_directory(${PROJECT_SOURCE_DIR}/src SRC)

或者使用下面的方法找出src目录下所有的.cpp文件,并存储在变量SRC中。使用file命令可以指定任意后缀的文件。GLOB参数指搜索当前路径,如果替换为GLOB_RECURSE表示递归搜索。*表示通配符。当然使用同样的方法也可搜索指定路径下所有的.h文件。

clike 复制代码
file(GLOB SRC ${PROJECT_SOURCE_DIR}/src/*.cpp)

修改后,CMakeLists.txt可以修改的更加规范:

cmake 复制代码
cmake_minimum_required(VERSION 3.16)

project(cmake_test_prj)

# file(GLOB SRC ${PROJECT_SOURCE_DIR}/src/*.cpp)
aux_source_directory(${PROJECT_SOURCE_DIR}/src SRC)
include_directories(${PROJECT_SOURCE_DIR}/inc)

add_executable(hello ${SRC})

三、使用CMake制作和使用库文件

3.1 静态库和动态库

静态库和动态库都是编译生成的二进制源文件。它们和源代码唯一的不同在于库的代码是机器可以直接识别的,但是人并不能识别。从某种意义上说,库文件是对源文件的一种加密方式。静态库在链接时直接打包复制到可执行文件中,在执行时直接和可执行文件一起加载到内存(不考虑虚拟内存和Cache命中的简单模型);而动态库则在仅在程序执行时被调用时才会被加载到内存

在Linux系统中,静态库文件以.a(archive)结尾,动态库文件以.so(shared object)结尾。在windows系统中,静态库文件以.lib(library)结尾,动态库文件以.dll(dynamic linked library)结尾。无论在哪个系统中,库文件的名字由lib-xxx-后缀三部分完成,我们通过cmake指定库文件名称时指定的是中间xxx的内容。

制作库文件后,发布者需要发布库文件和库文件对应的头文件。头文件虽然不参与编译,但是头文件中的声明说明了源文件中存在哪些函数和接口可供调用,所以对于使用者而言是必不可少的信息。

3.2 字符串操作

CMake中的变量默认都以字符串类型存储。我们可以打印由aux_source_directory命令输出的SRC变量查看这个字符串:

clike 复制代码
message(${SRC})

需要注意的是,CMake存储的字符串和其他语言定义的字符串不同,CMake的字符串由一个个子字符串(item)构成,在底层实现中,各个子字符串用分号隔开区分,但是打印输出时并不显示这个分号。在查找(例如删除)时,遵循完全匹配原则,即CMake只会查找和被删除字符串完全相同的子字符串进行删除

  • 字符串拼接
clike 复制代码
set(val_name ${val1} ${val2} ...)
  • 字符串追加
clike 复制代码
list(APPEND val_name ${val1} ${val2} ...)
  • 字符串删除
clike 复制代码
list(REMOVE_ITEM val_name ${val1} ${val2} ...)

在制作库文件时,我们往往需要找到除main函数所在文件的其他文件作为源文件,这时候就可以使用字符串操作优雅地找到子字符串的名称并将其删除。

clike 复制代码
list(REMOVE_ITEM STC ${PROJECT_SOURCE_DIR}/src/hello.cpp)

3.3 CMake制作库文件

制作库文件不需要包含测试程序,即main函数存在的文件。

使用下面的命令制作一个库文件。STATIC/SHARED参数指定要生成的库文件类型,如果缺省该参数默认生成静态库文件。

clike 复制代码
add_library(lib_name STATIC/SHARED [SOURCE1] [SOURCE2] ...)

生成静态库的一个示例:

clike 复制代码
cmake_minimum_required(VERSION 3.16)

project(cmake_test_prj)

aux_source_directory(${PROJECT_SOURCE_DIR}/src SRC)
include_directories(${PROJECT_SOURCE_DIR}/inc)

list(REMOVE_ITEM SRC ${PROJECT_SOURCE_DIR}/src/hello.cpp)

# add_executable(hello ${SRC})
add_library(test ${PROJECT_SOURCE_DIR}/src/func.cpp)

生成动态库的一个示例:

clike 复制代码
cmake_minimum_required(VERSION 3.16)

project(cmake_test_prj)

aux_source_directory(${PROJECT_SOURCE_DIR}/src SRC)
include_directories(${PROJECT_SOURCE_DIR}/inc)

list(REMOVE_ITEM SRC ${PROJECT_SOURCE_DIR}/src/hello.cpp)

# add_executable(hello ${SRC})
add_library(test SHARED ${PROJECT_SOURCE_DIR}/src/func.cpp)

3.4 CMake使用库文件

为了测试我们生成的库文件是否正确,新建一个lib文件夹,并在其中建立两个子文件夹,分别将静态库文件和动态库文件放入对应的文件夹:

如果我们使用了第三方的库(或自定义),使用下面的命令在CMake中指定第三方库文件的路径。可以同时指定多个路径。

clike 复制代码
link_directories(...)

如果要在程序中使用静态库,则应该使用下面的命令指定使用的静态库名称。

clike 复制代码
link_libraries(lib_name)

注意:在课程中提到,link_libraries只能用来连接静态库,且该指令需要放在生成可执行程序add_executable之前 。但是博主的环境中测试后,在这个简单的例子中就算使用该命令连接动态库文件(直接将路径指定为${PROJECT_SOURCE_DIR}/lib/shared),程序同样可以正常运行。

下面是一个加载静态库的CMakeLists.txt示例。注意在指定库文件的同时,头文件的路径依然需要我们指定。

clike 复制代码
cmake_minimum_required(VERSION 3.16)

project(cmake_test_prj)

include_directories(${PROJECT_SOURCE_DIR}/inc)

link_directories(${PROJECT_SOURCE_DIR}/lib/static)
link_libraries(test)

add_executable(hello ${PROJECT_SOURCE_DIR}/src/hello.cpp)

链接动态库常用的命令为target_link_libraries()(实际上,这个命令既可以链接静态库也可以链接动态库,一种说法是link_libraries()命令是老版本的命令,在较新的CMake版本中已经被舍弃了)。

clike 复制代码
target_link_libraries(
	<target> 
	<PUBLIC/PRIVATE/INTERFACE> <library 1> 
	<PUBLIC/PRIVATE/INTERFACE> <library 2>
)

这里的<target>可以是一个源文件,动态库文件或者可执行文件。使用动态库之前,需要首先明确一点:动态库链接时的链接权限具有可配置的传递性 。可使用的权限分三种,分别是PUBLIC、PRIVATE、INTERFACE,三种权限对应的传递性依次降低。如果不指定权限,默认为PUBLIC。为了说明它们的区别,我在这里用一幅图说明:

在上面的例子中,我们使用一个可执行文件作为target,它链接到一个动态库A。而动态库A链接到其他2个动态库和一个静态库。动态库B的权限为PUBLIC,动态库C的权限为PRIVATE,静态库D的权限为INTERFACE。

  • 如果链接的权限为PUBLIC,则库的链接具有传递性。不仅动态库A中可以使用动态库B的变量和函数(方法),可执行文件中也可以使用动态库B的变量和函数(方法)。
  • 如果链接的权限为PRIVATE,则库的链接不具有传递性。仅动态库A可以使用动态库C中的变量和函数(方法),而可执行文件不可以。
  • INTERFACE target是一种特殊的target,通常适用于传递头文件信息和编译选项,但库本身并不需要生成任何的二进制文件的情况。如果链接的权限为INTERFACE,动态库A可以将动态库D的定义和编译选项作为接口传递给可执行文件,但A并不依赖库D生成任何的文件。当你为一个目标设置INTERFACE权限时,你实际上是在告诉CMake:"当其他目标链接到我时,它们需要这些设置,但我自己不需要"。

动态库又被称为共享目标(shared object)。每个程序在执行时,对应一个进程。这个进程存储在一块虚拟的地址空间中。计算机通过MMU(Memory Management Unit,内存管理单元)将虚拟地址空间的地址映射到物理内存上。虚拟地址空间优化了内存的使用效率,同时对数据的地址作了一层保护。如果在进程运行期间调用了动态库中的函数,则动态库会被加载到物理内存中。假设此时另一个进程也需要调用这个动态库,则这个动态库不需要被重新加载,而是直接到物理内存中使用。所以,无论何时,同一个动态库在物理内存中只会存在一份,这时两个进程就对这一个动态库实现了"共享"。

通常而言,target_link_libraries()命令写在生成可执行文件命令add_executable()或者生成库命令add_library()之后。

下面是一个链接动态库的示例:

clike 复制代码
cmake_minimum_required(VERSION 3.16)

project(cmake_test_prj)

include_directories(${PROJECT_SOURCE_DIR}/inc)

link_directories(${PROJECT_SOURCE_DIR}/lib/shared)
# link_libraries(test)

add_executable(hello ${PROJECT_SOURCE_DIR}/src/hello.cpp)
target_link_libraries(hello test)

在上面的示例中,我们仍然使用link_directories命令指定连接库的路径。但是这个命令有一个缺点:它会影响所有的目标搜寻的范围,如果存在多个目标,并且它们需要连接的库在不同的目录下,但是名称相同,使用link_directories就会造成一些混淆的错误。所以,在多数情况下,优先使用target_link_directories命令为不同的目标指定不同的路径。下面是修改后的示例:

clike 复制代码
cmake_minimum_required(VERSION 3.16)

project(cmake_test_prj)

include_directories(${PROJECT_SOURCE_DIR}/inc)

add_executable(hello ${PROJECT_SOURCE_DIR}/src/hello.cpp)
target_link_directories(hello PUBLIC ${PROJECT_SOURCE_DIR}/lib/shared)
target_link_libraries(hello test)

注意:不仅仅可执行程序可以链接库,库和库之间也可以链接。静态库可以链接其他的静态库,也可以链接其他的动态库;动态库可以链接其他的静态库,也可以链接其他的动态库。具体的操作和上面提及的例子大同小异,只需要把生成可执行程序的命令改成生成库的命令即可,在这里不再赘述,重点在于梳理不同库之间的链接关系。

四、CMake命令进阶

4.1 CMake自定义宏

在项目中,我们往往需要在开发阶段打印大量的日志信息,但是在发布时又不想让这些日志信息输出。为了方便,可以在程序中添加宏定义的方式决定哪些程序会被预处理:

c 复制代码
#include <iostream>
#include "func.h"

using namespace std;

int main() {
    cout << "hello world!" << endl;

#ifdef DEBUG

    cout << "[DEBUG INFO] This is a DEBUG message." << endl;

#endif

    print_message();

    return 0;
}

为了使得命令生效,可以使用gcc/g++命令定义DEBUG宏:

既然可以通过命令行的方式定义,那么当然也可以在CMakeLists.txt中指定编译的宏。

clike 复制代码
add_definitions(-DDEBUG)

使用该命令可以一次指定多个编译的宏,每个宏之前都要加上-D前缀,并用空格或者换行分开:

clike 复制代码
add_definitions(-DFOO -DBAR)

4.2 CMake脚本嵌套

在实际的项目开发中,多模块开发是非常常见的。有些模块只需要生成静态库,有些模块需要生成用于测试的可执行程序。对于这些模块,我们可以分别对它们进行CMake脚本的编写,并且通过一个顶层的CMake脚本对这些子模块进行管理。一个CMakeLists.txt就对应一个节点,这样就形成了多个节点之间的父子关系。

在下面的例子中,工程根目录下是一个父节点,分别有calchello两个子节点。calc仅负责生成一个静态库,hello仅负责调用这个静态库并生成一个可执行文件用来测试,所以它们的CMake脚本可以独立编写。

修改后的工程结构可能和实际的项目开发有较大差距。博主水平有限,请见谅,在此仅为说明CMake在脚本嵌套上辨析的一些细节。

  • 修改后的inc/func.h
c 复制代码
#ifndef __FUNC_H_
#define __FUNC_H_

#include <iostream>

void print_message(void);
int add_func(int a, int b);
int sub_func(int a, int b);

#endif
  • 修改后的hello/hello.cpp
c 复制代码
#include <iostream>
#include "func.h"

using namespace std;

int main() {

    cout << "13 + 13 = " << add_func(13, 13) << endl;
    cout << "6 + 5 = " << sub_func(6, 5) << endl;

#ifdef DEBUG
    cout << "[DEBUG INFO] This is a DEBUG message." << endl;
#endif

    print_message();

    return 0;
}
  • 修改后的calc/add.cpp
c 复制代码
#include "func.h"

int add_func(int a, int b) {
    return a + b;
}

int sub_func(int a, int b) {
    return a - b;
}

父节点通过add_subdirectory()命令为其自身添加子节点。子节点可以使用(继承)父节点中的变量。或者可以将子节点中的变量看作局部变量,将父节点中的变量看作全局变量。利用这个特性,通常可以在父节点中定义生成库的名称、路径;生成可执行文件的名称、路径;头文件路径等信息。

  • 父节点CMakeLists.txt
clike 复制代码
cmake_minimum_required(VERSION 3.16)
project(cmake_test_prj)

set(INCPATH ${PROJECT_SOURCE_DIR}/inc)
set(EXECPATH ${PROJECT_SOURCE_DIR}/bin)
set(LIBPATH ${PROJECT_SOURCE_DIR}/lib)

add_subdirectory(calc)
add_subdirectory(hello)
  • calc/CMakeLists.txt
clike 复制代码
cmake_minimum_required(VERSION 3.16)
project(calc)

include_directories(${INCPATH})
aux_source_directory(./ SRC)
set(LIBRARY_OUTPUT_PATH ${LIBPATH})

add_library(calc STATIC ${SRC})
  • calc/CMakeLists.txt
clike 复制代码
cmake_minimum_required(VERSION 3.16)
project(hello)

include_directories(${INCPATH})
aux_source_directory(./ SRC)

link_directories(${LIBPATH})
link_libraries(calc)

set(EXECUTABLE_OUTPUT_PATH ${EXECPATH})

add_executable(hello ${SRC})

对于脚本中提及的可执行文件路径bin和库文件路径lib,我们事先不需要自己创建。执行CMake脚本后,不存在的路径会自动生成。之后即可执行make命令编译文件:

可以看到静态库文件和可执行程序正常生成,程序运行结果也是正常的。

4.3 其他的list命令

使用list命令还可以对字符串作更多操作,但是这些操作记忆起来相当繁杂,在此罗列,需要使用时查询即可。

CMake中一个字符串(list)的子字符串索引同样从0开始,-1指最后一个元素,以此类推。

  • 获取字符串的长度
clike 复制代码
list(LENGTH <list> <output variable>)
  • 获取字符串指定索引的元素
clike 复制代码
list(GET <list> <element index1> [<element index2> ...] <output variable>)
  • 使用特定连接符连接子字符串(即将隐式的;改为显式的某字符串)
clike 复制代码
list(JOIN <list> <glue> <output variable>)
  • 找到列表是否存在指定的元素,如果找到返回索引值,如果没找到返回-1
clike 复制代码
list(FIND <list> <value> <output variavle>)
  • 在指定索引之前添加多个元素
clike 复制代码
list(INSERT <list> <element_index> <element1> [<element2> ...])
  • 将指定元素添加到所有元素之前(插入到之后使用APPEND关键字)
clike 复制代码
list(PREPEND <list> <element1> [<element2> ...])
  • 弹出最后一个元素(输出变量可缺省)
clike 复制代码
list(POP_BACK <list> [<output variable>])
  • 弹出第一个元素(输出变量可缺省)
clike 复制代码
list(POP_FRONT <list> [<output variable>])
  • 删除指定索引的元素
clike 复制代码
list(REMOVE_AT <list> <index1> [<index2> ...])
  • 删除重复元素
clike 复制代码
list(REMOVE_DUPLICATES <list>)
  • 列表翻转
clike 复制代码
list(REVERSE <list>)
  • 列表排序
    • compare
      • STRING:默认方法,按字母顺序排序
      • FILE_BASENAME:如果是一系列路径名,按路径basename进行排序
      • NATURAL:按自然数方法进行排序
    • case:大小写是否敏感,分为SENSITIVE(默认)和INSENSITNVE
    • order:排列顺序,分为ASCENDING(默认)升序和DESCENDING降序
clike 复制代码
list(SORT <list> [COMPARE <compare>] [CASE <case>] [ORDER <order>])

*  原创笔记,码字不易,欢迎点赞,收藏~ 如有谬误敬请在评论区不吝告知,感激不尽!博主将持续更新有关嵌入式开发、FPGA方面的学习笔记。*


相关推荐
小帆的帆4 分钟前
vscode+msys2+clang+xmake c++开发环境搭建
c++·ide·vscode
LeonNo1117 分钟前
软考高项,考情学习
学习
真想骂*1 小时前
详解C++中“virtual”的概念及其含义
java·jvm·c++
菜菜江江1 小时前
运行 Mongodb Server
数据库·经验分享·学习·mongodb
薔薇十字2 小时前
【代码随想录day62】【C++复健】 97. 小明逛公园(Floyd 算法精讲);127. 骑士的攻击(A * 算法精讲)
开发语言·c++·算法
-指短琴长-2 小时前
Linux从0到1——线程自定义封装
linux·运维·c++
SUN_Gyq2 小时前
C++如何实现对象的克隆?如何实现单例模式?
java·开发语言·jvm·c++·算法
奔跑的犀牛先生2 小时前
概率论得学习和整理28:用EXCEL画折线图,X轴数据也被当成曲线的解决办法
学习·excel