CMake用一用就会了?!

在这里插入图片描述

“一个厨子做菜,抛开味道不谈,他至少得知道原材料是什么以及原材料在哪。”

前些日子接触了一些规模相对较大的项目,但一开始的项目编译就给我来了个下马威,该项目使用CMake构建,之前我对于CMake还停留在其只是一个项目构建工具的阶段,对里面的操作,细节不少还是比较生疏与模糊,所以对于我来说要把项目跑起来还不简单,于是乎就去学习并实践了CMake这个东西,最终也解决了相关问题, 这过程让我对于一些编译链接的底层有了更深入的理解,我也对如何组织我的代码,如何组织构建一个工程项目有了新的认识


CMake简单实战

我们完成一个计算小demo,以此来感受一下CMake的使用,在这里就直接用Visual Studio来完成
首先打开心爱的VS,然后我们选CMake工程,创建一个CMake工程项目。先清空一下自动生成的文件,然后添加一些目录与文件,工程结构如图所示:
在这里插入图片描述
其实要写的和平时的项目没啥区别,我们先把该demo写完:

  • main.cpp
#include <iostream>
#include "add.h"
#include "sub.h"
#include "mul.h"
#include "divi.h"

int main() {
    std::cout << add(114, 514) << std::endl;
    std::cout << sub(514, 114) << std::endl;
    std::cout << mul(514, 114) << std::endl;
    std::cout << divi(514, 114) << std::endl;
    return 0;
}
  • add.h
int add(int a, int b);
  • add.cpp
#include "add.h"
int add(int a, int b) {
    return a + b;
}
  • sub.h
int sub(int a, int b);
  • sub.cpp
#include "sub.h"
int sub(int a, int b) {
    return a - b;
}

剩下的代码类似,这样我们就构建好了一个Cal的demo,但是我们现在运行不起来的,因为我们这个是CMake工程,得编写一下CMake文件,打开我们的CMakeLists.txt文件,写下基础几步

  • 首先指定CMake最低版本
cmake_minimum_required (VERSION 3.8)
  • 定义项目名称
project ("CMakeProject1")
  • 添加源文件
add_executable(cal
    src/main.cpp
    cal/add.cpp
    cal/sub.cpp
    cal/mul.cpp
    cal/divi.cpp
)

这里是为项目添加一个可执行目标文件cal,然后后面的main.cpp、add.cpp等都是该目标文件所要用到的源文件。

  • 添加头文件路径
include_directories(cal)

我们这里的头文件都包含在cal这个目录下,指定后就会告诉编译器去这个路径下去找所要包含的头文件

demo完整CMakeLists代码如下:

cmake_minimum_required (VERSION 3.8)

project ("CMakeProject1")

include_directories(cal)

add_executable(cal
    src/main.cpp
    cal/add.cpp
    cal/sub.cpp
    cal/mul.cpp
    cal/divi.cpp
)

编写完CMake文件后,我们按下ctrl + s保存一下,则会自动完成CMake生成
在这里插入图片描述
接着我们会看到多了一个cal的可执行程序:
在这里插入图片描述
运行该程序,即可看到输出结果:
在这里插入图片描述

至此我们就完成了这个用CMake来构建的cal的demo
当然我们也只是完成了构建并成功跑了起来,而对于为什么要这样做,这样做的目的是什么,还没有去探究,比如为什么需要指定一个可执行程序?为什么需要指定头文件的包含目录?等等,我们接下来就去看看这些做法的意义。


编译与链接

“一个厨子做菜,抛开味道不谈,他至少得知道原材料是什么以及原材料在哪。”

从源文件到可执行文件过程中发生了什么?

我们都知道,当我们写好一个文件后,想要把它变成可执行文件,要经历以下几步:

  • 预处理
  • 编译
  • 汇编
  • 链接

预处理

