Makefile简明教程

2019-03-13 18:32:14 Makefile

如果你一直都知道Makefile但不知道怎么学,如果你学过Makefile但自己写的时候就不会写了,如果你看了很多文档和教程感觉自己会写了但一写出来就错还不知道错在哪,那这篇文章就很适合你。我看了很多讲Makefile的文档和教程,要么像官方文档东西太多太杂抓不住重点,要么是简单堆砌语法规则、逻辑混乱,没有讲清楚前因后果和必要的细节。

本文的目标是:尽可能从原理的角度讲解Makefile的构成,通过少量而精致的例子加快你对Makefile的理解,尽量用通用的规则而非特例来解释Makefile的行为。杜绝似懂非懂,以解决常见情形下的问题为主,避免繁文缛节,但会介绍必要的细节,以避免Makefile中的坑。

本文中像这样quote出来的部分可以考虑先略过。

如果需要了解更细节的东西,可以查阅GNU make手册,也有翻译版GNU make中文手册。

理想是好的,但毕竟Makefile的内容很多,根本不可能只用几段话就能让你能顺利地写出正确有用的Makefile出来。建议完整读完本文,理解每一个例子。

Makefile简介

Makefile是一个描述进行编译构建所需的方式方法的文本文件,用来实现自动化编译构建。你可以将它理解成一个工程文件,类似于于VS的.vcxproj文件。make命令可以解析并执行Makefile。我们可以使用make -f filename指定所要解析的Makefile的文件名,若不指定则make会有几个默认的文件名去寻找,Makefile是其中最为常见和常用的文件名,推荐使用。于是,直接执行make会解析并执行当前目录下的Makefile文件。

make还有其他的标准和实现,可能会有一些细微的差别,我们这里以GNU make为准。

第一个例子

我们将如下内容写到Makefile文件中。

myfile:
[tab]   echo 'Hello World!' > myfile
# end of file

在这个例子中,myfile是我们的“目标”文件名,即我们想创建一个叫myfile的文件。第二行是我们想要达成这个“目标”所要执行的“命令”,这个“命令”是将Hello World!输出到一个叫myfile的文件中。

注意:我们这里显式地标出了“命令”开头的tab字符,但在以后的例子中将不再标出。但是由于解析的问题,所有的tab被替换成了空格,你可以从这里下载所有正确的Makefile

若我们执行make myfile,表示我们想要制作myfilemake程序就会检查myfile这一文件是否存在,若不存在则会在Makefile中寻找myfile这一“目标”,并执行对应的“命令”。当我们再次执行时,由于myfile已存在,因此make不会重新执行“命令”。

另外,在Makefile中,我们使用#进行注释,这一点与bash相同。

第二个例子

foo.o: foo.c foo.h
    gcc -c foo.c

bar.o: bar.c bar.h
    gcc -c bar.c

main: main.c foo.h bar.h foo.o bar.o
    gcc main.c -o main foo.o bar.o

该例子的场景是:我们要通过main.c编译一个叫做main的程序,它需要使用foo.cbar.c里的函数,同时需要引入相关头文件里的声明。该任务通过make main来执行。

它相比于第一个例子,多了“目标”冒号之后的部分,它叫做“依赖”,“依赖”中每个“依赖项”都必须是一个Makefile中的“目标”或已存在的文件。为了构建一个“目标”,必须首先达成所有“依赖项”的构建。例如,main的“依赖”中有foo.obar.o,它们两个必须先被构建。当我们执行make main时,由于foo.obar.o并不存在,make程序就会在Makefile中寻找这两个“目标”的构建方法,构建完成后再进行main的构建。

但我们为什么要像foo.o: foo.c foo.h一样将必然存在的文件设置成“依赖”呢?似乎去掉之后也可以正常构建。但事实上我们是需要它们的,因为make存在这样一种机制,如果“目标”中的某个“依赖项”的修改时间晚于上次make该“目标”的时间,就说明该“目标”过时了,需要重新构建。如果你不把影响该“目标”的所有必要的“依赖”都写上,那当你修改某个文件后,make就会因为“目标”已存在而不再构建。

