5

这篇文章以我的一个小项目为例,阐述了面向 GNU Make 的 Makefile 文件的基本写法。由于我未认真阅读 GNU Make 的文档,并且对于符合 POSIX 标准的 Makefile 格式并不了解,所以我写的 Makefile 可能不甚严肃,还请擅长此道者不吝赐教。

我的小项目

这个项目基于 C 语言实现,但由于我是文式编程爱好者,项目文档与代码交织在扩展名为 .orz 的源文件中。在构建项目时,需要使用我自己写的文式编程处理工具 orez 从 .orz 文件中提取 C 代码,然后用 gcc 进行编译。

项目名曰 agn,其目录结构如下:

agn
├── orez-src   # 存放 .orz 文件
└── src        # 存放由 .orz 文件中提取的 C 源码

从 .orz 文件中提取 C 源码

先看一下 agn/orez-src 目录中有哪些文件:

$ cd agn/orez-src
$ ls
agn_arena.orz          agn_km.orz              agn_simplex.orz
agn_array.orz          agn_linear_algebra.orz  agn_tree.orz
agn_build.orz          agn_list.orz            agn_types.orz
agn_delaunay_mesh.orz  agn_point.orz           agn_vector.orz
agn_hash_table.orz     agn_points.orz
agn_kd_tree.orz        agn_pqueue.orz

以 agn_array.orz 为例,使用以下命令从中提取 C 源码文件:

$ orez -t -e ang_array.h ang_array.orz
$ orez -t -e agn_array.c agn_array.orz

对于每份 .orz 文件,皆可采用以上方式提取相应的 C 源码文件,唯独 agn_build.orz 文件例外,因为它未包含 C 源码。

下面来看如何为这件事在 agn/orez-src 目录内写一份 Makefile。

Makefile 的基本知识

Makefile 本质上是一棵树,它的每个结点称为「目标」,每个结点的子结点称为「依赖」。例如:

all: agn_arena.h agn_arena.c \
     agn_array.h agn_array.c \
     agn_hash_table.h agn_hash_table.c \
     ... ... ...

Makefile 中的第一个目标就是 Makefile 所描述的树的根结点。

目标 all 的依赖是要从上述除 agn_build.orz 之外的所有 .orz 文件中提取的 .h 与 .c 文件。反斜线是续行符。若不用续行符,就只能将所有依赖写在同一行。

all 的每个依赖本身也是 Makefile 所描述的树的结点,它们也可能有子结点(依赖)。例如:

agn_arena.h: agn_arena.orz
agn_arena.c: agn_arena.orz
... ... ...

agn_arena.h 与 agn_arena.c 都依赖 agn_arena.orz。同理,agn_arena.orz 也有依赖,不过它的依赖有些特殊,它的依赖是我,因为所有的 .orz 是我手写的。由于我不能真正地将我放在 Makefile 里作为 .orz 文件的依赖,因此依赖链就到 .orz 文件为止,它们就构成了 Makefile 所描述的树的叶结点。

我的任务是要用 Makefile 从 .orz 文件中提取 .h 与 .c 文件,这个任务需要由附加在 Makefile 目标上的命令来执行。Makefile 允许在每个目标的下面放置一组 Shell 命令。例如,可以将从 agn_array.orz 文件中提取 agn_array.h 与 agn_array.c 的命令分别放在 agn_array.hagn_array.c 这两个目标的下面:

agn_arena.h: agn_arena.orz
    orez -t -e ang_array.h ang_array.orz
agn_arena.c: agn_arena.orz
    orez -t -e ang_array.c ang_array.orz

每条命令之前要用 Tab 键进行缩进。记住,一定用 Tab 键,不要用空格!一定用 Tab 键,不要用空格!一定用 Tab 键,不要用空格!

基于目标、依赖以及命令,便可以写出 Makefile 的一条条规则。这些规则的格式如下:

目标: [依赖列表]
<Tab 缩进>[命令]

规则中的依赖列表与命令是可选的。

有耐心的人写的 Makefile

有了上述基本知识,我就可以写出可以从一组 .orz 文件提取 C 源码的 Makefile:

all: agn_arena.h agn_arena.c \
     agn_array.h agn_array.c \
     agn_delaunay_mesh.h agn_delaunay_mesh.c \
     agn_hash_table.h agn_hash_table.c \
     agn_kd_tree.h agn_kd_tree.c \
     agn_km.h agn_km.c \
     agn_linear_algebra.h agn_linear_algebra.c \
     agn_list.h agn_list.c \
     agn_point.h agn_point.c \
     agn_points.h agn_points.c \
     agn_pqueue.h agn_pqueue.c \
     agn_simplex.h agn_simplex.c \
     agn_tree.h agn_tree.c \
     agn_types.h agn_types.c \
     agn_vector.h agn_vector.c
