如果你已经写过一两手"Hello World"级别的 Makefile,那么很可能经历过这样的场面:源文件就三五个,还能一行一个 gcc xxx.c -o xxx 老老实实写下来;可一旦工程大起来,几十上百个 .c 散落在好几个目录里,你手写的每一条规则都成了高危劳动——多敲错一个文件名,make 就给你脸色看;多写一次重复的命令,改起来简直想砸键盘。

这一篇就是来解决这个痛点的。我会带你从"手工贴规则"一路走到"让 Makefile 自己认识工程":什么时候用变量、= 和 := 到底差在哪、哪些内置函数能替你干重活(比如自动抓取当前目录下所有 .c、把路径批量拼出来、在多个目录里往返收集文件)、怎么用一条 %.o:%.c 模式规则就覆盖"一百个文件"、又怎么做到把中间产物 .o 和最终可执行文件从源码里剥离出去、最后甚至聊到"一个工程里有多个子 Makefile、make 递归地进入子目录去干活"。

这堂课读完之后,你手里会多一套"通用 Makefile"的心法——换到任何 C 工程上,稍作裁剪就能跑。你会明白为什么很多大项目只有薄薄一个顶层 Makefile,却能把整棵目录树安排得明明白白。

在往下之前,我默认你已经知道 make 最基本的雏形长什么样。如果还是完全陌生,我建议先看一眼入门篇,把"凭文件时间戳判断要不要重编译、怎么把命令行敲进 Makefile"这几点先打通。这里我先把几个这堂课第一次要用力用的名词点一下名,它们的概念会在正文里正式展开:

  • 变量:Makefile 里用 名字 = 值 这种形式定义的一等公民,用 $(名字) 引用。
  • 目标 / 依赖 / 命令:一条规则的三件套。目标: 依赖 下一行写 命令(TAB 开头),意思就是"当依赖比目标新时,去执行这些命令来更新目标"。
  • 模式规则 %.o:%.c:用 %(百分号)当通配符,一次给"任意同名源文件"套上同一条编译规则。
  • 自动变量 $@ $^ $<:在命令里使用的魔法符号,分别代表"目标名""全部依赖""第一个依赖"。
  • 递归 make:一个 Makefile 在自身命令里再一次调用 make(往往是进入别的目录去派发任务)。
  • 伪目标:用 .PHONY 声明的、并不真的产出同名文件的目标(比如 clean)。

从一个痛点讲起:手工 Makefile 是怎么把人逼疯的

我曾经见一个刚学 Make 的朋友,对着一个一百多行的 Makefile 发愁。那个工程有 100 个".c"文件,他老老实实写了两百多行规则:每个 .o 一行编译规则,最后再来一行链接。当时他崩溃说:"要是老师再加一个文件,我是不是又要手抄三行?"

我说:对,这就是你需要"变量"和"函数"的时机。手工 Makefile 之所以可怕,是因为它把 数据和逻辑没有分开——文件列表(数据)和"怎么编译"(逻辑)死死地焊在一起。而进阶 Makefile 的全部艺术,就是先把数据(一份源文件列表)抽出来,再用几条通用的逻辑规则一次性消化掉它们。

我们先用一个具体的、可以亲手敲的工程来进入正题。请先创建一个目录,把下面这几个文件都放进去(都极小,纯粹为了演示):

首先是一个头文件和两个实现文件,我给它们做了点分工,方便之后讲到"多模块"时对照:

// util.h
#pragma once
int max_of(int a, int b);
// util.c
#include "util.h"
int max_of(int a, int b)
{
    return a > b ? a : b;
}
// calc.h
#pragma once
int add(int a, int b);
int sub(int a, int b);
// calc.c
#include "calc.h"
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

再准备一个 main.c,它调用上面两个头文件里声明的函数:

// main.c
#include <stdio.h>
#include "util.h"
#include "calc.h"
int main(void)
{
    printf("max = %d\n", max_of(5, 9));
    printf("add = %d sub = %d\n", add(3, 4), sub(3, 4));
    return 0;
}

现在你的目录里有 main.c、util.c、calc.c 以及对应的头文件。这就是我们这张"手术台",后续每一个 Makefile 都是拿这堆文件来开车。

先来一段难受的"手工"版本

如果不会任何变量和函数,你要写的 Makefile 会长成这样(这是标准的、甚至是合法的,但浑身透着"写第二次就想死"的气质):

# 手工版 Makefile:每一个目标文件都要自己点名
# 注意:下面所有命令行的缩进都必须是真实 Tab,不能用空格
main.o: main.c util.h calc.h
	gcc -c main.c -o main.o
 
util.o: util.c util.h
	gcc -c util.c -o util.o
 
calc.o: calc.c calc.h
	gcc -c calc.c -o calc.o
 
app: main.o util.o calc.o
	gcc main.o util.o calc.o -o app

这段代码句句正确,但你有没有看出问题:

  • 每一个 .o 都要写一行 gcc -c xxx.c,命令长得一模一样,只有名字在变;
  • 如果工程有 100 个源文件,你就得写 100 对规则;
  • 随便加一个文件,就要回到文件里手补两到三行;
  • 链接那行把三个 .o 挨个列出来,文件一多就变成超长的一串,极易漏写。

把这一段背下来当"反例"记住,然后我们开始向进阶进发。接下来我们要做的,就是回答一个问题:能不能让 Makefile 自己拿到"目录里的所有 .c"这份名单,再让同一条规则把每个 .c 都编译成对应 .o?

函数与通配符:wildcard 自动收集源文件

Make 自带一组可以被你在任何表达式里调用的函数。函数是 Make 提供的"内置小程序",它们不穿衣服地出现在 $(...) 里,写法统一是 $(函数名 参数1, 参数2, ...)——注意参数之间用逗号分隔,逗号和参数之间允许留空格。你迟早会爱上它们的,因为它们才是让 Makefile"认识你的工程"的关键。

这一节先认识最常用的那个:$(wildcard 模式...),它翻译成大白话就是"把当前目录(或指定目录)下所有、真实存在、能匹配给定模式的文件名列出来,用空格拼成一串一起返回"。它非常适合"动态确定要编译的源文件有哪些"这种场景——尤其是当你的源文件会随工程演进不断增删时。

为什么偏偏要在 Makefile 里用 wildcard,而不是直接写 *.c 的裸通配符?这里藏着一个通配符展开时机的坑,是初学者最常踩的、也是后面"边界与坑"一节会反复出现的主题。简单说:Make 在变量的赋值语句里不会自动展开 * 这种通配符,它只会原样保存;你要让通配符真正去匹配磁盘上的文件名,就必须显式调用 wildcard 函数。在依赖列表那种特定位置(比如 目标: *.c)Make 才会自动展开,但那是它内部的特殊规则,你在变量里不要指望它。

我们用 main.c、util.c、calc.c 这个工程,先写一个"只测不编"的 Makefile,专门看看 wildcard 能抓到什么、替换出来是什么:

# 目标 test 并不真会产出"test 这个文件",只是用来打印,后面会讲伪目标
# SRCS 变量用 wildcard 函数,把当前目录所有 .c 文件收集成空格分隔的列表
SRCS := $(wildcard *.c)
 
# 用看家本领:(变量:后缀=新后缀) 这种写法,把每个 .c 替换成同名 .o
# 注意它是"整体把 .c 尾巴换成 .o",不会动路径里的其它字符
OBJS := $(SRCS:.c=.o)
 
# 一个名为 test 的目标,它没有依赖,只有命令;运行 make test 才会执行
test:
	@echo "Src:"
	@echo $(SRCS)
	@echo "OBj:"
	@echo $(OBJS)

命令里的 @ 前缀意思是"执行命令但不把命令行本身打印出来",这样输出更干净(后面会碰见很多)。你在终端里敲 make test,会看到类似这样的输出:

$ make test
Src:
calc.c main.c util.c
OBj:
calc.o main.o util.o

看到了吗?我们只写了一行 SRCS := $(wildcard *.c),Make 就自动帮你把当前目录下那三个 .c 全抓出来了;再用一行 $(SRCS:.c=.o),它们便齐刷刷变成了对应的 .o 名单。从今往后,你往目录里新增一个 xxx.c,什么都不用改,这份名单会自动多出 xxx.o。 这就是"数据与逻辑分离"的甜头——你不再需要手工罗列每一个文件。

顺便说一句,那种 .c:.o 的简写有一个学名,叫 变量的替换引用(substitution reference)。它的完整形式其实是调用 patsubst 函数(我们后面会专门学到 patsubst)。$(SRCS:.c=.o) 是 $(patsubst %.c,%.o,$(SRCS)) 的一种便捷写法,功能几乎一样,只是缩写语法更适合一眼看懂。你先把它当成"给文件列表整体换个后缀"的快捷键即可。

