很多人提到Makefile总觉得这是上世纪的东西,但只要你还在Linux终端下写C语言、交叉编译嵌入式固件,或者接手任何一个持续集成脚本,迟早都要和Makefile正面交手。Makefile是一款构建工具make的脚本文件,它解决的是"什么文件需要重新编译、怎么编、编完怎么链接"这一整套依赖关系问题。这篇精华版不会按GNU手册从头抄,而是把我这些年实际写Makefile踩过的坑、常用的套路、以及和CMake怎么取舍,一次讲清楚。
我刚入行的时候也犯过傻,觉得Makefile就是把gcc命令收集到一个文件里,后来被"改了头文件却不重新编译"这种情况折腾到怀疑人生,才明白Makefile的核心不是命令,而是依赖。把这层关系想透,后面的语法和技巧都是水到渠成。
1. 先把本质搞清楚:Makefile是一张构建关系表,不是命令记事本
1.1 一个最小案例告诉我们的真相
假设你现在有一个hello.c,最朴素的编译方式是这样:
gcc -o hello hello.c文件多了以后,比如有main.c、tools.c、video.c,你会自然写一个shell脚本:
gcc -c main.c gcc -c tools.c gcc -c video.c gcc -o demo main.o tools.o video.o很多人把Makefile写成了上面这个shell脚本的翻版:把每一行编译命令原样抄进去。这其实是对Makefile最大的误解。
Makefile真正做的事情是:描述目标文件、依赖文件、生成命令三者之间的关系,然后在每次执行make时,根据文件时间戳判断哪些目标需要重新生成。
所谓"依赖",就是"如果依赖文件比目标文件新,那么目标文件已经过期,必须重新编译"。这个逻辑才是Makefile的灵魂。
1.2 时间戳与增量编译的基本原理
make判断是否需要重建一个目标,靠的是文件的时间戳,不是内容对比。
比如规则:
main.o: main.c tools.h gcc -c main.cmake执行时检查main.o和main.c、tools.h这两个依赖的时间戳。如果main.c或tools.h比main.o新,就说明源码有改动,需要重新执行下面的gcc命令;如果main.o比所有依赖都新,说明该目标已经是最新状态,什么都不做。
这就是增量编译的基础:我只改了一个.c文件,其他文件不用跟着重编。一个几百文件的项目,如果每次全量编译,一次构建可能花费几分钟;有了正确依赖,可能只需要一两秒。
这里藏着一个新手最容易踩的坑:你改了头文件,但Makefile里没有把该头文件写成某个.o的依赖,make根本不知道头文件变了,所以不会重新编译。这个我们后面单独开一节讲。
1.3 "make没有指明目标并且找不到makefile"到底是什么意思
这条报错几乎是所有新手都会遇到的:
make: *** No targets specified and no makefile found. Stop.拆开看是两件事:
- 当前目录没有找到名为
GNUmakefile、makefile、Makefile的文件(优先级依次是GNUmakefile > makefile > Makefile) - 你也没有用
make target的方式指定一个目标
make在没有找到makefile时自然无从下手。解决办法很简单:确保你的构建文件叫Makefile(大写M是Linux社区最常用的约定),或者用make -f 你的文件名指定。
如果makefile存在但还是报错,那就得看里面的目标名了。make执行时如果用默认目标,会使用文件中第一个非模式规则的目标。如果第一个目标写的是clean,那执行make直接清理,不编译任何东西。所以常规做法是让all作为第一个目标:
all: demo这样执行make就等于执行make all,也就是编译demo。
2. 从规则语法开始:target、prerequisites、recipe三要素
2.1 基础格式和那个特殊的Tab
一条基本规则长这样:
目标文件: 依赖文件1 依赖文件2 ... 命令1 命令2有几个硬性规则:
- 第一行是
目标: 依赖,冒号左右有没有空格都行 - 接下来每行命令必须以Tab开头,不能用普通空格替代
- 命令会自动传给shell执行,每行命令是在独立的shell里跑的
为什么是Tab不是空格?因为make的语法解析器当初就把Tab当作"命令起始符",这个是历史遗留。哪怕今天看起来很不合理,你依然得遵守。如果你用空格缩进,make会提示“missing separator”,这是最常见的报错之一。
2.2 一个三文件的编译示例
假设项目文件是main.c、tools.c、tools.h,初始版本Makefile可以这样写:
demo: main.o tools.o gcc -o demo main.o tools.o main.o: main.c tools.h gcc -c main.c tools.o: tools.c tools.h gcc -c tools.c clean: rm -f demo main.o tools.o执行make demo时,make会先看demo是否存在、是否比main.o和tools.o新。如果其中任何一个.o缺失或者源码被改过,就递归检查它自己的依赖关系,然后按需编译。
注意demo这个目标并没有all,如果你直接在命令行执行make,默认目标就是demo,效果一样。但我建议显式加一个all,防止以后在文件前面插入其他规则导致默认目标变化。
2.3 隐含规则与内置变量:Edit不写命令也能编译
GNU make里内置了一堆"隐含规则"。比如你写:
main.o: main.cmake会自己找到命令:
cc -c main.c -o main.o它默认调用的是cc,你可以用变量覆盖。常用的内置变量有:
| 变量 | 含义 |
|---|---|
CC | C编译器,默认cc |
CFLAGS | C编译参数,比如-O2 -Wall |
CPPFLAGS | 预处理参数,比如-I头文件路径 |
LDFLAGS | 链接参数,比如-L库路径 |
LDLIBS | 链接的库名,比如-lpthread -lm |
如果不想依赖隐晦的内建规则,也可以显式写模式规则:
%.o: %.c $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@$<代表第一个依赖,$@代表目标。这种写法在交叉编译时特别重要,因为我们要把CC替换成交叉编译器,把CFLAGS加进芯片SDK的头文件路径。
以上是Makefile的骨架。很多人学到这里就去写了,后面一碰项目就出问题,因为变量和函数没用好。下一节把语法补完整。
3. 变量、函数、自动化变量:构建脚本的编程能力
3.1 变量定义与赋值时机:= 和 := 的差别
Makefile变量和C语言宏有点像,就是文本替换。但赋值方式不同会带来完全不同的行为。
A = file1.o $(B) B = file2.o如果用=,A的取值是"递归展开"的:当A真正被使用的时候,B已经赋值了,所以A最终得到file1.o file2.o。这看着很方便,但容易造成循环引用,比如A = $(B)和B = $(A)会让make无限循环。
用:=则是简单展开,变量在赋值那一刻就被展开成当前值:
A := file1.o $(B) B := file2.o此时A的值就是file1.o,因为赋值时B尚未定义。官方实践里,大多数人会倾向使用:=,行为可预期。
还有两个常用操作符:
+=追加内容?=如果变量没赋值过才赋值
交叉编译时,我习惯写:
CC ?= gcc意思是如果用户没在命令行指定CC,默认用gcc;如果用户执行make CC=clang,就尊重用户的输入。
3.2 自动化变量:$@、$<、$^的价值
自动化变量是让Makefile不再重复写目标名和依赖名的关键。最常用的几个:
| 变量 | 含义 | 示例 |
|---|---|---|
$@ | 当前目标名 | main.o |
$< | 第一个依赖名 | main.c |
$^ | 所有依赖的列表(去重) | main.c tools.h |
$? | 比目标新的依赖列表 | 变更过的源文件 |
$(@D) | 目标所在目录 | obj/ |
$(@F) | 目标文件名 | main.o |
一个比较典型的场景:
$(BIN): $(OBJS) $(CC) $(LDFLAGS) -o $@ $^ $(LDLIBS)这样写的好处是,以后加源文件只需要改列表,链接命令基本不用动。
3.3 常用函数与通配符:wildcard、patsubst、shell
GNU make内置函数非常实用。
SRCS := $(wildcard *.c) OBJS := $(patsubst %.c,%.o,$(SRCS))wildcard把当前目录下所有.c文件展开成一个列表,patsubst做后缀替换,得到对应的.o列表。这比手动一个个写文件好维护得多。
如果源文件分布在多个目录,用foreach循环很方便:
SRC_DIRS := src common SRCS := $(foreach dir,$(SRC_DIRS),$(wildcard $(dir)/*.c))shell函数可以在解析Makefile时执行shell命令,比如获取当前时间或调用工具链版本:
BUILD_DATE := $(shell date +%Y%m%d)不过要克制,$(shell ...)在make解析阶段就会执行,滥用会让构建输出混乱。
还有notdir、basename、addprefix等,都是处理路径字符串用的。掌握以上几个就足够应付大多数项目了。
4. 头文件依赖与自动依赖生成:增量编译中最容易漏掉的雷
4.1 为什么改了头文件,make却不重新编译
这是我在实际项目里遇到最多的问题,排查过程也很有代表性。
场景:main.c里写了#include "tools.h",Makefile里只有main.o: main.c。此时你修改了tools.h,再执行make,它发现main.c的修改时间没变,main.o没变,于是什么都不做。可实际上调用接口变了,最后的可执行文件还是旧的。
问题的根因是:Makefile里根本没有把头文件纳入依赖关系,make只认写在规则里的依赖,不会自动去分析源码的include。
解决办法不能靠手写,因为头文件经常有嵌套包含,手写很快会漏掉。正确做法是用编译器生成依赖文件。
4.2 用编译器自动生成 .d 依赖文件
GCC和Clang都支持-MM参数,可以输出适合make的依赖规则。例如:
gcc -MM main.c输出:
main.o: main.c tools.h common.h这正好就是一条Makefile规则。于是我们可以为每个.c生成一个对应的.d文件,再把它include进主Makefile:
SRCS := $(wildcard *.c) OBJS := $(patsubst %.c,%.o,$(SRCS)) DEPS := $(patsubst %.c,%.d,$(SRCS)) %.d: %.c $(CC) $(CPPFLAGS) -MM $< > $@ include $(DEPS)解释一下执行流程:
- make读取Makefile时遇到
include $(DEPS) - 如果对应的
.d文件不存在,make会尝试用上面的规则生成它 - 生成后重新读取这些依赖规则
- 这样头文件一旦变化,
main.o就被标记为过期,自动重编
首次构建多了一个生成.d的步骤,但之后增量编译十分可靠。还可以加-MP参数,让GCC为每个头文件生成一个空目标,避免删除头文件后因找不到目标而报错。完整写法可以这样:
%.d: %.c $(CC) $(CPPFLAGS) -MM -MP $< > $@这个技巧对能否"稳定增量编译"影响非常大,强烈建议所有C/C++项目都用起来。
4.3 伪目标与.PHONY,clean为什么易"失效"
在Makefile里,clean这类目标通常不产生名为clean的文件,它只是执行删除命令的“动作”。如果不加声明,而恰好当前目录里存在一个叫clean的文件,make会发现clean没有依赖,而且存在比任何依赖都新的目标文件,于是认为它"不需要执行",命令就不跑了。
正确的做法是把所有不代表真实文件的目标都标记成伪目标:
.PHONY: all clean distclean伪目标的意思是:不要做时间戳检查,只要你在命令行显式提出,或者被其他规则依赖,就直接执行命令。
注意顺序。如果你把clean写在Makefile的第一行,执行make时默认目标就是clean,新手很容易被坑。我的习惯是all永远放第一。
4.4 并行编译的隐患:依赖不精确就上-j
make -j4多核编译能显著提速,但前提是依赖关系足够准确。如果某个.o实际上依赖一个头文件,但你没有把它写进规则,并行编译时可能两个文件同时编译,暂态文件名冲突,最后出现莫名其妙的链接错误。
加了自动依赖生成后,并行编译的安全性会好很多。不过还要注意目标间的依赖顺序,比如某些代码生成器必须先于编译执行,你就不能简单让所有目标并行。此时可以用order-only prerequisites,或者用$(shell ...)做阶段控制。新手阶段先保证单核编译正确,再考虑并行。
5. 实战:给RV1106平台写一份能落地的交叉编译Makefile
5.1 场景说明:交叉编译和头文件路径
RV1106是瑞芯微推出的一颗IPC视觉处理芯片,开发时通常在一台Linux PC上编译,代码最终跑到嵌入式板卡上。这种"编译环境和运行环境不同"的做法叫交叉编译,核心变化是编译器不再是gcc,而是某种带前缀的交叉编译器,比如arm-rockchip830-linux-uclibcgnueabihf-gcc。
为了解决这个问题,芯片厂商会提供SDK,里面包括工具链、头文件和库文件。我们需要做的就是把前缀、路径正确传入Makefile。
5.2 一个完整的RV1106项目Makefile
假设项目包含main.c、video.c、capture.c三个源文件,SDK根目录是/opt/rv1106_sdk,可以这样组织:
CROSS_COMPILE ?= arm-rockchip830-linux- CC := $(CROSS_COMPILE)gcc AR := $(CROSS_COMPILE)ar STRIP := $(CROSS_COMPILE)strip SDK_DIR := /opt/rv1106_sdk CFLAGS := -O2 -Wall CPPFLAGS := -I$(SDK_DIR)/include -I$(SDK_DIR)/include/rockchip LDFLAGS := -L$(SDK_DIR)/lib LDLIBS := -lpthread -lm SRCS := main.c video.c capture.c OBJS := $(patsubst %.c,%.o,$(SRCS)) DEPS := $(patsubst %.c,%.d,$(SRCS)) BIN := demo .PHONY: all clean all: $(BIN) $(BIN): $(OBJS) $(CC) $(LDFLAGS) -o $@ $(OBJS) $(LDLIBS) $(STRIP) $@ %.o: %.c $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@ %.d: %.c $(CC) $(CPPFLAGS) -MM -MP $< > $@ -include $(DEPS) clean: rm -f $(BIN) $(OBJS) $(DEPS)这里几个细节值得说明。
CPPFLAGS放头文件路径,CFLAGS放编译优化选项,分开放是GNU make的传统做法。LDFLAGS在链接时使用,LDLIBS保证放在源文件或目标文件之后,因为链接器对库的顺序敏感。
$(STRIP) $@这一步是可选的,用于裁剪符号表,固件体积能小很多。我倾向于在生成可执行文件后直接strip,避免后面忘记。
如果工具链前缀不是arm-rockchip830-linux-,你可以改CROSS_COMPILE变量,或者从命令行覆盖:
make CROSS_COMPILE=arm-linux-gnueabihf-5.3 头文件路径出错了怎么排查
如果你的源文件里写的是:
#include "video.h"编译器会先查看源文件所在目录,也就是当前目录下的video.h。如果video.h不在当前目录,而在/opt/rv1106_sdk/include下,但你又没有指定-I,就会报错:
fatal error: video.h: No such file or directory这时候用-I告诉编译器去哪里找,问题就解决了。但如果video.h还在更深层路径,比如include/rockchip/video.h,你代码里写#include "video.h",需要加-I$(SDK_DIR)/include/rockchip;如果你写的是#include "rockchip/video.h",只需要加-I$(SDK_DIR)/include即可。这一步很容易把多级目录搞混。
排查手段推荐两个:
- 执行
make -n,只打印要执行的命令,不真正执行,看-I参数是否带对了 - 执行
make V=1,如果你在Makefile里没有把$(info ...)加进去,就用它把所有变量打出来;也可以在Makefile里临时加一行$(info CPPFLAGS=...$(CPPFLAGS))直接看到变量值
5.4 常见错误处理清单
| 现象 | 可能原因 | 处理 |
|---|---|---|
No targets specified and no makefile found | 文件名不对或目录不对 | 检查文件名是Makefile,或使用make -f xxx |
missing separator | 命令前没用Tab | 把行首空格改成Tab |
undefined reference to | 链接顺序或库路径不对 | 检查LDLIBS放在目标文件之后,确认LDFLAGS的-L正确 |
| 改了头文件没重编 | 依赖文件没有包含头文件 | 检查.d依赖生成是否启用,是否include |
No rule to make target 'xxx.h' | 头文件被删除但依赖里还有 | 用-MP生成空目标,或删除对应.d文件 |
| 交叉编译符号格式不对 | 用了本机gcc而不是交叉编译器 | 检查CC变量是否被覆盖 |
6. Makefile和CMake怎么选:场景化的理性对比
6.1 CMake到底在解决什么问题
CMake不直接参与编译,它是个"构建系统的生成器"。你写一份CMakeLists.txt,CMake会生成Makefile、Ninja文件或者IDE工程文件。也就是说,CMake生成的Makefile和手写Makefile在底层并没有本质区别,区别在于CMake自动帮你做了很多依赖管理和平台探测。
比如你在CMakeLists.txt里写:
target_include_directories(demo PRIVATE "${SDK_DIR}/include")CMake会记录这个include路径在生成文件里,编译时自动带上。跨平台时,CMake能识别Windows、Linux、macOS的不同编译器和链接规则,这一点确实比手写Makefile强得多。
6.2 什么情况下坚持手写Makefile更划算
我见过不少团队,项目就两个文件,非得上CMake不可,最后维护起来比Makefile还痛苦。
手写Makefile的优势在于:
- 构建逻辑透明,出错容易定位
- 不需要额外安装和生成步骤
- 适合快速原型、小型工具库、单一平台
拿前面的RV1106例子来说,如果项目规模只有三五个源文件,手写Makefile完全够用,而且修改完直接make,不要额外跑cmake。
但如果项目满足下面任意一条,建议迁移到CMake:
- 需要跨平台编译
- 有多个子目录,源文件超过几十个
- 需要自动化生成配置头文件
- 要对外提供库,并且希望用户通过find_package集成
- 团队规模大,构建规则需要多人协作维护
这时候CMake的价值就体现出来了:它帮你管理了目录、源文件列表、选项开关和安装规则,底层还是调用Make或Ninja。
6.3 两者并非互斥,理解底层更重要
很多从CMake入门的人,遇到CMake生成的makefile出错就发怵。其实追到根上,CMake生成的所有Makefile仍然遵循本章讲的规则:目标、依赖、命令、时间戳。你只是不直接写它们而已。
我的建议是,新手先花一周把Makefile基本原理搞清楚,再上CMake。不是为了炫耀技能,而是为了在出问题时不至于把"构建系统"当成黑盒。理解了目标依赖关系,CMake里那些add_library、target_link_libraries也就没那么玄乎了。
最后分享一点个人体会。我维护过不少项目的构建文件,最大的感受是:Makefile的价值永远不在语法漂亮,而在依赖关系是否诚实。一个20行的Makefile,如果你把头文件依赖、伪目标、干净利落的clean都做齐了,它比100行但到处是FORCE的CMake还可靠。所以别小看这份"老古董",它值得你认真对待。