agn_agn_arena.h: agn_agn_arena.orz
    orez -t -e agn_agn_arena.h agn_agn_arena.orz
agn_agn_arena.c: agn_agn_arena.orz
    orez -t -e agn_agn_arena.c agn_agn_arena.orz
agn_agn_array.h: agn_agn_array.orz
    orez -t -e agn_agn_array.h agn_agn_array.orz
agn_agn_array.c: agn_agn_array.orz
    orez -t -e agn_agn_array.c agn_agn_array.orz
agn_agn_delaunay_mesh.h: agn_agn_delaunay_mesh.orz
    orez -t -e agn_agn_delaunay_mesh.h agn_agn_delaunay_mesh.orz
agn_agn_delaunay_mesh.c: agn_agn_delaunay_mesh.orz
    orez -t -e agn_agn_delaunay_mesh.c agn_agn_delaunay_mesh.orz
agn_agn_hash_table.h: agn_agn_hash_table.orz
    orez -t -e agn_agn_hash_table.h agn_agn_hash_table.orz
agn_agn_hash_table.c: agn_agn_hash_table.orz
    orez -t -e agn_agn_hash_table.c agn_agn_hash_table.orz
agn_agn_kd_tree.h: agn_agn_kd_tree.orz
    orez -t -e agn_agn_kd_tree.h agn_agn_kd_tree.orz
agn_agn_kd_tree.c: agn_agn_kd_tree.orz
    orez -t -e agn_agn_kd_tree.c agn_agn_kd_tree.orz
agn_agn_km.h: agn_agn_km.orz
    orez -t -e agn_agn_km.h agn_agn_km.orz
agn_agn_km.c: agn_agn_km.orz
    orez -t -e agn_agn_km.c agn_agn_km.orz
agn_agn_linear_algebra.h: agn_agn_linear_algebra.orz
    orez -t -e agn_agn_linear_algebra.h agn_agn_linear_algebra.orz
agn_agn_linear_algebra.c: agn_agn_linear_algebra.orz
    orez -t -e agn_agn_linear_algebra.c agn_agn_linear_algebra.orz
agn_agn_list.h: agn_agn_list.orz
    orez -t -e agn_agn_list.h agn_agn_list.orz
agn_agn_list.c: agn_agn_list.orz
    orez -t -e agn_agn_list.c agn_agn_list.orz
agn_agn_point.h: agn_agn_point.orz
    orez -t -e agn_agn_point.h agn_agn_point.orz
agn_agn_point.c: agn_agn_point.orz
    orez -t -e agn_agn_point.c agn_agn_point.orz
agn_agn_points.h: agn_agn_points.orz
    orez -t -e agn_agn_points.h agn_agn_points.orz
agn_agn_points.c: agn_agn_points.orz
    orez -t -e agn_agn_points.c agn_agn_points.orz
agn_agn_pqueue.h: agn_agn_pqueue.orz
    orez -t -e agn_agn_pqueue.h agn_agn_pqueue.orz
agn_agn_pqueue.c: agn_agn_pqueue.orz
    orez -t -e agn_agn_pqueue.c agn_agn_pqueue.orz
agn_agn_simplex.h: agn_agn_simplex.orz
    orez -t -e agn_agn_simplex.h agn_agn_simplex.orz
agn_agn_simplex.c: agn_agn_simplex.orz
    orez -t -e agn_agn_simplex.c agn_agn_simplex.orz
agn_agn_tree.h: agn_agn_tree.orz
    orez -t -e agn_agn_tree.h agn_agn_tree.orz
agn_agn_tree.c: agn_agn_tree.orz
    orez -t -e agn_agn_tree.c agn_agn_tree.orz
agn_agn_types.h: agn_agn_types.orz
    orez -t -e agn_agn_types.h agn_agn_types.orz
agn_agn_types.c: agn_agn_types.orz
    orez -t -e agn_agn_types.c agn_agn_types.orz
agn_agn_vector.h: agn_agn_vector.orz
    orez -t -e agn_agn_vector.h agn_agn_vector.orz
agn_agn_vector.c: agn_agn_vector.orz
    orez -t -e agn_agn_vector.c agn_agn_vector.orz

一个人,无论他是不是写 Makefile,当他看到上面这份 Makefile 的时候,可能会笑起来,这肯定是世界上最糟糕的程序员写出来的 Makefile。虽然很糟糕,但是却很能体现耐心。

