跳转至

练习 28:Makefile 进阶

原文:Exercise 28: Intermediate Makefiles

译者:飞龙

在下面的三个练习中你会创建一个项目的目录框架,用于构建之后的 C 程序。这个目录框架会在这本书中剩余的章节中使用,并且这个练习中我会涉及到 Makefile 便于你理解它。

这个结构的目的是,在不凭借配置工具的情况下,使构建中等规模的程序变得容易。如果完成了它,你会学到很多 GNU make 和一些小型 shell 脚本方面的东西。

基本的项目结构

首先要做的事情是创建一个 C 的目录框架,并且放置一些多续项目都拥有的,基本的文件和目录。这是我的目录:

$ mkdir c-skeleton
$ cd c-skeleton/
$ touch LICENSE README.md Makefile
$ mkdir bin src tests
$ cp dbg.h src/   # this is from Ex20
$ ls -l
total 8
-rw-r--r--  1 zedshaw  staff     0 Mar 31 16:38 LICENSE
-rw-r--r--  1 zedshaw  staff  1168 Apr  1 17:00 Makefile
-rw-r--r--  1 zedshaw  staff     0 Mar 31 16:38 README.md
drwxr-xr-x  2 zedshaw  staff    68 Mar 31 16:38 bin
drwxr-xr-x  2 zedshaw  staff    68 Apr  1 10:07 build
drwxr-xr-x  3 zedshaw  staff   102 Apr  3 16:28 src
drwxr-xr-x  2 zedshaw  staff    68 Mar 31 16:38 tests
$ ls -l src
total 8
-rw-r--r--  1 zedshaw  staff  982 Apr  3 16:28 dbg.h
$

之后你会看到我执行了 ls -l,所以你会看到最终结果。

下面是每个文件所做的事情:

LICENSE

如果你在项目中发布源码,你会希望包含一份协议。如果你不这么多,虽然你有代码的版权,但是通常没有人有权使用。

README.md

对你项目的简要说明。它以 .md 结尾,所以应该作为 Markdown 来解析。

Makefile

这个项目的主要构建文件。

bin/

放置可运行程序的地方。这里通常是空的,Makefile 会在这里生成程序。

build/

当值库和其它构建组件的地方。通常也是空的,Makefile 会在这里生成这些东西。

src/

放置源码的地方,通常是 .c.h 文件。

tests/

放置自动化测试的地方。

src/dbg.h

我将练习 20 的 dbg.h 复制到了这里。

我刚才分解了这个项目框架的每个组件,所以你应该明白它们怎么工作。

Makefile

我要讲到的第一件事情就是 Makefile,因为你可以从中了解其它东西的情况。这个练习的 Makeile 比之前更加详细,所以我会在你输入它之后做详细的分解。

CFLAGS=-g -O2 -Wall -Wextra -Isrc -rdynamic -DNDEBUG $(OPTFLAGS)
LIBS=-ldl $(OPTLIBS)
PREFIX?=/usr/local

