ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Makefile完全指南:从零到自动化构建的五个迭代实践

Makefile完全指南:从零到自动化构建的五个迭代实践 先聊个现象。在Linux下做C/C开发makefile是绕不过去的一道坎。很多人一开始翻教程看见满屏的$、$、%直接劝退也有人觉得无所谓gcc main.c -o main一条命令搞定为什么要学这个两种心态我都经历过但等项目从几个文件变成几十个文件、每次改一个头文件就得全体重编的时候你才会意识到makefile不是给项目添麻烦而是帮你把“什么该编译、什么不该编译、怎么编译”这件事系统性地管了起来。这篇文章我打算用五次代码结构的迭代从最简单的单文件规则开始一步步把makefile的原理、变量、自动变量、伪目标、函数、模式规则这些知识拆开讲清楚。整个过程不依赖任何图形界面纯命令行环境就能跟着操作适合Linux初学者、刚接触嵌入式开发或者准备面试想系统梳理makefile知识的人。1. 第一次迭代单文件场景下让make先跑起来1.1 最简单的makefile长这样假设你有一个hello.c里面就是经典的printf(hello\n)。正常情况下你会在终端敲gcc hello.c -o hello现在把它写进一个叫makefile或Makefile的文件里hello: hello.c gcc hello.c -o hello然后终端执行make屏幕上会打印出gcc hello.c -o hello当前目录出现可执行文件hello。就这么简单你其实已经掌握了makefile最核心的结构第一行是“目标冒号依赖列表”第二行是“编译命令”。命令前面那个缩进必须是Tab键不能是四个空格这是makefile最著名的坑后面我会专门展开说。这里有个很多人第一次接触make时容易误解的点make本身不是一个编译器它只是个“按规则执行命令”的调度器。gcc hello.c -o hello这条命令最终还是让gcc去干活make只是负责判断“这条命令该不该执行、什么时候执行”。1.2 目标、依赖与规则makefile的三大要素我们可以把makefile里的每个“配方”抽象成三件事目标target你要生成的东西可以是可执行文件、目标文件.o也可以是一个动作名比如clean。依赖prerequisites生成目标之前需要先准备好的文件可以是源文件、头文件也可以是其他目标。规则recipe真正执行的命令告诉make怎么从依赖生成目标。对应到上面那个例子hello是目标hello.c是依赖gcc hello.c -o hello是规则。make执行时有个简单的逻辑如果目标文件不存在或者目标文件比任何一个依赖文件“旧”时间戳更早那么make就执行规则里的命令来重建目标如果目标存在且比所有依赖都新make就告诉你hellois up to date什么都不做。这个“比较时间戳”的机制就是make能实现增量编译的底层原因。你可以自己做个实验第一次执行makegcc会跑一遍紧接着再执行一次make它就会说make: hello is up to date.因为hello已经比hello.c新了。如果你用touch hello.c把源文件时间戳刷成当前时间再执行make它又会重新编译一次——哪怕hello.c的内容并没有变化。1.3 文件名与-f参数为什么有人会报“No makefile found”默认情况下make会在当前目录按照GNUmakefile、makefile、Makefile的顺序查找文件。如果你把文件命名为build.mk直接敲make就会报make: *** No targets specified and no makefile found. Stop.这个报错有两个触发条件一是当前目录确实没有makefile二是makefile文件名不匹配默认规则。解决办法有两个要么把文件重命名为makefile或Makefile要么用-f参数显式指定make -f build.mk从工程习惯上讲我建议统一用Makefile这个命名。原因有两个第一很多开源项目和CI脚本默认找的就是Makefile第二在某些文件管理器里大写开头的文件会排在前面好找。当然这只是个习惯不是强制要求你用makefile也完全没问题。2. 第二次迭代多文件工程里用变量管好编译参数2.1 从单文件到多文件手工编译开始变得繁琐真实项目几乎不可能只有一个源文件。假设你的项目变成了三个文件main.c、utils.c、utils.h其中main.c和utils.c都引用了utils.h。如果继续用第一版的思路你可能会写app: main.o utils.o gcc main.o utils.o -o app main.o: main.c utils.h gcc -c main.c -o main.o utils.o: utils.c utils.h gcc -c utils.c -o utils.o这个版本已经能跑了但有个很明显的问题gcc和编译选项散落在每条规则里。一旦你想加上优化参数-O2就得改三处万一漏改一处两个目标文件的编译选项不一致排查起来特别恶心。这种“同一个参数写N遍”的代码迟早会出问题。2.2 引入变量改一处处处生效解决重复的最好办法就是把公共内容抽成变量。改造一下CC gcc CFLAGS -Wall -g TARGET app OBJS main.o utils.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ main.o: main.c utils.h $(CC) $(CFLAGS) -c $ -o $ utils.o: utils.c utils.h $(CC) $(CFLAGS) -c $ -o $这里定义了四个变量CC是编译器CFLAGS是编译参数TARGET是最终可执行文件OBJS是中间目标文件列表。使用变量时统一用$(变量名)引用make会在解析时把它替换成对应的值。这么改的好处立竿见影以后想加一个-O2优化参数只需要改CFLAGS这一处想换成clang编译把CC clang即可。整个makefile瞬间清爽了很多。这里要解释一下为什么CC而不是直接用gcc。因为make本身内置了一些默认变量CC就是其中之一默认值是cc而cc在绝大多数Linux发行版上是一个指向gcc的符号链接。所以你在makefile里写CC gcc其实是覆盖默认值这符合惯例。很多开源项目还会让用户通过命令行覆盖它make CCclang CFLAGS-O2 -Wall命令行里传入的变量会覆盖makefile里的赋值这是make设计上的一个特性实际用起来非常方便。2.3 、:、?的区别变量语法里的细节说到变量就不得不提makefile里几种赋值符的区别。这也是面试里高频出现的问题。递归展开赋值。变量在真正被使用时才展开且展开时能看到其定义之后的值。:简单展开赋值。变量在赋值时立即展开值就固定下来了。?如果变量没有被赋值过才赋该值如果之前已经赋过值则跳过。追加赋值在原有值后面继续追加。举个具体的例子说明和:的区别A hello B $(A) world A hi all: echo $(B)用定义B时B在echo那一刻才展开此时A已经是hi了所以输出是hi world。如果把B改成B : $(A) world那么B在赋值那一刻就固定为hello world后面A再变也影响不到它。实际写makefile我推荐能用:就用:因为它的行为更可预测不容易被后续变量变动“暗算”。?常用于允许外部覆盖的默认配置比如CFLAGS ? -O2这样用户执行make CFLAGS-g时你自己的默认值不会把它覆盖掉。3. 第三次迭代搞懂增量编译善用自动变量和伪目标3.1 增量编译的底层逻辑时间戳比较工程一大编译时间就会变长。如果每次只改了一个.c文件却要把整个项目全部重新编译一遍谁也受不了。make的增量编译机制就是为这个场景设计的它通过比较目标文件和依赖文件的时间戳判断哪些目标需要重建。举个典型场景修改了utils.c没改main.c。make在解析app: main.o utils.o时会逐个检查main.o和utils.o是否需要更新。main.o的时间戳比main.c和utils.h都新所以跳过utils.o的时间戳比utils.c旧所以重新执行gcc -c utils.c。最后链接时app比utils.o旧于是重新执行链接命令。这个过程非常像你收拾房间看见哪件东西的位置不对了就把它归位位置对的就不动它。make也是这个思路但它只认时间戳。所以要记住一个关键点如果你手动修改了某个文件却把它的时间戳改回去了比如用touch -d指定旧时间make会认为它没变从而跳过编译。这种问题非常隐蔽一旦遇到“我明明改了代码为什么编出来的还是老行为”先检查时间戳。3.2 $、$、$^让规则更简洁的自动变量第二版makefile里已经出现$和$了这一节集中讲清楚。自动变量是make在每条规则执行前自动设置的变量只在该规则内有效。变量含义$当前规则的目标文件$当前规则中的第一个依赖文件$^当前规则中的所有依赖文件以空格分隔$?当前规则中所有比目标文件新的依赖文件列表$(D)目标文件的目录部分$(F)目标文件的文件名部分用自动变量重写编译规则之后就非常简洁main.o: main.c utils.h $(CC) $(CFLAGS) -c $ -o $$在这里就是main.c$就是main.o。你再也不用担心文件名写错因为它是自动替换的。链接规则里用$^也很方便$(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^$^会把main.o utils.o全部带上。注意$^会自动去重如果同一个依赖文件出现两次make只会保留一个。如果想保留重复项要用$不过日常场景很少用到。3.3 .PHONY伪目标clean为什么需要声明几乎每个makefile里都会有一个clean目标用来删除中间文件和可执行文件clean: rm -f $(TARGET) $(OBJS)但如果你直接这么写会遇到一个坑假设当前目录下恰好有一个叫clean的文件执行make clean时make会检查clean这个目标有没有依赖。这里clean没有依赖如果clean文件已经存在make会认为它“不需要更新”于是什么都不做。你会看到make: clean is up to date.rm命令根本没执行。解决方式就是把clean声明为伪目标.PHONY: clean clean: rm -f $(TARGET) $(OBJS).PHONY告诉make这个目标不代表真实文件不要检查时间戳每次都要执行它的规则。同理install、test、format这些动作型目标都应该声明为.PHONY。顺带说一句clean只是约定俗成的名字你完全可以叫rm或cleanup但希望大家能统一用clean因为这是几乎所有开源项目都遵守的惯例别人接手你的makefile时不用猜。4. 第四次迭代用函数和路径搜索解放双手4.1 用wildcard自动收集源码文件第二次迭代里我们用OBJS main.o utils.o手动罗列目标文件。项目文件一旦变多比如十几个源文件手动维护这个列表就非常痛苦。此时可以用wildcard函数让make自己去目录里找文件SRCS $(wildcard src/*.c)这行代码会把src目录下所有.c文件的完整文件名带路径收集起来存到SRCS变量里。以后在src目录下新建一个.c文件makefile都不用改。有了源文件列表再用patsubst批量生成对应的目标文件列表OBJS $(patsubst src/%.c, build/%.o, $(SRCS))patsubst是make内置的模式替换函数格式是$(patsubst 原模式, 目标模式, 文本列表)。上面的写法会把src/main.c替换成build/main.o把src/utils.c替换成build/utils.o。这样源码列表和目标文件列表始终一一对应不会出现“加了一个文件但忘了编译它”的尴尬。4.2 用一个例子串起来源码和产物分离实际工程里我一般会把源码放在src目录头文件放在include目录编译产物放在build目录。这样做的好处是源码目录很干净不会堆积一堆.o文件和可执行文件清理时直接删掉build目录就行。一个典型的结构长这样CC : gcc CFLAGS : -Wall -Wextra -Iinclude TARGET : app BUILD_DIR : build SRC_DIR : src SRCS : $(wildcard $(SRC_DIR)/*.c) OBJS : $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS)) $(BUILD_DIR)/%.o: $(SRC_DIR)/%.c mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) -c $ -o $ $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ .PHONY: clean clean: rm -rf $(BUILD_DIR) $(TARGET)注意编译规则里有个mkdir -p $(BUILD_DIR)因为gcc不会自动创建不存在的目录。每次编译前先确保build目录存在不然会报“No such file or directory”。4.3 VPATH与vpath让make找到分散的源码有时候源码不在当前目录甚至分散在多个目录里比如src1、src2、common。你在规则里写app: main.o utils.o而main.c在src1里utils.c在src2里。make默认只在当前目录找依赖文件找不到就会报“No rule to make target main.o”。这时候有两种解决办法。第一种是用VPATH变量它指定make去哪些目录搜索依赖文件VPATH src1:src2:common第二种是更精细的vpath指令可以按文件类别分别指定搜索路径vpath %.c src1 src2 common vpath %.h include commonVPATH和vpath的区别在于粒度不同。VPATH是全局的所有文件类型都用同一组路径vpath可以针对不同后缀类型指定不同的搜索路径比如.c文件去src1 src2找.h文件去include找。当项目结构比较复杂时vpath明显更可控。需要提醒一点VPATH解决的是make“找依赖文件”的问题不是给gcc找头文件用的。gcc找头文件靠的是-Iinclude这种编译选项两者别搞混。5. 第五次迭代模式规则与依赖文件生成接近实际工程5.1 模式规则一条规则搞定所有同类型文件到了这一步makefile里最让人头疼的就是重复的编译规则了。假如项目有10个.c文件难道要写10条xxx.o: xxx.c的规则吗当然不用。make支持模式规则用%作为通配符匹配任意非空字符串。build/%.o: src/%.c $(CC) $(CFLAGS) -c $ -o $这条规则表示“任何一个build/xxx.o文件都可以由对应的src/xxx.c文件生成”。make在需要构建build/main.o时会用main替换%自动套用这条规则。有了模式规则前面例子里的多条编译规则可以压缩成一条整个makefile一下短了一大截。这也是make相比手写shell脚本最大的优势规则是可推导的而不是一份死板的清单。5.2 静态模式精准约束一批目标模式规则虽然方便但它是“全项目生效”的任何以build/%.o为目标的需求都会匹配上。有时候你想限定某一批文件使用特定规则可以用静态模式$(OBJS): build/%.o: src/%.c $(CC) $(CFLAGS) -c $ -o $静态模式的语法是“目标列表: 目标模式: 依赖模式”。它会强制要求OBJS里的每个目标都匹配build/%.o格式并把对应的.c文件作为依赖。如果某个目标不符合模式make会直接报错这相当于一种校验可以提前暴露拼写错误。静态模式比普通模式规则更安全也更适合“一批文件用一种规则另一批文件用另一种规则”的场景。比如test目录下的文件用特殊的测试编译选项而src目录下的文件用普通选项这时候静态模式就很好使。5.3 用-MMD自动生成头文件依赖聊到这里有个长期被忽视的问题终于要正面解决了头文件依赖。前面我们写main.o: main.c utils.h是手动把utils.h列为依赖。一旦项目头文件多了手动维护这个列表几乎不现实漏写一个头文件就会出现“改了头文件却不会触发重新编译”的诡异问题。现代gcc提供了一个非常实用的解决方案用-MMD选项在编译时自动生成依赖文件。它的原理是让gcc在编译main.c的同时扫描它包含的所有头文件然后把依赖关系写入一个.d文件里。make再用-include把这些.d文件包含进来就能准确知道每个.o依赖于哪些头文件。在makefile里落地这个方案只需要一步CFLAGS : -MMD -MP -Wall -Iinclude DEPS : $(OBJS:.o.d) -include $(DEPS)-MMD表示生成依赖文件但不影响正常编译-MP会为每个头文件生成一个空的伪目标防止头文件被删除后make因为找不到目标而报错。DEPS : $(OBJS:.o.d)是把build/main.o替换成build/main.d。-include前面的减号表示如果.d文件不存在不要报错——因为第一次编译时这些文件确实还不存在。加上这几行之后你就不再需要手动写“哪个.o依赖哪个.h”了编译器替你完成了这件事。这个机制也是很多现代构建系统比如CMake生成的Makefile底层的依赖跟踪思路。第五次迭代的完整makefile可以浓缩成下面这样CC : gcc CFLAGS : -MMD -MP -Wall -Wextra -Iinclude TARGET : app BUILD_DIR : build SRC_DIR : src SRCS : $(wildcard $(SRC_DIR)/*.c) OBJS : $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS)) DEPS : $(OBJS:.o.d) $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ build/%.o: src/%.c mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) -c $ -o $ -include $(DEPS) .PHONY: clean clean: rm -rf $(BUILD_DIR) $(TARGET)这个makefile已经具备了一个中型C工程的基本要素变量配置、自动收集源码、自动生成头文件依赖、伪目标、模式规则。从第一次迭代走到这里每一步都是在解决前一步暴露出来的痛点。6. 常见报错排查makefile最典型的几个坑6.1 “No targets specified and no makefile found”的几种解法这个报错我在前面提过一嘴这里补充完整。它最常见的原因有三个当前目录确实没有makefile或者写错了文件名。makefile存在但当前shell的当前目录不对你需要在正确的目录下执行make。使用了-f指定文件但路径写错了。排查顺序很简单先ls看看文件在不在再确认文件命名是否符合默认规则最后看当前目录对不对。如果是从别处拷贝的makefile还要检查文件是否被完整覆盖了有时候拷贝中断会留下一个0字节的makefilemake照样会报错。6.2 “missing separator”与Tab/空格之争这个报错非常经典makefile:2: *** missing separator. Stop.十有八九是你把规则命令前面的Tab写成了空格。makefile的语法规定规则底下的命令必须以Tab字符开头。make用Tab来区分“这是一条命令”还是“这是另一条规则”。如果你用了四个空格make会认为你在规则描述的位置写了非法内容于是报“missing separator”。有人觉得这个设计很坑但它的历史原因是早期makefile要兼容老式编辑器Tab是当时最常用的缩进字符。我自己踩过无数次这个坑后来总结出一个习惯写makefile时立刻在编辑器里开启“显示不可见字符”功能Tab显示为一条横线空格显示为点一眼就能看出来区别。如果你拿到一个别人的makefile怎么知道它是Tab还是空格可以执行cat -A makefileTab会显示为^I空格不显示一查便知。这个命令在排查问题的时候特别好用。6.3 “No rule to make target”的排查思路make: *** No rule to make target foo.o, needed by app. Stop.主要原因是make找不到生成foo.o的规则或者找不到对应的源文件。常见情况有以下几种源文件路径写错了。比如规则里写src/%.c但实际文件在source/目录下。wildcard函数没匹配到文件。可以用$(info $(SRCS))打印变量内容确认SRCS是否为空。依赖了某个不存在的头文件make在解析.d文件时报错。这时候直接打开对应的.d文件看里面记录的头文件路径是否存在。同时存在多个同名目标文件但make只匹配了一个。排查思路就一句话把makefile里的变量都打印出来看。在makefile里加一行$(info 调试信息: $(SRCS))执行make时就会在解析阶段打印对应内容比凭感觉猜快得多。6.4 调试工具make -n / make -d最后分享几个我高频使用的调试命令。make -n是干跑模式它会打印出将要执行的命令但不会真正执行。如果你想确认“改了源文件之后make到底打算重编哪些文件”先用make -n预览一遍再决定要不要真正执行。这个命令我几乎每天都会用尤其是清理了一些中间文件之后想预测一下下次编译会做什么。make -d会输出大量调试信息包括每个目标的时间戳比较结果。虽然信息量很大很刷屏但当你怀疑“make为什么没有重新编译某个文件”的时候它的参考价值最大。建议用make -d 21 | grep -A 5 main.o这样过滤一下只关注你关心的目标。make -p可以打印出make的所有内置规则和变量适合用来查默认规则。有时候你发现make“自动”会编译.c文件靠的就是这些内置规则-p能帮你看清楚它们长什么样。7. 最后聊点我自己的使用心得写makefile这件事一开始会觉得语法古怪、细节贼多但用顺手之后你会发现它的设计逻辑非常统一一切围绕“目标-依赖-规则”展开变量和函数只是用来减少重复的手段。我最开始写makefile也是从简单的单文件规则开始后面被项目逼着去学变量、自动变量、函数、模式规则每一步刚好对应一个实际痛点。所以如果你读完这篇文章还有哪里觉得不踏实最好的办法是找个几行代码的小项目亲手把五次迭代分别写一遍感受一下每个版本解决什么问题、引入什么新问题。这比单纯背语法记得牢得多。另外还有个很实用的小技巧想快速理解任何一个别人的makefile先运行make -n看它最终会执行哪些命令然后再倒回去看那些命令对应的变量是从哪来的。这个方法我试过很多次尤其在接手不熟悉的老项目时比逐行读makefile快得多。makefile的知识面其实不算深但胜在细节多只要掌握“怎么排查、怎么调试”就足够应付绝大多数日常开发了。
返回列表