4. Makefile变量与赋值
各位同学,欢迎来到第四章。前两章我们聊了变量和函数,算是把Makefile的“血肉”搭起来了。这一章我们聊聊它的“骨架”——条件判断、多目标、嵌套调用和文件包含。这些才是让Makefile真正“活”起来的东西。
我个人觉得,条件判断是Makefile里最容易上手、也最容易出bug的地方。多目标编译则是实战中绕不开的坎。至于嵌套调用和文件包含,说白了就是“分而治之”的思想——项目大了,一个Makefile管不过来,那就拆成多个。
本章核心脉络:条件判断让Makefile“会思考”,多目标编译让它“能干更多活”,嵌套调用和文件包含则让它“能管更大的项目”。
4.1 条件判断:让Makefile“会思考”
条件判断是Makefile里的if-else。它让同一个Makefile能根据不同的环境变量、编译器版本、调试开关,执行不同的编译逻辑。
4.1.1 ifeq / ifneq:比较两个值
ifeq 是最常用的条件判断。语法很简单:
ifeq ($(VARIABLE), value)
# 如果相等,执行这里
else
# 如果不相等,执行这里
endif
举个例子,根据是否开启调试模式,决定编译选项:
DEBUG ?= 0
ifeq ($(DEBUG), 1)
CFLAGS += -g -O0 -DDEBUG
else
CFLAGS += -O2
endif
app: main.c
gcc $(CFLAGS) -o $@ $^
我在项目中遇到过一个问题:有人把 ifeq ($(DEBUG), 1) 写成了 ifeq ($(DEBUG),1)——注意,逗号后面少了个空格。Makefile对空格敏感,这种写法会导致条件永远不成立。嗯,这种坑踩过一次就记住了。
小技巧:ifeq 也支持 ifeq ($(A), $(B)) 这种变量对变量的比较。另外 ifneq 就是“如果不相等”,用法完全一样。
4.1.2 ifdef / ifndef:检查变量是否定义
ifdef 检查一个变量是否被定义过(哪怕定义成空值也算定义)。ifndef 则相反。
ifdef CROSS_COMPILE
CC = $(CROSS_COMPILE)gcc
else
CC = gcc
endif
我曾经在交叉编译环境配置时,发现 ifdef 有个坑:如果变量被定义为空(比如 CROSS_COMPILE =),ifdef 仍然返回真。所以如果你要检查变量是否“有实际值”,最好用 ifeq ($(CROSS_COMPILE),) 来判断。
避坑指南:ifdef 只检查变量是否被定义,不检查值是否为空。如果你需要检查“非空”,用 ifneq ($(VAR),)。
4.2 多目标编译实战
一个项目通常有多个目标文件。比如一个服务器程序,可能要编译出 server、client、libcommon.a 三个目标。你想想看,如果每个目标都写一条规则,那Makefile会变得又臭又长。
4.2.1 多目标规则
Makefile允许一条规则对应多个目标:
all: server client libcommon.a
server: server.o net.o
gcc -o $@ $^
client: client.o net.o
gcc -o $@ $^
libcommon.a: common.o
ar rcs $@ $^
这里 all 是伪目标,它依赖三个子目标。执行 make all 就会依次编译它们。
4.2.2 模式规则与自动变量
多目标编译的“灵魂”是模式规则。它用 % 通配符匹配文件名:
%.o: %.c
gcc $(CFLAGS) -c $< -o $@
这条规则的意思是:任何 .o 文件都依赖同名的 .c 文件,然后用 gcc -c 编译。这里的 $@ 是目标文件名,$< 是第一个依赖文件。
我个人习惯把自动变量整理成一张表,方便查阅:
| 自动变量 | 含义 | 示例(目标 foo.o,依赖 foo.c bar.h) |
|---|---|---|
$@ |
目标文件名 | foo.o |
$< |
第一个依赖文件 | foo.c |
$^ |
所有依赖文件(去重) | foo.c bar.h |
$? |
比目标新的依赖文件 | (取决于文件时间戳) |
$* |
目标文件名去掉后缀 | foo |
实战经验:我在一个嵌入式项目中,用模式规则一次性处理了200多个源文件。只要写一条 %.o: %.c 规则,Makefile就自动匹配所有 .c 文件。配合 wildcard 函数,连源文件列表都不用手动写。
4.3 Makefile的嵌套调用(submake)
项目大了,你会把代码分成多个模块。每个模块有自己的Makefile,顶层Makefile负责调用它们。这就是嵌套调用。
4.3.1 基本语法:$(MAKE) -C
嵌套调用的标准写法是:
subdirs:
$(MAKE) -C lib
$(MAKE) -C src
$(MAKE) -C test
-C 选项让make先切换到指定目录,再执行那里的Makefile。注意,这里一定要用 $(MAKE) 而不是直接写 make。为什么?因为 $(MAKE) 会继承顶层make的命令行参数,比如 -j(并行编译)和 -k(出错继续)。
4.3.2 传递变量给子Makefile
子Makefile可能需要知道顶层的一些配置,比如编译器路径、编译选项。有两种方式传递:
- export:在顶层Makefile中
export CROSS_COMPILE,子Makefile自动继承。 - 命令行传递:
$(MAKE) -C src CFLAGS="-O2"。
我建议用export方式,因为更清晰。但要注意,export会传递所有变量,如果变量太多,反而容易造成命名冲突。我一般只export几个关键的全局变量,比如 CROSS_COMPILE、TOP_DIR。
# 顶层Makefile
export CROSS_COMPILE = arm-linux-gnueabihf-
export TOP_DIR = $(shell pwd)
all:
$(MAKE) -C lib
$(MAKE) -C src
避坑指南:子Makefile中不要用 export 覆盖顶层传下来的变量。我曾经在子目录里写了 export CROSS_COMPILE =,结果顶层传下来的交叉编译器被清空了,编译出来的东西在目标板上跑不起来。排查了半天才发现是这个问题。
4.4 include指令与文件包含
include 指令类似于C语言的 #include。它把另一个文件的内容“粘贴”到当前位置。这在大型项目中非常有用——你可以把公共的变量定义、规则模板、工具链配置放到单独的文件里,然后被多个Makefile共享。
4.4.1 基本用法
include config.mk
include $(TOP_DIR)/rules.mk
如果被包含的文件不存在,make会报错并停止。如果你希望“能包含就包含,没有就算了”,用 -include(或者 sinclude):
-include local_config.mk
这个特性在管理依赖文件时特别有用。比如:
%.d: %.c
gcc -MM $< > $@
-include $(OBJS:.o=.d)
这段代码自动生成每个 .c 文件的依赖关系(头文件列表),然后通过 -include 把它们加载进来。这样,当你修改了一个头文件,所有依赖它的源文件都会被重新编译。
4.4.2 实战:拆分Makefile
我习惯把大型项目的Makefile拆成三部分:
| 文件名 | 内容 | 示例 |
|---|---|---|
config.mk |
全局配置:编译器、路径、编译选项 | CC = gcc、CFLAGS = -Wall |
rules.mk |
通用规则:编译、链接、清理 | %.o: %.c、clean: |
Makefile |
项目特有:目标列表、依赖关系 | all: app、app: main.o foo.o |
这样做的好处是:如果你有多个子项目,它们可以共享 config.mk 和 rules.mk,只需要各自写一个简短的 Makefile 即可。
小技巧:include 指令支持通配符。比如 include *.mk 会包含当前目录下所有 .mk 文件。但要注意,如果没有任何 .mk 文件,make会报错。这时候用 -include *.mk 更安全。
4.5 本章小结
这一章我们聊了四个核心点:
- 条件判断:
ifeq、ifdef让Makefile能根据环境做决策。注意空格和空值的坑。 - 多目标编译:模式规则 + 自动变量,一条规则搞定所有源文件。
- 嵌套调用:
$(MAKE) -C管理多模块项目,用export传递全局变量。 - 文件包含:
include和-include实现配置和规则的复用。