☰
手写Makefile的四个深坑:头文件依赖、并行构建与增量编译修正方案
2026/10/23 7:02:01 网站建设 项目流程

“改了 .h 不重编,-j 一开就炸”这个现象,几乎每个手写构建脚本的人都会撞上。我之前帮一个团队排查构建问题,对方描述得很典型:串行编译一切正常,增量编译偶尔漏文件,一旦加上-j并行,整个构建就开始随机崩。最后问题全部收敛到四个点上:头文件依赖没进依赖图、并行调度撞上隐式依赖、源文件列表维护方式太粗糙、以及时间戳和假目标被误操作。这篇就把这四个坑逐一说透,给出一套可以直接抄的修正方案,再附上我平时排查构建问题用的几板斧。

这套内容适合正在手写 Makefile、或者刚接手一个没有完整依赖管理的 C/C++ 项目的开发者。看完之后,你至少能回答三个问题:为什么改头文件不触发重编、为什么并行编译会随机失败、以及如何用几句 Makefile 语法把这些问题彻底按下去。

1. 为什么手写构建脚本容易踩坑

1.1 串行能过、并行就炸,本质是依赖图不完整

Make、Ninja、CMake 生成器这类构建工具,核心逻辑都是同一件事:维护一张“文件依赖图”。节点是目标文件,边是依赖关系,比如parser.o依赖parser.cpp和config.h。构建时,工具沿着这张图做时间戳比较,决定哪些节点需要重建,哪些可以跳过。

串行编译时,make 会按某个固定的顺序执行命令。这个顺序如果碰巧正确,即使依赖图里有缺口也能跑通。比如目标app依赖gen.h和a.o,你的规则里把gen.h写在前面,串行时它会先生成gen.h,再编译a.o,一切正常。但a.o本身并没有声明依赖gen.h,缺了这条边。

一旦你执行make -j4,make 会同时调度所有“当前可以执行”的目标。a.o和gen.h变成两个互不等待的独立任务,如果a.o的编译命令先跑起来,而a.cpp里又通过#include "gen.h"引用了它,编译器就直接报fatal error: gen.h: No such file or directory。

拿做饭打比方:一个人进厨房,先切菜再炒菜,哪怕你忘了写“切菜是炒菜的前置步骤”,顺序对了照样出菜。两个人同时进厨房,你这边已经开火爆炒,那边还没切完菜,这盘菜就毁了。并行构建不是让构建变复杂了,它只是让原本藏在顺序里的依赖问题彻底曝光。

1.2 make 判断“该不该重编”的机制,比你想的简单

make 的决策逻辑只有一句话:目标文件不存在,或者它比任何一个前置文件旧,就重新生成。这句话里最关键的是“任何”两个字。目标对应一个规则,规则里声明了几个前置文件,make 就只比较这几个文件的时间戳。没有声明前置,make 就认为这个目标和别人没有关系。

这里有一个非常容易忽略的点:make 默认的内置规则只追踪同名源文件。你写了一个parser.cpp,make 知道parser.o依赖parser.cpp,但如果你在parser.cpp里#include "config.h",make 对此一无所知。头文件对 make 来说完全是透明的,它不会去读源码文件,也不会区分配置头文件、内联实现头文件还是普通声明文件。

这就是“改了 .h 不重编”的根源:不是编译器不认头文件,而是构建工具根本没有拿到头文件与目标文件之间的关系。要解决它,必须把这份关系显式地写进 Makefile,或者用编译器自动生成一份依赖清单,再让 make 加载进来。后面第三章会讲具体写法。

1.3 什么时候适合手写构建脚本,什么时候该换工具

手写 Makefile 的适用范围很明确:中小型项目、编译链路简单、对编译器参数有精细控制需求、不想引入额外依赖或构建环境受限。它的优点是透明、可控、启动快,缺点是依赖管理全靠自觉。

我给自己定过一个判断标准:如果项目里源文件超过几十个、头文件之间互相嵌套、还经常要并行构建,手写 Makefile 就要非常小心。这种情况下我会先在 Makefile 里把自动依赖生成、扁平化目标列表、order-only 目录这些基础打牢,跑稳之后再加并行。如果项目继续膨胀,才考虑迁到 CMake 或 Ninja,但即使迁过去,下面要讲的依赖图逻辑依然是通用的。

2. 四个坑逐个拆开看

2.1 坑一:改了 .h 不重编,头文件依赖根本没进依赖图

先看一个非常典型的错误写法:

