写一个通用的Makefile文件

make是一种自动构建目标文件的工具,最早应用于 C 语言的编译过程,现在也用于 node.js 等工程中。其语法独特而复杂,上手有一定的难度。这篇文章中我会以一个 C++ 工程为例,展示如何编写一个通用的 Makefile 文件。

Makefile 的基本语法是

1
2
TARGETS: DEPENDENCIES
OPERATIONS

每个 Makefile 文件都要指定一个终极目标。make工具会查看这个终极目标的依赖关系,将它分解成多个子目标,然后再自底向上地执行子目标的操作,在完成子目标的基础上实现终极目标。

当程序足够简单的时候,我们的 Makefile 可能只有一个目标。现在我们来设想一个比较复杂的情况。有这样一个 C++ 的工程:

1
2
3
4
5
6
7
8
demo
|- include
|- demo.hpp
|- src
|- demo.cpp
|- main.cpp
|- test
|- test.cpp

源文件、头文件和测试文件分别放置在三个文件夹中,如何顺利地编译这个工程呢?

如果你已经熟悉了 Makefile 的编写,你应该看得懂下面的操作:

1
2
3
4
5
6
7
8
9
10
11
12
.SUFFIXES:
.PHONY: all clean

all: src/main.o src/demo.o test/test.o
g++ -Wall -g -Iinclude -lm $^ -o demo.exe

src/main.o src/demo.o test/test.o: %.o: %.cpp
g++ -Wall -g -Iinclude $< -c -o $@

clean:
-@rm -f demo.exe
-@rm -f src/*.o test/*.o

这个工程的终极目标all依赖于三个目标文件。而每个文件夹下的目标文件分别由一条静态模式指令生成。在这个例子中,静态模式%.o匹配目标中的所有*.o文件,并设定其依赖文件为对应的%.cpp。对所有匹配成功的组合,将.cpp的源文件(用$<表示)编译成.o的目标文件(用$@表示),这样就实现了目标的编译。如果想要删除编译产生的文件,只需要调用伪目标clean即可。

不过上面的 Makefile 显然还不够完美,有两个地方值得改进。其一是封装编译的参数,当编译的参数需要修正时,我们只用修改一处,而不必逐行修改。其二是自动获取目标文件名,即使工程中有上百个源文件,Makefile 依旧会简洁明了,而不是充斥着各种文件的名称。

实现第一点并不困难,使用make的宏扩展功能即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.SUFFIXES:
.PHONY: all clean

CXX := g++
CXXFLAGS := -Wall -g
INCLUDES := -Iinclude
LIBS := -lm
TARGET := demo.exe
OBJS := src/main.o src/demo.o test/test.o

all: $(TARGET)

$(TARGET): $(OBJS)
$(CXX) $(CXXFLAGS) $(INCLUDES) $(LIBS) $^ -o $@

$(OBJS): %.o: %.cpp
$(CXX) $(CXXFLAGS) $(INCLUDES) $< -c -o $@

clean:
-@rm -f $(TARGET)
-@rm -f $(OBJS)

虽然 Makefile 变长了,但它的语义却更加清晰。如果我们要添加新的目标文件,只需要修改变量$(OBJS)的值即可。

不过这还是不够完美。有没有一种办法,可以不用输入目标文件的名字,只要是文件夹下符合要求的文件(例如所有的.cpp文件),统统拿来编译呢?这也不困难,只要运用通配符和有关的字符串函数就行了:

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
.SUFFIXES:
.PHONY: all clean

CXX := g++
CXXFLAGS := -Wall -g
INCLUDES := -Iinclude
LIBS := -lm
TARGET := demo.exe

SRCDIR := src
TESTDIR := test
SRCOBJS := $(patsubst %.cpp, %.o, $(wildcard $(SRCDIR)/*.cpp))
TESTOBJS := $(patsubst %.cpp, %.o, $(wildcard $(TESTDIR)/*.cpp))
OBJS := $(SRCOBJS) $(TESTOBJS)

all: $(TARGET)

$(TARGET): $(OBJS)
$(CXX) $(CXXFLAGS) $(INCLUDES) $(LIBS) $^ -o $@

$(OBJS): %.o: %.cpp
$(CXX) $(CXXFLAGS) $(INCLUDES) $< -c -o $@

clean:
-@rm -f $(TARGET)
-@rm -f $(OBJS)

这个 Makefile 比上一个更长了。不过我们已经看不到文件名了。就像你想到的那样,我们将目标文件的文件名存到了两个变量中

1
2
$(SRCOBJS) == "src/demo.o src/main.o"
$(TESTOBJS) == "test/test.o"

这是通过make的两个内置函数wildcardpatsubst实现的。wildcard返回所有符合给定模式的匹配。在上面的例子中,我们要匹配所有处于$(SRCDIR)$(TESTDIR)目录下的.cpp文件,并将其路径作为变量传入另一个函数patsubst,它会将每个路径中的.cpp替换成.o,最后存入我们指定的变量中。

有了如此逆天的功能,妈妈再也不用担心我们会写出又长又臭的 Makefile 了……