2、规则与目标:深入理解 Makefile 的规则结构

Makefile 的核心,说白了就是一条规则。你写一百行、一千行的 Makefile,拆开来看,全是规则的堆叠。我刚开始学的时候,总觉得这东西很玄乎,后来发现——嗯,其实就是告诉 make:「你要做什么,用什么做,怎么做。」

2.1 规则的基本三要素

一条完整的 Makefile 规则,长这样:

目标: 依赖
    命令

三部分缺一不可(虽然命令可以空着,但冒号必须要有)。我习惯把这三部分叫做「目标、依赖、命令」,记起来很简单。

要素 含义 举个栗子
目标(target) 你要生成的文件,或者你要执行的动作名 main.o、clean
依赖(prerequisites) 生成目标之前,必须先有的文件或目标 main.c、defs.h
命令(recipe) 如何从依赖生成目标的具体 shell 指令 gcc -c main.c

关键理解:Make 会检查「目标」和「依赖」的时间戳。如果依赖比目标新,或者目标不存在,就执行命令。否则跳过。这就是增量编译的根基。

2.2 依赖链:Make 是怎么知道该编译什么的?

你想想看,一个项目几十个源文件,难道每次改一个 .c 文件,就要全部重新编译?当然不是。Make 通过依赖链,只编译受影响的部分。

举个例子:

app: main.o utils.o
    gcc -o app main.o utils.o

main.o: main.c defs.h
    gcc -c main.c

utils.o: utils.c defs.h
    gcc -c utils.c

这时候如果你改了 defs.h,Make 会发现 main.o 和 utils.o 都依赖它,两个 .o 都比 .h 旧,于是重新编译两个 .o,再链接成 app。如果你只改了 main.c,那就只编译 main.o,utils.o 不动。

我的经验:依赖写全了,编译速度能快好几倍。我见过有人把所有 .c 文件都列在一个规则里,改一个文件就全量编译——那还不如写个 shell 脚本呢。

2.3 伪目标 .PHONY:别把「动作」当成「文件」

这里有个坑,我当年踩过。你写一个 clean 目标:

clean:
    rm -f *.o app

看起来没问题对吧?但如果你的目录下恰好有一个叫 clean 的文件……那 Make 就会认为 clean 这个目标已经是最新的了,什么都不执行。

为什么会这样?因为 Make 默认把目标当成文件名来处理。它检查到 clean 文件存在,而且没有依赖,就认为「嗯,这个目标已经完成了,跳过」。

解决办法就是用 .PHONY 声明伪目标:

.PHONY: clean
clean:
    rm -f *.o app

告诉 Make:「clean 不是一个文件,别拿时间戳来比,每次都给我执行命令。」

场景 不加 .PHONY 加 .PHONY
目录下有同名文件 跳过,不执行 正常执行
目录下无同名文件 正常执行 正常执行
多次调用 只执行一次 每次都执行

我曾经……在一个项目里忘了加 .PHONY,结果 clean 目标死活不执行。查了半天,发现 build 目录下有个叫 clean 的临时文件。从那以后,我所有「动作类」目标(clean、all、test、install)一律加上 .PHONY。

2.4 多目标与自动变量

有时候多个目标共享同一套依赖和命令。比如你要生成 a.o 和 b.o,规则可以写成:

a.o b.o: defs.h
    echo "编译中..."

但这样写,命令里的目标名怎么区分?这时候就要用自动变量了:

  • $@ —— 当前目标名
  • $< —— 第一个依赖
  • $^ —— 所有依赖(去重)

看个实际例子:

a.o b.o: defs.h
    gcc -c $< -o $@

当 Make 处理 a.o 时,$@ 就是 a.o,$< 是 defs.h。处理 b.o 时同理。这样一条规则就能搞定多个目标。

我建议:刚开始用自动变量时,可以在命令里加一行 @echo 目标: $@, 依赖: $^ 来调试,看看 Make 到底传了什么值进去。

2.5 规则结构全景图

下面这张图,把规则的核心结构、依赖链、以及 .PHONY 的位置都串起来了:

Makefile 规则结构全景图 一条完整的规则 目标 : 依赖1 依赖2 ... 命令(shell 指令) 目标(Target) 生成的文件 或 动作名 依赖(Prerequisites) 前置文件或目标 命令(Recipe) 具体的 shell 指令 依赖链:目标 → 依赖1 → 依赖1的依赖 → ... .PHONY: 声明伪目标,跳过文件检查 核心逻辑:Make 比较目标和依赖的时间戳,决定是否执行命令

2.6 实战中的几个小习惯

最后分享几个我自己的习惯,不一定适合所有人,但你可以参考:

  • 第一个目标写成 all —— 默认执行 all,里面列出所有要构建的最终目标。这样别人执行 make 就知道要编译什么。
  • 每个「动作」目标都加 .PHONY —— clean、install、test、distclean,一个不落。省得以后踩坑。
  • 依赖写细一点 —— 别偷懒只写 .c 文件,头文件也列上。虽然手动维护麻烦,但可以后面用自动生成依赖的工具(gcc -MM)来搞定。
  • 命令前面加 @ —— 比如 @echo "编译完成",这样不会把命令本身打印出来,输出更干净。

一句话总结:规则就是 Makefile 的细胞。目标、依赖、命令三要素搞清楚了,.PHONY 用对了,依赖链理清了,你的 Makefile 就成功了一半。