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时天天要用到。

我的习惯: 我会把OBJS单独列出来,而不是用wildcard函数自动搜集。为什么?因为有时候你不想编译所有.c文件,或者有些.c文件是测试用的。显式列出,心里有数。

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目录了。

我曾经踩过的坑: 把目标文件和源文件混在一起放。结果执行make clean时,不小心把源文件也删了。从那以后,我坚持用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
核心思想: Makefile的本质是依赖关系图。你只要把依赖说清楚,make会自动帮你做增量编译。不要试图手动管理编译顺序,那是make的工作。

8.6 知识体系图

下面这张图,帮你理清本章的核心逻辑:

Makefile实战知识体系 单目录项目 • 直接列出目标文件 • 模式规则简化 • 自动变量 $@ $^ $< • 适合小项目/教学 多目录项目 • 变量管理路径 • 自动搜集源文件 • 分离build目录 • 自动依赖生成 伪目标与清理 • .PHONY声明 • clean: 删除中间文件 • distclean: 恢复源码 • 避免文件同名陷阱 核心原则 把依赖说清楚,让make自动做增量编译 实践1:单目录起步 先跑通,再优化 实践2:多目录拆分 用变量管理路径 实践3:规范清理 clean + distclean

8.7 避坑指南

最后,分享几个我实际项目中踩过的坑:

  • 不要用通配符匹配头文件依赖:我曾经用*.h作为依赖,结果每次改任何头文件都要重新编译所有文件。正确做法是用-MMD自动生成精确依赖。
  • 小心并行编译make -j4时,如果依赖没写全,会出现奇怪的编译错误。我的经验是:先单线程编译通过,再试并行。
  • distclean要彻底:除了删除文件,还要考虑删除生成的目录。比如autom4te.cache/这种隐藏目录。
  • 测试你的clean:写完Makefile后,先执行make clean看看删了什么。别等到发布时才发现漏了文件。

好了,这一章的内容就到这里。Makefile的实战能力,就是在这些细节中积累起来的。多写、多试、多踩坑,慢慢就熟练了。


公众号:蓝海资料掘金营,微信 deep3321