思考题一

现在这个工程目录里除了 .c 文件,还有上一节创建的头文件 util.h、calc.h。请问:SRCS := $(wildcard *.c) 和 $(SRCS:.c=.o) 这两行之后,SRCS 和 OBJS 各包含了什么?会不会把 util.h、calc.h 也搅进来?

详解:不会。wildcard *.c 只匹配以 .c 结尾的文件名,头文件以 .h 结尾,根本不在匹配范围里,所以 SRCS 只含 calc.c main.c util.c。.c=.o 的替换引用只对 SRCS 中以 .c 结尾的每一项做替换,同理碰不到头文件,因此 OBJS 是 calc.o main.o util.o。换句话说,这两行承担的是"编译产物名单",头文件并没有进入这串名单——它们是"依赖"层面的东西,规则(以及专门讲的依赖问题)这一课才会把它请进来。

变量与赋值:= 与 := 的天壤之别

上一节我们写 SRCS := $(wildcard *.c) 时用了 :=,但课件里有几处用的是 =。它们到底差在哪?如果不搞清楚,你会在某一个深夜被一个"为什么变量值和我写的不一样"的问题折磨到崩溃。

Make 里的变量本质上分两类,按求值时机区分:

  • = 递归展开赋值(recursively expanded)。写下 b = $(a) bit 时,Make 并不立刻求值 $(a),而是把整段 $(a) bit 原样当字符串记下来,等以后每次用到 b 的时候才去现场展开 $(a) 当时的值。所以 b 的最终内容,取决于 a 变量在整个 Makefile 读完之后、真正被用到那一刻的值。
  • := 直接/立即赋值(simply expanded)。写下 b := $(a) bit 的一瞬间,Make 就当场求值右边的 $(a),并把求好的结果固定下来。之后再怎么改 a,b 都纹丝不动。

对,区别就是"推迟求值"还是"立刻求值"。它俩的差异用一个经典例子一眼就能看清。我们写一个 Makefile,观察同一个 b 在两种赋值下分别得到什么:

# = 递归展开:b 的右边 $a 被推迟到"用到时"才展开,所以取的是 a 的最终值
a = hello
b = $(a) bit
a = world
 
# 我们注释掉 := 版本,先看 = 的效果
#a = hello
#b := $(a) bit
#a = world
 
all:
	@echo $(a)
	@echo $(b)

跑一次,看到的是:

$ make
world
world bit

神奇吧?b = $(a) bit 这一行明明写在 a = hello 之后、a = world 之前,但 b 最终却打印成了 world bit。原因恰恰是 = 的"推迟求值":Makefile 是整体读一遍再去执行的,等真正轮到要打印 b 的时候,a 早已被最后一行 a = world 覆盖成了 world,于是 $(a) bit 就展开了 world bit。用 = 赋的值,等于"全文件范围里的最终值"。

现在我们把注释反过来,用 := 版本,也就是把上面 Makefile 里的三行换成:

# := 立即展开:b 在赋值的那一刻就把 $a 求值成当时的 hello,之后与 a 无关
a := hello
b := $(a) bit
a := world
 
all:
	@echo $(a)
	@echo $(b)

再跑,输出变成:

$ make
world
hello bit

看明白了吗:b := $(a) bit 在赋值那一瞬间就生吞了当时的 hello,拼出 hello bit 并固化成 b 的定稿。此后即便 a 被改成 world,b 也永远停在 hello bit。:= 才是我们通常理解的"赋值即定格"。

那么到底什么时候用哪个?给一条朴素但够用的经验:

  • 引用自身还没定值、又特别依赖当前时刻的值,或者要拿来做路径拼接、再被反复改写,优先 :=。课件里那句"这里必须使用 :=,否则会有递归引用的风险"正是这个道理——如果用 = 去写"基于上一版自身结果再加工"的变量,容易造成展开时套娃、甚至死循环(比如 x = $(x) 这种东西)。
  • 其它一般情况、尤其是想定义一组"互相引用但都取最终值"的常量,= 也行,这就是为什么你在很多 Makefile 开头看到 CC = gcc 这种宽宽松松的写法——它在被用到之前都不会被求值,反而对"后面想统一改编译器的行"更友好。

还有一个容易记混的点:如果变量之前从没定义过,那么用 = 和用 := 暂时看不出区别;区别只在"你写这一行之后,还有没有别的行会再改这个变量的(被依赖的)值"时才爆发出来。

最后,赋值运算符其实有三个。除了 =、:=,还有一个追加用的 +=:

CFLAGS := -Wall -Wextra   # 先给初值
CFLAGS += -g               # 再追加一个选项,结果是 "-Wall -Wextra -g"

+= 是把新内容接到已有值的后面(中间自动补空格)。它有个和上面呼应的细节——+= 的特性随变量"出身"而变:如果这个变量之前是用 := 定义的,那么 += 保持"立即展开"的脾气;如果之前用 = 定义、或从未定义过,那么 += 按 = 的"递归展开"处理。理解到"它的行为不是铁板一块、要看原变量出身"这一层就够用了,不必背参数。

思考题二

看下面这段 Makefile,猜 make 跑完,CFLAGS 打印出来是什么:

CFLAGS := -O2
CFLAGS2 = $(CFLAGS) -DVER2
CFLAGS := $(CFLAGS) -Wall
all:
	@echo $(CFLAGS)
	@echo $(CFLAGS2)

详解:第一行用 := 把 CFLAGS 定稿为 -O2;第二行 CFLAGS2 = $(CFLAGS) -DVER2 是 =(递归展开),右边 $(CFLAGS) 被推迟到"用 CFLAGS2 时"才求值;第三行用 := 把 CFLAGS 改为 -O2 -Wall。等到 all 的 echo 真的要展开时:CFLAGS 此刻已是 -O2 -Wall,CFLAGS2 是递归展开,取 CFLAGS 的当前值拼成 -O2 -Wall -DVER2。所以输出是两行:

-O2 -Wall
-O2 -Wall -DVER2

关键就在第二行用的是 =(会晚算),第三行用的是 :=(当下结算)。要是把第三行也改成 :=,而第二行仍为 =,那你还会看到 CFLAGS2 变成 -O2 -Wall -DVER2;但若把第二行改成 b := $(a) bit 式(即 CFLAGS2 := $(CFLAGS) -DVER2 紧跟在第一行后),则 CFLAGS2 会定格为 -O2 -DVER2,因为它在第三行动手前就已求值。你可以把这三行的顺序、运算符来回换着玩,是理解这两者最直观的试验场。

模式规则 %.o:%.c:一条规则吃掉所有文件

现在我们有了"整个目录所有 .o 的名单"(OBJS),下一步是让 Make 用同一条规则把每一个 .c 都编译成对应的 .o,而不是像开头那种手工版一句一句抄。答案就是模式规则。

模式规则的标志性写法是:

%.o: %.c
	命令

这里的 %(百分号)是一个占位通配符,它可以匹配"任意一段(不含路径分隔符 / 的)字符串"。理解它的方式是看"对号入座"的过程:

  • 当 Make 想为某个目标 calc.o 找规则时,见到 %.o 这个模式,就让 % 匹配 calc,于是格式上凑成 calc.o : calc.c;
  • 对 util.o,% 匹配 util,凑成 util.o : util.c;
  • 换句话说,%.o: %.c 一次性声明了"任何名字的 .o,其依赖就是对同名前缀的 .c"这一整族规则。

那为什么这一条规则能管用的前提,是它要配着模式规则里的自动变量来写命令?因为同一句命令会被套用到不同文件上,你必须让命令里的东西"跟着当前文件走",而不是写死。这就请出了自动变量——在规则命令里可用的、Make 替你填好的几个特殊变量:

  • $@:当前规则的目标名(Target)。对 calc.o 这条规则,$@ 就是 calc.o。
  • $^:当前规则的全部依赖,用空格连成一串,并把重复项去重。对 calc.o : calc.c,$^ 就是 calc.c。
  • $<:当前规则的第一个依赖。通常和"源文件"是同一回事(编译时 $< 一般就是那个 .c)。对 calc.o : calc.c,$< 是 calc.c。
  • 还有个常被提到的 $*:模式规则里 % 匹配到的那段"干名"。对 calc.o : calc.c,$* 是 calc。
  • $?:比目标新的依赖(增量的增量),串行/并行构建、依赖生成时用得上,表里先放着。
  • 自动变量的"目录/文件名"切分版:$(@D) 目标是 dir/file 时取 dir,$@F 取 file;$(<D) $(<F) 同理作用在 $< 上。比较 $(dir $@)(函数)能得到一样的效果,这一小族也是路径拼装的好帮手。

