搞定Makefile:Linux开发必备的自动化构建与增量编译

发布时间:2026/10/9 8:38:02
搞定Makefile:Linux开发必备的自动化构建与增量编译 刚开始学Linux的时候想必大家都有过这样的经历一个C语言项目拆成了十几个源文件每次改其中一个文件就要把整个项目重新编译一遍。gcc那一行命令越写越长加一个文件就要回去改命令少一个依赖就报一堆undefined reference最后编译命令长到自己都不想看第二遍。我当时在嵌入式开发里第一次被这种状态折磨的时候整个人都是崩溃的。后来学会用make和Makefile才感觉自己的项目构建从手工作坊直接迈进了流水线。这篇博文我就把自己用make这些年踩过的坑、总结出来的套路从头到尾梳理一遍希望能帮同样被编译折磨的你一次把Makefile弄明白。make是一个自动化构建工具Makefile是它的规则文件。你只要把项目里文件的依赖关系和编译步骤写在Makefile里之后一条make命令就能自动完成编译、链接、清理等一系列操作。它适合所有在Linux环境下做C/C开发、嵌入式开发以及任何需要通过命令行编译项目的同学哪怕你是刚接触Linux的小白这篇文章也足够带你上手。1. 为什么每个Linux开发者都离不开make和Makefile1.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.chello: 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。最典型的就是cleanclean: rm -f *.o app乍一看没问题但如果项目目录里恰好有一个文件名叫clean比如你写了一个clean的文本文件放在那里make就会认为clean已经存在而且没有依赖什么都不执行。为了避免这种文件撞名问题需要用.PHONY声明.PHONY: clean clean: rm -f *.o app.PHONY: clean就是在告诉makeclean不是一个真实文件不管它存不存在只要执行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来指定头文件搜索路径多个路径就写多个-ICFLAGS -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_MODErelease就会开启优化并关闭调试断言不加参数默认走调试模式。用这种办法一个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 -nmake就会把每一步要执行的命令全部打印出来你自己过一遍就知道哪里有遗漏。不放心的话还可以加-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还是GNUmakefilemake会按顺序找这三个。如果文件名是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.hgcc会先搜索源文件所在目录再搜索-I指定的路径如果写的是#include utils.h不会先搜当前目录必须靠-I指定。项目里如果用了尖括号引自定义头文件还忘记加-I编译必挂。排查建议这么来先确认头文件在哪个目录find . -name utils.h再确认Makefile里有没有把这个目录加进CFLAGSgrep 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 什么时候该放弃手写Makefilecmake和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 mmake -j$(nproc)以后在项目目录里直接敲m就是全核并行编译速度比裸敲make快一大截。写Makefile本身也是需要迭代的先写一个能用的然后慢慢加变量、加规则、加伪目标不知不觉你就会发现以前那些让你抓狂的编译问题早就不是问题了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询