因此,foo.c/foo.hbar.c/bar.h的修改会导致foo.obar.o过时而重新构建,从而使得main也必须重新构建。

依赖关系与并行编译

很显然,正常的话,“目标”之间通过“依赖”构成了一个DAG(有向无环图),利用拓扑排序可以得到一个“目标”序列,按此序列构建“目标”一定可以满足所有“目标”的“依赖”,但此序列并不唯一,而且其中可能存在相邻的几个“目标”之间是没有“依赖”的,它们可以并行地来处理。

直接make的话,make程序就是找到这样的一个序列按顺序构建,若加上-j参数,则会同时进行等同于你CPU的线程数的构建工作。你也可以使用类似-j4的参数手动指定这一数量。

如果你的Makefile出现了“循环依赖”,即这个图中出现了环,说明你写得肯定不对,这时make会尝试去掉其中的部分“依赖”后继续构建。

不过,“依赖”写重复了是不会有影响的。

几个基本原则

  • 处理的整体性:Makefile并不是一个脚本,因此它不是逐行执行的,而是作为一个整体来处理。但是,这不代表它完全没有顺序,因为有些语法必须存在顺序,例如我们之后要讲到的多次给相同的“变量”赋值、“变量”之间的相互赋值、“命令”内部的执行顺序等。但也仅限于这些语法,所有不绝对依赖于顺序的语法都是没有顺序的区别的。
  • Makefile处理的两个阶段:先建立所有“目标”及其“依赖”关系,再决定需要构建的“目标”并进行构建,这也是整体性的体现。
  • 默认目标:由前两条可以知道,“目标”之间不存在顺序的区别。但是,第一个“目标”比较特殊,它将成为“默认目标”,即我们直接执行make但不指定“目标”时默认去构建的“目标”,依惯例我们会将这第一个“目标”命名成all
  • 目标、依赖与命令的合并与拆分:“目标”的描述不一定只出现一次,你也可以将一个“目标”的“依赖”和“命令”分开来写,但是“命令”本身不可以拆分多次来写,给同一个“目标”多次规定“命令”以最后一次为准。自然而然地,你也可以将一个“目标”的多个“依赖”分开来写,而不一定非要把该“目标”的所有“依赖”都在一行里写完。类似地,多个“目标”如果有相同的“依赖”或“命令”,也可以把这几个“目标”写在一起,称为“多目标”。
  • 空白字符的处理:初学者可能会由于“命令”必须以tab开头而费解,但其实思考之后你就会发现,这是为了将构建“目标”需要执行的“命令”与其他Makefile语法区分开来。因此规定凡是tab开头的都是“命令”,反之都不是“命令”。也正是这个原因,在第一个“目标”出现之前,不可以出现以tab开头的行。但只要tab不在行的开头,就会被认为是与空格相同的空白字符。Makefile中的空行和只包含空白字符的行会被忽略。
  • 命令的执行make会调用shell执行所有的“命令”,默认是bash。但是每一行命令都会调用一个bash实例,相当于执行一个sh脚本,因此行之间没有连贯性。注意,只要该行是“命令”,所有字符都会交由bash执行。因此,“命令”中的#也会被交给bash来处理,它不会被Makefile认为是注释,但它有可能是bash中的注释。

体会一下,这些确实都是比较合理而自然的想法。

重要语法

至此,其实你已经可以写出能功能齐全的Makefile了,只是写起来可能会非常麻烦。为了更加容易地编写Makefile,我们需要介绍一些重要的语法。

在这一部分,我们将介绍一些语法,解释清楚它们的行为以及引入这些语法的原因。

伪目标

根据我们对“目标”的描述可以知道,虽然“目标”是个文件名,但你并不一定非要在“命令”中创建该文件。你可以通过这种方法,定义一些“目标”,仅仅是为了执行某些bash脚本,最常见的是定义一个clean用于删除所有编译生成的文件。而且由于该“目标”不会真正地去创建一个叫做clean的文件,因此每次make clean都能正常执行。

于是,我们考虑这样一个问题——你定义了这样的一个clean“目标”,但你同时有一个叫做clean的文件或者编译之后会生成一个叫做clean的文件,那当你执行make clean的时候就会因为clean已存在而不再执行该“目标”的“命令”了。

