☰
从依赖到构建:全面搞定Makefile核心语法与工程实战
2026/10/7 5:22:56 网站建设 项目流程

写Makefile这事,说难不难,说简单也容易翻车。我见过不少人,C/C++代码写得飞起,一到构建环节就抓瞎,要么在IDE里点鼠标点得心累,要么在服务器上对着 “make: *** No targets specified and no makefile found” 发呆。今天这篇就想把Makefile这件事从头到尾捋一遍,从为什么还在用、怎么上手、怎么排错,到它和CMake到底是什么关系,一次说清楚。

这里聊的Makefile,不是教科书里那种只用来编译一两个文件的玩具,而是能真正拿到真实工程里用的写法。不管你是刚接触命令行编译的菜鸟,还是已经在嵌入式Linux(比如RV1106这类板子)上被头文件路径折腾过的开发者,这篇文章应该都能给你一些能直接落地的参考。

1. 为什么到今天还在用Makefile

很多人一上来就问:现在都有CMake了,还有必要学Makefile吗?这个问题我在不同场合被问过无数次,结论很明确——非常有必要,而且要学就学扎实。

1.1 构建系统本质上在干什么

先把Makefile放到更大的图景里看。一次完整的程序构建,至少包含预处理、编译、汇编、链接这几步。你写了几十个甚至上百个源文件,如果每次改一个文件都要全量重新编译,时间成本完全没法接受。Makefile干的事情,本质上就是帮你判断哪些文件需要重新编译,并按照依赖关系按顺序执行编译命令。

这个“判断哪些文件变了”的机制,靠的是文件时间戳。make会比较目标文件和依赖文件的修改时间,如果依赖比目标新,就重新生成目标。所以Makefile的核心不是语法多花哨,而是依赖关系描述得准不准。

另外,构建过程还涉及编译选项、头文件搜索路径、宏定义、静态库和动态库链接参数。这些东西散落在命令行的历史记录里,换个环境就失效。写进Makefile,整个构建流程就变成可重复、可分享、可版本管理的东西了。

1.2 和直接写脚本的区别

有人会说:我写一个shell脚本,里面按顺序gcc每一个文件,不一样吗?不一样,差别还挺大。

shell脚本是无脑地从头跑到尾,就算你只改了一个文件,它也会把全部源文件重新编译一遍。而make只编译发生变化的文件,这是本质区别。在一个大工程里,全量编译可能十分钟,增量编译只要几秒,这个体验差距是决定性的。

再一个差别是错误处理。Makefile里,某一条命令失败了,make会立刻停下来报错,不会像shell脚本那样继续往后跑,避免了“前面编译已经挂了,后面还在傻乎乎地链接”这种浪费时间的操作。

还有依赖管理。Makefile可以精确描述头文件和源文件之间的依赖关系,某头文件一改,所有包含它的源文件都会重新编译。这一点,脚本很难做到这么细。

1.3 哪些场景非它不可

现在的构建工具体系里,CMake、Ninja、Bazel满天飞,但Makefile依然活跃在大量场景里。

  • 嵌入式Linux开发:瑞芯微、全志这类芯片的SDK里,大量工程还是用Makefile组织。比如RV1106的官方SDK,甚至很多驱动模块的编译方式就是make命令,配合内核的Kbuild系统。
  • 开源软件的经典构建方式:configure脚本生成Makefile,然后make、make install三步走,这是Linux下持续几十年仍然顺手的标准流程。
  • 小中型C/C++项目:如果一个项目就十几个文件,引入CMake反而显得笨重,手写一个两百行的Makefile,维护成本更低,也更直接。
  • 构建Algo、插件、固件等特定产物:很多底层组件需要在特定目录、特定工具链下构建,Makefile那套清晰的目标规则比大而全的构建系统更贴合需求。

所以,Makefile不是被淘汰的老古董,而是还在大量发挥作用的工程基础技能。搞懂了它,再去看CMake生成的复杂Makefile,甚至去看内核Kbuild的Makefile,你都只会觉得越来越通,而不是越来越懵。

2. 先把Makefile这几行语法吃透

Makefile语法不多,但每一条都值得琢磨清楚。接下来把最高频的几块过一遍,这部分基本是“菜鸟教程”必须覆盖的内容,但我尽量把背后的原理和容易踩的坑一起说。

2.1 规则三要素:目标、依赖和命令

Makefile里最基础的单元是规则,格式长这样:

目标: 依赖... 命令

这里有个坑必须提前说:命令前面必须是Tab键,不能是空格。别笑,这个坑十个人里至少有六个人踩过,而且报错信息非常诡异,叫 “missing separator”。如果你复制网页上的Makefile,缩进经常被复制成空格,然后make就给你来这么一句。

目标一般是一个文件名,也可以理解为“这条规则要生成什么”。依赖是生成目标所需要的文件或其他目标。命令则是真正干活的那条shell命令。

一个最小例子:

hello: hello.c gcc -o hello hello.c

它的意思是:要生成hello这个文件,前提是hello.c存在且比hello新。如果hello已经存在且hello.c没改过,make会告诉你“hello是最新的”,一条命令都不执行。理解了这个时间戳判断逻辑,后面大部分“为什么没重新编译”的问题都能自行解决。

2.2 变量与自动变量,让你少写一半废话

如果写规则时把文件名写死,那跟shell脚本也没啥区别。Makefile真正顺手的兵器是变量和自动变量。

变量的定义和赋值方式有几种,容易让人晕:

赋值符含义典型场景
=递归展开,使用时才展开适合引用后面才定义的变量
:=立即展开,定义时立刻取值适合需要当前值确定的场景
?=如果未定义则赋值设置默认值
+=追加累加源文件、依赖库

初学者一个常见的错误是把=和:=混用,结果变量互相引用导致死循环。例如:

A = $(B) B = $(A)

如果用=,make会提示递归变量引用;而用:=则会先取当下的值,取到的很可能是空。

自动变量是那种“不用自己起名字”的魔法变量:

  • $@:代表当前规则的目标
  • $^:代表全部依赖
  • $<:代表第一个依赖

举个例子:

main.o: main.c utils.h gcc -c -o $@ $<

这里$@就是 main.o,$<就是 main.c。写规则瞬间就少了,而且就算依赖顺序调整,也不用每个命令都改一遍。

2.3 伪目标、模式规则和隐含规则

先说伪目标。如果一个目标名和真实文件名重名,make就会认为“目标已经存在了”,不会执行规则。最典型的就是clean。很多项目都有一个clean规则,用来删除编译产物,但你不可能在目录里放一个叫clean的文件吧?万一有,这条规则就失效了。解决办法是用.PHONY把它声明为伪目标:

.PHONY: clean clean: rm -f *.o hello

声明了伪目标后,make不检查有没有同名文件,每次都会执行里面的命令。除了clean,install、test、all这些一般也都声明为伪目标。

模式规则是Makefile的“通配符”,用%匹配任意非空字符串。比如:

%.o: %.c $(CC) -c $(CFLAGS) $< -o $@

这条规则表示“任意.c文件都可以用该规则编译成对应的.o文件”,非常省事。有了它,每个源文件不用逐条写规则。

隐含规则则更激进:如果你完全不写生成.o的规则,make也会尝试用内置规则,默认用$(CC) -c加上一堆默认参数去编译。但默认参数很简陋,没有你的-I头文件路径,也没有优化选项。所以我的习惯是:不依赖隐含规则,显式写清楚编译和链接命令,虽然多几行,但可控性高很多,别人接手也容易看懂。

3. 实操:手写一个多目录C工程Makefile

纸上谈兵没用,下面用一个具体的多目录C工程来手写。这个工程的结构不复杂,但对准了很多实际痛点:源文件分散、头文件引用、自动收集文件、增量编译、清理产物。

3.1 工程结构设计与思路

假设项目长这样:

project/ ├── include/ │ └── utils.h ├── src/ │ ├── main.c │ ├── utils.c │ └── net/ │ └── socket_helper.c └── Makefile

头文件在include目录,源文件散落在src和src/net,后面还想把编译产物统一放到build目录,不污染源码树。这种结构在真实项目里非常常见。

在设计Makefile之前想清楚几点:

  • 用wildcard函数自动收集源文件列表,不用手动一个个加文件名
  • 用patsubst把.c文件路径转换成.o文件路径
  • 头文件路径用-I参数传给编译器
  • 编译时生成.d依赖文件,自动跟踪头文件变化
  • 产物目录统一管理,方便清理

3.2 分步写一个可直接用的Makefile

下面这个Makefile是我在类似小中型项目里常用模板的简化版本:

CC = gcc CFLAGS = -Wall -Wextra -O2 -g CPPFLAGS = -Iinclude -Iinclude/net LDFLAGS = BUILD_DIR = build SRC_DIR = src SRCS = $(shell find $(SRC_DIR) -name "*.c") OBJS = $(patsubst %.c,$(BUILD_DIR)/%.o,$(SRCS)) DEPS = $(OBJS:.o=.d) TARGET = bin/app .PHONY: all clean all: $(TARGET) $(TARGET): $(OBJS) @mkdir -p $(dir $@) $(CC) $(LDFLAGS) -o $@ $^ $(BUILD_DIR)/%.o: %.c @mkdir -p $(dir $@) $(CC) $(CPPFLAGS) $(CFLAGS) -MMD -MP -c $< -o $@ -include $(DEPS) clean: rm -rf $(BUILD_DIR) $(TARGET)

拆开看几个关键点:

  • wildcard在变量里不好嵌套复杂写法,所以这里直接用$(shell find ...)递归搜源文件。如果知道文件都在同一层,用$(wildcard src/*.c)就够。
  • patsubst把src/main.c转成build/src/main.o,注意这个转换结果里依然保留相对路径,目录结构不会扁平成一层。
  • 每条编译规则里的mkdir -p $(dir $@)确保目标目录存在。$(dir $@)提取的是目标文件的目录部分,比如build/src/net/。
  • -MMD -MP让编译器在编译时生成.d文件,里面记录了源文件对应的头文件依赖,然后用-include $(DEPS)把这些依赖文件引进来。这样改任何头文件,Makefile都能感知到对应的.o需要重新编译。这是现代C工程里比较标准的做法,靠它才能解决“改了头文件但没重新编译”的经典问题。

编译命令里的@mkdir -p前面的@,作用是make执行命令前默认会打印这条命令,加上@后只执行不打印,输出干净很多。

3.3 头文件路径的正确打开方式

头文件找不到是特别高频的报错,尤其是把工程从IDE搬到命令行构建时,经常遇到fatal error: xxx.h: No such file or directory。

Makefile里跟头文件搜索相关的变量是CPPFLAGS。注意,不是CFLAGS。CFLAGS 是给C编译器的选项,CXXFLAGS给C++,CPPFLAGS是C预处理器的选项,-I就放这里。虽然很多人习惯把-I塞进CFLAGS里也能跑,但规范一点,CPPFLAGS更准确,尤其是在同时编译C和C++混编项目时,可以一次指定两处生效。

-I参数后面跟的是搜索路径。比如-Iinclude,意思是编译器在遇到#include "utils.h"时,会先搜索当前源文件所在目录,再去include目录找。如果写成-Iinclude/net,那#include "socket_helper.h"就能直接命中。

还有一个容易出问题的点:搜索路径的顺序。如果有多个同名头文件分布在不同目录,-I会按照从左到右的顺序查找。最常见的坑是:系统的/usr/include里有一个旧版本的头文件,你把自己项目的目录放在后面,结果编译器找到了旧的,引入了莫名其妙的类型定义差异。所以自己的项目目录一定要放在靠前的位置。

再补充一点:#include "..."和#include <...>的搜索规则其实不同。前者先从当前文件所在目录找,再找-I路径;后者跳过当前目录,直接从-I路径和系统目录找。因此在代码里,项目内头文件用双引号,第三方库和系统头文件用尖括号,这个习惯能让Makefile里的路径设置更可预期。

3.4 交叉编译:RV1106这种嵌入式板子怎么改

如果你的目标平台是嵌入式Linux,比如瑞芯微RV1106这样的小型SoC板子,Makefile的调整思路其实特别简单:换编译器,加工具链路径,必要时调整链接参数。

我接触到RV1106这类平台的SDK时,工具链通常是arm-rockchip830-linux-uclibcgnueabihf-gcc这种前缀很长、但内核架构还是armhf的交叉编译器。拿到SDK之后,往往需要先做一件事:设置工具链的PATH。

CROSS_COMPILE = arm-rockchip830-linux-uclibcgnueabihf- CC = $(CROSS_COMPILE)gcc AR = $(CROSS_COMPILE)ar

CROSS_COMPILE是内核和U-Boot构建系统里用得最多的一个变量,很多平台的Makefile都在用这种套路。定义好CC之后,后面所有编译规则都不用改,它们统一引用$(CC)。

嵌入式平台经常还涉及静态链接、去掉调试符号、指定链接脚本等需求。比如:

LDFLAGS += -static CFLAGS += -mcpu=cortex-a7 -mfloat-abi=hard -mfpu=neon

不同芯片的cortex内核和浮点ABI参数可能不同,具体以芯片手册和SDK默认配置为准,这里只是演示思路。

另一个让我多次吃瘪的地方是:交叉编译时系统库路径和头文件路径都和本机不同。本机的/usr/include很可能不存在于交叉工具的sysroot里,所以CPPFLAGS里的-I必须指向SDK的include目录。如果程序依赖第三方库,库文件路径用-L指定,链接库名用-lxxx。很多工程在开发机上能编译,一交叉编译就报找不到crt1.o,基本都是sysroot工具链路径没配对。

4. 那些让你挠头的报错,一次说清

Makefile的语法报错和编译报错是两码事。这一节把最常见、最让人纠结的几个问题直接拿出来说透。

4.1 make没有指明目标并且找不到makefile

这是新手最容易撞上的报错,完整信息大概是:

make: *** No targets specified and no makefile found. Stop.

这句话字面意思很清楚:当前目录下没有Makefile,make不知道该干嘛。原因无非这几个:

  • 文件名不匹配:make默认找Makefile、makefile或GNUmakefile。如果你建的文件叫makefile.txt或Makefile.v1,make当然找不到。
  • 当前目录不对:在项目的子目录里执行了make,而Makefile在上级目录。
  • Makefile内容为空:文件存在,但没有写任何规则,同样会报这个错。

排查方法:先确认文件存在、名字对不对,再确认当前路径。如果你要指定别的文件名,可以用make -f 文件名。

更让人迷惑的一个变种是,报错里同时出现“规则makefile没有目标”之类的话,这往往是因为你在Makefile里用include foo.mk引入了一个不存在的文件,导致make在解析阶段就出问题。这种情况建议检查include行,必要时使用-include弱化对文件的强依赖。

4.2 头文件找不到和重复定义

头文件相关最常见的是fatal error: xxx.h: No such file or directory,前面已经说过用CPPFLAGS加-I。这里再补充一个排查顺序:

  1. 确认头文件确实存在于某个路径,没被遗漏在Git里。
  2. 确认Makefile里指定的搜索路径覆盖了该目录。
  3. 在命令行手动跑一次编译器,把Makefile里的变量展开后手动执行,看能不能编译,以此排除是Makefile拼接错误还是头文件内容本身有问题。
  4. 检查头文件里是否写了#ifndef或#pragma once防止重复包含。重复定义报错经常不是头文件路径的问题,而是宏保护缺失。

还有一个很隐蔽的坑:.d依赖文件过期了。编译选项改了、头文件路径改了,但之前生成的.d文件还引用旧路径,make认为依赖关系成立,不重编。这种情况最直接的解决办法就是make clean之后重新构建。

4.3 目标文件不会自动更新

“我明明改了源文件,为什么make说它是最新的?”这个问题我见过好多次,原因往往不是特别玄乎,但挺隐蔽。

  • 系统时间不一致。比如文件在NFS共享目录里,目标文件和源文件的时间戳因为时区、时钟漂移导致比较结果异常。简单验证办法是touch一下源文件,再执行make。
  • 修改时间比目标文件更早。这种情况常见于“从Git仓库checkout”代码,时间戳被保留为旧时间。解决办法也是touch源文件。
  • 依赖关系没声明。比如只写了main.o: main.c,没写main.o: utils.h。修改utils.h后,make根本不知道main.o需要重新生成。这就是为什么要用-MMD -MP自动生成依赖文件。

4.4 快速问题速查表

现象常见原因处理办法
missing separator命令前用了空格,不是Tab编辑器里显示控制字符,改成Tab
No targets specified目录下没有Makefile或文件名不对确认文件名和当前路径
make: Nothing to be done目标已存在且比依赖新touch依赖文件,或make clean后重来
头文件找不到缺少 -I 路径或路径写得不对检查CPPFLAGS
undefined reference链接时缺少库或库顺序不对补 -L 和 -l,注意顺序
改了头文件不重编依赖文件缺失或过期开启 -MMD -MP,或清理重编
变量循环引用用=互相引用改成:=,重构依赖

链接时库的顺序这个细节值得单独提醒一句:对静态库来说,被依赖的库要放在依赖者后面。比如main.o依赖libfoo.a,而libfoo.a又依赖libbar.a,链接命令应该写成-lfoo -lbar,顺序反了会出现undefined reference。

5. CMake和Makefile的本质区别,以及我选型的心得

平时在社区里看到“cmake和makefile区别”这种问题出现频率相当高。很多人把它们当成同一种东西的两个版本,其实差得很远。

5.1 它们根本不在同一层

Makefile是一种构建脚本,直接由make程序解释执行。CMake则是一种构建规则描述语言,本身不直接构建,它运行后会生成一个构建系统文件,默认生成的就是Makefile,也可以生成Ninja build文件,甚至能生成Visual Studio工程。

这句话是关键:CMake是生成Makefile的工具之一。套用一句好记的话:“CMake负责meta,Makefile负责actual。” 所以,你在一个用CMake管理的项目里敲make,其实走的依然是Makefile,只不过那份Makefile是CMake生成的。

这也解释了很多人的困惑:为什么CMake项目里的Makefile那么难读?因为它不是给你维护的,而是给make执行的。里面大量的变量、模式和中间状态由CMake生成的规则接管,人读起来当然头大。

5.2 可移植性和生态差异

Makefile本身是跨平台的工具,但内容很容易写平台相关。你在Linux下写的rm -rf在Windows的cmd里就是非法命令。CMake则替你抹平了这些差异,通过add_executable、target_link_libraries这种抽象语法,让你不用关心底层到底用什么命令做清理和复制。

CMake还提供了一整套查找依赖包、生成安装规则、支持测试框架的生态。如果你要构建一个大型跨平台项目、或者要集成第三方库,选CMake基本是当下默认答案。

但CMake也有它的复杂性。它的学习曲线其实比Makefile更陡峭:变量作用域、target属性、生成器表达式,这些概念的抽象度高,出了问题排查起来反而没有Makefile那么直接。有一些小项目用CMake,写出来的CMakeLists.txt比Makefile还绕,维护者换一茬就没人敢动了。

5.3 自动生成Makefile的工具路线

除了CMake,生成Makefile的经典路线还有GNU autotools那套:写configure.ac和Makefile.am,然后autoreconf生成configure脚本,用户执行configure生成Makefile。开源软件的老三样./configure && make && make install就是这么来的。

这套体系对库项目、需要在全国各种Unix变体上兼容的场景非常合适,但上手门槛比Makefile高出一大截。除非你要维护一个发布给全世界用户的开源库,否则我一般不太建议普通人入坑autotools。

还有像qmake、meson这些系统,meson也能生成后端的Ninja,但在“生成Makefile”这个语境里,主流还是CMake和autotools。

5.4 我的建议与选型判断

直接给一个参考路径:

项目类型推荐方式理由
单目录、三五文件的小工具手写Makefile最少心智负担,一眼看穿全部逻辑
多目录但没有跨平台需求手写Makefile结构和依赖关系好掌控,构建快
需要跨平台构建、打包发布CMake生态完善,便于对接CTest和CPack
嵌入式Linux SDK跟随SDK既有构建体系芯片厂商通常已给出Makefile链,不要为改而改
大型开源库项目CMake或autotools维护成本由体系解决,避免自己造轮子

我把手写Makefile放到这么靠前的位置,不是因为固守习惯,而是基于一个现实:Makefile是理解一切构建系统的基础。你如果连目标、依赖、变量这套概念都搞不清,看CMake生成的Makefile会像看天书;反之,Makefile基础扎实了,理解CMake的抽象反而容易得多。

5.5 手写Makefile时我习惯保留的几个功能

哪怕有CMake存在,我自己维护的不少工具项目依然坚持用Makefile,因为里面有一套顺手的小功能:

  • 默认目标all写清楚主产物,让make直接可以出成果
  • make install提供安装路径变量,方便本地调试时装到临时目录
  • make test跑简单测试用例,不用引入额外的测试框架
  • make clean里带上备份文件清理,避免.o和临时文件污染目录
  • make help用注释输出可用目标,别人拿到Makefile不用翻文档

这些目标统一用.PHONY声明,放在文件末尾或顶部成组整理,阅读体验好很多。比如:

.PHONY: help help: @echo "make 构建主程序" @echo "make install 安装到指定目录" @echo "make test 运行测试" @echo "make clean 清理构建产物"

一个Makefile做到这个程度,已经不是“会写”的范畴,而是变成了项目自动化入口。别人拿到你这份代码,第一件事敲make help,马上就对这个项目怎么构建、怎么验证了如指掌。

最后再说点个人经验

前前后后维护过的Makefile,加起来几十个是有的。踩过的坑里,最让我印象深刻的倒不是语法报错,而是“Makefile太精巧,读代码的人跟不上”。有些老项目里的Makefile写满了多层变量展开、条件分支、define宏,构建逻辑全靠反推。

所以后来我给自己定了几个Deadly Simple的原则:变量命名不要过度缩写;每一条规则只干一件事;关键路径用注释说明“为什么这么写”;不是特别必要,就不搞递归make那套跨目录调用的花活。一个合格的Makefile,应该让三个月后的自己一眼就能看明白,而不是像一个天书谜题。

如果你现在刚接触,我的建议很简单:找一个小项目,不用CMake,不用IDE,亲手写一个Makefile出来,把源文件、头文件、产物目录、clean规则都整理好,然后体验一下改一个源文件后执行make,那种“只重新编译该编译的东西”的精准感。手感建立起来之后,再去看CMake生成的巨型Makefile也好,看Linux内核Kbuild也好,你会发现,原来所有构建系统讲的都是同一件事:依赖关系,以及怎么高效地更新它们。

构建这个环节没有银弹,但先弄懂Makefile,至少能让你在所有构建工具面前都有底气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询