本文章记录的是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.cpp
和main.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.o
和main.o
这两个文件,如果这两个文件不存在或者被修改了,就会去执行生成这两个文件的命令。
写好Makefile
后,我们只需要在当前目录下输入make
命令,就会帮我们自动编译,如果源文件没有改动的话,也不会重新编译,只会对修改过的文件重新编译。
如果读者对编写Makefile
文件感兴趣的话,在此也附上一个耗子叔的跟我一起写Makefile的教程。
虽然make
这个工具解决了一些问题,具有以下几个好处:
- 当更新了hello.cpp时只会重新编译hello.o,而不需要把main.o也重新编译一遍。
- 能够自动并行地发起对hello.cpp和main.cpp的编译,加快编译速度(make -j)。
- 用通配符批量生成构建规则,避免针对每个.cpp和.o重复写 g++ 命令(%.o: %.cpp)。
但也还是存在一些缺点的:
- make 在 Unix 类系统上是通用的,但在 Windows 则不然。
- 需要准确地指明每个项目之间的依赖关系,有头文件时特别头疼。
- make 的语法非常简单,不像 shell 或 python 可以做很多判断等。
- 不同的编译器有不同的 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 则不然。
- 只需要写一份 CMakeLists.txt,他就能够在调用时生成当前系统所支持的构建系统。
需要准确地指明每个项目之间的依赖关系,有头文件时特别头疼。
- CMake 可以自动检测源文件和头文件之间的依赖关系,导出到 Makefile 里。
make 的语法非常简单,不像 shell 或 python 可以做很多判断等。
- CMake 具有相对高级的语法,内置的函数能够处理 configure,install 等常见需求。
不同的编译器有不同的 flag 规则,为 g++ 准备的参数可能对 MSVC 不适用。
- CMake 可以自动检测当前的编译器,需要添加哪些 flag。比如 OpenMP,只需要在 CMakeLists.txt 中指明 target_link_libraries(a.out OpenMP::OpenMP_CXX) 即可。
还是用前面的例子,编译hello.cpp
和main.cpp
文件,下面我们来看看要如何编写CMakeLists.txt
文件。
CMakeList.txt
add_executable(a.out main.cpp hello.cpp)
上面这条简单的命令,指明我们要生成一个a.out
的可执行文件,依赖main.cpp
和hello.cpp
两个源文件。
然后我们用一个shell脚本,来运行CMake
构建编译系统,来编译运行程序。
bash
cmake -B build
cmake --build build --target a.out
build/a.out
这三条命令的详细解释如下:
cmake -B build
这个命令调用了CMake来生成构建系统。-B
选项后面跟着的是构建目录的名称,这里是build
。CMake将会在当前源代码目录查找CMakeLists.txt
文件,这是CMake的配置文件,其中包含了如何构建项目的指令。然后CMake将会在build
目录中创建必要的构建系统文件,这些文件取决于你的操作系统和编译器。cmake --build build --target a.out
这个命令是用来实际编译项目的。--build
选项后面跟着的是之前创建的构建目录,这里是build
。--target
选项后面跟着的是要构建的目标名称,这里指定为a.out
。通常情况下,a.out
是一个默认的输出文件名,用于表示可执行文件,尤其是在Unix-like系统中。如果没有指定--target
,CMake将会尝试构建在CMakeLists.txt
文件中定义的所有目标。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要如何编写。如果想了解这两个工具的更为详细进阶的用法则需要自己去学习。