于是"整个目录所有源文件一步到位编译"的 Makefile 就成形了。注意这里我先用 =(或者你之前习惯了用 := 也行,演示统一用 =),它负责让 Make 在推导目标时拿到完整的名单:

# 指定编译器;CC 是 Make 内置的变量,默认值通常是 cc(大概率就是 gcc)
CC = gcc
# 编译选项:-c 表示只编译不链接,生成目标文件
FLAGS = -c
 
# 用 wildcard 抓取当前目录全部 .c
SRCS = $(wildcard *.c)
# 用替换引用把名单整体换成 .o;注意这里把 SRCS 写对了
OBJS = $(SRCS:.c=.o)
 
# 声明 all 是伪目标:它不产出名为 all 的文件,只是"默认要构建的一组入口"
.PHONY: all
# 默认目标 all 的依赖是所有 .o;要让 all 成功,先把每个 .o 都建出来
all: $(OBJS)
 
# 模式规则:任何一个 X.o,都依赖同一个 X.c,用下面两行命令去编译
# $< 是第一个依赖(即源 .c),$@ 是目标(即 .o)
%.o: %.c
	@echo "compling ... $<"
	@$(CC) $(FLAGS) $< -o $@
 
# clean 也是伪目标:不出文件,只负责收拾残局
.PHONY: clean
clean:
	@echo "clean ... Project"
	@rm -f $(OBJS)

执行 make,Make 会按照 all 的依赖(calc.o main.o util.o),用同一条模式规则把三个 .c 依次编成 .o,输出像这样:

$ make
compling ... util.c
compling ... calc.c
compling ... main.c
$ ls *.o
calc.o main.o util.o

你再去 touch util.c(把 util.c 的时间戳改新)然后 make,会看到只有 util.o 被重新编译——增量编译的魔力没丢:Make 仍是靠"依赖比目标新就重做"的老规矩判断的,只是现在"判断依据和重做命令"来自同一条通用规则。这就是模式规则的威力:一条规则,管住一族文件,且自动变量保证每份文件的命令都对得上号。

课件里提到"for each file 把 .c 替换成 .o"、并有 $(SRCS:.c=.o)(正确版)和笔误 $(SRC:.c=.o)(少了复数 s,变量名没对上)的对照,正是提醒你:变量名的拼写必须和定义处一字不差,这里 SRCS 和 SRC 是两个完全不同的变量,抄课件时先确认手滑没有。

思考题三

接上面的 Makefile,如果我把编译命令里 $< 误写成 $^(也就是 $(CC) $(FLAGS) $^ -o $@),对 calc.o 这一条来说,命令会变成什么?还能正常编译吗?

详解:$^ 是"全部依赖"。对 calc.o : calc.c 这条规则来说,它的全部依赖只有 calc.c 一个,所以 $^ 恰好等于 $<,命令变成 gcc -c calc.c -o calc.o,依然能正常编译。但当规则的依赖有多个时(比如链接那步 app: main.o util.o calc.o,$^ 会展开成三个 .o,$< 只取第一个 main.o),两者就天差地别了。给你一个随时记牢的口诀:$< 管"源"、$@ 管"果"、$^ 管"全家桶"——单依赖时 $< 和 $^ 常相等,多依赖瞬间分道扬镳,别混用。

伪目标 .PHONY:让 clean 这类"不产文件"的目标守规矩

细心的你可能注意到上面我给 all、clean 都写了 .PHONY 声明。这一节专门把它讲透。

先问个直白的问题:clean: 这个目标执行的是 rm -f ...,它根本不会产出名为 clean 的文件。那么如果你很不巧,目录里真的出现了一个叫 clean 的文件(比如有人一时兴起 touch clean 生成了个空文件),会出现什么后果?

Make 会认为 clean 这个目标"已经最新"——因为它检查规则时发现"目标文件 clean 存在,且没有比它更新的依赖"(clean 本来就没有依赖),于是判定"无需重新执行命令",直接跳过!结果就是你在终端敲 make clean 却什么都不清理,被一个同名空文件给"骗"了。这是让人抓狂的低级坑。

解决办法就是把这类"只执行动作、不产出同名文件"的目标用 .PHONY 声明:

.PHONY: clean
clean:
	rm -f $(OBJS)

.PHONY 的意思是"把名字列出的这些目标声明为'伪目标'":Make 一看到目标进了 .PHONY 列表,就不再去检查"目标文件存不存在、有没有比依赖旧",而是无条件每次执行它名下的命令。这样一来,哪怕目录里躺着名叫 clean 的文件,make clean 也会规规矩矩地执行清理命令。

除 clean 外,all、install、test、output、build 这类"动作型"目标都习惯性要挂牌进 .PHONY。给它们的统一原则是:只要它不该受"同名文件时间戳"支配,就声明成伪目标,也等于给读代码的人一个明确的信号"这是个动作入口,不是个文件"。

顺便,clean 里 rm -f 的 -f(force)是怕某些 .o 还没生成就执行删除时报 "No such file" 的错——-f 让它不存在的文件也安静跳过,不报错不中断。这也是共用清理命令时的好习惯。

临时文件与目标分离:addprefix 与独立目录

把工程跑通之后,一个"文明"的 Makefile 必须面对的现实就是:编译会攒下一大堆 .o(可能上百个),外加最外层的那个可执行文件,全和 .c、.h 源码挤在同一个目录里,乱成一片。课件里提出了很好的诉求:能不能在 make 时,让 .o 和可执行程序各自落到指定目录,这样将来清理时只需删目录,临时文件就全没了?

能。我们要用到一个新函数 addprefix,以及一个构造式:addprefix 前缀, 一份空格分隔的名字列表,它的作用是给列表里的每一项都统一加上同一个前缀,返回拼好的新列表。它特别适合用来做"路径拼接"。

比如:

# 给列表 bin 这个可执行名 + "project" 加上前缀,得到 "bin/project"
BIN := $(addprefix bin/, project)
# 给 objs/main.o objs/util.o 这类默认列表加前缀,得到带目录的完整路径

把它用到生成目录的场景。我们把上一节的单目录工程升级一下:.o 全都放进 objs/ 子目录,可执行程序放到 bin/,源文件继续留在原地。看这个"目录分离"版 Makefile:

# 引入编译器和若干常用命令(RM 是 Make 内置变量,MKDIR 是我们自建来命名 mkdir 的可改写变量)
CC = gcc
RM = rm
RM_FLAGS = -rf
MKDIR = mkdir
# 编译选项:-c 只编译不链接
FLAGS = -c
 
# 收集当前目录下所有 .c 作为源文件名单
SRCS = $(wildcard *.c)
# 把名单整体换成 .o(先不带到 objs 里,稍后统一加前缀)
OBJS = $(SRCS:.c=.o)
 
# 目标可执行程序的名字,先叫 project
BIN = project
# 新建目录的名字
OBJS_DIR = objs
BIN_DIR = bin
 
# ---- 关键:这里必须用 :=,否则 Makefile 会出现"递归引用"问题 ----
# 给可执行文件名加目录前缀,变成 bin/project
BIN := $(addprefix $(BIN_DIR)/, $(BIN))
# 给所有 .o 加目录前缀,变成 objs/util.o、objs/calc.o ...
OBJS := $(addprefix $(OBJS_DIR)/, $(OBJS))
 
# 需要新建的文件夹列表
DIR_LIST = $(OBJS_DIR) $(BIN_DIR)
 
# 默认目标 all:依赖"两个目录 + 可执行程序"
# 先造好目录,才能往里面放东西
all: $(DIR_LIST) $(BIN)
 
# 模式化地建目录:对 DIR_LIST 里的每一项,去 mkdir 建出它
$(DIR_LIST):
	@$(MKDIR) $@
 
# 链接所有 .o 成可执行程序;$^ 是所有依赖(objs 下的一串 .o),$@ 是 bin/project
$(BIN): $(OBJS)
	@echo "Link *.o to $(BIN)"
	@$(CC) $^ -o $@
 
# 模式规则升级:目标是 objs/内的某个 .o。这里 % 匹配"objs/ 之后、.o 之前"那段
# 注意写成 $(OBJS_DIR)/%.o:%.c,表示 X.o(在 objs/ 下)依赖同目录取名的 X.c(在源码根下)
$(OBJS_DIR)/%.o: %.c
	@echo "compling ... $<"
	@$(CC) $(FLAGS) $< -o $@
 
# clean 只删两个目录,临时文件随之全清,十分干脆
.PHONY: clean
clean:
	@$(RM) $(RM_FLAGS) $(DIR_LIST)

跑起来的输出会是(objs/、bin/ 两个目录先被创建,再逐文件编译,最后链接):

