01C++学习从构建系统学起从make到CMake

本文章记录的是b站up主双笙子佯谬开源的高性能编程与优化课程的学习笔记

一个C++程序的产生

当我们刚开始学习C++的时候,可能用的是集成开发环境,当我们写完代码想要运行程序的时候,只需要点击一下编译并运行的一个按钮,然后就可以运行我们的程序了。当时也没有仔细思考,为什么点击一个按钮就能够运行程序了。

从我们刚开始学习C语言的时候,我们就学习过,一个C语言源文件到一个可执行程序,需要经历预处理编译汇编链接这四个步骤才能使程序运行,同理C++程序也要经历这4个步骤。

下面我们通过一些简单的例子逐步的说明构建系统的重要性:

单文件编译

cpp 复制代码
#include <cstdio>

int main() {
    printf("Hello, world!\n");
    return 0;
}

上面是一个简单的hello world程序,我们可以用下面的shell脚本进行编译运行。

bash 复制代码
g++ main.cpp -o a.out
./a.out

多文件编译

我们把上面的一个文件拆分成两个文件,来进行多文件编译,因为在实际开发过程中,项目也是需要分成多个模块的,不能把所有代码都写到一个文件里面。

cpp 复制代码
// hello.cpp
#include <cstdio>

void hello() {
    printf("Hello, world\n");
}
cpp 复制代码
// main.cpp
#include <cstdio>

void hello();

int main() {
    hello();
    return 0;
}

然后我们也用一个shell脚本来编译运行这个程序。

bash 复制代码
g++ -c hello.cpp -o hello.o
g++ -c main.cpp -o main.o
g++ hello.o main.o -o a.out
./a.out

前面两条命令是分别对这两个源文件进行编译,第三条命令是将编译好的两个二进制文件链接成可执行文件 。

其实这三条命令也可以简化成以下一条命令:

bash 复制代码
g++ hello.cpp main.cpp -o a.out

之所以不写成一条命令,而是分别对每个源文件单独进行编译是因为这样可以减少编译时间,当其中某个文件修改了,只需要单独编译那一个文件就行,然后再重新链接就行了,而不需要把所有的源文件在编译一遍。

那我们怎么知道哪个文件被修改了,哪个文件需要重新编译呢?为了解决这个问题,就有大佬为我们开发出了构建工具,在Liunx操作系统下,内置了make构建工具,然后我们可以通过编写Makefile来指定编译规则,来看下面的例子。

利用Makefile编译

同样使用上面的例子,有两个源文件hello.cppmain.cpp,要将这两个源文件编译成一个可执行文件。上面的例子中我们使用shell脚本,但是shell脚本有个缺点,它不知道哪些文件被改动了,然后只重新编译这些文件。

make构建工具就能解决这个问题,make工具依赖Makefile文件指定程序的构建规则,下面我们编写Makefile文件。

Makefile 复制代码
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文件表示a.out文件生成依赖hello.omain.o这两个文件,如果这两个文件不存在或者被修改了,就会去执行生成这两个文件的命令。

写好Makefile后,我们只需要在当前目录下输入make命令,就会帮我们自动编译,如果源文件没有改动的话,也不会重新编译,只会对修改过的文件重新编译。

如果读者对编写Makefile文件感兴趣的话,在此也附上一个耗子叔的跟我一起写Makefile的教程。

虽然make这个工具解决了一些问题,具有以下几个好处:

  1. 当更新了hello.cpp时只会重新编译hello.o,而不需要把main.o也重新编译一遍。
  2. 能够自动并行地发起对hello.cpp和main.cpp的编译,加快编译速度(make -j)。
  3. 用通配符批量生成构建规则,避免针对每个.cpp和.o重复写 g++ 命令(%.o: %.cpp)。

但也还是存在一些缺点的:

  1. make 在 Unix 类系统上是通用的,但在 Windows 则不然。
  2. 需要准确地指明每个项目之间的依赖关系,有头文件时特别头疼。
  3. make 的语法非常简单,不像 shell 或 python 可以做很多判断等。
  4. 不同的编译器有不同的 flag 规则,为 g++ 准备的参数可能对 MSVC 不适用。

