文章

使用CMake开发C++工程

本文介绍了使用 CMake(CPack) 和 NSIS 构建并打包 C/C++ 工程项目的基本流程和方法,核心在于 CMakeLists.txt 文件的编写。


1. 引言

假如我们有一个大型的 C++ 项目,由非常多的互相调用的工程共同组成,一些用于生成库文件,一些用于实现逻辑功能。他们之间的调用关系复杂而严格,如果想在这样复杂的框架下进行二次开发,显然只拥有它的源码是远远不够的,还需要清楚的明白这几十个项目之间的复杂关系。即使是原作者给出了相关的结构文档,对新手来说建立工程的过程依旧是漫长而艰辛的,开发人员的核心业务是软件开发,而不是软件构建。Cmake目的是实现软件构建流程的自动化,并且是跨平台的。

原作者只需要生成一份 CMakeLists.txt 文档,框架的使用者们只需要在下载源码的同时下载作者提供的 CMakeLists.txt,就可以利用 CMake,在原作者的帮助下进行工程的搭建。Cmake 编程的过程实际上是编写 CMakeLists.txt的过程,使用的是 “Cmake” 语言和语法。

1.1. 传统编译

将一个C++项目从源文件编译得到可执行程序文件,需要使用C/C++编译器。较广泛使用的是 GCC(GNU Compiler Collection,GNU编译器套件),它可以编译很多种编程语言(包括C、C++、Objective-C、Fortran、Java等等)。GCC 编译的核心是 gcc 命令。对于源文件数量较少且路径单一的情况,可以直接在终端输入编译命令进行编译,示意如下:

1
g++ -c demo.cpp -I /aaa/bbb/ -o demo.exe

1.2. Make 编译

当源文件越来越多时,调用 gcc/g++ 命令逐个去编译时,就很容易混乱而且工作量大。特别地,当只改动工程项目中的某一些源程序文件时,单纯使用命令编译会把所有源程序文件重新编译一遍,而逐文件编译为 .o 文件再手动确定是否需要更新和编译工作量巨大。

为了简化编译操作,GNU 推出了 Make 工具。它是一个自动化编译工具,我们可以使用一条命令实现完全编译,但是需要编写一个规则文件,Make 工具依据它来批量处理编译,这个文件就是 Makefile 文件。一个典型的 Makefile 文件如下

1
2
3
4
5
6
7
8
a.out: hello.o main.o
    g++ hello.o main.o -o a.out

hello.o: hello.cpp
    g++ -c hello.cpp -o hello.o

main.o: main.cpp
    g++ -c main.cpp -o main.o

Makefile 文件规定了每个文件的依赖关系和生成各文件的规则。这样,当某个文件的所有依赖并没有发生变化时,即可不用重新编译该文件,而是直接利用之前编译好得到的 .o 文件即可。

Makefile 带来的好处就是——“自动化编译”,一旦写好,只需要一个 make 命令,整个工程完全自动编译,极大的提高了软件开发的效率。make 是一个命令工具,是一个解释 Makefile 中指令的命令工具,一般来说,大多数的 IDE 都有这个命令,比如:Delphi 的 make,Visual C++ 的 nmake,Linux 下 GNU 的 Makefile 都成为了一种在工程方面的编译方法。

Makefile 在一些简单的工程完全可以人工手写,但是当工程非常大的时候,手写 Makefile 也是非常麻烦的,如果换了个平台 Makefile 又要重新修改。另一方面,若想要更换开发环境,如需要将工程迁移到 Visual Studio IDE 进行开发,需要构建基于 VS 的项目框架,生成 .sln.vcproj 文件,此时 Makefile 就无能为力了。

.sln 文件(解决方案文件) 是 Visual Studio 的解决方案文件,用于组织和管理一个或多个项目。它可以包含一个或多个项目,以及这些项目之间的关系和配置信息。一个 .sln 文件本身并不包含任何代码或文件,它只是包含引用到该解决方案中所有项目的信息和设置。.sln 文件可以存储在版本控制系统中,以便多个开发人员共享和协作。 .vcxproj 文件(项目文件) 是 Visual C++ 项目文件,包含项目的设置和配置信息,例如编译器选项、预处理器选项、文件列表和库依赖项等。它通常是随着每个项目的创建而生成的,并存储在项目的根目录下。每个项目都有一个单独的 .vcxproj 文件,而解决方案只包含对每个项目的引用。

