本文来源于 B 站视频,鄙人只是跟着做一下笔记:

感觉这个老师讲的真的好 ,要是杭电也都是这样的的老师就好了(


编译链接的预备知识

在手动编译一些 C/C++ 项目时,你会经常遇到下面的命令

1
2
cmake .
make

这两个都是用来管理和编译 C/C++ 项目的工具,本文将浅浅地介绍这两个工具的使用

首先引出一个小项目作为例子,项目结构如下

1
2
3
4
5
./
├── factorial.cpp
├── function.h
├── main.cpp
└── printhello.cpp

目前共有 4 个文件,首先来看 printhello.cpp

1
2
3
4
5
6
7
8
9
#include <iostream>
#include "function.h"
using namespace std;

void printhello()
{
int i;
cout << "Hello World!" << endl;
}

这是一个很简单的文件,仅有一个 printhello() 函数,功能也很简单

但请记住这个定义了但没使用 i 变量,后面会出场

接着看 factorial.cpp ,它包含一个简单的递归函数,用于求阶乘

1
2
3
4
5
6
7
8
9
#include "function.h"

int factorial(int n)
{
if (n == 1)
return 1;
else
return n * factorial(n - 1);
}

现在来看 function.h ,它将上面两个函数注册其中,供下面的主函数调用

1
2
3
4
5
#ifndef _FUNCTIONS_H_
#define _FUNCTIONS_H_
void printhello();
int factorial(int n);
#endif

最后来看 main.cpp ,它通过引用头文件,调用了最开始的两个函数

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include "function.h"
using namespace std;

int main()
{
printhello();

cout << "This is main:" << endl;
cout << "The factorial of 5 is :" << factorial(5) << endl;
}

也就是说,我们现在有两个函数分别定义在不同的文件中,并且在主函数中调用了他们

所有的函数定义都写在头文件中,main.cpp 引用了该头文件,就能调用那两个函数

那么现在要编译这个项目,一个最偷懒的方法如下:

1
g++ main.cpp factorial.cpp printhello.cpp -o main

那这样的确可以成功运行,但如果源文件很多的话怎么办?文件一多,如果每次都要一起编译,就很浪费时间

一种省时间的方式是不一起来编译,而是分别编译,生成目标文件,最后再手动链接在一起

1
2
3
4
g++ -c main.cpp
g++ -c factorial.cpp
g++ -c printhello.cpp
g++ *.o -o main

image-20221201111659502

也就是说,在源文件很多的时候,可以只单独编译修改了的文件,然后再链接在一起,这样就可以省下其他文件的编译时间

那现在又有一个问题了,在文件很多的情况下,每次都手动输入这些命令是不是很麻烦

那这个过程其实完全可以写在一个脚本文件里面,一种通用的格式就叫 Makefile


Makefile

首先在当前目录新建一个 Makefile 文件(就叫这个名字),在里面编写脚本

下面来看看几个脚本版本,由浅入深地理解

VERSION 1

1
2
3
## VERSION 1
hello: main.cpp printhello.cpp factorial.cpp
g++ -o hello main.cpp printhello.cpp factorial.cpp

这是一个最基本最简单的 Makefilehello 指生成的可执行文件叫 hello ,冒号后面的内容是依赖,也就是说 hello 依赖于后面的几个 cpp 文件

而下面一行,首先是一个 Tab(不能是空格) ,然后是生成目标的命令 g++ -o hello main.cpp printhello.cpp factorial.cpp

也就是说

1
2
3
## VERSION 1
hello: hello 的依赖
hello 的生成命令

那写了 Makefile 之后怎么编译呢?先把之前临时文件都清除,然后使用 make 命令

make 会自动在当前目录下找 Makefile 文件,当然你也可以使用 -f 参数手动指定文件

1
make -f Makefile

这样就能自动编译文件了

而如果你再运行一次,你就会发现它会返回下面的内容

image-20221201113637495

这是因为 make 发现 hello 的修改日期比它的依赖都新,也就没有重新生成的必要了

这时给 main.cpp 加个空格修改一下,那么它的修改时间就比 hello 更新了,就需要重新生成,这个命令就会智能地重新运行

image-20221201114037810

这是第一个版本,这个版本的缺点也很明显,就是如果我每多一个文件,就要修改一次 Makefile ,文件一多也不好管理,而且它的生成命令也是最原始最偷懒的版本,所有文件一起编译

下面来看第二个版本

VERSION 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
## VERSION 2
CXX = g++
TARGET = hello
OBJ = main.o printhello.o factorial.o

$(TARGET): $(OBJ)
$(CXX) -o $(TARGET) $(OBJ)

main.o: main.cpp
$(CXX) -c main.cpp

printhello.o: printhello.cpp
$(CXX) -c printhello.cpp

factorial.o: factorial.cpp
$(CXX) -c factorial.cpp

VERSION 2 看上去就更加地专业

首先定义了三个变量 CXXTARGETOBJ ,其中 OBJ 包括3个元素

然后是 TARGET 依赖于 OBJ ,被依赖的文件如果没有生成,就会向下查找它依赖的文件,以及是怎么生成的,基本是个递归的过程,最终把每个目标都生成出来

image-20221201120949016

现在把 main.cpp 做一点修改,再调用 make 命令

image-20221201121127034

会发现只会编译你修改的文件,然后就去链接了,节省编译的时间

接下来是第3个版本,又升级了一下

VERSION 3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
## VERSION 3
CXX = g++
TARGET = hello
OBJ = main.o printhello.o factorial.o

CXXFLAGS = -c -Wall #显示警告信息

# $@表示目标文件,$^表示所有的依赖文件
$(TARGET): $(OBJ)
$(CXX) -o $@ $^

# $<表示第一个依赖文件,$@表示目标文件
%.o: %.cpp
$(CXX) $(CXXFLAGS) $< -o $@

.PHONY: clean # 伪目标,不是文件名
clean: # 清除所有的.o文件
rm -f *.o $(TARGET)

其中,$@ 就是 TARGET ,就是冒号前面的东西;$^ 就是依赖,就是冒号后面的内容,也就是 OBJ ,也就是少写了一些变量;%< 指的是依赖的第一个,但是这里其实也只有一个,也无所谓(

现在来运行一下,发现出现了警告,这是 -Wall 参数的作用,将所有的编译参数放在一起,可以方便日后修改

image-20221207230448335

而下面的

1
2
3
.PHONY: clean # 伪目标,不是文件名
clean: # 清除所有的.o文件
rm -f *.o $(TARGET)

是为了实现一个 make clean 的功能,也就是清空所有非源码文件

image-20221207230644017

可以这样来理解:我现在有一个 clean 目标,当我 make clean 的时候就会去执行对应的生成指令

当然啦,并没有什么 clean 文件,只是去骗它来执行 rm -f *.o $(TARGET) 这条命令罢了哈哈哈

然后为什么要有一行 .PHONY: clean 捏,这是因为如果当前目录正好有一个 clean 文件,那就不会被骗去执行生成命令了

而又有一个 .PHONY 目标依赖于 clean ,即使当前有一个 clean.PHONY 还是不存在的(其实这东西永远也不会存在),那么还是会去调用 clean 的生成命令,也就被骗去执行了那条指令

现在,如果又要加新文件,只要在 OBJ 变量后面加文件名就好了,但是这样还不是最方便的,最方便的应当是自动编译目录下的所有文件

VERSION 4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
## VERSION 4
CXX = g++
TARGET = hello
SRC = $(wildcard *.cpp) # 所有的.cpp文件
OBJ = $(patsubst %.cpp, %.o, $(SRC)) # 所有的.o文件,

CXXFLAGS = -c -Wall #显示警告信息

# $@表示目标文件,$^表示所有的依赖文件
$(TARGET): $(OBJ)
$(CXX) -o $@ $^

# $<表示第一个依赖文件,$@表示目标文件
%.o: %.cpp
$(CXX) $(CXXFLAGS) $< -o $@

.PHONY: clean # 伪目标,不是文件名
clean: # 清除所有的.o文件
rm -f *.o $(TARGET)

这个版本只变动了一个地方,也就是 SRCOBJ 两个变量那里

首先使用 wildcard 函数来找到当前目录下所有的 .cpp 文件

然后下面使用 patsubst 函数,也就是 pattern substitution

后面跟 3 个参数:要替换的模式,替换的内容,要替换的文本

也就是吧 SRC 中的 .cpp 全部换成 .o ,那么也就是对应的目标文件的路径

这样一来,就能实现上面说的功能了


CMake

Makefile 有一个致命的问题,就是与当前的开发环境强相关,比如到其他系统可能会出现编译器不同、路径不同等等各种问题

CMake 就是解决这种问题的工具(当然也还有很多同类型的工具),让你能够跨平台地生成自动生成 Makefile 文件

还是上面的例子,我们来用 CMake ,先创建一个 CMakeLists.txt 文件

然后很简单,只用三行:

1
2
3
4
5
cmake_minimum_required(VERSION 3.0)

project(hello)

add_executable(hello main.cpp factorial.cpp printhello.cpp)

首先指定最低版本,然后说现在有一个 projecthello ,再加上可执行程序 hello ,并且加上依赖的 cpp 就好了

然后使用 cmake . 来自动生成

image-20221207235620692

可以看见生成了不少东西,有一些缓存文件,还有个目录,当然还有个 Makefile

如果你去看这个文件,你会发现非常之长

image-20221207235853644

非常长,不管了,现在来用一下看看

image-20221208000015566

可以的,现在可以实现我们的目标了:跨平台地编译

然后现在发现多了很多相关的文件,如果都需要手动清理,就很麻烦

于是,就有了下面这种操作

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

这样,所有生成的文件就只在这个 build 目录里面,删起来就很方便

这也就是很多软件在 cmake 之前会创建一个目录的原因