在这种情况下,你可以将它定义成“伪目标”而避免这种情况,即无论该“目标”对应的文件是否存在,“命令”都会得到执行。除此之外“伪目标”没有其他作用,不过会有极个别语法会强制你定义一个“目标”为“伪目标”。其实,在绝大多数情况下你都不需要定义“伪目标”。

语法如下:

.PHONY: clean

变量

由Makefile的整体性,变量是全局有效的,不论它在哪定义。

变量的定义与使用

objects = program.o foo.o utils.o
program : $(objects)
    gcc -o program $(objects)
$(objects) : defs.h

在这段代码中我们使用=定义了一个叫做objects的变量,通过$(objects)的格式使用它。虽然变量的引用也有其他格式,但基本上我们只使用这一种方法。需要注意的是,由于我们需要$字符来进行变量的引用,欲使用该字符就需要用$$来转义,因此“命令”里bash脚本中的变量就需要两个$,用于区分Makefile的变量。

几种赋值方式

除了=,还有:=?=+=等赋值方式。

  • :=直接展开:变量的值在写这句话时就已经确定。若它引用了其他变量,则直接用那个变量当前的值,而不管该变量在此之后值如何变化。
  • =递归展开:变量只在引用时才确定它的值,因此这句话在Makefile中的任何位置都没有区别,即使它引用了其他变量,除非对自身多次赋值。需要注意避免循环定义。
  • ?=条件赋值:只有此变量在之前没有赋值的情况下才会对这个变量进行赋值,它是递归展开的
  • +=追加赋值:给变量追加内容,但会在追加内容之前添加一个空格。它是直接展开还是递归展开取决于该变量上一次赋值的方式。

但是如果对相同变量进行多次=:=,都以最后一次为准。

目标指定变量

如果你只希望一个变量只对某一个“目标”的构建过程中有效,你可以使用目标指定变量。它必须写成独立的一行,后面既不能跟着它的“依赖”,也不能在第二行写它对应的“命令”,只能另起一行重新声明该“目标”及其“依赖”与“命令”。如果该“目标”没有任何“命令”,它必须定义成“伪目标”以防止隐含命令造成的影响。

CXXFLAGS := -std=c99 -O2
CXXDFLAGS := -std=c99 -g -Wall
all debug: main
main: main.c
    gcc -o main main.c $(CXXFLAGS)
debug: CXXFLAGS := $(CXXDFLAGS) # 目标指定变量
.PHONY: debug

在此例中,我们实现了在构建debug这个“目标”过程中,变量CXXFLAGS要被赋值为$(CXXDFLAGS),但在其他地方不影响CXXFLAGS的值。

这一功能经常用于针对某个“目标”修改编译参数。

替换引用

foo := a.o b.o c.o
bar := $(foo:.o=.c)
foobar := obj/test.o
test := $(foobar:obj/%.o=%.c)

$(foo:.o=.c)将变量foo里所有用空格隔开的字符串中,所有以.o结尾的改成.c结尾;同理test变量的值为test.c,其中%类似通配符。

模式规则与自动化变量

模式规则使用%用于“目标”的批量创建,类似通配符。

%的转义有些复杂,若想使用%符号本身,通常会定义一个percent := %变量。但你还是应该尽可能避免使用%符号。

CFLAGS := -Wall
%.o %.x : %.c defs.h
    $(CC) $(CFLAGS) $< -o $@
%.x : CFLAGS += -g
%.o : CFLAGS += -O2

其中,%.x%.o%.c就是模式规则,用于匹配以.x.o.c结尾的“目标”和“依赖”。这样一来,我们make任何.x.o“目标”,都有一条规则,即找到对应的.c文件进行构建,而且它们都依赖defs.h

%中可以包含/,即可以匹配一个路径。并且,模式规则还支持忽略路径后进行匹配,例如test%.o: test%.c可以匹配到test/test6.o: test/test6.c。因此不论%是不是第一个符号,都可以匹配到路径。