为了解决make工具存在的这些问题,于是跨平台的Cmake应运而生,Cmake的全称是Cross-platform Make,一开始可能大家会跟我一样,误解为Cmake只是对C系语言的构建工具,其实CMake中的C指的是跨平台的意思。可以把CMake理解成一个构建系统的构建系统,CMake通过编写CMakeLists.txt文件描述如何生成构建系统所需的文件,比如在linux平台下,就是生成Makefile文件,然后在通过运行构建工具如make来构建程序。

使用CMake编译

前面提到了make存在的缺点,下面说说CMake的优点,怎么解决了make的缺点。

  • make 在 Unix 类系统上是通用的,但在 Windows 则不然。
  1. 只需要写一份 CMakeLists.txt,他就能够在调用时生成当前系统所支持的构建系统。
  • 需要准确地指明每个项目之间的依赖关系,有头文件时特别头疼。
  1. CMake 可以自动检测源文件和头文件之间的依赖关系,导出到 Makefile 里。
  • make 的语法非常简单,不像 shell 或 python 可以做很多判断等。
  1. CMake 具有相对高级的语法,内置的函数能够处理 configure,install 等常见需求。
  • 不同的编译器有不同的 flag 规则,为 g++ 准备的参数可能对 MSVC 不适用。
  1. CMake 可以自动检测当前的编译器,需要添加哪些 flag。比如 OpenMP,只需要在 CMakeLists.txt 中指明 target_link_libraries(a.out OpenMP::OpenMP_CXX) 即可。

还是用前面的例子,编译hello.cppmain.cpp文件,下面我们来看看要如何编写CMakeLists.txt文件。

CMakeList.txt 复制代码
add_executable(a.out main.cpp hello.cpp)

上面这条简单的命令,指明我们要生成一个a.out的可执行文件,依赖main.cpphello.cpp两个源文件。

然后我们用一个shell脚本,来运行CMake构建编译系统,来编译运行程序。

bash 复制代码
cmake -B build
cmake --build build --target a.out
build/a.out

这三条命令的详细解释如下:

  1. cmake -B build 这个命令调用了CMake来生成构建系统。-B选项后面跟着的是构建目录的名称,这里是build。CMake将会在当前源代码目录查找CMakeLists.txt文件,这是CMake的配置文件,其中包含了如何构建项目的指令。然后CMake将会在build目录中创建必要的构建系统文件,这些文件取决于你的操作系统和编译器。
  2. cmake --build build --target a.out 这个命令是用来实际编译项目的。--build选项后面跟着的是之前创建的构建目录,这里是build--target选项后面跟着的是要构建的目标名称,这里指定为a.out。通常情况下,a.out是一个默认的输出文件名,用于表示可执行文件,尤其是在Unix-like系统中。如果没有指定--target,CMake将会尝试构建在CMakeLists.txt文件中定义的所有目标。
  3. build/a.out 这是一个相对路径,指向构建目录build中的可执行文件a.out。如果你在命令行中输入这个路径,它将会执行编译后的程序。如果一切顺利,这个可执行文件就是由源代码编译而来的最终产品。

运行这个shell脚本的结果如下:

从结果中可以看到,终端输出了CMake Warning, 说我们的CMakeLists.txt文件没有project()命令,一个CMakeLists.txt中需要有一些基本的命令,包括项目名、cmake依赖的最低版本等,下面重新写一下CMakeLists.txt文件

CMakeLists.txt 复制代码
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_executable(a.out main.cpp hello.cpp)

然后其他的不用改变,把build目录删除,然后重新运行run.sh脚本。

制作库(library)文件

  • 有时候我们会有多个可执行文件,他们之间用到的某些功能是相同的,我们想把这些共用的功能做成一个库,方便大家一起共享。
  • 库中的函数可以被可执行文件调用,也可以被其他库文件调用。
  • 库文件又分为静态库文件和动态库文件。
  • 其中静态库相当于直接把代码插入到生成的可执行文件中,会导致体积变大,但是只需要一个文件即可运行。
  • 而动态库则只在生成的可执行文件中生成"插桩"函数,当可执行文件被加载时会读取指定目录中的.dll文件,加到内存中空闲的位置,并且替换相应的"插桩"指向的地址为加载后的地址,这个过程称为重定向。这样以后函数被调用就会跳转到动态加载的地址去。
  • Windows:可执行文件同目录,其次是环境变量%PATH%
  • Linux:ELF格式可执行文件的RPATH,其次是/usr/lib等

