
1. 项目概述make/Makefile到底解决了什么问题先聊点实际的。在Linux环境里写程序很多人最开始只会gcc main.c -o app这一条命令文件一多就傻眼了——三五个源文件还能靠CtrlR翻历史记录硬撑到几十个源文件、十几个头文件的时候编译这件事基本就变成了一场灾难依赖的头文件改了哪些文件需要重新编译哪个目录里的目标文件该更新链接参数写在哪一次命令里了如果你还没被这些问题折磨过那我只能说恭喜你还没遇到真正复杂点的项目。make就是用来解决自动化构建这件事的经典工具。它本身是一个构建管理器不是编译器也不是语言它负责根据你写好的Makefile文件自动判断哪些源文件过期了、需要重新编译然后按预设的规则去执行编译和链接命令。打个比方make就像是一个包工头Makefile就是施工图纸它告诉包工头每一步该干什么、材料之间是什么关系包工头照着图纸指挥工人干活而且只派活给该干的人。这一篇探索学习笔记就带你把make和Makefile这套东西从原理到实践整个捋一遍。不只是教你怎么写两行规则而是把为什么要这么写make是怎么判断要不要重新编译的遇到报错怎么追这些问题都说清楚。适合刚接触Linux环境编程的同学也适合已经会写几行Makefile但总感觉隔着一层纱、遇到问题只会百度的人。2. 环境准备与第一个能跑起来的Makefile2.1 搭建最小的实验环境工欲善其事必先利其器。先确认你的Linux环境里有make工具。绝大多数发行版都是自带make的你可以打开终端敲一下make -v如果提示找不到命令说明你没装build-essential那套工具链。Debian/Ubuntu系执行sudo apt install build-essentialRed Hat/CentOS系执行sudo yum install gcc make装上即可。注意make的版本并不重要无论是3.8x还是4.x核心行为完全一致旧版照样能干活。为了后面讲起来不抽象我们从头构造一个小实验项目。假设我要写一个单词统计工具功能是读入一个文本文件统计里面每个单词出现的次数思路很简单目的纯粹是为了演示Makefile。项目结构如下word_count/ ├── include/ │ └── counter.h ├── src/ │ ├── main.c │ └── counter.c └── Makefilecounter.h里声明了一个统计函数counter.c里实现具体的统计逻辑main.c负责读文件和调用统计函数。逻辑不复杂但足以表现出多文件、有依赖这个特点。2.2 手写第一个Makefile先跑起来再讲道理先给出一个最朴素的Makefile版本不追求技巧只求让你直观看到一个Makefile长什么样app: main.o counter.o gcc -o app main.o counter.o main.o: src/main.c include/counter.h gcc -Iinclude -c src/main.c -o main.o counter.o: src/counter.c include/counter.h gcc -Iinclude -c src/counter.c -o counter.o clean: rm -f app *.o把这个文件放到word_count目录下执行make你会看到屏幕依次打印三条命令执行完以后目录里多出app可执行文件。执行make clean又瞬间恢复干净。现在拆开看每一行到底在说什么。Makefile的基本单元叫做规则一条规则由三部分组成目标target要生成的文件名比如main.o、app依赖prerequisites生成这个目标需要哪些文件比如main.o依赖src/main.c和include/counter.h命令recipe真正执行的shell命令必须以Tab键开头这个坑后面细说所以main.o: src/main.c include/counter.h这句话翻译成人话就是如果src/main.c或include/counter.h比main.o更新那么运行下面那行gcc命令重新生成main.o。第一版Makefile的核心结构就这些。但你可能已经在心里嘀咕了一个文件一个规则地写这跟写脚本有什么区别源文件一多这不还是灾难吗别急这正是我接下来要展开讲的内容只有理解了make的工作流程你才能真正写出来写一次、到处用的Makefile。3. 深入make的核心原理它到底是怎么判断谁该被重建的3.1 时间戳与依赖关系的自动推导make判断一个目标需不需要重新构建依据听起来简单到不可思议比较目标文件和依赖文件的时间戳。只要任何依赖文件的修改时间比目标文件晚make就认为目标过时了必须重新生成。这就是增量编译的精髓所在。在小型项目里你可能感受不到但稍微想象一下一个包含两三千个源文件的大型项目如果你改了其中一个.c文件就要全部重新编译一遍那每次构建都要花掉几十分钟甚至更久。而make通过时间戳自动筛选出真正需要重编的那几个文件构建时间能压缩到几十秒。这也是make存在几十年依然屹立不倒的立身之本。我第二个想让你特别注意的地方是make具备依赖关系的自动推导能力如果你写的是C/C项目make内置了一套隐含规则。刚才第一版Makefile里的这两条编译规则main.o: src/main.c include/counter.h gcc -Iinclude -c src/main.c -o main.o如果你只写依赖关系不写下面的gcc命令make也能用内置规则帮你编译。因为make认识两个神奇的变量$当前目标的名字和$依赖列表里的第一个文件。对于编译.o文件内置规则命令本质上是$(CC) $(CFLAGS) -c $ -o $你甚至可以写一个更精简的版本试试把第一版Makefile里的两条.o规则命令删掉只保留目标和依赖make照样能编译出.o文件。但这存在一个隐患隐含规则不会自动帮你加-Iinclude头文件搜索路径所以counter.h放到include目录下时你必须用CFLAGS变量补上否则找不到头文件会直接报错。3.2 命令前的符号和-符号是什么意思在实际项目里你会经常看到Makefile里某些命令行前面带着某些带着-。这两个前缀各有各的作用。表示执行命令时不把命令本身打印到屏幕上。比如你写echo build done终端上只会显示build done不会先显示echo那一行命令。反过来说Makefile里正常的命令执行时都会先回显命令文本这正是第一版Makefile运行时屏幕上打印的三条命令的来历。-开头的命令表示忽略这条命令的执行错误。比如-rm -f *.o如果目录里根本没有.o文件rm会返回一个非零退出码默认情况下make会认为自己执行失败并终止后续工作加上-前缀就能容忍失败继续执行下去。clean规则里这个写法非常实用。3.3 再次强调Tab键与空格的血案这是所有人都会踩的坑。Makefile的命令行必须用Tab键缩进你用四个空格缩进make会直接给你报错Makefile:12: *** missing separator. Stop.翻译过来就是你这命令前缺少分隔符。为什么make要这么设计因为Makefile存在一个历史遗留的语法结构问题同一行既可能是目标: 依赖这样的规则头也可能是shell命令两者不可能靠看起来像一行缩进的文本进行区分。早期make设计者干脆规定命令必须以Tab开头程序猿不用纠结它到底是规则还是命令出现歧义前就直接判定为命令。代价是几代程序员在这里栽过的跟头。真遇到这个问题的时候检查方法很简单vim里执行:set listTab会显示成^I空格则显示成普通的小圆点或小横线一眼就能看出来哪一行有问题。3.4 一个实操小实验亲手验证时间戳机制我建议你亲自做一个小实验这比任何理论解释都直观。先make编译出app然后再执行一次make。第二次执行时屏幕会显示make: app is up to date.这是因为没有任何文件发生变化目标app的时间戳比它所依赖的所有文件都要新make判断没有活要干。接下来手动修改counter.h往里面加一句注释后保存再执行make。注意观察make的输出它会重新编译counter.o然后重新链接生成app但不会重新编译main.o。为什么因为我这个项目里main.c和counter.c都包含了counter.h按说两个.o都应该重建但如果你按第一版Makefile写编译main.o那条规则没有把counter.h列为它的依赖项所以make自然认为main.o没有过时。这暴露了第一版Makefile的一个真实缺陷依赖声明不完整会导致增量编译结果不可靠。要修复只需要在main.o规则的依赖列表里补上include/counter.h。这也是我后面要讲的头文件依赖自动收集这个话题的引子。4. 正式动手逐步完善Makefile的核心技巧4.1 用变量管理重复内容降低维护成本第一版Makefile里源文件列表、头文件路径、编译选项是散落在各条规则里的一旦项目结构调整你得自己回去改多处文本很累也容易漏。真正的Makefile高手绝不会这么干他们会用变量把这些信息集中管理。改造一下CC gcc CFLAGS -Wall -Wextra -Iinclude TARGET app SRCS src/main.c src/counter.c OBJS main.o counter.o $(TARGET): $(OBJS) $(CC) -o $(TARGET) $(OBJS) main.o: src/main.c include/counter.h $(CC) $(CFLAGS) -c src/main.c -o main.o counter.o: src/counter.c include/counter.h $(CC) $(CFLAGS) -c src/counter.c -o counter.o .PHONY: clean clean: rm -f $(TARGET) *.o变量好处是显而易见的改编译器、加编译选项、换目标文件名都只动一处。但我必须提醒你这里还有个常见的伪善陷阱中级的Makefile学习者会用$(CC)和$(CFLAGS)这种系统内置变量名但容易误以为变量名是make规定的其实不是这样这里全部是自定义的你完全可以起个名字叫MY_GCCmake根本不在乎你管它叫什么。4.2 使用自动变量$、$^、$ 的妙用真正的拐点是学习使用自动变量。这三兄弟能把你从重复的文本中彻底解放出来$当前规则里的目标文件名$^当前规则里所有依赖文件的列表去重后的$依赖列表里的第一个文件用它们可以把两条.o规则写成这样main.o: src/main.c include/counter.h $(CC) $(CFLAGS) -c $ -o $ counter.o: src/counter.c include/counter.h $(CC) $(CFLAGS) -c $ -o $这里$取的是第一个依赖文件也就是对应的.c源文件$是目标文件名。你可以试着把那两条规则里的命令合并成一条通用规则但先别急马上还有更好的方案。4.3 模式规则把一类文件一次搞定如果有一百个.c文件要编译一个文件一行规则写一百行这太低效了。make支持模式规则用百分号%做通配一条规则搞定所有同类型的编译。%.o: %.c include/counter.h $(CC) $(CFLAGS) -c $ -o $这条规则的意思是任何一个.o文件如果它对应的.c文件通过%通配对应时间戳更新就执行编译命令。这里%.o匹配到main.o则$就会自动变成main对应的源文件路径。问题来了main.c在src子目录%.c匹配到的路径是src/main.c这没问题。但生成的main.o是放在当前目录的编译器直接输出。如果你的项目源文件分散在多个目录最好让.o也分目录存放这就引入了另一个特性。4.4 VPATH与vpath告诉make去哪里找文件make搜索依赖文件时默认只搜索当前目录。如果你的源文件都在src和include子目录里第一版Makefile里之所以编译通过是因为我写明了src/main.c这个路径。但假设规则是%.o: %.c目标main.o需要依赖main.cmake首先在当前目录找main.c找不到就直接放弃不会自动去src目录看。解决办法是设置VPATH变量告诉make额外去哪些目录搜索依赖文件VPATH src include加上这一行%.o: %.c里对应的%.c就能找到src/main.c了include的环境虽然没有直接用但如果有头文件在include目录也可以在这里声明。VPATH和vpath的区别有必要说明VPATH是全局的影响所有类型文件的搜索vpath是精细化控制可以按模式分别指定搜索路径例如vpath %.c src vpath %.h include实际项目中我更推荐用vpath因为它不会引入目录同名文件互相干扰这类副作用行为可控得多。4.5 自动收集依赖关系省去手工维护头文件依赖前面提到手工在规则里列头文件依赖既繁琐又容易漏。有一个成熟方案能自动完成这项工作思路是让gcc帮你生成依赖关系然后在Makefile里include这些依赖文件。gcc有一个-MM选项能输出源文件对头文件的依赖信息格式长这样main.o: src/main.c include/counter.h include/common.h你可以在规则里先编译出.d依赖文件再通过include把它们导入Makefile。完整的自动化流程如下SRCS src/main.c src/counter.c OBJS $(SRCS:.c.o) DEPS $(SRCS:.c.d) %.o: %.c $(CC) $(CFLAGS) -MMD -c $ -o $ -include $(DEPS)所有关键点都在这里了$(SRCS:.c.o)是变量替换把列表中所有.c后缀替换成.o一行代码就把源文件列表映射成了目标文件列表后面不管加多少个.c文件OBJS都会自动更新这就是Makefile写活的关键技巧。-MMD选项让gcc在编译的同时生成同名的.d依赖文件内容就是那个源文件所依赖的头文件列表。-include $(DEPS)把.d文件当作Makefile的一部分包含进来使那些自动生成的头文件依赖关系自动变成构建规则的一部分。因为.d文件里已经有了main.o: src/main.c include/counter.h这样的规则就等于替你把依赖声明写好了。这套方案几乎是所有中大型Makefile项目的标配也是理解后面高级用法的地基。去查资料时你会发现CMake生成的Makefile也在背后用了大量类似的处理原理是相通的。4.6 伪目标.PHONY的必要性看第一版Makefile里的clean规则目标clean既不是文件也不依赖任何东西。如果你在当前目录下不小心建了一个名为clean的文件make执行clean时发现目标文件的时间戳最新就会直接告诉你make: clean is up to date.然后什么都不做。因为make的默认逻辑只认文件它认为clean这个文件已经存在且无需更新。解决办法就是把clean声明为伪目标.PHONY: clean.PHONY告诉make这个目标不是真实文件名无论磁盘上有没有名叫clean的文件只要执行make clean就无条件执行命令。同理all、install、test这些动作目标都应该像这样声明成伪目标避免与常规文件名发生冲突。如果一个项目里还想支持编译但不链接的检查模式、生成文档的doc目标等都可以参考同样的思路来处理。4.7 一个比较完整的可用版本综合上面所有改进最终我提供一个可以直接拿去用的版本。它不算极端精简但胜在清晰可靠、可扩展CC gcc CFLAGS -Wall -Wextra -O2 -Iinclude TARGET bin/app SRCS src/main.c src/counter.c OBJS $(SRCS:.c.o) DEPS $(SRCS:.c.d) $(TARGET): $(OBJS) mkdir -p bin $(CC) -o $ $(OBJS) %.o: %.c $(CC) $(CFLAGS) -MMD -c $ -o $ -include $(DEPS) .PHONY: all clean all: $(TARGET) clean: rm -rf bin src/*.o src/*.d注意这里我做了两个小改动一是把最终可执行文件放到了bin子目录编译前先确保目录存在二是把生成的.o和.d文件放在了每个源文件同目录方便清理。这套Makefile已经具备实际项目的雏形了。5. 常见报错与排错思路实录5.1 make: *** No rule to make target xxx.o, needed by app. Stop.这类报错在刚开始写Makefile时几乎人人都会遇到。拆开看意思是make在构建app时需要某个.o文件但既找不到这个目标文件也找不到生成它的规则。常见原因有三个OBJS变量里多写了某个源文件而这个源文件根本不存在或者路径写错了。源文件放在src目录里但Makefile规则里写的是%.o: %.c没有vpath或VPATH告诉make去哪里找.cmake在当前目录找不到源文件自然认为无法生成这个.o。一个更隐蔽的原因目标文件名和源文件名的对应关系不是一一对应的。比如你编译foo.c却写成了gcc -c foo.c -o bar.o那么bar.o这个目标就永远没法靠模式规则生成因为bar.c不存在。排错的第一步永远是先用make -n跑一遍。这个参数叫试运行它只打印命令而不真正执行你会看到make打算干什么问题就能快速显现。建议把它养成习惯改动结构以后先试运行一次。5.2 Makefile:18: *** libs跟着报错Stop.有同学在实际项目里遇到过类似make[2]: *** [makefile:18: libs] error 1这样的报错直接从表面看是make执行到Makefile文件第18行、名为libs的目标时出错了返回的退出码是1。这个报错的迷惑性在于错误原因往往根本不在第18行。因为Makefile的出错表现为某个目标无法完成构建真实原因藏在这个目标依赖的更深层构建里。比如libs依赖于libfoo.alibfoo.a的构建脚本执行失败返回了非零状态码最终呈现出来的就是libs这个外层目标失败。这种情况要顺着构建树一层层往下追不能只看表面报错行。排查思路推荐这样做单独执行那个失败的目标比如make libs或者用make -j1 V1强制单线程并打印详细命令把真正失败的编译命令揪出来单独拿到命令行里手动跑一遍错误信息就会直接显示出来。在Makefile里排查报错跟警察追案是一个逻辑——得找到第一现场不能只看案发地点。5.3 make: Nothing to be done for all这句提示字面意思很清楚all目标已经是最新的了。但如果你压根还没编译过就觉得莫名其妙。常见的场景是你执行了make all而Makefile里压根没有all这个目标make用自己的默认目标是第一个目标所以all指向了别的目标而那个目标已经是最新的或不存在。要么检查你的Makefile里是否有all这个目标要么删除掉之前编译产生的文件再重新make。记得给all声明为.PHONY这种行为才会稳定可预期。5.4 missing separator 与 recipe commences before first target这两条报错都是命令前没有使用Tab键引起的直接后果。missing separator通常在命令行的位置检测到缩进有问题recipe commences before first target则是同一问题的不同表现。解决方法就是删掉行首的空格重新用一个Tab键。这地方没有任何技术含量纯粹是格式纪律问题。5.5 Makefile里的中文注释和中文目录名如果你在Makefile里写注释注意保证文件编码正常不要出现奇怪的字符。有些环境里的make对非ASCII字符支持并不好注释里的全角字符可能导致解析出错。更麻烦的是中文目录名——我没少见过在Windows共享目录里写Makefile、然后拿到Linux下跑的项目路径里带着中文或空格导致make解析时当成多个依赖项。解决办法是路径一律用英文目录名不要带空格不要带特殊符号。5.6 警惕make和cmake的功能混用很多新同学刚接触CMake总觉得它就是Makefile的更高阶版本。这话说起来不太准确——CMake是一个构建系统生成器它能生成Makefile也能生成别的构建文件比如Ninja、Xcode工程、Visual Studio工程用户写CMakeLists.txt而不是直接写Makefile。对应关系好比是你用Browser不太关心浏览器内核一样——你写CMakeLists构建时由CMake帮你生成一套构建文件然后交给make去执行。如果你正在用CMake或者打算学CMake我建议至少先把make的基础原理搞清楚。因为CMake生成出来的Makefile也是make语义在跑出现构建问题追根溯源时最终还是要回到目标和依赖、时间戳、命令序列这些底层概念上。手写Makefile是理解整个构建体系的最直接途径直接绕过去的话后面排查问题会像隔着一层雾。6. 从Makefile小白到入门级进阶的实操心得6.1 先别贪多学会最小可用原则我见过太多人一上来就试图写一个能适配所有情况的万能Makefile变量套变量、函数套函数、把make的每个功能都用上结果被复杂规则折磨得怀疑人生。Makefile不是越复杂越好它要解决的核心问题是增量构建正确且高效。先把编译哪些源文件、生成什么目标、依赖哪些头文件这三件事理清楚你至少能应付80%项目的日常构建。6.2 增量构建下的坑修改头文件后没生效这是最常见的诡异问题明明改了头文件重新编译但程序行为还是旧的。原因通常是依赖声明不完整某个.c文件没有正确声明它依赖了那个头文件make判断它无需重建。如果你用的是前面讲到的-MMD自动依赖方案这个问题基本不会出现。如果没有用请检查那条.o规则的依赖列表里是否包含了相关头文件。6.3 并行编译(-j)的性能收益与风险make支持并行执行构建命令make -j$(nproc)能把四核八线程机器的编译时间压下来一大截。但并行构建的前提是Makefile里正确声明了依赖关系。依赖声明有漏洞的话多个目标可能同时访问同一个中间文件轻则产出错误结果重则直接构建失败。这也是为什么自动化依赖收集如此重要——手写依赖很难保证完整而完整是并行的安全前提。首次大规模编译时我建议先用make -j2试水确认一切正常后再把并行度换成满核。与-j配合使用的最实用参数是-l可以限制系统负载上限避免编译瞬间把机器拖成幻灯片。6.4 用好这些辅助命令效率翻倍make这个工具本身还自带几个特别实用的调试参数用好了能节省大量排查时间make -n只打印要执行的命令不执行也就是预演make -B无视时间戳强制执行所有规则make -d输出完整的debug信息包括make每一次比较时间戳的细节排查为什么这个目标没重建时特别有用make -p打印Makefile里定义的所有变量和规则能看到系统内置隐含规则在背后干了什么我个人排查问题的固定流程是先make -n看逻辑再make -d追时间戳补充问题建议再make -B强制重建一次做对照。这套流程基本能定位90%的Makefile问题。6.5 我踩过的两个印象深刻的坑第一个坑是误操作过把Makefile复制到Windows上用Notepad编辑保存回来之后所有换行符变成了\r\nmake直接报了诡异的解析错误。如果你遇到一个Makefile在别人机器上能跑、你这里报错第一反应应该查一下文件的换行符是不是变成CRLF了用file Makefile就能看出来。第二个坑跟名字有关。我曾给一个项目的Makefile起了个名字叫makefile全小写和工具同名结果在某个目录里导致行为异常。后来我习惯性地把构建文件命名为大小写规范Makefile——因为当目录里同时存在makefile和Makefile时GNU make会优先选择Makefile而其他某些平台工具可能有不同规则。统一用Makefile避免这类低级纠纷。6.6 一点关于是否该手写Makefile的个人意见市面上有种论调说新手不该学Makefile直接上CMake就行。我做项目时的体会是如果你只是用IDE或者构建脚本的现代工具链直接学CMake完全可行毕竟写CMakeLists比手写Makefile容易维护。但如果你想深入理解构建这件事本身——比如搞懂为什么改了头文件后大部分工具有时会出问题为什么增量编译会失效为什么某些构建优化选项会影响产物——这些底层逻辑在Makefile里是一目了然的而且一旦理解透了看CMake那些行为会轻松得多。学Makefile不意味着你以后要手写它而是让你在面对一个陌生构建系统时不至于完全黑盒操作。遇到问题能多一条排查路径这本身就是竞争力。