资讯详情

资讯详情

Makefile先决条件详解:依赖关系管理与自动依赖生成实践

1. 为什么依赖关系是Makefile的骨架很多人写Makefile的经历是这样的一开始只是几条简单的编译命令gcc -o main main.c跑通就完事了。等到项目里文件多起来改一个头文件所有目标文件都得重编于是开始加依赖再往后依赖写错了导致该重编的没重编调试半天发现是Makefile的锅。这种场景我见得太多了说到底就是没把“先决条件”这件事吃透。Makefile里的先决条件英文叫prerequisites也有人直接叫依赖。它回答的是一个非常朴素的问题我这条规则要执行得先保证哪些东西已经就位、已经是最新的这个问题听起来简单但Make判断“是否需要重新构建”的全部逻辑都建立在对先决条件的比较之上。你写对了增量编译又快又准你写漏了要么该更新的没更新要么每次全量重编白白浪费时间。这篇文章面向的是已经能写基本Makefile、但经常在依赖关系上翻车的开发者。我会把先决条件的分类、书写方式、自动变量配合、自动依赖生成、常见坑位全部拆开讲一遍。不管你是维护一个几十个文件的小项目还是几百个源文件的中型工程这些内容都能直接拿去用。读完之后你至少能做到两件事第一看到一条规则就能判断它的依赖写得对不对第二知道怎么让编译器帮你自动生成依赖而不是靠手工维护。2. 先决条件的本质与Make的决策逻辑2.1 Make到底在比较什么要理解先决条件得先理解Make的核心工作模型。Make在执行一条规则之前会做一次“新旧比较”它拿目标文件和每一个先决条件文件的时间戳做对比。只要有一个先决条件比目标文件更新Make就认为目标“过期”了需要重新执行规则里的命令。如果目标文件不存在那更不用说直接执行。这里有个容易被忽略的点Make比较的是文件修改时间不是内容哈希。这意味着如果你用touch把一个源文件的时间戳改到未来Make每次都会重编它。反过来如果你从版本控制里检出一批文件时间戳顺序乱了也可能导致意外的重编或漏编。我在实际项目里就遇到过某次批量替换文件后目标文件的时间戳反而比源文件新结果改了代码却没触发重编排查了好一阵。所以先决条件的第一个作用是告诉Make“这个目标依赖哪些文件的内容”。第二个作用是决定规则的执行顺序——如果先决条件本身也是另一个规则的目标Make会先去构建那个先决条件这就是依赖链的递归展开。2.2 先决条件的三种类型很多人只知道写在冒号后面的那种依赖其实GNU Make把先决条件分成了三类用途完全不同。普通先决条件就是最常见的写在目标后面参与时间戳比较也会触发递归构建。比如main.o: main.c defs.h gcc -c main.c -o main.o这里main.c和defs.h都是普通先决条件任何一个比main.o新就会重新编译。order-only先决条件用竖线|分隔写在竖线后面的依赖只保证“存在且已构建”但不参与时间戳比较。这个特性非常实用。典型场景是输出目录你希望目录先被创建出来但目录的时间戳变化不应该导致目标重编。build/app: app.c | build gcc app.c -o build/app build: mkdir -p build如果build写成普通先决条件那么每次往目录里添加文件目录的修改时间就会更新导致build/app被无谓地重链。用order-only就避免了这个问题。这个坑我在早期项目里踩过当时怎么也想不通为什么没改代码却一直在重新链接。双冒号规则里的先决条件行为又不一样。target:: prerequisites这种写法允许同一个目标有多条独立规则每条规则单独判断、单独执行。这个用法比较冷门一般用在需要把同一个目标拆成多个独立构建步骤的场景日常项目里很少见但知道有这么回事遇到奇怪的Makefile时不会懵。2.3 依赖链的递归展开过程当Make要构建一个目标时它会先检查这个目标的所有先决条件。如果某个先决条件本身也是一个规则的目标Make会先递归地去构建那个先决条件构建完成后再回来判断当前目标是否需要更新。这个过程是深度优先的。举个例子app: main.o util.o gcc main.o util.o -o app main.o: main.c defs.h gcc -c main.c -o main.o util.o: util.c defs.h gcc -c util.c -o util.o执行make app时Make先看app的先决条件main.o和util.o。发现main.o有对应规则于是转去构建main.o构建main.o时又发现它依赖main.c和defs.h这两个没有对应规则直接检查文件是否存在、是否比main.o新。处理完main.o再处理util.o最后回到app判断是否需要链接。理解这个递归过程很重要因为依赖写漏了往往不是当前目标出问题而是整条链上某个环节没被触发。比如defs.h如果只在main.o的依赖里写了没写在util.o的依赖里那么改了defs.h之后util.o不会重编最终链接出来的程序就可能带着过期的目标文件。3. 先决条件的书写方式与自动变量配合3.1 显式依赖与隐式依赖显式依赖就是你手动写在规则里的那些文件名。隐式依赖则是通过模式规则和后缀规则推导出来的。比如%.o: %.c $(CC) -c $(CFLAGS) $ -o $这条模式规则本身没有写出具体的先决条件但当你有一个foo.o需要构建时Make会尝试匹配%.o: %.c推导出foo.o依赖foo.c。这就是隐式依赖。显式依赖和隐式依赖可以叠加。比如foo.o: foo.c common.h $(CC) -c $(CFLAGS) $ -o $这里foo.c既是显式写出的也可能被模式规则推导出来但Make会合并处理不会重复。实际项目中我通常会把公共头文件写成显式依赖把源文件交给模式规则处理这样既简洁又不容易漏。3.2 自动变量在依赖场景下的用法自动变量是Make里非常实用的工具尤其在写依赖和命令时能省很多事。跟先决条件关系最密切的几个自动变量含义典型用途$当前目标文件名命令里指定输出$第一个先决条件编译单个源文件$^所有先决条件去重链接多个目标文件$所有先决条件不去重需要保留重复项的场景$?比目标新的先决条件只处理更新过的文件$order-only先决条件$^和$的区别在于是否去重。大多数链接场景用$^就够了但如果某个库需要重复链接比如某些静态库有循环依赖就得用$。这个细节知道的人不多但遇到链接顺序问题时很有用。$?在增量处理场景下很有价值。比如你要把一批更新过的文件复制到某个位置backup: $(SOURCES) cp $? backup/这样每次只复制比backup新的源文件而不是全部重来。3.3 多目标规则与依赖共享GNU Make支持多目标规则写法是foo.o bar.o: common.h这条规则等价于分别给foo.o和bar.o加上common.h依赖。注意它和下面这种写法不同foo.o bar.o: common.h gcc -c $ -o $带命令的多目标规则命令会对每个目标各执行一次$分别取foo.o和bar.o但$始终是common.h这通常不是你想要的。所以多目标规则一般只用来批量添加依赖不写命令命令交给模式规则去处理。还有一种独立目标写法foo.o: common.h bar.o: common.h效果一样只是写起来啰嗦。项目里头文件依赖多的时候用多目标规则能少写不少重复内容。4. 自动生成依赖让编译器替你干活4.1 手工维护依赖为什么不可靠小项目里手工写依赖还能应付一旦源文件超过二三十个头文件交叉引用多起来手工维护就是灾难。你加了一个#include忘了往Makefile里加对应依赖改了那个头文件之后相关目标文件不重编程序行为诡异调试成本极高。这种问题我在多个项目里都遇到过每次都是血的教训。根本原因在于依赖关系是代码本身决定的不是Makefile决定的。源文件里#include了什么就依赖什么。让开发者手工同步这个信息本质上是在做一件编译器已经知道的事情。正确的做法是让编译器在编译时把依赖信息输出出来Makefile自动读取。4.2 用-MM和-MD生成依赖文件GCC提供了一组选项来输出依赖信息-M输出所有依赖包括系统头文件-MM输出依赖但排除系统头文件-MD编译的同时生成依赖文件文件名基于输出文件-MMD同上但排除系统头文件日常项目里用-MMD最合适因为系统头文件基本不会变没必要纳入依赖跟踪否则依赖文件会非常庞大。典型用法CFLAGS -MMD -MP-MP的作用是为每个依赖的头文件生成一个空目标规则防止头文件被删除后Make报错。这个选项很实用建议加上。编译之后每个.o文件旁边会多出一个.d文件内容大致是main.o: main.c defs.h util.h defs.h: util.h:然后在Makefile末尾用-include把这些.d文件包含进来-include $(OBJS:.o.d)用-include而不是include是因为第一次构建时.d文件还不存在-include会静默忽略不存在的文件不会报错。4.3 完整的自动依赖方案把上面的内容整合起来一个可用的自动依赖Makefile骨架是这样的CC gcc CFLAGS -Wall -O2 -MMD -MP SRCS main.c util.c parser.c OBJS $(SRCS:.c.o) TARGET app $(TARGET): $(OBJS) $(CC) $^ -o $ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ -include $(OBJS:.o.d) clean: rm -f $(OBJS) $(OBJS:.o.d) $(TARGET)这套方案的好处是你永远不需要手工维护头文件依赖。新增#include之后下次编译时.d文件会自动更新后续构建就能正确触发重编。我现在的项目基本都用这个模式省心很多。注意.d文件必须和.o文件一起清理否则残留的旧依赖文件可能引用已经不存在的头文件导致奇怪的报错。5. 依赖关系中的常见坑与排查方法5.1 依赖写漏导致的“改了没反应”这是最典型的问题。现象是修改了某个头文件执行make却提示“nothing to be done”或者只重编了部分文件。原因就是那个头文件没有出现在相关目标文件的先决条件里。排查方法先用make -n看Make打算执行哪些命令如果该重编的没出现基本可以确定是依赖漏了。进一步可以用make -d输出详细的决策过程看Make对每个文件的时间戳比较结果。-d的输出非常冗长建议配合grep过滤关键目标。根治方法就是上面说的自动依赖生成。只要.d文件机制正常工作这类问题基本不会出现。5.2 依赖写多导致的“每次都全量重编”反过来依赖写多了也会出问题。常见的情况是把整个目录或者通配符当成依赖app: *.c gcc *.c -o app这种写法下任何.c文件变化都会导致app重编而且*.c的展开是在Makefile解析时进行的新增文件不会自动纳入。更隐蔽的是把生成的文件当成依赖比如把日志文件、临时文件写进依赖列表导致每次构建都触发重编。还有一种情况是order-only依赖写成了普通依赖前面提到的目录时间戳问题就属于这一类。判断方法很简单如果某个依赖只是“需要存在”而不影响目标内容就应该放到竖线后面。5.3 依赖循环与Make的报错依赖循环是指A依赖BB又依赖AMake会直接报错make: Circular A - B dependency dropped.有时候循环不是直接的而是通过多个中间目标间接形成的。比如生成头文件的规则依赖某个目标文件而那个目标文件又依赖这个头文件。这种问题在代码生成场景下比较常见。解决办法通常是打破循环把生成步骤拆成独立的阶段或者用order-only依赖切断时间戳比较。如果确实是逻辑上的循环依赖那说明构建流程设计有问题需要重新梳理。5.4 常见问题速查表现象可能原因排查手段解决方向改了头文件不重编依赖漏写make -n看命令加自动依赖生成每次全量重编依赖写多或order-only误用检查依赖列表精简依赖用竖线报循环依赖规则互相引用看报错信息拆分阶段或改order-only首次构建报缺.d文件用了include而非-include看报错行改成-include头文件删除后报错缺-MP选项看.d文件内容CFLAGS加-MP链接时符号重复$^去重导致库顺序问题检查链接命令改用$6. 依赖管理在真实项目中的组织策略6.1 分层组织依赖关系项目大了之后把所有依赖堆在一个Makefile里会很难维护。我通常的做法是按目录分层每个子目录一个Makefile顶层Makefile负责协调。子目录的Makefile只管自己目录内的源文件和头文件依赖跨目录的依赖通过变量传递。比如顶层定义公共头文件路径COMMON_HEADERS include/common.h include/config.h export COMMON_HEADERS子目录Makefile里$(OBJS): $(COMMON_HEADERS)这样公共头文件变化时所有子目录的目标都会重编而各子目录内部的依赖由各自的自动依赖机制处理。6.2 生成文件与依赖的交互代码生成场景下依赖关系会更复杂。比如用工具从.proto文件生成.c和.h文件然后这些生成的文件又被其他源文件包含。这时候依赖链是.proto- 生成的.h- 包含它的.o。处理这种场景关键是让生成规则的目标明确并且把生成的头文件加入依赖generated.pb.h generated.pb.c: schema.proto protoc --c_out. schema.proto main.o: main.c generated.pb.h $(CC) $(CFLAGS) -c main.c -o main.o注意生成规则用了多目标但这里两个文件是同时生成的所以不能简单用多目标规则加命令那样会执行两次。正确做法是选一个作为主目标另一个作为副产物或者用模式规则配合。这个细节容易出错需要特别注意。6.3 依赖关系的可视化检查当依赖关系复杂到一定程度光看Makefile很难判断对不对。这时候可以用make -p输出Make的内部数据库里面包含所有规则和变量展开后的结果。输出很长但配合grep能找到特定目标的完整依赖链。另一个办法是用make --graph需要graphviz支持生成依赖图。不过这个功能对大型项目输出会很乱适合小范围排查。我一般只在怀疑有循环依赖或者依赖链异常时才用。7. 几个容易被忽略的细节7.1 空目标与伪目标的依赖处理伪目标.PHONY本身不对应文件但它的先决条件仍然会被处理。比如.PHONY: all clean all: app clean: rm -f *.o appall依赖app执行make all时会先构建app。clean没有先决条件直接执行命令。这里要注意的是如果目录下恰好有个文件叫all或clean没有.PHONY声明的话Make会认为目标已存在且没有更新的先决条件从而不执行命令。这是经典坑位.PHONY一定要加。7.2 变量在依赖中的展开时机Makefile里的变量展开分两个阶段解析阶段和命令执行阶段。先决条件里的变量在解析阶段就会展开这意味着如果你用定义的递归变量在依赖里引用时可能得到意外的结果。比如SRCS $(wildcard *.c) OBJS $(SRCS:.c.o) app: $(OBJS)这里$(OBJS)在解析app规则时展开wildcard也在那时执行。如果之后新增了.c文件重新执行Make时才会重新展开所以没问题。但如果你在同一个Makefile里动态修改SRCS就要注意展开顺序。用:立即展开赋值可以避免一些意外但也会失去延迟展开的灵活性。我的经验是依赖列表里的变量尽量用简单赋值或者确保在引用前已经定义完整。7.3 依赖文件本身的依赖自动生成的.d文件也有依赖吗严格来说.d文件依赖对应的源文件和头文件但这个依赖是编译器在生成时确定的Make不需要再跟踪。-include只是把.d文件的内容读进来不要求.d文件本身是最新的。如果.d文件不存在-include静默跳过下次编译时会重新生成。有一种边界情况如果.d文件存在但内容过期比如头文件被删除但.d还引用它配合-MP生成的空目标规则可以避免报错。这也是为什么建议加-MP的原因。8. 从依赖角度优化构建速度8.1 减少不必要的依赖依赖越精确重编范围越小。把公共头文件拆细避免一个“万能头文件”被所有源文件包含能显著减少重编范围。我见过一些项目把所有声明塞进一个common.h结果改任何一处都导致全量重编。拆成多个小头文件按需包含构建速度能提升好几倍。8.2 利用order-only依赖隔离目录变化前面提过输出目录用order-only依赖可以避免目录时间戳变化触发重编。类似地一些只用于组织结构的文件比如版本标记文件也可以用order-only只要它们不影响目标内容。8.3 并行构建与依赖顺序make -j可以并行构建但前提是依赖关系正确。如果依赖漏写并行构建可能因为文件还没生成就被引用而失败而且这种失败往往是间歇性的很难复现。所以依赖写对不仅是正确性问题也是并行构建的前提。我一般会先用-j1确认构建通过再用-j加速这样能区分是依赖问题还是并行竞争问题。依赖关系这件事说到底就是一句话让Make准确知道什么变了、什么需要重做。手工维护依赖在项目规模上去之后必然出错自动依赖生成是标配。order-only依赖、多目标规则、自动变量这些工具用好了Makefile能写得既简洁又可靠。我在多个项目里反复验证过这套组合只要.d文件机制正常运转依赖相关的构建问题基本可以绝迹。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →