拓冰建站拓冰建站
首页 / 资讯中心 / 正文

makefile完全指南:从目标依赖到自动化构建

1. 为什么编译一堆文件值得一个专门的工具来管我第一次认真接触Linux下的C项目时老师只丢给我一句话在终端敲make就行。我照做了项目编译通过几秒钟出结果。当时觉得神奇因为在此之前我习惯的方式是自己在终端里一行行敲gcc命令比如gcc -o app main.c utils.c logger.c config.c -I./include -lm刚开始文件少这条命令还能凑合。等文件一多问题就全来了命令越来越长敲完容易出错改了一个.c文件想把整个工程重新编译一遍但每次都要把所有文件重新编译几十秒起步更麻烦的是如果某个头文件变了我根本意识不到哪些.c文件依赖它经常改了头文件之后忘记重新编译对应的.o导致程序跑的还是老代码。说白了编译本身不是问题问题出在编译的管理上哪些文件需要重新编译、哪些不用动、以什么顺序编译、头文件依赖怎么追踪。这些事如果全靠人脑去记项目规模一大必然翻车。make干的事情就是把这套编译规则写进一个文件里——这个文件就是makefile。它读起来像一份菜谱告诉make我想做出什么东西目标需要哪些原料依赖以及怎么做命令。make拿到这份菜谱后会检查目标和依赖的时间戳判断哪道菜需要回锅加热哪道菜可以直接端上桌。网上很多教程会说make是自动化构建工具这个定义没错但对新手来说容易产生一个错觉觉得make是某种编译器。其实make本身不编译任何东西它只是根据规则帮你把gcc、ld这些编译命令组织起来、按需执行。真正的编译工作还是由编译器完成的。这篇文章适合谁想搞懂make和makefile是什么的Linux初学者面试前想临时补一下构建知识点的求职者以及对STM32、uboot、内核这些嵌入式项目里那堆makefile感到头痛的人。我会把自己实际用到的写法、踩过的坑都写在里面尽量让这份笔记不只是概念介绍而是能直接拿过来用的工具手册。2. makefile的最小骨架目标、依赖与规则的三角关系2.1 从三行代码理解makefile先看一个最简makefile长什么样app: main.c utils.c gcc -o app main.c utils.c这短短两行已经包含了makefile最核心的三要素目标target想生成的东西这里就是app这个可执行文件。依赖prerequisites生成目标需要哪些材料这里是main.c和utils.c两个源文件。规则recipe/command用依赖生成目标的具体命令第二行那个以Tab键开头的gcc命令就是规则。make执行的时候逻辑是这样的先看目标文件app存不存在。如果不存在直接执行规则命令如果存在再比较目标和所有依赖的时间戳——只要有任何一个依赖比目标新说明源代码新改了目标过期了需要重新执行命令如果所有依赖都没有目标新说明目标是最新的make就说没问题啥也不用干。注意一个细节规则命令前面必须是Tab键不能是空格。这是新手遇到最多的报错之一后面我会专门讲怎么排查。你可以理解为make的硬性规矩冒号下面跟的、以Tab开头的内容才被当作要执行的命令否则会直接报missing separator。2.2 第一个目标是默认目标这坑了多少人make在没有任何参数时运行它会去找当前目录下的makefile文件默认执行第一个目标。很多人写makefile习惯先写clean比如clean: rm -f *.o app app: main.c utils.c gcc -o app main.c utils.c这种情况下你在终端敲make它执行的是clean瞬间把你之前编译出来的.o文件和app全删了。我当年就这么吃亏过一次看着终端里一堆rm输出人都是懵的。所以约定俗成的做法是把all作为第一个目标后面跟整个工程真正要生成的东西all: app app: main.c utils.c gcc -o app main.c utils.c clean: rm -f app这样make默认构建appmake clean负责清理。2.3 伪目标让clean这种假目标真正可靠刚才的clean有个隐患。万一你的目录里恰好有一个叫clean的文件make检查clean这个目标时发现它已经存在而且没有依赖就会说make: clean is up to date.然后什么都不做。明明你想清理它却告诉你已经是最新的了就很无语。解决办法是用.PHONY把clean声明成伪目标.PHONY: clean伪目标的意思是这个目标不代表任何真实文件它只是一个动作的名字不要拿它和磁盘上的文件做时间戳比较。声明以后不管目录里有没有clean这个文件执行make clean都会老老实实跑命令。我第一次看到.PHONY时心想这东西有什么用啊直到真的被同名文件坑过一次才长记性。建议所有不生成文件的目标——clean、install、test这些——都一律加上.PHONY。2.4 增量编译是怎么自动发生的我们用gcc编译多文件工程时正确做法是先把每个源文件编译成目标文件.o最后再链接成可执行文件。这样一来如果只改了一个.c文件就只需要重新编译那一个文件再重新链接一次就行链接速度比编译快得多。makefile可以非常自然地描述这层关系app: main.o utils.o gcc -o app main.o utils.o main.o: main.c utils.h gcc -c main.c utils.o: utils.c utils.h gcc -c utils.c这里的依赖关系是app依赖main.o和utils.omain.o又依赖main.c和utils.h。make会自动递归检查整条依赖链。你改了utils.c它发现utils.o比utils.c旧就去重新编译utils.o然后发现app比新的utils.o旧再去链接app。而main.o不用重编因为是utils.c变了跟main.o的依赖无关。这就是make最聪明的部分它不需要知道你的编译逻辑它只按时间戳做事。你负责把依赖关系写清楚它负责判断什么需要重做。3. 变量、自动变量与常用函数写一个不重复自己的makefile3.1 变量让makefile摆脱硬编码如果每个目标都把gcc命令完整写一遍那makefile本身就会变得又臭又长。更合理的做法是把编译命令的参数提取成变量CC gcc CFLAGS -Wall -g -I./include LDFLAGS -lm app: main.o utils.o $(CC) $(LDFLAGS) -o app main.o utils.o main.o: main.c utils.h $(CC) $(CFLAGS) -c main.cCC是编译器名字CFLAGS是编译选项警告开关、调试信息、头文件路径LDFLAGS是链接选项。引用变量统一用$(变量名)的方式。这样做的好处是想换编译器、想加编译选项只需要改文件顶部的一处定义而不是满文件地改。makefile里的变量赋值方式有四种新手经常分不清赋值符号含义典型场景递归展开赋值解析时才展开适合写稍后会用到的值:立即展开赋值定义时立刻取值更符合直觉推荐多用?如果变量未定义才赋值允许外部环境变量覆盖追加内容给CFLAGS追加选项举个例子说明和:的区别A hello B $(A) world A bye # 此时 B 的值是 bye world因为 是递归展开用到B时才去找A的最新值 C : hello D : $(C) world C : bye # 此时 D 的值是 hello world因为 : 在定义时就确定了我最常用的是:和?。?特别适合接环境变量比如让用户可以在命令行覆盖默认值这个下面马上会说到。3.2 自动变量$、$^、$ 是写规则的三把刀写规则命令时你经常要写目标名所有依赖第一个依赖如果都用硬编码工作量大且容易漏改。make提供了一批自动变量专门在规则命令里用自动变量含义$当前规则的目标名$^当前规则的所有依赖去重$当前规则的第一个依赖$*目标去掉扩展名后的部分有了它们上面的例子就能简化成main.o: main.c utils.h $(CC) $(CFLAGS) -c $ -o $$会自动展开成main.c$自动展开成main.o。这样即使目标名改了命令也不用动。链接阶段也是最常见的使用方式app: main.o utils.o $(CC) $(LDFLAGS) -o $ $^$^展开为main.o utils.o一次引用所有依赖。3.3 通配符和两个高频函数wildcard、patsubst真正工程里的源文件通常不会只有两三个而是几十个。手动一个个列出来不现实。这时候要用通配符和函数让make自动发现源文件SRCS $(wildcard *.c) OBJS $(patsubst %.c,%.o,$(SRCS))$(wildcard *.c)会把当前目录下所有.c文件列出来赋值给SRCS。$(patsubst %.c,%.o,$(SRCS))把SRCS中的每个.c后缀换成.o得到期望的目标文件列表。这两个函数我几乎在每个makefile里都会用到。一开始可能觉得多了一层抽象但用习惯以后往目录里加新源文件时完全不用改makefile相当省心。除了这两个$(notdir 路径)可以去掉路径只剩文件名$(shell 命令)可以在makefile里执行shell命令并取回结果。比如内核源码和uboot里经常见的$(shell ...)用法本质就是这种思路把一些环境探测交给shell去跑。3.4 命令行传变量makefile里的参数开关有时候你不想改makefile只想临时换一下编译行为比如热词里出现的这种命令make have_x11no have_glfwno prefix/usr/local这其实是make的一个设计命令行上传入的变量值会覆盖makefile里的同名赋值。也就是说只要makefile里写的是prefix ? /usr/local命令行上传了prefix/usr/local命令行生效。这个机制非常适合做软开关和安装路径配置。写makefile时凡是可能由用户定制的参数都应该用?来声明默认值留出命令行覆盖的口子。4. 一个能直接抄的多文件工程makefile从自动收集源文件到并行编译4.1 一份适合中小型C项目的通用makefile把前面说的知识点串起来我整理了一份自己经常复用的小型C工程makefile。不是花架子每一行都有实际用途CC : gcc CFLAGS : -Wall -O2 -g -I./include LDFLAGS : -lm TARGET : app SRCS : $(wildcard src/*.c) OBJS : $(patsubst src/%.c,build/%.o,$(SRCS)) DEPS : $(OBJS:.o.d) all: $(TARGET) $(TARGET): $(OBJS) $(CC) $(LDFLAGS) -o $ $^ build/%.o: src/%.c mkdir -p build $(CC) $(CFLAGS) -MMD -MP -c $ -o $ clean: rm -rf build $(TARGET) -include $(DEPS) .PHONY: all clean这份makefile有几个细节值得展开讲源文件目录与管理。我把.c放在src/下中间产物.o和依赖文件.d统一丢进build/目录。好处是工程根目录不会被编译产物搞乱。$(wildcard src/*.c)自动收集所有源文件加新文件不用改makefile。模式规则。build/%.o: src/%.c是一条模式规则它告诉make任何一个build/下的.o文件都由对应的src/下的.c文件生成。make会自动匹配对每个源文件套用这条规则。用模式规则替换掉一条条手写规则之后makefile的体积和可维护性都上了一层台阶。自动依赖生成。这里用了gcc的-MMD -MP选项编译时顺便生成.d依赖文件。.d文件记录了这个.o文件依赖的所有头文件这样头文件变化时也能触发重新编译。-include $(DEPS)让它们参与make的依赖检查。这个功能一开始可以不加但项目头文件多了之后比如只需要加一行#include common.h改动如果依赖没跟上make不会意识到需要重编程序可能在用旧对象文件非常难受。加上.d依赖以后这类问题就自动消失了。命令前的符号。mkdir -p build前面的表示执行命令时不在终端打印这条命令本身。mkdir -p的意思是目录存在不报错不存在就创建。4.2 并行编译老电脑也能享受多核红利make默认是一个目标一个目标串行执行但多个没有依赖关系的目标完全可以并行编译。方法特别简单make -j8-j后面的数字建议填CPU物理核心数或稍大一点。想知道自己机器有几个核心可以用nproc我自己的习惯是make -j$(nproc)让make自己拿核心数。如果项目里有多个独立的.o要编译并行后速度提升非常明显。一个几分钟的编译任务并行后可能三四十秒就完了。需要提醒的是-j在某些复杂的makefile里可能引发意外的竞争问题比如多个编译任务同时写同一个文件但一般中小型项目不用太担心开大了顶多多占内存。4.3 嵌入式和Go项目里常见的make用法热词里出现了好几条嵌入式相关的比如u-boot make menu、stm32 makefile、cubemx makefile vscode一起说说。嵌入式开发里make的使用率相当高。STM32CubeMX生成的工程可以直接生成makefile选中Makefile工具链然后在终端里用make -j编译烧录用make flash或者配合其他脚本。很多人在VSCode里做STM32开发其实也是调用了底层的make——VSCode的tasks.json里通常写着command: make。理解了makefile你就知道CubeMX那个工程骨架在编译阶段到底做了什么出了问题也知道去哪看。uboot和Linux内核的编译是另一个经典场景。它们的顶层makefile极其复杂但你日常只需记住几个目标make menuconfig # 打开图形化配置菜单 make -j$(nproc) # 开始编译 make dtbs # 只编译设备树你不需要把顶层makefile读完只需要理解make的依赖和目标机制这些目标在背后就是一组规则menuconfig依赖配置工具dtbs依赖所有设备树源文件。底层逻辑和最简单的makefile一模一样。Go项目纯用go build就能完成任务为什么还要套一层make我见过很多团队用make来封装流程比如make test跑全部测试、make lint做静态检查、make build-linux交叉编译出Linux二进制。这种用法的本质不是让make来管编译依赖而是把它当成一套统一的任务入口把容易记错的多步命令封装成简短目标。这个思路在任何语言的项目里都通用。5. 高频报错与排查链路从No targets到missing separator5.1 make: *** No targets specified and no makefile found. Stop.这个报错我见过太多次了几乎每个Linux新手都会撞上。热词里也专门有一条make没有指明目标并且找不到makefile。这句英文拆开看就三件事没有指定目标make时没带目标名默认用第一个目标找不到makefile文件当前目录下没有名为makefile、Makefile或GNUmakefile的文件常见原因我按出现频率排个序当前目录根本不是你项目所在目录或者项目目录里压根没创建makefile。文件名写错了。GNU make查找文件名是有先后顺序的GNUmakefile-makefile-Makefile。Windows上习惯的文件名到了Linux下大小写敏感如果建的是MakeFile或Makefile.txtmake也认不出来。makefile在子目录里但你人不在那个目录下。这时直接用make -C 子目录名或者先cd进子目录再make。排查时先执行ls -l看目录下有没有makefile再确认文件名。另外注意有些同学把工程文件从Windows拖到Linux虚拟机里文件名后缀可能被改掉或者打包时文件权限有问题这种情况ls能看到但读不了执行一下chmod r Makefile就能解决。5.2 Makefile:2: *** missing separator. Stop.这个报错八成是Tab键问题。makefile的规则命令必须以Tab开头但很多编辑器默认用空格代替Tab。你把文件从Windows记事本或某些IDE里粘贴过来时空格就悄悄混进去了。make解析到第二行发现这一行既不是变量赋值也不是目标规则于是报缺少分隔符。排查命令特别实用cat -A Makefile-A参数会把不可见字符全部显示出来。Tab会显示成^I行尾是$。你如果看到那行开头不是^I而是普通空格就是它了。解决办法很简单在编辑器里把缩进方式改成Tab或者把那行开头的空格删掉重新敲一个Tab。顺便说一句我用的VSCode会在状态栏显示缩进方式报错时第一反应就是看缩进是不是被自动换成空格了。5.3 No rule to make target xxx.h, needed by xxx.o这条的意思是make找不到某个依赖文件。常见原因头文件确实不存在或者路径写错了。检查#include的头文件名有没有拼错。头文件在别的目录但makefile里的规则没有把目录加进路径。此时在CFLAGS里加-I./目录名或者在依赖规则里明确写上路径。头文件是编译过程中生成的比如配置工具生成的config.h但生成这个头文件的目标还没执行。这条报错也是因为没有提前生成的典型案例。某次编译顺序出问题就卡在这了。解决办法是把生成头文件的规则排在编译规则之前让它作为依赖被make自动调度。理解依赖链之后这类错误基本看哪一行依赖没理顺就补哪一行。5.4 app is up to date. 和 Nothing to be done这两种提示不是错误是make在告诉你目标没有过期不需要重编。你可能觉得不对我刚改了代码啊。这时候先别急检查两点是否真的保存了源文件目标文件的时间戳是否晚于源文件。你在执行的是不是正确的目标。比如改了utils.c却去执行make clean这种伪目标它可不管源码动不动该啥反应还是啥反应。如果确实想无视时间戳强制重新编译可以用make -Bforce rebuild。排查时间戳问题我习惯用stat 文件名看详细时间stat utils.c app对比一下两个的时间谁新谁旧问题一目了然。5.5 undefined reference to ... 链接顺序的经典坑这条严格来说不完全是make的报错但因为通过make触发编译很多人会在makefile场景下遇到。典型情况是app: main.o gcc -o app main.o -lm这个是正常的。但如果写成gcc -o app -lm main.o有时候就会报undefined reference to pow。原因是ld在处理库的时候只扫描一次从左到右在遇到-lm时扫描的是之前的符号如果那时main.o里的符号还没被解析出来后面再出现main.o时数学库已经被处理过了函数就找不到了。解决办法很简单库的-l参数一定要放在引用它的目标文件之后。遇到链接报错时先看我是不是把库变量放在了命令行顺序的前面。这个坑在链接sqlite、pthread等库时也经常出现排查思路完全一样。5.6 结合真实场景eclipse makefile:49: fw-cnpc-app-proj.elf error 1热词里有一条eclipse makefile:49: fw-cnpc-app-proj.elf error 1这是交叉编译工具链在链接ELF文件时出错的典型输出。看到error 1不要慌往前翻日志真正有用的信息在它上面——通常是某个具体错误比如arm-none-eabi-gcc: error: ...或者某个头文件找不到。那个error 1只是make对链接命令退出码的转述表示这条命令失败了。排这种错我的固定套路是先把make的完整输出拉长看看make 21 | tee build.log找到第一条报错而不是看最后一行如果命令太长看不清用make -n把将要执行的命令打印出来手动在终端里跑一遍就能看到编辑器里被吞掉的细节。这套方法在嵌入式交叉编译和普通Linux程序上都屡试不爽。6. 面试考点和真实项目定位make和CMake到底怎么分工6.1 make在项目里到底扮演什么角色一句话定位make它是构建流程的调度器不是构建流程本身。编译靠gcc、链接靠ld、库管理靠各种工具而make负责在正确的时间调用正确的工具、传正确的参数。这就解释了它在真实项目里的位置。很多现代项目在外层还会套一层CMake典型流程是cmake . make热词里的cd nvbandwidth cmake . make就是这种模式。CMake根据CMakeLists.txt生成makefilemake再去执行具体的编译。为什么不直接用makefile因为makefile在跨平台场景下有短板——Windows上的路径分隔符、不同编译器的参数差异、寻找第三方库的机制都很麻烦。CMake把这些抽象了生成对应平台的构建文件。但CMake生成的构建文件底层几乎仍然是makefile体系Linux下如此。对学习路径的建议是先认真学makefile再学CMake会轻松很多。因为CMake生成的makefile虽然复杂但里面的很多语法——目标、变量、依赖——和手写makefile是一脉相承的。见过底层原理的人看CMakeLists时更容易心领神会。6.2 面试喜欢问的make/makefile问题以下几个方面是最常出现在Linux面试题里的没必要背标准答案理解原理就能答出来makefile中目标、依赖、规则的关系。一句话讲清楚目标是最终文件依赖是材料规则是从材料到目标的过程。make比较目标和依赖的时间戳决定要不要执行规则。为什么命令前要是Tab而不是空格。make的解析器把Tab当作规则命令开始的标记。这确实是历史设计但既然语法规定了就得遵守。面试时可以说这是make的语法定义行首Tab用来识别命令区域。什么是伪目标为什么要用.PHONY。伪目标不指向真实文件用来避免和目标文件重名导致的误判保证clean这类操作每次都真正执行。$、$^、$分别代表什么。目标名、所有依赖、第一个依赖。这三个用得最多建议背熟。makefile如何实现增量编译。通过拆分目标文件让make自动比较时间戳只重建发生变化的部分。makefile和shell脚本有什么区别。makefile重点在于声明依赖关系make根据依赖自动推导执行顺序shell脚本是顺序执行的命令流不会有make这种自动判断哪些需要重跑的机制。Makefile、makefile、GNUmakefile的区别。GNU make依序查找这三者查到哪个用哪个。一般推荐用Makefile因为它排在Linux下ls列表的前面容易发现。6.3 值得记住的几个make调试技巧最后再分享一下我平时用make时会用到的小工具算是在错误排查之外的日常技巧make -n # 只打印将要运行的命令不真正执行 make -B # 强制重新构建所有目标忽略时间戳 make -C 目录名 # 进入子目录执行make make -w # 打印当前工作目录适用于子目录递归构建时-n这个参数尤其推荐。刚写完一个makefile不确定写没写对之前先make -n看一遍即将执行的命令。它会直接打印出gcc那几行你一眼就能看出变量有没有展开、顺序对不对。比直接make踩到错误再回头看要从容得多。最后再分享一个小技巧写makefile这么多年最大的体会是不要把它当成一门高深的语言去学而是当成一份给机器看的编译笔记。你在终端手工敲过的每一次编译命令其实就是最原始的构建脚本makefile做的只是把这份笔记结构化、可复用、带依赖判断而已。所以从我之前是怎么编译的出发一条条把命令搬进makefile再逐步用变量替换重复的部分这个过程本身就是在学会make。如果你刚入门建议拿一个只有三五个源文件的小项目练手自己手工写一份makefile完整跑一遍编译、清理、增删源文件的过程。一旦体会过改一处makefile全工程编译逻辑都跟着走的感觉后面再接触内核或嵌入式项目里那些庞大makefile时心态会完全不一样。至少你不会再害怕它了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门