make 介绍

make(GNU make)是一个项目构建工具,用于方便地编译、链接多个源代码文件,自动决定哪些源文件需要重新编译,从而高效地构建自己地项目,普遍用于处理 c/c++项目,但也可以用于其他语言。

make 的通常用法是:

  • 在项目目录下把编译链接的命令,写入 Makefile(指定文件名的一个文本文件,类似于 shell 脚本)
  • 在项目命令下,执行make命令,会自动读取当前目录下的 Makefile 并解析执行
  • make命令可以后接参数:
    • make,相对于执行make all,通常代表从头编译整个项目
    • make install,通常代表把当前项目安装到系统中,还可以附加一些参数指定安装位置等,例如prefix=/usr/local
    • make clean,通常代表清理项目中的杂项文件,不包括源文件
    • 注意这些只是约定俗成的作法,具体的命令行为其实完全取决于 Makefile 中写入的内容

这里的 make 是 GNU make,但是其实在各种 IDE 中,也都集成了一个类似的项目构建工具,例如 VS 的 nmake,它们发挥着类似的作用。

为什么用 make

对于简单的 c/c++文件,我们可以敲几行gcc ...命令完成编译,但是对于复杂一点的文件,比如需要拆分成几个部分分别编译,最后链接,比如需要使用很多的库,需要指定头文件,需要一些编译选项等,会导致我们每次修改之后,都需要把 n 行的编译命令重新敲一遍,非常的繁琐低效。

一个很直接的想法是把重复使用的编译命令写入 shell 脚本,每次执行一遍 shell 脚本即可,为什么还要专门的 make 和 Makefile?make 解析 Makefile 之后,确实还是要交给 shell 去执行命令,但是相比于直接使用 shell 脚本,使用 Makefile 还有以下几点优势:

  • 结构化脚本:Makefile 把很多行命令分成了若干个 target,target 之间存在依赖关系,而 shell 脚本只能简单地从上到下顺序执行
  • 选择性更新:对于每一个 target,它都可以理解为输入文件,执行命令和输出文件三要素组成,如果在本次对源文件的修改中,当前 target 所依赖的输入源文件并没有被改动,那么 make 就会直接跳过这个 target,不做无意义的重复编译,节约时间
  • 定制的语法:由于 Makefile 不依赖于具体的 shell,因此可以使用自己独有的一套通配符,更适合编译的命令语法,这使得 Makefile 的书写更加简洁(但也更加晦涩难读),最终会通过字符串替换的形式解析成更具体的编译命令,传递给 shell 执行,需要注意的是,每行命令都在一个单独的 shell 中执行,也就是不同的进程,这些 Shell 之间没有继承关系

使用 make 编译一个项目工程(由若干源文件,头文件和库组成),我们希望采用如下的"偷懒"策略,来节约每次改动后的重新编译时间:

  • 如果这个项目没有编译过,那么我们所有的源文件都要编译并被链接
  • 如果这个工程的某几个源文件被修改,那么我们只编译被修改的源文件,并链接目标程序
  • 如果这个工程的几个头文件被改变了,那么我们需要编译引用了这几个头文件的源文件,并链接目标程序。

在正确编写 Makefile 文件的前提下,这三个优化目标是很容易达到的:Makefile 的语法并不是苛刻的,只是建议我们以一种合理的方式去写 Makefile,因为 make 会按照一种固定的逻辑去组织编译顺序,跳过一些无意义的重复编译环节。我们需要理解并利用 make 的执行逻辑,就可以达到这三个优化目标。

make 的每次执行,都会首先解析当前目录下的 Makefile 文件,然后才能明白需要执行哪些命令,当然我们也可以指定任何一个名称的文件被 make 所使用,例如

1
2
3
4
make # 使用Makefile文件

make -f rules.txt # 使用rules.txt文件
make --file=rules.txt # 同上

Makefile 语法

