使用GNU-make管理项目

在本文中读者会看到有关 make 的介绍,make 是一种控制编译或者重复编译软件的工具。make 可以自动管理软件编译的内容、方式和时机,从而使程序员能够把精力集中在编写代码上。

使用 GNU make 管理项目

第 4 章 使用 GNU make 管理项目
原文出处:《GNU/Linux编程指南》第二版
原文作者: Kurt Wall 等著

1 为何使用 make

除了最简单的软件项目,make 对于其他所有项目而言都很必要。首先,包含多个源代码文件的项月在编译时都有长而且复杂的命令行。而且,编程项目经常需要使用那些很少用到且难以记忆的特殊编译选项。make 可以通过把这些复杂而难记的命令行保存在 makefle 文件中来解决上述两个问题,makefile 将在下一小节讨论。

make 还能减少重复编译所需要的时间,因为它很聪明,能够判断哪些文件被修改过。进而只重新编译程序被修改过的部分。makefile 为项目构建了一个依赖信息数据库,因而可以让 make 在每次编译前检查是否可以找到所有需要的文件。make 还可以让你建立一个稳定的编译环境。最后,make 可以让编译过程自动执行,因为从 shell 脚本或者 cron (定时)作业调用 make 非常容易。

2 编写 makefile

make 是怎样完成这些神奇工作的呢?是通过使用 makefile 文件做到的。

makefile 是一个文本形式的数据库文件,其中包含一些规则告诉 make 编译哪些文件、怎样编译以及在什么条件下去编译。每条规则包含以下内容:

  • 一个 "目标体" (target),即 make 最终需要创建的东西。
  • 包含一个或多个 "依赖体" (dependency)的列表,依赖体通常是编泽目标体需要 的其他文件。
  • 为了从指定的依赖体创建出目标体所需执行的 "命令" (command) 的列表。

虽然目标体通常都是程序,但它们可以是诸如文本文件、手册页面等任何东西。目标体甚至能测试和设置环境变量。类似地,也可以定义依赖体以确保编译开始前存在某个特殊的环境变量。最后,makefile 中的命令可以是编译器的命令或 shell 命令,它们能设置环境变量、删除文件,或者任何俞令行所能完成的功能,如从 FTP 站点下载文件等。GNU make 被调用后会顺序查找名为 GNUmakefile、makefile 或 Makefile 的文件。出于某种原因,可能只是习惯和长期形成的约定吧,大多数 Linux 程序员使用最后一种形式 Makefile。

Makefile 规则有下列通用形式:

