3、变量与赋值:变量的定义与引用、赋值运算符的区别与使用场景
变量是 Makefile 的灵魂。没有变量,你写出来的就是一坨硬编码,改个路径得翻遍整个文件。我刚开始学 Makefile 那会儿,就吃过这个亏——一个项目里路径写了十几遍,后来客户要求换编译器,我改到想砸键盘。
说白了,变量就是给一串文本起个名字。你定义它,然后在别处引用它。Make 在解析时会把它展开成实际的值。但这里有个坑:不同的赋值运算符,展开的时机完全不同。用错了,轻则编译警告,重则链接出一堆莫名其妙的错误。
3.1 变量的定义与引用
先看最基本的用法。定义一个变量,用等号左边写名字,右边写值。引用变量,用 $() 或者 ${} 把变量名包起来。
# 定义
CC = gcc
CFLAGS = -Wall -O2
# 引用
$(CC) $(CFLAGS) -c main.c -o main.o
我个人习惯用 $(),因为跟 Shell 的 ${} 区分开,眼睛扫过去更清楚。当然你用 ${} 也没问题,Make 都认。
变量名大小写敏感。CC 和 cc 是两个不同的变量。业内约定俗成:工具链相关的用大写(CC、LD、AR),用户自定义的用小写(obj_dir、src_files)。
$() 把变量名括完整。比如 $(OBJ_DIR)files 没问题,但 $OBJ_DIRfiles 会被解析成变量 OBJ_DIRfiles,大概率是空值。
3.2 赋值运算符详解
Makefile 里有四种赋值运算符:=、:=、?=、+=。它们的行为差异,是新手最容易踩坑的地方。
3.2.1 递归展开赋值 =
= 是 Make 的默认赋值方式。它的特点是:延迟展开。也就是说,等号右边的变量引用不会立即求值,而是在最终使用这个变量的时候才展开。
A = hello
B = $(A) world
A = hi
all:
@echo $(B) # 输出 "hi world"
为什么会这样?因为 B 在定义时只是记下了 $(A) world 这个表达式,并没有去算 $(A) 的值。等到 echo $(B) 执行时,A 已经被赋成了 hi,所以 $(A) 展开为 hi。
这个特性在某些场景下很灵活,但也很容易造成困惑。我在项目中遇到过一个问题:两个变量互相引用,结果 Make 陷入了无限递归,直接报错退出。
= 让变量循环引用自己。比如 A = $(A) foo,Make 会一直展开下去直到栈溢出。
3.2.2 简单赋值 :=
:= 是我最常用的赋值运算符。它的行为更符合直觉:立即展开。等号右边的变量引用在赋值那一刻就被求值,之后右边变量再怎么变,左边也不会受影响。
A = hello
B := $(A) world
A = hi
all:
@echo $(B) # 输出 "hello world"
你看,B 在定义时就把 $(A) 展开成了 hello,后面 A 改成 hi 跟它没关系了。
我建议:能用 := 的地方尽量用 :=。它让变量的值可预测,调试起来省心。尤其是在大型项目中,变量定义分散在多个文件里,用 := 能避免很多隐晦的依赖问题。
3.2.3 条件赋值 ?=
?= 的意思是:如果变量没有被定义过,才赋值;否则跳过。它常用于给变量提供默认值。
CC ?= gcc
all:
@echo $(CC) # 如果外部没传 CC,就输出 gcc
这个在交叉编译场景下特别有用。用户可以在命令行传入 CC=arm-linux-gnueabihf-gcc,Makefile 里用 CC ?= gcc 兜底。用户传了就用用户的,没传就用默认的。
嗯,这里要注意:?= 判断的是变量是否被定义过,而不是值是否为空。如果一个变量被定义成了空值,?= 也不会覆盖它。
3.2.4 追加赋值 +=
+= 用于在变量现有值后面追加内容。它会在新旧值之间自动加一个空格。
CFLAGS = -Wall
CFLAGS += -O2
CFLAGS += -I./include
# 最终 CFLAGS 的值是 "-Wall -O2 -I./include"
+= 的展开方式取决于变量之前是用 = 还是 := 定义的。如果之前是 :=,那 += 也按简单展开处理;如果之前是 =,那 += 也按递归展开处理。这个细节容易忘,我建议统一用 := 定义初始值,再用 += 追加,行为最清晰。
3.3 四种赋值运算符对比
| 运算符 | 名称 | 展开时机 | 典型场景 |
|---|---|---|---|
= |
递归展开赋值 | 使用时展开 | 需要延迟求值的复杂表达式 |
:= |
简单赋值 | 定义时立即展开 | 绝大多数场景,推荐使用 |
?= |
条件赋值 | 仅当未定义时赋值 | 提供默认值、命令行覆盖 |
+= |
追加赋值 | 继承之前的展开方式 | 逐步构建编译选项、链接库列表 |
3.4 实战中的选择策略
说了这么多理论,到底怎么选?我总结了一套自己的规则,分享给你:
- 定义工具链变量(
CC、LD、AR):用?=,让用户可以从命令行覆盖。 - 定义编译选项(
CFLAGS、LDFLAGS):用:=给初始值,然后用+=追加。 - 定义路径和文件列表:用
:=,确保值在定义时就固定下来。 - 定义需要根据其他变量动态计算的表达式:用
=,但一定要小心循环引用。
我曾经踩过的一个坑:在一个嵌入式项目中,我用 = 定义了 OBJS = $(SRCS:.c=.o),然后又在后面用 += 往 SRCS 里加文件。结果 OBJS 每次展开都不一样,导致 Make 认为目标文件一直在变,每次都重新编译所有源文件。后来改成 :=,问题解决。
3.5 知识结构图
下面这张图把四种赋值运算符的核心逻辑串起来了,你可以对照着看:
3.6 常见误区与避坑指南
- 误区一:以为
=和:=没区别。结果变量值跟预期不一样,排查半天才发现是展开时机的问题。 - 误区二:用
?=给空变量赋值,以为能覆盖。实际上?=只检查变量是否被定义过,不检查值是否为空。 - 误区三:在
+=后面忘了加空格。其实+=会自动加空格,但很多人习惯手动加一个,结果变成两个空格,虽然不影响编译,但看着别扭。
我曾经犯过的错:在一个多目录项目中,我在子 Makefile 里用 = 定义了 SRC_DIR = ../src,然后在顶层 Makefile 里用 include 包含了这个子 Makefile。结果因为 = 的延迟展开特性,SRC_DIR 的值在顶层上下文里被重新计算,指向了错误的路径。后来全部改成 :=,路径就对了。
记住一句话:变量赋值没有银弹。四种运算符各有各的脾气,理解它们的展开时机,比死记硬背语法重要得多。你想想看,Makefile 的调试本来就比普通代码麻烦,要是变量再搞出幺蛾子,那真是欲哭无泪。