3、Makefile变量基础:变量的定义、赋值与引用,以及变量的作用域

说实话,变量是Makefile的灵魂。没有变量,你写的Makefile就是一堆硬编码的路径和命令,改一次要改半天。我刚开始学Makefile时,觉得变量不就是个占位符嘛,后来才发现,这里面的门道比我想象的多得多。

今天我们就来彻底搞懂Makefile里的变量。从怎么定义、怎么赋值、怎么引用,到变量到底在哪些地方能生效。这些东西搞明白了,你写Makefile的效率至少翻一倍。

3.1 变量的定义与引用

先看最基础的东西。Makefile里定义变量很简单,就是 变量名 = 值 这种格式。引用变量时用 $(变量名) 或者 ${变量名},我个人习惯用圆括号,看着更清晰。

CC = gcc
CFLAGS = -Wall -O2
TARGET = myapp

$(TARGET): main.c utils.c
    $(CC) $(CFLAGS) -o $(TARGET) main.c utils.c

你看,这里定义了三个变量:CC、CFLAGS、TARGET。后面引用的时候,直接用 $(变量名) 就行。这样做的好处很明显——如果我想换编译器,只需要改第一行,不用满文件去找 gcc 这个词。

小技巧:变量名建议用大写字母,这是Makefile社区的惯例。虽然小写也能用,但跟Makefile内置函数混在一起容易看花眼。

3.2 四种赋值方式

这里有个坑,我当年踩过。Makefile里赋值不是只有 = 一种,实际上有四种不同的赋值操作符。每种的行为都不一样,用错了会出大问题。

操作符 名称 特点 使用场景
= 递归展开赋值 使用时才展开,引用后续定义的变量也能生效 需要延迟求值的场景
:= 简单赋值 定义时立即展开,只使用当前已定义的变量 大多数情况,推荐使用
?= 条件赋值 只有变量未定义时才赋值 提供默认值
+= 追加赋值 在原有值后面追加内容,自动加空格 逐步构建变量值

3.2.1 递归展开赋值(=)

这是Makefile默认的赋值方式。它的特点是:等到真正使用变量的时候才去计算值。这意味着,你可以在定义变量A时引用变量B,即使B还没定义,只要在使用A之前B被定义了就行。

A = $(B) world
B = hello

all:
    echo $(A)   # 输出:hello world

为什么会这样?因为 echo $(A) 执行时,Make会去展开 A,发现 A = $(B) world,然后继续展开 $(B),此时 B 已经定义好了,所以结果是 hello world。

注意:递归展开有个隐患——如果变量之间存在循环引用,会导致无限递归。我曾经在项目里遇到过,Make直接报错退出,排查了半天才发现是两个变量互相引用了。

3.2.2 简单赋值(:=)

这个我强烈推荐大家多用。简单赋值在定义时就把值算好了,后面再怎么改引用的变量,都不会影响已经赋值的变量。

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

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

你看,虽然 B 后来改成了 hi,但 A 在定义时就已经把 $(B) 展开成 hello 了。这种行为更符合大多数编程语言的直觉,不容易出意外。

3.2.3 条件赋值(?=)

这个很简单:如果变量还没定义,就赋值;如果已经定义了,就什么都不做。常用于提供默认值。

CC ?= gcc    # 如果没定义 CC,就用 gcc

# 用户可以在命令行覆盖:
# make CC=clang

我在写通用Makefile时特别喜欢用 ?=,这样用户可以通过命令行传参覆盖默认值,灵活性很高。

3.2.4 追加赋值(+=)

在原有值后面追加内容,自动加空格分隔。这个在逐步构建编译选项时特别有用。

CFLAGS = -Wall
CFLAGS += -O2
CFLAGS += -I./include

# 最终 CFLAGS = -Wall -O2 -I./include

3.3 变量的作用域

变量不是在哪都能用的。Makefile里变量的作用域分三种:全局变量、目标级变量、模式级变量。搞混了的话,你会发现有些变量在某个规则里就是取不到值。

3.3.1 全局变量

在Makefile顶层定义的变量,默认就是全局的。整个Makefile文件以及它包含的其他Makefile都能访问到。

# 全局变量
PROJECT = myapp

include config.mk   # config.mk 里也能用 $(PROJECT)

all:
    echo $(PROJECT)

3.3.2 目标级变量

有时候你希望某个变量只对特定的目标生效。可以用 目标: 变量名 = 值 这种语法。

# 只对 debug 目标生效
debug: CFLAGS = -g -O0
debug:
    $(CC) $(CFLAGS) main.c

# 这里的 CFLAGS 还是全局的值
release:
    $(CC) $(CFLAGS) main.c

这个特性我在调试时经常用。正常编译用 -O2,调试时只想加 -g,用目标级变量就不用改全局配置。

3.3.3 模式级变量

模式级变量跟模式规则类似,用 % 匹配一类目标。比如让所有 .o 文件都带某个编译选项。

# 所有 .o 文件都加上这个选项
%.o: CFLAGS += -DDEBUG

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

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

3.4 变量覆盖与命令行传参

Makefile里有个优先级的问题。变量的最终值取决于它在哪被定义的。优先级从高到低是这样的:

  1. 命令行参数:make CC=clang,最高优先级
  2. Makefile中的赋值:普通定义
  3. 环境变量:从Shell继承来的变量,优先级最低

举个例子:

# 假设环境变量 CC=gcc
CC = g++

all:
    echo $(CC)   # 输出 g++

# 但如果执行 make CC=clang,输出就是 clang

这里有个小坑:如果你想禁止用户通过命令行覆盖某个变量,可以用 override 指令。

override CC = gcc   # 命令行传参也改不了

不过我个人不太建议用 override,除非你有非常特殊的需求。剥夺用户的灵活性,有时候会带来麻烦。

3.5 知识结构图

下面这张图把变量相关的核心知识点串起来了,方便你对照复习。

Makefile 变量知识体系 Makefile 变量 定义与引用 变量名 = 值 $(变量名) 或 ${变量名} 四种赋值方式 = 递归展开赋值 := 简单赋值(推荐) ?= 条件赋值 += 追加赋值 变量作用域 全局变量 目标级变量 模式级变量 变量覆盖与优先级 命令行参数 > Makefile赋值 > 环境变量 override 指令可禁止命令行覆盖

3.6 实战建议

说了这么多,最后给几个我实际项目里总结出来的建议:

  • 能用 := 就别用 =:简单赋值的行为更可控,不容易出诡异的问题。
  • 善用 ?= 提供默认值:这样你的Makefile既好用又灵活。
  • 目标级变量用来做调试配置:比如 debug: CFLAGS = -g,干净利落。
  • 别滥用 override:除非你有充分的理由,否则让用户能覆盖你的变量是好事。

核心要点:变量是Makefile的基石。搞懂了定义、赋值、引用和作用域,你就能写出可维护、可扩展的构建系统。记住四种赋值方式的区别,特别是 = 和 := 的差异,这是新手最容易踩坑的地方。