8、Makefile实战:单目录项目编译、多目录项目编译、伪目标(.PHONY)、clean与distclean。
好,我们终于要动手写真正的Makefile了。
前面几章我们把Makefile的语法、变量、函数都过了一遍。说实话,光看语法是记不住的。我自己带新人时,发现一个规律:只要亲手写过三个Makefile,基本就入门了。今天我们就从最简单的单目录项目开始,一步步深入到多目录结构。
8.1 单目录项目:最朴素的写法
假设你有一个项目,所有源文件都在同一个目录下。比如:
project/
├── main.c
├── utils.c
├── utils.h
└── Makefile
最直接的Makefile长这样:
CC = gcc
CFLAGS = -Wall -O2
main: main.o utils.o
$(CC) $(CFLAGS) -o main main.o utils.o
main.o: main.c utils.h
$(CC) $(CFLAGS) -c main.c
utils.o: utils.c utils.h
$(CC) $(CFLAGS) -c utils.c
clean:
rm -f *.o main
嗯,这里要注意:依赖关系一定要写全。我见过太多人只写.c文件,忘了头文件。结果改了头文件,make以为没变化,不重新编译。这种bug最难查。
但这样写有个问题——重复代码太多。每个目标文件都要写一遍编译命令。项目大了根本维护不了。所以我们要用变量和模式规则来优化:
CC = gcc
CFLAGS = -Wall -O2
OBJS = main.o utils.o
main: $(OBJS)
$(CC) $(CFLAGS) -o $@ $^
%.o: %.c utils.h
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) main
看到没?$@代表目标,$^代表所有依赖,$<代表第一个依赖。这三个自动变量,你写Makefile时天天要用到。
8.2 多目录项目:真正的工程实践
单目录的项目,说实话只适合教学。真实项目里,代码肯定要分目录放。比如:
project/
├── src/
│ ├── main.c
│ ├── utils.c
│ └── utils.h
├── lib/
│ ├── math.c
│ └── math.h
├── include/
│ └── config.h
└── Makefile
这时候Makefile怎么写?我见过有人把所有路径写死在规则里,结果改个目录结构就要改Makefile。太蠢了。
正确的做法是用变量管理路径:
CC = gcc
CFLAGS = -Wall -O2 -Iinclude -Isrc -Ilib
SRC_DIR = src
LIB_DIR = lib
BUILD_DIR = build
SRCS = $(SRC_DIR)/main.c $(SRC_DIR)/utils.c $(LIB_DIR)/math.c
OBJS = $(SRCS:.c=.o)
TARGET = app
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) -o $@ $^
$(BUILD_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -rf $(BUILD_DIR) $(TARGET)
这里有个小技巧:$(dir $@)能自动提取目标文件的目录部分。这样你就不用手动创建build目录了。
8.3 伪目标:.PHONY 到底有什么用?
你想想看,如果当前目录下恰好有一个叫clean的文件,会发生什么?
make会检查clean这个目标是否存在。如果存在且没有依赖,它就认为clean已经是最新的了,什么都不做。你的rm命令永远不会被执行。
这就是.PHONY存在的意义:告诉make,这个目标不是真正的文件,不要做文件存在性检查。
.PHONY: clean distclean
clean:
rm -f *.o
distclean: clean
rm -f config.log config.status
我个人习惯,所有不生成文件的目标都加上.PHONY。包括clean、distclean、install、test等等。多写一行,少踩一个坑。
8.4 clean 与 distclean:区别在哪?
这两个目标,很多初学者搞混。我简单解释一下:
| 目标 | 删除内容 | 典型场景 |
|---|---|---|
| clean | 编译生成的中间文件(.o、.a、可执行文件) | 日常开发,重新编译 |
| distclean | clean的内容 + 配置生成的文件(Makefile、config.h等) | 发布源码前,清理所有非源码文件 |
说白了,clean是让你能重新编译。distclean是让项目恢复到刚下载时的状态。
我参与过一个嵌入式项目,Makefile里只有clean没有distclean。结果要发布源码时,发现一堆configure生成的文件混在里面。最后手动删了半天。从那以后,我每个项目都加上distclean。
.PHONY: clean distclean
clean:
rm -rf build/
rm -f $(TARGET)
distclean: clean
rm -f Makefile config.h config.log
rm -rf autom4te.cache/
8.5 实战:一个完整的多目录Makefile
好了,我们把上面所有知识点串起来,写一个真正能用的Makefile:
CC = gcc
CFLAGS = -Wall -O2 -Iinclude -Isrc -Ilib
LDFLAGS = -lm
SRC_DIR = src
LIB_DIR = lib
BUILD_DIR = build
TARGET = app
SRCS = $(wildcard $(SRC_DIR)/*.c) $(wildcard $(LIB_DIR)/*.c)
OBJS = $(patsubst %.c, $(BUILD_DIR)/%.o, $(SRCS))
DEPS = $(OBJS:.o=.d)
.PHONY: all clean distclean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
$(BUILD_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
-include $(DEPS)
clean:
rm -rf $(BUILD_DIR)
rm -f $(TARGET)
distclean: clean
rm -f *.log *.status
rm -rf config.cache/
这个Makefile有几个亮点:
- 自动搜集源文件:用wildcard函数,不用手动列文件
- 自动生成依赖:-MMD -MP 帮你生成头文件依赖,改了头文件会自动重新编译
- 目录自动创建:mkdir -p 保证build目录结构存在
- 伪目标保护:all、clean、distclean都加了.PHONY
8.6 知识体系图
下面这张图,帮你理清本章的核心逻辑:
8.7 避坑指南
最后,分享几个我实际项目中踩过的坑:
- 不要用通配符匹配头文件依赖:我曾经用
*.h作为依赖,结果每次改任何头文件都要重新编译所有文件。正确做法是用-MMD自动生成精确依赖。 - 小心并行编译:
make -j4时,如果依赖没写全,会出现奇怪的编译错误。我的经验是:先单线程编译通过,再试并行。 - distclean要彻底:除了删除文件,还要考虑删除生成的目录。比如
autom4te.cache/这种隐藏目录。 - 测试你的clean:写完Makefile后,先执行
make clean看看删了什么。别等到发布时才发现漏了文件。
好了,这一章的内容就到这里。Makefile的实战能力,就是在这些细节中积累起来的。多写、多试、多踩坑,慢慢就熟练了。