【cpp查漏补缺】2-Makefile入门

【cpp查漏补缺】2-Makefile入门

spiritTrance

前言

上一节 中我们简单介绍了cpp是如何一步步构建出可执行文件的。但在大型工程中,我们常用Make来构建项目。本文简单介绍Makefile的编写规则。

入门级Makefile介绍

宝宝级的项目结构

项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
spirittrance@hexadecimal:~/Desktop/learnMakefile$ tree
.
├── bin
├── build
├── include
│ └── calc.h
├── lib
├── Makefile
└── src
├── add.cpp
├── div.cpp
├── main.cpp
├── mul.cpp
└── sub.cpp

其中main.cpp内容如下

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include "calc.h"
using namespace std;
int main(){
cout << "Add: " << myadd(9, 3) << endl;
cout << "Sub: " << mysub(9, 3) << endl;
cout << "Mul: " << mymul(9, 3) << endl;
cout << "Div: " << mydiv(9, 3) << endl;
return 0;
}

calc.h内容如下:

1
2
3
4
5
6
7
#ifndef __CALC_H__
#define __CALC_H__
int myadd(int a, int b);
int mysub(int a, int b);
int mymul(int a, int b);
int mydiv(int a, int b);
#endif

除此之外,如果使用的是Vscode,请记得使用相关扩展,进行相应配置。如C++ Extension,以便于进行快速查找。

最简单的Makefile

在本节开始,我首先给出最简单的示例:

1
2
3
4
5
6
# Easy Makefile
main: src/main.cpp src/add.cpp src/sub.cpp src/mul.cpp src/div.cpp
g++ -I ./include src/adda.cpp src/sub.cpp src/mul.cpp src/div.cpp src/main.cpp -o bin/main