准备语法

  • 注释:井号#表示单行注释
  • 回显:默认情况下,make 会把执行的命令(包括命令部分的注释)依次具体地打印出来,可以在某一行命令开头加上@关闭回显,make 就会静默执行它。通常会对注释和echo语句关闭命令的回显,不然看起来很多余,而对其他的命令保留回显,这会让我们明白 make 到底干了些什么
  • 通配符: Makefile 支持和 Bash 一致的通配符规则,例如*.o代表所有的.o结尾的文件
  • 忽略错误: make 会收集每一条命令的执行结果,来判断是否遇到错误,是否继续执行下一题命令,对于有的命令我们希望忽略它的错误,例如删除了不存在的文件,可以在命令开头使用-,代表忽略这条命令的错误,例如-rm *.o
1
2
3
4
5
# 没有忽略的命令错误,可能会中断整个编译过程
make: *** [source] Error 1

# 被忽略的命令错误,只会输出如下信息
make: [source] Error 1 (ignored)

编译目标

一个 Makefile 主要由若干个 target 顺序排列组成,每个 target 的语法结构如下

1
2
<target> : <prerequisites>
[tab] <commands>

包括以下内容:

  • 编译目标(target):既是这个子结构的标签,通常也是这一环节得到的目标文件
    • target 既可以是一个文件名,也可以是多个文件名,中间用空格分隔
    • 可以有多个子结构使用同一个 target,相当于把这 target 拆分成多个部分写在 Makefile 中。解析时会把这些部分合在一起进行处理,而不视作名称冲突
  • 前置依赖(prerequisites):就是执行这个编译目标之前所需要的前置依赖文件,中间用空格分隔
  • 命令(commands):就是具体要执行的编译命令

一个最简单的 target 如下,target 为 a.out,目标文件为 a.out,prerequisites 为 hello.c,commands 只有一条gcc编译命令。

1
2
a.out : hello.c
gcc hello.c -o a.out

注:

  • 在本文中,尽量使用 target,prerequisites,commands 这些名称来强调在这个语法结构中的角色,避免歧义
  • 在本文中,目标文件的含义为一个 target 结构中经过编译命令,最终产生的文件,例如.o 文件,可执行文件等,而非通常语境下的.o 文件
  • 特别注意,命令前面必须是 tab,而非连续的空格,考虑到 tab 在一些编辑器中可能会被自动替换为连续空格,可以使用下列设置把命令的识别前缀改成>(写在 Makefile 中,最好是开头)
1
.RECIPEPREFIX = >
  • Makefile 中的每一行命令都被送出一个单独的 shell 进程中执行,因此不能相互传递 shell 变量等,避免的方法是使用分号分隔符;在一行中写入多条命令,如果写在一起太长的话,还可以在第一行结尾加上\表示续行,比如每一行命令以;\结尾。如果不希望 make 为每一行命令单独开一个 shell 进程,还可以使用如下设置
1
.ONESHELL:

树形依赖关系

为了下文的方便,我们把所有的出现在 prerequisites 部分的前置依赖文件,都理解为一个自动产生的、无效的 target

1
2
3
4
5
6
7
8
a.out : hello.c hi.h
gcc hello.c -o a.out

# 自动理解为产生了如下两个无效的target
hello.c :
[END]
hi.h :
[END]

这里的[END]是便于理解而加上去的,并不是合法的 Makefile 语法。并且 Makefile 实际上要求 target 不能为空:prerequisites 和 commands 至少要存在一个。

在这样的理解下,我们可以说:一个 target 的 prerequisites 只能由 0 个或几个 target 组成,所有的 target 会根据依赖关系构成一个有向图,其中节点为 target 或者无效的 target。

这里为了简化问题,我们不考虑有向图中存在回路的情形,只考虑一个大致的树结构:一般的节点(target)都只能指向一个父节点,但是允许叶子(无效 target)指向多个父节点。 在这个有向图结构中,每个节点(target)和叶子(无效 target)都有自己的非负权重,取决于这个文件的时间戳:更新时间越晚,权重越大,如果这个文件不存在,权重为零。

