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默认构建app,make 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.o,main.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. 变量、自动变量与常用函数:写一个不重复自己的makefile
3.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_x11=no have_glfw=no 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 separator"
5.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.txt,make也认不出来。 - 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 -B(force 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 2>&1 | 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生成makefile,make再去执行具体的编译。为什么不直接用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时,心态会完全不一样。至少,你不会再害怕它了。