
动手写Makefile的人十有八九是被现实逼的。我自己第一次正经写Makefile是因为接手一个C语言项目源码几十个文件头文件一改动手动敲的那条编译命令就得全部重跑一遍编译一次五六分钟一半时间浪费在重复编译完全没变化的文件上。后来我把编译命令从手敲换成了make把目标、依赖、规则写好之后改一个文件就只编一个文件几分钟变成几秒钟那种感受确实只有经历过手动编译痛苦的人才懂。这篇内容围绕Linux下的makefile展开从最基础的语法逻辑讲到工程级Makefile的组织方式再讲到常见报错的排查思路和并行编译的注意事项。无论你是刚接触Linux的入门者还是已经写过一些简单Makefile但经常被报错卡住的人这篇文章应该都能帮你把Makefile这层窗户纸真正捅破。1. Makefile解决的核心问题不重复劳动也不漏掉该做的事Makefile存在的理由可以用一句话概括让计算机自己判断哪些东西需要重新构建并且只构建这些东西。这句话听起来简单但背后是一个完整的工程思想——增量构建。1.1 没有构建工具的日常是什么样假设你手头有一个项目包含 main.c、utils.c、network.c 三个源文件它们都引用了 common.h。最常见的编译方式是gcc main.c utils.c network.c -o app这个命令的问题是每一次改动哪怕只是改了一行注释都要把三个文件全部重新编译一遍。文件少还能忍文件一多效率就直线下降。更麻烦的是如果你只改了一个文件却忘了重新编译其他依赖相关文件链接出来的程序可能是旧头文件和新源文件混在一起的状态程序跑起来行为诡异你还不知道问题出在哪里。1.2 make的基本工作原理时间戳对比make的核心逻辑很简单比较目标文件和依赖文件的时间戳。如果依赖文件比目标文件新说明目标文件已经过时需要重新生成如果目标文件比所有依赖文件都新说明它是最新的跳过这条规则。这就是增量构建的底层机制。把这个逻辑翻译成人话如果你今天改了 main.cmain.c 的时间戳晚于 main.omake 就会重新生成 main.o。但 utils.o 的时间戳早于 utils.c 和 common.hmake 就认为它不用重新编译。整个判断过程是递归的make 会从最终目标开始一层层检查依赖直到最底层的源文件。1.3 为什么不是写好编译脚本就完事有些人会觉得我写一个 shell 脚本把所有编译命令放进去不也能解决问题吗确实能解决重复劳动的问题但解决不了漏掉该做的事的问题。shell 脚本不具备依赖判断能力它只会机械地按顺序执行每条命令不管目标是否最新也不管编译顺序是否合理。Makefile 的核心不是编译而是构建关系建模。你把目标、依赖、规则三者写清楚make 才能帮你做正确的决策。2. Makefile的三层结构目标、依赖与命令的底层逻辑Makefile 的语法并不复杂核心只有一条规则target: dependencies command这行规则表达了三个信息我要生成什么target、它依赖什么dependencies、怎么生成command。这是一切Makefile的基石不管写多复杂的工程底层都是这条规则的组合与变形。2.1 默认目标是第一条规则的目标这一条是新手最容易懵的地方。make 如果不带参数默认执行 Makefile 里的第一个目标。但这个第一个目标不是第一次出现的某个规则的目标而是第一个不以点号开头的目标。很多入门者把 clean 目标放到文件开头结果执行 make 时发现自己写的编译规则根本就没跑反而是 clean 被当作默认目标执行了一脸疑惑。所以工程实践上的惯例是把最终产物可执行文件或者库作为第一个目标其余辅助目标放在后面。2.2 命令前的Tab一个折磨无数人的细节命令那一行的开头必须是一个 Tab 字符不能用空格替代。这是 Makefile 语法里最形式主义的要求但也是报错出现频率最高的地方。很多编辑器默认把 Tab 展开成空格你写好 Makefile 之后一执行直接报missing separator。解决办法有两个改编辑器的插入空格代替Tab选项或者在命令行用sed -i s/^ /\t/ Makefile批量把开头的四个空格替换成 Tab。2.3 每条命令在独立shell中执行make 执行规则里的命令时每条命令都是在一个独立的 shell 进程中运行的。也就是说你在一条命令里cd src下一条命令并不会留在 src 目录。我见过有人写build: cd src gcc -c main.c结果 gcc 仍然在项目根目录执行找不到 main.c。正确写法是build: cd src gcc -c main.c或者用$(MAKE) -C src递归进入子目录这是后话。2.4 伪目标那些不对应文件的目标clean、install、test 这些目标不生成任何文件它们只是给你执行一段命令的入口。如果不做任何处理当目录下恰好出现了一个叫clean的文件时make 就会发现clean 这个目标已经存在而且没有依赖文件比它更新然后拒绝执行 clean 规则里面的命令。这就是著名的clean 失效问题。解决办法是声明伪目标.PHONY: clean install test.PHONY告诉 make这些目标是假的不要检查文件是否存在每次执行都直接运行命令。这个声明看起来只是多写一行没有声明和声明之间行为天差地别。3. 从零手写一个工程级Makefile变量、自动依赖与多目录管理说明书里的 Makefile 例子都是hello world但真到项目里需要考虑的问题远不止语法本身。源文件放哪里、中间问件放哪里、头文件依赖怎么自动生成、新增文件要不要改Makefile这些才是工程实践真正关心的问题。我直接给一个可以在中型 C 项目里直接用的模板然后逐行解释设计思路。3.1 一个可以直接落地的模板CC : gcc CFLAGS : -Wall -Wextra -O2 -stdc11 LDFLAGS : -lm SRC_DIR : src BUILD_DIR : build TARGET : app SRCS : $(wildcard $(SRC_DIR)/*.c) OBJS : $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS)) DEPS : $(OBJS:.o.d) $(TARGET): $(OBJS) $(CC) $(OBJS) -o $ $(LDFLAGS) $(BUILD_DIR)/%.o: $(SRC_DIR)/%.c mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) -MMD -MP -c $ -o $ -include $(DEPS) .PHONY: clean clean: rm -rf $(BUILD_DIR) $(TARGET)3.2 变量赋值的两种方式 与 : 的选择Makefile 里给变量赋值有两种常见写法。是递归展开赋值变量的值在使用时才会展开如果两个变量互相引用很容易造成无限循环。:是立即展开赋值定义时值就已经确定后面再改变量引用的变量也不会影响它。工程实践上我基本只用:。它更符合直觉也更容易排查问题。你可以看到上面模板第一行写的是CC : gcc而不是CC gcc这不是随手写的是有意为之。3.3 wildcard与patsubst别让新文件变成维护负担wildcard $(SRC_DIR)/*.c会把 src 目录下所有以 .c 结尾的文件名取出来。这个函数让新增一个源文件后手动改Makefile变成历史。你把新文件丢进 src 目录make 执行时它会自动出现在 SRCS 里。patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS)是模式替换函数它会把 src/main.c 变成 build/main.o把 src/utils.c 变成 build/utils.o。这行代码的价值在于把源文件和编译产物之间的路径对应关系描述清楚同时让所有 .o 文件落在同一个目录方便统一清理。3.4 模式规则处理一类文件而不是一个个文件上面模板里这条规则$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) -MMD -MP -c $ -o $是一个典型的模式规则。%是通配符它一旦匹配上两个%会对应同一个字符串。比如 make 需要生成 build/main.o它会用 main 去匹配%然后发现 build/main.o 依赖 src/main.c于是执行编译命令。这条规则对 build 目录下所有 .o 文件都有效不需要为每个文件单独写一条规则。命令里的$是第一个依赖文件的名称在这里就是 src/main.c$是目标文件名也就是 build/main.o。这两个自动变量后文会专门讲。命令前的表示执行时不回显该命令目录里没有 build 目录时先创建不会在终端刷一条 mkdir 输出日志干净很多。3.5 -MMD与-MP自动生成头文件依赖模板里CFLAGS最后那两个参数很关键。-MMD让 gcc 在编译 .c 文件时同时生成一个 .d 文件里面记录了这个 .c 文件实际包含的所有头文件清单。-MP会为这些头文件生成空的伪目标防止头文件被删除后 make 报缺少规则。这样生成的 .d 文件用下面这句引入-include $(DEPS)-include前面的减号表示如果 .d 文件不存在不要报错。第一次编译时 .d 文件还不存在这句会安静跳过编译完成后 .d 文件生成下一次 make 就能把头文件依赖纳入检查。这套机制真正实现了头文件依赖自动追踪。你不需要手动维护common.h 被哪些文件包含这种清单编译器自己生成make 自己读取。这是我强烈建议所有 C/C 项目采用的做法哪怕你项目很小。4. 自动变量与函数让Makefile具备可编程能力写到第三部分的时候已经用到了$、$两个自动变量。这一节把自动变量和常用函数一次讲透它们才是让Makefile从一条条规则变成一套规则体系的关键。4.1 自动变量速查与记忆方法自动变量是在规则执行时由 make 自动填充的特殊变量不需要你手动赋值。高频的几个如下变量含义记忆提示$当前规则的目标文件名at目标$第一个依赖文件名left最左边$^所有依赖文件名已去重all全部$?比目标新的依赖文件列表问号表示谁变新了$(D)目标所在目录目标(D)ir$(F)目标文件名不含目录目标(F)ile$(C)当前目录不常用知道即可我在项目里最常用的是$和$。例如$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c $(CC) $(CFLAGS) -c $ -o $这行命令展开后等价于gcc -Wall -O2 -c src/main.c -o build/main.o。$帮你自动取到源文件$帮你自动取到目标文件不写死任何具体名字规则就具备通用性。$^在链接阶段很实用它一次性展开所有 .o 文件配合$(CC) $^ -o $就能把所有中间文件链到一起。4.2 常用函数的实战场景make 内置函数数量不算少但工程上高频的其实是这几个$(wildcard *.c)展开通配符拿到所有符合模式的文件名$(patsubst pattern,replacement,text)模式替换$(subst from,to,text)简单字符替换$(filter pattern...,text)从列表中筛选匹配的项$(shell command)执行 shell 命令并返回标准输出filter在区分不同模块源文件时非常好用。假设你的源码目录里混了主程序文件和测试文件MAIN_SRCS : $(filter-out %_test.c,$(SRCS)) TEST_SRCS : $(filter %_test.c,$(SRCS))两条规则把源文件分成两拨主程序不编译测试代码测试文件单独处理。这种区分在工程里很常见手动维护两份名单容易漏用 filter 自动处理更可靠。4.3 条件判断一份Makefile适配多种场景不同场景需要不同编译选项最常见的是 debug 和 release 两种模式。用条件判断可以做到一份Makefile通吃ifeq ($(DEBUG),1) CFLAGS -g -O0 -DDEBUG else CFLAGS -O2 -DNDEBUG endif执行make DEBUG1就进入调试模式默认走 release。注意ifeq条件判断是在 make 解析阶段完成的不是执行阶段。也就是说你不能指望它根据某个命令的运行结果去分支只能根据变量值或环境来做判断。这种解析期判断的特点需要在使用时心里有数。4.4 $(MAKECMDGOALS)知道用户执行了什么目标MAKECMDGOALS变量保存了用户执行 make 时输入的目标列表。利用它可以在构建前做一些前置检查。比如某个项目在用户执行 install 目标前检查依赖库是否装好ifeq ($(MAKECMDGOALS),install) ifeq ($(shell ldconfig -p | grep libjpeg),) $(error libjpeg not found, please install it first) endif endif这样用户执行make install时如果系统里没有 libjpegmake 会在最开始就报错而不是等到编译或链接阶段才暴露问题排查成本低得多。5. Makefile排错实录从报错信息到根因定位写 Makefile 的过程本质上就是和报错斗争的过程。我把实际项目里见过的高频问题整理成一份排查思路每条都从报错信息出发讲清楚背后真正的原因。5.1 missing separator八成是空格替代了Tab这个报错最直接make 在解析规则时发现命令行没有以 Tab 开头或者出现了它无法理解的字符。新手看到missing separator就慌了其实它就是格式问题。排查步骤用cat -A Makefile查看文件Tab 会显示为^I空格就是普通空格一眼就能看出来。如果用编辑器改的检查编辑器是否把 Tab 自动转成空格。临时修正可以使用sed -i s/^ /\t/ Makefile但根因还是要改编辑器配置。5.2 No rule to make target依赖路径对不上这条报错的意思是make 需要生成某个目标但找不到任何规则能生成它。最常见的原因是规则里的某个依赖文件路径拼写错误或者某个被依赖的中间目标确实不存在。定位思路先执行make -n它只打印命令不实际执行你能看到 make 认为哪些文件需要构建再检查报错里提到的那个文件是否真的存在于对应目录。路径变量过多时可以用$(info SRCS$(SRCS))在 make 解析时打印变量值看展开结果是否符合预期。高端的报错往往不需要高端技巧把变量值打印出来问题就清楚了一半。5.3 Circular dependency dropped依赖关系成环了这条报错告诉你某个目标直接或间接依赖了它自己。工程里常见的原因是把某个伪目标编进了真实的依赖链比如app: utils.o ... utils.o: utils.c clean # 这里把 clean 放进了依赖clean不生成文件却成为 utils.o 的依赖依赖关系就乱了。解法是重新梳理依赖图把所有伪目标从真实依赖链里摘出来并统一声明为.PHONY。5.4 隐藏得最深的坑通配符展开时机我见过有人写OBJ *.o然后发现规则里的$^展开出来就是字面上的*.o根本不会匹配任何文件。原因在于是递归展开赋值通配符在使用时才处理而且 wildcard 展开不是所有场景都自动发生。解决方法是使用OBJ : $(wildcard *.o)用:立即展开让变量在定义时就拿到具体的文件列表。这个坑极其隐蔽因为没有报错只是程序编译出来的东西不对或者链接时少文件排查起来费时费力。5.5 undefined reference to xxx链接器在抱怨不是make的问题这条报错来自链接器不是 make 语法层面。常见原因有三个库没有写进LDFLAGS、某个 .o 文件没有被链接、库的链接顺序不对。排查手段是make V1或者make --trace把实际执行的链接命令完整打印出来看哪些 .o 参与了链接哪些库被遗漏。V1在不少项目的 Makefile 里是打开详细输出用的如果没有这个支持可以直接在命令行加make SHELLsh -x把每条命令的展开过程都打出来。6. 并行编译与调试手段Makefile实战中的加速与定位到这节Makefile 已经能写能跑能排错但工程实践还有两个绕不开的话题编译太慢怎么办以及复杂问题怎么定位。6.1 make -j并行编译的收益与代价make -j8会同时跑 8 个编译任务充分利用多核 CPU。对现代机器来说编译速度提升非常可观。但并行编译有一个经典前提你的依赖关系必须写对。如果某个目标依赖了一个由其它规则生成的文件但这个依赖没有写进依赖列表串行编译时碰巧能跑通并行编译时就会因为文件还不存在而失败。这种问题在全量编译时会偶发增量编译时反而少出现因为文件已经存在。所以排查思路是先make clean再make -j如果并行失败而串行成功基本可以断定有隐藏的依赖没有声明。对大型项目推荐用make -j$(nproc)动态获取 CPU 核数而不是硬编码一个数值。6.2 调试参数-n 与 -d先把最常用的调试参数列出来参数作用make -n干跑打印命令不执行make -d输出调试信息包含每一步依赖判断的细节make --trace打印每条规则执行的原因make -p打印所有变量和规则的最终值-n是在改动 Makefile 后快速验证逻辑的首选零风险。--trace会告诉你某条规则为什么要执行是因为哪个依赖文件更新了。当你在纠结为什么这个文件又编译了一遍时--trace直接把答案打到屏幕上。-d输出最详尽但也最杂通常到了这一步说明问题已经比较深了需要看 make 的决策过程。6.3 递归make的注意事项当项目有多个子目录时很多人会在根 Makefile 里用make -C subdir进入子目录执行该目录下的 Makefile。这种做法本身没问题但要注意优先用$(MAKE)而不是写死make。$(MAKE)会把顶层命令行参数例如 -j 和 DEBUG1自动传给子 make保证行为一致。如果写死make你在顶层传的 DEBUG 变量就不会进入子目录构建结果就是调试选项失效还得费劲排查。6.4 从Makefile到CMake不是替代是互补很多新项目直接用 CMake 自动生成 Makefile这种做法没问题。CMake 生成的 Makefile 极其复杂但它替你处理了跨平台问题、编译选项检测、安装规则等一大堆事情。那还有没有必要手写 Makefile我的答案是有必要。排查问题时你看 CMake 生成的 Makefile 底层逻辑如果不懂 make 的语法和依赖判断机制会非常吃力。阅读开源项目时很多老牌项目依然直接使用手工 Makefile理解它才能读懂项目的构建方式。所以我的建议是掌握 Makefile 是基本功使用 CMake 是工程化的选择两者不是二选一而是从底层理解到上层工具的递进关系。你先把 Makefile 的依赖模型想清楚再去用 CMake会发现很多 CMake 指令背后的原理都不难理解。最后的几点个人体会写 Makefile 这几年最大的体会是它的语法并不难难的是建立依赖关系建模的思维方式。你把目标、依赖、命令三者之间的关系想清楚Makefile 自然写得干净、高效、可维护。很多问题的根因其实是对项目构件关系理解不透而不是语法不会。再分享一个小技巧。写 Makefile 时尽量保证增量编译正确性和并行编译正确性。方法是所有中间文件都要有对应的依赖声明不要依赖隐式规则帮你补依赖也不要因为串行能跑通就跳过依赖声明。养成这个习惯之后你会发现你的 Makefile 在团队协作里几乎不怎么出问题别人改起来也省心。如果你想继续深入建议找几个真实开源项目读一读它们的 Makefile看看别人怎么组织变量、怎么处理子目录、怎么设计多目标。读十个项目比你从头翻一个月文档都管用。