3、变量与赋值:Makefile 中的变量定义与自动变量实战

变量这东西,说白了就是 Makefile 的「记忆体」。你想想看,一个项目里源文件几十上百个,编译选项、路径、工具链参数满天飞。要是每个规则都手写死字符串,改一次就得全局替换——我早年就吃过这个亏,改了个库路径漏了三处,链接阶段报错报得我头皮发麻。

所以这一章,咱们把变量定义和自动变量彻底捋清楚。搞懂了它们,你的 Makefile 才能从「一次性脚本」进化成「可维护的工程文件」。

3.1 四种赋值运算符:=、:=、?=、+=

Makefile 里赋值不是只有一种等号。四种运算符各有各的脾气,用错了轻则变量值不对,重则递归死循环。我见过不少新手在 = 和 := 上翻车,咱们一个一个拆开看。

运算符 名称 求值时机 典型场景
= 递归展开赋值 使用时才展开 引用其他变量,延迟求值
:= 简单赋值 定义时立即展开 固定路径、工具链前缀
?= 条件赋值 仅当变量未定义时赋值 默认值、用户可覆盖的配置
+= 追加赋值 在原有值后追加 累积源文件列表、编译选项

3.1.1 = 递归展开赋值

= 是 Makefile 最原始的赋值方式。它的特点是:定义时不求值,使用时才展开。这意味着你可以引用后面才定义的变量。

# 示例:递归展开
A = $(B)
B = hello

all:
    @echo $(A)   # 输出 hello,因为使用时 B 已定义

听起来很灵活对吧?但坑也在这里。如果变量之间存在循环引用,Make 会直接报错:

# 危险!循环引用
X = $(Y)
Y = $(X)

all:
    @echo $(X)   # 无限递归,Make 报错退出
我曾经在一个大型嵌入式项目里,用 = 定义了一堆相互依赖的路径变量。结果改了一个路径,整个变量链炸了,Make 卡死在那。排查了半小时才发现是循环引用。从那以后,我给自己定了个规矩:能用 := 的地方绝不用 =。

3.1.2 := 简单赋值

:= 是我个人最常用的赋值方式。它在定义时立即展开右侧的所有变量引用,之后右侧变量再变,左侧也不受影响。

# 示例:简单赋值
B = hello
A := $(B)   # 此时 A 被展开为 "hello"
B = world

all:
    @echo $(A)   # 输出 hello,不是 world

为什么我偏爱 :=?因为它的行为符合直觉——你写代码时看到的值,就是运行时拿到的值。排查问题的时候少了很多「变量到底什么时候展开」的猜谜游戏。

我的习惯:所有固定路径、工具链前缀、编译选项,一律用 :=。只有那些确实需要延迟求值的场景(比如依赖其他规则动态生成的变量),才考虑用 =。

3.1.3 ?= 条件赋值

?= 的意思是:如果变量还没定义,就赋值;如果已经定义了,就跳过。这个在给用户提供默认配置时特别好用。

# 用户可以在命令行覆盖
CC ?= gcc
CFLAGS ?= -O2 -Wall

# 命令行:make CC=clang CFLAGS="-O3"
# 此时 CC 为 clang,CFLAGS 为 -O3

嗯,这里要注意:?= 只检查变量是否被定义过,不检查值是否为空。如果变量被定义为空字符串,?= 也不会覆盖它。

3.1.4 += 追加赋值

+= 就是在原有值后面追加内容,中间自动加空格。这个在累积源文件列表、编译选项时非常顺手。

SRCS := main.c
SRCS += utils.c
SRCS += io.c

# 此时 SRCS = "main.c utils.c io.c"

但有个细节:+= 的求值方式取决于变量之前是用 = 还是 := 定义的。如果之前是 =,追加后仍然是递归展开;如果之前是 :=,追加后变成简单赋值。我个人建议统一用 := 打底,再用 += 追加,避免混用带来的不确定性。

3.2 自动变量:$@、$^、$< 的实战应用

自动变量是 Makefile 里的「语法糖」,但绝不是可有可无的装饰。它们能让你写出通用规则,一条规则匹配所有同类文件。我在项目中用自动变量重构过一份 200 行的 Makefile,最后缩到 30 行,而且可读性反而更高了。

自动变量 含义 示例值(规则 obj/%.o: src/%.c)
$@ 目标文件名 obj/main.o
$^ 所有依赖文件列表(去重) src/main.c src/header.h
$< 第一个依赖文件 src/main.c
$? 比目标新的依赖文件列表 (增量编译时使用)
$* 模式匹配的「茎」(stem) main

3.2.1 $@:目标文件,就是你自己

$@ 代表当前规则的目标。不管目标是普通文件还是伪目标,它都指向那个名字。

# 通用编译规则
obj/%.o: src/%.c
    gcc -c $< -o $@

# 等价于手动写:
# obj/main.o: src/main.c
#     gcc -c src/main.c -o obj/main.o

你看,用了 $@,一条规则就能处理所有 .c 到 .o 的编译。不用为每个源文件单独写规则。

3.2.2 $^:所有依赖,一股脑全给你

$^ 列出当前规则的所有依赖文件,并且自动去重。链接目标文件时特别好用:

# 链接所有 .o 文件
myapp: obj/main.o obj/utils.o obj/io.o
    gcc $^ -o $@

# 等价于:
# gcc obj/main.o obj/utils.o obj/io.o -o myapp

如果依赖列表里有重复文件(比如通过变量展开导致的),$^ 会自动去重,不会导致链接时符号重复定义。这个细节我当年踩过坑——手动写依赖列表时漏了去重,链接报错找了半天。

3.2.3 $<:第一个依赖,最常用的那个

