
“改了 .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 -j4make 会同时调度所有“当前可以执行”的目标。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.cppmake 知道parser.o依赖parser.cpp但如果你在parser.cpp里#include config.hmake 对此一无所知。头文件对 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文件加载进 Makefilemake 就认识头文件依赖了。这里有两个细节必须注意第一-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_bsub_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把不映射到实际文件的目标全部声明为.PHONYmake 就不再把它们当成文件对待。比这更隐蔽的是手误操作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 -stdc17 -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 --debugv会输出详细的目标判定过程包括“considering target”“prereq is older/newer”这类信息。它能直接告诉你 make 在比较哪些文件的时间戳、哪个前置文件触发了重建。信息量有点大我会配合目标路径一起用比如make --debugv build/obj/parser.o锁定单个目标看它的判定逻辑。stat看时间戳命令格式取决于系统Linux 用stat -c %y %n filemacOS 用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 不重编”或者“并行炸锅”我能直接回溯是哪次改动引入的问题排查时间从几个小时缩短到几分钟。