如果同时匹配多个模式规则或者一个显式声明的“目标”同时也匹配一个模式规则,这种情况较为复杂,有几个原则。但你应该尽可能避免这种情况发生。

  • 显式声明的“目标”比模式规则优先。
  • 有“命令”的优先。
  • 显式声明的“目标”无“命令”但模式规则有“命令”,则两者均有效。

再其他的情况一般也没有什么实际意义,你应该使用空命令来尽可能避免这些情况。

在本例中,我们对于模式规则也使用了我们前面所说的“目标指定变量”,这也是可以的,我们称它为“模式指定变量”。

为了在“命令”中使用在模式规则中匹配到的名字,我们需要自动化变量。常见的如下:

  • $<:第一个依赖,在本例中是匹配到的%.c
  • $^:所有依赖,但不包括下面要说的“order-only依赖”。
  • $@:目标。在本例中是匹配到的%.o%.x

更多的可以查阅手册

它与普通变量用法相同,只是省略了括号。因此$@$(@)是完全一样的。所有针对变量的语法,自动化变量也可以使用,例如前文所述的“替换引用”。

order-only依赖

我们使用管道符号|分隔“常规依赖”和“order-only依赖”,|左边都是常规依赖,右边都是“order-only依赖”。“order-only依赖”可以不存在。

它表示该“依赖”只有在文件不存在的情况下,才会进行该“依赖”的构建,而无视它是否需要更新。它通常用来创建目录。

DIRS := obj bin
$(DIRS):
    mkdir $@
obj/%.o : %.c | obj
    gcc -o $@ -c $<
bin/% : obj/%.o | bin
    gcc -o $@ $<

之所以要把待创建的目录声明为“order-only依赖”而不是“常规依赖”,是因为一旦目录中的文件名产生变化,该目录就会被认为修改过,若此时依然使用“常规依赖”,则“目标”就会因为“依赖”更新而重新构建。“order-only依赖”可以无视更新。

当order-only依赖是一个目录时,不能以/结尾,必须去掉,否则判断会有问题。不太清楚是bug还是feature。

条件语句

info := 0
ifeq ($(MAKECMDGOALS),test)
test: clean
ifneq ($(info),0)
    echo info=1
else
    echo info=0
endif
    echo This is target [test].
