持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第13天,点击查看活动详情
Make 作为 Unix 系统中最常用的构建工具,在 C 语言项目中非常常见。它能根据指定的Shell命令进行构建。规则也很简单,开发者规定要构建哪个文件、它依赖哪些源文件,当那些文件有变动时,如何重新构建它。
Makefile 则提供了 make 命令执行的模板。一个工程中的源文件不计数,其按类型、功能、模块分别放在若干个目录中,Makefile 定义了一系列的规则来指定,哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作。
但实际上,作为业务开发目前已经很少用 make 的原始用途构建文件了。今天这篇我们来聊聊,如何用好 Makefile 和 make 命令,帮助我们管理好项目工程里的指令。
什么是 Makefile
make命令执行时,需要一个 Makefile 文件,以告诉make命令需要怎么样的去编译和链接程序。构建规则都写在Makefile文件里面,要学会如何Make命令,就必须学会如何编写Makefile文件。
这里其实 make 命令会识别 makefile 和 Makefile,首字母是否大写均可。甚至可以通过命令参数指定为其他文件:
$ make -f rules.txt
# 或者
$ make --file=rules.txt
Makefile文件由一系列规构成。每条规则的形式如下。
target ... : prerequisites ...
command
...
...
-
target: 目标通常是文件名,指明Make命令所要构建的对象,它可以是一个文件名,也可以是多个文件名,之间用空格分隔。
-
prerequisites: 前置条件通常是一组文件名,之间用空格分隔。它指定了"目标"是否重新构建的判断标准:只要有一个前置文件不存在,或者有过更新(前置文件的last-modification时间戳比目标的时间戳新),"目标"就需要重新构建。
-
command: 该target要执行的命令(任意的shell命令),可以是一行或者多行,每行命令之前必须有一个tab键。
上面三个分类,target 是必须的,prerequisites 和 command 二者至少要存在一个。
当然,上面的规则是基于【文件】的,从命令的角度看,我们的 target 不必是文件名,而是一个操作的名称,如:
clean:
rm a.txt
这是一个极简版的 Makefile,作用很简单,调用 rm 命令删除掉 a.txt 文件,这个时候的 clean 显然就不是文件名了,而是一个操作。事实上,我们还可以定义成: remove, delete, cleanup 等等。
如果碰巧,当前目录下有一个 clean 文件,这里就会撞车,make 命令会认为文件已经存在,不会进入执行 command。这种情况下我们可以将其声明为 phony:
.PHONY: clean
clean:
rm *.o temp
这样 make 就知道每次都需要执行 clean 下的 command。
管理多个命令
如果仅仅只有一个命令需要执行,其实直接写一个 bash 脚本就 ok,但很多时候我们需要区分场景,比如有时候我们希望编译项目,有时候希望执行单测,或是跑一下 linter。这个时候用多个 bash 脚本就显得很不方便。
事实上,我们要执行的命令通常也并不复杂,这个时候用 Makefile 来管理是非常方便的,示例:
test:
go test -gcflags=all=-l ./...
bench:
@go test -bench=. -benchtime=5s
在这个 Makefile 中我们定义了两个 target,第一个 test 是执行单测,第二个 bench 是执行基准测试。
保存后,我们只需要分别运行:
make testmake bench
就可以分别执行二者的命令。如果留空,没有指定 target,默认会执行Makefile文件的第一个目标。
$ make
这样和 make test 在这个场景里是等价的。
使用 prerequisites 解决依赖
前面我们知道,所谓 prerequisites 是前提条件的意思,通常是一组文件名,用空格分隔。只要有一个前置文件不存在,或者有过更新(前置文件的last-modification时间戳比目标的时间戳新),"目标"就需要重新构建。
a.txt: b.txt
cp b.txt a.txt
在这个示例中,target 是 a.txt, prerequisites 是 b.txt。也就意味着,如果目录下 b.txt 已经存在,运行 make a.txt 是没问题的,否则由于缺少前提条件,这里是无法执行的。报错信息:
make: *** No rule to make target `b.txt', needed by `a.txt'. Stop.
但有的命令就是存在一些前置的条件,比如这里我就是没有 b.txt,但我可以提供生成 b.txt 的命令,make 能够解决这个问题呢?
当然,这就是 prerequisites 的定位。
我们可以对上面的 Makefile 进行扩展:
a.txt: b.txt
cp b.txt a.txt
b.txt:
echo "this is tony" > b.txt
注意,b.txt 作为 target 的这条规则里,没有 prerequisites,所以可以独立存在。只要 b.txt 不存在,那么执行 make b.txt 时就一定会调用这里的 echo 命令。
现在我们重新执行 make a.txt,make 就会探测到,哦,b.txt 还不存在,那么就会先进入 b.txt 作为 target 的命令,通过 echo 创建出 b.txt,随后回到原命令,执行 cp 的后续操作。
有了这个前提,我们来思考一个真实案例。
现在我们有一个 Golang 项目工程:
- 我们需要通过一些代码生成工具,生成测试代码 ;
- 基于测试代码编译出来二进制的可执行文件;
- 运行可执行文件来验证测试结果。
对应的 Makefile 如下:
test: build_test
./execute test
build_test: generate_test
go build -gcflags '-N -l' -tags 'test' -o output
chmod 755 output
generate_test:
codegen ./...
要运行 test 前,先看 build_test 文件是否存在,这里当然不存在,我们是依赖命令的能力,在 build_test 中不会真正生成这个同名文件。
所以,开始看 build_test 作为 target 的规则,发现前置依赖 generate_test,进而发现这个依赖有对应的 target。
最后会出现的情况是,我们执行 make test 的时候,会先执行 generate_test 里的 codegen,随后进入 build_test 的 go build,chmod。最后进入 test 自己的 ./execute test。
这样就把依赖项串联起来了。
当然,你也可以实际去依赖文件,比如 conf 目录下的文件有更新,则需要重新构建,我们可以这样写:
build: conf/*
go build -gcflags '-N -l' -tags 'test' -o output
Makefile 同样也支持通配符,比如 *,?,...。这里的 conf/* 就代表 conf 目录下的所有文件,同理。*.txt 代表所有以 txt 结尾的文件。
多行命令
target 对应的 command 是可以多行的,我们在上面的 build_test 中就看到过,但注意。这里每行命令在一个单独的shell中执行。这些Shell之间没有继承关系。
有些时候我们就是希望前后两条命令在一个进程中执行,不希望被拆开,这个时候有两种处理办法:
- 分号分隔
一个解决办法是将两行命令写在一行,中间用分号分隔。
var-kept:
export foo=bar; echo "foo=[$$foo]"
- 反斜杠转义
var-kept:
export foo=bar; \
echo "foo=[$$foo]"
注释
Makefile 中的注释用 # 即可:
# 这是注释
result.txt: source.txt
# 这是注释
cp source.txt result.txt # 这也是注释
禁止 echo
默认情况下 make 是会把所有执行的命令都先打印出后,随后执行,如果我们不希望这种自动 echo,可以类似上面 bench 的例子,在 command 前面加上 @ 即可
bench:
@go test -bench=. -benchtime=5s
变量
Makefile 支持大家用 = 来进行赋值,调用的时候,变量放到 $() 中即可。
txt = Hello World
test:
@echo $(txt)
这里我们就把 Hello World 赋值给了 txt 这个变量,并在 test 这个 target 中被 echo 出来。
使用 ifndef 进行条件判断
ifndef variable-nameIf the variable
variable-namehas an empty value, thetext-if-trueis effective; otherwise, thetext-if-false, if any, is effective. The rules for expansion and testing ofvariable-nameare identical to theifdefdirective.
ifndef 能够判断后面的变量是否为空,借用这个能力,我们可以在 Makefile 里做一些基础判断,示例:
ifndef $(GOPATH)
GOPATH=$(shell go env GOPATH)
export GOPATH
endif
在随后也可以继续使用 $(GOPATH) 这个变量。
这里还引用了另外的一个能力,我们可以利用 shell 函数来执行 shell 命令,如此处的 go env GOPATH。
srcfiles := $(shell echo src/{00..99}.txt)
小结
Makefile 的能力非常强大,建议大家多探索一下,项目里常见的命令我们都可以封装到 Makefile 中,降低执行脚本的心智负担。这里我们只是个入门,把最常用的一些场景梳理了下,希望可以帮助到大家。感谢阅读!
更多使用细节大家可以参照下面的资料: