
1. 我为什么放着脚本不用非要在项目里放一个Makefile先聊聊我是怎么掉进Makefile这个坑的。早些年做一个嵌入式相关的模拟项目X编译步骤其实不算复杂写好的C文件要编译成目标文件再链接成可执行文件有时候还要跑一下测试程序最后把生成物拷贝到指定目录。一开始我图省事把这一串操作写成了shell脚本每次要编译就./build.sh要清理就./clean.sh看起来也没什么问题。真正让我难受的是改代码之后的重编流程。项目里几十个源文件脚本每次都是从零开始全部重新编译。最开始源码少等CPU几秒钟也就过去了等代码量跑到几百个文件的时候一次完整编译要等好几分钟。我明明只改了一个文件却要盯着屏幕等所有文件全部重编一遍那感觉就像为了热一口剩饭把整个厨房重新装修了一遍。后来我开始琢磨能不能只重新编译那些“变了”的文件这不就是Makefile最擅长的事情吗。GNU Make这套工具本质上做的事情就是把“哪些文件需要重新生成”这个问题变成一张清晰的目标依赖表然后根据文件的时间戳自动判断哪些目标过期了只重建过期的部分。这个思路放在今天依然非常实用不管是写C/C、做嵌入式交叉编译、生成文档还是维护一套复杂的数据处理流水线它都能帮你把构建过程标准化、自动化。这篇文章我打算从一个实际使用的角度而不是从语法手册的角度把Makefile的核心机制、完整搭建过程、多目录项目组织方式以及我这些年踩过的一些坑从头到尾讲一遍。适合刚接触Makefile的初学者也适合那些已经在用但总觉得“哪里不对”的开发者对照着检查一下自己项目的组织方式。2. 规则与依赖把Makefile当成一张“只重算脏数据”的执行表2.1 一条规则背后的三要素Makefile最基本的东西是一条规则长这样main.o: main.c utils.h gcc -c main.c -o main.o第一行叫做“依赖关系”冒号左边的是目标文件main.o右边是它依赖的源文件main.c和头文件utils.h。第二行叫做“命令”前面必须是一个Tab缩进不能是四个空格这是新手最容易犯的第一个错误。这条规则的意思是如果main.o不存在或者main.c、utils.h里任何一个文件的修改时间比main.o更晚那么Make就去执行下面的命令重新生成main.o。你可以把Make理解成一台“只会处理过期数据的机器”。每次运行make的时候它先看目标文件存在不存在不存在就直接重建存在就比较目标和依赖的时间戳只要依赖比目标新就认为目标过期了然后执行对应的命令。这种“时间戳对比 增量更新”的机制和你平时用增量备份软件的逻辑非常像只是它针对的是文件之间的依赖关系。这里需要留意的是Make并不是把所有规则从头到尾按顺序执行一遍。它会先解析整份Makefile在内存里构建出一棵依赖树然后从你指定的最终目标开始深度优先遍历。比如你执行make all如果all依赖main.o和utils.oMake会先去检查main.o需不需要重建检查main.o的时候又得先看它依赖的main.c和utils.h有没有变化。只有确认某个目标确实过期了才会去执行对应的命令。2.2 伪目标别让“clean”变成一个实际文件再讲一个特别有迷惑性的概念——伪目标。最常见的例子是cleanclean: rm -f *.o main如果你在项目目录里执行make clean这条命令就会把.o文件和可执行文件删掉。但是假如你的项目目录里碰巧存在一个名字叫clean的文件你会发现执行make clean的时候Make会提示“clean已是最新”然后什么都不做。原因很简单Make眼里只有文件和依赖关系它认为目标clean已经存在而且没有任何依赖自然就是最新的。解决办法是用.PHONY把这个目标声明为“伪目标”.PHONY: clean clean: rm -f *.o main声明之后Make就不再检查有没有名为clean的文件每次都老老实实执行后面的命令。我自己的习惯是所有不代表实际文件的“动作型目标”比如clean、install、test、format全部都要加上.PHONY声明。这个习惯能避免很多莫名其妙的问题尤其是当你的构建目录里出现了同名文件的时候。2.3 先画图再动手把依赖关系写在纸上我自己在写一份新项目的Makefile时不会急着敲键盘。我会先在纸上把整个项目的构建流程画出来标清楚哪些文件是源文件、哪些是中间产物、哪些是最终交付物它们之间的依赖方向是怎样的。这个习惯看起来很简单但对于后续理解Makefile的行为至关重要。举个例子一个典型的小型C程序可能长这样目标依赖命令作用appmain.o,utils.o,logger.o链接生成可执行文件main.omain.c,utils.h编译utils.outils.c,utils.h编译logger.ologger.c,logger.h编译clean无删除中间产物把这张表画出来之后Makefile要怎么写其实你已经心里有数了。规则之间不是散乱的而是一层一层的依赖链最上层的最终目标依赖中间目标中间目标依赖源文件。Make会自动沿着这条链把需要重建的部分挑出来单独执行。这里还要注意一个容易忽略的问题头文件也是依赖的一部分。很多人写Makefile的时候只在目标后面写了.c文件漏掉了.h文件结果改了头文件之后make毫无反应因为Make根本不知道头文件发生了变化。更稳妥的办法是先让编译器自动生成头文件依赖信息后面我在实战章节会专门讲到这个技巧。3. 从零搭一个可维护的构建入口变量、模式规则与函数组合3.1 先写死再抽象一套最朴素的Makefile我先给一份最朴素的、几乎没有任何“技巧”的Makefile它做的事情很简单编译三个C文件链接成一个可执行文件app。app: main.o utils.o logger.o gcc main.o utils.o logger.o -o app main.o: main.c utils.h logger.h gcc -c main.c -o main.o utils.o: utils.c utils.h gcc -c utils.c -o utils.o logger.o: logger.c logger.h gcc -c logger.c -o logger.o clean: rm -f app *.o .PHONY: clean这份Makefile是能跑的但它有一个明显的问题每增加一个源文件你就要手动追加一条编译规则同时还要修改链接命令里的目标文件列表。刚开始项目小还好文件多起来之后维护成本直线上升。而且手动列出的文件列表特别容易漏漏了哪个链接的时候就会报“未定义引用”的错。更优雅的做法是让Makefile自己去找目录里的源文件再用模式规则统一生成对应的目标文件命令里用自动变量代替手动写的文件名。这样写出来的Makefile新增源文件时几乎不用改任何东西。3.2 变量是Makefile的“配置中心”先介绍变量。Makefile里的变量本质上就是字符串替换但用好了之后整个构建流程的“可配置性”会提升一个档次。CC : gcc CFLAGS : -Wall -O2 -g TARGET : app SRCS : main.c utils.c logger.c OBJS : $(SRCS:.c.o)这里CC是编译器CFLAGS是编译选项TARGET是最终生成的可执行文件名SRCS是源文件列表OBJS是从源文件列表推导出来的目标文件列表。$(SRCS:.c.o)是一种变量替换写法意思是把SRCS里所有.c结尾的字符串替换成.o结尾。有了这些变量之后链接命令可以写成$(TARGET): $(OBJS) $(CC) $(CFLAGS) $(OBJS) -o $(TARGET)你可能会问为什么这里用了:而不是。这两种赋值方式的区别在于求值时机。是递归展开式变量也就是说变量在使用的时候才会去展开展开时如果引用了其他变量也会一层一层地展开:是简单展开式变量在定义的那一刻就会立即把右边的值计算出来。绝大多数情况下我都会用:因为它更直观不容易出现“定义的时候没问题用的时候值变了”这种隐藏坑。3.3 自动变量和模式规则少写一半重复代码接下来是模式规则。考虑下面这条规则%.o: %.c $(CC) $(CFLAGS) -c $ -o $%.o: %.c的意思是任何一个以.o结尾的目标文件都由它对应的.c文件来生成。比如Make需要生成main.o它就会自动寻找同目录下的main.c然后执行后面的命令。这里面的$和$是自动变量$表示依赖列表中的第一个文件$表示当前目标的名字。我简单整理一下最常用的几个自动变量自动变量含义$当前目标名$第一个依赖文件名$^所有依赖文件名的列表重复项会被去除$?所有比目标更新的依赖文件列表$*模式规则中%匹配到的部分模式规则加自动变量一下子就把每个源文件单独写一条规则的麻烦解决了。Make会自动把目标文件foo.o匹配到模式%.o然后找到对应的foo.c。3.4 wildcard、patsubst和shell三兄弟解决“动态找文件”手动维护SRCS列表虽然不算大问题但总有漏掉的时候。更好的办法是让Make自己去目录里搜SRCS : $(wildcard src/*.c) OBJS : $(patsubst src/%.c,build/%.o,$(SRCS))$(wildcard src/*.c)会展开成src目录下所有.c文件的列表这样做的好处是以后新增源文件时Makefile一行都不用改。$(patsubst src/%.c,build/%.o,...)负责把src/foo.c转换成build/foo.o。这里有一个实际项目里常见的问题如果目标文件和源文件不在同一个目录直接用%.o: %.c的模式规则会匹配不上。解决方法是把模式规则写成带目录前缀的形式build/%.o: src/%.c mkdir -p build $(CC) $(CFLAGS) -c $ -o $这条规则的意思是build目录下的任何一个.o文件都从src目录下对应的.c文件编译而来。如果build目录不存在先执行mkdir -p build创建目录再执行编译。注意前缀的作用是“执行命令但不回显”这个细节在Makefile输出比较杂乱的时候非常有用。如果你还想在Makefile里调用shell命令来探测环境可以用$(shell ...)函数。比如常见的做法是获取当前系统类型UNAME : $(shell uname)不过我个人的建议是$(shell ...)能不用就尽量少用因为它会在Make解析阶段执行外部命令如果这个命令本身有问题调试的时候会比较难定位。但如果确实需要跨平台判断它又是一个绕不开的利器。3.5 一个可以直接抄作业的小型C项目示例把上面这些内容组合起来一份可维护的Makefile大概长这样CC : gcc CFLAGS : -Wall -O2 -g TARGET : app SRCDIR : src BUILDDIR : build SRCS : $(wildcard $(SRCDIR)/*.c) OBJS : $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRCS)) $(TARGET): $(OBJS) $(CC) $(CFLAGS) $(OBJS) -o $(TARGET) $(BUILDDIR)/%.o: $(SRCDIR)/%.c mkdir -p $(BUILDDIR) $(CC) $(CFLAGS) -c $ -o $ clean: rm -rf $(BUILDDIR) $(TARGET) .PHONY: clean这份Makefile里已经包含了变量定义、wildcard动态搜索、patsubst路径转换、模式规则、伪目标声明。你只要把SRCDIR换成自己的源码目录基本就能用起来。新增源文件不需要改Makefile删除源文件也不会报错因为编译时Make只关心实际存在的.c文件。可以说这份Makefile已经覆盖了日常小型项目八成以上的需求。4. 目录拆分、多目标与并行构建让Makefile真正成为项目指挥中心4.1 不要让每个子目录都有一套“自己的Makefile”项目规模再大一点源码往往会拆成多个子目录比如src/core/、src/utils/、src/io/。很多初学者这时候会习惯在每个子目录里都放一个Makefile然后在顶层用make -C subdir递归调用。这样做有一个很直接的问题子Makefile之间的依赖关系是松散的改了一个子目录里的头文件其他目录里的Makefile并不会自动知道你经常要手动去各个目录跑一遍。我的做法是坚持“顶层单一Makefile”也就是所有子目录的源文件都汇总到一份Makefile里统一管理。目录结构长这样project/ ├── Makefile ├── include/ │ ├── core.h │ ├── utils.h │ └── io.h └── src/ ├── core/ │ ├── core_a.c │ └── core_b.c ├── utils/ │ └── utils.c └── io/ └── io.c在顶层Makefile里先递归收集所有子目录的源文件再统一生成编译规则SRCS : $(wildcard src/*/*.c) OBJS : $(patsubst src/%.c,build/%.o,$(SRCS))src/*/*.c这个通配符会匹配src目录下所有子目录里的.c文件不需要关心具体有几个子目录。模式规则依然只用一条build/%.o: src/%.c mkdir -p $(dir $) $(CC) $(CFLAGS) -Iinclude -c $ -o $这里比之前多了一个$(dir $)作用是提取目标文件所在的目录然后创建对应的目录结构。-Iinclude是头文件搜索路径让所有源文件都能找到include目录下的头文件。这一套做完之后无论你项目里有多少个子目录顶层Makefile始终只需要维护一份复杂度不会随着目录数量增加。4.2 多目标与“先做什么再做什么”的编排有些项目会同时生成多个产物比如一个可执行文件加一个静态库或者一个主程序加一组测试程序。这种情况下你可以在Makefile里定义多个顶层目标然后用一条“总目标”把它们组织起来all: app libutils.a app: $(APP_OBJS) $(CC) $(APP_OBJS) -o app libutils.a: $(UTILS_OBJS) ar rcs $ $^ test: app ./app --run-tests .PHONY: all test需要注意的是Make默认只执行Makefile里第一条规则的目标所以在文件开头放一条all并把所有要构建的产物都列为它的依赖是最常见的组织方式。test目标依赖app这样你执行make test的时候Make会先检查app需不需要重新构建如果需要就先构建app再执行测试命令。依赖关系在这里不仅仅是“文件生成”的关系也能表达“动作先后顺序”的关系。4.3 并行构建与它带来的“玩具变杀手”现代电脑动辄八核十六核全部串行编译确实浪费。GNU Make提供了-j参数比如make -j8就是同时跑8个任务。用好了编译时间能缩短好几倍。但并行构建也藏着一个很经典的坑如果你的规则之间横跨了多个目录的中间产物并且某些中间产物是靠“副作用”生成的并行模式下就很容易出现“某个文件还没生成另一个任务已经开始用了”的竞态问题。举个例子如果你用了$(shell ...)在Make解析阶段生成文件或者某条规则会顺手修改一个其他规则依赖的文件-j一开就会出现间歇性的构建失败而且不是必现只是偶尔出问题非常难排查。我自己的经验是并行构建的前提是你的Makefile严格遵循“规则只依赖它明确列出的文件命令只修改目标文件”这个原则。如果两条规则之间有隐含依赖一定要显式写清楚。另外-j参数可以写死在Makefile里MAKEFLAGS -j$(shell nproc)nproc是Linux下获取CPU核心数的命令这样每次运行make都会自动用满所有核心。不过我对这种写法持保留态度更推荐在命令行执行时显式指定-j因为不是所有环境都适合跑满并行比如在内存小的机器上并行编译内存会很快被占满。4.4 头文件依赖自动生成不再被“改了头文件却不重编”坑前面提到过头文件必须写进依赖列表里。用手动维护的方式每个源文件对应哪些头文件是个几乎不可能完美维护的列表。实际上编译器的自带的依赖生成功能能帮我们做这件事。GCC和Clang都支持-MMD参数CFLAGS : -Wall -MMD DEPFILES : $(OBJS:.o.d) -include $(DEPFILES)-MMD会在编译每个.c文件时额外生成一个同名的.d文件里面记录的正是这个源文件实际包含的头文件依赖列表。-include $(DEPFILES)负责把这些.d文件读进来这样Make就自动知道了“这个.o文件依赖那一堆头文件”。以后你改头文件Make会自动重编所有包含它的源文件不需要手动维护依赖关系。细心的人可能注意到我写的是-include而不是include。加了个减号的意思是“如果文件不存在不要报错”。因为首次构建的时候.d文件还没生成直接include会把Makefile解析直接中断加上减号就不会有这个风险。这个技巧是我觉得整个Makefile世界里性价比最高的一个特性强烈建议所有项目都用上。5. 三次“什么都没发生”的排查记录Makefile的常见坑与定位办法5.1 坑一clean目标“失效”了真相是一个同名文件有一次我维护一个老的模拟项目X执行make clean的时候终端里什么都没输出文件一个都没删。我第一反应是命令写错了翻看Makefileclean那段写的是rm -f build/*.o app看起来没问题。我又手动执行了一下rm -f build/*.o app文件确实能删掉。接着我试了make clean -n这个参数的意思是“只打印将要执行的命令但不真正执行”输出显示Make认为clean是最新的根本不需要执行后面的命令。这时候我才想起来去看目录下是不是有个叫clean的文件。用ls clean一看果然有个空文件躺在那里。那是在一次手动测试时意外创建出来的文件。这就是我前面强调.PHONY的原因。给所有动作类目标加上.PHONY声明就能避免这个坑。排查这一类问题的顺序我总结成一个小流程先make 目标 -n看Make认为该不该执行再看目标名是否与现有文件重名最后检查命令行前的缩进是Tab还是空格。5.2 坑二改了头文件make却说“一切最新”另一个更隐蔽的问题是源文件改了能正常重编唯独改了头文件之后make毫无反应。这个问题的根源在于依赖列表里没有头文件。比如规则写成main.o: main.cMake只知道main.c变了才需要重建main.o完全不知道main.c里还包含了utils.h。以前解决这个问题要靠手动把utils.h加到依赖列表里后来有了-MMD自动生成.d文件才算根治。但要注意的是如果使用了-MMD.d文件的更新需要重新编译才能触发而触发重新编译又依赖.d文件里记录的头文件依赖这就形成了一个“鸡生蛋、蛋生鸡”的问题。解决办法是首次构建时用-include包含一个当前还不存在的.d文件让Make忽略缺失文件随后的构建中.d文件已经有了头文件变化就能立刻反应出来。如果你在用非GNU系的make工具比如在某些嵌入式IDE内置的make上-MMD可能不可用那就只能退回到手动维护依赖列表或者在脚本里统一扫描头文件生成依赖规则。5.3 坑三并行编译偶发失败依赖关系“隐写”在哪里还有一次我在项目里加了MAKEFLAGS -j8之后编译出现了偶发失败。不是每次都会失败十个源文件的项目有时候连编五次都没事有时候第一轮就挂。报错信息经常是“找不到某个.o文件”或者“链接时缺少某个模块”。我排查了很久才发现问题是这样的某个源文件在编译时会额外生成一个配置文件而另一个目标的命令会在编译过程中读取这个配置文件。但我只在第一条规则里写了“生成配置文件”第二条规则的依赖列表里完全没有提到它。串行执行时因为顺序刚好是先生成后读取所以一直没暴露一旦并行执行两个任务同时启动读取命令跑到生成命令之前就必然出错。修复方法有两个方向。一个是显式把配置文件加入第二条规则的依赖让两条规则之间有明确的先后关系。另一个是把“生成配置文件”这个动作单独拆成一个独立目标让所有依赖它的规则都声明这一点。这个故事给我最大的教训是并行构建是一面照妖镜它会把Makefile里所有隐式依赖全都照出来。如果你的项目在-j下偶发失败首先应该怀疑的不是机器的问题而是规则之间有没有没写清楚的共享产物。5.4 排查Makefile问题时我会用到的调试方法这里分享几个我日常最常用的调试手段。第一个是make -n也就是空跑模式。它会把Make认为需要执行的命令全部打印出来但不会真正执行。在改动Makefile之后先跑一遍make -n看看命令列表是否符合预期能拦截掉很多低级错误。第二个是make -d这是GNU Make的调试模式会输出海量的解析信息包括文件时间戳的比较结果。信息量很大建议输出到文件里再看比如make -d debug.log然后去日志里搜索具体目标名。第三个是make -p它会把整个Makefile解析完成后内部维护的变量和规则数据库打印出来非常适用于确认某个变量到底被展开成了什么值。这三种方法配合使用基本能覆盖九成以上的Makefile排查场景。遇到过make clean不生效、改了头文件不重编、并行编译偶发失败这类问题的人大概率都能在这套方法里找到答案。我在实际使用中最深的体会是Makefile本身不是算法竞赛不需要写出花哨的技巧。把规则写清楚把依赖写完整把伪目标声明好让工具老老实实地做增量构建就已经赢过一大半项目。很多时候项目里的构建问题根源不是Make不懂你的意图而是你的Makefile根本没有把真实依赖关系告诉它。理顺依赖Makefile就顺了。