Makefile 的局限性还包括:

  • make 在 UNIX 类系统上是通用的,但是 Windows 不支持;
  • 需要准确指出每个项目之间的依赖关系,有头文件时特别头疼;
  • make 语法非常简单,无法实现 shell 或 python 那样做很多判断等;
  • 不同编译器有不同的 flag 规则,为 g++ 准备的参数可能对 msvc 并不适用。

1.3. CMake 编译

为了解决 make 的问题,出现了 CMake 这个跨平台工具。CMake 能够输出各种各样的 Makefile 或者 project 文件,从而帮助程序员减轻负担。

CMake 通过编写 CMakeLists.txt 文件,实现跨平台生成对应能用的 Makefile 或工程文件,我们不需要自己再去修改。CMakeLists.txt 是一种平台无关的文件,用来定制整个编译流程,然后再根据目标用户的平台进一步生成所需的本地化 Makefile 和工程文件,如 Unix 的 Makefile 或 Windows 的 Visual Studio 工程。从而做到 “Write once, run everywhere”。一些使用 CMake 作为项目架构系统的知名开源项目有 VTK、ITK、KDE、OpenCV、OSG 等。

2. 安装 CMake

前提,已经安装有至少一个C/C++编译器,如 MinGW-W64。可以通过gcc --version 查看版本来确认安装是否成功。

