3. 变量与赋值:四种赋值运算符的深度解析

变量是 Makefile 的灵魂。没有变量,你写出来的就是一坨硬编码的路径和命令,改一次要翻遍整个文件。我刚开始学 Makefile 时,就被这四种赋值符搞晕过——=、:=、?=、+=,长得差不多,行为却天差地别。今天咱们就把它们彻底掰扯清楚。

3.1 变量的定义与引用

先看最基本的用法。定义一个变量,然后引用它:

CC = gcc
CFLAGS = -Wall -O2

hello: hello.c
    $(CC) $(CFLAGS) -o hello hello.c

引用变量用 $() 或 ${},我习惯用圆括号,看着更清爽。单字符变量可以省略括号,比如 $@、$<,但多字符必须加括号,否则 Make 会把它当成 $(C) 加一个字母 C,结果就是空值。

踩坑提醒: 我曾经见过有人写 $CFLAGS,Make 解析成 $(C)FLAGS,变量 C 没定义,直接展开成空字符串。这种 bug 很难肉眼发现。

3.2 四种赋值运算符的区别

这四种赋值符的核心区别在于:什么时候展开。说白了,就是 Make 在解析时,是立刻计算右边的值,还是等到使用时再算。

运算符 名称 展开时机 典型场景
= 递归展开赋值 使用时展开 需要延迟求值
:= 简单展开赋值 定义时展开 固定值、避免递归
?= 条件赋值 仅当未定义时赋值 默认值
+= 追加赋值 取决于原变量类型 增量添加

3.3 =:递归展开赋值

这是 Makefile 的默认赋值方式。它的特点是:右值在变量被使用时才展开。你想想看,这意味着什么?

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

all:
    @echo $(B)   # 输出 "hi world"

为什么会这样?因为 B 在定义时并没有把 $(A) 展开成 hello,它只是记下了 $(A) world 这个表达式。等到 echo $(B) 执行时,A 已经被改成 hi 了,所以最终结果是 hi world。

我的经验: 递归展开适合定义那些依赖其他变量的变量。比如 OBJS = $(SRCS:.c=.o),你可以在后面再定义 SRCS,顺序无所谓。但小心别写出循环引用——A = $(B) 和 B = $(A) 会让 Make 无限递归,直接报错。

3.4 :=:简单展开赋值

这个就好理解了:定义时立即展开右边的所有变量。一旦赋值完成,右边的内容就固定死了。

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

all:
    @echo $(B)   # 输出 "hello world"

看到区别了吗?B 在定义时就把 $(A) 展开成了 hello,后面 A 怎么改都影响不到 B。

我个人习惯在绝大多数场景下用 :=。为什么?因为行为可预测,不会出现「变量值被后面代码偷偷改了」这种诡异问题。尤其是大型项目,几十个 Makefile 互相 include,用 := 能省掉很多调试时间。

建议: 如果你不确定用哪个,就用 :=。它更接近常规编程语言的赋值语义,心智负担小。

3.5 ?=:条件赋值

这个运算符的意思是:如果变量还没定义,就赋值;如果已经定义了,就跳过。说白了就是「设置默认值」。

CC ?= gcc
CFLAGS ?= -O2

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

如果你在命令行传入了 make CC=clang,那么 CC ?= gcc 就不会执行,CC 保持为 clang。如果没传,就用 gcc 作为默认值。

我在项目中经常用 ?= 来让用户覆盖编译选项。比如:

INSTALL_DIR ?= /usr/local
DEBUG ?= 0

这样用户可以在命令行指定 make DEBUG=1,而不用改 Makefile 本身。

注意: ?= 只检查变量是否被定义过,不检查值是否为空。如果你之前写了 VAR =(赋空值),VAR ?= value 不会生效,因为 VAR 已经被定义了。

3.6 +=:追加赋值

这个最直观:在变量原有值后面追加内容,中间自动加一个空格。

CFLAGS := -Wall
CFLAGS += -O2
CFLAGS += -DNDEBUG

all:
    @echo $(CFLAGS)   # 输出 "-Wall -O2 -DNDEBUG"

但有个坑:+= 的展开行为取决于原变量是用 = 还是 := 定义的。

  • 如果原变量用 := 定义,+= 追加时右边立即展开。
  • 如果原变量用 = 定义,+= 追加时右边延迟展开。

举个例子:

A = $(B)
A += $(B)
B = hello

all:
    @echo $(A)   # 输出 "hello hello"

因为 A 最初是递归展开的,+= 追加的 $(B) 也是延迟展开,最终两个 $(B) 都展开成 hello。

换成 := 试试:

A := $(B)
A += $(B)
B = hello

all:
    @echo $(A)   # 输出 " hello"

第一次赋值时 B 还没定义,A 是空。第二次追加时,$(B) 立即展开,但 B 仍然没定义,所以追加的还是空。最终 A 只有一个空格。

避坑指南: 我曾经在一个项目里混合使用 = 和 +=,结果变量值总是多出一些莫名其妙的内容。后来全部改成 := 加 +=,问题就消失了。记住:统一用 := 定义基础变量,然后用 += 追加,这样行为最可控。

3.7 四种赋值符的选择策略

说了这么多,到底怎么选?我总结了一个简单的决策流程:

赋值运算符选择决策流程 需要定义变量 是否允许用户覆盖? 是 使用 ?= 否 需要延迟展开? 是 使用 = 否 需要追加已有变量? 是 使用 += 否 使用 := 核心原则:能用 := 就用 :=,需要覆盖用 ?=,需要延迟用 =,追加用 += 统一风格比追求「最优」更重要,团队项目尤其如此

3.8 实战中的常见模式

最后分享几个我在实际项目中常用的变量定义模式:

# 1. 用户可覆盖的配置
INSTALL_PREFIX ?= /usr/local
DEBUG ?= 0

# 2. 立即展开的固定值
CC := gcc
AR := ar

# 3. 依赖其他变量的路径
SRC_DIR := src
BUILD_DIR := build
CFLAGS := -I$(SRC_DIR) -Wall -O2

# 4. 增量追加
CFLAGS += -DDEBUG=$(DEBUG)
LDFLAGS := -lm
LDFLAGS += -lpthread

# 5. 延迟展开(特殊场景)
TARGET = $(BUILD_DIR)/$(PROJECT_NAME)
PROJECT_NAME := myapp
我的习惯: 所有「不会变」的变量用 :=,所有「允许用户改」的变量用 ?=,所有「需要追加」的用 +=。至于 =,我只有在写递归变量时才用,比如 OBJS = $(SRCS:.c=.o) 这种。这样分工明确,看代码的人一眼就能知道每个变量的意图。

嗯,四种赋值符的区别就讲到这里。记住一句话:展开时机决定一切。理解了这一点,你就能驾驭 Makefile 的变量系统了。