前言
之前用了很久 GNU Make,但一直没有仔细整理,这篇文章整理一下相关内容。
这篇会尽量同时满足两种阅读方式:
- 如果是刚开始接触 Makefile,可以从头顺着看
- 如果已经写过一些 Makefile,也可以把它当一份入门向的快速手册
这一篇先只讲 GNU Make 的基础部分,重点放在:
make到底在干什么- 一条规则到底由什么组成
- 为什么有时候只改一个
.c文件,Make 就只会重编那一部分 - 变量、自动变量、伪目标、模式规则这些最常见内容怎么用
虽然这一篇先从基础讲起,但我还是会把 条件判断、函数、自动依赖 这些很常见的内容一起整理进来。
不然很多真实项目里的 Makefile,读到一半其实还是会断。
先有一个整体印象
如果先只用一句话概括 GNU Make,我觉得可以这样理解:
Make 不是编译器,它是一个“根据依赖关系决定要不要执行命令”的工具。
比如我们平时在写 C/C++ 项目时,会有这样一串关系:
hello.c -> hello.o -> hello
main.c -> main.o -> hello
Makefile 的作用,就是把这种“谁依赖谁、要怎么生成”的关系写出来。
然后每次执行:
make
GNU Make 就会做几件事:
- 读取当前目录下的 Makefile
- 找到默认目标
- 检查目标和依赖文件的修改时间
- 只执行那些确实需要重新执行的命令
所以 Make 最核心的一点其实不是“帮你编译”,而是:
它帮你判断哪些东西该重新做,哪些东西不用动。
最小的使用流程
如果是最常见的使用方式,流程通常就是下面这样:
- 准备源代码
- 编写
Makefile - 在命令行执行
make
例如:
make
make clean
其中:
make通常表示构建默认目标make clean通常表示清理中间文件或产物
一条最简单的 Makefile
先看一个最小例子:
hello: hello.c
gcc hello.c -o hello
它的意思很直接:
- 目标是
hello hello依赖hello.c- 如果需要更新
hello,就执行gcc hello.c -o hello
这里有一个非常经典的坑:
配方行前面必须是 Tab,不是空格。
很多人第一次写 Makefile 报错,问题就卡在这里。
一条规则到底长什么样
Makefile 最核心的组成单位就是“规则(rule)”。
一条规则通常长这样:
target: prerequisites
recipe
也就是:
target:目标prerequisites:先决条件,也常直接叫依赖recipe:要执行的命令
可以粗略理解成:
“如果要得到
target,并且prerequisites有变化,那就执行下面这条命令。”
例如:
hello.o: hello.c hello.h
gcc -c hello.c -o hello.o
它表示:
hello.o依赖hello.c和hello.h- 只要这两个文件里有任意一个比
hello.o更新,或者hello.o根本不存在 - 那么就重新编译
hello.o
Make 到底是怎么判断“要不要重新构建”的
初学 Makefile 时,最值得先搞清楚的就是这一点。
对于“目标是文件”的规则,GNU Make 主要看的是文件修改时间。
可以先记住这个最基础的判断方式:
- 如果目标文件不存在,就执行配方
- 如果某个依赖文件比目标文件新,就执行配方
- 否则就不执行
例如:
hello: hello.o
gcc hello.o -o hello
hello.o: hello.c
gcc -c hello.c -o hello.o
假设现在 hello 和 hello.o 都已经生成好了。
这时如果你只改了 hello.c,再执行:
make
通常会发生的事情是:
make发现默认目标是hellohello依赖hello.o,所以先看hello.ohello.o依赖hello.c,而hello.c比hello.o更新- 所以先重新编译
hello.o - 因为
hello.o变新了,所以再重新链接hello
也就是说,Make 本质上是在沿着依赖图往下检查,再按需要往上重建。
为什么通常要把“编译”和“链接”分开
刚开始写时,很多人会先写成这样:
hello: main.c hello.c
gcc main.c hello.c -o hello
它当然能工作,但有一个问题:
只要 main.c 或 hello.c 任何一个改了,整个程序都会重新从头编译并链接。
如果项目很小,这还好。
但文件一多,这种写法就不太合适了。更常见的做法是拆成两层:
hello: main.o hello.o
gcc main.o hello.o -o hello
main.o: main.c hello.h
gcc -c main.c -o main.o
hello.o: hello.c hello.h
gcc -c hello.c -o hello.o
这样做的好处是:
- 改
main.c时,只需要重编main.o - 改
hello.c时,只需要重编hello.o - 最后再把新的
.o文件链接成可执行文件
这也是 Make 在 C/C++ 项目里特别常见的原因之一:
它非常适合描述“多个源文件分别编译,最后统一链接”的流程。
默认目标是谁
通常直接执行:
make
GNU Make 会尝试构建 第一条规则的目标。
所以很多 Makefile 都会把总目标放在最前面,例如:
all: hello
hello: main.o hello.o
gcc main.o hello.o -o hello
这时默认目标就是 all。
也正因为如此,很多项目会把:
all:
当成一个总入口。
make 默认会找哪个 Makefile
平时我们直接敲:
make
很多时候并没有显式告诉它“你该读哪个文件”。
GNU Make 默认会按这个顺序查找:
GNUmakefilemakefileMakefile
不过一般还是更推荐用:
Makefile
原因也很简单:
- 这是最常见的名字
- 目录列表里也比较显眼
- 不会像
GNUmakefile那样带太强的“只给 GNU Make 用”的意味
如果你确实想手动指定文件,也可以写:
make -f MyMakefile
make --file=MyMakefile
这一点在切换不同构建脚本、或者做实验性 Makefile 时挺有用。
先看一个稍微完整一点的例子
下面这个例子比前面稍微像样一点:
app: main.o calc.o
gcc main.o calc.o -o app
main.o: main.c calc.h
gcc -c main.c -o main.o
calc.o: calc.c calc.h
gcc -c calc.c -o calc.o
clean:
rm -f app main.o calc.o
如果只看结构,这里已经出现了 Makefile 里最常见的几种目标:
- 真正的构建目标,比如
app - 中间目标,比如
main.o、calc.o - 操作型目标,比如
clean
这个版本已经能正常工作,但还不够“像 GNU Make”。
因为里面还有不少重复内容可以继续提炼。
用变量减少重复
写 Makefile 时,一个很常见的问题就是重复。
例如下面这条规则里,目标文件列表写了两遍:
app: main.o calc.o
gcc main.o calc.o -o app
文件少的时候还好,文件一多就很容易漏改。
所以通常会先提一个变量出来:
OBJS = main.o calc.o
app: $(OBJS)
gcc $(OBJS) -o app
main.o: main.c calc.h
gcc -c main.c -o main.o
calc.o: calc.c calc.h
gcc -c calc.c -o calc.o
clean:
rm -f app $(OBJS)
这里的 $(OBJS) 就是变量引用。
如果你只记一种写法,记这个就够了:
NAME = value
$(NAME)
= 和 := 可以先粗略怎么区分
GNU Make 的变量展开细节其实不少,但入门时我觉得先抓最常见的区别就够用了。
最常见的两种赋值方式是:
VAR = value
VAR2 := value
可以先粗略理解成:
=:递归展开,等到变量真正被用到时再展开:=:立即展开,定义时就把右边结果算出来
看一个很小的例子:
A = $(B)
B = hello
C := $(D)
D := world
那么:
$(A)最后会得到hello$(C)在定义时D还没有值,所以结果通常是空
所以如果只是日常写简单 Makefile:
- 想要“后面改变量,前面引用也跟着变”,可以多用
= - 想要“这里现在是多少就固定成多少”,可以用
:=
?=:给变量一个“默认值”
除了 = 和 :=,还有一种在工程 Makefile 里很常见的写法:
PREFIX ?= /usr/local
它可以粗略理解成:
如果 PREFIX 还没有被设置,那就给它一个默认值。
这类写法常见在:
- 安装路径
- 编译器选择
- 一些允许用户覆写的配置项
例如:
CC ?= gcc
PREFIX ?= /usr/local
这样 Makefile 自己有一套默认行为,但使用者又可以按需改掉。
命令行也可以直接覆盖变量
GNU Make 一个很实用的地方是:
你可以在命令行里直接给变量赋值。
例如:
make CC=clang
make CFLAGS='-O0 -g'
make PREFIX=/tmp install
这在日常里非常方便,因为它能让 Makefile 保持通用,同时又不必为了某次临时构建去修改文件本身。
如果和前面的 ?= 放在一起看,会更容易理解它的用途:
- Makefile 里先给出默认值
- 真正构建时,按环境需要临时覆盖
所以像:
PREFIX ?= /usr/local
就很适合配合:
make PREFIX=/opt/app install
一起使用。
GNU Make 内置了很多隐式规则
前面我们是这样写的:
main.o: main.c calc.h
gcc -c main.c -o main.o
calc.o: calc.c calc.h
gcc -c calc.c -o calc.o
但对于 .c -> .o 这种太常见的事情,GNU Make 本来就知道怎么做。
也就是说,你很多时候其实可以只写依赖,不必把编译命令重复写一遍:
OBJS = main.o calc.o
app: $(OBJS)
gcc $(OBJS) -o app
main.o: calc.h
calc.o: calc.h
这里之所以能工作,是因为 GNU Make 有内置的隐式规则,会尝试推断:
main.o <- main.c
calc.o <- calc.c
如果项目规则比较标准,这种写法会简洁很多。
不过也正因为是“自动推断”,所以当行为不符合预期时,排查起来会稍微绕一点。
初学阶段如果觉得不安心,先把命令显式写出来也完全可以。
常见的内置变量
GNU Make 除了内置规则,还约定了一些常用变量名。
在 C/C++ 项目里最常见的有:
CC = gcc
CXX = g++
CFLAGS = -O2 -Wall
CXXFLAGS = -O2 -Wall
CPPFLAGS = -Iinclude
LDFLAGS =
LDLIBS =
可以先粗略记成:
CC:C 编译器CXX:C++ 编译器CFLAGS/CXXFLAGS:编译选项CPPFLAGS:预处理相关选项,例如头文件搜索路径LDFLAGS:链接阶段选项LDLIBS:链接库,例如-lm
例如:
CC = gcc
CPPFLAGS = -Iinclude
CFLAGS = -O2 -Wall
LDLIBS = -lm
app: main.o calc.o
$(CC) main.o calc.o -o app $(LDLIBS)
这样后面如果换编译器、加优化选项、改头文件路径,改起来会轻松很多。
自动变量:把“当前这条规则里的东西”拿出来用
写 Makefile 时,自动变量特别常用。
最常见的几个是:
$@:当前规则的目标$<:当前规则的第一个先决条件$^:当前规则的所有先决条件(去重后)
先看最常见的两个例子:
hello: hello.o main.o
gcc $^ -o $@
hello.o: hello.c
gcc -c $< -o $@
它们展开后大概就是:
- 第一条里的
$@是hello - 第一条里的
$^是hello.o main.o - 第二条里的
$<是hello.c - 第二条里的
$@是hello.o
所以自动变量最大的意义其实就是:
少重复写文件名,让规则更通用。
用模式规则一次处理一批 .c -> .o
如果每个 .o 文件都手写一遍,还是会有点烦。
这时通常会进一步写成模式规则:
CC = gcc
CPPFLAGS = -Iinclude
CFLAGS = -O2 -Wall
OBJS = main.o calc.o
app: $(OBJS)
$(CC) $^ -o $@
%.o: %.c
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
clean:
rm -f app $(OBJS)
这里:
%.o: %.c表示“任何.o文件都可以由同名.c文件生成”%匹配的那部分通常叫 stem
例如要生成 main.o 时:
- 目标模式
%.o匹配到main.o %对应的部分是main- 所以依赖
%.c就变成了main.c
这类规则在 GNU Make 里非常重要。
因为很多时候你真正想表达的并不是“这个文件怎么编”,而是“这类文件都按这个办法编”。
一个更像日常项目的入门版 Makefile
如果把前面的东西揉到一起,一个比较常见、也比较适合入门项目起步的版本,大概会长这样:
CC = gcc
CPPFLAGS = -Iinclude
CFLAGS = -O2 -Wall
LDLIBS = -lm
TARGET = app
SRCS = main.c calc.c
OBJS = $(SRCS:.c=.o)
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $^ -o $@ $(LDLIBS)
%.o: %.c calc.h
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
clean:
rm -f $(TARGET) $(OBJS)
这里又出现了一个常见写法:
OBJS = $(SRCS:.c=.o)
它的意思是把 SRCS 里的每个 .c 替换成 .o。
如果:
SRCS = main.c calc.c
那么:
OBJS = main.o calc.o
这也是 GNU Make 很常见的一种“变量替换引用”。
.PHONY 是什么,为什么 clean 常常要配它
像 clean、all 这种目标,很多时候并不真的对应某个文件。
它们更像“动作名称”。
这类目标通常会写成伪目标:
.PHONY: clean
clean:
rm -f app *.o
为什么需要 .PHONY?
因为如果当前目录下刚好真的有一个文件叫 clean,那么 Make 可能会以为这个目标已经是“最新的”,从而不执行对应命令。
加上:
.PHONY: clean
之后,GNU Make 就知道:
clean 不是一个真实文件,而是一个始终可以执行的操作目标。
通常常见的伪目标有:
allcleanruntestinstall
make clean 为什么不会默认执行
这一点初学时也挺容易混。
像下面这种规则:
clean:
rm -f app *.o
并不会在你直接执行 make 时自动跑。
因为 make 默认只会从默认目标开始,沿着它的依赖关系往下做事。
而 clean 通常既不是第一条规则,也不是其他目标的依赖,所以它不会自动执行。
只有你明确写:
make clean
它才会去执行 clean 这个目标。
依赖不一定都是“重建条件”
GNU Make 里有一个初学阶段不一定马上用到,但知道了很有帮助的概念:
order-only prerequisites(仅顺序依赖)。
普通依赖有两层含义:
- 先把依赖处理好
- 如果依赖比目标新,就重建目标
但有时候我们只想保留第一层,不想要第二层。
最典型的例子就是“输出目录必须先存在,但目录时间戳变化不应该导致所有目标重编”。
例如:
OBJDIR = build
OBJS = build/main.o build/calc.o
app: $(OBJS)
gcc $^ -o $@
build/%.o: %.c | $(OBJDIR)
gcc -c $< -o $@
$(OBJDIR):
mkdir -p $(OBJDIR)
这里:
| $(OBJDIR)
表示 $(OBJDIR) 是一个仅顺序依赖:
- 构建
build/%.o之前,要先确保目录存在 - 但不会因为目录时间戳变了,就把所有
.o都判定为过期
这个写法在稍微正规一点的工程里很常见。
通配符和 wildcard
Makefile 里经常会看到 *,但它不是在任何地方都一样好使。
例如在规则里:
clean:
rm -f *.o
这里的 *.o 是 shell 在执行命令时展开的。
但如果你在变量定义里直接写:
SRCS = *.c
那它通常不会自动变成当前目录下所有 .c 文件。
这时更常见的写法是:
SRCS := $(wildcard *.c)
OBJS := $(SRCS:.c=.o)
其中:
$(wildcard *.c):找出当前目录下所有.c文件- 再把它们替换成对应的
.o
如果项目文件数量固定,我一般更倾向于手写文件列表。
如果项目文件会经常增删,再考虑 wildcard 会更省事一点。
几个很实用的 make 命令行选项
前面主要都在讲 Makefile 怎么写。
但实际使用 GNU Make 时,命令行参数也很重要,尤其是在排错和确认行为的时候。
我觉得入门阶段最值得先记住的是下面这些:
make -n:只看会执行什么,不真正执行
make -n
make -n clean
这个参数也叫 --just-print 或 --dry-run。
它的作用是:
- 把本来要执行的命令打印出来
- 但不真正执行
这在两种场景里特别有用:
- 你刚写完 Makefile,想先确认规则会不会跑歪
- 你要执行一个清理、安装或者批量构建动作,想先“演练”一遍
如果把它和前面讲的依赖关系一起看,其实 make -n 很适合拿来观察:
- 哪些目标会被重建
- 它们会按什么顺序执行
- 最后到底会跑哪些命令
make -B:强制全部重建
make -B
这个参数是 --always-make。
它的意思可以粗略理解成:
别看时间戳了,相关目标都按需要重做一遍。
它通常适合:
- 你怀疑依赖关系没写对,想强制完整构建一次
- 你改了某些不会明显反映在时间戳判断里的东西,想保险一点
当然,这样做的代价也很直接:会慢一些。
make -d 和 make --trace:排查 Make 到底在想什么
如果你写 Makefile 时遇到这种问题:
- 为什么这个目标没重建
- 为什么它重建了我以为不该重建的东西
- 为什么它套用了某条隐式规则
那就可以看看:
make -d
make --trace
其中:
make -d会输出很详细的调试信息make --trace相对更聚焦一些,会告诉你目标为什么被处理、执行了哪些命令
我自己会更建议先试 --trace,因为 -d 的输出通常非常多。
如果 --trace 还不够,再上 -d 会更容易看。
make -j:并行执行
make -j4
这个参数表示允许同时跑多个任务。
对于多文件编译项目来说,如果依赖关系写得合理,-j 往往能明显加快构建速度。
不过这里也有一个前提:
Makefile 里的依赖关系必须写对。
因为并行构建会把一些原来“串行时碰巧没出问题”的隐藏依赖暴露出来。
所以刚开始学习时,我更建议先把普通串行构建逻辑写顺,再去加:
make -j
make -C:先切到别的目录再构建
make -C src
这个参数的意思是:
- 先进入
src目录 - 再在那个目录里读取 Makefile 并执行构建
如果你在上层目录想调用子目录里的构建逻辑,这个参数会很方便。
make -p:把 Make 当前看到的规则和变量打印出来
make -p
这个参数会打印 Make 读完 Makefile 之后形成的数据库,包括:
- 规则
- 变量
- 很多内置定义
它的输出会很多,但在你怀疑:
- 某个变量最后到底变成了什么
- 内置规则是不是参与进来了
的时候会很有帮助。
make --warn-undefined-variables
make --warn-undefined-variables
这个参数会在引用未定义变量时给出警告。
它特别适合抓这类问题:
- 变量名拼错了
- 你以为某个变量有值,实际上根本没定义
Makefile 里变量很多的时候,这类错误其实挺隐蔽的,所以这个选项很好用。
include:把别的 Makefile 片段读进来
GNU Make 支持:
include other.mk
它的作用很像“把另一个文件内容插进来继续读”。
这个功能很常见的用途有:
- 把通用变量拆到单独文件里
- 把不同模块的规则拆开
- 自动包含依赖文件,例如
.d文件
不过作为入门阶段,我觉得先知道它的存在就够了。
如果一开始就把 Makefile 拆太散,反而不利于理解主线。
从“单文件示例”走向“项目骨架”
前面那些例子主要是为了讲清楚 Make 的基本机制。
但一旦开始把它用于实际项目,通常就不只是“一个可执行文件 + 几个 .c 文件”这么简单了。
比较常见的目录组织方式大概像这样:
project/
├── Makefile
├── src/
├── include/
├── tests/
├── build/
└── bin/
可以先粗略理解成:
src/:源码include/:头文件tests/:测试代码build/:中间产物bin/:最终可执行文件
这不是什么硬性标准,但它反映了一种很常见的思路:
Makefile 不只是“编一两个文件”,而是在组织一个项目的构建流程。
所以当项目慢慢变大时,Makefile 里通常也会开始出现:
- 自动搜集源文件
- 生成目标文件列表
- 分离构建产物目录
- 增加
test、install、check之类的操作目标
也就是说,Makefile 既可以很小,也可以逐渐演变成一个项目级的构建入口。
@:只显示命令结果,不显示命令本身
前面所有例子里,命令行默认都会被 Make 打印出来。
有时候这正是我们想要的,因为方便观察构建过程。
但有些规则里,我们只想显示结果,不想把命令本身也打印出来。
这时可以在命令前面加 @:
check:
@echo start check
这样执行 make check 时,通常只会看到:
start check
而不会先把:
echo start check
打印出来。
这个写法很常见于:
echo提示信息- 创建目录
- 一些想让输出更干净的辅助命令
$(MAKE):在 Makefile 里再调用 make
如果一个目标里需要继续调用另一个 make,通常更推荐写:
$(MAKE)
而不是直接手写:
make
例如:
subsystem:
$(MAKE) -C src
这样做的好处主要是:
- 更符合 GNU Make 的习惯写法
- 一些上层
make的参数和状态更容易正确传递下去
这个点在递归构建、子目录构建里会比较常见。
define:定义多行变量或预制配方
到这里为止,我们前面写的变量基本都是单行。
但 GNU Make 其实也很常见把一段多行内容先存进变量里,再在后面展开使用。
最常见的写法是:
define RUN_CC
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
endef
然后在规则里用:
%.o: %.c
$(RUN_CC)
也可以把它理解成:
把一段多行文本先打包成变量,后面按需要展开。
这个能力常见在两种场景里:
- 预制一段公共命令
- 预制一段后面要反复展开的模板
不过这里要注意一点:
define 本质上还是变量,不是“新规则语法”。
也就是说,它只是先把文本存起来。
如果你想让展开后的文本再被 Make 当成真正的 Makefile 语句重新解析,通常还要配合后面会提到的 eval。
条件判断:按场景切换构建逻辑
GNU Make 的条件判断最常见的几种写法是:
ifeq ($(CC),clang)
CFLAGS += -Weverything
endif
ifneq ($(DEBUG),1)
CFLAGS += -O2
endif
ifdef CROSS_COMPILE
CC := $(CROSS_COMPILE)gcc
endif
ifndef BUILD_DIR
BUILD_DIR := build
endif
可以先粗略记成:
ifeq:两个值相等时成立ifneq:两个值不相等时成立ifdef:变量已定义时成立ifndef:变量未定义时成立
这类判断非常适合做:
debug/release区分- 不同编译器的参数区分
- 交叉编译前缀区分
- 给目录、工具链设置默认值
例如一个很常见的写法:
DEBUG ?= 0
ifeq ($(DEBUG),1)
CFLAGS += -O0 -g
else
CFLAGS += -O2
endif
这样就可以通过:
make DEBUG=1
切到调试构建。
这里还有一个很重要的细节:
Make 的条件判断是在读取 Makefile 时就处理的。
这意味着:
- 它更像“文本开关”
- 不是等规则执行时才判断
- 自动变量比如
$@、$<不能直接在这里用
如果你的判断依赖于规则真正执行时的上下文,那通常就该把判断写进 shell 命令,而不是写成 ifeq。
常见函数:Makefile 开始“像编程”的地方
GNU Make 的函数很多,但我觉得最值得先掌握、也最常在项目里看到的,主要是下面这些:
wildcardpatsubstfilteraddprefixaddsuffixforeachcallshelleval
patsubst
前面我们已经见过:
OBJS = $(SRCS:.c=.o)
这个本质上就是一种替换。
写成函数形式就是:
OBJS = $(patsubst %.c,%.o,$(SRCS))
如果只是简单地改后缀,前面的简写通常就够用了。
但如果模式稍微复杂一点,patsubst 会更清楚。
filter
这个函数适合从一堆文件名里筛出自己想要的那一类。
例如:
FILES = main.c calc.c readme.md
CSRCS = $(filter %.c,$(FILES))
那么:
CSRCS = main.c calc.c
addprefix 和 addsuffix
它们很适合给一组名字统一补目录或后缀。
例如:
NAMES = main calc
SRCS = $(addsuffix .c,$(NAMES))
OBJS = $(addprefix build/,$(NAMES))
OBJS := $(addsuffix .o,$(OBJS))
这样就能很自然地拼出 build/main.o build/calc.o 这种列表。
foreach
这个函数适合把一个列表映射成另一种形式。
例如:
DIRS = src lib tests
INCFLAGS = $(foreach dir,$(DIRS),-I$(dir))
结果大概就是:
-Isrc -Ilib -Itests
call
如果你开始觉得某些表达式一直在重复,通常就会遇到 call。
例如:
to_obj = $(patsubst %.c,%.o,$(1))
OBJS = $(call to_obj,$(SRCS))
这里可以先粗略理解成:
to_obj是一个带参数的模板$(1)表示传进来的第一个参数call负责把参数代进去展开
它不算第一天就必须会写,但很值得能看懂。
shell
这个函数表示执行一个外部命令,并把输出结果拿回来作为变量值。
例如:
CUR_GIT_HASH := $(shell git rev-parse --short HEAD)
它很方便,但最好别滥用,因为:
- 会让 Makefile 更依赖外部环境
- 调试时不如纯文本展开直观
我的倾向是:确实需要从外部拿信息时再用。
eval
这是 GNU Make 比较“元编程”的一个能力。
它的作用可以先粗略理解成:
把展开后的文本,再当成 Makefile 语句重新解析一次。
例如:
define PROGRAM_template
$(1): $$($(1)_OBJS)
$$(CC) $$^ -o $$@
endef
$(eval $(call PROGRAM_template,app))
如果没有 eval,很多多行模板最后只会变成普通字符串;
有了 eval,它们才会真的生成规则。
自动生成头文件依赖
前面我们一直在手写:
main.o: main.c calc.h
calc.o: calc.c calc.h
但真实项目里,头文件一多,这种写法很容易漏。
GNU Make 文档里很推荐的一种思路是:
- 每个源文件对应一个
.d文件 .d文件里记录这个源文件依赖了哪些头文件- 再把这些
.d文件include进来
如果用 GCC 或 Clang,日常里更常见的简写通常会是:
CPPFLAGS += -MMD -MP
SRCS = main.c calc.c
OBJS = $(SRCS:.c=.o)
DEPS = $(OBJS:.o=.d)
-include $(DEPS)
然后编译规则还是:
%.o: %.c
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
这样在编译 .o 的同时,编译器也会顺手生成对应的 .d 文件。
我觉得这部分非常值得尽早掌握,因为它解决的是一个很常见的问题:
头文件明明改了,但 Makefile 里没写到依赖,结果目标没有重编。
一个带条件和自动依赖的稍微完整版本
如果把前面的几块揉在一起,一个更接近真实项目起步状态的版本大概会长这样:
CC ?= gcc
DEBUG ?= 0
CPPFLAGS += -Iinclude -MMD -MP
CFLAGS += -Wall
LDLIBS += -lm
ifeq ($(DEBUG),1)
CFLAGS += -O0 -g
else
CFLAGS += -O2
endif
TARGET = app
SRCS = $(wildcard *.c)
OBJS = $(SRCS:.c=.o)
DEPS = $(OBJS:.o=.d)
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $^ -o $@ $(LDLIBS)
%.o: %.c
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
clean:
rm -f $(TARGET) $(OBJS) $(DEPS)
-include $(DEPS)
这个版本已经比“纯手写依赖”的版本更像一个可以往后继续扩的工程 Makefile 了。
.SECONDEXPANSION:先知道它在干什么
这个功能不算入门第一优先级,但你后面读一些比较灵活的 Makefile 时,可能会碰到。
它的典型用途是:
- 让先决条件列表再展开一次
- 在第二次展开时使用目标相关的变量
例如:
.SECONDEXPANSION:
app_OBJS := main.o calc.o
test_OBJS := test.o calc.o
app test: $$($$@_OBJS)
这里可以粗略理解成:
app的依赖在第二次展开后变成$(app_OBJS)test的依赖在第二次展开后变成$(test_OBJS)
也就是说,它让“不同目标自动拿各自那套变量”这件事变得更自然。
这个功能很强,但我觉得第一次学 Make 没必要一开始就上手写。
先做到“看到时知道它在干什么”,已经很够用了。
一个从零开始比较顺手的学习顺序
如果你是第一次比较系统地看 GNU Make,我觉得下面这个顺序会比较顺:
- 先理解“目标、依赖、命令”三件事
- 再理解 Make 是靠文件时间戳判断要不要更新
- 然后理解“编译”和“链接”为什么要分开
- 接着补变量和自动变量
- 然后看隐式规则、模式规则、伪目标
- 最后再补条件判断、函数、自动依赖
也就是说,Makefile 最难的地方通常不是语法本身,而是:
你得先把“构建过程”这件事拆成依赖图来看。
快速手册
如果只是临时想回忆常见写法,可以先看这一节。
最基本的规则
target: prerequisites
command
常用命令
make
make <target>
make clean
make -f MyMakefile
make -n
make -B
make -j4
make -C dir
make --trace
make --warn-undefined-variables
常见变量写法
CC = gcc
CXX = g++
CFLAGS = -O2 -Wall
CPPFLAGS = -Iinclude
LDFLAGS =
LDLIBS = -lm
PREFIX ?= /usr/local
条件判断
ifeq ($(DEBUG),1)
CFLAGS += -O0 -g
else
CFLAGS += -O2
endif
常见自动变量
$@ # 当前目标
$< # 第一个先决条件
$^ # 所有先决条件
最常见的模式规则
%.o: %.c
$(CC) -c $< -o $@
伪目标
.PHONY: all clean
变量替换
SRCS = main.c calc.c
OBJS = $(SRCS:.c=.o)
通配符函数
SRCS := $(wildcard *.c)
常见函数
OBJS := $(patsubst %.c,%.o,$(SRCS))
CSRCS := $(filter %.c,$(FILES))
INCFLAGS := $(foreach dir,$(DIRS),-I$(dir))
命令行覆盖变量
make CC=clang
make CFLAGS='-O0 -g'
make PREFIX=/tmp install
安静执行一条命令
@echo hello
仅顺序依赖
target: normal-deps | order-only-deps
自动依赖
CPPFLAGS += -MMD -MP
DEPS := $(OBJS:.o=.d)
-include $(DEPS)
总结
GNU Make 入门时,我觉得最容易让人混乱的地方,不是变量多,也不是符号多,而是很多人一开始会把它当成“编译命令收集器”。
但更准确一点地说,Make 真正在做的事情其实是:描述依赖关系,然后根据文件状态决定哪些命令要执行。
只要把这件事想清楚,后面的很多写法就会顺不少。
比如:
- 为什么目标和依赖要分开写
- 为什么改了一个头文件,可能会让多个
.o都重编 - 为什么
clean这种目标通常要写成.PHONY - 为什么模式规则和自动变量能明显减少重复
- 为什么稍微像样一点的 Makefile 很快就会出现条件判断、函数和自动依赖
如果只是日常写小项目的 Makefile,其实先掌握这一篇里的内容,大多数基础场景已经够用了。
后面再去读别人项目时,我觉得可以重点看这几件事:
- 变量是怎么组织的
- 条件判断在切什么构建分支
- 函数是在拼什么文件列表
- 自动依赖是不是已经接上了
- 有没有用
eval或.SECONDEXPANSION做更灵活的规则生成
参考资料
- GNU Make Manual
- GNU Make Manual: An Introduction to Makefiles
- GNU Make Manual: Rule Syntax
- GNU Make Manual: Types of Prerequisites
- GNU Make Manual: Automatic Variables
- GNU Make Manual: Pattern Rules
- GNU Make Manual: Phony Targets
- GNU Make Manual: The Function wildcard
- GNU Make Manual: Including Other Makefiles
- GNU Make Manual: Conditional Parts of Makefiles
- GNU Make Manual: Defining Multi-Line Variables
- GNU Make Manual: Functions for Transforming Text
- GNU Make Manual: Functions for File Names
- GNU Make Manual: The foreach Function
- GNU Make Manual: The call Function
- GNU Make Manual: The eval Function
- GNU Make Manual: Generating Prerequisites Automatically
- GNU Make Manual: Secondary Expansion
- GNU Make
make(1)man page - 笨办法学C 中文版:练习28 Makefile 进阶
- 无限十三年/从零开始学Makefile
- 无限十三年/CPP/Make
- 廖雪峰/Makefile教程
- 本文主要参考了你提供的本地资料:
GNU_Make中文手册v3.8.pdf、make.md、make_notes.md、/home/yuangod/project/c/test/make.man - 其中函数、条件判断、自动依赖、二次展开等部分,主要整理自
make_notes.md和 GNU Make 官方手册对应章节