刚开始学Linux的时候,想必大家都有过这样的经历:一个C语言项目拆成了十几个源文件,每次改其中一个文件,就要把整个项目重新编译一遍。gcc那一行命令越写越长,加一个文件就要回去改命令,少一个依赖就报一堆undefined reference,最后编译命令长到自己都不想看第二遍。我当时在嵌入式开发里第一次被这种状态折磨的时候,整个人都是崩溃的。后来学会用make和Makefile,才感觉自己的项目构建从手工作坊直接迈进了流水线。这篇博文,我就把自己用make这些年踩过的坑、总结出来的套路,从头到尾梳理一遍,希望能帮同样被编译折磨的你一次把Makefile弄明白。
make是一个自动化构建工具,Makefile是它的规则文件。你只要把项目里文件的依赖关系和编译步骤写在Makefile里,之后一条make命令,就能自动完成编译、链接、清理等一系列操作。它适合所有在Linux环境下做C/C++开发、嵌入式开发,以及任何需要通过命令行编译项目的同学,哪怕你是刚接触Linux的小白,这篇文章也足够带你上手。
1. 为什么每个Linux开发者都离不开make和Makefile
1.1 手动编译的痛点:当项目从"hello.c"变成"一堆.c"
先回想一下最简单的单文件编译,gcc hello.c -o hello,回车,搞定,确实没什么好说的。但一个正常项目不可能只有一个源文件。假设你的项目里有main.c、utils.c、network.c、config.c、parser.c,还牵扯到几个自定义头文件,手动编译就会变成这样:
gcc -o app main.c utils.c network.c config.c parser.c -I./include -lm -lpthread如果其中某个文件改动了,你只能重新执行上面整条命令,把全部源文件重新编译一遍。一个几万行的项目,全量编译一次可能要几分钟甚至更久,而实际改动的往往就一个文件。这时候大部分时间都浪费在重复编译那些没变过的代码上。
你可能会想,那我写个shell脚本把编译命令存起来,每次跑一下不就行了?确实能解决"命令太长"的问题,但脚本根本不知道哪些文件需要重新编译,它只会无脑全部重编。而且一旦项目新增文件,你还要手动打开脚本改命令,遗漏一个,编译就会失败。这种"脚本化"方案只解决了记命令的问题,没解决依赖关系管理的问题。
1.2 make解决的核心问题:时间戳与增量编译
make的核心思路特别简单,就一句话:如果一个目标文件比它的依赖文件旧,就重新生成这个目标文件;否则就跳过。
这里说的目标文件,就是你想生成的东西,可以是可执行程序,也可以是.o中间文件。依赖文件,就是生成目标所需要的源文件或头文件。make通过比较文件的时间戳来判断是否要执行编译命令。比如app依赖main.o和utils.o,如果main.o比app新,说明main.o刚被编译过,app可能没包含最新的代码,所以make会重新链接生成app。
这样带来的直接好处就是增量编译。你改了某个.c文件,只有依赖它的那些目标会被重新编译,其他没动过的文件直接跳过。以我刚才举的例子来说,改一个utils.c,只有utils.o和app需要重新生成,main.c、network.c这些完全不用重编。对于大项目来说,这个效率提升是肉眼可见的。
我觉得把make和Makefile的关系比作"菜谱"和"厨师"特别合适。Makefile就是菜谱,写清楚每一步怎么做、需要什么材料;make就是照菜谱做菜的那个厨师,你只要喊一声"make",它就会严格按照菜谱来执行,而且还会自己判断哪道菜需要回锅热一下,哪道菜可以原样端上来。
2. Makefile语法核心:目标、依赖与命令
2.1 最简单、能跑起来的Makefile长什么样
Makefile的基本规则格式是:
目标(target): 依赖(prerequisites) 命令(recipe)注意,命令前面必须是一个Tab键,不能是空格。这个坑我当年踩了不止一次,看着缩进没问题,一执行就报"missing separator",排查半天发现是编辑器默认把Tab换成了空格。现在很多编辑器都会自动转换缩进,写Makefile的时候务必确认好。
先看一个最简单但完整的例子。假设只有main.c:
hello: main.c gcc -o hello main.c保存为Makefile,然后在终端执行make,它就会检查hello和main.c的时间戳。如果hello不存在,或者main.c比hello新,就执行下面那行gcc命令。再跑一次make,因为main.c没变化,hello已经是最新的,make会告诉你"make: 'hello' is up to date."。
多个文件的项目就需要分步编译了,最常见也最标准的写法是这样:
app: main.o utils.o network.o gcc -o app main.o utils.o network.o main.o: main.c utils.h network.h gcc -c main.c utils.o: utils.c utils.h gcc -c utils.c network.o: network.c network.h gcc -c network.c这里每一行目录和依赖的关系非常清晰。main.o依赖main.c和两个头文件,只要这两个头文件有任何一个被修改了,main.o就会被重新编译,这就是Makefile管理依赖关系的精髓。需要说明的是,gcc编译源文件时,可以通过头文件的#include自动建立依赖,但make本身不会自动知道"main.c里include了utils.h",所以你需要用gcc -MM main.c这类命令来生成依赖信息,或者用后面会讲到的自动依赖生成方法。初学阶段,先把头文件手动写进依赖列表,是完全正确的入门姿势。
2.2 自动变量与模式规则:为什么大项目没人手写全部规则
如果每个.o文件都要手写一条规则,就算只有十几个源文件,Makefile也会变得非常啰嗦。为了解决这个问题,make提供了一批自动变量,写规则的时候可以直接引用:
| 自动变量 | 含义 |
|---|---|
$@ | 当前目标文件名 |
$^ | 所有依赖文件列表,去重后的完整集合 |
$< | 第一个依赖文件 |
$* | 目标文件名去掉后缀后的部分 |
有了自动变量,上面的规则可以简化成:
app: main.o utils.o network.o gcc -o $@ $^ main.o: main.c utils.h network.h gcc -c $< utils.o: utils.c utils.h gcc -c $< network.o: network.c network.h gcc -c $<这样每个.o的规则就只有一行了,但还要重复写好几遍,还是不够优雅。更好用的是通配符和模式规则。看这个写法:
%.o: %.c gcc -c $<这个规则的意思是:任何一个.o文件,都会尝试找同名的.c文件来编译。以main.o为例,make会找main.c,找到了就用gcc -c main.c生成main.o。只要你的项目每个.c文件都编译成同名.o,这一条规则就能覆盖所有源文件,再也不用一个个写规则了。
2.3 伪目标与常见动作:clean、install、all到底是什么
除了真实的文件目标,Makefile里还有一种特殊目标,叫伪目标(phony target)。最典型的就是clean:
clean: rm -f *.o app乍一看没问题,但如果项目目录里恰好有一个文件名叫clean,比如你写了一个clean的文本文件放在那里,make就会认为clean已经存在,而且没有依赖,什么都不执行。为了避免这种"文件撞名"问题,需要用.PHONY声明:
.PHONY: clean clean: rm -f *.o app.PHONY: clean就是在告诉make:clean不是一个真实文件,不管它存不存在,只要执行make clean就给我跑后面的命令。
常见的伪目标约定有:
all:默认构建目标,通常放在Makefile第一个,执行make时如果不带参数,就会构建它clean:清理所有编译生成的中间文件和可执行文件install:把编译好的程序安装到系统目录distclean:比clean更彻底,连配置文件一起清理
我自己的习惯是,每个Makefile都先写一个all作为默认目标,让make不带参数时能做完整构建。最近一个项目里,我的Makefile开头大概是这样的:
.PHONY: all clean install all: app app: main.o utils.o gcc -o $@ $^ %.o: %.c gcc -c $< clean: rm -f *.o app3. 进阶用法与实际项目中的Makefile设计
3.1 变量系统:自定义变量、预定义变量、运行时覆盖
Makefile里支持变量,用法和shell脚本有点像,但语法不太一样。定义一个变量,引用变量,都是在项目里非常高频的操作:
CC = gcc CFLAGS = -Wall -g -O2 LDFLAGS = -lm -lpthread app: main.o utils.o $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)用变量最大的好处是,以后想换编译器、想改编译选项,只需要改一处变量定义,所有编译规则自动生效。尤其是在嵌入式开发中,交叉编译器的名字往往特别长,比如arm-linux-gnueabihf-gcc,把它定义为CC变量,整个Makefile看起来会清爽很多。
make的变量赋值方式有几种,区别还挺微妙:
=:递归展开赋值,变量在引用时才展开,容易造成循环引用:=:立即展开赋值,var := $(other) 时other的值立即固定?=:如果变量没被定义过,才赋值,适合给默认值+=:追加赋值,通常用于给CFLAGS这类变量追加参数
建议项目中优先用:=,因为它的行为更可预期,排查问题的时候少掉头发。
make内建了很多默认变量,比如CC默认是cc(很多系统上是指向gcc的软链接),CFLAGS默认是空。也就是说,你甚至可以不定义CC,直接写编译规则,make也能用系统默认的cc去编译。但为了可移植性和明确性,我还是建议把CC、AR、CFLAGS这些关键变量显式写出来。
变量的一个经典用法是让用户从命令行覆盖:
make CFLAGS="-O0 -g"这样,命令行里传入的CFLAGS会覆盖Makefile里定义的CFLAGS值。我调试的时候经常用这一招,不用改文件就能切换优化级别,非常方便。
3.2 多目录工程与嵌套Makefile:大型项目怎么组织
项目一旦大起来,把所有源文件堆在一个目录里就不太合理了。常见做法是每个子模块一个目录,比如src、lib、test,每个目录里放一个自己的Makefile,然后在顶层用一个Makefile统一调用。这种设计叫作递归make。
顶层Makefile的核心是-M或者--directory这个参数:
.PHONY: all all: $(MAKE) -C src $(MAKE) -C lib用$(MAKE)而不是直接写make,是因为递归调用时应该继承父make进程的命令行参数和环境变量。-C src让make先进入src目录找Makefile执行,执行完再回到当前目录继续。这种方式的好处是模块边界清晰,每个子目录只需要关心自己的编译规则。
子目录的Makefile通常还要负责把编译产物放到统一位置,或者通过顶层变量传递公共配置。比如在顶层定义:
export CC = arm-linux-gnueabihf-gcc export CFLAGS = -Wall -O2 -I$(ROOT_DIR)/include然后子目录的Makefile里直接用CC和CFLAGS,不需要重复定义。export关键字会把变量传递给子make进程。要注意的是,递归make有一个被业界讨论很多的问题是依赖关系跨目录不透明,顶层make只知道"我调用过子目录的make",但不知道子目录里具体哪个文件变了。架构简单时这完全够用,如果项目复杂度上来了,可以考虑用CMake替你做这些事,后面我会专门说cmake和make的边界。
3.3 头文件路径问题:嵌入式项目里最常见的坑
日常写代码的时候,头文件分散在项目不同目录是常态。比如某个嵌入式SDK(我之前在RV1106平台上就遇到过),它的头文件分布在include/、sdk/include/、sdk/driver/include/好几个地方,编译时必须让编译器知道去哪里找头文件。
gcc用-I来指定头文件搜索路径,多个路径就写多个-I:
CFLAGS = -I./include -I./sdk/include -I./sdk/driver/include -Wall这个思路在Makefile里就是往CFLAGS变量里追加路径。但要注意,如果头文件路径是相对路径,它相对于的是gcc命令执行时所在的目录,也就是你执行make所在的目录,不是Makefile文件所在的目录。在递归make或者使用$(MAKE) -C进入子目录时,路径就要写对,否则会报找不到头文件的错。
嵌入式交叉编译里还有一个更隐蔽的坑:sysroot。交叉编译时,你的编译器和链接器默认搜索的系统头文件、库文件,跟当前Linux发行版的是不一样的。如果项目需要引用交叉工具链自带的系统库接口,只加-I还不够,可能还需要通过--sysroot指定交叉工具链的根目录。手动写复杂Makefile时很容易忽略这一点,一旦编译时出现找不到stdio.h、找不到libc库这类问题,先检查I和L的路径是否真的指向了目标平台的sysroot。
3.4 条件判断与函数:让Makefile拥有"逻辑"
Makefile并不只是平铺直叙的规则列表,它还支持类似编程语言的条件判断和函数调用。
条件判断最常见的场景是调试版和发布版切换:
ifeq ($(BUILD_MODE), release) CFLAGS := $(CFLAGS) -O2 -DNDEBUG else CFLAGS := $(CFLAGS) -g -O0 endif这样执行命令时,如果用make BUILD_MODE=release,就会开启优化并关闭调试断言;不加参数默认走调试模式。用这种办法,一个Makefile可以轻松适配多种构建需求。
make自带的函数里,wildcard和patsubst是我最常用的两个。wildcard用来收集指定规则的文件列表:
SRCS := $(wildcard src/*.c)这会把src目录下所有.c文件路径收集起来,得到一个以空格分隔的字符串列表。patsubst用来做模式替换:
OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))意思就是把src/main.c替换成build/main.o。两个函数一配合,就能实现"自动收集源文件、自动生成目标文件列表",以后往目录里新增一个.c文件,Makefile完全不用改。配合-B或者-n参数,这种动态源文件列表的方式在大项目里非常实用。
还有foreach函数,处理子目录批量操作时很顺手。比如你想对多个子目录里的源文件做同样处理,可以把目录列表和一个逻辑模板套进foreach里,比手写一堆重复规则省太多事。
4. 常用命令与调试技巧:make的参数你真的会用吗
4.1 高频参数解析:-j、-n、-B、-C、-f
很多同学使用make就是裸敲,最多加个clean。实际上make的命令行参数设计得相当精细,熟练掌握几个高频参数,效率能再上一个台阶。
| 参数 | 作用 |
|---|---|
-j N | 并行执行N个任务,-j$(nproc)表示用满所有CPU核心 |
-n | 只打印要执行的命令,不真正执行 |
-B | 强制认为所有目标都过期,全量重新构建 |
-C dir | 进入目录再执行make |
-f file | 指定Makefile文件,默认找GNUmakefile、makefile、Makefile |
先说-j,这是我最想强调的一个参数。多核机器上,make -j8理论上比单线程快好几倍。为什么能并行呢?因为不同目标的编译是相互独立的,make可以同时生成main.o和utils.o,等它们都完成后再执行链接。这里注意,并行时如果两个目标同时要生成同一个文件,就会出现竞争冲突,所以Makefile里的依赖关系必须写准确。我实际使用中见过有人乱加-j导致编译随机失败的,排查一圈发现是两个规则同时生成build目录下的同名.o文件。解决办法就是别在多个规则里共享输出文件,让每个目标的输出都唯一。
-n配合调试特别香。你想确认规则的依赖关系对不对,又不想真的编译,直接make -n,make就会把每一步要执行的命令全部打印出来,你自己过一遍就知道哪里有遗漏。不放心的话还可以加-B强制重建,配合-n来看全量执行的完整命令序列。
-C就是进入目录执行,用于递归make,前面已经说过。而-f是你把规则文件起了别的名字时用的,比如make -f build.mk。顺带一提,make默认查找文件名的顺序是GNUmakefile、makefile、Makefile。Linux程序员习惯用Makefile,因为字母排序会排前面,ls时一眼能看到。
4.2 调试Makefile的技巧:打印变量、检查内建规则
写Makefile的人一定都有过这种时刻:我明明定义了变量,为什么执行结果不对?我明明写了规则,为什么没有生效?这时候就需要给Makefile做"debug"。
最简单的调试手段是用$(info)在解析阶段打印变量:
$(info CFLAGS is $(CFLAGS))make在读取Makefile时就会把这行输出到屏幕,你可以很直观地看到变量最终的值。比info更强力的是$(warning)和$(error),warning会打印警告但继续,error会直接终止make并报错,这两个函数在排查"变量赋值不符合预期"时特别好用。
还有一个隐藏神器是make -p,它会把make的所有内建变量、内建规则、当前Makefile定义的变量全部打印出来。输出内容非常长,我一般会配合grep过滤,比如:
make -p | grep '^CC'能看到CC变量的默认值。相同思路可以查CFLAGS、LDFLAGS等内建变量的默认值,分析"为什么没写这些变量但编译还能过"这类问题。
如果make执行时出现了莫名其妙的跳过,别忘了先检查时间戳。make的增量编译完全建立在文件时间戳的基础上,如果你用touch命令改了一个文件的访问时间,也许会影响make的判断(正常情况下它比较的是修改时间mtime)。一个项目如果出现"改了代码但没重新编译",百分之九十是时间戳没更新,或者编译生成的文件时间比源文件还新。遇到这种玄学问题,直接find . -name "*.o" -exec ls -l {} \;看看.o文件的修改时间,基本就能定位了。
5. 常见问题排查与实战心得:那些年我们踩过的坑
5.1 "make: *** No targets specified and no makefile found. Stop."怎么解决
这是几乎所有新手都会遇到的第一条make报错,也包括我自己当年在内。这条错误的字面意思很直白:make既没有在命令行指定目标,也没有在当前目录找到makefile文件。
排查思路按顺序来:
- 当前目录确实有Makefile吗,ls看一下。如果没有,你需要先创建一个,或者找到项目里真正的构建文件在哪里
- 文件名是Makefile还是makefile还是GNUmakefile,make会按顺序找这三个。如果文件名是build.mk这种自定义名字,必须用
make -f build.mk指定 - 当前工作目录对不对,有没有
make -C进入子目录的漏网之鱼
有一个细节需要注意:如果你的Makefile里定义的第一个目标是clean,那么不带参数直接执行make时,它会把clean当成默认目标跑一遍,直接把你项目里的编译产物删了。这属于"没有指明目标但找到了makefile"的坑,报错不一定有,后果却很严重。所以我的建议是,默认目标一定放最前面,或者用all显式声明。
5.2 Windows下VS Code报"make : 无法将'make'项识别为 cmdlet、函数"怎么办
这条报错在Windows环境里特别常见,尤其是用VS Code写代码、装了Remote或本地终端之后。它的核心原因是:make不是Windows自带的命令,系统根本没装过这个程序,PowerShell当然找不到它。gcc同理,Windows默认也没有。
解决办法有几种:
- 在Windows上安装MSYS2或MinGW-w64,安装完把bin目录加入PATH,就能获得Windows版的make和gcc
- 用WSL,在Linux子系统中装gcc和make,开发和编译都在Linux环境里做,这也是我比较推荐的方式,毕竟你要学的是Linux开发工具,纯Windows环境的意义不大
- 在Windows上用Chocolatey或winget安装make,装好后同样需要把路径加到PATH
有件事要提醒一下:很多同学装了MinGW之后,命令里可能叫mingw32-make,而不是make,这是MinGW项目的特殊命名,用法和make一样,只是名字不同。把它复制一份重命名为make.exe,或者直接用mingw32-make命令,都能正常工作。VS Code里如果还报错,重启终端,确保PATH环境变量重新加载。
5.3 Makefile里找不到头文件:路径和-I参数怎么处理
报错形式一般是:
fatal error: xxx.h: No such file or directory原因很简单:gcc在当前默认路径和之前设过的-I路径里都没有找到这个头文件。注意,gcc在编译源文件时,如果源文件里写的是#include "utils.h",gcc会先搜索源文件所在目录,再搜索-I指定的路径;如果写的是#include <utils.h>,不会先搜当前目录,必须靠-I指定。项目里如果用了尖括号引自定义头文件,还忘记加-I,编译必挂。
排查建议这么来:
- 先确认头文件在哪个目录:
find . -name "utils.h" - 再确认Makefile里有没有把这个目录加进CFLAGS:
grep CFLAGS Makefile - 如果没有,加上
-I./路径,重新编译
嵌入式项目里更复杂,头文件可能不在项目目录而在工具链的sysroot下。你加了-I还是找不到,就要去工具链目录下搜一下这个头文件,确认路径写全。比如RV1106 SDK里,有些头文件放在sdk/include/,有些放在sysroot/usr/include/,编译器默认能搜到sysroot下的标准头文件,但SDK私有的头文件必须显式加-I。我自己习惯在最开始就建一个变量:
INC_DIRS := -I$(SDK_ROOT)/include -I$(SDK_ROOT)/sysroot/usr/include CFLAGS += $(INC_DIRS)这样后面想加路径只需要改INC_DIRS这一处。
5.4 GitHub项目编译时卡在权限问题:要不要sudo make
很多开源项目在GitHub上的README都会写make,然后让你make install。如果在普通用户下执行make install,经常会碰到Permission denied,因为默认安装路径是/usr/local,普通用户没有写权限。于是很多人第一反应是加sudo。
这里有坑。sudo make install本身通常没什么问题,但有些人会整个流程都用sudo,比如sudo make。这样编译生成的文件属主会变成root,以后你想在项目目录里手动清理、修改构建配置、重新编译,都会遇到文件权限不匹配的问题。而且sudo环境会改变HOME环境变量,有些构建脚本依赖HOME路径,sudo后可能找不到配置或缓存,出现莫名其妙的报错。
我的建议是:make绝对不要加sudo,普通用户编译没问题。make install如果确实要安装到系统目录,才用sudo。如果不想动系统目录,更优雅的解决方法是改安装前缀:
./configure --prefix=$HOME/.local make make install这样程序就装到用户目录下,不需要root权限。如果项目没有configure脚本,可以直接改Makefile里prefix变量。以后写Makefile时,也建议把安装目录设计成变量,比如:
PREFIX ?= /usr/local install: install -d $(DESTDIR)$(PREFIX)/bin install -m 755 app $(DESTDIR)$(PREFIX)/bin用DESTDIR和PREFIX两个变量控制安装路径,是开源项目最常见的做法,打包和用户自定义安装都方便。
5.5 什么时候该放弃手写Makefile:cmake和make区别在哪
这个话题在各大技术社区都快被问烂了,但我还是想从实际工程的角度说下我的理解。make本身是构建执行器,它负责"按规则执行命令、管理增量构建";cmake是构建系统生成器,它负责分析项目结构、检查依赖、生成Makefile或者其他平台的构建文件。
用一句话总结:cmake是"生成Makefile的Makefile",但它的能力远不止这些。它还能生成Ninja、Visual Studio、Xcode等不同平台的工程文件,提供跨平台的依赖查找和编译选项检测。你写的CMakeLists.txt描述"项目有什么目标、每个目标由哪些源文件组成、需要链接哪些库",然后cmake帮你把这些信息转换成make能理解的规则。
什么时候继续用Makefile:
- 项目规模不大,源文件几十个以内
- 个人工具、实验室代码,不需要给别人跨平台构建
- 嵌入式裸机或简单SDK开发,构建逻辑很直接
什么时候换cmake:
- 项目需要跨平台构建(Windows、macOS、Linux都要支持)
- 依赖第三方库,需要自动查找库路径和头文件路径
- 项目模块多,需要更复杂的配置逻辑
- 想让构建流程更规范,方便团队协作
我也不是说Makefile会被cmake完全替代。make至今仍然是Linux内核、无数底层工具链的构建基础设施。很多开源项目的构建流程,底层跑的依然是make。所以这两个不是"谁淘汰谁"的关系,而是不同层面的工具。我刚工作那会儿也迷信过cmake,后来项目复杂度降下来,反而又回到了Makefile,因为它简单透明,一个文件看完所有构建逻辑,不用引入cmake那套缓存和生成文件。
结尾:一点个人的体会
把这篇文章里所有内容浓缩成一句话:Makefile的本质是梳理依赖关系,而不是背语法。我见过很多同学把Makefile的语法背得滚瓜烂熟,但一到写项目还是不知道从哪下手。反过来说,只要能把自己的项目里"哪个文件依赖哪个文件、需要什么命令生成"想清楚,写出来的Makefile八九不离十都好用。
最后再分享一个小技巧。我现在几乎每台开发机上都会在.bashrc或.zshrc里加一个别名:
alias m='make -j$(nproc)'以后在项目目录里直接敲m,就是全核并行编译,速度比裸敲make快一大截。写Makefile本身也是需要迭代的,先写一个能用的,然后慢慢加变量、加规则、加伪目标,不知不觉你就会发现,以前那些让你抓狂的编译问题,早就不是问题了。