将这份 Makefile 放在 agn/orez-src 目录,然后在该目录中执行

$ make

便可从每份 .orz 文件中抽取相应的 .h 与 .c 文件。

模式规则

上一节那份很冗长的 Makefile 中,所有的 .h 与 .c 目标与它们的 .orz 依赖,它们的名称除后缀不同之外,其他部分是相同的。因此,所有的 .h 与 .c 目标都在重复下面这个模式:

%.h: %.orz
    命令
%.c: %.orz
    命令

在 Makefile 中,上述形式的目标与依赖称为模式规则。利用模式规则,可将所有的 .h 与 .c 目标对应的规则替换为上述两条规则。

模式规则虽然能够大幅简化 Makefile,但是带来一个新的问题,在模式规则中怎么写命令?在普通规则中,目标与依赖的名称都是确定的,由依赖产生目标的命令也是确定的,例如:

agn_array.h: agn_array.orz
    orez -t -e ang_array.h ang_array.orz

若将其改写成模式规则,是像下面这样吗?

%.h: %.orz
    orez -t -e %.h %.orz

这是错的!

模式规则中的命令,在使用目标与依赖的名称时,需要使用 Makefile 规定的符号。指代目标名称的符号是 $@。依赖可能是一个列表,$< 符号指代依赖列表中第一个依赖。这两个符号也可以在普通规则中使用。请记住它们!

上述那个错误的模式规则应改为:

%.h: %.orz
    orez -t -e $@ $<

基于上述知识,现在给出大幅简化的 Makefile 的完整内容:

all: agn_arena.h agn_arena.c \
     agn_array.h agn_array.c \
     agn_delaunay_mesh.h agn_delaunay_mesh.c \
     agn_hash_table.h agn_hash_table.c \
     agn_kd_tree.h agn_kd_tree.c \
     agn_km.h agn_km.c \
     agn_linear_algebra.h agn_linear_algebra.c \
     agn_list.h agn_list.c \
     agn_point.h agn_point.c \
     agn_points.h agn_points.c \
     agn_pqueue.h agn_pqueue.c \
     agn_simplex.h agn_simplex.c \
     agn_tree.h agn_tree.c \
     agn_types.h agn_types.c \
     agn_vector.h agn_vector.c

%.h: %.orz
    orez -t -e $@ $<
%.c: %.orz
    orez -t -e $@ $<

文本中的规律

上一节最后给出的 Makefile,看上去依然很不舒服。既然那么多的 .h 与 .c 目标对应的模式规则语句可以一举简化为 2 行语句,那么有什么理由让 all 目标的依赖如此庞大?此外,还有一个问题,all 目标的所有依赖都是硬性的,在向 orez-src 目录增加或删除 .orz 文件时,皆需手动维护 all 目标的依赖列表。这两个问题可归结为一个问题,如何自动生成这个依赖列表?要解决这个问题,方法只有一个,即找规律。有了规律,就可以做自动化。

all 目标的所有依赖都是要从 .orz 文件中抽取的 .h 与 .c 文件,并且这些 .h 与 .c 的文件名与 .orz 文件名相比,只有扩展名不同。用下面这份 Bash 脚本可以很容易生成这些依赖:

#!/bin/bash
echo -n "all: "
for i in $(ls *.orz)
do
    if [ "$i" = "agn_build.orz" ]
    then
        continue
    fi
    target=${i%%.orz}
    echo -n " ${target}.h ${target}.c"
done
echo ""

原理就是用 ls 命令获取当前目录下所有扩展名为 .orz 的文件,剩下的事情就是用字符串替换的把戏,将 .orz 的文件名去掉 .orz 后缀,然后再给它加上 .h.c 后缀。注意,按前文所述,不需要从 agn_build.orz 中产生 .h 与 .c 文件,因此上述 Bash 代码在生成依赖列表时,忽略了 agn_build.orz。

这样的小把戏,Makefile 也能做,而且更为简单。Makefile 中可以使用 Make 工具(GNU Make)提供的内建的函数 wildcard,它的功能类似于上述 Bash 代码中的 ls *.orz,只不过它会将所得结果返回给一个 Makefile 变量。例如:

orz_files = $(wildcard *.orz)

$(wildcard *.orz) 便是对 wildcard 函数的调用,*.orz 是传给这个函数的参数。wildcard 的返回结果被保存于变量 orz_files 中。

上述 Makefile 语句之所以不具备排除 agn_build.orz 的功能,是因为 $(wildcard *.orz) 的返回结果是 Makefile 所在目录中的全部 .orz 文件名,因此 agn_build.orz 会被包含在内。可以从 $(wildcard *.orz) 的返回结果中剔除 agn_build.orz 解决这个问题,不过这需要调用 Make 内建函数 subst 来实现,即:

orz_files = $(subst agn_build.orz,,$(wildcard *.orz))

subst 是一个多参数的函数,参数之间以 , 间隔,且 , 与参数之间不能有空格。这个函数可将一个字符串(第三个参数)中的部分字符串(第一个参数)替换为指定的字符串(第二个参数)。上述 Makefile 语句可以解读为,将 $(wildcard *.orz) 返回的字符串中的 agn_build.orz 删除(替换为空字符串)。

现在,已经能够获得 Makefile 所在目录内除 agn_build.orz 之外的所有 .orz 文件名并将其表存在一个变量中,接下来要解决的问题是基于这些 .orz 文件名生成 .h 与 .c 文件名。这个问题可以继续用 subst 函数来解决,即:

c_head_files = $(subst .orz,.h,$(orz_files))
c_files = $(subst .orz,.c,$(orz_files))

上述两行语句对 orz_files 变量中的 .orz 分别替换为 .h.c。注意,在 Makefile 中使用一个变量类似于调用一个没有参数的函数。

注:用 patsubst 函数更稳妥一些,因为它支持通配符。例如,c_files = $(patsubst %.orz,%.c,$(orz_files))

如果只是对变量中的字符进行替换,Makefile 提供了更简洁的等价写法:

c_head_files = $(orz_files:.orz=.h)
c_files = $(orz_files:.orz=.c)

利用上述知识,可以写出一份很简洁的 Makefile,它能够基于除 agn_build.orz 之外所有的 .orz 文件生成 C 源码文件:

orz_files = $(subst agn_build.orz,,$(wildcard *.orz))
c_head_files = $(orz_files:.orz=.h)
c_files = $(orz_files:.orz=.c)

all: $(c_head_files) $(c_files)

%.h: %.orz
    orez -t -e $@ $<

%.c: %.orz
    orez -t -e $@ $<

增加一些命令

基于 .orz 产生的 .h 与 .c 文件需要转移到 agn/src 目录,向模式规则中增加两条文件复制的命令来解决这一问题:

%.h: %.orz
    orez -t -e $@ $<
    cp $@ ../src

%.c: %.orz
    orez -t -e $@ $<
    cp $@ ../src

每条命令之前要用 Tab 键进行缩进。记住,一定用 Tab 键,不要用空格!一定用 Tab 键,不要用空格!一定用 Tab 键,不要用空格!

若想删除 .orz 生成的所有 .h 与 .c 文件,可以再增加一条规则:

clean:
    rm -f $(c_head_files) $(c_files)

在 Makefile 所在目录,执行以下命令便可令上述删除 .h 与 .c 文件的规则产生作用:

$ make clean

增加了上述功能的 Makefile 的完整内容如下:

orz_files = $(subst agn_build.orz,,$(wildcard *.orz))
c_head_files = $(orz_files:.orz=.h)
c_files = $(orz_files:.orz=.c)

all: $(c_head_files) $(c_files)

%.h: %.orz
    orez -t -e $@ $<
    cp $@ ../src

%.c: %.orz
    orez -t -e $@ $<
    cp $@ ../src

clean:
    rm -f $(c_head_files) $(c_files)

目标与依赖皆是带时间状态的文件

来做一个小游戏。先用上一节的 Makefile 生成 .h 与 .c 文件,然后在 Makefile 所在目录增加一个名为 clean 的文件,再执行 make clean 命令,亦即在 agn/orez-src 目录内执行以下命令:

$ make
$ touch clean
$ make clean

结果会发生什么?

make clean 命令失效了,它没法删除基于 .orz 文件生成的那些 .h 与 .c 文件,只得到下面这样的提示:

make: 'clean' is up to date.

发生了什么?

Make 工具会将 Makefile 中的 clean 目标当作那份新建的 clean 文件了。由于在 Makefile 中,clean 目标没有任何依赖,而这份目标对应的文件已经存在,因此 Make 工具就会认为不需要为 clean 目标做什么。

现在要记住 Makefile 的第一要义,目标与依赖皆为文件,再记住第二要义,若目标比依赖新,Make 就拒绝执行目标所在规则中的命令

看下面的模式规则:

%.h: %.orz
    orez -t -e $@ $<
    cp $@ ../src

%.h%.orz 都对应着与 Makefile 在同一目录的文件,若 %.h%.orz 新,那么这条规则中的所有命令都不会被 Make 工具执行。

