先聊个现象。在Linux下做C/C++开发,makefile是绕不过去的一道坎。很多人一开始翻教程,看见满屏的$@、$<、%直接劝退;也有人觉得无所谓,gcc main.c -o main一条命令搞定,为什么要学这个?两种心态我都经历过,但等项目从几个文件变成几十个文件、每次改一个头文件就得全体重编的时候,你才会意识到,makefile不是给项目添麻烦,而是帮你把“什么该编译、什么不该编译、怎么编译”这件事系统性地管了起来。这篇文章我打算用五次代码结构的迭代,从最简单的单文件规则开始,一步步把makefile的原理、变量、自动变量、伪目标、函数、模式规则这些知识拆开讲清楚。整个过程不依赖任何图形界面,纯命令行环境就能跟着操作,适合Linux初学者、刚接触嵌入式开发,或者准备面试想系统梳理makefile知识的人。
1. 第一次迭代:单文件场景下,让make先跑起来
1.1 最简单的makefile长这样
假设你有一个hello.c,里面就是经典的printf("hello\n")。正常情况下你会在终端敲:
gcc hello.c -o hello现在把它写进一个叫makefile(或Makefile)的文件里:
hello: hello.c gcc hello.c -o hello然后终端执行make,屏幕上会打印出gcc hello.c -o hello,当前目录出现可执行文件hello。就这么简单,你其实已经掌握了makefile最核心的结构:第一行是“目标+冒号+依赖列表”,第二行是“编译命令”。命令前面那个缩进必须是Tab键,不能是四个空格,这是makefile最著名的坑,后面我会专门展开说。
这里有个很多人第一次接触make时容易误解的点:make本身不是一个编译器,它只是个“按规则执行命令”的调度器。gcc hello.c -o hello这条命令最终还是让gcc去干活,make只是负责判断“这条命令该不该执行、什么时候执行”。
1.2 目标、依赖与规则:makefile的三大要素
我们可以把makefile里的每个“配方”抽象成三件事:
- 目标(target):你要生成的东西,可以是可执行文件、目标文件
.o,也可以是一个动作名,比如clean。 - 依赖(prerequisites):生成目标之前需要先准备好的文件,可以是源文件、头文件,也可以是其他目标。
- 规则(recipe):真正执行的命令,告诉make怎么从依赖生成目标。
对应到上面那个例子,hello是目标,hello.c是依赖,gcc hello.c -o hello是规则。
make执行时有个简单的逻辑:如果目标文件不存在,或者目标文件比任何一个依赖文件“旧”(时间戳更早),那么make就执行规则里的命令来重建目标;如果目标存在且比所有依赖都新,make就告诉你hellois up to date,什么都不做。
这个“比较时间戳”的机制就是make能实现增量编译的底层原因。你可以自己做个实验:第一次执行make,gcc会跑一遍;紧接着再执行一次make,它就会说make: 'hello' is up to date.,因为hello已经比hello.c新了。如果你用touch hello.c把源文件时间戳刷成当前时间,再执行make,它又会重新编译一次——哪怕hello.c的内容并没有变化。
1.3 文件名与-f参数:为什么有人会报“No makefile found”
默认情况下,make会在当前目录按照GNUmakefile、makefile、Makefile的顺序查找文件。如果你把文件命名为build.mk,直接敲make就会报:
make: *** No targets specified and no makefile found. Stop.
这个报错有两个触发条件:一是当前目录确实没有makefile,二是makefile文件名不匹配默认规则。解决办法有两个:要么把文件重命名为makefile或Makefile,要么用-f参数显式指定:
make -f build.mk从工程习惯上讲,我建议统一用Makefile这个命名。原因有两个:第一,很多开源项目和CI脚本默认找的就是Makefile;第二,在某些文件管理器里,大写开头的文件会排在前面,好找。当然这只是个习惯,不是强制要求,你用makefile也完全没问题。
2. 第二次迭代:多文件工程里,用变量管好编译参数
2.1 从单文件到多文件,手工编译开始变得繁琐
真实项目几乎不可能只有一个源文件。假设你的项目变成了三个文件:main.c、utils.c、utils.h,其中main.c和utils.c都引用了utils.h。
如果继续用第一版的思路,你可能会写:
app: main.o utils.o gcc main.o utils.o -o app main.o: main.c utils.h gcc -c main.c -o main.o utils.o: utils.c utils.h gcc -c utils.c -o utils.o这个版本已经能跑了,但有个很明显的问题:gcc和编译选项散落在每条规则里。一旦你想加上优化参数-O2,就得改三处;万一漏改一处,两个目标文件的编译选项不一致,排查起来特别恶心。这种“同一个参数写N遍”的代码,迟早会出问题。
2.2 引入变量:改一处,处处生效
解决重复的最好办法就是把公共内容抽成变量。改造一下:
CC = gcc CFLAGS = -Wall -g TARGET = app OBJS = main.o utils.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $@ $^ main.o: main.c utils.h $(CC) $(CFLAGS) -c $< -o $@ utils.o: utils.c utils.h $(CC) $(CFLAGS) -c $< -o $@这里定义了四个变量:CC是编译器,CFLAGS是编译参数,TARGET是最终可执行文件,OBJS是中间目标文件列表。使用变量时统一用$(变量名)引用,make会在解析时把它替换成对应的值。
这么改的好处立竿见影:以后想加一个-O2优化参数,只需要改CFLAGS这一处;想换成clang编译,把CC = clang即可。整个makefile瞬间清爽了很多。
这里要解释一下为什么CC而不是直接用gcc。因为make本身内置了一些默认变量,CC就是其中之一,默认值是cc,而cc在绝大多数Linux发行版上是一个指向gcc的符号链接。所以你在makefile里写CC = gcc其实是覆盖默认值,这符合惯例。很多开源项目还会让用户通过命令行覆盖它:
make CC=clang CFLAGS="-O2 -Wall"命令行里传入的变量会覆盖makefile里的赋值,这是make设计上的一个特性,实际用起来非常方便。
2.3 =、:=、?=的区别:变量语法里的细节
说到变量,就不得不提makefile里几种赋值符的区别。这也是面试里高频出现的问题。
=:递归展开赋值。变量在真正被使用时才展开,且展开时能看到其定义之后的值。:=:简单展开赋值。变量在赋值时立即展开,值就固定下来了。?=:如果变量没有被赋值过,才赋该值;如果之前已经赋过值,则跳过。+=:追加赋值,在原有值后面继续追加。
举个具体的例子说明=和:=的区别:
A = hello B = $(A) world A = hi all: @echo $(B)用=定义B时,B在echo那一刻才展开,此时A已经是hi了,所以输出是hi world。如果把B改成B := $(A) world,那么B在赋值那一刻就固定为hello world,后面A再变也影响不到它。
实际写makefile,我推荐能用:=就用:=,因为它的行为更可预测,不容易被后续变量变动“暗算”。?=常用于允许外部覆盖的默认配置,比如:
CFLAGS ?= -O2这样用户执行make CFLAGS=-g时,你自己的默认值不会把它覆盖掉。
3. 第三次迭代:搞懂增量编译,善用自动变量和伪目标
3.1 增量编译的底层逻辑:时间戳比较
工程一大,编译时间就会变长。如果每次只改了一个.c文件,却要把整个项目全部重新编译一遍,谁也受不了。make的增量编译机制就是为这个场景设计的:它通过比较目标文件和依赖文件的时间戳,判断哪些目标需要重建。
举个典型场景:修改了utils.c,没改main.c。make在解析app: main.o utils.o时,会逐个检查main.o和utils.o是否需要更新。main.o的时间戳比main.c和utils.h都新,所以跳过;utils.o的时间戳比utils.c旧,所以重新执行gcc -c utils.c。最后链接时,app比utils.o旧,于是重新执行链接命令。
这个过程非常像你收拾房间:看见哪件东西的位置不对了,就把它归位;位置对的就不动它。make也是这个思路,但它只认时间戳。所以要记住一个关键点:如果你手动修改了某个文件却把它的时间戳改回去了(比如用touch -d指定旧时间),make会认为它没变,从而跳过编译。这种问题非常隐蔽,一旦遇到“我明明改了代码,为什么编出来的还是老行为”,先检查时间戳。
3.2 $@、$<、$^:让规则更简洁的自动变量
第二版makefile里已经出现$@和$<了,这一节集中讲清楚。自动变量是make在每条规则执行前自动设置的变量,只在该规则内有效。
| 变量 | 含义 |
|---|---|
$@ | 当前规则的目标文件 |
$< | 当前规则中的第一个依赖文件 |
$^ | 当前规则中的所有依赖文件,以空格分隔 |
$? | 当前规则中所有比目标文件新的依赖文件列表 |
$(@D) | 目标文件的目录部分 |
$(@F) | 目标文件的文件名部分 |
用自动变量重写编译规则之后就非常简洁:
main.o: main.c utils.h $(CC) $(CFLAGS) -c $< -o $@$<在这里就是main.c,$@就是main.o。你再也不用担心文件名写错,因为它是自动替换的。
链接规则里用$^也很方便:
$(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $@ $^$^会把main.o utils.o全部带上。注意$^会自动去重,如果同一个依赖文件出现两次,make只会保留一个。如果想保留重复项,要用$+,不过日常场景很少用到。
3.3 .PHONY伪目标:clean为什么需要声明
几乎每个makefile里都会有一个clean目标,用来删除中间文件和可执行文件:
clean: rm -f $(TARGET) $(OBJS)但如果你直接这么写,会遇到一个坑:假设当前目录下恰好有一个叫clean的文件,执行make clean时,make会检查clean这个目标有没有依赖。这里clean没有依赖,如果clean文件已经存在,make会认为它“不需要更新”,于是什么都不做。你会看到make: 'clean' is up to date.,rm命令根本没执行。
解决方式就是把clean声明为伪目标:
.PHONY: clean clean: rm -f $(TARGET) $(OBJS).PHONY告诉make:这个目标不代表真实文件,不要检查时间戳,每次都要执行它的规则。同理,install、test、format这些动作型目标都应该声明为.PHONY。
顺带说一句,clean只是约定俗成的名字,你完全可以叫rm或cleanup,但希望大家能统一用clean,因为这是几乎所有开源项目都遵守的惯例,别人接手你的makefile时不用猜。
4. 第四次迭代:用函数和路径搜索,解放双手
4.1 用wildcard自动收集源码文件
第二次迭代里,我们用OBJS = main.o utils.o手动罗列目标文件。项目文件一旦变多,比如十几个源文件,手动维护这个列表就非常痛苦。此时可以用wildcard函数让make自己去目录里找文件:
SRCS = $(wildcard src/*.c)这行代码会把src目录下所有.c文件的完整文件名(带路径)收集起来,存到SRCS变量里。以后在src目录下新建一个.c文件,makefile都不用改。
有了源文件列表,再用patsubst批量生成对应的目标文件列表:
OBJS = $(patsubst src/%.c, build/%.o, $(SRCS))patsubst是make内置的模式替换函数,格式是$(patsubst 原模式, 目标模式, 文本列表)。上面的写法会把src/main.c替换成build/main.o,把src/utils.c替换成build/utils.o。这样源码列表和目标文件列表始终一一对应,不会出现“加了一个文件但忘了编译它”的尴尬。
4.2 用一个例子串起来:源码和产物分离
实际工程里,我一般会把源码放在src目录,头文件放在include目录,编译产物放在build目录。这样做的好处是源码目录很干净,不会堆积一堆.o文件和可执行文件,清理时直接删掉build目录就行。
一个典型的结构长这样:
CC := gcc CFLAGS := -Wall -Wextra -Iinclude TARGET := app BUILD_DIR := build SRC_DIR := src SRCS := $(wildcard $(SRC_DIR)/*.c) OBJS := $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS)) $(BUILD_DIR)/%.o: $(SRC_DIR)/%.c mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) -c $< -o $@ $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $@ $^ .PHONY: clean clean: rm -rf $(BUILD_DIR) $(TARGET)注意编译规则里有个mkdir -p $(BUILD_DIR),因为gcc不会自动创建不存在的目录。每次编译前先确保build目录存在,不然会报“No such file or directory”。
4.3 VPATH与vpath:让make找到分散的源码
有时候源码不在当前目录,甚至分散在多个目录里,比如src1、src2、common。你在规则里写:
app: main.o utils.o而main.c在src1里,utils.c在src2里。make默认只在当前目录找依赖文件,找不到就会报“No rule to make target 'main.o'”。
这时候有两种解决办法。第一种是用VPATH变量,它指定make去哪些目录搜索依赖文件:
VPATH = src1:src2:common第二种是更精细的vpath指令,可以按文件类别分别指定搜索路径:
vpath %.c src1 src2 common vpath %.h include commonVPATH和vpath的区别在于粒度不同。VPATH是全局的,所有文件类型都用同一组路径;vpath可以针对不同后缀类型指定不同的搜索路径,比如.c文件去src1 src2找,.h文件去include找。当项目结构比较复杂时,vpath明显更可控。
需要提醒一点:VPATH解决的是make“找依赖文件”的问题,不是给gcc找头文件用的。gcc找头文件靠的是-Iinclude这种编译选项,两者别搞混。
5. 第五次迭代:模式规则与依赖文件生成,接近实际工程
5.1 模式规则:一条规则搞定所有同类型文件
到了这一步,makefile里最让人头疼的就是重复的编译规则了。假如项目有10个.c文件,难道要写10条xxx.o: xxx.c的规则吗?当然不用。make支持模式规则,用%作为通配符,匹配任意非空字符串。
build/%.o: src/%.c $(CC) $(CFLAGS) -c $< -o $@这条规则表示:“任何一个build/xxx.o文件,都可以由对应的src/xxx.c文件生成”。make在需要构建build/main.o时,会用main替换%,自动套用这条规则。
有了模式规则,前面例子里的多条编译规则可以压缩成一条,整个makefile一下短了一大截。这也是make相比手写shell脚本最大的优势:规则是可推导的,而不是一份死板的清单。
5.2 静态模式:精准约束一批目标
模式规则虽然方便,但它是“全项目生效”的,任何以build/%.o为目标的需求都会匹配上。有时候你想限定某一批文件使用特定规则,可以用静态模式:
$(OBJS): build/%.o: src/%.c $(CC) $(CFLAGS) -c $< -o $@静态模式的语法是“目标列表: 目标模式: 依赖模式”。它会强制要求OBJS里的每个目标都匹配build/%.o格式,并把对应的.c文件作为依赖。如果某个目标不符合模式,make会直接报错,这相当于一种校验,可以提前暴露拼写错误。
静态模式比普通模式规则更安全,也更适合“一批文件用一种规则,另一批文件用另一种规则”的场景。比如test目录下的文件用特殊的测试编译选项,而src目录下的文件用普通选项,这时候静态模式就很好使。
5.3 用-MMD自动生成头文件依赖
聊到这里,有个长期被忽视的问题终于要正面解决了:头文件依赖。前面我们写main.o: main.c utils.h,是手动把utils.h列为依赖。一旦项目头文件多了,手动维护这个列表几乎不现实,漏写一个头文件,就会出现“改了头文件却不会触发重新编译”的诡异问题。
现代gcc提供了一个非常实用的解决方案:用-MMD选项在编译时自动生成依赖文件。它的原理是让gcc在编译main.c的同时,扫描它包含的所有头文件,然后把依赖关系写入一个.d文件里。make再用-include把这些.d文件包含进来,就能准确知道每个.o依赖于哪些头文件。
在makefile里落地这个方案只需要一步:
CFLAGS := -MMD -MP -Wall -Iinclude DEPS := $(OBJS:.o=.d) -include $(DEPS)-MMD表示生成依赖文件但不影响正常编译,-MP会为每个头文件生成一个空的伪目标,防止头文件被删除后make因为找不到目标而报错。DEPS := $(OBJS:.o=.d)是把build/main.o替换成build/main.d。-include前面的减号表示:如果.d文件不存在,不要报错——因为第一次编译时这些文件确实还不存在。
加上这几行之后,你就不再需要手动写“哪个.o依赖哪个.h”了,编译器替你完成了这件事。这个机制也是很多现代构建系统(比如CMake生成的Makefile)底层的依赖跟踪思路。
第五次迭代的完整makefile可以浓缩成下面这样:
CC := gcc CFLAGS := -MMD -MP -Wall -Wextra -Iinclude TARGET := app BUILD_DIR := build SRC_DIR := src SRCS := $(wildcard $(SRC_DIR)/*.c) OBJS := $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS)) DEPS := $(OBJS:.o=.d) $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $@ $^ build/%.o: src/%.c mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) -c $< -o $@ -include $(DEPS) .PHONY: clean clean: rm -rf $(BUILD_DIR) $(TARGET)这个makefile已经具备了一个中型C工程的基本要素:变量配置、自动收集源码、自动生成头文件依赖、伪目标、模式规则。从第一次迭代走到这里,每一步都是在解决前一步暴露出来的痛点。
6. 常见报错排查:makefile最典型的几个坑
6.1 “No targets specified and no makefile found”的几种解法
这个报错我在前面提过一嘴,这里补充完整。它最常见的原因有三个:
- 当前目录确实没有makefile,或者写错了文件名。
- makefile存在,但当前shell的当前目录不对,你需要在正确的目录下执行make。
- 使用了
-f指定文件,但路径写错了。
排查顺序很简单:先ls看看文件在不在,再确认文件命名是否符合默认规则,最后看当前目录对不对。如果是从别处拷贝的makefile,还要检查文件是否被完整覆盖了,有时候拷贝中断会留下一个0字节的makefile,make照样会报错。
6.2 “missing separator”与Tab/空格之争
这个报错非常经典:makefile:2: *** missing separator. Stop.十有八九是你把规则命令前面的Tab写成了空格。
makefile的语法规定,规则底下的命令必须以Tab字符开头。make用Tab来区分“这是一条命令”还是“这是另一条规则”。如果你用了四个空格,make会认为你在规则描述的位置写了非法内容,于是报“missing separator”。
有人觉得这个设计很坑,但它的历史原因是早期makefile要兼容老式编辑器,Tab是当时最常用的缩进字符。我自己踩过无数次这个坑,后来总结出一个习惯:写makefile时立刻在编辑器里开启“显示不可见字符”功能,Tab显示为一条横线,空格显示为点,一眼就能看出来区别。
如果你拿到一个别人的makefile,怎么知道它是Tab还是空格?可以执行:
cat -A makefileTab会显示为^I,空格不显示,一查便知。这个命令在排查问题的时候特别好用。
6.3 “No rule to make target”的排查思路
make: *** No rule to make target 'foo.o', needed by 'app'. Stop.主要原因是make找不到生成foo.o的规则,或者找不到对应的源文件。常见情况有以下几种:
- 源文件路径写错了。比如规则里写
src/%.c,但实际文件在source/目录下。 wildcard函数没匹配到文件。可以用$(info $(SRCS))打印变量内容,确认SRCS是否为空。- 依赖了某个不存在的头文件,make在解析
.d文件时报错。这时候直接打开对应的.d文件,看里面记录的头文件路径是否存在。 - 同时存在多个同名目标文件,但make只匹配了一个。
排查思路就一句话:把makefile里的变量都打印出来看。在makefile里加一行$(info 调试信息: $(SRCS)),执行make时就会在解析阶段打印对应内容,比凭感觉猜快得多。
6.4 调试工具:make -n / make -d
最后分享几个我高频使用的调试命令。
make -n是干跑模式,它会打印出将要执行的命令,但不会真正执行。如果你想确认“改了源文件之后,make到底打算重编哪些文件”,先用make -n预览一遍,再决定要不要真正执行。这个命令我几乎每天都会用,尤其是清理了一些中间文件之后,想预测一下下次编译会做什么。
make -d会输出大量调试信息,包括每个目标的时间戳比较结果。虽然信息量很大很刷屏,但当你怀疑“make为什么没有重新编译某个文件”的时候,它的参考价值最大。建议用make -d 2>&1 | grep -A 5 "main.o"这样过滤一下,只关注你关心的目标。
make -p可以打印出make的所有内置规则和变量,适合用来查默认规则。有时候你发现make“自动”会编译.c文件,靠的就是这些内置规则,-p能帮你看清楚它们长什么样。
7. 最后聊点我自己的使用心得
写makefile这件事,一开始会觉得语法古怪、细节贼多,但用顺手之后你会发现,它的设计逻辑非常统一:一切围绕“目标-依赖-规则”展开,变量和函数只是用来减少重复的手段。我最开始写makefile也是从简单的单文件规则开始,后面被项目逼着去学变量、自动变量、函数、模式规则,每一步刚好对应一个实际痛点。所以如果你读完这篇文章还有哪里觉得不踏实,最好的办法是找个几行代码的小项目,亲手把五次迭代分别写一遍,感受一下每个版本解决什么问题、引入什么新问题。这比单纯背语法记得牢得多。
另外还有个很实用的小技巧:想快速理解任何一个别人的makefile,先运行make -n看它最终会执行哪些命令,然后再倒回去看那些命令对应的变量是从哪来的。这个方法我试过很多次,尤其在接手不熟悉的老项目时,比逐行读makefile快得多。makefile的知识面其实不算深,但胜在细节多,只要掌握“怎么排查、怎么调试”,就足够应付绝大多数日常开发了。