我们考虑一个稍复杂的例子

1
2
3
4
5
6
7
8
edit : main.o kbd.o
cc -o edit main.o kbd.o

main.o : main.c defs.h
cc -c main.c

kbd.o : kbd.c defs.h command.h
cc -c kbd.c

根据 prerequisites 自动补充几个无意义的 target:main.o,kdb.o,main.c,defs.h,kdb.c,command.h, 其中名为 main.o 和 kdb.o 的 target 我们已经具体给出了,因此合并到一起不再是无效的 target。对应的有向图结构为

执行逻辑

一般来说,make 的默认执行的 target 是 Makefile 中的第一个 target,这也是编译的最终目标,树的根节点。其他的 target 一般是作为它的 prerequisites 由这个目标牵连出来的。当然,我们也可以要求 make 完成指定的 target

  • make: make 命令默认执行第一个 target
  • make targetname: make 会执行名为 targetname 的 target,例如make main.o

make 的执行逻辑: make 执行一个 target 时,会反向检索以当前节点为根节点的整个子树结构,然后从叶子往上一层层地执行每一个节点

  • 对于叶子节点,由于是无效 target,没有具体的 commands 去执行,只会检查它是否存在,如果不存在会报错:make 既找不到它,也找不到生成它的方法
1
No rule to make target X, needed by Y.  Stop.;
  • 对于一般节点,也就是正常的 target,如果它存在,并且权重比所有指向它的节点的权重都严格地大,那么无需重复编译,直接跳过;否则就逐条执行它的 commands

伪目标

1
2
3
4
5
6
7
8
9
10
.PHONY: cleanall cleanobj cleandiff

cleanall : cleanobj cleandiff
rm program

cleanobj :
rm *.o

cleandiff :
rm *.diff

上面的 Makefile 文件定义了三个 target,但是这些 target 都不是对应子结构中真正的生成文件名,这些子结构也没有生成什么目标文件,这样的 target 被称为伪目标(phony target)。

使用.PHONY:语句可以设置某个或某些编译目标为伪目标,.PHONY:语句不是必须在 Makefile 的开头,例如

1
.PHONY: clean

伪目标的作用:make 不会试图查找一个名为clean的文件,并且把它的时间戳作为权重,而是永远把这个 target 的权重设为 0。

不使用.PHONY:设置通常也没事,但是如果 make 真的找到了于 target 同名的文件,就会错误地给这个 target 赋予一个权重,可能导致 make 跳过这个 target 不执行。 伪目标同样可以作为其他 target 的依赖。

Makefile 约定俗称的一些伪目标如下

  • all 通常是最终目标,其功能一般是执行其它所有的目标,编译整个项目
  • clean 通常是删除所有在编译过程中,被 make 创建的文件,也包括最终的可执行文件
  • install 通常是安装已在本地编译好的程序,其实就是把目标可执行文件(或者库文件和附带的头文件)拷贝到指定的位置

Makefile 示例

如下是一个使用了多个源文件和头文件的 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
37
38
39
40
41
42
43
44
edit : main.o kbd.o command.o display.o insert.o search.o files.o utils.o
# 最后一步,把所有的.o文件编译链接为edit可执行文件,前置依赖是这些.o文件
cc -o edit main.o kbd.o command.o display.o \
insert.o search.o files.o utils.o

main.o : main.c defs.h
# 将源文件main.c编译为目标文件,还需要依赖头文件defs.h
cc -c main.c

kbd.o : kbd.c defs.h command.h
# 将源文件kdb.c编译为目标文件,还需要依赖头文件defs.h command.h
cc -c kbd.c

command.o : command.c defs.h command.h
# 将源文件command.c编译为目标文件,还需要依赖头文件defs.h command.h
cc -c command.c

display.o : display.c defs.h buffer.h
# 将源文件display.c编译为目标文件,还需要依赖头文件defs.h buffer.h
cc -c display.c