现在,再来看一开始那个小游戏。由于 Makefile 中的 clean 目标与 Makefile 同一目录下的 clean 文件重名,因此 Make 工具便认为 Makefile 中的 clean 与那份真实的 clean 文件对应,又由于 clean 目标没有任何依赖,这意味着它永远都处于最新的状态,从而导致该条规则中的命令不会被 Make 工具执行。

若想消除 Makefile 中的 clean 目标与名为 clean 的文件的关联,需要借助 Makefile 的伪目标,即 .PHONY

.PHONY: clean
clean:
    rm -f $(c_head_files) $(c_files)

.PHONY 是 Makefile 的内建目标。Make 工具遇到该目标时,就会知道该目标的依赖都不是文件,或者都是伪目标。

下面这份完整的 Makefile 能够解决上面遇到的全部问题。

orz_files = $(subst agn_build.orz,,$(wildcard *.orz))
c_head_files = $(orz_files:.orz=.h)
c_files = $(orz_files:.orz=.c)

all: $(c_head_files) $(c_files)

%.h: %.orz
    orez -t -e $@ $<
    cp $@ ../src

%.c: %.orz
    orez -t -e $@ $<
    cp $@ ../src

.PHONY: clean

clean:
    rm -f $(c_head_files) $(c_files)

C 源码文件的编译

现在可以去 agn/src 目录,为编译 .c 与 .h 文件而得到可执行文件而写一份 Makefile 了。写这份 Makefile,上文中所提供的 Makefile 知识已经足够,真正需要的知识是怎么用 gcc 编译 C 程序以及 Linux 系统(或类 Unix 系统)的标准目录结构。下面直接给出这份 Makefile 的全部内容:

CC = gcc
CFLAGS = -std=c11 -pedantic -Werror -O2 -fPIC -pipe -I./
LDFLAGS = -shared -lm -lgsl -lgslcblas -lqhull_r
INSTALL = /usr/bin/install -c

prefix=/usr/local
includedir=$(prefix)/include
libdir=$(prefix)/lib

lib_header_files = $(wildcard *.h)
c_files = $(wildcard *.c)
objects = $(c_files:.c=.o)

libagn.so: $(objects)
    $(CC) $(LDFLAGS) $^ -o libagn.so

%.o: %.c
    $(CC) $(CFLAGS) -c $^ -o $@

.PHONY: install uninstall clean

install:
    mkdir -p $(includedir)/agn
    cp $(lib_header_files) $(includedir)/agn
    $(INSTALL) libagn.so $(libdir)

uninstall:
    rm -rf $(includedir)/agn
    rm -f $(libdir)/libagn.so

clean:
    rm -f $(objects) libagn.so

项目顶级目录中的 Makefile

现在,agn 目录的两个子目录 orez-src 与 src 中都有了一份 Makefile。要想构建整个 agn 项目,可以先到 orez-src 目录执行一次 make 命令,然后再到 src 目录执行一次 make 命令,但是这样做有些繁琐。为了方便,可以在 agn 目录写一份 Makefile,通过它来完成这些任务。

写这份 Makefile 也不需要再多的 Makefile 知识,上述知识足够用。这份 Makefile 就像是一个用户界面:

agn_host_ip = 192.168.3.7

.PHONY: all install uninstall clean update-orez

all:
    cd orez-src; make
    cd src; make

install:
    cd src; make install

uninstall:
    cd src; make uninstall

clean:
    cd orez-src; make clean
    cd src; make clean

update-orez:
    rsync -av $(agn_host_ip)::agn-orez-files orez-src/

这份 Makefile 除了能够完成从 .orz 文件生成 C 源码文件以及编译 C 源码的任务之外,还能够在局域网中从一台指定的机器上通过 rsync 工具同步最新的 .orz 文件的功能。

结束语

许多年前,我基本上掌握了 GNU Autotools 的用法,还写过一份可能是国内最好的面向初学者的 GNU Autotools 不完全指南,但是现在我却在手写 Makefile。没有大项目,而且对于非 C 项目而言,掌握 GNU Autotools 近似于掌握屠龙之技。

像 GNU Autotools 与 CMake 这样的工具,可能对于比较大的 C/C++ 项目较为合适,但也不尽然。诸如 Linux 内核与 Android 系统,它们的构建系统直接基于 Makefile 实现。大项目真正困难的应该是大项目本身,而不是手写 Makefile 这种小事。

等真正有一天,我觉得手写 Makefile 很繁琐的时候,再去考虑使用 GNU Autotools 或 CMake 或那些所谓的更为现代的项目构建工具。


garfileo
6k 声望1.9k 粉丝

这里可能不会再更新了。