CC := g++ CFLAGS := -g -O2 -Wall -Iinclude OBJS := src/parser.o src/util.o src/main.o app: $(OBJS) $(CC) $(CFLAGS) -o $@ $(OBJS) src/%.o: src/%.cpp $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f app $(OBJS)

这段 Makefile 里,parser.o只依赖私有src/parser.cpp。如果parser.cpp里写了#include "../include/config.h",而某个公共函数的定义在config.h里改了,执行make的时候输出会是make: 'app' is up to date.。不是编译器的问题,是 make 根本不认为config.h和任何 .o 文件有关系。

我排查这个问题时的第一步是执行make -n,看 make 到底打算跑哪些命令。如果它打算跑编译,但编译命令没出现,说明 make 认为目标已经足够新。接着我去看.d依赖文件里有没有config.h,发现没有,问题就清楚了:依赖关系缺失。

解决方案是用编译器自动生成依赖文件。GCC 和 Clang 都支持-MMD,它能在编译的同时生成一份.d文件,记录这个.o实际包含的所有头文件路径:

g++ -MMD -Iinclude -c src/parser.cpp -o build/parser.o

生成的parser.d内容类似:

build/parser.o: src/parser.cpp include/config.h include/common.h

然后通过-include $(DEPS)把这堆.d文件加载进 Makefile,make 就认识头文件依赖了。这里有两个细节必须注意:第一,-MMD只收集用户头文件,不包含系统库头文件,依赖文件不会膨胀到几百行;第二,如果重构时删掉了某个头文件,旧的.d文件里还残留它的路径,make 会报No rule to make target 'config.h',所以还要在依赖生成参数里加-MP,为每个头文件生成一条空规则,让缺失头文件时不至于直接中断构建。

2.2 坑二:-j 一开就炸,并行把没写全的依赖全抖出来了

make -j4炸锅的报错五花八门,最常见的三种:

  • fatal error: gen.h: No such file or directory,说明编译某个.o时,它需要的生成头文件还没被生成出来。
  • No rule to make target 'build/sub/foo.o',说明两个目标在同一个目录里互相竞争,结果一个目标还没写完,另一个目标已经去找它了。
  • 链接阶段报一堆.o文件缺失或重复符号,说明中间产物被多个并行任务同时写乱,或者旧产物残留。

我见过最典型的场景是“生成头文件参与编译”的项目。伪代码长这样:

all: app app: a.o b.o gen.h $(CXX) a.o b.o -o app a.o: a.cpp $(CXX) -c a.cpp -o a.o b.o: b.cpp gen.h $(CXX) -c b.cpp -o b.o gen.h: gen.py python3 gen.py > gen.h

串行时all -> app -> gen.h这条路径会先走到gen.h,然后才编译a.o。但并行时,a.o和gen.h都可能被同时调度,a.cpp里#include "gen.h"就会撞上文件还没生成的时间窗。

另一个高频坑是目录创建。很多人写规则时会把所有.o输出到build/obj下,但 Makefile 里没有声明build/obj这个目录本身的创建规则。串行时第一个.o的命令执行,可能会因为目录不存在直接失败;并行时更乱,多个任务同时判断“目录不存在”,各自去创建,最终谁先谁后完全不可控。

再一个重灾区是递归 make。外层 Makefile 写all: sub_a sub_b,sub_a和sub_b各自$(MAKE) -C进入子目录。当外层使用-j4时,两个子 make 同时启动,它们之间的依赖关系对外层完全不透明。一旦子项目共享了某些生成文件或公共目标文件,竞争就不可避免。经典观点是“递归 make 被认为有害”,我实际踩过之后完全认同:不是完全不能用,而是你必须非常清楚每个子 make 之间没有任何全局调度,并行安全性全靠手动保证。

遇到并行炸锅,我的处理顺序是:先make -j1完整跑一遍,确认没有隐性问题;再make -j2逐步加。如果-j1都过不了,跟并行无关,是别的构建问题。如果-j1能过、-j4炸,优先排查生成文件依赖和目录创建规则,这两类问题的概率占了八成以上。

2.3 坑三:新增源文件不参与构建,源文件列表和清理策略的隐性坑

“加了新 .cpp 文件,make 却不编译它”,这个现象背后的原因分三种。

第一种,源文件列表是硬编码的。很多人写 Makefile 会在顶部写:

SRCS := src/parser.cpp src/util.cpp src/main.cpp

新增src/ops.cpp时如果忘了往这个列表里加,make 根本不知道它的存在。这种错漏在手工维护列表的项目里非常常见,尤其是文件数量一多,漏加一个文件往往要等链接阶段才发现符号缺失。