clean:
    rm -f bin/*
    rm -f obj/*
endif

由于Makefile并不是一个脚本,因此条件语句的目的是通过判断来决定Makefile中的相应位置应该有什么内容。ifeqifneq分别判断相等和不等,以及功能如其名的endifelse。其中$(MAKECMDGOALS)是一个特殊的变量,它表示执行make时指定的“目标”。直接执行make clean会提示找不到目标,但执行make test就会间接执行clean,请仔细体会上面的例子。

忽略错误

在语法前加上-可以忽略错误继续执行,它既可以用于Makefile语法,例如下面要讲的include,也可以用于“命令”中,例如:

clean:
    -rmdir obj
    -rmdir bin

如果obj不存在,也不会因报错而不进行第二个rmdir

引用外部文件

使用include可以引入外部文件,除了直接引入一个外部文件,常见的用法还有:

  • 使用某个命令行参数作为引入的文件名,通过执行make时给命令行变量不同的值,让Makefile引入不同的外部文件。
  • 通过引入Makefile自己创建的文件来间接实现自己修改自己。

我们着重看一下第二点。

Objects := obj/foo.o obj/bar.o
all: $(Objects)
ifneq ($(MAKECMDGOALS),clean)
-include $(Objects:.o=.d)
endif
clean:
    rm -f bin/*
    rm -f obj/*
%.d:
    echo "$(@:.d=.o) : $(@:obj/%.d=%.c)\n\tgcc -c \$$< -o \$$@" > $@

在这个例子中,%.d规则生成了类似这样的内容的文件(bar.d):

obj/bar.o : bar.c
    gcc -c $< -o $@

还是由于Makefile的整体性,make在处理include时,虽然对应的文件不存在,但只要它是Makefile中的一个“目标”,make就有办法构建它,并在构建完成后在include语句的位置引入。

这个例子实现在了在Makefile中生成规则并引入。我们利用一个判断实现了当“目标”为clean的时候不进行include,这样在make clean时就不会进行构建.d文件这种多余的工作了。

用于生成%.d文件的echo语句看起来异常复杂,这其实都是由转义规则导致的。为了使用echo命令输出$@,需要使用\$来转义$以防止bash把$@当做变量。为了在Makefile中写上这条命令,还需要使用$$来对$转义。请参阅后文的“转义字符”一节。

函数

函数调用与变量的引用几乎完全一样,包括展开方式。若一个递归展开变量使用了函数,函数也将延迟展开。我们使用$(FUNCTION ARGUMENTS)的语法调用函数。常见的函数有:

  • $(wildcard PATTERN):通过PATTERN取文件名,它可以使用bash通配符。
  • $(shell COMMAND):在shell中调用COMMAND并将结果返回,与bash中的` `作用类似。
  • $(subst FROM,TO,TEXT):把TEXT中的FROM替换为TO
  • $(patsubst PATTERN,REPLACEMENT,TEXT):同样是替换,但可以使用%进行模式替换。
  • $(filter PATTERN...,TEXT):从TEXT中找出所有符合PATTERN的单词。
  • $(filter-out PATTERN...,TEXT):同理,过滤掉不符合PATTERN的单词。
  • $(dir NAMES…):取文件名序列NAMES…中的所有目录部分。
  • $(notdir NAMES…):同理,取出非目录部分。
  • $(basename NAMES…):同理,取出前缀部分,即最后一个.之前的部分。
  • $(suffix NAMES…):同理,取出后缀部分,即..之后的部分。
  • $(strip STRING):删掉STRING前后所有的空白字符,并将STRING中所有连续的空白字符合并成一个。

其他函数可以查阅手册(文本处理函数文件名处理函数)。

除了文本处理函数和文件名处理函数,还有一些其他的特殊函数,其实上面讲的wildcardshell都属于这类。这在后面的其他函数一节中有一些介绍。

第三个例子

假设我们有这样的一个工程,你可以下载这个例子:

.
├── debug
│   └── debug.cpp
├── defs.h
├── main.cpp
├── Makefile
└── src
    ├── command.cpp
    ├── file.cpp
    └── utils
        └── utils.cpp

所有的.cpp文件都依赖同一个头文件defs.h,只有main.cpp中含有main函数,我们希望将所有.cpp文件编译成一个可执行程序main,位于bin目录下。同时,在obj目录存放编译生成的.o文件,且具有与.cpp文件相同的目录结构,例如src/file.cpp将编译出obj/src/file.o。执行make -j可以并行编译,make run可以执行,make clean可以清空编译文件。

CXX         := g++
RM          := rm -f
MKDIR       := mkdir -p
RMDIR       := rmdir

CXXFLAGS    := -std=c++11 -O2
LDFLAGS     := 
CXXINCLUDE  := -I.

OBJDIR      := obj
BINDIR      := bin

Main        := $(BINDIR)/main
Header      := defs.h
Objects     := $(patsubst ./%.cpp, $(OBJDIR)/%.o, $(shell find . -name '*.cpp'))

Dirs        := $(BINDIR) $(shell echo $(patsubst %/,%,$(dir $(Objects))) | tr ' ' '\n' | sort -u | tr '\n' ' ')

all: $(Main)

$(OBJDIR)/%.o: %.cpp $(Header) | $(Dirs)
    $(CXX) $(CXXFLAGS) $(CXXINCLUDE) -c $< -o $@

$(Main): $(Objects) $(Header) | $(Dirs)
    $(CXX) $(CXXFLAGS) $(CXXINCLUDE) -o $@ $^ $(LDFLAGS)

$(Dirs):
    $(MKDIR) $@

run: $(Main)
    $(Main)

clean:
    $(RM) -r $(OBJDIR)
    $(RM) -r $(BINDIR)

.PHONY: all run clean

解释一下几个地方:

  • Objects变量存放我们需要的所有.o文件的信息,它通过shell函数获取了所有.cpp文件的路径和名称,把前缀的./去掉,加上了obj/,再把后缀改成.o
  • Dirs变量存放我们需要创建的所有目录的信息,虽然使用函数dir再把结尾的/去掉就可以获取目录信息,但里面会出现重复的目录。为了去重,我们利用了bash命令。由于sort -u只能针对\n分隔的字符串去重,我们还借助了tr
  • 所有的.o“目标”通过一个模式规则来匹配,它们都依赖于一个头文件,还有一个对目录的order-only依赖,用来创建目录。
  • 最终的目标bin/main依赖于所有.o文件和一个头文件,同样地,需要创建目录。在“命令”中,使用$^引用了所有“依赖”,一起链接成一个可执行程序。

易错特性

命令行变量

如果我们在执行make时,定义了命令行变量,例如make CC=gcc则是定义了变量CC,值为gcc。这时,命令行变量会覆盖Makefile中对CC的赋值。但如果在Makefile中的变量定义前加上override关键字,则是Makefile中的赋值的优先级更高。

所有bash中的变量对于Makefile都是可见的,如果你在bash中定义了CFLAGS,即使命令行和Makefile中都没有定义,你也可以正常使用它的值。但如果你在命令行或Makefile中也定义了一个CFLAGS,那将以你定义的为准,除非你在执行make时加了-e参数,该参数会使得系统环境变量覆盖掉Makefile变量,但对override变量无效,也不影响命令行变量。

优先级如下:

Makefile的override变量 > 命令行变量 > -e后的环境变量 > Makefile的普通变量 > 环境变量

转义字符

$($$)在全文都需要转义;#(\#)在全文除“命令”的部分都需要转义(在“命令”中会原封不动地交给bash处理)。%的转义比较复杂不建议使用,前面已经讲过。\不需要转义。

要注意区分Makefile的转义字符和“命令”中bash脚本的转义字符,这里说的是Makefile的转义字符。

变量中的空格

在变量的赋值号之后到第一个有效字符或换行符之前的空白字符都会忽略,但在有效字符之后的空白字符都被包含在变量中,因此不要随意在变量定义的行尾加空格。例如我们可以使用如下方法定义一个只包含一个空格的变量。

nullstring :=
space := $(nullstring) # end of the line

在这个例子中,注释写不写都是一样的,只要你在$(nullstring)后有一个空格。

行分割

类似C语言,Makefile允许在行末使用\将一行内容分割成两行。由于“命令”是交给bash执行的,因此对于“命令”和其他语法的行分割中的空白字符分别有两种处理方式。

  • 在“命令”之外,\与换行会解析成一个空格,并与周围的空格合并成一个空格。详见文档

  • 在“命令”之内,\与换行会原样传递给bash,但它后面如果是个tab则删掉一个tab详见文档

尽管第二点中的删掉tab看起来比较奇怪,但其实很容易理解,这是为了让行分割后的“命令”依然可以以tab开头,同时保持了命令内容包括空白字符在内完完整整地交给bash。因此要特别注意在“命令”的行分割时可能需要添加必要的空格。

多目标的匹配

多目标如果整体匹配时,只构建第一个目标。例如%.o %.x : %.c,执行make foo.o foo.x时,只会构建%.o。因此不建议滥用多目标。

高级用法

目录搜寻

给变量VPATH赋值可指定搜索文件的路径,用:分隔。

VPATH := src:tools:test
test/test.o : test.c
    gcc -c $< -o $@

假设test.ctest目录下,由于我们指定VPATH包含了test,即使我们写的是test.c而不是test/test.cmake也能找到它。

自动生成头文件依赖

GCC提供了解析头文件依赖的功能,加上-MM参数即可,如果想要把引入的系统头文件也输出,需要使用-M

利用这一点,配合Makefile引入外部文件修改自身的方法,就可以实现自动生成头文件依赖。

Objects := $(wildcard *.cpp)
Objects := $(Objects:.cpp=.o)
all: main
main: $(Objects)
    $(CXX) $(CXXFLAGS) -o $@ $^ $(LDFLAGS)
%.o: %.cpp
    $(CXX) $(CXXFLAGS) $(CXXINCLUDE) -c $< -o $@
%.d: %.cpp
    set -e; rm -f $@; \
    $(CXX) $(CXXFLAGS) $(CXXINCLUDE) -MM $< > $@.$$$$; \
    sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' $@.$$$$ > $@; \
    rm -f $@.$$$$
ifneq ($(MAKECMDGOALS),clean)
-include $(Objects:.o=.d)
endif
clean:
    rm -f *.o *.d main
.PHONY: all clean

这个例子的目的是编译当前目录下所有的.cpp到可执行程序main,并自动生成头文件依赖。我们通过看起来很复杂的脚本为每个.cpp生成了.d文件。例如main.d的内容可能是这样的main.o main.d : main.cpp defs.h,但其实g++生成的是main.o : main.cpp defs.h,我们是通过sed命令把main.d加了进去。之所以要这么做,是因为我们必须让.d文件也依赖对应的头文件,否则,在此头文件中新引入其他头文件不会让.d文件重新构建,这样的话,对新引入的其他头文件进行改动就不会导致“目标”被重新构建。

你可以下载第四个例子试一试。

第四个例子

假设我们有这样的一个工程,你可以下载这个例子:

.
├── main.cpp
├── Makefile
├── src
│   ├── command.cpp
│   ├── command.h
│   ├── console.cpp
│   └── console.h
├── unit_test.cpp
└── utils
    ├── database.cpp
    ├── database.h
    ├── file.cpp
    └── file.h

main.cppunit_test.cpp是两个带有main函数的程序。你想要把当前目录和下一级目录下的所有.cpp编译成.oobj目录,然后main.ounit_test.o分别与其他所有.o链接成可执行程序bin/mainbin/unit_test。每个代码可能需要include不同的源文件,因此你需要自动生成头文件依赖。为了支持gdb调试,你还想要通过make debug编译出能够调试的代码。

CXX         := g++
RM          := rm -f
MKDIR       := mkdir -p
RMDIR       := rmdir
SED         := sed

CXXFLAGS    := -std=c++11 -O2
CXXDFLAGS   := -std=c++11 -g -Wall
LDFLAGS     := 

VPATH       := src:utils
CXXINCLUDE  := $(patsubst %,-I%,$(subst :, ,$(VPATH)))

OBJDIR      := obj
BINDIR      := bin
Dirs        := $(OBJDIR) $(BINDIR)

Main        := $(BINDIR)/main
Test        := $(BINDIR)/unit_test

Objects     := $(patsubst %.cpp, $(OBJDIR)/%.o, $(notdir $(wildcard *.cpp */*.cpp)))

