一篇从 C/C++ 编译流程入手,系统整理 Makefile 规则、变量、条件判断、内置函数、GCC Flag 与头文件依赖处理的学习笔记。
Makefile 学习笔记
Makefile 在初学阶段经常给人一种“不太直观”的感觉:语法很简短,但背后牵涉到编译流程、依赖关系和增量构建等概念。
如果先把这些概念拆开来看,再回头理解 Makefile,整体会清晰很多。
可以先把它理解成一份“构建说明书”:
main.c、print.c是源文件.o文件是编译后的目标文件- 最终的可执行文件
main是链接后的结果 make负责根据依赖关系决定哪些内容需要重新构建
这篇笔记按“编译过程 -> Makefile 基本写法 -> 依赖识别”的顺序整理。
一、C/C++ 编译过程
1. 预处理 编译 汇编 链接
先准备一个最简单的 main.c:
#include <stdio.h>
int main() {
printf("Hello, Ubuntu C!\n");
return 0;
}一个 C/C++ 源文件变成可执行文件,通常会经历 4 个阶段:
- 预处理
- 编译
- 汇编
- 链接
可以先简单理解为:
- 预处理:展开头文件和宏
- 编译:把源码转换成汇编代码
- 汇编:把汇编代码转换成目标文件
- 链接:把多个目标文件和库组合成可执行文件
1.1 预处理(Preprocessing)
gcc -E main.c -o main_c.i
g++ -E main.cpp -o main_cpp.i-E 的意思是:只做预处理,先别往下走。
预处理主要做这些事:
- 展开
#include - 替换
#define - 处理
#if、#ifdef之类的条件编译 - 删除注释
生成的文件一般是 .i,可以把它理解成“展开后的源码”。
比如:
#include <stdio.h>
#define PI 3.14
int main() {
printf("PI = %f\n", PI);
return 0;
}执行:
gcc -E test.c -o test.i执行后可以看到 PI 已经被替换成 3.14,头文件内容也会被展开。
常见搭配:
gcc -E file.c | less
gcc -E -P file.c -o file.i
gcc -E -dM file.c- 第一条:看看预处理结果
- 第二条:去掉那些
#开头的行号信息 - 第三条:查看当前所有宏定义
1.2 编译(Compiling)
gcc -S main_c.i -o main_c.s
g++ -S main_cpp.i -o main_cpp.s-S 表示:把代码编译成汇编代码,然后停下。
生成的 .s 文件就是汇编代码,它比 C 语言更接近底层机器指令。
常见写法:
gcc -S hello.c
gcc -S hello.c -o my_asm.s
gcc -S -O2 hello.c
gcc -S -march=armv7-a hello.c-O2:开启优化-march=...:指定目标架构
这个阶段常用于学习编译结果、分析优化效果,或者观察不同平台下生成的汇编代码。
1.3 汇编(Assembling)
gcc -c main_c.s -o main_c.o
g++ -c main_cpp.s -o main_cpp.o-c 表示:编译到目标文件为止,不要链接。
生成的 .o 文件是目标文件,它已经完成单个源文件的编译,但还没有和其他目标文件链接在一起。
更常见的写法其实是直接从 .c 到 .o:
gcc -c main.c -o main.o
gcc -c print.c -o print.o虽然中间仍然会经过“预处理 -> 编译 -> 汇编”,但 GCC 会自动把这些步骤串起来完成。
多文件项目里,这一步特别重要,因为只改了一个 .c 文件时,不需要把整个项目从头做一遍。
1.4 链接(Linking)
gcc main_c.o -o main_c
g++ main_cpp.o -o main_cpp链接阶段会把多个 .o 文件以及依赖的库文件组合起来,形成最终可运行的程序。
2.一步到位
平时我们最常见的是直接这么写:
gcc main.c -o main_c
g++ main.cpp -o main_cpp这相当于让 GCC 把前面的 4 个阶段都自动跑完。
3.常见 GCC Flag
在实际开发中,gcc 很少只写最基础的 -c 或 -o,通常还会搭配一组常用选项控制警告、优化、调试信息和头文件搜索路径。
下面是一些最常见的选项:
gcc -Wall -Wextra -O2 -g main.c -o main这条命令里常见选项的含义如下:
-Wall:开启一组常见警告-Wextra:开启更多额外警告-Werror:把警告当成错误处理-O0:不优化,便于调试-O1、-O2、-O3:逐步增强优化-Og:兼顾调试体验和一定优化-g:生成调试信息,便于gdb等工具使用-I<dir>:添加头文件搜索目录-L<dir>:添加库文件搜索目录-l<name>:链接某个库,例如-lm-DNAME=value:定义一个宏-std=c11、-std=c17:指定 C 语言标准-std=c++17、-std=c++20:指定 C++ 标准-fPIC:生成位置无关代码,常用于构建动态库-shared:生成动态库-MMD -MP:生成头文件依赖信息,后面 Makefile 会用到
几个典型例子:
gcc -Wall -Wextra -g main.c -o main
gcc -O2 main.c -o main
gcc -Iinclude -c src/main.c -o main.o
gcc main.o print.o -L./lib -lmylib -o main
gcc -DDEBUG=1 main.c -o main如果按用途分类,可以简单记成下面几组:
- 调试相关:
-g、-O0、-Og - 警告相关:
-Wall、-Wextra、-Werror - 优化相关:
-O1、-O2、-O3 - 头文件和库相关:
-I、-L、-l - 依赖生成相关:
-MMD、-MP
二、Makefile
现在看一个稍微像样一点的小项目:
.
├── main.c
├── print.c
└── print.hmain.c:
#include <stdio.h>
#include "print.h"
int main()
{
printf("Start\n");
for (int i = 1; i <= 5; i++)
{
print(i);
}
printf("End\n");
return 0;
}print.c:
#include "print.h"
#include <stdio.h>
void print(const int a)
{
printf("%d\n", a);
}print.h:
#ifndef MAKEFILELEARN_PRINT_H
#define MAKEFILELEARN_PRINT_H
void print(const int a);
#endif这个项目的编译过程可以理解成:
main.c + print.h -> main.oprint.c + print.h -> print.omain.o + print.o -> main
对应的命令大概是:
gcc -c main.c -o main.o
gcc -c print.c -o print.o
gcc main.o print.o -o main当项目文件变多后,手动维护这些命令会变得低效且容易出错,这也是 Makefile 出现的原因。
Makefile 本质上就是在告诉 make:
- 我要生成什么
- 它依赖谁
- 真要生成时,该执行什么命令
1.规则(Rule)
Makefile 的核心就是规则,基本格式如下:
target: prerequisites
command1
command2注意这里的命令前面通常要用 Tab 缩进,而不是空格。这是 Makefile 中最容易出错的细节之一。
对应到上面的项目,可以写成:
main: main.o print.o
cc main.o print.o -o main
main.o: main.c
cc -c main.c -o main.o
print.o: print.c
cc -c print.c -o print.o执行 make:
cc -c main.c -o main.o
cc -c print.c -o print.o
cc main.o print.o -o main如果不显式指定目标,make 默认会执行第一个目标,也就是这里的 main。
你也可以只构建某个局部目标:
make main.o1.1 隐式规则
有时候会发现:即使没有显式写出 main.o 和 print.o 的规则,make 仍然可以完成编译。
比如下面这个 Makefile:
main: print.o main.o
cc print.o main.o -o main为什么它还能工作?
这是因为 make 内置了一些默认规则,这些规则称为 隐式规则(Implicit Rule)。
比如它大致知道:
xyz.o: xyz.c
cc -c -o xyz.o xyz.c也就是说,当 make 发现需要 main.o,但没有找到显式规则时,它会尝试查找是否存在 main.c,再根据隐式规则完成编译。
1.2 模式规则
如果你不想完全依赖隐式规则,也不想一个 .o 写一条规则,就可以用模式规则。
%.o: %.c
cc -c $< -o $@这里有几个常见自动变量:
$@:当前目标$<:第一个依赖$^:所有依赖
比如对于 main.o: main.c 这条规则:
$@就是main.o$<就是main.c
于是可以把 Makefile 写得更简洁:
TARGET = main
SRCS = main.c print.c
OBJS = $(SRCS:.c=.o)
$(TARGET): $(OBJS)
cc $^ -o $@
%.o: %.c
cc -c $< -o $@这里链接那一行用 $^ 很合适,因为它确实需要“所有依赖的 .o 文件”。
而编译 .c -> .o 时,用 $< 更准确,因为这里只需要第一个依赖,也就是对应的那个 .c 文件。
2.变量与条件
当文件越来越多时,直接把文件名写死在 Makefile 中会比较繁琐,这时可以通过变量减少重复。
定义变量:
VARIABLE_NAME = value使用变量:
$(VARIABLE_NAME)例如:
TARGET = main
SRCS = main.c print.c
OBJS = $(SRCS:.c=.o)
$(TARGET): $(OBJS)
$(CC) $(OBJS) -o $(TARGET)这里最值得注意的是:
OBJS = $(SRCS:.c=.o)它的意思是:把 SRCS 里每个文件名的 .c 后缀替换成 .o。
所以:
main.c print.c会变成:
main.o print.o这是一种很常见的后缀替换写法。
2.1 Makefile 内置函数
除了简单的后缀替换,Makefile 还内置了不少字符串和列表处理函数。文件一多时,这些函数会非常实用。
基本形式如下:
$(function arguments)下面列几个最常用的例子。
addprefix
给列表中的每一项添加统一前缀:
FILES = main.c print.c
SRCS = $(addprefix src/,$(FILES))结果是:
src/main.c src/print.c如果源文件都放在 src/ 目录下,这个函数会比手动逐个拼接更方便。
addsuffix
给列表中的每一项添加统一后缀:
NAMES = main print
OBJS = $(addsuffix .o,$(NAMES))结果是:
main.o print.osubst
做简单字符串替换:
SRCS = src/main.c src/print.c
OBJS = $(subst .c,.o,$(SRCS))结果是:
src/main.o src/print.opatsubst
按模式做替换,比 subst 更灵活:
SRCS = src/main.c src/print.c
OBJS = $(patsubst %.c,%.o,$(SRCS))结果是:
src/main.o src/print.owildcard
按通配符查找文件:
SRCS = $(wildcard src/*.c)如果 src/ 下有多个 .c 文件,这个写法可以自动收集它们。
dir 和 notdir
用于拆分路径:
SRCS = src/main.c src/print.c
DIRS = $(dir $(SRCS))
FILES = $(notdir $(SRCS))结果分别类似于:
src/ src/
main.c print.cbasename 和 suffix
用于获取文件名主体或后缀:
FILES = main.c print.h
BASES = $(basename $(FILES))
SUFS = $(suffix $(FILES))结果分别是:
main print
.c .hfilter 和 filter-out
用于筛选列表:
FILES = main.c print.c print.h
CSRCS = $(filter %.c,$(FILES))
HEADERS = $(filter %.h,$(FILES))
NON_C = $(filter-out %.c,$(FILES))sort
排序并去重:
FILES = print.c main.c print.c
SORTED = $(sort $(FILES))结果是:
main.c print.cforeach
按列表逐项展开:
DIRS = src include lib
FLAGS = $(foreach d,$(DIRS),-I$(d))结果是:
-Isrc -Iinclude -Ilibshell
执行一条 shell 命令,并获取结果:
CURRENT_DIR = $(shell pwd)这个函数很方便,但不建议滥用。因为它会让 Makefile 依赖外部命令执行结果,复杂度也会随之增加。
一个更接近实战的例子:
SRC_DIR = src
SRCS = $(wildcard $(SRC_DIR)/*.c)
OBJS = $(patsubst %.c,%.o,$(SRCS))
DEPS = $(patsubst %.c,%.d,$(SRCS))
INCLUDES = $(addprefix -I,include third_party/include)这里分别使用了:
wildcard收集源文件patsubst生成.o和.daddprefix生成一组-I头文件参数
2.2 变量赋值方式
Makefile 中常见的赋值方式不止 = 一种,不同写法的行为略有区别。
=
递归展开赋值。变量在真正使用时才展开右侧内容。
CC = gcc
CFLAGS = $(COMMON_FLAGS) -O2
COMMON_FLAGS = -Wall -Wextra最终 CFLAGS 会展开成:
-Wall -Wextra -O2:=
立即展开赋值。变量在定义时就完成右侧展开。
COMMON_FLAGS = -Wall
CFLAGS := $(COMMON_FLAGS) -O2
COMMON_FLAGS = -Wall -Wextra此时 CFLAGS 仍然是:
-Wall -O2因为它在赋值那一刻就已经确定了。
?=
条件赋值。只有变量此前没有定义时,才会进行赋值。
CC ?= gcc
CFLAGS ?= -O2这个写法在写可复用 Makefile 时很常见,因为它允许用户从命令行覆盖变量:
make CC=clang CFLAGS="-O0 -g"如果命令行已经传入 CC 或 CFLAGS,那么 ?= 这一行就不会再覆盖它。
+=
追加赋值:
CFLAGS = -Wall
CFLAGS += -Wextra
CFLAGS += -O2结果会变成:
-Wall -Wextra -O2一个常见组合如下:
CC ?= gcc
CFLAGS ?= -O2
CFLAGS += -Wall -Wextra这种写法比较适合做默认配置:既给出推荐值,又保留用户覆盖空间。
2.3 条件判断
Makefile 也支持简单条件判断,常见写法有 ifeq、ifneq、ifdef、ifndef。
ifeq
判断两个值是否相等:
CC ?= gcc
ifeq ($(CC),gcc)
CFLAGS += -Wall
endif如果 CC 是 gcc,就追加 -Wall。
也常用于区分构建模式:
BUILD ?= release
ifeq ($(BUILD),debug)
CFLAGS += -O0 -g
else
CFLAGS += -O2
endififneq
判断两个值是否不相等:
ifneq ($(wildcard config.mk),)
include config.mk
endif这里的意思是:如果 config.mk 存在,就把它包含进来。
ifdef
判断变量是否已定义:
ifdef DEBUG
CFLAGS += -O0 -g
endif命令行这样执行:
make DEBUG=1就会启用调试选项。
ifndef
判断变量是否未定义:
ifndef CC
CC = gcc
endif这个写法和 CC ?= gcc 的用途很接近,不过 ?= 通常更简洁。
一个综合例子如下:
CC ?= gcc
BUILD ?= release
CFLAGS += -Wall -Wextra
ifeq ($(BUILD),debug)
CFLAGS += -O0 -g
else ifeq ($(BUILD),release)
CFLAGS += -O2
endif
ifdef SANITIZE
CFLAGS += -fsanitize=address
endif执行时可以这样传参:
make BUILD=debug
make BUILD=debug SANITIZE=1这种写法在需要区分调试版、发布版和附加构建选项时很常见。
3. 隐藏命令输出
默认情况下,make 会把每条执行的命令先打印一遍。
如果不希望命令在执行前被打印出来,可以在命令前加 @:
main: main.o print.o
@cc main.o print.o -o main4. 伪目标
有些目标并不是为了生成同名文件,而是为了执行一个动作,例如:
cleanalltest
这类目标通常叫 伪目标(Phony Target)。
例如:
.PHONY: clean
clean:
rm -f $(OBJS)为什么要写 .PHONY?
因为如果目录里刚好存在一个名为 clean 的真实文件,make 可能会认为该目标已经满足,不再执行对应命令。加上 .PHONY 是为了明确告诉 make:这是一个命令标签,而不是文件。
一个更完整一点的版本:
TARGET = main
SRCS = main.c print.c
OBJS = $(SRCS:.c=.o)
.PHONY: all clean
all: $(TARGET)
clean:
rm -f $(OBJS) $(TARGET)
$(TARGET): $(OBJS)
cc $^ -o $@
%.o: %.c
cc -c $< -o $@运行:
make clean
make all5.依赖识别
Makefile 最重要的能力之一,是它可以只重新编译真正受影响的部分。
原因在于:依赖关系。
make 在执行时会先判断:
- 这个目标依赖谁?
- 依赖有没有更新?
- 如果依赖比目标“更新”,那就说明目标过期了,要重做
5.1 Make 如何判断要不要重建
还是这个例子:
main: main.o print.o
cc main.o print.o -o main
main.o: main.c
cc -c main.c -o main.o
print.o: print.c
cc -c print.c -o print.omake 大致会建立这样一棵依赖关系树:
main
├── main.o
│ └── main.c
└── print.o
└── print.c执行 make 时,它会从目标开始一路往下看时间戳:
- 先看
main依赖main.o、print.o - 再看
main.o依赖main.c - 再看
print.o依赖print.c - 如果依赖文件比目标文件新,就重新执行对应命令
比如:
- 如果你改了
main.c - 那么
main.c的时间戳会比main.o新 make就会重新生成main.o- 接着发现
main.o比main新 - 于是再重新链接出新的
main
但 print.c 没动,所以 print.o 不需要重编。
这就是增量编译。
它的核心思想是:只重建已经过期的目标,不重复处理未发生变化的部分。
5.2 只写 .c 依赖的问题
上面的规则看起来已经不错了,但还藏着一个经典坑:
main.o: main.c
print.o: print.c这里 .o 只依赖对应的 .c 文件,却没有把头文件 .h 算进去。
问题在于:
假设你修改了 print.h,比如把函数声明改了:
void print(int a, int b);这时候:
main.c实际上受到了影响,因为它#include "print.h"print.c也受到了影响- 但 Makefile 并不知道这件事
因为在它眼里:
main.o只看main.cprint.o只看print.c
于是当头文件发生变化时,make 可能不会重新编译对应的 .o 文件,最终得到一个并不完整的构建结果。
5.3 解决办法:让编译器顺手生成依赖文件 .d
为了让 make 知道:
main.o不只依赖main.c- 还依赖它包含的头文件,比如
print.h
我们通常会让编译器在编译 .c 的同时,额外生成一个 .d 文件。
.d 文件可以理解成“依赖清单”,专门记录某个 .o 文件依赖了哪些头文件。
比如编译器可能会生成这样的内容:
main.o: main.c print.h
print.o: print.c print.h这就对了。以后只要 print.h 一改,make 就知道应该把相关的 .o 重新编译。
5.4 常见写法
一个很常见的做法是使用 -MMD -MP:
CC = gcc
TARGET = main
SRCS = main.c print.c
OBJS = $(SRCS:.c=.o)
DEPS = $(SRCS:.c=.d)
.PHONY: all clean
all: $(TARGET)
clean:
rm -f $(OBJS) $(DEPS) $(TARGET)
$(TARGET): $(OBJS)
$(CC) $^ -o $@
%.o: %.c
$(CC) -MMD -MP -c $< -o $@
-include $(DEPS)这个版本可以作为一个比较实用的入门模板。
各部分的作用
DEPS = $(SRCS:.c=.d)把 main.c print.c 变成 main.d print.d。
$(CC) -MMD -MP -c $< -o $@这条命令在编译 .o 的同时,也生成对应的 .d 文件。
其中:
-MMD:生成用户头文件的依赖信息-MP:为头文件生成伪目标,避免头文件被删除时make直接炸掉
-include $(DEPS)这一行的意思是:把这些 .d 文件也读进来,当成 Makefile 的补充规则。
前面的 - 很关键,它表示:如果这些 .d 文件暂时还不存在,也不要因此报错。
为什么一开始会不存在?
因为第一次编译前,这些 .d 文件通常还不存在。
5.5 传统 %.d: %.c 写法与原理
除了 -MMD -MP 这种直接在编译 .o 时顺手生成 .d 的做法,还有一种更传统的写法,是把 .d 文件的生成单独写成规则:
%.d: %.c
rm -f $@; \
$(CC) -MM $< > $@.tmp; \
sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.tmp > $@; \
rm -f $@.tmp这段规则看起来比较绕,但它其实只做了三件事。
a. 先让编译器输出原始依赖
$(CC) -MM $< > $@.tmp这里的 -MM 会让编译器分析当前 .c 文件依赖了哪些用户头文件,并输出类似下面的内容:
main.o: main.c print.h这里输出的是 main.o 的依赖关系,但还没有把 main.d 自己写进去。
b. 再把输出结果改写成同时描述 .o 和 .d
sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.tmp > $@这一步是整段规则的关键。
假设当前目标是 main.d,那么:
$*表示模式匹配中的主干部分,也就是main$@表示当前目标,也就是main.d
原始内容:
main.o: main.c print.h经过 sed 改写后会变成:
main.o main.d: main.c print.h这样处理的意义在于:
main.o依赖main.c和print.hmain.d自己也依赖main.c和print.h
于是当头文件变化时,不仅 main.o 会过期,main.d 也会过期,Make 就知道应该重新生成这份依赖文件。
c. 用临时文件避免写到一半的中间状态
rm -f $@.tmp中间先写到 $@.tmp,再生成最终的 $@,是为了避免依赖文件写了一半时被 make 读到不完整内容。
?. 为什么要写成这种形式
传统写法的核心目的,是让 .d 文件本身也有正确的依赖关系。
如果只生成:
main.o: main.c print.h那么 make 只知道 main.o 依赖头文件,却不知道 main.d 也应该在头文件变化时更新。
改写成:
main.o main.d: main.c print.h之后,.d 文件和 .o 文件就会一起随着头文件变化而重新生成。
这也是这类 sed 写法长期存在的原因。它看起来不够直观,但目的非常明确。
5.6 为什么本次重新生成的 .d 仍然会影响结果
很多人第一次看到 include $(DEPS) 时会有一个疑问:
make一开始不是已经把.d文件读进来了吗?- 如果本次执行过程中
.d文件又被重新生成,那这些新内容怎么还能影响本次构建结果?
关键点在于:被 include 进来的文件,对 make 来说也是 makefile 的一部分。
也就是说,make 不只是“读取它们一次就结束”,而是会把这些被包含的文件也当作需要维护的目标来看待。
更准确地说,执行过程通常是这样的:
make先读取主 Makefile 和被include的.d文件- 如果发现某个被包含的
.d文件不存在,或者已经过期,就先尝试把它更新 - 只要这些被包含的 makefile 文件发生了变化,
make会重新启动一次读取过程 - 第二次读取时,新生成的
.d内容就已经生效了
这也是为什么 .d 虽然是通过 include 引入的,但它在本次执行中重新生成后,依然能够立刻反映到后续构建结果中。
可以把它理解成:
- 第一次读取:先拿到旧版规则
- 发现依赖说明书过期了,于是先更新说明书
- 说明书更新后,重新读一遍
- 再按照最新版说明书决定接下来要编译什么
因此,.d 文件并不是“只在下一次 make 才生效”,而是只要它在当前执行中被成功重建,make 就会重新读取它。
5.7 整个流程串起来看
现在 make 的工作方式就更完整了:
- 先读取主 Makefile
- 再尝试读取
include进来的.d依赖文件 - 如果某个
.d文件缺失或过期,就先更新它 - 如果被包含的 makefile 文件发生变化,
make会重新读取一次规则 - 再根据最新依赖关系比较目标和依赖的时间戳
- 最后只重新编译真正受影响的部分
比如你只改了 print.h:
make通过main.d知道main.o依赖print.h- 通过
print.d知道print.o也依赖print.h - 所以会重新编译
main.o和print.o - 然后重新链接
main
这时 Make 才能正确追踪头文件变化带来的影响。
5.8 一个更接近实战的小模板
最后放一个更常见、也更适合抄回去直接改的版本:
CC = gcc
CFLAGS = -Wall -Wextra -O2
TARGET = main
SRCS = main.c print.c
OBJS = $(SRCS:.c=.o)
DEPS = $(SRCS:.c=.d)
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(OBJS) -o $(TARGET)
%.o: %.c
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
clean:
rm -f $(TARGET) $(OBJS) $(DEPS)
-include $(DEPS)小结
1. 编译流程
C/C++ 从源文件到可执行文件,通常会经历预处理、编译、汇编和链接四个阶段。理解这一点之后,再看 Makefile 中的 .c、.o 和最终目标文件,关系会清晰很多。
2. Makefile 的基本结构
Makefile 的核心是规则:目标、依赖和命令。make 会根据这些规则决定应该构建什么、先构建谁,以及哪些部分可以跳过。
3. 变量、函数与条件
变量、内置函数、条件赋值和条件判断,主要解决的是“如何把 Makefile 写得更灵活、更少重复、更便于维护”。
4. 增量编译的关键
Make 并不是盲目地全量重编,而是依据依赖关系和时间戳,只重建已经过期的目标。
5. .d 文件的意义
头文件依赖如果没有被正确描述,增量编译就可能失真。.d 文件的价值就在于把这部分隐藏依赖补全。
6. 本文的核心结论
理解 Makefile,关键不在于死记语法,而在于建立一套稳定的思路:
- 目标是什么
- 它依赖什么
- 依赖变化后谁会过期
- 规则何时会被重新读取
只要把这条主线理顺,Makefile 的大部分写法都能找到位置。