第二种,依赖文件被清理掉了。有些构建脚本为了“干净”,每次构建前都rm -rf build,或者把.d文件一并删掉。这样做的后果是:依赖信息全部丢失,下次构建必然全量重编,而且增量构建的时间优势完全消失。更隐蔽的问题是,如果只删了.d但.o还在,make 会认为.o已经是最新的,跳过编译,但它没有依赖信息,后续修改任何头文件都无法触达这些.o文件,最终产生“改头文件无效”的诡异现象。

第三种,不同目录下有同名源文件。比如src/parser/lexer.cpp和src/compiler/lexer.cpp,如果构建规则都把.o输出到同一个展平目录build/lexer.o,两个源文件会互相覆盖,最终产物取决于谁最后编译完。在并行模式下这个“谁最后”完全随机,结果就是构建结果不可复现。

关于通配符,很多人误以为SRCS := $(wildcard src/*.cpp)会自动追踪新增文件。实际执行make时,Makefile 会被重新解析,$(wildcard)是解析期求值,所以新增文件在下次 make 时确实能看到。但这里有前提:你的源文件列表不是提前固化在某个生成脚本或 configure 输出里的。如果 Makefile 本身由另一套脚本生成,就必须重新跑生成脚本;如果你在 Makefile 里手动拼了一个显式列表,同样也不会自动更新。

我的经验是:源文件列表尽量交给$(wildcard)或$(shell find ...)去求值,然后把目标路径做成和源码路径同构的结构,避免同名文件互相覆盖。比如src/parser/lexer.cpp输出到build/obj/parser/lexer.o,而不是展平的build/lexer.o。

2.4 坑四:假目标、touch 和 make -t,时间戳操作反而把状态搞乱

Makefile 里那些不产生真实文件的目标,比如clean、install、all,如果没有声明为假目标,一旦磁盘上恰好存在同名文件,就会发生“make clean不执行”的怪事。

原理还是没离开时间戳:make 发现clean这个文件存在,而且没有前置文件比它新,就认为它是最新的,没什么可做。要解决这个问题,写法是:

.PHONY: all clean install

把不映射到实际文件的目标全部声明为.PHONY,make 就不再把它们当成文件对待。

比这更隐蔽的是手误操作touch。我见过有人想强制重编某个模块,直接touch src/parser.cpp,结果parser.o被重编,紧接着所有依赖parser.o的目标也连锁重编,最后半个项目都重编了一遍。这不算错,但很多人没意识到touch一个上游文件会引发多大范围的连锁反应。

真正破坏性大的是make -t。这个选项会把所有目标的时间戳改成“最新时间”,同时不执行任何命令。相当于给 make 撒了个谎,让它认为所有产物都已经构建完成。下次你再改代码,make 比较时间戳时发现目标比前置新,就直接跳过,导致怎么改源码都不生效。我碰到过一次,排查了半天才发现是有人误执行了make -t。

我调试时的替代方案是make -W。-W选项可以指定某个文件“视为已经更新”,但不真正改动文件时间戳:

make -W src/parser.cpp build/obj/parser.o

这会让 make 模拟parser.cpp刚被修改的情况,去检查parser.o是否应该重建,非常适合用来验证依赖关系是否正确,而且不会污染真实构建状态。

3. 一套能直接抄的修正方案

3.1 依赖生成的标准姿势:-MMD -MP -MT 三件套

下面是一个针对中型 C++ 项目的手写 Makefile 模板,我把前面所有坑的修正方案都揉进去了:

CXX := g++ CXXFLAGS := -g -O2 -Wall -Wextra -std=c++17 -Iinclude DEPDIR := .dep OBJDIR := build/obj BINDIR := build/bin SRCS := $(wildcard src/*.cpp) OBJS := $(patsubst src/%.cpp,$(OBJDIR)/%.o,$(SRCS)) DEPS := $(patsubst src/%.cpp,$(DEPDIR)/%.d,$(SRCS)) DEPFLAGS = -MT $@ -MMD -MP -MF $(DEPDIR)/$*.d $(OBJDIR)/%.o: src/%.cpp | $(OBJDIR) $(CXX) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@ $(BINDIR)/app: $(OBJS) | $(BINDIR) $(CXX) $(CXXFLAGS) $(OBJS) -o $@ $(OBJDIR) $(BINDIR): mkdir -p $@ -include $(DEPS) .PHONY: all clean all: $(BINDIR)/app clean: rm -rf $(OBJDIR) $(DEPDIR) $(BINDIR)

逐个拆解关键点。

DEPFLAGS里的-MT $@指定依赖文件中目标的名字。.d文件的记录格式是“目标: 依赖列表”,如果不显式指定,编译器生成的目标名可能和你的实际路径不一致,导致一下迷惑的编译报错。

-MMD表示编译时同步收集依赖并输出.d文件,不需要单独跑一遍 “make depend”。-MP为每个头文件生成一条空规则,防止头文件被删除后 make 直接报错。-MF指定依赖文件输出路径,这里用.dep/单独存放,不污染源码目录。

-include $(DEPS)和include不同:-include在文件不存在时不报错,适合首次构建前.d文件还没有的情况。如果写成include,第一次 make 会因为找不到.d文件而直接失败。

$(OBJDIR)/%.o: src/%.cpp | $(OBJDIR)里竖线后面是 order-only 前置。order-only 依赖只在目录不存在时触发创建,之后即使目录时间戳更新,也不再让.o目标重新编译。如果不加这个竖线,目录每次被 mkdir 都会变新,所有.o目标可能会被连锁触发重建。

这个模板在多个子目录场景下也基本可用,但要注意一点:.dep/下的.d文件如果直接用$*做文件名,不同子目录的同名源文件会碰撞。我的做法是把DEPDIR下的文件名保留相对路径结构,比如$(DEPDIR)/src/parser/lexer.d,规则需要再定制一下,核心思想是不要让源文件的目录结构在生成物里消失。

3.2 并行编译的正确打开方式:扁平化依赖图与 order-only 目录

有了自动依赖之后,-j并行是否就绝对安全了?还不是。自动依赖只解决了“头文件”这一层,生成文件、目录创建、子模块之间的共享产物依然要手动管理。

我的建议很明确:如果项目体量允许,把子目录的源文件全部收进同一个 Makefile,形成一张扁平依赖图。不要用递归 make。你可以在一个 Makefile 里这样收集多级源文件:

SRCS := $(wildcard src/*.cpp src/*/*.cpp)

