3. 第一个Makefile

变量是 Makefile 的灵魂。没有变量,你写的每一行路径、每一个编译器选项都得手敲,改一次就得改几十处。我刚开始用 Makefile 时,就吃过这个亏——一个项目里硬编码了十几处源文件路径,后来加了个模块,改得我头皮发麻。

说白了,变量就是给一段文本起个名字。Make 在解析时会把它展开成实际内容。但问题来了:什么时候展开?展开几次?不同赋值方式的结果完全不同。这一节,咱们就把这四种赋值运算符掰开揉碎讲清楚。

3.1 变量的定义与引用

先看最基本的用法:

# 定义变量
CC = gcc
CFLAGS = -Wall -O2
TARGET = myapp

# 引用变量
$(TARGET): main.o utils.o
    $(CC) $(CFLAGS) -o $@ $^

引用变量用 $() 或 ${},我习惯用圆括号,看着更清爽。注意,变量名区分大小写,CC 和 cc 是两个不同的变量。

小技巧:Make 内置了一些常用变量,比如 $(CC) 默认就是 cc,$(RM) 是 rm -f。你可以覆盖它们,但别滥用。

3.2 四种赋值运算符

这才是重点。四种赋值方式,每种都有自己的“展开时机”。我画了张图帮你理解:

Makefile 四种赋值运算符对比 = 递归展开赋值 定义时只记录文本 使用时才完全展开 可能产生无限递归 := 简单展开赋值 定义时立即展开 后续引用已展开的值 行为更接近传统语言 ?= 条件赋值 仅当变量未定义时才赋值 常用于提供默认值 不覆盖外部传入的值 += 追加赋值 在原有值后追加内容 自动添加空格分隔 展开方式继承原变量 核心区别: = 是“延迟展开”,:= 是“立即展开”,?= 是“保护已有值”,+= 是“追加内容”

3.2.1 =:递归展开赋值

这是 Make 的默认赋值方式。它不会在定义时展开变量,而是等到真正使用时才展开。听起来有点绕?看个例子:

A = hello
B = $(A) world
A = hi

all:
    @echo $(B)

猜猜输出什么?是 hi world。因为 B 在定义时只记住了 $(A) world 这个文本,等到 echo 时才去查 A 的值,而此时 A 已经被改成 hi 了。

踩坑提醒:我曾经在项目里用 = 定义了一个变量,它引用了自身:LIST = $(LIST) new_item。结果 Make 直接报“无限递归”错误。这种自引用用 = 是死路一条。

3.2.2 :=:简单展开赋值

这个就好理解了——定义时立即展开,后续引用的是展开后的值。还是刚才的例子:

A := hello
B := $(A) world
A := hi

all:
    @echo $(B)

输出是 hello world。因为 B 在定义时就把 $(A) 展开成了 hello,后面 A 怎么改都不影响 B。

我个人习惯:能用 := 的地方尽量用 :=。它让变量的值可预测,调试时少了很多“灵异事件”。

3.2.3 ?=:条件赋值

这个运算符的意思是:如果变量还没定义,就给它赋值;如果已经定义了,就跳过。说白了就是“安全默认值”。

CC ?= gcc
CFLAGS ?= -O2

all:
    @echo $(CC) $(CFLAGS)

如果你在命令行传了 make CC=clang,那么 CC 就是 clang,?= 不会覆盖它。这在写通用 Makefile 时特别有用——用户可以通过环境变量或命令行参数覆盖默认值。

实战场景:我在写交叉编译的 Makefile 时,经常用 ?= 定义工具链路径:CROSS_COMPILE ?= arm-linux-gnueabihf-。用户只要 export 自己的路径就能覆盖,不用改 Makefile。

3.2.4 +=:追加赋值

这个最简单——在变量原有值后面追加内容,自动加一个空格分隔。但有个细节要注意:追加的展开方式继承原变量的赋值方式。

CFLAGS := -Wall
CFLAGS += -O2

# 等价于 CFLAGS := -Wall -O2

如果原变量是用 = 定义的,那么 += 追加的内容也是延迟展开;如果原变量是用 := 定义的,追加的内容就是立即展开。嗯,这里要注意,别混用导致预期外的结果。

3.3 四种赋值方式对比表

运算符 名称 展开时机 自引用 推荐场景
= 递归展开 使用时 不允许 需要延迟求值的场景
:= 简单展开 定义时 允许 大多数场景,推荐使用
?= 条件赋值 定义时(仅未定义) 允许 提供默认值,允许外部覆盖
+= 追加赋值 继承原变量 视原变量而定 逐步构建编译选项、源文件列表

3.4 实战中的选择策略

说了这么多理论,到底怎么选?我总结了几条经验:

  1. 默认用 :=:可预测、好调试。除非你有明确的延迟展开需求。
  2. 提供默认值时用 ?=:让用户能通过环境变量或命令行覆盖。
  3. 逐步构建列表时用 +=:比如源文件列表、链接库列表。
  4. 只有明确需要延迟展开时才用 =:比如你想让一个变量根据最终状态决定值。

一个真实案例:我之前维护一个嵌入式项目,Makefile 里有几十个变量。最初全用 =,结果调试时经常搞不清某个变量到底展开成什么。后来我花了一天时间,把所有能用 := 的变量都改成了 :=,编译速度没变,但可读性和可调试性提升了一大截。

3.5 变量引用的高级技巧

除了基本的 $(VAR),还有几个实用技巧:

# 变量替换引用
SRCS = main.c utils.c
OBJS = $(SRCS:.c=.o)

# 等价于 OBJS = main.o utils.o

# 使用 patsubst 函数
OBJS2 = $(patsubst %.c, %.o, $(SRCS))

# 条件赋值与默认值
OUTPUT ?= build/output.bin

变量替换引用在编译规则里特别常用。你想想看,源文件列表和对象文件列表往往只有后缀不同,用 $(SRCS:.c=.o) 一行就搞定了,不用写两遍。

注意:变量替换引用只支持单字符后缀替换。如果需要更复杂的模式匹配,用 patsubst 函数更灵活。