all: $(Main) $(Test)

$(OBJDIR)/%.o: %.cpp | $(Dirs)
    $(CXX) $(CXXFLAGS) $(CXXINCLUDE) -c $< -o $@

$(OBJDIR)/%.d: %.cpp | $(Dirs)
    @set -e; $(RM) $@; \
    $(CXX) $(CXXFLAGS) $(CXXINCLUDE) -MM $< > $@.$$$$; \
    $(SED) -i 's,\($*\)\.o[ :]*,\1.o $@ : ,g' $@.$$$$; \
    $(SED) '1s/^/$(OBJDIR)\//' < $@.$$$$ > $@; \
    $(RM) $@.$$$$

ifneq ($(MAKECMDGOALS),clean)
-include $(Objects:.o=.d)
endif

$(Main):    $(filter-out $(OBJDIR)/$(subst $(BINDIR)/,,$(Test).o), $(Objects))
    $(CXX) $(CXXFLAGS) $(CXXINCLUDE) -o $@ $^ $(LDFLAGS)

$(Test):    $(filter-out $(OBJDIR)/$(subst $(BINDIR)/,,$(Main).o), $(Objects))
    $(CXX) $(CXXFLAGS) $(CXXINCLUDE) -o $@ $^ $(LDFLAGS)

$(Dirs):
    $(MKDIR) $@

