由浅入深,教你使用Makefile编译C++文件
一、什么是Makefile
通常一个工程文件项目会包含许多的源文件,可能一个文件夹包含很多的源文件,也可能很多源文件按照类型、功能、模块放在不同的文件夹里,这时,如何对这些代码的编译就成了个问题。Makefle就是为这个问题而生的。
Makefile 是一种文本文件,用于定义软件项目的构建规则和依赖关系,它定义了一套规则,决定了哪些文件要先编译,哪些文件后编译,哪些文件要重新编译。它通常与构建工具 make 配合使用,使得项目的编译、链接和其他构建任务能够自动执行。make 是一个构建工具,负责根据 Makefile 中定义的规则来生成目标文件。Makefile 中包含一系列规则,每个规则描述了如何从源文件生成目标文件。
二、Makefile的优点
- Makefile 允许开发人员定义项目的构建规则,使得构建过程能够自动执行。这减少了手动构建的需要,提高了构建的效率。
- Makefile 明确定义了目标和依赖关系,确保只有在需要时才会重新构建相关文件。这提高了构建的速度,避免了不必要的重新编译。
- Makefile 提供了一种结构化的方式来组织和管理项目的构建规则。这使得项目的构建逻辑更易于理解和维护,尤其是在大型项目中。
- Makefile 可以跨平台使用,只要 make 工具在目标平台上可用。这使得项目可以在不同的操作系统上进行构建,而不需要修改构建规则。
- Makefile 允许开发人员使用变量、条件语句、循环等高级功能,使得构建规则更加灵活和可配置。这样可以适应不同的项目需求和变化。
三、Makefile的基本编写规则
Makefile 的基本结构包括目标(target)、依赖项(dependencies)和命令(commands)。
每个规则描述了一个或多个目标,这些目标依赖于一组文件(依赖项),并且规定了生成目标的命令。
规则的结构如下:
target: dependencies
command1
command2
...
-
target
是规则生成的目标,可以是可执行文件、库文件等。 -
dependencies
是target
生成所依赖的文件或其他目标。 -
command1
,command2
等等是执行的命令,用于生成target
(注意:命令前面用tap
隔开)。
文字描述如下图所示:
Make 工具使用 Makefile 中的规则,通过比较文件的时间戳,确定哪些文件需要重新生成,然后按照规则执行相应的命令。这使得开发人员能够以更加自动化和可维护的方式管理项目的构建过程。
四、代码示例
为了方便理解,这里引用了一个小型的C++工程代码方便后续解释,结构如下:
这个文件夹下面一共包含三个C++文件、一个头文件和一个Makefile文件,下面将分别介绍这几个文件的内容。
- printhello.cpp
这个源文件包含一个 printhello()
函数,它的功能就是打印一句话(这里有个变量 i 并未用到,后面会讲)。
#include <iostream>
#include "functions.h"
using namespace std;
void printhello() {
int i;
cout << "\nHello world from the printhello.cpp!\n" << endl;
}
- factorial.cpp
这个源文件包含一个 factorial(int n)
函数,它的作用是返回 n 的阶乘。
#include "functions.h"
int factorial(int n) {
if (n == 1)
return 1;
else
return n * factorial(n - 1);
}
- main.cpp
主函数的作用就是调用另外两个源文件的函数,并在主函数里输出一句话。
#include <iostream>
#include "functions.h"
using namespace std;
int main() {
printhello();
cout << "This sentenceis comes from the main function!\n" << endl;
cout << "The factorial of 5 is :" << factorial(5) << "!\n" << endl;
return 0;
}
- functioins.h
这个头文件就是包含另外两个源文件的函数声明。
#ifndef _FUNCTIONS_H_
#define _FUNCTIONS_H_
void printhello();
int factorial(int n);
#endif
五、手动编译工程文件
这一部分是用来引入Makefile文件,用来展示如果没有Makefile文件的话,我们是如何手动编译工程文件。
通过上方main函数,我们可以知道这个小工程最后生成可执行文件依赖于上面的三个源文件,我们可以不使用Makefile文件进行编译,编译过程如下:
- 编译之前,检查此时文件夹所包含的文件:
PS E:\C++\CUDA\Makefile> ls
目录: E:\C++\CUDA\Makefile
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2024/1/29 16:53 117 factorial.cpp
-a---- 2024/1/29 16:53 101 functions.h
-a---- 2024/1/30 16:53 263 main.cpp
-a---- 2024/1/29 22:17 425 Makefile
-a---- 2024/1/30 16:53 164 printhello.cpp
- 使用g++编译器,通过
main.cpp
factorial.cpp
printhello.cpp
三个源文件和-o
命令,生成hello.exe
可执行文件,然后通过命令查看此时文件夹里已经生成了目标文件:
PS E:\C++\CUDA\Makefile> g++ main.cpp factorial.cpp printhello.cpp -o hello
PS E:\C++\CUDA\Makefile> ls
目录: E:\C++\CUDA\Makefile
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2024/1/29 16:53 117 factorial.cpp
-a---- 2024/1/29 16:53 101 functions.h
-a---- 2024/1/30 16:57 58857 hello.exe
-a---- 2024/1/30 16:53 263 main.cpp
-a---- 2024/1/29 22:17 425 Makefile
-a---- 2024/1/30 16:53 164 printhello.cpp
- 运行可执行文件
PS E:\C++\CUDA\Makefile> .\hello.exe
Hello world from the printhello.cpp!
This sentenceis comes from the main function!
The factorial of 5 is :120!
文件编译并执行成功!
六、Makefile Version 1
由上面这个版本可以看出,虽然不使用Makefile文件也可以编译工程,但是如果源文件很多呢?
那之后的每次编译都要敲一长串的命令,修改之后又要重新敲,这样就显得很繁琐,于是我们引入了Makefile文件。
参考上面的基本编写规则,我们可以写出下面的Makefile文件:
## VERSION 1
hello: main.cpp factorial.cpp printhello.cpp
g++ -o hello main.cpp factorial.cpp printhello.cpp
注释: 使用 #
符号进行注释。注释可以出现在行首或行内,但不会被 Make 解释为命令。
通过上面的Makefile文件基本结构我们可以得出:
hello
是目标文件main.cpp
factorial.cpp
printhello.cpp
是依赖文件g++ -o hello main.cpp factorial.cpp printhello.cpp
是命令- 上面命令就是使用
g++
编译器,通过-o
命令生成可执行文件(也就是目标文件)hello
所依赖的文件是main.cpp
factorial.cpp
printhello.cpp
- 上面命令就是使用
使用Makefile文件编译之前,我们需要删除上次手动编译产生的 hello.exe
可执行文件,避免和我们新的编译方法产生歧义,然后查看文件夹。
- 如下所示,此时已经成功删除(这一步骤后面就不重复赘述了)。
PS E:\C++\CUDA\Makefile> rm .\hello.exe
PS E:\C++\CUDA\Makefile> ls
目录: E:\C++\CUDA\Makefile
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2024/1/29 16:53 117 factorial.cpp
-a---- 2024/1/29 16:53 101 functions.h
-a---- 2024/1/30 16:53 263 main.cpp
-a---- 2024/1/29 22:17 425 Makefile
-a---- 2024/1/30 16:53 164 printhello.cpp
- 编写好Makefile文件后,然后我们就可以使用
make
命令来编译,可以看到终端会执行我们之前写好的编译命令。
PS E:\C++\CUDA\Makefile> make
g++ -o hello main.cpp factorial.cpp printhello.cpp
- 然后通过命令查看文件夹,发现产生了新的
hello.exe
可执行文件。
PS E:\C++\CUDA\Makefile> ls
目录: E:\C++\CUDA\Makefile
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2024/1/29 16:53 117 factorial.cpp
-a---- 2024/1/29 16:53 101 functions.h
-a---- 2024/1/30 17:37 58857 hello.exe
-a---- 2024/1/30 16:53 263 main.cpp
-a---- 2024/1/29 22:17 425 Makefile
-a---- 2024/1/30 16:53 164 printhello.cpp
- 运行可执行文件(这一步骤后面也不重复赘述了)
PS E:\C++\CUDA\Makefile> .\hello.exe
Hello world from the printhello.cpp!
This sentenceis comes from the main function!
The factorial of 5 is :120!
文件编译并执行成功!
七、Makefile Version 2
有了上面的 Makefile Version 1 ,我们就可以一次编写好Makefile文件,后续无论怎么修改源文件的内容,我们只需要利用make工具,按照Makefile文件重新编译就好,不需要每次都输入一长串的命令,Makefile的优势就凸显出来了。
但是上面的 Makefile Version 1存在一个问题,如果我的源文件的数量非常多呢?
这意味着假如我修改其中某一个源文件的代码,但是下次编译需要把全部的源文件都重新编译一遍,这样的话当工程庞大时,会大大降低执行效率。
于是我们引入几个新的编写规则,在Makefile文件中引入变量:
- 定义变量
在 Makefile 中,可以使用=
或:=
运算符定义变量。=
是简单的赋值,而:=
是用于延迟展开的赋值(二者区别这里不展开叙述,有需要的可以自行查阅资料)。
# 使用等号定义变量
CXX = g++
TARGET = hello
OBJ = main.o factorial.o printhello.o
这里其实类似于C/C++的宏定义,可以简单理解为等号左边就等同于等号右边。
- 使用变量
在 Makefile 中使用变量,可以通过 $() 或 ${} 语法。变量的引用可以出现在规则的目标、依赖项、命令中等位置。
# 使用变量在规则中
$(TARGET): $(OBJ)
$(CXX) -o $(TARGET) $(OBJ)
如上所示, $(TARGET)
就等同于 hello
,$(OBJ)
就等同于 main.o factorial.o printhello.o
然后我们就写出了版本二:
## VERSION 2
CXX = g++
TARGET = hello
OBJ = main.o factorial.o printhello.o
$(TARGET): $(OBJ)
$(CXX) -o $(TARGET) $(OBJ)
mian.o: main.cpp
$(CXX) -c main.cpp
factorial.o: factorial.cpp
$(CXX) -c factorial.cpp
printhello.o: printhello.cpp
$(CXX) -c printhello.cpp
在这个版本中,我们更改了最终目标文件的依赖项,版本一里我们的可执行文件直接依赖于源文件,在版本二中,我们的可执行文件依赖于 .o
文件,也就是源文件编译后的 .obj
文件,通过 -o
命令生成。然后所有的.obj
文件再分别依赖于对应的 .cpp
文件,通过 -c
命令生成。
- 我们删除之前生成的可执行文件(本步骤上面有介绍,这里省略),重新编译:
PS E:\C++\CUDA\Makefile> make
g++ -c main.cpp
g++ -c factorial.cpp
g++ -c printhello.cpp
g++ -o hello main.o factorial.o printhello.o
通过命令执行顺序我们可以看到,当执行到第一个生成目标文件命令时缺少了 $(OBJ)
所代表的 main.o factorial.o printhello.o
三个中间文件,于是先执行后面的三条命令,等后面命令执行完并生成 .o
文件后回到第一条命令,所有文件都具备了,再执行第一条命令。
- 查看编译后的文件夹,可以看到已经生成了三个
.o
文件和hello.exe
可执行文件。
PS E:\C++\CUDA\Makefile> ls
目录: E:\C++\CUDA\Makefile
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2024/1/29 16:53 117 factorial.cpp
-a---- 2024/1/30 18:29 754 factorial.o
-a---- 2024/1/29 16:53 101 functions.h
-a---- 2024/1/30 18:29 58857 hello.exe
-a---- 2024/1/30 16:53 263 main.cpp
-a---- 2024/1/30 18:29 2753 main.o
-a---- 2024/1/30 18:29 405 Makefile
-a---- 2024/1/30 16:53 164 printhello.cpp
-a---- 2024/1/30 18:29 2414 printhello.o
- 文件编译成功(执行步骤上面有介绍,这里省略)。
通过版本二,我们可以看到,Makefile文件似乎写的更复杂了,每个 .cpp
都要写一条编译命令。
但是实际上,如果我们修改了其中的某一个源文件,只需要重新编译一下他所对应的 .o
文件即可,不用所有的源文件全部都编译。下面举个例子给大家看一下。
比如我在 printhello.cpp
最后加一个空格(不影响程序,但是文件修改了),然后查看一下文件夹:
可以看到我的 printhello.cpp
文件生成时间晚于hello.exe
可执行文件,也就意味着printhello.cpp
文件是新的,那就需要重新编译整个工程文件。
重新编译之后发现,它仅对 printhello.cpp
和最终的 hello.exe
可执行文件进行编译(生成可执行文件的过程应该称为链接),并不涉及其他源文件,所以这种写法看似复杂,实则“一劳永逸”,当工程庞大时能大大提高运行效率。
八、Makefile Version 3
由版本一到版本二,在文件执行效率上会有很大的提升,但是编写Makefile文件的效率缺大大降低了,虽说是“一劳永逸”,但是能不能让这“一劳”更简单点呢?
这里我们再引入一个新的规则:
- 自动变量
Makefile 提供了一些特殊的自动变量,它们在规则的命令中有特殊的含义。例如,$@
表示目标,$<
表示第一个依赖项,$^
表示所有依赖项。
$(TARGET): $(OBJ)
$(CXX) -o $@ $^
如上:$@
代表 $(TARGET)
,$^
代表所有的 $(OBJ)
。
- 伪目标
有时候,需要定义一些不对应实际文件的伪目标,用于执行一些操作,如清理、测试等。这些目标通常被声明为.PHONY
。有了伪目标之后,如果同文件夹内有同名的文件将不会产生冲突。
.PHONY: clean
clean:
rm -f *.o $(TARGET)
这个伪目标可以代替执行一些命令,如同上面的示例里,输入 make clean
的作用就是强制删除所有的 .o
文件和生成的目标文件。
于是,我们就可以写出 Makefile Version 3了:
## VERSION 3
CXX = g++
TARGET = hello
OBJ = main.o factorial.o printhello.o
CFLAGS = -c -Wall
$(TARGET): $(OBJ)
$(CXX) -o $@ $^
%.o: %.cpp
$(CXX) $(CFLAGS) $< -o $@
# 在Windows VSCode中make.exe使用的是cmd的命令,删除用del,在Linux环境下删除则用rm
.PHONY: clean
clean:
rm -f *.o $(TARGET)
# del -f *.o $(TARGET).exe
在Makefile里 %
通常用作通配符,表示匹配一组模式规则,如上%.o
即代表所有的 .o
文件,这里使用的 $<
则代表第一个依赖项,即触发构建的文件名,也就是源文件的文件名。
- 删除之前生成的可执行文件(本步骤上面有介绍,这里省略),重新编译结果如下图所示:
可以看到,和版本二的编译结果对比,我们发现这里多出了一个警告,警告的内容是在printhello.cpp
里的 void printhello()
函数里有一个变量 i
并未使用(第四章:代码示例里有提到)。可是在前几次的编译里,包括手动编译中都没有这个警告。
原因在于这里我们引入了一个新的变量 CFLAGS = -c -Wall
,这里有一个新的命令 -Wall
,第一个 W
是大写,代表的是warning all,显示所有警告。其余部分的执行命令与版本二中的基本相同,建议执行编译时加上 -Wall
命令。
此时查看文件夹:
PS E:\C++\CUDA\Makefile> ls
目录: E:\C++\CUDA\Makefile
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2024/1/29 16:53 117 factorial.cpp
-a---- 2024/1/30 19:16 754 factorial.o
-a---- 2024/1/29 16:53 101 functions.h
-a---- 2024/1/30 19:16 58857 hello.exe
-a---- 2024/1/30 16:53 263 main.cpp
-a---- 2024/1/30 19:16 2753 main.o
-a---- 2024/1/30 19:16 661 Makefile
-a---- 2024/1/30 18:44 166 printhello.cpp
-a---- 2024/1/30 19:16 2414 printhello.o
可以看到已经生成了三个 .o
文件和hello.exe
可执行文件。
- 我们调用clean(我这里是在Windows VSCode环境下):
PS E:\C++\CUDA\Makefile> make clean
del -f *.o hello.exe
- 查看执行完删除命令后的文件夹
PS E:\C++\CUDA\Makefile> ls
目录: E:\C++\CUDA\Makefile
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2024/1/29 16:53 117 factorial.cpp
-a---- 2024/1/29 16:53 101 functions.h
-a---- 2024/1/30 16:53 263 main.cpp
-a---- 2024/1/30 19:57 666 Makefile
-a---- 2024/1/30 18:44 166 printhello.cpp
可以看到所有的 .o
文件和最终的目标文件已删除。
九、Makefile Version 4
第四个版本和第三版差别不大,只是对最上面的进行了一个更简单的优化,这里直接贴文件内容:
## VERSION 4
CXX = g++
TARGET = hello
SRC = $(wildcard *.cpp)
OBJ = $(patsubst %.cpp, %.o, $(SRC))
CFLAGS = -c -Wall
$(TARGET): $(OBJ)
$(CXX) -o $@ $^
%.o: %.cpp
$(CXX) $(CFLAGS) $< -o $@
# 在Windows VS Code中make.exe使用的是cmd的命令,删除用del,在Linux环境下删除则用rm
.PHONY: clean
clean:
# rm -f *.o $(TARGET)
del -f *.o $(TARGET).exe
可以看到第四版里采用了下面两句Makefile:
- SRC = $(wildcard .cpp)
这行代码使用了 wildcard
函数,它是 Makefile 中的一个函数,用于展开通配符,并返回匹配的文件列表。在这里,*.cpp
是一个通配符,表示所有以 .cpp
结尾的文件。也就是说,$(wildcard *.cpp)
将返回当前目录下所有以 .cpp
结尾的文件列表,并将这个列表赋值给变量 SRC
。
- OBJ = $(patsubst %.cpp, %.o, $(SRC))
这行代码使用了 patsubst
函数,它用于替换模式字符串。在这里,它的作用是将 $(SRC)
中的所有 .cpp
文件替换为相应的 .o
文件。也就是说,$(patsubst %.cpp, %.o, $(SRC))
将把 $(SRC)
中的每个 .cpp
文件替换为对应的 .o
文件,最终得到一个以 .o
结尾的文件列表,并将其赋值给变量 OBJ
。
通过以上两个语句,能进一步简化代码,不用把所有文件都列出来了。
运行效果和版本三相同,这里就不再演示了。
本篇的代码案例来自B站于仕琪教授的:
Makefile 20分钟入门,简简单单,展示如何使用Makefile管理和编译C++
于教授讲的非常好,还有不理解的同学建议去听一听,视频只有20分钟,很棒!