insert.o : insert.c defs.h buffer.h
# 将源文件insert.c编译为目标文件,还需要依赖头文件defs.h buffer.h
cc -c insert.c

search.o : search.c defs.h buffer.h
# 将源文件search.c编译为目标文件,还需要依赖头文件defs.h buffer.h
cc -c search.c

files.o : files.c defs.h buffer.h command.h
# 将源文件files.c编译为目标文件,还需要依赖头文件defs.h command.h
cc -c files.c

utils.o : utils.c defs.h
# 将源文件utils.c编译为目标文件,还需要依赖头文件defs.h
cc -c utils.c

# 这个设置是把clean当成一个伪目标,一个纯粹的标签,而不会尝试去寻找名为clean的文件
.PHONY: clean

clean :
# 清除编译过程中产生的所有.o文件 清除最终的可执行文件edit
rm edit main.o kbd.o command.o display.o \
insert.o search.o files.o utils.o

Makefile 进阶语法

自动变量

前文中的示例是一个标准的 Makefile 文件,但是存在一个问题:每次重复地把所有文件名写在 prerequisites 和 commands 中,太过繁琐。在 Makefile 中,支持如下的自动变量来简化 commands 的书写

  • $@ 指代当前的 target,也就是这个环节最终产生的目标文件名
  • $^ 指代 prerequisites 中的所有项,以空格分隔
  • $< 指代 prerequisites 中的第一个
  • $? 指代 prerequisites 中比目标文件的时间戳更新的那些文件
  • $(@D)$(@F)$@拆解得到的编译目标存放的目录和编译目标的纯文件名
  • $(<D)$(<F) 同上,只不过是对$<拆解得到的目录和纯文件名

例如

1
2
3
4
5
6
7
edit : main.o kbd.o command.o display.o insert.o search.o files.o utils.o
cc -o edit main.o kbd.o command.o display.o \
insert.o search.o files.o utils.o

# 可以简化为
edit : main.o kbd.o command.o display.o insert.o search.o files.o utils.o
cc -o $@ $^

自定义变量

支持和 shell 中类似的变量语法,例如

1
2
3
txt = Hello World
test :
@echo ${txt}

这里首先定义了名为 txt 的变量,然后使用${txt}获取它的值,也就是字符串Hello world,并输出。

但是基于字符串替换的变量赋值存在一个问题:何时,怎么样把一个变量名扩展为一个字符串,为了解决这个问题,make 使用如下四种赋值语句

1
2
3
4
VARIABLE = value  # 在执行时扩展,允许递归扩展
VARIABLE := value # 在定义时就扩展(和Mathematica的逻辑相反)
VARIABLE ?= value # 只有在该变量为空时才设置值
VARIABLE += value # 将值追加到变量的尾端

例如可以使用如下的语句,给变量 DEBUG 赋予默认值,但是如果 make 命令自带了 DEBUG 的定义,就不执行这个赋值,给 OBJ_DIR 和 OBJ_DIR_FOR_SUB 赋予了目录值,在执行时才展开

1
2
3
4
5
# 区分是否使用debug模式,默认debug
DEBUG ?= 1

OBJ_DIR = ${BIN_DIR}/obj
OBJ_DIR_FOR_SUB = ../${OBJ_DIR}

流程控制

以一个例子说明 if 结构

1
2
3
4
5
6
7
8
DEBUG ?= 1
ifeq (${DEBUG}, 1)
CFLAGS = -g3 -Wall -DDEBUG
BIN_DIR = bin/debug
else
CFLAGS = -O2 -DNDEBUG
BIN_DIR = bin/release
endif

在这个例子中,如果 make 自带了 DEBUG 的定义就使用,否则 DEBUG 使用默认值 1,然后进入 if 语句,根据 DEBUG 的值分别进入一个分支,设置编译选项 CFLAGES 和目录 BIN_DIR。

