1. 从一次构建失败说起为什么依赖关系是Makefile的命门很多人第一次写Makefile脑子里想的都是“我要编译哪个文件、用什么命令”于是写出来的东西长这样app: gcc -o app main.c util.c能跑没问题。但只要项目稍微大一点改一个头文件整个项目全量重编或者更糟——改了某个源文件make告诉你“app已是最新”死活不重新编译。这时候你才意识到Makefile真正的主角不是命令而是依赖关系。make这个工具的核心逻辑其实就一句话根据依赖关系判断目标是否需要重建。它维护的是一张有向无环图DAG每个节点是一个目标target每条边是一个先决条件prerequisite。当你执行make时它从默认目标出发递归检查每个目标的先决条件是否比目标本身更新如果是就执行对应的命令重建目标。这个机制听起来简单但实际用起来坑多得让人头皮发麻。我见过太多项目Makefile写得像一锅粥依赖关系要么缺失、要么冗余、要么写错了方向导致构建结果不可复现CI上跑得好好的本地就是编不过。问题的根源几乎都指向同一个地方没有真正理解先决条件prerequisites的语义和用法。这篇文章就是要把“依赖”这件事彻底讲透。从最基础的语法到自动依赖生成再到顺序依赖与并行构建的冲突处理我会把这些年踩过的坑、总结的经验全部倒出来。不管你是刚接触Makefile的新手还是写了几年C/C项目的老手相信都能从中找到之前忽略的细节。先明确一个概念在GNU Make的语境里“先决条件”和“依赖”经常混用但严格来说先决条件prerequisite是写在冒号右边的东西而依赖dependency是两者之间的关系。本文不刻意区分读者根据上下文理解即可。2. 先决条件的三种类型普通依赖、顺序依赖与仅顺序依赖GNU Make的先决条件不是只有一种。很多人写了几年Makefile只知道冒号右边列文件其实Make提供了三种不同语义的先决条件用错了场景轻则构建效率低下重则产生难以排查的竞态问题。2.1 普通先决条件触发重建的核心机制这是最常见的用法app: main.o util.o gcc -o app main.o util.o main.o: main.c defs.h gcc -c main.c -o main.omain.o依赖于main.c和defs.h。当main.c或defs.h的修改时间比main.o新时make就会重新执行编译命令。这个“修改时间比较”是Make判断是否重建的唯一依据所以文件时间戳的准确性至关重要。这里有一个容易被忽略的细节如果先决条件文件不存在Make会直接报错终止除非该先决条件本身也是另一个规则的目标或者你用模式规则/后缀规则能推导出生成它的方法。比如app: main.o util.o gcc -o app main.o util.o如果当前目录下没有main.o和util.o也没有规则能生成它们make会直接报“No rule to make target main.o”。很多新手在这里卡住以为是编译器问题其实是依赖链断了。另一个关键点是先决条件的顺序会影响构建顺序但不决定执行顺序。Make在构建目标之前会先按顺序处理所有先决条件但如果你用了-j并行构建多个先决条件可能同时被处理。这一点后面会详细展开。2.2 顺序先决条件用|分隔的“只保证顺序”GNU Make 3.80之后引入了一个非常有用的特性——顺序先决条件order-only prerequisites语法是用竖线|分隔target: normal_prereq1 normal_prereq2 | order_only_prereq1 order_only_prereq2 command竖线左边的先决条件如果比目标新会触发目标重建竖线右边的先决条件只保证在目标构建之前被构建完成但即使它比目标新也不会触发目标重建。这个特性解决了一个非常经典的场景输出目录的创建。OBJDIR : build $(OBJDIR)/%.o: %.c | $(OBJDIR) gcc -c $ -o $ $(OBJDIR): mkdir -p $(OBJDIR)如果没有|每次build目录的时间戳变化比如里面新增了文件都会导致所有.o文件被重新编译。加上|之后build目录只在不存在时被创建存在时即使比.o文件新也不会触发重编。这个技巧在实际项目中极其常用尤其是当构建产物目录和源文件目录分离的时候。我见过有人用.PHONY来绕过这个问题把目录声明为伪目标但那样每次都会执行mkdir -p虽然mkdir -p本身是幂等的但总归多了一次shell调用而且逻辑上不够干净。顺序先决条件才是正解。2.3 仅顺序依赖的典型误用与纠正顺序先决条件虽然好用但有一个常见的误用把它当成“弱依赖”来用以为“反正不触发重建随便写写没关系”。实际上顺序先决条件仍然会参与构建顺序的调度如果写错了可能导致目标在依赖尚未就绪时就开始构建。举个例子app: main.o | config.h gcc -o app main.o这里config.h是顺序先决条件意味着make会先确保config.h存在如果有规则生成它的话但config.h的修改不会触发app的重建。如果main.o的编译实际上需要config.h的内容而main.o的规则里没有声明对config.h的依赖那么修改config.h后main.o不会重编app也不会重链最终产物就是过时的。这种bug非常隐蔽因为构建过程不会报错只是结果不对。正确的做法是凡是影响编译结果的输入文件都必须作为普通先决条件声明。顺序先决条件只用于那些“只需要存在内容不影响输出”的东西比如输出目录、锁文件、标记文件等。3. 自动依赖生成让头文件依赖不再靠手写手写头文件依赖是Makefile维护中最痛苦的事情之一。一个C文件可能包含十几个头文件头文件之间还有嵌套包含靠人工维护几乎不可能不出错。好在编译器提供了自动生成依赖的机制配合Make的include指令可以实现依赖关系的自动更新。3.1-MMD -MP参数的原理与使用GCC和Clang都支持-M系列参数来输出依赖信息。常用的组合是gcc -MMD -MP -c main.c -o main.o-MMD生成依赖文件但不包含系统头文件比如/usr/include下的。依赖文件默认与目标文件同名扩展名换成.d即main.d。-MP为每个先决条件除了主文件生成一个空的伪目标规则。这是为了防止头文件被删除后make报“No rule to make target”错误。生成的main.d内容大致如下main.o: main.c defs.h util.h defs.h: util.h:注意那些空的defs.h:和util.h:规则这就是-MP的作用。当头文件被删除时Make会认为这个目标存在但没有命令不会报错只是跳过。如果没有-MP删除头文件后make会直接失败提示找不到规则。在Makefile中你需要把.d文件包含进来SRCS : main.c util.c OBJS : $(SRCS:.c.o) DEPS : $(OBJS:.o.d) app: $(OBJS) gcc -o $ $^ %.o: %.c gcc -MMD -MP -c $ -o $ -include $(DEPS)-include前面的减号表示如果.d文件不存在不要报错。第一次构建时.d文件还没生成这是正常的。3.2 依赖文件的包含时机与陷阱这里有一个非常关键的细节-include $(DEPS)这行代码放在Makefile的什么位置会影响依赖关系的加载时机。Make解析Makefile是自上而下的include指令会在解析到那一行时立即读取被包含的文件。如果.d文件是在本次make运行中生成的那么它会在下一次make运行时才被包含进来。这意味着第一次构建时头文件依赖是不生效的。只有第二次及以后的构建.d文件存在了依赖关系才完整。这不是bug而是Make的工作方式。如果你修改了一个头文件但对应的.d文件还没生成比如新增了一个源文件那么第一次构建不会因为头文件变化而重编。第二次就好了。为了避免这个问题有些项目会在构建前先跑一遍“生成依赖”的步骤或者用make depend这样的伪目标来强制生成。但在大多数场景下依赖文件在第一次构建后就会生成后续构建都能正常工作所以这个问题影响不大。另一个陷阱是.d文件本身也需要被清理。很多项目的clean规则只删了.o和可执行文件忘了.d。结果就是如果你删除了某个源文件但对应的.d文件还在make会尝试包含一个引用了不存在文件的依赖文件导致报错。正确的clean规则应该同时删除.d文件clean: rm -f $(OBJS) $(DEPS) app3.3 多目录项目的依赖管理策略当项目有多个子目录时依赖管理会变得复杂。常见的做法是每个子目录有自己的Makefile顶层Makefile通过递归调用$(MAKE) -C subdir来构建。但递归Make有一个著名的问题依赖关系无法跨目录传递。比如src/目录下的main.o依赖于include/defs.h但src/Makefile不知道include/目录的存在include/目录的Makefile也不知道main.o依赖它。如果修改了defs.hmake在src/目录下不会触发main.o的重编。解决这个问题有几种思路第一种是非递归Make用一个顶层Makefile管理所有源文件通过VPATH或vpath指定搜索路径。这种方式依赖关系完整但Makefile会变得很大而且需要仔细处理路径问题。第二种是在递归Make中显式声明跨目录依赖。比如在顶层Makefile中SUBDIRS : src lib all: $(SUBDIRS) $(SUBDIRS): $(MAKE) -C $ src: lib这样src的构建会在lib之后进行但具体的文件级依赖仍然缺失。如果lib中的头文件被修改src中的目标不会自动重编除非你在src/Makefile中手动声明对../lib/xxx.h的依赖。第三种是使用-include配合自动依赖但把.d文件集中存放。比如所有.d文件都生成到build/deps/目录下顶层Makefile统一包含。这样跨目录的依赖也能被捕获因为编译器在生成依赖时会记录头文件的完整路径相对于编译时的当前目录。我个人在中小型项目中倾向于非递归Make依赖关系清晰调试方便。大型项目比如超过几百个源文件可能会考虑CMake等更高级的构建系统但那是另一个话题了。4. 并行构建下的依赖陷阱-j不是万能的make -j是提升构建速度的利器但它对依赖关系的正确性要求极高。在串行构建下即使依赖关系有缺失构建顺序碰巧对了结果也是对的。但在并行构建下任何依赖缺失都可能导致竞态条件表现为随机失败、产物不一致等难以复现的问题。4.1 并行构建的调度逻辑Make在并行构建时会维护一个“就绪队列”。一个目标只有在所有先决条件都构建完成后才会被放入就绪队列等待执行。-j N表示最多同时运行N个命令。关键点在于Make只根据你声明的依赖关系来判断“就绪”。如果你没有声明某个依赖Make就认为它不需要等待可能会在依赖尚未生成时就开始构建目标。举个例子app: main.o util.o gcc -o app main.o util.o main.o: main.c gcc -c main.c -o main.o util.o: util.c generated.h gcc -c util.c -o util.o generated.h: ./generate.sh generated.h这个Makefile看起来没问题util.o依赖generated.hgenerated.h有规则生成。但在make -j下main.o和util.o可能同时开始构建。util.o会等待generated.h生成但main.o不会。如果main.c也包含了generated.h但依赖里没写那么main.o可能在generated.h生成之前就开始编译导致编译失败或使用了旧版本的头文件。这种问题的排查非常困难因为串行构建下可能完全正常只有并行构建才会偶发失败。解决方法是确保所有实际存在的依赖都被显式声明。自动依赖生成-MMD在这里就非常重要了它能捕获所有头文件依赖包括生成的头文件。4.2 顺序先决条件在并行构建中的价值顺序先决条件在并行构建中有一个特殊价值它可以用来表达“这个目标必须在那个目标之后构建但不需要触发重建”。这在处理生成文件时特别有用。比如你有一个代码生成器它生成多个头文件generated.h generated.c: generator ./generator util.o: util.c | generated.h generated.c gcc -c util.c -o util.o这里generated.h和generated.c是顺序先决条件保证它们在util.o之前生成但它们的修改不会触发util.o的重编。等等这不对——如果生成的头文件内容变了util.o应该重编才对。所以这里应该用普通先决条件util.o: util.c generated.h gcc -c util.c -o util.o顺序先决条件更适合这种场景build/: mkdir -p build build/%.o: %.c | build/ gcc -c $ -o $build/目录只需要存在它的时间戳变化不应该触发重编。4.3 一个真实的竞态问题排查过程我曾经遇到过一个并行构建的竞态问题项目在CI上使用make -j8构建大约每十次构建会出现一次失败错误信息是某个.o文件编译时找不到某个头文件。但单独构建那个.o文件又完全正常。排查过程如下第一步确认头文件确实存在且路径正确。手动编译没问题说明不是路径配置错误。第二步检查Makefile中的依赖声明。发现那个头文件是由一个代码生成工具生成的生成规则写在另一个子目录的Makefile中。当前子目录的Makefile只声明了对生成工具可执行文件的依赖没有声明对生成的头文件的依赖。第三步分析并行构建的时序。在-j8下生成工具的执行和.o文件的编译可能同时进行。由于没有声明对头文件的依赖Make认为.o文件可以立即开始编译而此时头文件可能还没生成。第四步修复。在.o文件的规则中显式添加对生成头文件的依赖%.o: %.c $(GENERATED_HEADERS) gcc -c $ -o $同时确保$(GENERATED_HEADERS)有正确的生成规则并且生成规则本身也声明了对生成工具的依赖。修复后CI上的随机失败消失了。这个案例的教训是在并行构建下任何隐式依赖都必须显式化。编译器能找到的头文件Make不一定知道。5. 依赖关系的调试与验证怎么确认写对了写了依赖关系之后怎么验证它是对的Make提供了一些调试选项可以帮助你查看依赖图和执行过程。5.1make -d和make -p的输出解读make -d会输出大量的调试信息包括Make如何判断每个目标是否需要重建、先决条件的比较结果等。输出非常冗长通常需要配合grep来过滤。比如make -d 21 | grep -A5 main.o可以看到Make对main.o的详细判断过程。make -p会打印出Make内部的所有规则和变量包括隐式规则和自动生成的依赖。这个输出更适合用来检查依赖关系是否如你预期make -p -n 2/dev/null | grep ^main.o:-n表示不实际执行命令只打印。这样可以安全地查看依赖关系。5.2 用--dry-run验证构建顺序make -n或--dry-run会打印出将要执行的命令但不实际执行。结合-j可以模拟并行构建的调度make -n -j8虽然不会真正并行执行但可以看到Make计划以什么顺序执行命令。如果发现某个命令的执行顺序不符合预期说明依赖关系可能有问题。5.3 依赖关系检查清单在实际项目中我通常会对照以下清单来检查依赖关系是否正确检查项说明常见问题源文件依赖每个.o是否依赖对应的.c漏写导致修改源文件不重编头文件依赖是否通过-MMD自动生成手写遗漏导致修改头文件不重编生成文件依赖生成的头文件/源文件是否被正确依赖并行构建竞态目录依赖输出目录是否用顺序先决条件目录时间戳变化导致全量重编工具依赖代码生成器等工具是否被依赖工具更新后不重新生成清理规则clean是否删除了所有中间文件.d文件残留导致后续构建报错这个清单看起来简单但每一条我都见过实际项目踩坑。尤其是头文件依赖和生成文件依赖在项目规模变大后几乎必然出问题。6. 从依赖关系看Makefile的设计哲学写了这么多关于依赖的技术细节最后想聊一点更宏观的东西。Make这个工具诞生于1976年距今已经快五十年了但它的核心设计思想——基于依赖关系的增量构建——至今仍然是构建系统的基石。无论是CMake、Bazel还是Ninja底层都离不开依赖图这个模型。Makefile的依赖关系之所以让人又爱又恨是因为它把复杂性暴露给了用户。你需要自己声明每一条依赖自己处理头文件、生成文件、目录、工具之间的依赖。这种“手动挡”的方式在小型项目中很灵活在大型项目中就容易失控。但反过来想正是这种暴露让你不得不去思考“这个目标的输入到底是什么”。很多构建问题的根源其实是开发者自己都没想清楚依赖关系。Make只是把这个事实暴露出来了而已。我个人的经验是把依赖关系当成文档来写。每一条先决条件都在告诉后来者“这个文件的变化会影响这个目标”。当依赖关系清晰完整时Makefile本身就是最好的项目结构说明。反之如果依赖关系一团糟那说明项目的模块划分和接口设计可能也有问题。所以下次写Makefile的时候不妨多花几分钟想想先决条件该怎么写。这几分钟的投入会在后续无数次的构建中回报你。
阅读完成 · 觉得有帮助?