clean:
rm -rf bin/*

注意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.cppadd_.cpp,但不要修改依赖。之后执行make,你会发现有如下报错:

1
make: *** 没有规则可制作目标“src/add.cpp”,由“main” 需求。 停止。

然后将依赖删除,再次执行make,你会发现能够顺利编译出可执行文件。因此,依赖的作用是检查源文件是否存在。如果不存在,则会阻止执行相应语句。

wildcard函数

在上一小节中,我们用"紧跟的一长串"来描述那后面的一大堆源文件,这确实很长。如果项目再大一点,我们也不可能把所有文件一一罗列出来。那么,我们是否有更好的编写方法呢?答案是有,那就是使用wildcard函数。下面给出修改后的示例:

1
2
3
4
5
6
SOURCE=$(wildcard src/*.cpp)
main: $(SOURCE)
g++ -I ./include $(SOURCE) -o bin/main

clean:
rm -rf bin/*

其中,我们用SOURCE变量来标识源文件,之后按照$(SOURCE)的方式来引用变量,之后是wildcard函数的用法:

1
$(wildcard pattern)

wildcard函数的作用在于匹配所有模式,如*.cpp会匹配所有以.cpp结尾的文件。

最后,我们介绍一下其他的赋值方法:

1
2
3
4
5
6
7
a=sub
a+=set # 增添赋值,a的结果为subset
echo $(a) # subset
b?=superset
a?=superset # 条件赋值,如果该变量未被赋值,那么使用后面给定的值
echo $(a) # subset
echo $(b) # superset

进阶Makefile

更复杂的项目结构

实际项目的项目结构只会更复杂,比如,源文件下还有多层文件夹,每个文件夹下面又有很多文件夹和源文件,这种情况,应该怎么办呢?
这里我们修改一下项目结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
.
├── bin # 存放二进制可执行文件
│ └── main
├── include # 存放头文件
│ └── calc.h
├── lib # 存放相应的库
│ └── libcalc.a
├── Makefile # Makefile
├── output # 中间代码文件
│ └── src
│ └── calc
│ ├── add_sub
│ │ ├── add.o
│ │ └── sub.o
│ └── mul_div
│ ├── div.o
│ └── mul.o
└── src # 源文件
├── calc
│ ├── add_sub
│ │ ├── add.cpp
│ │ └── sub.cpp
│ └── mul_div
│ ├── div.cpp
│ └── mul.cpp
└── main.cpp

没错,我们把add.cpp和sub.cpp移动到了src/calc/add_sub下面,而mul.cppdiv.cpp移动到了src/calc/mul_div下面。

foreach函数

现在应该怎么做呢?我们可以选用foreach函数。现在仍然放出修改后的Makefile.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SOURCE_DIR=src src/calc/add_sub src/calc/mul_div    # 定义了包含源代码的所有文件夹
SOURCE=$(foreach dir,$(SOURCE_DIR),$(wildcard $(dir)/*.cpp)) # foreach函数的应用
INCLUDE=./include # include的文件夹
TARGET_DIR=bin # 目标文件所在文件夹
TARGET=main # 目标文件名
FLAGS=-Og -I $(INCLUDE) # 所有标记
FLAGS+=$(OPTFLAGS) # 可扩展性要求

$(TARGET): $(SOURCE) # 这里进行了相应的修改,请注意
g++ $(FLAGS) $(SOURCE) -o $(TARGET_DIR)/$(TARGET)

clean:
rm -rf bin/*

test:
echo $(SOURCE)

这里我们先介绍一下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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Advanced Makefile
# - source variables
SOURCE_DIR=src # 源代码目录
SOURCE_MAIN=$(SOURCE_DIR)/main.cpp # 源代码main函数路径
SOURCE_CALC=$(foreach source,$(wildcard src/calc/*), $(wildcard $(source)/*.cpp)) # 源代码计算函数相关路径
# - obj file variables
OBJECT_DIR=output # 目标代码文件目录
OBJECT_CALC=$(patsubst %.cpp,./$(OBJECT_DIR)/%.o,$(SOURCE_CALC)) # 与计算函数相关的目标代码文件路径
# - library file variables
LIB_NAME=calc # 库名称
LIB_DIR=lib # 库所在的目录
LIB_CALC=$(LIB_DIR)/lib$(LIB_NAME).a # 库所在的路径
# - target binary file variables
TARGET=bin/main # 可执行文件的路径
# - include variables
INCS=./include # 包含文件所在目录

# generate target binary executable file
$(TARGET): $(SOURCE_MAIN) $(LIB_CALC)
g++ -I $(INCS) -L ./lib $(SOURCE_MAIN) -l$(LIB_NAME) -o $(TARGET)

# generate static library
$(LIB_CALC): $(OBJECT_CALC)
ar -rcs $(LIB_CALC) $(OBJECT_CALC)

# generate object file
./$(OBJECT_DIR)/%.o: %.cpp
@mkdir -p $(dir $@)
g++ -c $< -o $@

# clean
clean:
rm -rf bin/*

testPatsubst:
@echo $(OBJECT_CALC)

上面所给出的示例有相当大的变化,我们首先解释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.cppdiv.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
2
3
./$(OBJECT_DIR)/%.o: %.cpp  # OBJECT_DIR=output
@mkdir -p $(dir $@)
g++ -c $< -o $@

首先介绍一下,目标和依赖的%是什么意思。回忆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
2
$(TARGET): $(SOURCE_MAIN) $(LIB_CALC) $(INCS)/calc.h
g++ -I $(INCS) -L ./lib $(SOURCE_MAIN) -l$(LIB_NAME) -o $(TARGET)

也就是将头文件直接加入依赖。问题是,大型项目里面维护这样的依赖是很痛苦的事情。这里有一个更好的解决方案:gcc提供了几个选项,可以生成*.d文件,记录依赖关系:

  • -MMD:生成的文件记录的依赖只包括用户文件。(注意包含头文件)
  • -MD:生成的文件记录的依赖包括用户文件和系统文件
  • -MP:基于-MMD-MD之外的选项,为每个依赖添加一个没有依赖的伪目标。可以避免删除头文件时,Makefile因找不到目标来更新依赖报错。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
> g++ -MMD -I ./include -L ./lib/ ./src/main.cpp  -lcalc -o main
> cat main.d
main: src/main.cpp include/calc.h
> g++ -MD -I ./include -L ./lib/ ./src/main.cpp -lcalc -o main
> cat main.d
main: src/main.cpp /usr/include/stdc-predef.h \
/usr/include/c++/11/iostream \
/usr/include/x86_64-linux-gnu/c++/11/bits/c++config.h \
... # 100 more lines was omitted
/usr/include/c++/11/bits/basic_ios.tcc \
/usr/include/c++/11/bits/ostream.tcc /usr/include/c++/11/istream \
/usr/include/c++/11/bits/istream.tcc include/calc.h
> g++ -MMD -MP -I ./include -L ./lib/ ./src/main.cpp -lcalc -o main
> cat main.d
main: src/main.cpp include/calc.h
include/calc.h:

因此,我们可以继续对之前的Makefile做如下修改:

1
2
3
4
5
6
7
8
9
10
...
# - dependency file
DEPS=$(patsubst ./$(OBJECT_DIR)/%.o,./$(OBJECT_DIR)/%.d,$(OBJECT_CALC))

...
./$(OBJECT_DIR)/%.o: %.cpp
@mkdir -p $(dir $@)
g++ -MMD -MP -c $< -o $@

-include: $(DEPS)

include用于将指定文件的内容插入到当前文本中。include 前加了-符号,其作用是指示make 在 include 操作出错时忽略这个错误,并继续执行接下来的操作。出错的原因是*.d尚未产生的时候,这通常在初次编译和clean后编译产生。

链接先后问题的补充

1
2
g++ -I ./include -L ./lib/ ./src/main.cpp -lcalc -o main    # 编译通过
g++ -I ./include -L ./lib/ -lcalc ./src/main.cpp -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.
推荐阅读
【cpp查漏补缺】3-CMake入门 【cpp查漏补缺】3-CMake入门 【cpp查漏补缺】1-cpp是如何跑起来的? 【cpp查漏补缺】1-cpp是如何跑起来的? 【cpp查漏补缺】4-gdb的使用 【cpp查漏补缺】4-gdb的使用
 Comments