$ make
mkdir bin
mkdir objs
compling ... util.c
compling ... calc.c
compling ... main.c
Link *.o to bin/project
$ tree objs bin
objs
├── calc.o
├── main.o
└── util.o
bin
└── project

这里有几个必须讲透的细节:

第一,为什么 BIN、OBJS 两次赋值中间必须用 :=? 我们第一次 OBJS = $(SRCS:.c=.o) 用的是 =,第二次 OBJS := $(addprefix ...) 用的是 :=。假如把第二次也写成 =,那么"第二次的 $(OBJS)"里引用的还是旧的 OBJS 自己——由于 = 是推迟求值的,第二次赋值读 $(OBJS) 时它取的是当前(尚未更新的)值,然后在展开时又把这"当前值"拿去和别的东西拼,很容易叠出歧义甚至循环引用。用 := 则"当下结算",干净利落地把旧名单换成带目录的新名单。这在"变量基于自身结果再做加工"(自引用加工)时是硬性要求——凡是要"拿变量当前值加工出新值再写回去"的场合,一律用 :=。

第二,$(OBJS_DIR)/%.o: %.c 这条模式规则怎么理解? 这里 % 匹配的是"目标里、objs/ 前缀之后、.o 结尾之前的那段"(不含 /,所以 % 不能跨目录)。比如目标 objs/util.o,% 匹配 util,规则就装配成 objs/util.o : util.c。依赖落在源码根下,目标却藏在 objs/ 里——源文件和产物就此分家。

顺带一提,Make 内置了记忆工具:这样构造的次要产物(.o 名单)是"隐含中间文件",Make 在推导最终 all 时可能会自动帮你补齐 objs/util.o 这类缺失目标。实际编译时它会自动尝试用这条模式规则去生成,无需你再额外声明,这也正是它能跑通的关键。

第三,为什么 clean 现在如此省心? 因为所有临时文件都集中在 objs/、bin/ 两个目录里,rm -rf 两个目录就等于把全部中间产物一锅端,不会误伤源码。这就是"目录分离"最大的功德——清理的成本低到一个目录两条命令。

到这里你已经掌握五个函数/构造了:wildcard、.c=.o 替换、addprefix、$(dir ...)(还没用上但马上就来)、伪目标 .PHONY。够打一个新的单模块工程了。可一旦工程变成"多模块",我们还需要 foreach 和 shell 这两张王牌。

多模块单可执行:foreach 与 shell

现实里的大工程很少把所有源文件都堆在一个目录,而是按功能拆成一个个模块目录:main/、Module0/、Module1/……课件就演示了由脚本一次性造出 100 个 .c、再按每 10 个分进一个 Module$i 目录的自动化过程。在多模块布局下,我们没法再用一句 wildcard *.c 收齐,因为源文件"散"在多个目录里了。