同样地,还有 for 循环结构

1
2
3
4
5
LIST = one two three
all :
for i in $(LIST); do \
echo $$i; \
done

子目录中的 Makefile

还有一个需要注意的点,就是项目拆分为多个子目录时,可以在每个子目录中使用 Makefile,在项目根目录中使用 Makefile 依次调用它们。 这里需要解决的问题有两个:

  • 变量的传递,和 shell 中一样,可以使用 export 把当前的变量导出,这样在下层的 Makefile 中仍然可以解析这个变量
1
2
3
4
5
6
7
8
9
OBJ_DIR = ${BIN_DIR}/obj
OBJ_DIR_FOR_SUB = ../${OBJ_DIR}

export DEBUG OBJ_DIR_FOR_SUB

CXX = g++ ${CFLAGS}
CC = gcc ${CFLAGS}

export CXX CC
  • 进入子目录,例如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 获取所有需要进入的子目录
SUB_DIR = MyMatrixBase MyOutput MyLinearSolve MyGauss MyFEbasefunction src
SUB_DIR += ${BIN_DIR}

# 最终编译目标,前置依赖是所有子目录的目标
all: ${SUB_DIR}

# 前置依赖是ECHO
# ${MAKE}是内置变量,指代make自身
${SUB_DIR} : ECHO
@${MAKE} --no-print-directory -C $@

# 显示进入了哪个目录
ECHO :
@echo go to sub-dirs: ${SUB_DIR}

多层 Makefile 项目示例

这是我自己写的一个有限元多重网格法的实验,项目组织不太标准,但至少是 work 的,项目结构如下

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
37
|-bin
|-debug
|-obj
Makefile(6)
|-release
|-obj
Makefile(7)
|-include
MyHead.h
|-MyMatrixBase 矩阵基础操作
MyMatrixBase.h
MyMatrixBase_1.cpp
MyMatrixBase_2.cpp
MyMatrixBase_3.cpp
MyMatrixBase_4.cpp
MyMatrixBase_5.cpp
Makefile(1)
|-MyOutput 输出
output.cpp
output.h
Makefile(2)
|-MyCG 共轭梯度法
Conjugate_Gradient.cpp
Conjugate_Gradient.h
Makefile(3)
|-MyMG 多重网格法
MultiGrid.h
MultiGrid_1.cpp
MultiGrid_2.cpp
MultiGrid_3.cpp
Makefile(4)
|-src 源文件
main.cpp
test.cpp
test.h
Makefile(5)
Makefile(0)

在项目根目录下的 Makefile(0)

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
37
38
39
40
41
42
# Makefile(0)

# 区分是否使用debug模式,默认debug
DEBUG ?= 1
ifeq (${DEBUG}, 1)
CFLAGS = -g3 -Wall -DDEBUG
BIN_DIR = bin/debug
else
CFLAGS = -O2 -DNDEBUG
BIN_DIR = bin/release
endif

OBJ_DIR = ${BIN_DIR}/obj
OBJ_DIR_FOR_SUB = ../${OBJ_DIR}

export DEBUG OBJ_DIR_FOR_SUB

CXX = g++ ${CFLAGS}
CC = gcc ${CFLAGS}

export CXX CC

SUB_DIR = MyMatrixBase MyOutput MyCG MyMG src

SUB_DIR += ${BIN_DIR}

all: ${SUB_DIR}

${SUB_DIR}: ECHO
@${MAKE} --no-print-directory -C $@

ECHO:
@echo go to sub-dirs: ${SUB_DIR}


