Makefile 完全学习笔记:从入门到通用模板解析
你是否曾经在 Linux 下编译一个大型项目时,看到终端里飞快滚动的
gcc命令,却不知道背后发生了什么?你是否遇到过修改了一个头文件,整个项目都要重新编译,等了半天?本文将带你从零开始,彻底搞懂 Makefile 的规则、变量、函数,以及一个能用于真实项目的通用 Makefile 是如何设计出来的。本文面向小白,力求通俗,所有例子均可亲手测试。
文章目录
- [Makefile 完全学习笔记:从入门到通用模板解析](#Makefile 完全学习笔记:从入门到通用模板解析)
-
- [1. 为什么需要 Makefile?](#1. 为什么需要 Makefile?)
-
- [1.1 从 Visual Studio 说起](#1.1 从 Visual Studio 说起)
- [1.2 Makefile 的诞生](#1.2 Makefile 的诞生)
- [2. Makefile 的最简例子](#2. Makefile 的最简例子)
- [3. Makefile 的核心规则](#3. Makefile 的核心规则)
-
- [3.1 规则的基本格式](#3.1 规则的基本格式)
- [3.2 命令执行的触发条件](#3.2 命令执行的触发条件)
- [4. 一步一步完善 Makefile:从粗暴到优雅](#4. 一步一步完善 Makefile:从粗暴到优雅)
-
- [4.1 第 1 个 Makefile:简单粗暴(不推荐)](#4.1 第 1 个 Makefile:简单粗暴(不推荐))
- [4.2 第 2 个 Makefile:分离编译,但规则重复](#4.2 第 2 个 Makefile:分离编译,但规则重复)
- [4.3 第 3 个 Makefile:使用模式规则(`%`)](#4.3 第 3 个 Makefile:使用模式规则(
%)) - [4.4 第 4 个 Makefile:处理头文件依赖](#4.4 第 4 个 Makefile:处理头文件依赖)
- [4.5 第 5 个 Makefile:自动生成头文件依赖(终极方案)](#4.5 第 5 个 Makefile:自动生成头文件依赖(终极方案))
- [5. 通用 Makefile 的设计思想(参考 Linux 内核)](#5. 通用 Makefile 的设计思想(参考 Linux 内核))
-
- [5.1 情景演绎:顶层 Makefile 和 Makefile.build 的配合](#5.1 情景演绎:顶层 Makefile 和 Makefile.build 的配合)
-
- [顶层 Makefile(简化版)](#顶层 Makefile(简化版))
- Makefile.build(公共规则)
- 执行流程
- [6. Makefile 中的变量与赋值](#6. Makefile 中的变量与赋值)
-
- [6.1 四种赋值方式](#6.1 四种赋值方式)
- [6.2 变量的导出(`export`)](#6.2 变量的导出(
export)) - [6.3 自动变量(常用)](#6.3 自动变量(常用))
- [7. Makefile 常用函数(小白友好版)](#7. Makefile 常用函数(小白友好版))
-
- [7.1 `(foreach var, list, text)\`](#7.1 `(foreach var, list, text)`)
- [7.2 `(wildcard pattern)\`](#7.2 `(wildcard pattern)`)
- [7.3 `(filter pattern..., text)\`](#7.3 `(filter pattern..., text)`)
- [7.4 `(patsubst pattern, replacement, text)\`](#7.4 `(patsubst pattern, replacement, text)`)
- [7.5 `(shell command)\`](#7.5 `(shell command)`)
- [7.6 `(if condition, then-part, else-part)\`](#7.6 `(if condition, then-part, else-part)`)
- [8. 假想目标(.PHONY)](#8. 假想目标(.PHONY))
- [9. 完整示例:一个简单项目的 Makefile](#9. 完整示例:一个简单项目的 Makefile)
- [10. 总结与速查](#10. 总结与速查)
1. 为什么需要 Makefile?
1.1 从 Visual Studio 说起
在 Windows 下使用 Visual Studio 开发时,你只需点一下"生成解决方案",IDE 就会自动找出哪些文件被修改过,然后只重新编译这些文件,最后链接成 exe。你从来没有操心过"哪些文件需要重新编译"这件事。
但在 Linux 下,如果你直接用命令行编译:
bash
gcc -o test main.c sub.c
每次都会把 main.c 和 sub.c 全部重新编译 ,即使你只修改了 sub.c 里的一行代码。当项目有几百个文件时,这种"全量编译"会让你等到怀疑人生。
1.2 Makefile 的诞生
Makefile 就是 Linux 下的"自动化编译脚本"。配合 make 命令,它可以:
- 根据文件的时间戳,判断哪些文件被修改过;
- 只重新编译那些修改过的文件(以及受它们影响的文件);
- 最后链接成最终程序。
简单说:Makefile 让你像用 IDE 一样高效编译。
2. Makefile 的最简例子
我们先从一个最简单的 Makefile 开始,感性认识一下。
makefile
hello: hello.c
gcc -o hello hello.c
clean:
rm -f hello
将上面内容保存为 Makefile(注意:gcc 前面必须是 Tab 键,不能是空格)。然后在终端执行:
bash
make # 编译生成 hello
make clean # 删除 hello
解释:
hello是一个目标 (target),它依赖hello.c。- 如果
hello.c比hello新(或者hello不存在),则执行下面的命令gcc -o hello hello.c。 clean是一个没有依赖的目标,它的命令总是被执行(用于清理)。
⚠️ Tab 陷阱 :Makefile 中每个命令前必须是 Tab 字符,不能用空格。这是新手最容易犯错的地方。如果你的编辑器自动把 Tab 转成空格,请关闭该功能。
3. Makefile 的核心规则
3.1 规则的基本格式
makefile
目标(target) ... : 依赖(prerequisites) ...
命令(command)
...
- 目标 :要生成的文件(如
hello、main.o),或者一个动作的名字(如clean)。 - 依赖 :生成目标所需要的文件(如
hello.c)。如果任何一个依赖比目标新,就执行命令。 - 命令 :生成目标的具体操作(如
gcc ...)。每个命令前必须有一个 Tab。
3.2 命令执行的触发条件
- 目标文件不存在;
- 依赖文件中有任何一个比目标文件更新(根据修改时间判断)。
这就是 Makefile 能做到"增量编译"的根基。
4. 一步一步完善 Makefile:从粗暴到优雅
我们以 main.c、sub.c、sub.h 三个文件为例,展示如何逐步写出一个高效的 Makefile。
4.1 第 1 个 Makefile:简单粗暴(不推荐)
makefile
test: main.c sub.c sub.h
gcc -o test main.c sub.c
- 问题 :任何文件改动都会重新编译两个
.c文件。如果项目很大,效率极低。
4.2 第 2 个 Makefile:分离编译,但规则重复
makefile
test: main.o sub.o
gcc -o test main.o sub.o
main.o: main.c
gcc -c -o main.o main.c
sub.o: sub.c
gcc -c -o sub.o sub.c
clean:
rm *.o test -f
- 优点 :修改
sub.c只会重新编译sub.o,再链接,不再编译main.c。 - 缺点 :每个
.c都要写一条规则,文件多了很啰嗦。
4.3 第 3 个 Makefile:使用模式规则(%)
makefile
test: main.o sub.o
gcc -o test main.o sub.o
%.o: %.c
gcc -c -o $@ $<
clean:
rm *.o test -f
%.o: %.c是一个模式规则 ,表示"所有.o文件都由同名的.c文件生成"。$@是自动变量 ,代表目标文件名(例如main.o)。$<代表第一个依赖文件名(例如main.c)。
这样,无论有多少 .c 文件,一条规则就够了!
4.4 第 4 个 Makefile:处理头文件依赖
上面的模式规则有一个隐患:如果修改了 sub.h,sub.c 会重新编译(因为 sub.c 包含了它),但 sub.h 并没有出现在 sub.o 的依赖列表中。Make 不知道 sub.o 依赖 sub.h,因此不会重新编译。
解决方法:手动添加头文件依赖规则。
makefile
test: main.o sub.o
gcc -o test main.o sub.o
%.o: %.c
gcc -c -o $@ $<
sub.o: sub.h # 手动添加
clean:
rm *.o test -f
- 缺点:头文件多了,手动添加非常繁琐,而且容易遗漏。
4.5 第 5 个 Makefile:自动生成头文件依赖(终极方案)
GCC 可以帮我们自动生成依赖关系。使用 -MD 或 -Wp,-MD,depfile 选项。
makefile
objs := main.o sub.o
test: $(objs)
gcc -o test $^
# 寻找已经存在的依赖文件(.main.o.d, .sub.o.d)
dep_files := $(foreach f, $(objs), .$(f).d)
dep_files := $(wildcard $(dep_files))
# 如果存在依赖文件,就包含进来
ifneq ($(dep_files),)
include $(dep_files)
endif
# 编译规则:生成 .o 的同时生成依赖文件 .$@.d
%.o: %.c
gcc -Wp,-MD,.$@.d -c -o $@ $<
clean:
rm *.o test -f
distclean:
rm $(dep_files) *.o test -f
解释:
-
编译时用
-Wp,-MD,.$@.d,GCC 会生成一个.main.o.d文件,内容类似:text
main.o: main.c sub.h -
然后用
include把这些依赖文件包含进 Makefile,Make 就知道了每个.o还依赖哪些头文件。 -
第一次编译时依赖文件还没生成,所以用
wildcard过滤掉不存在的文件,避免include报错。
🎉 这个 Makefile 已经可以用于真实项目了:增量编译、自动检测头文件、只重新编译必要的文件。
5. 通用 Makefile 的设计思想(参考 Linux 内核)
在实际项目中,源文件往往分布在多个目录中。比如:
text
project/
├── Makefile
├── main.c
├── sub.c
└── a/
├── Makefile
├── sub2.c
└── sub3.c
我们希望:
- 在顶层执行
make就能编译所有目录; - 每个子目录有自己的
Makefile,只描述该目录要编译哪些文件; - 有一个公共的
Makefile.build来定义编译规则。
5.1 情景演绎:顶层 Makefile 和 Makefile.build 的配合
顶层 Makefile(简化版)
makefile
# 要编译的当前目录文件及子目录
obj-y += main.o
obj-y += sub.o
obj-y += a/
# 最终生成的可执行文件名
TARGET = app
all: start_recursive_build $(TARGET)
start_recursive_build:
make -C ./ -f Makefile.build
$(TARGET): built-in.o
$(CC) $(LDFLAGS) -o $@ $^
clean:
rm -f $(TARGET) *.o
Makefile.build(公共规则)
makefile
# 清空局部变量
obj- :=
subdir-y :=
# 包含当前目录的 Makefile(即描述文件)
include Makefile
# 先处理子目录(如果有)
subdir_objs := $(foreach f, $(subdir-y), $(f)/built-in.o)
# 规则1:进入子目录编译
$(subdir-y):
make -C $@ -f $(TOPDIR)/Makefile.build
# 规则2:编译当前目录的 .c 文件
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
# 规则3:将当前目录的 .o 和子目录的 built-in.o 合并成 built-in.o
built-in.o: $(obj-y) $(subdir_objs)
$(LD) -r -o $@ $^
# 伪目标,触发子目录编译
.PHONY: $(subdir-y)
执行流程
- 用户执行
make,顶层 Makefile 的第一个目标是all。 all依赖start_recursive_build,它执行make -f Makefile.build。Makefile.build包含当前目录的Makefile,得到obj-y的内容(main.o、sub.o、a/)。- 因为
a/是子目录,Makefile.build 会执行make -C a -f Makefile.build,进入子目录编译。 - 子目录同样把自己编译成
built-in.o返回。 - 回到顶层,把
main.o、sub.o、a/built-in.o链接成顶层的built-in.o。 - 最后顶层 Makefile 用
built-in.o链接成最终的可执行文件app。
💡 这种机制非常灵活:你只需要在每个目录的
Makefile中写obj-y += xxx.o或obj-y += subdir/,无需关心编译细节。
6. Makefile 中的变量与赋值
6.1 四种赋值方式
| 操作符 | 类型 | 含义 |
|---|---|---|
= |
延时变量 | 在使用时才展开,可以递归引用 |
:= |
立即变量 | 在定义时就展开,值固定 |
?= |
延时变量(条件) | 只有变量未定义时才赋值 |
+= |
继承类型 | 追加值,类型跟随原变量 |
示例:
makefile
A = $(B) # 延时变量,此时 A 的值未知
B = hello
C := $(B) # 立即变量,此时 C 已经是 "hello"
B = world
test:
@echo "A = $(A)" # 输出 "A = world"
@echo "C = $(C)" # 输出 "C = hello"
6.2 变量的导出(export)
当使用 make -C subdir 进入子目录时,子目录中的 Makefile 默认看不到父 Makefile 中定义的变量。如果想共享,需要用 export 导出。
makefile
CC = gcc
export CC # 子目录中的 Makefile 也能看到 CC
6.3 自动变量(常用)
| 自动变量 | 含义 |
|---|---|
$@ |
规则的目标文件名 |
$< |
第一个依赖文件名 |
$^ |
所有依赖文件名(去重) |
$+ |
所有依赖文件名(不去重) |
$* |
目标模式中 % 匹配的部分 |
示例:
makefile
%.o: %.c
gcc -c -o $@ $<
当执行 main.o: main.c 时,$@ 是 main.o,$< 是 main.c。
7. Makefile 常用函数(小白友好版)
函数调用格式:$(函数名 参数1,参数2,...)
7.1 $(foreach var, list, text)
作用 :对 list 中的每个元素,赋给 var,然后执行 text,最后把所有结果用空格连接。
例子:
makefile
objs := a.o b.o
dep_files := $(foreach f, $(objs), .$(f).d)
# dep_files = .a.o.d .b.o.d
7.2 $(wildcard pattern)
作用 :列出当前目录下所有符合 pattern 的真实存在的文件。
例子:
makefile
c_files := $(wildcard *.c) # 假设有 main.c sub.c,则 c_files = main.c sub.c
7.3 $(filter pattern..., text)
作用 :从 text 中筛选出符合 pattern 的单词。
例子:
makefile
obj-y := a.o b.o c/ d/
dirs := $(filter %/, $(obj-y)) # dirs = c/ d/
files := $(filter-out %/, $(obj-y)) # files = a.o b.o
7.4 $(patsubst pattern, replacement, text)
作用 :模式替换,将 text 中符合 pattern 的单词替换成 replacement。
例子:
makefile
subdir-y := c/ d/
subdir-y := $(patsubst %/, %, $(subdir-y)) # 结果为 c d
7.5 $(shell command)
作用:执行 shell 命令,并返回其输出。
例子:
makefile
cur_dir := $(shell pwd)
7.6 $(if condition, then-part, else-part)
作用 :条件判断。condition 非空则执行 then-part,否则执行 else-part。
8. 假想目标(.PHONY)
如果一个目标不是真正的文件(比如 clean),但当前目录下恰好有一个名为 clean 的文件,那么 make clean 会认为目标已经最新,不执行命令。为了避免这种情况,我们把它声明为假想目标:
makefile
.PHONY: clean
clean:
rm -f *.o test
这样 make clean 总会执行命令。
9. 完整示例:一个简单项目的 Makefile
我们以 main.c、sub.c、sub.h 为例,写一个带自动依赖生成的 Makefile。
目录结构
text
.
├── Makefile
├── main.c
├── sub.c
└── sub.h
Makefile 内容
makefile
# 目标文件
TARGET = test
objs = main.o sub.o
# 生成依赖文件列表(.main.o.d .sub.o.d)
dep_files := $(foreach f, $(objs), .$(f).d)
dep_files := $(wildcard $(dep_files))
# 如果有依赖文件,就包含它们
ifneq ($(dep_files),)
include $(dep_files)
endif
# 默认目标
all: $(TARGET)
$(TARGET): $(objs)
$(CC) -o $@ $^
# 编译规则:生成 .o 的同时生成 .d 依赖文件
%.o: %.c
$(CC) -Wp,-MD,.$@.d -c -o $@ $<
# 清理
.PHONY: clean distclean
clean:
rm -f $(TARGET) $(objs)
distclean:
rm -f $(dep_files) $(TARGET) $(objs)
执行过程
- 第一次
make:dep_files为空(没有.d文件),跳过include。- 执行
gcc -Wp,-MD,.main.o.d -c -o main.o main.c,生成main.o和.main.o.d。 - 同样生成
sub.o和.sub.o.d。 - 最后链接
test。
- 修改
sub.h后再次make:dep_files现在有.sub.o.d,其内容为sub.o: sub.c sub.h。- Make 发现
sub.h比sub.o新,所以重新编译sub.o,再链接。 main.o不受影响。
10. 总结与速查
| 概念 | 说明 |
|---|---|
| 规则格式 | 目标: 依赖 + Tab命令 |
| 增量编译 | 根据时间戳决定是否重新生成 |
| 自动变量 | $@、$<、$^ |
| 模式规则 | %.o: %.c |
| 头文件依赖自动生成 | -Wp,-MD,.$@.d + include |
延时变量 = |
使用时才展开 |
立即变量 := |
定义时立即展开 |
| 导出变量 | export VAR |
| 假想目标 | .PHONY: clean |
| 常用函数 | foreach、wildcard、filter、patsubst、shell |
| 通用 Makefile 设计 | obj-y + Makefile.build 递归编译 |
🎉 现在你已经掌握了 Makefile 的核心知识。从最简单的单文件到支持多目录、自动依赖检测的通用模板,只要勤动手练习,你也能写出优雅高效的 Makefile。