我们需要两个新工具:

  • $(wildcard 目录/*.c):在指定目录里收集 .c。它可以带上目录前缀。改一下参数就能对某个模块目录下手。
  • $(foreach 变量, 列表, 表达式):Make 的"遍历函数"。它对 列表里的每一项,依次把这个项赋给 变量,然后对每项都要去求一次(展开)表达式,最后把所有展开结果用空格拼起来一并返回。这相当于一个小循环,非常擅长"对多个模块目录,每个都去 wildcard 一次并汇总"。

把它们俩组合起来,配合模式规则,就能一个 Makefile 管住整棵模块树。我们先把目录准备一下:沿用 main.c、util.c、calc.c,把它们分别送进模块目录(头文件也跟着走),再给每个目录配一个纯占位的 main.c 以便单独链接也行;但为了和课件"每个模块是一个独立编译单元"的精神对齐,这里我们让每个模块目录里放自己的源码,main/ 里放真正的入口。

为演示 foreach 与 shell,请在工程下这样做(假定当前有三个模块目录:main/、Module0/、Module1/,并让每个模块目录里各有自己的源文件与同名头文件):

mkdir -p main Module0 Module1
# 把 util.c/util.h 送进 Module0,calc.c/calc.h 送进 Module1,main.c 送进 main
mv util.c util.h Module0
mv calc.c calc.h Module1
mv main.c main

然后写这个"多模块单可执行"的 Makefile。它要用到 $(shell ...)(让 Make 去执行一条 shell 命令、并取其输出)来动态发现有哪些模块目录:

# Modules:先手写 main 这个固定模块
MODULES := main
# 再在命令行上补齐空位:$(shell ls -d Module*) 调 shell 列出所有 Module* 目录
# ls 输出以空格隔开,正好粘进变量;因为用 :=,此处立刻得到模块目录名单
MODULES += $(shell ls -d Module*)
 
# 目标可执行文件名字,以及清理命令
BIN := project
RMF := rm -f
 
# 核心一行:对 MODULES 的每一项都去对应目录 wildcard 收 .c,再全部拼接
# 翻译:SRCS = "main里的.c" + "Module0里的.c" + "Module1里的.c" ...
SRCS := $(foreach module, $(MODULES), $(wildcard $(module)/*.c))
# 把所有 .c 整体换 .o(这一版产物与源码同目录,后续再分离)
OBJS := $(SRCS:.c=.o)
 
# 编译器
CC = gcc
 
# 链接:把所有模块的 .o 打包成一个可执行程序
$(BIN): $(OBJS)
	@$(CC) $^ -o $@
	@echo "链接 $^ 形成 $@"
 
# 模式规则:任一模块目下的 X.o 依赖同目录的 X.c,编译后落到同一目录
%.o: %.c
	@$(CC) -c $< -o $@
	@echo "编译 $< 形成 $@"
 
# 清理:删掉全部模块里的 .o 和最终可执行
.PHONY: clean
clean:
	@$(RMF) $(OBJS) $(BIN)
	@echo "清理 $(OBJS) $(BIN)"
 
# 一个诊断目标:把内部各份名单打印出来,方便观察 foreach 收集到了什么
.PHONY: test
test:
	@echo $(MODULES)
	@echo "-----------------------"
	@echo $(SRCS)
	@echo "-----------------------"
	@echo $(OBJS)

敲 make test 你会看到 foreach 的三份名单;敲 make 则逐个模块编译、再链接:

$ make test
main Module0 Module1
-----------------------
main/main.c Module0/util.c Module1/calc.c
-----------------------
main/main.o Module0/util.o Module1/calc.o

$ make
编译 main/main.c 形成 main/main.o
编译 Module0/util.c 形成 Module0/util.o
编译 Module1/calc.c 形成 Module1/calc.o
链接 main/main.o Module0/util.o Module1/calc.o 形成 project
$ ./project
max = 9
add = 7 sub = -1

注意它是怎么做到"动态发现模块"的:MODULES += $(shell ls -d Module*) 这一行是让 shell 去磁盘上看一眼到底有哪些 Module* 目录,再把它们的名字塞回来。将来你 mkdir Module2 加一个模块,不需要碰 Makefile——下次 make 它自己就知道了。这就是"Makefile 认识工程"的具象化。

顺带讲个容易读懵的坑:如果哪一天你在 Makefile 里看到 $$module(两个 $)这种写法,它的意思是"这里 $$ 代表一个字面 $,也就是 Make 先把它转成一个 $ 交给 shell,shell 再去展开自己的变量 $module"。因为 Make 自己把单个 $ 视为变量引用符号,你想要"shell 的变量",就得用 $$ 来逃逸。课件里"递归 make"那节会用 $$module,正是这个道理,我们下面就会见到它本人。

思考题四

如果我不想用 shell 去 ls -d,而是希望 MODULES 里恰好只有 main、Module0、Module1 三个,能不能直接 MODULES := main Module0 Module1,然后完全去掉 += $(shell ...) 那行?这样有什么得与失?

详解:完全可以。把 MODULES := main Module0 Module1 写死,等价于"我在 Makefile 里手工开列模块名单",foreach 照常工作,SRCS 仍能收齐三个目录的全部 .c。收益是:不依赖 shell、不依赖磁盘上是不是真有这些目录、构建更可重现;代价是:每新增一个模块要手工往名单里补一个字,不再"自动感知"。这就引出工程设计里的取舍:名单写死(:= 硬编码)与动态探测(shell/wildcard)都正确,一个偏稳定可复制,一个偏省心自适应。实务上小工程直接列出来最直白,大工程为了"加模块即生效"多爱用动态探测;两者也常结合(固定 main + += $(shell ls -d Module*) 正是课本的混合姿势)。

多模块文件大分离:patsubst 与目录重建

课件在场景 4 里干了一件"漂亮活":不但 make 时把每个模块的 .o 编译进 objs/ 子目录,而且连 objs/ 内部都保持和源码一致的多级目录结构——objs/main/main.o、objs/Module0/util.o……这样"临时文件全部集中在 objs 一棵树下,且路径和源码一一对应,改起来好找、删起来好删"。

要实现"把 main/util.c 换成 objs/main/util.o",前面学的 .c=.o 替换就不够灵活了——它只会简单换最外层后缀,不带"前置 objs/ 前缀"。请出第二个重器函数 patsubst(pattern substitute,按模式替换):

写法:$(patsubst 模式, 替换串, 文本)。它把 文本(通常是一份空格分隔的文件名单)里每个匹配 模式 的项,按 替换串 里的 % 对应关系做替换。这里 % 在模式与替换串中都是"匹配到的那段干名"的占位符。

举几个例子看清它的脾气:

  • $(patsubst %.c, objs/%.o, main/util.c) → objs/main/util.o。% 匹配 main/util,把它搬进 objs/%.o。
  • $(patsubst %.c, %.o, $(SRCS)) 和 $(SRCS:.c=.o) 基本等价——前者就是后者的"正式做法"。
  • 而课件里真正要用的,就是那句 OBJS := $(patsubst %.c, $(OBJS_DIR)/%.o, $(SRCS)):它把每个 模块目录/名字.c 换成 objs/模块目录/名字.o,达成"前缀 + 保持模块路径"两个目标一起搞定。

和 patsubst 并肩作战的还有 $(dir 路径) 函数:它提取 路径 的"目录部分"(一直提取到最后一个 / 为止,含末尾的 /)。比如 $(dir objs/Module0/util.o) 得 objs/Module0/。它的作用就是在命令里"目标所在的目录",好让我们先 mkdir -p 把它建出来——因为 objs/Module0/ 这种二级目录在首次编译时还不存在,编译命令先在对应目录写 .o 会失败。

把这些拼成一个漂亮的"多模块 · 多级 objs 目录" Makefile:

# 模块名单:先固定 main 这个模块 + 动态发现的 Module*
MODULES := main
MODULES += $(shell ls -d Module*)
 
# 可执行名与常用工具
BIN := project
RMF := rm -f
MKDIR := mkdir
 
# 收集所有模块目录下的 .c
SRCS := $(foreach module, $(MODULES), $(wildcard $(module)/*.c))
 
# 中间产物统一目录
OBJS_DIR := objs
 
# 用 patsubst 把每个 模块/X.c 换成 objs/模块/X.o(前置 objs 且保留模块子路径)
OBJS := $(patsubst %.c, $(OBJS_DIR)/%.o, $(SRCS))
 
# 编译器
CC := gcc
 
# 链接
$(BIN): $(OBJS)
	@$(CC) $^ -o $@
	@echo "链接 $^ 形成 $@"
 
# 模式规则:目标 objs/模块/X.o 由源码 模块/X.c 编译而来
# $(dir $@) 拿到 objs/模块/ 这一层,先生成目录,再编译进去
$(OBJS_DIR)/%.o: %.c
	@$(MKDIR) -p $(dir $@)
	@$(CC) -c $< -o $@
	@echo "编译 $< 形成 $@"
 
# 清理:删全部 .o 与可执行(目录留着还是删掉随你,这里保留目录更直观)
.PHONY: clean
clean:
	@$(RMF) $(OBJS) $(BIN)
	@echo "清理 $(OBJS) $(BIN)"
 
# 诊断目标
.PHONY: test
test:
	@echo $(MODULES)
	@echo "-----------------------"
	@echo $(SRCS)
	@echo "-----------------------"
	@echo $(OBJS)

跑 make 的效果(注意 objs/ 下出现了和源码同构的多级目录):

$ make
编译 main/main.c 形成 objs/main/main.o
编译 Module0/util.c 形成 objs/Module0/util.o
编译 Module1/calc.c 形成 objs/Module1/calc.o
链接 objs/main/main.o objs/Module0/util.o objs/Module1/calc.o 形成 project
$ tree objs
objs
├── Module0
│   ├── calc.o
│   ├── main.o
│   └── util.o
├── Module1
│   ├── calc.o
│   ├── main.o
│   └── util.o
└── main
    └── main.o

上面 $(OBJS_DIR)/%.o: %.c 这条模式规则,自动化了"目标重建目录树"这件事:$(MKDIR) -p $(dir $@) 里的 -p(parents)会让 mkdir 一次性把 objs/main/ 这种多级目录连带建出来,且目录已存在时也不会报错(静默通过)——这正是"第一次编译、目录还不存在"时能成功的保证。缺了 -p,第二次编译时 mkdir objs/main/ 遇到已存在的目录会报错中断。

它的缺点也要心里有数:源文件 .c 与产物 .o 不再在同一目录,一旦你想"改完代码只看 .s/.o 有没有产生"就不如先前直观;不过换来的是清理的极致优雅——rm -rf objs bin 一条命令,整个工程恢复出厂。

思考题五

为什么这个 Makefile 里,OBJS 用的是 patsubst %.c -> objs/%.o,而不是先 $(SRCS:.c=.o) 再 $(addprefix objs/, ...)?两者结果一样吗?

详解:在"每个模块目录都自带子路径、且名单里全是 .c"这个前提下,两者最终结果恰好一致。先 OBJS2 := $(SRCS:.c=.o),得到 main/main.o Module0/util.o Module1/calc.o(此时还没有 objs/ 前缀);再执行 $(addprefix objs/, $(OBJS2)),addprefix 会机械地在每一项最前面贴一个 objs/,得到 objs/main/main.o objs/Module0/util.o objs/Module1/calc.o——正好等于 patsubst 的结果。那差别在哪?差别在语义边界:addprefix 只会不加辨别地机械加前缀,名单里哪怕混进一个不是 .c 的名字(比如某个 foo.bar)也会被强行贴上 objs/;而 patsubst 是"模式匹配",只替换匹配 %.c 的项,不匹配的项原样保留。所以你若是想"只给源文件派生出的 .o 加前缀、其它杂名一律不动",非用 patsubst 不可——这也正是课件那句 $(patsubst %.c, $(OBJS_DIR)/%.o, $(SRCS)) 比"换后缀再加前缀"更严谨到位的原因。结论:本场景下两种写法结果一致,但 patsubst 语义更干净、天然只动 .c,拼路径时一律优先它。

引入头文件:addprefix -I 与头文件搜索路径

多模块工程还有一个绕不开的坎:头文件的 #include "xxx.h" 找不到路径。当 main/main.c 里写 #include "util.h" 而 util.h 却躺在 Module0/ 里时,gcc 默认只在"当前目录(main/)"和系统头目录里找,于是报 util.h:No such file or directory。

解决办法是给编译器程序指定额外的头文件搜索路径:gcc 的 -I目录 选项就是干这个的,-IModule0 -I Module1 会让它在这些目录里也找 .h。现在问题是:一个多模块工程里这种 -I 前缀要追加遍所有模块目录——这活又轮到 addprefix 了:

# 把每个模块名前面都批 -I,得到 "-Imain -IModule0 -IModule1" 这样一串
INCLUDES := $(addprefix -I, $(MODULES))

$(addprefix -I, $(MODULES)) 会把 main Module0 Module1 变成 -Imain -IModule0 -IModule1。注意 -I 和目录名之间没有空格也能被 gcc 识别(gcc -Iinc 等效 gcc -I inc),所以不用额外填空格。

在"多模块文件分离"那一版 Makefile 的基础上,只要加三处改动就齐了:

  1. 增加一行 INCLUDES := $(addprefix -I, $(MODULES));
  2. 编译命令里在 $< 之后带上 $(INCLUDES);
  3. 因为 main/main.c 里 #include "util.h" 要找 Module0/、#include "calc.h" 要找 Module1/,而头都在它们自己的模块目录里,叠加 -I 后编译器就能按图索骥。

把上一节 Makefile 的编译规则升级成这样:

# 定义一个编译产物:头文件搜索路径(每个模块名批量加 -I 前缀)
INCLUDES := $(addprefix -I, $(MODULES))
 
# 模式规则里把 INCLUDES 追加进编译命令
$(OBJS_DIR)/%.o: %.c
	@$(MKDIR) -p $(dir $@)
	@$(CC) -c $< -o $@ $(INCLUDES)
	@echo "编译 $< 形成 $@"

是不是有一种"模块目录这一堆,-I 前缀一起批上"的顺畅感?加上之后,make 终于能平趟整个头文件迷宫,./project 正常打印。

收割一波:内置变量

聊到这里不妨把"内置变量"正式请上台。Make 自带一批名字就约定好的变量,你可以在 Makefile 里直接用,也可以随时重写它们。常用的几颗:

  • CC:C 编译器名字,默认通常为 cc(多数发行版上就是 gcc)。我们一直拿它当"编译器"用。
  • CFLAGS:额外传给 C 编译器的选项。这是行业默认的"编译选项集中营",习惯把所有 -Wall -g -O2 之类都塞这里。
  • CPPFLAGS / LDFLAGS / LDLIBS:分别放"预处理器选项(如 -I、-D)""链接选项""要链接的库"。规整的工程会用 $(CFLAGS)、$(LDFLAGS) 把这些变量名引到具体命令里,而不是把 -I、-l 直接撒得到处是。
  • RM:删除命令,默认 rm -f。课件里故意定义 RMF := rm -f 也是给"清理命令"一个名。
  • MKDIR:本身不是标准内置,但工程里常见"用变量给 mkdir 命名",和 RM 一个套路,方便集中管理命令。

make -p 这个开关会把 Make 的全部内置与隐含变量dump 一大堆出来(make -p | less 更友好),是查"这个变量默认值是多少"的权威来源。你只要知道了"这些约定俗成的名字用于什么",读别人工程时就不会一头雾水;不想用也完全可以,写死 gcc 也是一样跑。

思考题六

头文件搜索到底靠什么?为什么 #include "util.h"(双引号)找不到,而 #include <stdio.h>(尖括号)却能找到?

详解:两者的搜索路径规则不同。#include "..."(双引号)先从这条 .c 文件所在目录(更准确说是"包含这条语句的源文件所在目录")找,然后才轮到 -I 指定的目录、再到系统头目录;所以默认情况下 main/main.c 里 #include "util.h" 只在 main/ 找,找不到就报错。而 #include <...>(尖括号)直接从 -I 目录和系统头目录搜,不先看源文件身边目录。这就是为什么多模块工程中写带引号的 #include 相对头常常"找不到"、必须靠 -IInclude 把对应目录补进去指路。一旦你加了 INCLUDES := $(addprefix -I, $(MODULES)),相当于告诉 gcc"这些模块目录也统统搜一遍",#include "util.h" 就能在 Module0/ 命中。顺带一个小坑:头文件用了 #pragma once(或 #ifndef 防护)是为了同一个 .h 被多个 .c 重复包含时不至于重复定义,这与我们指路的 -I 是两回事,别混。

静态模式规则:让部分目标套用同一条针对性的规则

讲完模式规则 %.o:%.c,还有一位更"精准"的亲戚——静态模式规则(static pattern rules)。它解决的是这样一个问题:不是所有的目标都能用一条模式 %.o:%.c 一刀切,也许我只想让某一份明确列出的依赖名单套用"前缀替换"的规则,其它目标各自为政。

静态模式规则的语法是老三样,只是中间多了一层"模式对":

目标列表: 目标模式: 依赖模式
	命令

它读作:对 目标列表 里列出的每一个目标,先把它套进 目标模式(借以提取 % 匹配的干名),再把干名套进 依赖模式 拼出它的依赖。

一个经典例子:想让 main.o、util.o、calc.o 这三个目标通通"按 %.o 找 %.c 依赖、用同一套命令编译",但不想让规则波及名单之外的任何文件,就可以写:

OBJS := main.o util.o calc.o
 
$(OBJS): %.o: %.c
	gcc -c $< -o $@

对照一下它和 %.o: %.c 的区别:普通模式规则是"适配方目标族",任何未来出现的 X.o 都可能命中它;静态模式规则是"精确点名 + 批量套公式",只对 OBJS 里白纸黑字列出的目标生效,规则范围被收得更紧——适合"我明明已经精确知道要编哪些,只是懒得多抄命令"的场景。它既给你模式规则的"复用命令"方便,又给你显式名单的"把控范围"安全。

实务上,静态模式规则和前面的场景是绝配。比如课件那种场景,把"多模块分离"里那句泛化的隐式规则,换成静态式,意图往往更清楚(下面是信手改写的示意,把名单固定下来):

SRCS := $(wildcard *.c)
OBJS := $(SRCS:.c=.o)
 
# 显式点名 OBJS,全部套用 objs/ 下的静态模式规则编译
$(OBJS): %.o: %.c
	gcc -c $< -o $@

正因为静态模式规则要"点名",它牺牲了"自动适应新增文件"的柔性——新加源文件如果你没把它塞进 OBJS,它就不会被编。多数工程更爱自由的普通模式规则;只有在"明确要锁定的那批目标、一步都不能错"时,静态式才是最优解。把它和普通模式规则放一起看,你会更清楚"通配"与"点名"各自的滋味,也就更理解为什么课程大纲里要单独列它一项。

条件与函数化:让 Makefile 会根据参数的反复权衡

到这一步,你可能望着属性众多的 Makefile 出神:要是不同环境、不同参数下,我想让"用它一套配置、否则用另一套",该怎么做?所谓条件与函数化,正是让 Makefile 具备"根据不同条件走不同分支"的能力——和编程语言里的 if 一脉相承。

Make 提供的条件指令长这样(四个一组,用于不同粒度):

ifeq (a, b)        # 如果 a 等于 b
    …这些行生效…
else               # 否则
    …这些行生效…
endif

和 if 平起平坐的兄弟还有 ifdef 变量名(该变量"有定义"即真)、ifndef 变量名(未定义即真)、ifneq (a, b)(不相等即真)。这套指令是读取 Makefile 文本时的预处理开关——早在你真正执行任何一条命令之前,Make 就已经读完全文、按条件选中了该保留的行,落选的整段直接就"不存在"。

实战味十足的搭配是"按编译器分支"或"按是否 debug 分支"。比如:

CC := gcc
# 默认编译选项
CFLAGS := -Wall -Wextra
 
# 如果定义了 DEBUG(make DEBUG=1),就加 -g 并禁止优化,方便调试
ifeq ($(DEBUG),1)
	CFLAGS += -g -O0
else
	# 否则按发布习惯开优化
	CFLAGS += -O2
endif
 
# 头部多给一个"系统定义宏"演示 ifdef
ifeq ($(CC),clang)
	CFLAGS += -Werror
endif

于是同一份 Makefile,你 make 得发布版参数,make DEBUG=1 又在命令行上覆盖 DEBUG 得到调试版参数——条件让它"见风使舵"。

这里有一个必须点破的坑:命令行给变量赋值(make VAR=值)优先级最高。命令行的赋值会覆盖 Makefile 里 =/:= 写的初值,除非你在 Makefile 里用了 override 指令 去强行盖住它。所以课件里那种"稍后重构"的写法是正常的:你可以在 Makefile 里给 CC、CFLAGS 设默认值,而运行构建的人总能 make CC=clang CFLAGS=-O3 临时覆写,这正是 Makefile 参数化的魅力。如果你不想让别人覆盖某个关键变量,就用 override CC := gcc。

"函数化"这一词还有一个角度:把反复用到的拼接/处理逻辑做成变量,一次写、处处引用。前面所有 OBJS := $(patsubst ...)、INCLUDES := $(addprefix -I, $(MODULES)) 其实就是这种思路的落地——把一个"加工逻辑的内存"变成一块可随手用的砖。函数(我们已见识过 wildcard、addprefix、patsubst、foreach)和条件(ifeq 等)合在一起,Makefile 就不再是四处贴命令的文本,而是一份能权衡、能复用、能自适应的"构建程序"。

思考题七

下面这段用了 ifdef,请问 make DEBUG=1 all 与 make all 各自会不会打印 DEBUG已定义?

ifdef DEBUG
	define MSG
DEBUG已定义
	endef
else
	MSG := 未定义
endif
all:
	@echo 分支选择结果: $(MSG)

详解:make DEBUG=1 all 时,DEBUG 由命令行传入了值(等于已定义),所以 ifdef DEBUG 为真,MSG 进入"已定义"分支,打印 分支选择结果: DEBUG已定义。make all 时命令行没给 DEBUG,Makefile 里也没定义 DEBUG,ifdef 判假,走 else 分支,MSG := 未定义,打印 分支选择结果: 未定义。这里顺带演示了 define/endef 定义多行变量(MSG 占多行,中间含空行和中文文本),以及 $(MSG) 在 echo 里顺利展开——多行变量以其文本参与。

递归 make:多 Makefile、多可执行、子目录各自为政

工程再上一台阶,就会遇到"多个可执行程序、每个模块还想独立编译"的场景。课件场景 6 的姿势很优雅:每一个模块目录放它自己的 Makefile,顶层再放一个"总指挥" Makefile,让 make 递归地钻进各子目录去派活。这就是递归 make(recursive make):一个 Makefile 的命令里再次调用 make(通常是 cd 子目录 && make 或 make -C 子目录)。

先看每个模块目录里的"小 Makefile"。课件用一个 subMakefile 做模板,再用 sed 把 project 批量替换成 project0、project1……形成各模块独立的 Makefile。模板长这样(此行若想照抄,注意每个模块的 BIN 换成自己的可执行名):

# 模块内 Makefile 模板(每个子目录各有一份,可执行的 BIN 各自不同)
BIN := project0          # 换成 project1 / project2 ...
CC := gcc
# 收集本目录内所有 .c(注意:这里是模块目录内,而非整个工程根)
SRCS := $(wildcard *.c)
# 后缀替换得到本目录的 .o 名单
OBJS := $(SRCS:.c=.o)
 
# 链接本模块的所有 .o 到 BIN;$^ 是所有源对象,$@ 是 BIN
$(BIN): $(OBJS)
	$(CC) -o $@ $^
 
# 模式规则:本目录的 X.o 依赖同目录 X.c
%.o: %.c
	$(CC) -c $<
 
# 清理本目录产物
.PHONY: clean
clean:
	rm -f *.o $(BIN)
 
# output:把编好的可执行挪到上一层的 bin/ 统一收留
.PHONY: output
output:
	mkdir -p ../bin
	mv $(BIN) ../bin

注意这里 $(CC) -c $< 没写 -o $@——gcc 对 -c file.c 的默认输出恰好就是 file.o(同目录同名换后缀),所以省事可行。output 那两行则体现"发布":把编好的可执行程序挪到上一级统一的 bin/ 目录。

然后是最上层的"总指挥" Makefile(就在所有模块目录的上一级):

# 动态发现所有模块目录(Module*)
MODULES += $(shell ls -d Module*)
 
# build:总指挥,逐模块递归进入、各自执行 make,任一失败则中断整个构建
.PHONY: build
build:
	@for m in $(MODULES); do \
		(cd $$m && make) || exit 1; \
		echo "build $$m ... done"; \
	done
 
# clean:同样递归进每个模块执行 make clean
.PHONY: clean
clean:
	@for m in $(MODULES); do \
		(cd $$m && make clean); \
		echo "clean $$m ... done"; \
	done
 
# output:递归进每个模块执行 make output,把可执行统一收集到 bin/
.PHONY: output
output:
	@for m in $(MODULES); do \
		(cd $$m && make output); \
	done

这段代码有一个关键点值得说三遍:命令里用到 $$m(两个 $ 的变量),写单 $ 的话 Make 会当成自己的变量去展开,展开不出什么来;写成 $$m 后,Make 把 $$ 转成字面 $,留给 shell,shell 的 for m in ...; do ...$m... 才能正常拿到每个模块名。在 Makefile 的命令行里想用 shell 自己的变量(如循环变量),一律 $$ 逃逸。

而 (cd $$m && make) 这种写法:在子 shell 里先切进该模块目录,再调用 make 干该模块的活;外面套一层圆括号是为了"cd 只作用于这个子 shell,不污染父 Makefile 所在目录"。|| exit 1 让任一模块失败就立刻中断(返回非零),从而整个工程"有一个挂了就叫停"。递归进入时,Make 会用缩进和 make[n] 的标记(如 make[1]: Entering directory ...)来提示"我正在哪个层级干活"——你看编译输出,凡是冒号前面顶着 make[1] 的,就是它"潜到子模块里去执行的那一次"。

在顶层敲 make build,输出大致如下(留意子模块的进出提示):

$ make build
make[1]: Entering directory `…/Module0'
gcc -c util.c
gcc -c main.c
gcc -o project0 util.o main.o
make[1]: Leaving directory `…/Module0'
build Module0 ... done
make[1]: Entering directory `…/Module1'
gcc -c calc.c
…

再 make && make output(先生成再发布),再 ls bin 就能看到一堆 project0 project1 … 全被收到 bin/ 之下。这套"顶层挂帅 + 每个模块自带 Makefile + 递归派活"的组织方式,正是很多中大型工程管理多目标、多模块的标准套路——既让每个模块能被单独构建/清理/发布,又不失整体的统一编排。

思考题八

为什么顶层 build 用了 (cd $$m && make),而不是直接写 $(MAKE) -C $$m?两者结果一样吗?如果 Module0/Makefile 里的 $(BIN) 写的是 project 而不是 project0,会发生什么?

详解:多数情况下两者等价——make -C 目录 就是"先 cd 进目录再调 make"的整洁等价形式,$(MAKE) 是 Make 提供的"当前 make 命令"变量(用它比硬写 make 更稳,能保留 -n、-j 等运行时参数)。差别主要在维护性与可读性:(cd 目录 && make) 更显式、和 shell 的写法一致,便于嵌入 || exit 1、echo 这类周边逻辑;$(MAKE) -C 目录 更简洁,是官方更推荐的一行式。第二问是关键:若某模块 BIN := project(和别的模块撞名),多模块彼此 make clean 时可能互相清掉对方的可执行;若每个模块都把可执行命名成 project,发布时 mv $(BIN) ../bin 还会互相覆盖,最后 bin/ 里只剩最后一个模块的。这正是课件用 sed "s/project/project$i/g" 批量生成 project0、project1 的用意——通过给每个模块的可执行名加一个不重复的下标,避免跨模块放炮与覆盖。 所以在递归 make 的工程里,"可执行/产物命名全局唯一"是一项非常重要的纪律。

边界与坑总账:那些让你 make 报错的细节

学到这里,主菜都上了。为了让你上手不被气到,最后把这节课一路埋下的、以及 Makefile 历史级的坑打包成"总账"——每一条都可能让 make 报出意义不明的错误,或让你的 Makefile 悄悄表现不对。逐条过一遍,你的防线就齐了。

坑一:行首必须是 Tab,不是空格。 这是 Make 世界第一条铁律。规则下面的命令,第一列必须是真实 Tab 字符。很多人从网页复制代码,Tab 被编辑器"温柔地"转成了 4 个空格,于是 make 报 缺失分隔符(missing separator) 之类。你要么手敲 Tab,要么在脚本里统一处理。Vim 之类编辑器里检查一下 set list 让 Tab 显形,是最快的自检法。

坑二:变量引用必须带括号或花括号 $(var) / ${var}。 单字符自动变量可以裸写(如 $@、$<),但普通变量名一裸写就解析不了——尤其注意 $(var) 与 ${var} 等价,可别写成 $var。写了 $var 会被当成 $(v)ar(只取变量名首字母),结果几乎必定错。这是"天天写、天天踩"的经典。

坑三:通配符展开时机。 裸 * 在变量赋值里不自动展开——你写 OBJS = *.o,得到的是字面字符串 *.o,不是文件名列表。要么用 $(wildcard *.o),要么把裸通配符放在依赖列表那种 Make 能自动展开的位置。搞清楚"在哪些上下文通配符会扩展开、哪些不会",是所有 Makefile 高手的必修课。

坑四:= 与 := 的求值时机。 这一整节课的重点。"等文件里所有赋值都跑完才结算的那个值"(=)和"当下就定死的那个值"(:=)完全可能不同。凡自引用加工(拿自身当前值再改)必用 :=,否则轻则算错、重则循环引用。课件那句"这里必须 :="说的正是这一个坑。

坑五:Makefile 里想用 shell 变量,写 $$ 不是 $。 递归 make 那段已经充分演示:单 $ 被 Make 吃掉,双 $ 才通向 shell。for m in ...; do ... $$m ... 就是标准搬运法。

坑六:同名文件会骗过"伪目标"。 不标 .PHONY 的 clean 之类,一旦磁盘上恰好有同名文件,make clean 会以为"它已最新"而跳过。凡是"只动作、不产文件"的目标,记得进 .PHONY。

坑七:命令里忘写 $<、$@ 而写死文件名。 模式规则靠自动变量"跟着当前文件走"。你把编译命令写成 gcc -c main.c -o main.o 钉死在一条模式下,那么其它 .o 都只会重复编译 main.c。模式规则下的命令必须用自动变量指向"当前这份"文件。

坑八:多模块的头文件找不到,是 -I 没加。 散落在别目录的头文件要靠 -I目录(以及 addprefix 批量生成)指路,否则 #include "xxx.h" 报 No such file。

坑九:目录层次没建,先在子目录写 .o 会失败。 用 mkdir -p $(dir $@)(-p 兼有连建与不报错改源目录的双重好处)在命令里先把目标目录造出来。

坑十:递归 make 的可执行命名不唯一。 多个子模块若都叫 project,会互相覆盖、发布时踩踏。用下标(project$i)或更语义化的模块名保证全局唯一。

坑十一:差一个括号或逗号就报 Parse error。 $(函数名 a, b) 的逗号缺一不可;ifeq (a,b) 里逗号、括号、空格也有讲究(ifeq (a, b) 与 ifeq (a,b) 语义有别,注意括号内别多加意外空格)。这类"微瑕"防不胜防,make 的报错行号是最好的导航。

把这十一条当"入坑防弹衣"背下来——它们几乎覆盖了初学者在进阶 Makefile 上能遇到的所有低级挫败。遇到怪错,先对号入座查一遍,八成能当场解决。

综合实战与自测

我们把全篇的知识点最终串成一份"通用 Makefile":兼容多模块、多级 objs/、自动发现模块、带 -I 头文件路径、参数化条件可选 debug,还带了 clean/test 伪目标。你可以(在你那些 main/ Module0/ Module1/ 目录还在的前提下)把下面这份直接存成顶层 Makefile 试用:

# 通用 Makefile —— 多模块 / 产物与源码分离 / 自动发现模块 / 可选 debug
# 1. 模块名单:固定 main + 用 shell 动态发现 Module*
MODULES := main
MODULES += $(shell ls -d Module* 2>/dev/null)
 
# 2. 产物与可执行命名
BIN := project
OBJS_DIR := objs
BIN_DIR := bin
 
# 3. 通配收集所有源文件,patsubst 转成 objs/下同构路径的 .o
SRCS := $(foreach m, $(MODULES), $(wildcard $(m)/*.c))
OBJS := $(patsubst %.c, $(OBJS_DIR)/%.o, $(SRCS))
 
# 4. 头文件搜索路径:每个模块名批量加 -I
INCLUDES := $(addprefix -I, $(MODULES))
 
# 5. 编译工具与默认选项;命令行可用 make DEBUG=1 覆盖
CC := gcc
CFLAGS := -Wall -Wextra
ifeq ($(DEBUG),1)
	CFLAGS += -g -O0
else
	CFLAGS += -O2
endif
 
# 6. 总目标:依赖产物目录 + 可执行;产物目录作为成型前置
.PHONY: all clean test
all: $(OBJS_DIR) $(BIN)
 
$(OBJS_DIR):
	mkdir -p $(BIN_DIR) $@
 
# 7. 链接(使用自动变量 $^、$@)
$(BIN): $(OBJS)
	$(CC) $(CFLAGS) $^ -o $@
	@echo "链接完成 -> $@"
 
# 8. 模式规则:objs/<模块>/<名>.o 依赖 <模块>/<名>.c,先建目录再编译
$(OBJS_DIR)/%.o: %.c
	mkdir -p $(dir $@)
	$(CC) $(CFLAGS) -c $< -o $@ $(INCLUDES)
 
# 9. 清理:把产物目录与可执行一锅端
clean:
	rm -rf $(OBJS_DIR) $(BIN_DIR) $(BIN)
	@echo "清理完成"
 
# 10. 诊断
test:
	@echo "MODULES : $(MODULES)"
	@echo "SRCS    : $(SRCS)"
	@echo "OBJS    : $(OBJS)"
	@echo "INCLUDES: $(INCLUDES)"

你可以先 make test 看看四份名单,再 make,接着 touch 一个源文件再 make 验证增量,最后 make clean 全清。这一整套动作,把我们从"手工烂 Makefile"到"自动认识工程的通用 Makefile"的所有知识点都练了一遍。

综合自测(全部带详解)

一、make 默认执行哪一个目标?如果顶层 Makefile 里没有定义 all,而第一个规则是 build:,会发生什么?

详解:默认目标是"Makefile 里第一个非文件名目标(且不以 . 开头、不在 .PHONY/SUFFIXES 等特殊列表里)"。如果你没有 all 而第一个规则是 build:,那么命运会落在 build 上——你直接敲 make 等价于 make build。所以"第一个规则放 all"或在文件顶部写 all: 实际目标,是显式的约定俗成,别指望 Make 自动替你选"正确的那个"。同理,若第一个规则没有声明 .PHONY,它也会成为默认目标。

二、在 %.o: %.c 这条模式规则的命令里,为什么 $< 比 $@ 更适合当"源文件"传进 gcc -c?

详解:模式规则被具体实例化时(比如套到 calc.o 上),$@ 是目标 calc.o,$< 是它的第一个(也是唯一的)依赖 calc.c。gcc -c $< 的意义是"编译这个源文件",目标文件由 -o $@ 指定;若误用 $@(gcc -c $@),命令就会变成 gcc -c calc.o——对"目标已有同名文件"就开始二次编译自身,且 calc.o 不是待编译的源代码,语义完全错乱。所以直觉上记住:$< 常指"要喂给编译器的那份源",$@ 常指"要落盘的那个产物"。

三、把最小的大坑排序:假如你在一台机器上临时克隆别人的 Makefile,打开发现所有命令行都用 4 个空格缩进,第一件事应该做什么?它会导致哪个错误?

详解:第一件事就是把所有空格缩进改回真正的 Tab(或用 sed 's/^ /\t/' 之类把每行的前导 4 空格替换成 Tab)。否则 make 会对命令行报 missing separator(缺失分隔符,DX 上是 *** missing separator. Stop.)。这条错误是"缩进用了空格"的经典哨兵。工具上,make 还支持 -k(尽量多跑、别一错就停)方便一次看到所有类似报错。

四、为什么 SRCS := $(wildcard *.c) 用 := 是安全的,而 SRCS = $(wildcard *.c) 也"看起来差不多"——是否就用 = 也行?

详解:对"只读一遍、不与自身纠缠"的情形,= 与 := 都能工作,SRCS = $(wildcard *.c) 在真正用到 SRCS 时同样会得到 *.c 的文件列表。但用 := 的收益在于"那一刻就固定、与后续无关,且后续对 SRCS 再做 := 加工(比如 OBJS := $(patsubst ...))时取值稳定、绝不递归循环"。在自引用加工场景(OBJS 一类基于自身再改的)上,:= 是唯一不会踩"递归引用"坑的选择——这也是本篇反复强调、课件里特别注明 BIN :=/OBJS := 必须用 := 的根本原因。普通只读名单两者皆可,但统一用 := 的习惯能替你挡掉未来 N 个潜伏 bug。

五、(进阶思考)为什么在"把 .o 放到 objs/ 下"的场景里,$(OBJS_DIR)/%.o: %.c 里的 mkdir -p 放在命令里、由 Make 每次执行命令前自动检查目标目录,而不是直接在 Makefile 顶部造一次目录?

详解:Make 的依赖判断是"目标文件是否比依赖旧"。如果你只在 Makefile 顶部 mkdir -p objs,它只在读文件时执行一次;而目标 objs/main/main.o 的依赖里并没有 objs/main/ 这个目录——换句话说"目录"不是通过依赖关系保证存在的,假如 objs/ 稍后被删掉、.o 却没同步删(或首次构建时目录压根不存在),Make 仍会尝试往不存在的目录里写 .o,一旦失败就报错。把 mkdir -p 写进规则命令、并借助 $(dir $@) 精确指向"当前正要写的那个目录",就能确保"任何情况下,执行这条规则前目录必在"。这体现的是 Make 哲学中的重要一环:规则的命令才是最终真相,别指望依赖关系替你处理"目录这种副作用对象"。


到这里,这一趟 Makefile 进阶之旅就完整走完了。我们从一个痛点出发:手工 Makefile 如何因"数据和逻辑焊在一起"而痛苦;然后学会了用 wildcard 自动收集源文件、用 :=/= 分清楚赋值的轻重缓急、用 %.o:%.c 模式规则加 $@ $^ $< 自动变量让一条规则管住一族文件、用 .PHONY 给"不产文件"的目标立规矩;接着用 addprefix、patsubst、$(dir ...) 把产物从源码里剥离、并把多模块工程收编进统一的 objs/ 目录树;用 foreach 加 shell 动态发现模块、用 addprefix -I 给头文件指路;再谈静态模式规则的"点名"、条件 ifeq 与 define/endef 的可分支性与函数化;最后用递归 make 组织起"多个子 Makefile + 多个可执行"的大工程,并把那十一条坑打包成总账交给你。

把这些环环相扣的概念真正串起来的,其实是这么一句话:Make/build 的本质,是"让构建工具认识你的工程结构,而不是你替构建工具背下每个文件"。 当你能用变量、函数、模式规则让 Makefile 自己从"一堆目录"里看出门道、算出清单、派发任务,你就从"手抄两百行命令的人"成长为了"写几十行让 Makefile 去指挥攻城略地的人"。后者正是所有大型开源项目里,那份薄薄却无比强大的顶层 Makefile 的气魄。

下课之前,我还想叮嘱一句真正的实战要点:Makefile 不仅是"会跑",更是"好改"。你把 CFLAGS、OBJS_DIR、MODULES 这些都设成变量并用 := 定格、把规则写成模式规则和伪目标、把重复逻辑做成函数与条件,将来无论是加一个模块、加一个头路径、还是切一个编译器,都只需要改一行。保持这种"抽象出名字、让人与文件分离"的写作意识,你的任何工程都会越改越轻松。现在,带着这份心法,去让 make 替你干活吧。