使用CMake,我们也可以很方便的制作库文件,只需要在CMakeLists.txt中添加相应的命令即可,看下面的例子:

CMakeLists.txt 复制代码
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_library(hellolib STATIC hello.cpp)
add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC hellolib)

add_library(hellolib STATIC hello.cpp)表示通过hello.cpp制作一个hellolib的静态链接库。

target_link_libraries(a.out PUBLIC hellolib)则表示可执行文件a.out要链接hellolib这个库文件。PUBLIC 是一个关键字,它指定了链接库的范围。使用 PUBLIC 关键字意味着链接的库不仅会被目标(a.out)使用,还会被任何依赖于这个目标的其他目标(比如其他可执行文件或库)使用。这相当于在编译器的命令行中传递链接选项给所有的链接目标。

在制作库的时候,我们一般是把函数实现编译成二进制的库文件,然后提供一个头文件,头文件中包括这些函数的声明,于是用户需要使用我们制作的库时,就需要在源代码中导入我们的头文件,然后链接的时候链接我们的库文件。下面我们重新把hello函数的声明和实现分开。

cpp 复制代码
// hello.cpp
#include <cstdio>

void hello() {
    printf("Hello, world\n");
}
cpp 复制代码
// hello.h
void hello();
cpp 复制代码
// main.cpp
#include <cstdio>

#include "hello.h"

int main() {
    hello();
    return 0;
}
cpp 复制代码
// other.cpp
#include <cstdio>

#include "hello.h"

void otherfunc() {
    hello();
}
CMakeLists.txt 复制代码
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_library(hellolib STATIC hello.cpp)
add_executable(a.out main.cpp other.cpp)
target_link_libraries(a.out PUBLIC hellolib)
bash 复制代码
// run.sh
cmake -B build
cmake --build build --target a.out
build/a.out

运行run.sh结果如下:

分模块编译

一个实际的项目肯定是分模块来编写的,分模块编译有很多好处,便于维护管理等。下面我们将上面的例子的hello的静态库划分成一个模块来编译。当前工作目录如下:

bash 复制代码
├── CMakeLists.txt
├── hellolib
│   ├── CMakeLists.txt
│   ├── hello.cpp
│   └── hello.h
├── main.cpp
└── run.sh

其中最外层目录下的CMakeLists.txt的内容如下:

CMakeLists.txt 复制代码
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_subdirectory(hellolib)

add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC hellolib)

hellolib目录下的CMakeLists.txt中的内容为:

CMakeLists.txt 复制代码
add_library(hellolib STATIC hello.cpp)

主要命令是add_subdirectory(hellolib),用于告诉 CMake 在当前项目的构建过程中包含另一个子目录。这个子目录通常包含另一个项目的 CMake 配置文件 CMakeLists.txt,这样 CMake 就可以使用这个子目录中的源文件和资源来构建一个库或可执行文件。

当我们需要使用第三方库时,就可以采用子模块的形式,将第三方库引用进自己的项目里。

笔记就记录到这了,本文主要记录的是从make到CMake的进化,并没有详细去说明Makefile和CMakeLists.txt要如何编写。如果想了解这两个工具的更为详细进阶的用法则需要自己去学习。

相关推荐
凌云行者1 小时前
OpenGL入门005——使用Shader类管理着色器
c++·cmake·opengl
凌云行者1 小时前
OpenGL入门006——着色器在纹理混合中的应用
c++·cmake·opengl
赵民勇2 天前
cmake中execute_process详解
cmake
凌云行者3 天前
OpenGL入门004——使用EBO绘制矩形
c++·cmake·opengl
长弓聊编程5 天前
应该怎么理解CMakeLists.txt中一些指令的INTERFACE、PUBLIC和PRIVATE参数
cmake
凌云行者6 天前
OpenGL入门003——使用Factory设计模式简化渲染流程
c++·cmake·opengl
凌云行者6 天前
OpenGL入门002——顶点着色器和片段着色器
c++·cmake·opengl
梦起丶7 天前
CMake 生成器表达式介绍
c++·cmake
梦起丶9 天前
CMake 生成器表达式---条件表达式和逻辑运算符
c++·cmake
咩咩大主教11 天前
QtCreator通过CMake多文件编译.cpp、.qss、.h、.ui文件,达到MVC三层架构的效果
c++·qt·cmake·qtcreator·qss·多文件编译·qmake