SOURCES=$(wildcard src/**/*.c src/*.c)
OBJECTS=$(patsubst %.c,%.o,$(SOURCES))

TEST_SRC=$(wildcard tests/*_tests.c)
TESTS=$(patsubst %.c,%,$(TEST_SRC))

TARGET=build/libYOUR_LIBRARY.a
SO_TARGET=$(patsubst %.a,%.so,$(TARGET))


all: $(TARGET) $(SO_TARGET) tests

dev: CFLAGS=-g -Wall -Isrc -Wall -Wextra $(OPTFLAGS)
dev: all

$(TARGET): CFLAGS += -fPIC
$(TARGET): build $(OBJECTS)
       ar rcs $@ $(OBJECTS)
       ranlib $@

$(SO_TARGET): $(TARGET) $(OBJECTS)
       $(CC) -shared -o $@ $(OBJECTS)

build:
       @mkdir -p build
       @mkdir -p bin


.PHONY: tests
tests: CFLAGS += $(TARGET)
tests: $(TESTS)
       sh ./tests/runtests.sh

valgrind:
       VALGRIND="valgrind --log-file=/tmp/valgrind-%p.log" $(MAKE)


clean:
       rm -rf build $(OBJECTS) $(TESTS)
       rm -f tests/tests.log
       find . -name "*.gc*" -exec rm {} \;
       rm -rf `find . -name "*.dSYM" -print`


install: all
       install -d $(DESTDIR)/$(PREFIX)/lib/
       install $(TARGET) $(DESTDIR)/$(PREFIX)/lib/


BADFUNCS='[^_.>a-zA-Z0-9](str(n?cpy|n?cat|xfrm|n?dup|str|pbrk|tok|_)|stpn?cpy|a?sn?printf|byte_)'
check:
       @echo Files with potentially dangerous functions.
       @egrep $(BADFUNCS) $(SOURCES) || true

要记住你应该使用一致的 Tab 字符来缩进 Makefile。你的编辑器应该知道怎么做,但是如果不是这样你可以换个编辑器。没有程序员会使用一个连如此简单的事情都做不好的编辑器。

头部

这个 Makefile 设计用于构建一个库,我们之后会用到它,并且通过使用 GNU make 的特殊特性使它在任何平台上都可用。我会在这一节拆分它的每一部分,先从头部开始。

Makefile:1

这是通常的 CFLAGS,几乎每个项目都会设置,但是带有用于构建库的其它东西。你可能需要为不同平台调整它。要注意最后的 OPTFLAGS 变量可以让使用者按需扩展构建选项。

Makefile:2

用于链接库的选项,同样也允许其它人使用 OPTFLAGS 变量扩展链接选项。

Makefile:3

设置一个叫做 PREFIX 的可选变量,它只在没有 PREFIX 设置的平台上运行 Makefile 时有效。这就是 ?= 的作用。

Makefile:5

这神奇的一行通过执行 wildcard 搜索在 src/ 中所有 *.c 文件来动态创建 SOURCES 变量。你需要提供 src/**/*.csrc/*.c 以便 GNU make 能够包含 src 目录及其子目录的所有此类文件。

Makefile:6

一旦你创建了源文件列表,你可以使用 patsubst 命令获取 *.c 文件的 SOURCES 来创建目标文件的新列表。你可以告诉 patsubst 把所有 %.c 扩展为 %.o,并将它们赋给 OBJECTS

Makefile:8

再次使用 wildcard 来寻找所有用于单元测试的测试源文件。它们存放在不同的目录中。

Makefile:9

之后使用相同的 patsubst 技巧来动态获得所有 TEST 目标。其中我去掉了 .c 后缀,使整个程序使用相同的名字创建。之前我将 .c 替换为 .o 来创建目标文件。

Makefile:11

最后,我将最终目标设置为 build/libYOUR_LIBRARY.a,你可以为你实际构建的任何库来修改它。

这就是 Makefile 的头部了,但是我应该对“让其他人扩展构建”做个解释。你在运行它的时候可以这样做:

$ make PREFIX=/tmp install

$ make OPTFLAGS=-pthread

如果你传入匹配 Makefile 中相同名称的变量,它们会在构建中生效。你可以利用它来修改 Makefile 的运行方式。第一条命令改变了 PREFIX,使它安装到 /tmp。第二条设置了 OPTFLAGS,为之添加了 pthread 选项。

构建目标

我会继续 Makefile 的分解,这一部分用于构建目标文件(object file)和目标(target):

Makefile:14

要记住在没有提供目标时 make 会默认运行第一个目标。这里它叫做 all:,并且它提供了 $(TARGET) tests 作为构建目标。查看 TARGET 变量,你会发现这就是库文件,所以 all: 首先会构建出库文件。之后,tests 目标会构建单元测试。

Makefile:16

另一个用于执行“开发者构建”的目标,它介绍了一种为单一目标修改选项的技巧,如果我执行“开发构建”,我希望 CFLAGS 包含类似 Wextra 这样用于发现 bug 的选项。如果你将它们放到目标的那行中,并再编写一行来指向原始目标(这里是 all),那么它就会将改为你设置的选项。我通常将它用于在不同的平台上设置所需的不同选项。

Makefile:19

构建 TARGET 库,然而它同样使用了 15 行的技巧,向一个目标提供选项来为当前目标修改它们。这里我通过适用 += 语法为库的构建添加了 -fPIC

Makefile:20

现在这一真实目标首先创建 build 目录,之后编译所有 OBJECTS

Makefile:21

运行实际创建 TARGETar 的命令。 $@ $(OBJECTS) 语法的意思是,将当前目标的名称放在这里,并把 OBJECTS 的内容放在后面。这里 $@ 的值为 19 行的 $(TARGET),它实际上为 build/libYOUR_LIBRARY.a。看起来在这一重定向中它做了很多跟踪工作,它也有这个功能,并且你可以通过修改顶部的 TARGET,来构建一个全新的库。

Makefile:22

最后,在 TARGET 上运行 ranlib 来构建这个库。

Makefile:24-24

用于在 build/bin/ 目录不存在的条件下创建它们。之后它被 19 行引用,那里提供了 build 目标来确保 build/ 目录已创建。

你现在拥有了用于构建软件的所需的所有东西。之后我们会创建用于构建和运行单元测试的东西,来执行自动化测试。

单元测试

C 不同于其他语言,因为它更易于为每个需要测试的东西创建小型程序。一些测试框架试图模拟其他语言中的模块概念,并且执行动态加载,但是它在 C 中并不适用。这也不是必要的,因为你可以仅仅编写一个程序用于每个测试。

我接下来会涉及到 Makefile 的这一部分,并且你会看到 test/ 目录中真正起作用的内容。

Makefile:29

如果你拥有一个不是“真实”的目标,只有有个目录或者文件叫这个名字,你需要使用 g.PHONY: 标签来标记它,以便 make 忽略该文件。

Makefile:30

我使用了与修改 CFLAGS 变量相同的技巧,并且将 TARGET 添加到构建中,于是每个测试程序都会链接 TARGET 库。这里它会添加 build/libYOUR_LIBRARY.a 用于链接。

Makefile:31

之后我创建了实际的 test: 目录,它依赖于所有在 TESTS 变量中列出的程序。这一行实际上说,“Make,请使用你已知的程序构建方法,以及当前 CFLAGS 设置的内容来构建 TESTS 中的每个程序。”

Makefile:32

最后,所有 TESTS 构建完之后,会运行一个我稍后创建的简单 shell 脚本,它知道如何全部运行他们并报告它们的输出、这一行实际上运行它来让你看到测试结果。

Makefile:34-35

为了能够动态使用 Valgrind 重复运行测试,我创建了 valgrind: 标签,它设置了正确的变量并且再次运行它。它会将 Valgrind 的日志放到 /tmp/valgrind-*.log,你可以查看并了解发生了什么。之后 tests/runtests.sh 看到 VALGRIND 变量时,它会明白要在 Valgrind 下运行测试程序。

你需要为单元测试创建一个小型的 shell 脚本,它知道如何运行程序。我们开始创建这个 tests/runtests.sh 脚本:

echo "Running unit tests:"

for i in tests/*_tests
do
    if test -f $i
    then
        if $VALGRIND ./$i 2>> tests/tests.log
        then
            echo $i PASS
        else
            echo "ERROR in test $i: here's tests/tests.log"
            echo "------"
            tail tests/tests.log
            exit 1
        fi
    fi
done

echo ""

当我提到单元测试如何工作时,我会在之后用到它。

清理工具

我已经有了用于单元测试的工具,所以下一步就是创建需要重置时的清理工具。

Makefile:38

clean: 目标在我需要清理这个项目的任何时候都会执行清理。

Makefile:39-42

这会清理不同编译器和工具留下的多数垃圾。它也会移除 build/ 目录并且使用了一个技巧来清理 XCode 为调试目的而留下的 *.dSYM

如果你碰到了想要执行清理的垃圾,你只需要简单地扩展需要删除的文件列表。

安装

然后,我会需要一种安装项目的方法,对 Makefile 来说就是把构建出来的库放到通常的 PREFIX 目录下,它通常是 /usr/local/lib

Makefile:45

它会使 install: 依赖于 all: 目录,所以当你运行 make install 之后也会先确保一切都已构建。

Makefile:46

接下来我使用 install 程序来创建 lib 目标的目录。其中我通过使用两个为安装者提供便利的变量,尝试让安装尽可能灵活。 DESTDIR 交给安装者,便于在安全或者特定的目录里执行自己的构建。 PREFIX 在别人想要将项目安装到其它目录而不是 /user/local 时会被使用。

Makefile:47

在此之后我使用 insyall 来实际安装这个库,到它需要安装的地方。

install 程序的目的是确保这些事情都设置了正确的权限。当你运行 make install 时你通常使用 root 权限来执行,所以通常的构建过程应为 make && sudo make install

检查工具

Makefile 的最后一部分是个额外的部分,我把它包含在我的 C 项目中用于发现任何使用 C 中“危险”函数的情况。这些函数是字符串函数和另一些“不保护栈”的函数。

Makefile:50

设置变量,它是个稍大的正则表达式,用于检索类似 strcpy 的危险函数。

Makefile:51

这是 check: 目标,使你能够随时执行检查。

Makefile:52

它只是一个打印信息的方式,使用了 @echo 来告诉 make 不要打印命令,只需打印输出。

Makefile:53

对源文件运行 egrep 命令来寻找任何危险的字符串。最后的 || true 是一种方法,用于防止 make 认为 egrep 没有找到任何东西是执行失败。

当你执行它之后,它会表现得十分奇怪,如果没有任何危险的函数,你会得到一个错误。

你会看到什么

我在完成这个项目框架目录的构建之前,还设置了两个额外的练习。下面这是我对 Makefile 特性的测试结果:

$ make clean
rm -rf build
rm -f tests/tests.log
find . -name "*.gc*" -exec rm {} \;
rm -rf `find . -name "*.dSYM" -print`
$ make check
Files with potentially dangerous functions.
^Cmake: *** [check] Interrupt: 2

$ make
ar rcs build/libYOUR_LIBRARY.a
ar: no archive members specified
usage:  ar -d [-TLsv] archive file ...
      ar -m [-TLsv] archive file ...
      ar -m [-abiTLsv] position archive file ...
      ar -p [-TLsv] archive [file ...]
      ar -q [-cTLsv] archive file ...
      ar -r [-cuTLsv] archive file ...
      ar -r [-abciuTLsv] position archive file ...
      ar -t [-TLsv] archive [file ...]
      ar -x [-ouTLsv] archive [file ...]
make: *** [build/libYOUR_LIBRARY.a] Error 1
$ make valgrind
VALGRIND="valgrind --log-file=/tmp/valgrind-%p.log" make
ar rcs build/libYOUR_LIBRARY.a
ar: no archive members specified
usage:  ar -d [-TLsv] archive file ...
      ar -m [-TLsv] archive file ...
      ar -m [-abiTLsv] position archive file ...
      ar -p [-TLsv] archive [file ...]
      ar -q [-cTLsv] archive file ...
      ar -r [-cuTLsv] archive file ...
      ar -r [-abciuTLsv] position archive file ...
      ar -t [-TLsv] archive [file ...]
      ar -x [-ouTLsv] archive [file ...]
make[1]: *** [build/libYOUR_LIBRARY.a] Error 1
make: *** [valgrind] Error 2
$

当我运行 clean: 目标时它会生效,但是由于我在 src/ 目录中并没有任何源文件,其它命令并没有真正起作用。我会在下个练习中补完它。

附加题

  • 尝试通过将源文件和头文件添加进 src/,来使 Makefile 真正起作用,并且构建出库文件。在源文件中不应该需要 main 函数。
  • 研究 check: 目标会使用 BADFUNCS 的正则表达式来寻找什么函数。
  • 如果你没有做过自动化测试,查询有关资料为以后做准备。