1
2
3
4
target: dependency [dependency [...]
command
command
[...]

警告:

每一个命令的第一个字符必须是制表符,仗使用 8 个空格是不够的。这一点经常不被人们注意, 并且当所使用的编辑器友好的将制表符转换成 8 个空格时,会产生问题;因为如果用空格代替制表符,make 会在执行过程中显示 Missing Separator (缺少分隔符)并停止。

target 是需要创建的二进制文件或目标文件。dependency 是在创建 target 时需要输入的一个或多个文件的列表。命令序列是创建 target 文件所需要的步骤,如编译命令。此外,除非特别指定,否则 make 的工作目录就是当前目录。

3 编写 makefile 的规则

如果上一节的内容对你来说太抽象, 那么本节使用程序清单 4.1 再具体讨论。这是用于编译第 3 章中出现的程序 howdy 和 hello 的 makefile 文件。

程序清单 4.1 演示目标体、依赖体和命令的简单 makefile 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
howdy: howdy.o helper.o helper.h
gcc howdy.o helper.o -o howdy

helper.o: helper.c helper.h
gcc -c helper.c

howdy.o: howdy.c
gcc -c howdy.c

hello: hello.c
gcc hello.c -o hello

all: howdy hello

clean:
rm howdy hello *.o

要编译 howdy,只需在 makefile 所在目录下输入 make 即可。就这么简单。

这个 makefile 文件包含 6 条规则。第一个目标体 howdy 称为默认(default) 目标体,这是 make 要创建的文件。howdy 有 3 个依赖体,分别为 howdy.o、helper.o 和 helper.h,要编译生成 howdy,必须要有这 3 个文件。第二行调用编译器的命令供 make 执行来创建 howdy。由对第 3 章内容的回忆可知,这条命令从两个目标文件创建名为 howdy 的可执行文件。把头文件 helper.h 作为一个依赖体列入是为了避免编译器调用未声明的函数产生出错信息。接下来的两条规则告诉 make 怎样生成单个目标文件,helper.o 和 howdy.o。这些规则使用了 gcc 的 -c 选项,只创建目标文件但跳过链接。如果只想生成两个目标文件而不生成 howdy 本身,可以使用下面两条命令:

1
2
$ make helper.o
$ make howdy.o

更简洁一点,只需使用

1
$ make helper.o howdy.o

正如你所看到的那样,make 允许把多个目标作为参数。这两种方法都能使用相应的规则和命令生成目标文件。图 4.1 给出了这个过程的图示。

图4.1 把生成 howdy 的步骤归结到第 3 章讨论的一般性的 预处理/编译/链接 过程上。howdy.c 和 helper.c 这两个源代码文件经预处理后编译成目标文件。然后链接器把来自文件 howdy.o 和 helper.o 的目标代码和标准库以及 C 启动代码链接到一起生成二进制文件 hello。

compile

现在,make 的价值就体现出来了:通常情况下,如果试图在依赖体 helper.o 和 howdy.o 不存在的情况下使用所示的命令编译 howdy,则 gcc 会报错井退出。另一方面,在看到 howdy 需要这两个文件(和 helper.c)后,make 先确认它们是否存在,如果不存在则首先执行命令生成它们,然后再返回到第一条规则创建可执行文件 howdy。当然,如果 helper.h 不存在,make 也会放弃执行,因为它没有创建 helper.h 的规则。

"一切都很好",也许你会这么想,"但是 make 怎样知道什么时候需要重新编译一个文件呢?" 答案极其简单:如果指定的目标文件 make 找不到,make 就会生成它。如果目标体存在,make 会对目标体文件和依赖体文件的时间戳进行比较。如果有一个或多个依赖体比目标体新,make 就重新编译生成目标体,因为 make 认为新的依赖体意味着对代码做过修改,必须把改动融入到目标体中去。

第四条规则相当简单。它定义了如何编译生成第 3 章介绍的简单程序 hello。第五条是创建 hello 和 howdy 的笼统规则,它还表明甚至二进制文件都能作依赖体。下一小节将讨论第六条规则,即伪目标。

3.1 伪目标

除了一般的文件目标体, 比如 howdy 和 hello 之外,make 也允许指定伪目标。称其为伪目标是因为它们并不对应于实际的文件。程序清单 4.1 中最后一个目标体 clean 就是伪目标。伪目标体规定了 make 应该执行的命令。但是,因为 clean 没有依赖体,所以它的命令不会被自动执行。下面解释 make 是如何工作的:当遇到目标体 clean 时,make 先查看其是否有依赖体,因为 clean 没有依赖体,所以 make 认为目标体是最新的而不执行任何操作。为了编译这个目标体,必须输入make clean。在本例中,clean 删除了可执行文件 hello 和 howdy 以及构成 howdy 的目标文件。在创建和发行仅包含源代码的压缩包或者需要彻底重新编译时可能会用到这样一个目标体。

然而,如果恰巧有一个名为 clean 的文件存在,make 就会发现它。然后和前面一样,因为 clean 没有依赖体文件,make 就认为这个文件是最新的而不会执行相关命令。为了处理这类情况,需要使用特殊的 make 目标体 .PH0NY.PHONY 的依赖体文件的含义和通常一样, 但是 make 不检查是否存在有文件名和依赖体中的一个名字相匹配的文件,而是直接执行与之相关的命令。在使用了 .PHONY 之后,前面的例子如下:

程序清单 4.2 带有 PHONY 目标的 Makefile 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
howdy: howdy.o helper.o helper.h
gcc howdy.o helper.o -o howdy

helper.o: helper.c helper.h
gcc -c helper.c

howdy.o: howdy.c
gcc -c howdy.c

hello: hello.c
gcc hello.c -o hello

all: howdy hello

.PHONY: clean

clean:
rm howdy hello *.o

3.2 变量

为了简化编辑和维护 makefile,make 允许在 makefile 中创建和使用变量。所谓的变量其实是用指定文本串在 makefile 中定义的一个名字,这个文本串就是变量的值。下面是定义变量的一般方法:

1
VARNAME=some_text [...]

把变量用括号括起来,井在前面加上 "$" 符号,就可以引用变量的值:

1
$(VARNAME)

此时,VARNAME 在等式右端展开为它所代表的文本。变量一般都在 makefle 的头部定义,并且,按照惯例,所有的 makefle 变量都应该是大写( 虽然这不是必须的)。这样,如果变量的值发生变化,就只需要在一个地方修改,从而简化了 makefile 的维护。现在,继续现在修改程序清单 4.1,加入两个变量,结果如程序清单 4.3 所示。

程序清单 4.3 在 makefle 中使用变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
OBJS = howdy.o helper.o
HDRS = helper.h

howdy: $(OBJS) $(HDRS)
gcc $(OBJS) -o howdy

helper.o: helper.c $(HDRS)
gcc -c helper.c

howdy.o: howdy.c
gcc -c howdy.c

hello: hello.c
gcc hello.c -o hello

all: howdy hello

clean:
rm howdy hello *.o

OBJS 和 HDRS 在被引用的每个地方都展开成它的取值。编译时也是如此。

实际上,make 使用两种变量:递归展开变量和简单展开变量。递归展开变量在引用时逐层展开,即如果在展开式中包含了对其他变量的引用,则这些变量也将被展开,直到没有需要展开的变量为止,这就是所谓的递归展开。下面的例子有助于弄清这个概念。

假设变量 TOPDIR 和 SRCDIR 如下定义:

1
2
TOPDIR = /home/kwall/myproject
SRCDIR = $(TOPDIR)/src

这样,SRCDIR 的值是 /home/kwall/myproject/src,则工作正常。但是,考虑下面的变量定义:

1
2
CC = gcc
CC = $(CC) -o

很清楚,定义者想要得到的结果是 "CC=gcc -o"。但是实际并非如此;CC 在被引用时递归展开,从而陷入一个无限循环中;CC 将扩展为 $(CC) 的值,从而永远也读不到 -o 选项。幸运的是,make 能够检测到这个问题并报告错误:

1
*** Recursive variable 'CC' references itself (eventually). Stop

为了避免这个问题,可以使用简单展开变量。与递归展开变量在引用时展开不同,简单展开变量在定义处展开,并且只展开一次,从而消除了变量的嵌套引用。在定义时,其语法与递归展开变量有细微的不同:

1
2
CC := gcc -o
CC += -O2

第一个定义使用 := 设置 CC 的值为 gcc -o,第二个定义使用 "+=" 在前面定义的 CC 后附加了 -O2,从而 CC 最终的值是 gcc -o -O2。如果在使用 make 变量时遇到 "VARNAME references itself" 这类错误信息,就可以使用简单展开变量来解决。一些程序员仅使用简单展开变量,以避免出现意想不到的问题;但既然现在是在 Linux 上,你可以自由选择使用的方式。

除用户定义变量外, make 也允许使用环境变量、自动变量和预定义变量。使用环境变量非常简单。在启动时,make 读取己定义的环境变量,并且创建与之同名同值的变量。但是,如果 makefile 中有同名的变量,则这个变量将取代与之相应的环境变量,所以应当注意这一点。

此外,make 也提供一长串预定义变量和自动变量,但是它们看起来有些神秘。之所以称为自动变量是因为 make 自动用特定的、熟知的值替换它们。表 4.1 给出了部分自动变量。

表 4.1 自动变量

变量 说明
$@ 规则的目标所对应的文件名
$< 规则中的第一个相关文件名
$^ 规则中所有相关文件的列表,以空格为分隔符
$? 规则中日期新于目标的所有相关文件的列表,以空格为分隔符
$(@D) 目标文件的目录部分(如果目标在子目录中)
$(@F) 目标文件的文件名部分(如果目标在子目录中)

除了表 4.1 列出的自动变量外,make 还预定义了许多其他变量,用于定义程序名或给这些程序传递标志和参数。这些预定义的变量看上去更像常规的 make 变量而不是像字符名称的自动变量。表 4.2 给出了一些有用的预定义变量。

表4.2 用于程序名和标志的预定义变量

变量 说明
AR 归档维护程序,默认值=ar
AS 汇编程序,默认值=as
CC C 编译程序,默认值=cc
CPP C 预处理程序,默认值=cpp
RM 文件删除程序,默认值="rm -f"
ARFLAGS 传给归档维护程序的标志,默认值=rv
ASFLAGS 传给汇编程序的标志,没有默认值
CFLAGS 传给 C 编译器的标志,没有默认值
CPPFLAGS 传给 C 预处理程序的标志,没有默认值
LDFLAGS 传给链接程序(Id)的标志,没有默认值

如果需要,可以在 makefile 中重新定义这些变量,但是在大多数情况下,这些默认值都是合理的。

3.3 隐式规则

除了在 makefile 文件中显式指定的规则(称为显式规则)外,make 还有一整套隐式规则,或称为预定义规则。这些规则多数有特殊目的而且用途有限,所以在这里只介绍几种最常用的隐式规则。隐式规则简化了 makefile 的编写和维护。

假设有下面这样的一个 makefile:

1
2
3
4
5
6
7
8
9
OBJS = editor.o screen.o keyboard.o

editor: $(OBJS)
cc -o editor $(OBJS)

.PHONY: clean

clean:
rm editor $(OBJS)

默认目标 editor 所对应的命令提及了 editor.o,screen.o 和 keyboard.o,但是 makefile 中没有怎样编译生成这些目标的规则。此时,make 就使用所谓的隐式规则,实际上,对每一个名为 somefile.o 的目标(object)文件,make 首先找到与之相应的源代码 somefile.c,并且用 gcc -c somefile.c -o somefile.o 编译生成这个目标文件。所以,在本例中 make 先查找名 为 editor.c,screen.c 和 keyboard.c 的文件并将它们编译为目标文件(editor.o,screen.o 和 keyboard.o),然后,编译生成默认目标 editor。

实际的机制比这里所描述的要全面。目标文件(.o) 可以从 C、Pascal 和 Fortran 等源代码中生成,所以 make 也应去查找符合实际情况的相关文件。所以,如果在工作目录下有 editor.p,screen.p 和 keyboard.p 三个 Pascal 文件(p 通常被认为是 Pascal 源代码的扩展名),make 就会激活 Pascal 编译器来编译它们,而不用 C 编译器。因此,如果出于某种原因而在项目中需要使用多种语言时,就不能依靠隐式规则,因为此时使用该规则所得到的结果可能会与期望的有所不同。

3.4 模式规则

通过定义用户自己的隐式规则,模式规则提供了扩展 make 的隐式规则的一种方法。模式规则类似于普通规则,但是它的目标必定含有符号 "%",这个符号可以与任何非空字符串匹配;为与目标中的 "%" 匹配,这个规则的相关文件部分也必须使用 "%"。例如,下面的规则:

1
%.o: %.c

告诉 make 所有形为 somename.o 的目标(object)文件都应从源文件 somename.c 编译而来。

与隐式规则一样, make 预定义了一些模式规则:

1
2
%.o: %.c
$(CC) -c $(CFLAGS) $(CPPFLAGS) $< -o $@

与前面的例子相同,make 定义了一条规则,即任何 x.o 的文件都从 x.c 编译而来。每次使用该规则时,该规则用自动变量 $<$@ 来代替第一个依赖体和目标体。此外,变量 $(CC)$(CFLAGS)$(CPPFLAGS) 的默认值如表 4.2 所示。

3.5 注释

在 makefile 中插入注释时,必须在注释前加上符号 "#"。make 读到 "#" 后,它忽略该符号以及这一行余下的字母。注释可以出现在 makefile 的所有地方。但是,因为多数 shell 把 "#" 看作是元符号(通常也是注释符),所以在命令中加入注释时要特别小心。此外,实际上就 make 本身而言,一个只含注释的行就是一个空行。

4 命令行选项和参数

同多数 GNU 程序一样,make 也有丰富的命令行选项。表 4.3 列出了最常用的部分。

表 4.3 常用的 make 俞令行选项

选项 说明
-f file 指定 makefile 的文件名
-n 打印将需要执行的命令,但实际上并不执行这些命令
-Idirname 指定被包含的 makefile 所在的目录
-s 在执行时不打印命令名
-w 如果 make 在执行时改变目录,打印当前目录名
-Wfile 如果文件己修改,则使用 -n 来显示 make 将要执行的命令
-r 禁止使用所有 make 的内置规则
-d 打印调试信息
-i 忽略 makefile 规则中的命令执行后返回的非零错误码。此时,即使某个命令返回非零的退出状态值,make 仍将继续执行
-k 如果某个目标编译失败,继续编译其他目标。通常,make 在一个目标编译失败后终止
jN 每次运行 N 条命令,这里 N 是非零整数

5 调试 make

如果在使用 make 时遇到问题,-d 选项能够使 make 在执行命令时打印大量的额外调试信息。此时,因为需要显示 make 内部所做的每一件事以及为什么做这些事的调试信息,将会产生大量的输出。其中包括如下信息:

  • 在重新编译时 make 需要检查的文件
  • 被比较的文件以及比较的结果
  • 需要被重新生成的文件
  • make 想要使用的隐式规则
  • make 实际使用的隐式规则以及所执行的命令

6 常见的 make 出错信息

这里列出使用 make 时可能遇到的最常用的出错信息,完整文档诮参见 make 使用手册或其信息页。

  • No rule to make target 'target'. Stop makefile 中没有包含创建指定的 target 所需要的规则,而且也没有合适的默认规则可用。
  • 'target' is up to date 指定 target 的相关文件没有变化。
  • Target 'target' not remade because of errors 在编译 target 时出错,这一消息仅在使用make的 -k 选项时才会出现。
  • command: Command not found make 找不到命令。递常是因为命令被拼写错误或者不在路径 $PATH 下。
  • Illegal option -option 在调用 make 时包含了不能被 make 识别的选项。

7 有用的 makefile 目标

除了前面提及的 clean,编写 makefile 时还有一些常用的目标。名为 install 的目标把最终的二进制文件,所支持的库文件或 shell 脚本,以及相关的文档移动到文件系统中与之相应的最终位置,并适当设置文件的权限和属主。此外,install 通常也编译程序,以及运行简单的测试以确认程序已正确编译。uninstall 目标则删除由 install 目标所安装的那些文件。如果需要,在设置 install 目标前存储系统当前的设置。

dist 目标可以用来生成准备发布的软件包。最低限度,dist 目标将删除编译工作目录中旧的二进制文件和目标文件并创建一个归档文件(如普通的压缩包),以便上传到万维网页或FTP站点。

为了方便其他开发者,可以用一个 tags 目标来创建或更新程序的标记表。如果程序的验证过程比较复杂,也可以创建一个 单独的 test 或 check 目标来执行这一过程并显示适当的诊断信息。与之类似,installtest 或 installcheck 目标,通常被用来验证安装过程。当然,在此之前,install 目标必须已经成功地编译和安装了所需的程序。