编译器在正式编译之前会对源代码进行预处理。这一步主要包括:

  • 处理头文件的包含(例如C/C++中的#include)。
  • 处理宏定义和替换(例如#define和#ifdef等)。
  • 移除注释和执行条件编译指令。

预处理的结果是一个中间代码文件,该文件扩展名通常还是源文件的扩展名(如.i或.ii,根据语言和编译器不同有所区别)。

编译

在编译阶段,编译器将预处理过的源代码转换为中间代码或目标代码(即机器指令的汇编语言)。该过程包括:

  • 词法分析:将代码分解为基本语法元素(token)。
  • 语法分析:检查代码的结构是否符合编程语言的语法规则,生成抽象语法树(AST)。
  • 语义分析:检查代码的逻辑是否合理,例如类型检查、变量是否声明等。
  • 中间代码生成:生成机器无关的中间表示形式。
  • 目标代码生成:将中间表示转为机器码,通常是汇编代码。此时生成的文件叫目标文件(.o或.obj)。

目标文件是计算机可理解的低级代码,但尚不可执行。这里具体的可以看《编译原理》这门课与教材。

汇编

  • 编译器生成的汇编代码通过汇编器(assembler)转化为机器语言的二进制形式,成为目标文件(Object File)。在这个阶段,代码还不能独立运行。
  • 目标文件中可能包含了未解析的符号(例如函数调用或变量引用),这些符号将在链接阶段被解析。

链接

  • 链接器(Linker)负责将所有目标文件、库文件以及依赖项链接在一起,生成一个完整的可执行文件。
  • 链接分为两种:
    (1)静态链接:将所有的库函数和依赖项直接嵌入到可执行文件中。生成的可执行文件体积较大,但不依赖外部库。
    (2)动态链接:链接器将代码和动态库的引用放在可执行文件中,在运行时加载这些库。生成的可执行文件较小,依赖运行时的库文件(例如.dll或.so)。
  • 链接器还会解析目标文件中的符号,将函数调用和变量引用的地址填充到正确的位置。

对于链接来说,链接器会做很多事情,如符号解析、地址分配、重定位、节段合并、库文件处理等,具体可以看看《计算机的底层秘密》、《程序员的自我修养》。
经过这几步,一个可执行程序就诞生了,我们可以直接在操作系统中运行起来。那其实我们再回到CMake上,也同样在做这几步,目标文件,依赖项,库。


错误探究

接下来我们尝试对刚才的demo中的CMakeLists做一些变动,看看会发生什么

  • 注释掉include_directories
#include_directories(cal)

ctrl + s保存后,重新生成,不出意外我们会喜提一个错误:

严重性 代码 说明 项目 文件 行 禁止显示状态
错误 C1083 无法打开包括文件: “add.h”: No such file or directory D:\C++程序设计\CMakeProject1\out\build\x86-debug\CMakeProject1 D:\C++程序设计\CMakeProject1\src\main.cpp 2

C1083 无法打开包括文件"add.h",这个错误表明,**我们引入了一个"add.h"的文件,但是编译器无法找到,编译器查找""引入的文件时,首先会在当前源文件所在目录去找,而我们的main.cpp与add.h文件并不在同一个目录下,所以会找不到,接着编译器会再去我们所指定的目录去找,也就是前面的include_directories,由于我们已经将其注释,所以编译器也无法找到,于是就会发生,无法打开"add.h"的错误。
我们把注释取消,告诉编译器去"cal"目录下查找,ctrl + s保存后重新生成,全部生成成功。

  • 注释掉其中一个依赖的cpp文件
add_executable(cal
    src/main.cpp
    #cal/add.cpp
    cal/sub.cpp
    cal/mul.cpp
    cal/divi.cpp
)

ctrl + s保存后,重新生成,不出意外的喜提一个链接错误:

严重性 代码 说明 项目 文件 行 禁止显示状态
错误 LNK2019 无法解析的外部符号 “int __cdecl add(int,int)” (?add@@YAHHH@Z),函数 _main 中引用了该符号 D:\C++程序设计\CMakeProject1\out\build\x86-debug\CMakeProject1 D:\C++程序设计\CMakeProject1\out\build\x86-debug\main.cpp.obj 1

Link2019 无法解析的外部符号,相信很多朋友都遇到过这个错误,报这个错误的原因其实就是在链接阶段,通这意味着编译成功了,但在链接阶段未能找到某个外部符号(函数或变量)的定义。具体错误信息表明:在 main.cpp 文件中的 main 函数里调用了一个函数 add,但链接器无法找到这个 add 函数的定义,因此抛出了“无法解析的外部符号”的错误。 那为什么编译能过呢?因为我们已经给它声明了,在main.cpp中引用了add.h的头文件,告诉编译器有这个函数,编译器不需要知道这个函数的实现的具体位置,而那是在链接阶段做的事情。
我们把注释取消,添加目标文件所依赖的add.cpp,这时候在链接阶段就能去找到了,ctrl + s保存后重新生成,生成成功。

“一个厨子做菜,抛开味道不谈,他至少得知道原材料是什么以及原材料在哪。”,而我们要生成的可执行文件就是这道菜,原材料就是我们所需要的依赖文件以及库文件,原材在哪就是我们需要去指定找的路径与过程,而CMake的那些语法正是在做这些事情。


前面我们通过回顾小学时期学的编译链接过程与对demo的分析,已经初步了解了CMake的这些语法在干嘛,以及为什么要这样做,在不依赖任何第三方库的情况下,我们已经能够脱离任何IDE来跑起来一个可执行程序了,现在我们就来探究一下库这个东西以及如何在CMake上完成对库的操作。
首先回顾一下库的知识:

  • 库:是一个代码的集合,可以被多个程序共享。库提供了特定功能的实现,开发者可以直接调用库中的代码,而不需要重新实现这些功能。库通常分为两个类型:静态库和动态库。

静态库与静态链接

静态库回顾

  • 定义:静态库(Static Library)是指在编译时将库中的代码直接嵌入到可执行文件中。这意味着当你编译程序时,静态库的代码会被复制到最终的可执行文件中。

  • 文件扩展名:在Windows下通常是 .lib,在Unix/Linux下通常是 .a。

  • 优点:
    (1)独立性:静态库的代码被包含在可执行文件中,因此运行时不需要依赖库文件,减少了部署时的复杂性。
    (2)速度:在程序启动时,无需加载外部库,可能会稍微提高启动速度。

  • 缺点:
    (1)体积:因为每个可执行文件都包含库的代码,生成的可执行文件通常会比较大。
    (2)更新困难:如果库的实现需要更新,所有使用该库的可执行文件都必须重新编译。

制作静态库

我们来完成一个静态库的制作,也是一个计算的demo,只不过我们把所需的源文件都制作成库。项目开始的结构:
在这里插入图片描述
首先是head.h头文件:

  • head.h
#ifndef HEAD_H
#define HEAD_H

int add(int a, int b);
int sub(int a, int b);
int mul(int a, int b);
double divi(int a, int b);

#endif

我们打包成库时也要给比人提供头文件,这样才方便调用,这里就直接把头文件放入一个head.h中

然后是各功能的源文件:

  • add.h
#include "head.h"
int add(int a, int b) {
    return a + b;
}
  • sub.h
#include "head.h"
int sub(int a, int b) {
    return a - b;
}

其余的类似
接着编写CMakeList文件:

  • CMakeList文件
cmake_minimum_required(VERSION 3.10)

# 项目名称
project(CMakeProject1)

# 设置包含头文件目录
include_directories(${CMAKE_SOURCE_DIR}/include)

# 源文件列表
set(SOURCES
    src/add.cpp
    src/sub.cpp
    src/mul.cpp
    src/divi.cpp
)

# 创建静态库
add_library(cal STATIC ${SOURCES})

# 设置库文件输出目录
set_target_properties(cal PROPERTIES
    ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib
)

这里的set 指将所需源文件都命名为一个变量SOURCES,以方便后面使用,然后创建静态库使用add_library,指定类型为STATIC表明是静态库,然后后面跟 ${SOURCES},这里的$指的是取这个变量的值,取出来也就是:

src/add.cpp
src/sub.cpp
src/mul.cpp
src/divi.cpp

set_target_properties这里设置库的输出路径,也就是/lib文件夹下。
编写完后,ctrl+s后重新生成,可以发现lib文件夹下多了一个cal.lib文件
在这里插入图片描述
这样我们就完成了静态库的制作

链接静态库

接下来我们尝试链接刚才生成的库文件:
项目结构如下:
在这里插入图片描述
编写相关代码:

  • main
#include <iostream>
#include "head.h"

int main() {
    int a = 10, b = 5;
    std::cout << "Add: " << add(a, b) << std::endl;
    std::cout << "Sub: " << sub(a, b) << std::endl;
    std::cout << "Mul: " << mul(a, b) << std::endl;
    std::cout << "Div: " << divi(a, b) << std::endl;
    return 0;
}

  • CMakeList
cmake_minimum_required(VERSION 3.10)

# 项目名称
project(CMakeProject1)

add_executable(test src/main.cpp)

target_include_directories(test PRIVATE include)

target_link_directories(test PRIVATE lib)

target_link_libraries(test cal)

Ctrl+s保存后重新生成,运行查看结果:
在这里插入图片描述
成功使用静态库,这样我们就学会了如何用CMake制作静态库并使用静态库

动态库与动态链接

动态库知识点回顾:

  • 定义:动态库(Dynamic Library)是在运行时加载的库,编译时程序只链接库的接口。动态库的代码不被直接包含在可执行文件中,而是在程序运行时通过操作系统动态加载。
  • 文件扩展名:在Windows下通常是 .dll,在Unix/Linux下通常是 .so。
  • 优点:
    (1)共享:多个程序可以共享同一个动态库的实例,从而节省内存和磁盘空间。
    (2)更新便利:更新动态库时,只需替换库文件,不需要重新编译依赖该库的所有程序。
    (3)小型可执行文件:可执行文件体积小,因为它不包含库的实现。
  • 缺点:
    (1)运行时依赖:在运行时必须确保相关的动态库存在,否则会导致程序无法启动。
    (2)加载时间:动态库在运行时加载可能会稍微增加启动时间。
    (3)版本控制:不同版本的动态库可能引发“地狱版本问题”,即某个程序可能需要特定版本的库,导致冲突。
制作动态库

制作动态库的过程大体和静态库一样,不过需要多注意几个步骤,下面我们同样完整的制作一个动态库。项目开始的结构:
在这里插入图片描述
首先是head.h头文件:

  • head.h
#ifndef HEAD_H
#define HEAD_H

#ifdef MYLIBRARY_EXPORTS
#define MYLIBRARY_API __declspec(dllexport)
#else
#define MYLIBRARY_API __declspec(dllimport)
#endif

MYLIBRARY_API int add(int a, int b);
MYLIBRARY_API int sub(int a, int b);
MYLIBRARY_API int mul(int a, int b);
MYLIBRARY_API double divi(int a, int b);

#endif

这里我们使用宏定义来区分导入导出,也就是如果定义了了导出宏,那么就使用“__declspec(dllexport)”,如果没有定义,则为“__declspec(dllimport)”

然后是各功能的源文件:

  • add.cpp
#include "head.h"
MYLIBRARY_API int add(int a, int b) {
    return a + b;
}

可以看到add函数前面多了个“MYLIBRARY_API”,这个东西我们在head.h对其进行了定义
导出库时,即代表“__declspec(dllexport)”,这将告诉编译器我们要将其导出一个为库文件,如果没有这个东西,那么后面生成的库文件可能会少了.lib文件,导致生成时无法找到。 这是由于导出为动态库时,不仅包含.dll文件,还要生成一个.lib,告诉编译器动态库的信息,.lib文件在编译时就已经确定,而.dll在运行时才会去链接。 当然“__declspec(dllexport)”这个关键字只有在windows下使用MSVC的编译器才会要这样做,在其他平台上就不需要或者情况不是这样。
其他的源文件也类似:

  • sub.cpp
#include "head.h"
MYLIBRARY_API int sub(int a, int b) {
    return a - b;
}

剩下的以此类推

然后还需编写CMakeList文件:

cmake_minimum_required(VERSION 3.10)

# 项目名称
project(CMakeProject1)

include_directories(${CMAKE_SOURCE_DIR}/include)

# 源文件列表
set(SOURCES
    src/add.cpp
    src/sub.cpp
    src/mul.cpp
    src/divi.cpp
)

# 创建动态库,定义 MYLIBRARY_EXPORTS 宏以导出符号
add_library(cal SHARED ${SOURCES})
target_compile_definitions(cal PRIVATE MYLIBRARY_EXPORTS)

# 设置库文件输出目录
set_target_properties(cal PROPERTIES
    ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib
    LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib
    RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib
)

这里的set 指将所需源文件都命名为一个变量SOURCES,以方便后面使用,然后创建动态库也使用add_library,只不过我们要指定为SHARED,而target_compile_definitions则代表在编译时的宏定义,即定义为导出,那么上面的MYLIBRARY_API 就代表 __declspec(dllexport)set_target_properties这个语句则代表库文件的输出目录,可以指定所生成库文件相关的东西的输出目录,这三个分别代表:

  • ARCHIVE_OUTPUT_DIRECTORY:指定静态库(.lib 或 .a 文件)的输出目录。
  • LIBRARY_OUTPUT_DIRECTORY:指定动态库(.dll 或 .so 文件)的输出目录。
  • RUNTIME_OUTPUT_DIRECTORY:指定可执行文件和动态库的运行时文件的输出目录。

经过上面几步,我们就编写好了,接下来ctrl+s保存后,生成全部,可以看到在指定的输出目录lib下多了一些东西:
在这里插入图片描述
至此我们就完成了动态库的制作。

链接动态库

我们就拿刚才制作的库文件,来链接一下,重新建一个项目或者把项目结构设置成这样:
在这里插入图片描述
head.h文件就不用改了,而保留cal.lib文件是因为编译时需要告诉编译器动态库的信息
给出相关代码(和静态库的一样其实):

  • main.cpp
#include <iostream>
#include "head.h"
int main() {
    int a = 10, b = 5;
    std::cout << "Add: " << add(a, b) << std::endl;
    std::cout << "Sub: " << sub(a, b) << std::endl;
    std::cout << "Mul: " << mul(a, b) << std::endl;
    std::cout << "Div: " << divi(a, b) << std::endl;
    return 0;
}
  • 编写CMakeList:
cmake_minimum_required(VERSION 3.10)

# 项目名称
project(CMakeProject1)

add_executable(test src/main.cpp)

target_include_directories(test PRIVATE include)

target_link_directories(test PRIVATE lib)

target_link_libraries(test cal)

target_link_directories表示目标文件所要链接的库文件,target_link_libraries则是指定去哪个路径下去找。写完后我们ctrl+s保存,全部生成,生成成功。然而,如果此时直接点击运行的话,不出意外我们又会喜提一个报错:
在这里插入图片描述
相信很多朋友在做项目时都碰到类似的问题,找不到.dll,只是因为我们的.dll文件在可执行程序调用时,找不到它在哪,其实我们只需要让它找得到就好了,最好的方法就是直接将.dll文件放和可执行文件放在同一个路径下,或者指定.dll的路径,这里我直接把他们放在同一个目录下:
在这里插入图片描述
这时候,我们重新运行一下,发现大功告成:
在这里插入图片描述
至此,我们就学会了如何制作和使用动态库了,现在我们已经可以完成所有项目(包括使用第三方库)的项目的构建了,可以随心所欲了属于是。当然,我们现在学的内容,也只是一些基本的操作与使用,其实还有很多东西,比如find_package等一些更高级的内容,这些后续有机会探讨。


跨平台

上面我们完成了一个用CMake构建项目的简单实战,但是一开始用的话,肯定会有和我一样的疑问:CMake这么麻烦,我为什么要用CMake呢?我直接打开VS,新建一个空项目,然后添加源文件,添加头文件,再一键运行不就好了吗? 的确,打开VS直接添加文件一件运行似乎方便很多,不过在一些情况中,CMake有其强大之处:

  • 跨平台支持
    假设我们开发了一款软件,该软件既要能在windows上运行,又要能在Linux上运行,或者在其他更多的操作系统上运行,我们在小学三年级中学过,不同的操作系统对于项目的构建是不一样的,他们有各自的一套规则,那对于同一个项目,我们想要进行编译链接生成可执行程序,就需要有不同的操作,那想要成功运行起来,我们就要针对不同的操作系统编写不同的文件,而则个工程量可是不小的,而CMake 是一个跨平台的工具,支持多种操作系统和编译器(如 Linux、macOS、Windows,支持 Makefiles、Visual Studio 项目、Xcode 项目等)。使用 CMake,开发者可以通过一套统一的构建脚本(CMakeLists.txt)来生成不同平台上的构建文件,从而避免了为每个平台编写不同的构建配置。
  • 更好的拓展与管理
    CMake 提供了广泛的模块支持,允许开发者根据项目的需求来添加、配置和扩展项目。通过find_package()可以轻松查找第三方库,如 Boost、OpenSSL、Qt 等。CMake 支持模块化的开发,可以管理大型项目中的多个子项目或模块。
  • 简化复杂的构建过程
    对于多语言、多库、多目标的项目,手动编写平台相关的构建文件(如Makefile或Visual Studio解决方案)可能会变得极为复杂。CMake 提供了自动化和标准化的构建过程,可以处理复杂的依赖关系、库的链接以及编译选项的设置。

CMake 之所以成为主流的构建工具,是因为它解决了跨平台编译、依赖管理、复杂项目构建等方面的痛点,简化了开发者的构建流程,并且提供了良好的可扩展性和灵活性。尤其对于需要支持多个操作系统和编译器的项目,CMake 会是个好的选择。

总结

学习并使用CMake的过程中,我们还学习了很多本质的东西,与其说在学CMake,倒不如说是在学如何去构建一个项目,学习编译链接原理,CMake看似复杂的背后其实蕴含了很多“基础”的东西,知其然好,知其所以然更好,只有知道自己在做什么,以及去做的途径与方法,才能把事情做好,正如开头所言:

“一个厨子做菜,抛开味道不谈,他至少得知道原材料是什么以及原材料在哪。”

好好享受做菜的过程吧!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值