$< 只取第一个依赖文件。在编译规则里,第一个依赖通常就是源文件本身,所以 $< 几乎成了编译规则的标配。

# 编译规则的标准写法
obj/%.o: src/%.c src/%.h
    gcc -c $< -o $@

# 这里 $< 是 src/%.c,头文件只作为依赖触发重编译
核心原则:$< 用于「输入」,$@ 用于「输出」,$^ 用于「全部依赖」。记住这个口诀,写规则时就不容易搞混。

3.2.4 $? 和 $*:增量编译与模式匹配

$? 列出所有比目标新的依赖文件。这个在增量编译场景下很有用:

# 只重新编译修改过的文件
update: obj/main.o obj/utils.o
    @echo "需要重新编译: $?"
    gcc $? -o myapp

$* 在模式规则中代表匹配到的「茎」。比如规则 %.o: %.c 匹配 main.o 时,$* 就是 main。这个在生成依赖文件时偶尔会用,但日常场景不如前三个频繁。

3.3 实战:用变量和自动变量重构一个真实 Makefile

光说不练假把式。咱们拿一个实际项目片段来演示。假设你有一个 C 项目,目录结构如下:

project/
├── src/
│   ├── main.c
│   ├── utils.c
│   └── io.c
├── include/
│   ├── utils.h
│   └── io.h
└── obj/

这是「反面教材」版本——所有路径写死,没有变量:

# 反面教材:硬编码版本
myapp: obj/main.o obj/utils.o obj/io.o
    gcc obj/main.o obj/utils.o obj/io.o -o myapp

obj/main.o: src/main.c include/utils.h include/io.h
    gcc -c src/main.c -o obj/main.o

obj/utils.o: src/utils.c include/utils.h
    gcc -c src/utils.c -o obj/utils.o

obj/io.o: src/io.c include/io.h
    gcc -c src/io.c -o obj/io.o

看着就累对吧?改个目录名得改七八处。现在用变量和自动变量重构:

# 重构版本:变量 + 自动变量
SRC_DIR := src
OBJ_DIR := obj
INC_DIR := include

SRCS := $(wildcard $(SRC_DIR)/*.c)
OBJS := $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SRCS))
DEPS := $(OBJS:.o=.d)

CC := gcc
CFLAGS := -I$(INC_DIR) -MMD -MP

# 链接规则
$(OBJ_DIR)/myapp: $(OBJS)
    $(CC) $^ -o $@

# 编译规则
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
    $(CC) $(CFLAGS) -c $< -o $@

# 包含自动生成的依赖文件
-include $(DEPS)

.PHONY: clean
clean:
    rm -rf $(OBJ_DIR)/*.o $(OBJ_DIR)/*.d $(OBJ_DIR)/myapp
我建议把 SRC_DIR、OBJ_DIR 这类路径变量放在文件顶部,用 := 赋值。这样后续改路径只需要改一处,而且 := 保证展开时机可控,不会出现「变量还没定义就被引用」的诡异问题。

重构后的版本,核心变化有三点:

  1. 路径变量化:SRC_DIR、OBJ_DIR 集中管理,改一处全局生效。
  2. 自动变量替代硬编码:$@、$^、$< 让规则通用化,不再为每个文件写单独规则。
  3. 函数自动生成文件列表:wildcard 和 patsubst 自动扫描源文件并生成目标文件列表,新增文件不用改 Makefile。

嗯,这里要注意:-MMD -MP 是 GCC 的自动依赖生成选项,它会为每个 .c 文件生成一个 .d 文件,里面记录了头文件依赖。然后用 -include 包含这些 .d 文件,这样头文件变更时也能触发重新编译。这个技巧我在实际项目中用了好几年,非常稳。

3.4 知识结构图:变量与自动变量全景

下面这张图把本章的核心知识点串起来了。你可以把它当作一个「速查地图」,写 Makefile 时瞄一眼就知道该用哪种赋值、哪个自动变量。

变量与自动变量知识结构图 变量定义(四种赋值) = 递归展开 使用时才求值 小心循环引用 := 简单赋值 定义时立即展开 推荐默认使用 ?= 条件赋值 未定义时才赋值 用户默认配置 += 追加赋值 自动加空格追加 累积源文件列表 自动变量(规则内使用) $@ 目标文件 输出文件名 $^ 所有依赖 去重后列表 $< 首个依赖 编译输入文件 $? 更新依赖 增量编译用 $* 模式茎 匹配的中间部分 实战口诀 路径配置用 :=,默认选项用 ?=,累积列表用 += 编译用 $< 输入、$@ 输出,链接用 $^ 全部依赖 避免用 = 做复杂变量链,防止循环引用

3.5 避坑指南:我踩过的那些变量坑

最后分享几个实战中容易翻车的点,都是我用真金白银的加班时间换来的教训。

坑一:= 的递归展开导致意外覆盖
我曾经在项目里用 = 定义了一个 CFLAGS,然后在后面又用 += 追加。结果因为 = 的延迟求值特性,追加的内容被反复展开,最后编译选项膨胀了三倍。排查时打印变量值,自己都看傻了。
解决方案:基础变量一律用 :=,需要追加时再用 +=。
坑二:自动变量在规则外无效
有新手问我:「为什么我在 all 目标外面用 $@ 打印出来是空的?」自动变量只在规则的命令部分有效,在依赖列表或全局作用域里是未定义的。这个 Make 的语法限制,不是 bug。
坑三:?= 对空字符串不生效
如果你在 Makefile 里写了 VAR =(赋值为空),后面再用 VAR ?= value 是不会覆盖的。因为 VAR 已经被定义过了,只是值为空。想覆盖空值,得用 VAR := value 或者先 undefine VAR。