# 声明伪目标
.PHONY:run clean
run:
@ ${BIN_DIR}/test.exe
clean:
rm ${OBJ_DIR}/*
rm ${BIN_DIR}/test.exe

在 MyMatrixBase、MyOutput、MyCG 和 MyMG 的 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
37
38
39
40
41
42
43
44
45
46
47
48
# Makefile(1)

MyMatrixBase:MyMatrixBase1.o MyMatrixBase2.o MyMatrixBase3.o MyMatrixBase4.o MyMatrixBase5.o libmar.a ${OBJ_DIR_FOR_SUB}/libmar.a

libmar.a:MyMatrixBase1.o MyMatrixBase2.o MyMatrixBase3.o MyMatrixBase4.o MyMatrixBase5.o
ar -cr libmar.a *.o;

${OBJ_DIR_FOR_SUB}/libmar.a:libmar.a
cp libmar.a ${OBJ_DIR_FOR_SUB}/libmar.a

MyMatrixBase1.o:MyMatrixBase1.cpp
${CXX} -c MyMatrixBase1.cpp
MyMatrixBase2.o:MyMatrixBase2.cpp
${CXX} -c MyMatrixBase2.cpp
MyMatrixBase3.o:MyMatrixBase3.cpp
${CXX} -c MyMatrixBase3.cpp
MyMatrixBase4.o:MyMatrixBase4.cpp
${CXX} -c MyMatrixBase4.cpp
MyMatrixBase5.o:MyMatrixBase5.cpp
${CXX} -c MyMatrixBase5.cpp

# Maekfile(2)
MyOutput:${OBJ_DIR_FOR_SUB}/output.o

${OBJ_DIR_FOR_SUB}/output.o: output.cpp output.h
${CXX} -c $< -o $@

# Maekfile(3)
MyCG:${OBJ_DIR_FOR_SUB}/Conjugate_Gradient.o

${OBJ_DIR_FOR_SUB}/Conjugate_Gradient.o: Conjugate_Gradient.cpp Conjugate_Gradient.h
${CXX} -c $< -o $@

# Maekfile(4)
MyMG:MultiGrid_1.o MultiGrid_2.o MultiGrid_3.o libmg.a ${OBJ_DIR_FOR_SUB}/libmg.a

libmg.a:MultiGrid_1.o MultiGrid_2.o MultiGrid_3.o
ar -cr libmg.a *.o

${OBJ_DIR_FOR_SUB}/libmg.a:libmg.a
cp libmg.a ${OBJ_DIR_FOR_SUB}/libmg.a

MultiGrid_1.o:MultiGrid_1.cpp MultiGrid.h
${CXX} -c MultiGrid_1.cpp
MultiGrid_2.o:MultiGrid_2.cpp MultiGrid.h
${CXX} -c MultiGrid_2.cpp
MultiGrid_3.o:MultiGrid_3.cpp MultiGrid.h
${CXX} -c MultiGrid_3.cpp

在 src 下的 Makefile 为

1
2
3
4
5
6
7
8
# Makefile(5)
SRC: ${OBJ_DIR_FOR_SUB}/test.o ${OBJ_DIR_FOR_SUB}/main.o

${OBJ_DIR_FOR_SUB}/test.o: test.cpp test.h
${CXX} -c $< -o $@

${OBJ_DIR_FOR_SUB}/main.o: main.cpp
${CXX} -c $< -o $@

在 bin 目录下的最后两个 Makefile

1
2
3
4
5
6
7
# Makefile(6) debug
test.exe:obj/*.o obj/*.a
${CXX} $^ -o $@

# Makefile(7) release
test.exe:obj/*.o obj/*.a
${CXX} $^ -o $@

最后

随着软件发展的越来越复杂,时至今日,make 和 Makefile 也不够用了,写起来太长,而且需要考虑跨平台等适配性问题,使得编写 Makefile 过于复杂。 最终在 make 和 Makefile 的上层,又出现了构建工具 cmake 和 CMakeLists,cmake 重点解决的是跨平台问题。 我们可以通过编写 CMakeLists.txt,使用 cmake 自动生成需要的 Makefile,然后 make 根据 Makefile 生成具体要执行的 gcc 命令等,交给 shell 去执行。

一句话,这篇学习笔记的 make 和 Makefile 都白学了,哈哈哈哈。