debug: CXXFLAGS := $(CXXDFLAGS)
debug: $(Main) $(Test)

run: $(Main)
    $(Main)

test: $(Test)
    $(Test)

clean:
    $(RM) $(OBJDIR)/*.o
    $(RM) $(OBJDIR)/*.d
    $(RM) $(Main)
    $(RM) $(Test)
    -$(RMDIR) $(OBJDIR)
    -$(RMDIR) $(BINDIR)

.PHONY: all debug run test clean

解释一下几个地方:

  • Objects变量存放我们需要的所有.o文件的信息,它获取了所有.cpp文件的路径和名称,把路径去掉但加上了obj/,再把后缀改成.o
  • 我们通过对VPATH赋值,设置了文件的寻找路径。由于头文件也在这些路径里,我们通过字符串替换的办法得到了用于指定头文件路径的参数,存放在变量CXXINCLUDE中。
  • 在自动生成头文件依赖时,一定要特别注意路径,因为GCC生成的.o是不带路径的,我们多用了一个sed命令手动加上。
  • 在编译main时我们通过filter-outunit_test.o去掉,unit_test同理。
  • 我们对debug“目标”设置目标指定变量。
  • set -e前有一个@关闭了命令回显,由于用了行分割,所以这个@对后面几行也有效。
  • 最后我们避免使用危险命令rm -rf,虽然正常情况不会有问题,但还是应尽量避免,防止在调试Makefile时因路径写错而出事故。

其他语法

命令回显

在“命令”的开头加上一个@,可以让make执行时不把这条“命令”输出,通常用于echo命令。

单行规则

“目标”、“依赖”和“命令”可以写在同一行,只需在“依赖”的后面加;再写“命令”。

隐含变量与隐含规则

make中存在一些隐含变量和一些隐含规则,可以查阅手册(隐含变量隐含规则)。但只要是你明确指定的变量和规则,隐含的就会失效。

其他函数

除了上文介绍的函数,make中还有一些其他的特殊函数,这些函数在文档中是没有分类的,可以在文档目录中搜索Function(主要在第八章)。

下面介绍两个还算常见的函数。

$(foreach VAR,LIST,TEXT):对LIST中的每个单词,执行TEXT表达式。在TEXT中,用VAR作为LIST中的每个单词的记号(文档)。例如:

dirs := a b c d
files := $(foreach dir,$(dirs),$(wildcard $(dir)/*))

等价于

files := $(wildcard a/* b/* c/* d/*)

$(call VARIABLE,PARAM,PARAM,...):将所有PARAM分别命名并存储为临时变量$(1)$(2)……并执行表达式VARIABLEVARIABLE中可以引用这些临时变量,它必须定义成递归展开的(文档)。例如:

reverse = $(2) $(1)
foo = $(call reverse,a,b)

其中,a会被存储到临时变量$(1)b会被存储到$(2)。将它们代入到reverse中,于是变量foo的值为b acall函数比较像在编辑器中做正则替换。做正则替换时,你需要描述你想要替换成什么。VARIABLE变量就类似于这个描述,只不过在它里面还可以使用其他函数。

上例中的$(call)语法实际上已经实现了用于生成字符串的自定义函数,但它不能实现针对“命令”的函数,也不能生成Makefile规则本身。为了实现这一点,我们需要“宏”。

宏使用define来开始定义,使用endef来结束,其行为接近于C的宏。宏既可以是命令包,也可以是多行变量

define mycmd
echo 'compling .cpp to .o using mycmd'
$(CXX) $(CXXFLAGS) $(CXXINCLUDE) -c $< -o $@
endef

在该例中,我们定义了一个名为mycmd的命令包。我们可以在“命令”部分使用与变量相同的方式进行调用,它本质上就是个变量。

%.o: %.cpp
    $(mycmd)

命令包默认是递归展开的,类似=。因此我们可以在定义的命令包中使用变量、自动化变量等。在GNU Make 4中允许指定展开类型,可以在上面链接的文档中看到例子。

利用宏的这一特性,可以结合eval函数来实现生成Makefile规则本身。文档中给出了一个较好的例子。

Guile

GNU Make 4开始支持Guile,即在Makefile中写Scheme。

报错信息

若没搞懂为什么报错,请查阅手册

此外,为了查错,可以给make-n参数,目的是只输出当前将要执行的命令但不真正执行。但它不完全保证不执行,一些特殊的规则例如“order-only依赖”是会被执行的。

如果长时间无法加载评论,请对 *.disqus.com 启用代理!