【cpp查漏补缺】2-Makefile入门
前言
上一节 中我们简单介绍了cpp是如何一步步构建出可执行文件的。但在大型工程中,我们常用Make来构建项目。本文简单介绍Makefile的编写规则。
入门级Makefile介绍
宝宝级的项目结构
项目结构如下:
1 | spirittrance@hexadecimal:~/Desktop/learnMakefile$ tree |
其中main.cpp
内容如下
1 |
|
calc.h
内容如下:
1 |
|
除此之外,如果使用的是Vscode,请记得使用相关扩展,进行相应配置。如C++ Extension,以便于进行快速查找。
最简单的Makefile
在本节开始,我首先给出最简单的示例:
1 | # Easy Makefile |
注意cpp文件需要使用g++,如果是gcc的话会报错(因为使用了iostream,gcc貌似找不到)
关于Makefile的编写规则,有三个要素:目标,依赖,执行语句(动作)。
如上例,main
即为目标,后面紧跟的一长串为依赖,下面那个是执行语句。需要在这里强调的是,执行语句前面的符号是制表符\t
而不是四个空格,这一点务必注意,否则你会看到missing separator
的错误。
在终端输入make main
后,可以发现bin
下面多了可执行文件。另外如果只使用make
,那么默认执行第一个语句。如果输入make clean
,那么会执行clean后的语句rm -rf bin/*
,会清除bin
目录下的二进制文件。
话说回来,所谓的依赖有什么用呢?你可以尝试把add.cpp
重命名为add_.cpp
,然后在构建语句中修改add.cpp
为add_.cpp
,但不要修改依赖。之后执行make
,你会发现有如下报错:
1 | make: *** 没有规则可制作目标“src/add.cpp”,由“main” 需求。 停止。 |
然后将依赖删除,再次执行
make
,你会发现能够顺利编译出可执行文件。因此,依赖的作用是检查源文件是否存在。如果不存在,则会阻止执行相应语句。
wildcard函数
在上一小节中,我们用"紧跟的一长串"来描述那后面的一大堆源文件,这确实很长。如果项目再大一点,我们也不可能把所有文件一一罗列出来。那么,我们是否有更好的编写方法呢?答案是有,那就是使用wildcard函数。下面给出修改后的示例:
1 | SOURCE=$(wildcard src/*.cpp) |
其中,我们用SOURCE变量来标识源文件,之后按照$(SOURCE)
的方式来引用变量,之后是wildcard函数的用法:
1 | $(wildcard pattern) |
wildcard函数的作用在于匹配所有模式,如*.cpp
会匹配所有以.cpp
结尾的文件。
最后,我们介绍一下其他的赋值方法:
1 | a=sub |
进阶Makefile
更复杂的项目结构
实际项目的项目结构只会更复杂,比如,源文件下还有多层文件夹,每个文件夹下面又有很多文件夹和源文件,这种情况,应该怎么办呢?
这里我们修改一下项目结构:
1 | . |
没错,我们把add.cpp和sub.cpp移动到了
src/calc/add_sub
下面,而mul.cpp
和div.cpp
移动到了src/calc/mul_div
下面。
foreach函数
现在应该怎么做呢?我们可以选用foreach函数。现在仍然放出修改后的Makefile.
1 | SOURCE_DIR=src src/calc/add_sub src/calc/mul_div # 定义了包含源代码的所有文件夹 |
这里我们先介绍一下foreach函数的用法:
foreach(var,list,text)
该函数的用法是:从list逐个取出值赋值给var,之后使用text进行相应的展开。注意text可以引用变量var。如果你想知道上例的结果,可以使用make test
查看结果。
注意到Makefile多了一个OPTFLAGS的变量,但该变量未定义,该怎么用呢?你可以在命令行输入make OPTFLAGS=-Wall
(用途是打开警告信息),然后观察编译结果有何不同。
如果你不加以定义,即直接输入make
,那么默认是空,没有影响。
如果对源代码的结构不熟悉,但是希望把所有的cpp文件包含进来(也就是不知道层级结构是啥情况),又应该怎么办呢?待补充。(也许会用到函数)
patsubst函数
在1-cpp是如何跑起来的 中,我们介绍了cpp的构建过程,一般来说,我们希望拿到目标文件并加以保存,减少编译时间。现在,我们希望能够生成中间代码并进行编译,应该如何做呢?你会想到g++
的-c
选项,但如果是Makefile,如何进行相应的编写呢?这就需要patsubst函数了。下面给出修改后的Makefile:
1 | # Advanced Makefile |
上面所给出的示例有相当大的变化,我们首先解释patsubst函数。
1 | patsubst %.cpp,str%.o,$(var) |
这一函数接受一个var,其中var应当是一个列表(linux的字符串列表),在列表中寻找后缀为.cpp
的元素,并保留.cpp
前的字符串,并在前面拼接上str
的字符串,将后缀改为.o
,然后输出一系列列表。本例中,你可以尝试make testPatsubst
观察输出。
结果为
./output/src/calc/add_sub/add.o ./output/src/calc/add_sub/sub.o ./output/src/calc/mul_div/div.o ./output/src/calc/mul_div/mul.o
依赖问题
在开始后面几节之前,我先介绍一下这个Makefile做了什么事:首先,将add.cpp,sub.cpp,mul.cpp
和div.cpp
分别生成相应的目标文件,之后将四个目标文件进行归档,生成静态库libcalc.a
,最后编译main.cpp
并与该静态库链接,生成可执行文件main
。可以看到,所有行前后都有依赖关系:$(TARGET)
依赖$(LIB_CALC)
,$(LIB_CALC)
依赖$(OBJECT_CALC)
,而$(OBJECT_CALC)
依赖$(SOURCE_CALC)
(在Makefile中用./$(OBJECT_DIR)/%.o: %.cpp
表示,这一行语句的含义将马上介绍),构成了级联的依赖关系。
在开始下一节内容之前,我希望你能在编译完整的基础上,依次做如下事情:
1.删除lib/libcalc.a
,但保留output/*
,之后make
2.不删除lib/libcalc.a
,但删除output/*
,之后make
3.删除lib/libcalc.a
,同时删除output
下的一个或多个文件,之后make
4.删除lib/*
和output/*
,之后make
5.修改任意源文件,然后make
6.修改头文件,然后make
做完这三件事后,你会对cpp的编译过程有更深的理解,以及能够体会到为什么Makefile要维护所谓的依赖关系。比较特殊的是第6条,这一条我们在【自动生成依赖】一节讨论。
模式匹配与变量
在上一节中,我们注意到如下语句:
1 | ./$(OBJECT_DIR)/%.o: %.cpp # OBJECT_DIR=output |
首先介绍一下,目标和依赖的
%
是什么意思。回忆patsubst
函数,不难猜出这一句话的含义是:
如果要生成形如
./output/src/str.o
的目标文件,需要依赖形如./src/str.cpp
的源文件,其中str是可以任意更换的,因此用%表示。这一种形式实际上是在做模式匹配。
之后我们把注意力留在$@
和$<
这两个变量上面。根据第二条执行语句,不难猜出,$@
代指目标,$<
代指依赖。准确来说,是第一个依赖。
最后,我们把注意力留在第一条执行语句。mkdir
是一条linux的指令,前面加上@
,意思是不要打印执行的语句。而dir
函数和linux的dir函数含义不同。在Makefile里面,指的是返回相应文件所在的目录。如$@
为./output/src/str.o
的情况下,dir $@
返回./output/src
。
到这里,这一部分的关键之处便解释完毕。最后需要指出的是,使用./$(OBJECT_DIR)/%.o
而不是%.o
的原因在于,希望不要将生成文件和源文件混合存放。因此,在本小节的项目结构里面,你可以看见output
下面有src
目录。
伪目标
在最后,我们调用clean
的时候,我们通常都能成功执行。但如果该目标缺少依赖,并且目录下同样有一个名为clean
的文件,进行make clean
的时候,你会得到:
make: “clean”已是最新。
这个时候,你需要在前面加上:
1 | .PHONY:clean |
表明clean并不是一个真正的目标,只是一个伪目标。之后每次make clean
都一定能成功。
自动生成依赖
在【依赖】这一小节,我们提过修改文件头,观察编译情况。答案是不会更新。如果我们希望修改文件头会引发重新编译,应当如何设计呢?可以做如下修改:
1 | $(TARGET): $(SOURCE_MAIN) $(LIB_CALC) $(INCS)/calc.h |
也就是将头文件直接加入依赖。问题是,大型项目里面维护这样的依赖是很痛苦的事情。这里有一个更好的解决方案:gcc提供了几个选项,可以生成*.d
文件,记录依赖关系:
-MMD
:生成的文件记录的依赖只包括用户文件。(注意包含头文件)-MD
:生成的文件记录的依赖包括用户文件和系统文件-MP
:基于-MMD
或-MD
之外的选项,为每个依赖添加一个没有依赖的伪目标。可以避免删除头文件时,Makefile因找不到目标来更新依赖报错。
示例如下:
1 | > g++ -MMD -I ./include -L ./lib/ ./src/main.cpp -lcalc -o main |
因此,我们可以继续对之前的Makefile做如下修改:
1 | ... |
include用于将指定文件的内容插入到当前文本中。include 前加了-符号,其作用是指示make 在 include 操作出错时忽略这个错误,并继续执行接下来的操作。出错的原因是
*.d
尚未产生的时候,这通常在初次编译和clean后编译产生。
链接先后问题的补充
1 | g++ -I ./include -L ./lib/ ./src/main.cpp -lcalc -o main # 编译通过 |
需要注意的是,源文件需要放在库文件之前。这一细节在《深入理解计算机系统》7.6.3 链接器如何使用静态库来解析引用 一节中有所解释,其给出的准则如下:
关于库的一般准则是它们放在命令行的结尾。库之间如果是:
- 相互独立的:库可以以任何顺序放在命令行中
- 不是相互独立的:具体规则是,前面库中产生的引用需要在后面的库中能找到相应定义
如果需要满足依赖需求,可以在命令行上重复库。如libx.a和liby.a构成循环依赖,可以使用gcc foo.c libx.a liby.a libx.a
,但更好的方式是将两者合并成一个单独的存档文件。
关于a.o->liba.a->libb.a->liba.a->a.o的依赖,解决方式是gcc -o main a.o liba.a libb.a liba.a
而没有a.o
,`需要思考一下为什么
总结
参考资料
- Title: 【cpp查漏补缺】2-Makefile入门
- Author: spiritTrance
- Created at: 2023-10-01 15:33:07
- Updated at: 2024-01-06 20:07:27
- Link: https://spirittrance.github.io/2023/10/01/cpp_2_Makefile用法/
- License: This work is licensed under CC BY-NC-SA 4.0.