0. 前言
本篇博客适合帮助Makefile初学者过渡到中高级水平,但深入的技能仍需在实践中不断积累。
1. 基础概念
1.1 目标、依赖和规则: 理解Makefile中的基本结构和工作原理。
在Makefile中,目标、依赖和规则的概念构成了核心,它们定义了如何构建和更新项目的文件。
目标 (Targets):
目标通常代表要构建的文件名。它可以是一个可执行文件、一个对象文件,或者甚至是一个标签(伪目标)。
例如,在以下规则中,`program`是目标:
program: main.o utils.o
gcc -o program main.o utils.o
依赖 (Dependencies):
依赖是目标所依赖的文件。只有当一个或多个依赖比目标更新时,目标才会被重新构建。
在上面的例子中,`main.o`和`utils.o`是`program`的依赖。
规则 (Rules):
规则定义了如何从依赖构建目标。它是一个命令序列,通常是shell命令。
在上面的例子中,规则是:
gcc -o program main.o utils.o
当键入`make program`,Make会检查`program`的依赖是否比它新,如果是,则执行规则来构建它。
1.2 变量: 学习如何在Makefile中设置和使用变量。
在Makefile中,你可以使用变量来保存并复用值。这在多处需要相同值或配置时非常有用。例如,你可能希望在多个规则中使用相同的编译器或相同的编译器标志。
定义变量:
定义变量的语法很简单:
VARIABLE_NAME = value
或
VARIABLE_NAME := value
两者之间的主要区别是`=`使用的是懒惰赋值,只在使用时评估其值,而`:=`使用立即赋值,立即评估其值。
使用变量:
要在Makefile中使用变量,你可以使用`$(VARIABLE_NAME)`或`${VARIABLE_NAME}`。例如:
CC = gcc
CFLAGS = -Wall
program: main.o utils.o
$(CC) $(CFLAGS) -o program main.o utils.o
这里,我们定义了两个变量`CC`和`CFLAGS`,然后在规则中使用它们。这样,如果我们决定更改编译器或标志,我们只需要在一个地方更改它,而不是在整个Makefile中。
Makefile中的目标、依赖和规则是其核心,它们告诉Make如何构建你的项目。而变量提供了一种方式来复用和配置这些构建步骤,使得Makefile更加灵活和可维护。理解这些基础概念是编写和理解Makefile的关键。
2. 模式规则
模式规则是Makefile中的一种强大工具,允许你为一组文件定义规则,而不是为每个文件单独定义。这通过使用模式(通常是%
字符)来匹配文件名的一部分来实现。
2.1 基本的模式规则:
一个基本的模式规则看起来像这样:
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
在这里:
- `%.o` 是目标模式,表示所有以`.o`结尾的文件。
- `%.c` 是依赖模式,表示所有以`.c`结尾的文件。
- `$<` 是一个自动变量,代表规则的第一个依赖(在本例中是`%.c`文件)。
- `$@` 是一个自动变量,代表规则的目标(在本例中是`%.o`文件)。
这条规则告诉make如何从一个`.c`文件构建一个`.o`文件。
例如,假设你的项目有一个名为`main.c`的源文件。由于上面的模式规则,你可以简单地键入`make main.o`,make就会知道如何使用上面的规则来编译`main.c`并生成`main.o`。
2.2 内置模式规则:
GNU Make提供了一系列内置的模式规则,这意味着在许多情况下,你甚至不需要为常见的操作(如从`.c`文件编译`.o`文件)编写规则。
这也是为什么你可以在没有Makefile的情况下使用make的原因。例如,如果你只有一个`main.c`文件,只需键入`make main`,GNU Make会使用其内置的模式规则来编译它。
但是,大多数项目都需要更多的定制,这就是为什么在Makefile中定义你自己的规则通常是有益的。
2.3 自定义模式规则:
除了基本的模式规则外,你可以定义更复杂的模式,以处理项目中的特定情况。
例如,假设你的源文件存放在一个名为`src`的目录中,但你希望对象文件生成到`build`目录中。你可以使用以下模式规则:
build/%.o: src/%.c
$(CC) $(CFLAGS) -c $< -o $@
现在,假设你有一个文件`src/main.c`,当你键入`make build/main.o`时,上述规则将被用来从`src/main.c`生成`build/main.o`。
模式规则为Makefile提供了一种高效、简洁的方式,可以为许多文件定义通用的构建和更新规则。它们通过使用模式来匹配文件名部分,使得规则定义更加灵活。
3. 函数
GNU Make提供了一系列函数,帮助你更加灵活地操作变量、文件名等。下面举例说明常见的函数用法。
3.1 $(wildcard ...)
这个函数用于获取匹配给定模式的文件列表。例如,要获取当前目录下所有的`.c`文件,你可以这样做:
SOURCES = $(wildcard *.c)
如果你想获取多级目录下的所有`.c`文件,你可以使用:
SOURCES = $(wildcard src/**/*.c)
3.2 $(patsubst ...)
这个函数执行模式替换。它的常见用途是从一种文件列表生成另一种。例如,从`.c`文件列表生成对应的`.o`文件列表:
SOURCES = $(wildcard *.c)
OBJECTS = $(patsubst %.c,%.o,$(SOURCES))
这里,`$(patsubst %.c,%.o,$(SOURCES))`将`SOURCES`中的每个`.c`文件转换为相应的`.o`文件。
3.3 $(foreach ...)
这个函数用于循环。它对列表中的每个项执行某个操作。例如,给每个名字打招呼:
NAMES = Alice Bob Charlie
GREETINGS = $(foreach name,$(NAMES),Hello $(name)!)
这会生成`GREETINGS`变量,其内容是"Hello Alice! Hello Bob! Hello Charlie!"。
3.4 $(filter ...) 和 $(filter-out ...)
$(filter pattern...,text) 返回`text`中所有匹配`pattern`的单词。
FILES = foo.c bar.js baz.c
C_FILES = $(filter %.c,$(FILES))
这会返回所有`.c`文件,即`foo.c baz.c`。
`$(filter-out pattern...,text)`与`$(filter ...)`相反,返回不匹配`pattern`的单词。
FILES = foo.c bar.js baz.c
NOT_C_FILES = $(filter-out %.c,$(FILES))
这会返回不是`.c`文件的文件,即`bar.js`。
3.5 文件名操作
- `$(dir names...)`: 返回每个文件名的目录部分。例如,`$(dir src/foo.c bin/bar)`返回`src/ bin/`.
- `$(notdir names...)`: 返回每个文件名的非目录部分。例如,`$(notdir src/foo.c bin/bar)`返回`foo.c bar`.
- `$(suffix names...)`: 返回每个文件名的后缀。例如,`$(suffix src/foo.c bin/bar.js)`返回`.c .js`.
- `$(basename names...)`: 返回每个文件名,但不带后缀。例如,`$(basename src/foo.c bin/bar.js)`返回`src/foo bin/bar`.
GNU Make的这些函数为Makefile的作者提供了丰富的工具,使得文件操作、模式匹配和文本处理变得简单且灵活。理解这些函数如何工作对于编写复杂的Makefile是关键。
4. 自动变量
在Makefile中,自动变量为我们提供了一种方式,可以引用规则的目标、依赖和其他相关内容,而不必明确地指定它们。这增加了Makefile的灵活性和可重用性。
4.1 $@: 目标文件名
这个变量代表规则的目标。考虑以下规则:
my_program: main.o utils.o
gcc -o $@ $^
在这里,`$@`代表`my_program`。
4.2 $^: 所有的依赖文件,不重复
这个变量表示所有的依赖文件,但是不会重复。继续上面的例子:
my_program: main.o utils.o
gcc -o $@ $^
在这里,`$^`将代表`main.o utils.o`。如果有重复的依赖,`$^`只会列出它们一次。
4.3 $<: 第一个依赖文件
这个变量表示规则中的第一个依赖文件。它在模式规则中特别有用。例如:
%.o: %.c
gcc -c $< -o $@
在这个规则中,如果你正在为`main.o`构建,那么`$<`将代表`main.c`。
4.4 $(?): 新于目标的所有依赖文件
这个变量列出所有比目标新的依赖文件。它在增量构建中非常有用,当你只想重新构建那些由于某些依赖关系而已经更改的部分时。
考虑以下规则:
my_program: main.o utils.o
gcc -o $@ $?
如果`utils.o`是唯一在上次构建`my_program`后修改过的文件,那么`$?`将只代表`utils.o`。
自动变量在Makefile中为我们提供了一个简洁和灵活的方法来引用规则的目标、依赖和其他关系,使得我们不必多次明确地指定它们。理解并有效地使用这些自动变量可以简化Makefile并提高其可维护性。
5. 条件执行
在Makefile中,有时你可能想要根据某些条件来执行不同的操作。例如,你可能想要根据当前的操作系统或编译器选项来更改构建参数。为了支持这种需求,GNU Make提供了一系列的条件指令。
5.1 ifeq (arg1, arg2)
这个指令用于检查两个参数是否相等。如果它们相等,那么`ifeq`块中的代码会被执行。
ifeq ($(CC),gcc)
CFLAGS = -Wall
else
CFLAGS = -O2
endif
在上述例子中,如果变量`CC`的值是`gcc`,则`CFLAGS`被设置为`-Wall`;否则,它被设置为`-O2`。
5.2 ifneq (arg1, arg2)
这与`ifeq`相反,它检查两个参数是否**不**相等。
ifneq ($(DEBUG),true)
CFLAGS = -O2
else
CFLAGS = -g
endif
如果`DEBUG`变量不等于`true`,则`CFLAGS`被设置为`-O2`;否则,它被设置为`-g`。
5.3 ifdef variable-name
这个指令检查一个变量是否已定义。如果定义了这个变量,不管它的值是什么(即使为空),`ifdef`块的内容都会被执行。
ifdef OUTPUT_DIR
ODIR = $(OUTPUT_DIR)
else
ODIR = ./build
endif
如果已经定义了`OUTPUT_DIR`变量,`ODIR`将被设置为其值;否则,它将默认为`./build`。
5.4 ifndef variable-name
这与`ifdef`相反,它检查一个变量是否**没有**被定义。
ifndef CC
CC = gcc
endif
如果变量`CC`没有被定义,它将默认设置为`gcc`。
条件指令在Makefile中允许根据特定的条件执行不同的操作。这为Makefile的作者提供了更大的灵活性,特别是当需要处理不同的平台、编译器或配置选项时。理解并有效地使用这些条件指令可以使你的Makefile更加适应不同的构建环境和需求。
6. 包含其他Makefiles
在大型项目中,Makefile可能会变得相当庞大和复杂。为了提高可读性和维护性,一个常见的做法是将Makefile分解为多个小文件,然后在主Makefile中包含这些文件。GNU Make提供了`include`指令,用于此目的。
6.1 使用 `include`
基本语法是:
include filename
例如,假设你有三个额外的Makefiles:`variables.mk`、`rules.mk`和`config.mk`,你可以这样包含它们:
include variables.mk rules.mk config.mk
这将按照指定的顺序导入这三个文件的内容。
6.2 动态生成的Makefiles
你还可以包含由其他规则生成的Makefile。这在自动依赖管理中非常有用。例如,你可以使用gcc的`-M`选项来生成依赖,并将其保存在`.d`文件中:
-include $(DEPFILES)
`-include`与`include`几乎相同,但它不会因为文件不存在或有任何其他错误而失败。
6.3 条件包含
你可以结合使用`include`和Make的条件语句,根据特定的条件来决定是否包含某个文件。例如:
ifeq ($(DEBUG),true)
include debug.mk
else
include release.mk
endif
根据`DEBUG`变量的值,你可以决定是包含`debug.mk`还是`release.mk`。
6.4 通配符
你也可以使用通配符与`include`结合,例如,要包含所有`.mk`文件,你可以这样做:
include *.mk
或者,要包含子目录中的所有Makefile:
include */*.mk
`include`指令允许Makefile的作者将其内容分解为多个文件,从而提高了其可读性和维护性。这也为复用规则、变量定义和其他构建逻辑提供了机会。在大型项目中,有效地使用这个特性可以帮助你保持Makefile的结构清晰和组织良好。
7. 伪目标和.PHONY
在Makefile中,大多数目标都是指向文件的,例如对象文件、库或可执行文件。然而,有些目标并不代表文件,而是代表一组要执行的命令。这种不代表文件的目标被称为"伪目标"。
伪目标常用于执行一组命令,例如清除构建产物、生成文档、运行测试等。由于伪目标不是文件,它们不受文件的存在性或修改日期的影响。
例如,常见的`clean`目标通常用于删除所有生成的文件:
clean:
rm -f *.o my_program
当你执行`make clean`时,关联的命令将被执行,而不管是否存在一个名为`clean`的文件。
7.1 .PHONY
问题是,如果存在一个名为`clean`的文件或目录,那么`make clean`可能不会按预期工作,因为make会检查文件的修改日期。为了告诉make一个目标是伪目标(不是文件),我们使用`.PHONY`声明。
在上面的例子中,为了确保`clean`始终被视为伪目标,你可以这样做:
.PHONY: clean
clean:
rm -f *.o my_program
这样,即使存在一个名为`clean`的文件,`make clean`也会正常工作。
你可以为Makefile中的多个伪目标使用`.PHONY`。例如:
.PHONY: clean install test
clean:
rm -f *.o my_program
install:
cp my_program /usr/local/bin/
test:
./test_script.sh
伪目标是Makefile中的特殊目标,它们不代表文件,而是代表要执行的操作。使用`.PHONY`声明可以确保make正确地将这些目标视为伪目标,而不是文件。这对于确保命令如预期那样执行(特别是在可能存在与伪目标同名的文件时)非常重要。
8. 静默规则和特殊目标
默认情况下,当`make`执行规则中的命令时,它会在执行之前回显(即打印)这些命令。虽然这在调试Makefile时很有帮助,但在常规使用中可能会引起混淆或导致输出过于冗长。
为了阻止命令的回显,可以在命令前放置一个`@`符号。例如:
quiet:
@echo This will be printed without showing the command
如果你在Makefile中执行`make quiet`,你将只看到:
This will be printed without showing the command
而不是:
echo This will be printed without showing the command
This will be printed without showing the command
为了在整个Makefile中禁用命令回显,可以使用特殊目标`.SILENT`:
.SILENT:
quiet:
echo This will also be printed without showing the command
8.1 特殊目标
Make提供了一系列的特殊目标,这些目标具有特定的意义,不是真正的构建目标。
.SUFFIXES
`.SUFFIXES`定义了一组文件后缀,以用于旧式的隐式依赖规则。例如,Make默认知道`.c`源文件可以被编译为`.o`对象文件。你可以使用`.SUFFIXES`来清除所有的后缀或添加新的后缀。
# 清除所有的默认后缀
.SUFFIXES:
# 只定义.c和.o为有效的后缀
.SUFFIXES: .c .o
在现代的Makefile中,许多人倾向于使用模式规则而不是后缀规则,但了解这个特殊目标仍然很有用,特别是当处理旧的Makefile时。
.DEFAULT
如果make不能找到构建目标的规则,它会尝试使用`.DEFAULT`规则。这对于捕获未定义的目标很有用。
.DEFAULT:
@echo "Unknown target: $@"
如果你尝试构建一个未在Makefile中定义的目标,上面的`.DEFAULT`规则会打印一个消息。
静默规则允许你控制Makefile命令的回显。特殊目标,如`.SUFFIXES`和`.DEFAULT`,为Makefile提供了额外的控制,允许你定义如何处理特定的情况或更改make的默认行为。理解和利用这些特性可以帮助你编写更强大和灵活的Makefile。
9. 高级依赖
在大型项目中,源文件间的依赖关系可能会变得复杂。例如,一个C++源文件可能包含多个头文件,这些头文件又可能包含其他头文件。如果任何头文件发生更改,与之相关的源文件都应该重新编译。
手动跟踪这些依赖关系并将其放入Makefile中是不切实际的,尤其是在文件经常变动的情况下。幸运的是,我们可以自动化这个过程。
9.1 生成动态依赖
编译器(如GCC)通常提供了生成依赖信息的选项。对于GCC,这是`-M`和其变体。
例如,对于一个名为`example.c`的源文件,以下命令:
gcc -M example.c
会输出`example.c`文件的依赖关系,通常是这样的形式:
example.o: example.c header1.h header2.h ...
为了将这些依赖关系保存到文件中,可以使用`-MF`选项。通常,这些依赖关系保存在`.d`文件中。例如:
gcc -M -MF example.d example.c
这会生成一个名为`example.d`的文件,其中包含了`example.c`的所有依赖关系。
9.2 在Makefile中使用动态依赖
一旦生成了`.d`文件,我们需要在Makefile中包含它们,这样make就可以知道何时重新编译源文件。
使用`-include`(或`include`)指令来包含这些依赖文件:
SRC = example.c
OBJ = $(SRC:.c=.o)
DEP = $(OBJ:.o=.d)
my_program: $(OBJ)
gcc $^ -o $@
-include $(DEP)
在这里:
- 我们首先定义了源文件、对象文件和依赖文件的列表。
- 在目标规则中,我们只是像往常一样编译我们的程序。
- 使用`-include $(DEP)`来包含所有`.d`文件。`-include`是不会出错的,所以如果依赖文件不存在,make不会报错。
9.3 结合动态依赖生成和编译
一个常见的做法是在编译源文件的同时生成依赖信息。GCC提供了`-MMD`选项,可以在编译时自动创建`.d`文件。
%.o: %.c
gcc -c -MMD $< -o $@
使用动态依赖,我们可以确保当头文件或其他依赖性更改时,只有受影响的源文件会被重新编译。这不仅减少了不必要的编译,还确保了构建的正确性。在大型项目中,这种自动依赖跟踪是至关重要的。
10. 内置函数和高级技巧
Makefile提供了一系列强大的内置函数,允许开发者进行高级操作和处理。以下是其中一些函数的详细描述:
10.1 $(eval ...)
`$(eval ...)`函数允许你动态地评估Makefile代码。这意味着你可以在Makefile执行时生成和评估新的规则、变量等。这是一个相当高级的功能,通常用于更复杂的Makefile场景。
例如,假设你想为一个动态生成的文件列表创建规则:
FILES = file1 file2
define RULES
$(1).o: $(1).c
gcc -c $(1).c -o $(1).o
endef
$(foreach file, $(FILES), $(eval $(call RULES, $(file))))
上述代码为`file1`和`file2`定义了编译规则,即使这些文件名是从其他地方动态生成的。
10.2 $(call ...)
`$(call ...)`函数允许你调用一个make宏。这是一种定义可重用的代码块并使用参数调用它的方法。
在上面的`$(eval ...)`例子中,我们已经看到了`$(call ...)`的用法。在那里,`RULES`是一个宏,它接受一个参数(文件名),然后为该文件定义一个编译规则。
以下是一个更简单的例子:
define say_hello
@echo Hello, $(1)!
endef
greet:
$(call say_hello, world)
运行`make greet`将会输出"Hello, world!"。
10.3 $(value ...)
`$(value ...)`函数返回变量的纯文本值,不执行任何变量或函数引用。
考虑以下例子:
VAR = value of VAR
ANOTHER_VAR = $(VAR)
result1:
@echo $(ANOTHER_VAR)
result2:
@echo $(value ANOTHER_VAR)
在这里,`result1`和`result2`都会输出"value of VAR"。但是,使用`$(value ...)`有其优势,特别是在处理包含多次引用或递归引用的变量时。`$(value ...)`直接获取变量的内容,不对其中的任何东西进行评估或扩展。
Make的内置函数和高级技巧为开发者提供了大量的灵活性,可以用来处理复杂的构建场景和需求。通过理解并掌握这些功能,你可以更有效地组织和管理你的Makefile,从而优化你的构建流程。
11. 高级话题
以下是Makefile的一些高级特性和技巧,它们能够帮助你更有效地构建大型或复杂的项目。
11.1 并行构建:使用`-j`参数
默认情况下,`make`会一次处理一个目标。但在多核/多线程的现代计算机上,我们可以同时处理多个目标,从而加速构建过程。这是通过`make`的`-j`参数实现的,它允许你指定可以同时执行的最大任务数。
例如,要允许最多4个并行任务,可以运行:
make -j4
注意:并行构建要求你的Makefile是“线程安全”的。这意味着没有目标依赖于另一个目标的副作用。正确声明所有依赖关系是很重要的。
11.2 使用`VPATH`和`vpath`寻找文件
在大型项目中,源文件可能会被组织在多个子目录中。`VPATH`和`vpath`是`make`提供的两种方法,用于指定在哪里查找文件。
`VPATH`:是一个由冒号分隔的目录列表,当`make`需要找到文件时,它会在这些目录中搜索。
VPATH = src:../headers
上述设置告诉`make`在`src`目录和`../headers`目录中查找文件。
`vpath`:提供了更精细的控制,允许你为特定的文件模式指定目录。
vpath %.h ../headers
这告诉`make`只在`../headers`目录中查找`.h`文件。
11.3 使用`make`的双冒号规则
通常,我们在Makefile中为每个目标使用一个规则。但是,有时我们可能希望对一个目标应用多个规则。这就是双冒号规则发挥作用的地方。
使用双冒号(`::`)代替单冒号来定义规则。每个双冒号规则都是独立的,并且只有当目标不存在或比其依赖关系旧时,才会执行。
例如:
file.txt:: source1.txt
cat source1.txt > file.txt
file.txt:: source2.txt
cat source2.txt >> file.txt
这两个规则都适用于`file.txt`。当我们运行`make file.txt`时,两个规则都会按顺序执行,这样`file.txt`会首先包含`source1.txt`的内容,然后是`source2.txt`的内容。
上述高级技巧和特性可以帮助你更好地管理和优化你的构建流程,特别是在处理大型或复杂的代码库时。理解并正确使用这些特性可以使你的Makefile更加强大和灵活。
12. 最佳实践
在创建和管理Makefiles时,遵循一些最佳实践可以确保你的构建过程既高效又可维护。
12.1 组织和模块化大型Makefile
随着项目的增长,Makefile可能变得非常庞大和复杂。在这种情况下,将其模块化并将其组织成逻辑块是有意义的:
- 将代码划分为多个Makefiles:对于大型项目,考虑将Makefile划分为多个子Makefiles,每个子Makefile负责项目的特定部分。然后,使用`include`指令将它们包含在主Makefile中。
- 使用注释:在Makefile中,使用`#`开头的行来添加注释。这对于解释目标、变量和其他代码段的用途和工作方式非常有用。
- 按功能组织:将相关的目标、变量和规则组织在一起,并使用空行和注释为每个部分提供上下文。
12.2 递归Make的问题
在大型项目中,经常可以看到递归`make`的使用,这意味着从一个Makefile中调用`make`来构建另一个子目录中的目标。虽然这在某些情况下可能是有用的,但它通常是有问题的:
- 依赖性问题:递归`make`可能会导致在不同的Makefile之间丢失依赖关系,这可能导致构建不正确。
- 性能问题:对于每个子目录,启动一个新的`make`实例会增加构建时间。
非递归技巧:
为了避免这些问题,可以使用非递归技巧:
- 单一Makefile:尽管这可能听起来与直觉相反,但对于大多数项目,使用单一的Makefile(可能通过`include`指令模块化)通常比使用多个递归Makefiles更高效和更易于管理。
- 使用`VPATH`或`vpath`:这些变量允许你指定`make`在哪里查找源文件,使你可以在单一的Makefile中构建多个目录的代码。
12.3 为可移植性设计
如果你的项目可能在不同的系统或平台上构建,确保Makefile是可移植的:
- 使用条件赋值:根据不同的系统或条件,使用条件赋值来设置变量。
- 避免硬编码路径:使用Makefile变量而不是硬编码的路径和工具名。
正确地组织和构建Makefile可以大大提高项目的构建效率和可维护性。确保了解递归与非递归的利弊,并始终考虑如何最大限度地提高Makefile的清晰性和可移植性。
13. 学习资源与实践
13.1 学习资源
- `man make`: 查看GNU Make的手册页。
- [GNU Make Manual](https://www.gnu.org/software/make/manual/make.html): 官方文档提供了详细的信息和示例。
13.2 实践
学习Makefile的关键是实践。开始编写简单的Makefile,然后逐步增加复杂性。尝试为你的项目或其他开源项目编写或优化Makefile。
当你遇到问题或不理解的地方,查阅文档、搜索相关问题或询问社区。逐步深入,不断实践,最终你会达到高级水平。