或者用 find 命令:

SRCS := $(shell find src -name '*.cpp' | sort)

然后把所有.o输出到按目录结构组织的路径下,顶层目标直接依赖全部.o。这样 make 拥有完整的依赖图,-j8时才能在全局范围内做正确调度,而不是让子 make 之间互相瞎猜。

如果你实在必须用递归 make,尽量把它控制在一个方向上:外层负责链接,子层负责编译。外层不要对子 make 做并行调度,而是分阶段执行,比如先把各子模块的.o用$(MAKE) -C串行编译完,再统一做链接。代价是慢一些,但至少不会随机炸。

关于-jN的取值,我个人的经验是:纯编译场景下,N取逻辑核心数或略小一点,N = nproc是安全起点;如果机器还在跑别的任务,取核心数的一半,或者加上-l负载限制。-j开得过大并不会线性提速,反而会因内存和进程调度开销变慢,甚至触发 OOM。我常用的一句话是:先-j1打底,再-j2验证,最后才上-j4或更高。

碰到生成文件场景时,一定要显式地把生成文件加进依赖图。比如gen.h由gen.py生成,那所有编译时#include "gen.h"的.o都必须直接或间接依赖gen.h。用生成工具输出的.d文件可以覆盖大部分情况,但有些生成文件是在编译过程中才被发现的,这时候手动在规则里补一条$(OBJDIR)/parser.o: gen/gen.h也不丢人,清晰第一。

3.3 模板跑起来之后,还需要注意什么

把上面的模板放到真实项目里后,有几个操作习惯必须同步养成。

第一,不要把clean写成全量rm -rf后还天天执行。增量构建的价值全部建立在.o和.d时间戳有效的条件上,动辄全量重编等于自废武功。clean是辅助手段,不是日常手段。

第二,.d文件要随着构建自动维护,不要手动去改。我之前试过为了临时排除某个头文件依赖,直接在.d文件里手工删行,结果下次重新编译时.d被重新生成,改动全部丢失,还浪费了不少排查时间。正确做法是调整源码里的#include,或者改编译参数中的-I路径。

第三,头文件的-I路径会影响依赖文件的内容。同一个#include "config.h",搜索路径不同,记录在.d文件里的路径就不同。所以编译规则里的CXXFLAGS必须保持稳定,不要今天-Iinclude、明天-I../include,否则增量构建会因为路径不匹配而出现“明明依赖文件里没有某个头文件,实际编译时却用了它”的错觉。

4. 排查技巧和实战心得

4.1 四个排查命令:make -n、make --debug、stat、make -W

