☰
Makefile核心原理与实战:依赖、增量编译与CMake取舍
2026/10/8 3:23:42 网站建设 项目流程

很多人提到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.c

make执行时检查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.c

make会自己找到命令:

cc -c main.c -o main.o

它默认调用的是cc,你可以用变量覆盖。常用的内置变量有:

变量含义
CCC编译器,默认cc
CFLAGSC编译参数,比如-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还可靠。所以别小看这份"老古董",它值得你认真对待。

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

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

立即咨询