在 Windows 环境下,CMake 可以在官网(https://cmake.org)下载,安装后包括一个控制台程序和一个 GUI 程序。可以通过 cmake --version 检查安装情况和版本情况。

在 VScode 中,需要安装以下两个插件:

  • CMake
  • CMake Tools

在 Cmake Tools 插件设置中将 Cmake 的 make.exe 可执行程序完整路径设置到 cmake.cmakePath。同时将 make.exe 所在的路径添加到系统的环境变量中(因为后续用 cpack.exe 打包时候也要找路径)。

使用 CMake 插件创建 CMakeLists.txt 文件(文件名一个字都不能错)。两种创建方式:

  • 手动创建,直接在工程项目的根目录下新建一个 CMakeLists.txt 文件;

  • 自动创建,在 VSCode 中打开工程项目文件夹,输入快捷键组合 Ctrl + Shift + P 然后输入 cmake quick start 进行快速设置。首次设置会弹出 Select a Kit 需要选择一个编译器,若正确安装 MinGW-W64 并添加了环境变量,一般会自动检索到类似 GCC XX.X.X x86-64-w64-mingw32 的编译器,注意检查后面的路径是否正确,然后选择即可。选择后即会在项目根目录下自动创建CMakeLists.txt 文件。

alt text

3. 编写 CMakeLists.txt

一个 CMakeLists.txt 文件有自己的格式和命令,下面以基本的 CMakeLists.txt 为例,按照文件内从上到下的顺序逐一介绍。

3.1. 版本与编译选项配置

1
2
3
4
5
6
7
8
9
10
11
12
# 指定 cmake 的最低版本
cmake_minimum_required(VERSION 3.10)
# 设置 cmake 的 C 版本为 C99
set(CMAKE_C_STANDARD 99)
# 设置 cmake 的 C++ 版本为 C++11
set(CMAKE_CXX_STANDARD 11)
# 要求强制执行C++版本检查,若不符合则报错
set(CMAKE_CXX_STANDARD_REQUIRED True)
# 设置 cmake 默认编译选项为 Release
set(CMAKE_BUILD_TYPE "Release" CACHE STRING "" FORCE)
# 在控制台打印编译选项
message(STATUS "[INFO] Set to ${CMAKE_BUILD_TYPE} build.")

CMakeLists.txt 文件中:

  • 采用 set(变量 文件名/路径/...) 函数给文件名/路径名或其他字符串起别名,用 ${变量} 获取变量内容;
  • 采用 ${xxx} 来取变量的值;

3.2. 可执行程序配置

一个大型 C++ 项目一般包括多个子项目路径,分别实现较为独立的不同功能,假设项目路径为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//test文件夹
|--3rdpaty
    |--glm
        |--rotate.h
|--include
    |--a.h
    |--b.h
|--path1
    |--add.cpp
    |--add.h
    |--CMakeLists.txt // path1
|--function.h
|--function.cpp
|--main.cpp
|--CMakeLists.txt // main

根目录下存在 main.cppfunction.cpp/.h,假设他们可以完整编译为可执行程序,但需要依赖 include 文件夹中的头文件和 3rdparty 文件夹中的头文件,那么在 CMakeLists.txt(main) 中内容可写为

1
2
3
4
5
# 指定项目名称为 test
project(test)
# 编译可执行程序(test),以及生成该程序需要的源文件
add_executable(test function.cpp main.cpp
    function.h include/a.h include/b.h 3rdparty/glm/rotate.h)

指定了项目名后,后面可能会有多个地方用到这个项目名,如果更改了这个名字,就要改多个地方,比较麻烦,那么可以使用 PROJECT_NAME 来表示项目名。即

1
2
add_executable(${PROJECT_NAME} function.cpp main.cpp
    function.h include/a.h include/b.h 3rdparty/glm/rotate.h)

至此,就可以构建和运行项目了,即先运行 cmake 命令来构建项目,然后使用编译工具进行编译。以 Windows 平台控制台为例,输入以下命令即可完成构建。

1
2
3
4
mkdir build
cd build
cmake -G"MinGW Makefiles" ..
cmake --build .

其中,

  • 第一行命令:手动创建了 build 文件夹;
  • 第二行命令:前往 build 目录下;
  • 第三行命令:进行工程项目的构建。
    • Windows 下,CMake 默认使用微软的 MSVC 作为编译器,若想使用 MinGW 编译器,可以通过 -G 参数来进行指定,只有第一次构建项目时需要指定;
    • 构建系统需要指定项目 CMakeLists.txt(main)所在路径,所以用 .. 表示 CMakeLists.txt 在上一级目录(因为第二行命令已经进入了 build 目录)。
  • 第四行命令:在 build 目录下会生成 Makefile 文件,最后调用编译器来实际编译和链接项目。
    • --build 指定编译生成的文件存放目录,其中就包括可执行文件;
    • . 表示存放目录为当前目录。如果一切顺利,在 build 目录下会生成 test.exe 可执行文件。

3.3. 头文件搜索

为了避免繁琐的头文件列写过程,我们可以 add_executable() 后,指定其头文件搜索路径

1
2
3
4
5
6
7
add_executable(${PROJECT_NAME} function.cpp main.cpp)
# 添加当前根目录为头文件搜索路径
target_include_directories(${PROJECT_NAME} PRIVATE .)
# 添加 include 目录为头文件搜索路径
target_include_directories(${PROJECT_NAME} PRIVATE include)
# 添加 include/glm 目录为头文件搜索路径
target_include_directories(${PROJECT_NAME} PRIVATE 3rdparty/glm)

PRIVATE 表示隐式包含,此时后面给出的头文件搜索路径只能被当前 ${PROJECT_NAME} 所使用。如果选择 PUBLIC 则后续其他可执行程序等也都可以使用。为了保证高内聚低耦合的特点,建议尽量使用 PRIVATE

可以看出,为了包含头文件搜索路径,我们需要写很多次重复语句,其实也可以一条语句里面包含若干个路径。为了简化头文件搜索路径定义,我们可以采用如下两种方式

  • (1)自动导出头文件路径列表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    add_executable(${PROJECT_NAME} function.cpp main.cpp)
    # 从根目录下开始遍历寻找所有头文件,存到 all_h 列表
    file(GLOB_RECURSE all_h ./*.h)
    foreach(HEADER ${all_h})
    # 对于每一个头文件,得到其路径 h_path
    get_filename_component(h_path ${HEADER} DIRECTORY)
    # 将路径添加到 h_dirs 列表 
    list(APPEND h_dirs ${h_path})
    endforeach()
    # 对 h_dirs 列表去除重复项
    list(REMOVE_DUPLICATES h_dirs)
    # 将 h_dirs 添加项目 test 的头文件搜索路径
    target_include_directories(${PROJECT_NAME} PRIVATE ${h_dirs})
    

    优点:当新添加头文件时,可以自动更新搜索路径

    缺点:若源程序文件中显式给出了头文件的相对路径包含关系,如#include <glm/rotate.h>,此时需要的头文件搜索路径为 ./3rdparty。但是因为该路径下没有头文件,自动导出的路径不会包含该路径。这种情况下会出现链接错误,提示找不到头文件。

  • (2)手动设置头文件路径列表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    add_executable(${PROJECT_NAME} function.cpp main.cpp)
    # 显式添加头文件搜索路径到列表 h_dirs
    list(APPEND h_dirs
      ./
      ./include
      ./3rdparty
    )
    # 将 h_dirs 添加项目 test 的头文件搜索路径
    target_include_directories(${PROJECT_NAME} PRIVATE ${h_dirs})
    

    优点:可以更好配合头文件引用关系

    缺点:需要手动维护头文件路径列表,路径多了列表会很长

一般鼓励大家第二种方法,因为项目的头文件列表就推荐显式维护,便于开发者掌控项目的头文件依赖关系,避免引入不需要使用的头文件。特别是当项目依赖多种多样的第三方库时,每个第三方库对其头文件的包含形式都不一样,如

1
2
3
#include "aaa.h"
#include <bb/ccc.h>
#include <../cc/ddd.h>

这种情况下,就需要特别注意头文件搜索路径,保证每个头文件都能搜索到。

3.4. 源文件搜索

可以看到,add_executable() 仍然需要列写很多源文件,这在大型工程项目中也十分繁琐,可以采用与头文件类似的方法对源文件进行搜索,同样也包含两种搜索方式,这里以第一种方式举例说明。

1
2
3
file(GLOB_RECURSE all_c ./*.c ./*.cpp) # 遍历搜索 .c 和 .cpp 文件添加到 all_c
add_executable(${PROJECT_NAME} ${all_c})
target_include_directories(${PROJECT_NAME} PRIVATE ${h_dirs})

注意,源文件不是遍历其路径,而是需要直接显式指定到文件,因此不再需要 get_filename_component() 操作。

针对源文件还有一种新的搜索方式如下。

1
2
3
aux_source_directory(./ ./path1 all_c) # 将目录 ./ 和 ./path 中的所有源文件添加到 all_c
add_executable(${PROJECT_NAME} ${all_c})
target_include_directories(${PROJECT_NAME} PRIVATE ${h_dirs})

aux_source_directory 函数会搜索指定目录下的所有源文件,并将它们的文件名(包括路径)存储在变量variable中。这个函数会自动将所有符合条件的源文件添加到变量中,所以你不需要手动一个一个地列举所有的源文件。

最后,若某个路径下存在项目不需要的源文件,可以手动指定去除。以去除 add.cpp 为例,在 add_executable() 之前插入以下命令

1
list(REMOVE_ITEM all_c ./path1/add.cpp)

3.5. 子项目(WIP)

CMake 同样可以管理多个子项目路径构成的复杂 C/C++ 工程。

通过子项目路径下的 CMakeLists.txt(path1)文件联合根目录下的 CMakeLists.txt(main)进行配置

1
2
3
4
5
6
7
####### CMakeLists.txt (main)
# 添加子项目路径 path1
add_subdirectory(path1)
add_executable(test function.cpp main.cpp function.h ${path1src})

####### CMakeLists.txt (path1)
set(path1src path1/add.cpp path1/add.h)

3.6. others(WIP)

  • project(xxx),必须
  • add_subdirectory(子文件夹名称),若父目录包含多个子目录则必须
  • add_library(库文件名称 STATIC 文件),通常子目录(二选一)
  • add_executable(可执行文件名称 文件),通常父目录(二选一)
  • include_directories(路径),必须
  • link_directories(路径),非必须
  • target_link_libraries(库文件名称/可执行文件名称 链接的库文件名称),必须

一个典型的 CMakeLists.txt 文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
cmake_minimum_required(VERSION 3.0.0) # 设置最小的cmake版本号
set(PROJECT_NAME "myproject") # 设置工程名称
project(${PROJECT_NAME} VERSION 0.2.0) # 设置工程版本

set(CMAKE_C_STANDARD 99) # 设置C标准
set(CMAKE_CXX_STANDARD 11) # 设置C++标准

include(CTest)
enable_testing()

set(CMAKE_VERBOSE_MAKEFILE "ON") # 启用详细打印,方便查看bug

### 通过遍历来搜寻所有 .h 文件,并保存到 all_h 变量中
# ${PROJECT_SOURCE_DIR} 是默认的工程根目录
file(GLOB_RECURSE all_h
        ${PROJECT_SOURCE_DIR}/src/**.h
)

### 遍历每个 .h 文件(这段抄的网上的)
foreach(file  ${all_h})
    # 找到文件的上级路径
    string(REGEX REPLACE "/$" "" CURRENT_FOLDER_ABSOLUTE ${file})
    string(REGEX REPLACE "(.*/)(.*)" "\\1" CURRENT_FOLDER ${CURRENT_FOLDER_ABSOLUTE})
    list(APPEND include_h  ${CURRENT_FOLDER})
endforeach()
### 删除冗余路径
list(REMOVE_DUPLICATES  include_h) # 得到去冗的所有包含 .h 文件的路径,存到 include_h 变量
# MESSAGE("include_directories"  ${include_h}) # 打印所有包含 .h 文件的路径

# 规定 .h 文件的路径
include_directories(${include_h})

### 遍历所有 .c/.cpp 文件,存到 ALL_SRC 变量中
file(GLOB_RECURSE ALL_SRC
    ${PROJECT_SOURCE_DIR}/src/**.c
    ${PROJECT_SOURCE_DIR}/src/*.cpp
    )
# MESSAGE("add_executable"  ${ALL_SRC}) # 打印

# 把所有.c/.cpp 文件添加到名字为 ${PROJECT_NAME} 的可执行程序中
if(WIN32)
    set(RESOURCE_FILES res.rc) # add icon
endif()
add_executable(${PROJECT_NAME} ${ALL_SRC} ${RESOURCE_FILES})

### 定义宏定义来控制工程中的一些功能
# 比如代码里通过 #ifdef AASSAA aaa #else bbb
# 那么就可以通过 `-DAASSAA` 传给编译器进行宏定义
# 等价于代码中写 `#define DAASSAA`
add_definitions(-D__INFO__)
add_definitions(-D__CLEAN__)

### 如果是Windows环境
if(WIN32)
    # 对 add_library 或 add_executable 生成的文件进行链接操作
    # 这里额外链接 TCP 通讯所需的 winsock2.h 依赖的 ws2_32.lib
    target_link_libraries(${PROJECT_NAME} ws2_32)

    ### 安装(linux中的sudo apt install 的概念,win中不知道是啥)
    if(MINGW) # 根据Windows的环境变量得到mingw64的安装位置,然后得到bin文件夹
        find_program(MINGW_EXECUTABLE mingw32-make.exe PATH $ENV{PATH} REQUIRED)
        get_filename_component(MINGW_BIN ${MINGW_EXECUTABLE} DIRECTORY)
        set(EXTRA_DLL # 注意,这里最好使用Depends工具来查看exe程序依赖哪些第三方dll,然后逐一添加
            "${MINGW_BIN}/libstdc++-6.dll"
            "${MINGW_BIN}/libgcc_s_seh-1.dll"
            "${MINGW_BIN}/libwinpthread-1.dll"
        )
        # 将依赖的dll文件拷贝到安装路径(目前支持seh异常模型版本的mingw64)
        install(FILES ${EXTRA_DLL} DESTINATION .)
    endif()
    # 将可执行程序打包到(将来安装位置的)根目录
    install(TARGETS ${PROJECT_NAME} DESTINATION .)
    # 将一些额外的资源文件夹,配置文件(夹)等拷贝到目标目录
    # 文件夹用 'DIRECTORY' , 文件用 'FILES'
    install(DIRECTORY ${PROJECT_SOURCE_DIR}/data/ DESTINATION data)
    install(DIRECTORY ${PROJECT_SOURCE_DIR}/config/ DESTINATION config)
    install(DIRECTORY ${PROJECT_SOURCE_DIR}/res/ DESTINATION res)
    set(CMAKE_INSTALL_SYSTEM_RUNTIME_DESTINATION ".") # 不知道有没有用,先放着了
    include(InstallRequiredSystemLibraries) # 不知道有没有用,应该有用,把系统dll打包到exe

    ### 打包
    # set(CPACK_INSTALL_PREFIX "/home/DSS") # 给linux用的,大概把?
    # 设置一些名字
    set(CPACK_PACKAGE_NAME ${PROJECT_NAME})
    set(CPACK_PROJECT_NAME ${PROJECT_NAME})
    set(CPACK_PROJECT_VERSION ${PROJECT_VERSION})
    set(CPACK_CMAKE_GENERATOR "MinGW Makefiles") # 如果前面用了自动生成CMakeLists.txt,这里也可以不写
    set (CPACK_RESOURCE_FILE_LICENSE  
        "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE") # 如果工程没有License文件也可以不写
    set(CPACK_PACKAGE_VERSION_MAJOR "${${PROJECT_NAME}_VERSION_MAJOR}") # 大版本号
    set(CPACK_PACKAGE_VERSION_MINOR "${${PROJECT_NAME}_VERSION_MINOR}") # 小版本号
    set(CPACK_SOURCE_GENERATOR "TGZ") # 压缩方式,我随便写了一个,支持很多种

    ### 在 Windows 环境种,采用开源的 NSIS 来构建安装程序
    set(CPACK_GENERATOR NSIS)
    set(CPACK_NSIS_PACKAGE_NAME "${PROJECT_NAME}")
    set(CPACK_NSIS_DISPLAY_NAME "${PROJECT_NAME}")

    # 添加ico文件给打包的安装程序
    set(CPACK_PACKAGE_ICON "${CMAKE_CURRENT_SOURCE_DIR}\\\\DSSimulator.ico")
    set(CPACK_NSIS_MUI_ICON "${CMAKE_CURRENT_SOURCE_DIR}\\\\DSSimulator.ico")

    ### 用来告诉安装程序,卸载的时候需要额外删掉前面 install 时额外加入的 资源文件(夹)和 dll 等
    # 这里采用函数的形式(百度抄的),也可以在外部编写 '.nsi'文件然后引用进来(听着就麻烦)
    function(uninstall_extra)
        foreach(file IN LISTS ARGN)
            if(IS_DIRECTORY "${file}")
                set(command "rmdir /s /q \"$INSTDIR\\\\${file}\"")
            else()
                set(command "del /f /q \"$INSTDIR\\\\${file}\"")
            endif()
            set(CPACK_NSIS_EXTRA_UNINSTALL_COMMANDS "${CPACK_NSIS_EXTRA_UNINSTALL_COMMANDS}\n !system '${command}'")
        endforeach()
    endfunction()

    if(MINGW) # delete all things in install directory
        uninstall_extra("$INSTDIR")
    endif()
endif()

# 这句话必须要有哦
include(CPack)

### 这是用来提示自己的命令行的命令,不然老年痴呆记不住
# cmake --build . --target install --verbose
# cpack.exe .\CPackConfig.cmake
  • 采用 aux_source_directory(路径 变量) 可获取路径下所有的.cpp/.c/.cc文件(不包括子目录),并赋值给变量;
  • 采用 add_compile_definitions(xxx=1) 可以给宏具体值,但是只有高版本的cmake支持,等价于 #define xxx 1
  • 采用 add_subdirectory(子文件夹名称) 编译子文件夹的 CMakeLists.txt
  • 如果需要将工程编译为静态库,那么使用 add_library(库文件名称 STATIC 文件)。注意,库文件名称通常为 libxxx.so,在这里要去掉前后缀写 xxx 即可;
  • 规定 .so/.a 库文件路径使用 link_directories(路径)

3.7. 生成(Generate)

我们一般会在 CMakeLists.txt 所在的目录下(一般也就是工程项目的根目录)手动新建一个 build 文件夹,这将用于存储 CMake 构建的中间文件和生成的目标文件。这种方式实际上是 cmake 的 out-of-source 构建方式。这种方法可以保证生成中间产物与源代码分离(即生成的所有目标文件都存储在 build 文件夹中,因此不会干扰源代码中的任何文件)。

采用命令行的方式可操作如下

1
2
3
4
mkdir build
cd build
cmake ..
make

若使用 VSCode 并安装了合适的插件,那么在使用快捷键 Ctrl+S 保存 CMakeLists.txt 时,会自动生成生成项目构建所需的中间文件。

配置和生成过程如下图所示。

alt text

生成完毕得到的中间文件如下图所示。

alt text

3.8. 构建(Build)

采用 CMake 构建项目有三种方式:

  • 方式1:打开命令板(Ctrl+Shift+P)并运行 CMake:Build
  • 方式2:或从底部状态栏中点击”build”按钮;
  • 方式3:打开命令行窗口(快捷键 Ctrl + ~ )输入 cmake --build build

下图是采用方式2进行构建的示意图。

alt text

3.9. 运行和调试(Debug)

运行和调试项目,打开某个源代码文件,并设置一个断点。然后打开命令板(Ctrl+Shift+P),并运行 CMake: debug,然后按 F5 继续调试。

或者点击 VSCode 下方的 【虫子】 图标进行 DEBUG 调试。

4. 基于 CMake 的打包

4.1. CPack

CPack 是 CMake 2.4.2 之后的一个内置工具,用于创建软件的二进制包和源代码包。

CPack 在整个 CMake 工具链的位置如下图所示。

alt text

CPack 支持打包的包格式有以下种类:

  • 7Z (7-Zip file format)
  • DEB (Debian packages)
  • External (CPack External packages)
  • IFW (Qt Installer Framework)
  • NSIS (Null Soft Installer)
  • NSIS64 (Null Soft Installer (64-bit))
  • NuGet (NuGet packages)
  • RPM (RPM packages)
  • STGZ (Self extracting Tar GZip compression
  • TBZ2 (Tar GZip compression)
  • TXZ (Tar XZ compression)
  • TZ (Tar Compress compression)
  • ZIP (ZIP file format)

为什么要用打包工具:软件程序想要在生产环境快速被使用,就需要一个一键安装的安装包,这样生产环境就可以很方便的部署和使用。生成一键安装的安装包的工具就是打包工具。其中 NSIS 是 Windows 环境下使用的打包工具。

选择 CPack 的原因:C++ 工程大部分都是用 CMake 配置编译, 而 CPack 是 CMake 内置的工具,支持打包成多种格式的安装包。因为是 CMake 的内置工具,所以使用的方式也是通过在 CMakeLists.txt 配置参数,就能达到我们的需求。使用起来很方便,容易上手。

如何安装 CPack:安装 CMake 的时候会把 CPack 一起安装了。

4.2. NSIS

官网下载最新版本并安装:https://nsis.sourceforge.io/Download。

NSIS是开发人员创建 Windows 下安装程序的工具。它可以创建能够安装、卸载、设置系统设置、提取文件等的安装程序。

NSIS允许您创建从只复制文件的基本安装程序到处理许多高级任务(如编写注册表项、设置环境变量、从internet下载最新文件、自定义配置文件等)的非常复杂的安装程序的所有内容。

NSIS基于脚本文件,支持变量、函数和字符串操作,就像一种普通的编程语言一样,但它是为创建安装程序而设计的。在默认选项下,它的开销只有34kb。同时由于其强大的脚本语言和对外部插件的支持,仍然提供了许多选项。

安装完成后,NSIS 具备一个 GUI,但是我一般不用,而是直接通过 CMakeLists.txt 文件调用 NSIS 进行打包。

如果需要使用 GUI 来辅助生成打包脚本,参考 此处

4.3. 基于 CPack 和 NSIS 的打包

完成项目构建后, 你会发现 build 目录下面多了两个文件 CPackConfig.cmakeCPackSourceConfig.cmake。在终端执行以下命令完成打包,得到可执行安装程序。

1
cpack.exe .\CPackConfig.cmake

alt text

将安装程序分发到其它 Windows 平台即可完成安装。

注意,若发布的安装程序在完成安装后,提示缺少某个 dll 文件,那么需要重新更改 CMakeLists.txt 文件,将相应的 dll 文件进行安装。最好使用dll依赖查询工具来查看编译得到的可执行程序(.exe)依赖哪些第三方dll,然后逐一添加。

可选的工具包括 Dependencies

5. 参考文献

[1] maskerII. 【简书】CMakeLists 入门

[2] TomKing-tm. vsCode+CMake开发环境搭建

[3] 双笙子佯谬. 现代C++中的高性能并行编程与优化

[4] 魔豆的BLOG. 使用NSIS制作安装包

本文由作者按照 CC BY 4.0 进行授权