手写构建脚本出问题时,最忌讳靠猜。我按使用频率排序,几乎只靠四条命令。

make -n是 dry-run,打印 make 准备执行的命令但不真正执行。它用来确认“make 到底有没有打算重编某个目标”。如果文件改了但make -n没有输出对应编译命令,说明依赖关系根本没关联上,往下再去查.d文件或时间戳比较逻辑。

make --debug=v会输出详细的目标判定过程,包括“considering target”“prereq is older/newer”这类信息。它能直接告诉你 make 在比较哪些文件的时间戳、哪个前置文件触发了重建。信息量有点大,我会配合目标路径一起用,比如make --debug=v build/obj/parser.o,锁定单个目标看它的判定逻辑。

stat看时间戳,命令格式取决于系统:Linux 用stat -c '%y %n' file,macOS 用stat -f '%Sm %N' file。它可以快速对比两个文件谁新谁旧,判断 make 的行为是否符合预期。

make -W是最适合验证依赖关系的工具。因为它能模拟某个文件更新,而不动真实时间戳。比如我想知道config.h改了之后parser.o会不会重编,执行:

make -W include/config.h build/obj/parser.o

如果依赖关系正确,make 会打印编译命令;如果依赖缺失,它会告诉你“up to date”。这个测试可以反复跑,不会污染构建状态。

4.2 并行错误的定位思路:先串行定基准,再逐个放开

并行构建炸锅时,不要直接make clean && make -j8重来。那样会丢掉现场,而且大概率还会再炸一次。我的做法是:

先跑一次make -j1,确认串行能过。如果串行都过不了,说明是依赖文件缺失、编译命令写错、头文件路径不对这类基础问题,跟并行无关。

然后用make -j2复现,记录第一个报错的目标,比如parser.o。接下来单独编译这个目标:

make -j1 build/obj/parser.o

如果单独编译成功,但在并行环境下失败,基本可以确定是依赖图缺失导致的竞态,不是编译命令本身的问题。接下去查这个目标的规则:它到底依赖了哪些前置文件?它编译时实际需要哪些文件?把“实际需要”和“规则声明”两个集合对比,差额就是缺依赖的地方。

排查过程中可以用make -k继续编译其他独立目标,收集更多错误,但要注意-k在执行上并不会让有依赖冲突的构建变稳定,它只是尽可能做完能做的部分。

还有一个技巧:并行失败报“链接时找不到符号”的时候,优先检查目标文件是否真的更新了。有时候源文件改了,但-jN下依赖文件还没被重新生成,链接阶段用了旧产物,看起来像是代码问题,实际是构建顺序问题。

4.3 几条我长期遵守的构建脚本铁律

这些规则没有一条是我凭空想出来的,全是踩坑踩出来的。

第一,所有动作型目标,一律声明.PHONY。哪怕你觉得当前项目里绝不会有人建一个叫clean的文件,也要写。成本一行,收益是省掉一次几小时的排查。

第二,不要为了提速而跳过串行验证。项目改完构建脚本,我会依次执行make clean && make -j1、make && make -j4、再增量修改一个源文件后make -j4。三步都稳定了才放心提交。

第三,构建产物和依赖文件永远不要和源码混在同一个目录。用build/、.dep/之类的独立目录,一是方便清理,二是避免通配符扫描源文件时把构建产物也扫进去,出现一些匪夷所思的规则路径。

第四,新增源文件时,不要只改源码,还要确认它真的进入了SRCS列表。这个听起来很蠢,但确实是高频事故。如果项目结构是$(wildcard)还好,如果是显式列表,新增文件漏加是迟早的事。

第五,.d文件被误删之后,最稳妥的应对是make clean后全量重编,不要试图用 touch 去“修补”时间戳关系。你根本不知道哪些.o缺了头文件依赖,与其猜,不如重新建立一张完整的依赖图。

我在实际使用当中发现,把这四条命令配合前面的模板一起用,排查效率会高很多。构建脚本本身也是代码,它会腐烂、会积累技术债。你能做的是让依赖关系尽量自动生成、让并行调度尽量基于完整图、让时间戳操作尽量只出现在明确知道后果的场景。把这些基础打牢,手写构建脚本其实没有想象中那么难维护。

最后分享一个小习惯:每次改完构建脚本,我都会在干净环境里执行一次make -j1加一次make -j4,然后把命令和结果记录到版本控制的提交说明里。下次再有人报“改 .h 不重编”或者“并行炸锅”,我能直接回溯是哪次改动引入的问题,排查时间从几个小时缩短到几分钟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询