4. Makefile进阶:条件判断、函数使用、多目标编译、伪目标

好,咱们接着聊。上一章我们把Makefile的基本规则捋了一遍,能写个简单的单文件编译了。但实际项目哪有这么简单?一个工程少说几十个源文件,还有各种配置开关。今天我就把Makefile里真正好用的几个进阶功能讲透。

4.1 条件判断:让Makefile“活”起来

条件判断,说白了就是让Makefile根据不同的情况走不同的分支。我最早接触这个功能,是因为一个项目要同时支持Debug和Release两种编译模式。

语法其实很简单:

ifeq ($(变量), 值)
    # 成立时执行
else
    # 不成立时执行
endif

举个例子,我习惯这样写:

DEBUG ?= 0

ifeq ($(DEBUG), 1)
    CFLAGS = -g -O0 -DDEBUG
else
    CFLAGS = -O2 -DNDEBUG
endif

all:
    gcc $(CFLAGS) main.c -o main

你想想看,只要在命令行敲make DEBUG=1,就能切到调试模式。是不是很方便?

常用的判断还有:

  • ifneq — 不相等时执行
  • ifdef — 变量已定义时执行
  • ifndef — 变量未定义时执行
我的小习惯:条件判断里尽量用 ?= 赋值,这样用户可以在命令行覆盖默认值。我曾经见过同事用 = 赋值,结果想改个编译选项还得去改Makefile,太死板了。

4.2 函数使用:别重复造轮子

Makefile内置了不少实用函数。说实话,我刚开始也觉得记这些函数麻烦,但用熟了之后,真香。

4.2.1 字符串处理函数

函数 作用 示例
$(wildcard *.c) 获取所有.c文件 返回 main.c foo.c bar.c
$(patsubst %.c,%.o,$(SRC)) 替换后缀 把.c换成.o
$(notdir $(PATH)) 去掉路径 src/main.c → main.c

我最常用的组合拳是这样的:

SRC = $(wildcard src/*.c)
OBJ = $(patsubst src/%.c, obj/%.o, $(SRC))

$(OBJ): obj/%.o: src/%.c
    gcc -c $< -o $@

这段代码什么意思?自动扫描src/目录下所有.c文件,然后生成对应的.o文件到obj/目录。加一个新源文件?什么都不用改,直接make就行。

核心思路:用 wildcard 自动收集源文件,用 patsubst 生成目标文件列表。这样Makefile就“自适应”了。

4.2.2 其他实用函数

  • $(foreach var, list, text) — 循环处理列表
  • $(shell command) — 执行shell命令
  • $(info text) — 打印调试信息

举个例子,我曾经需要为多个子目录分别编译:

SUBDIRS = src lib test

define build_subdir
    $(MAKE) -C $(1)
endef

all:
    $(foreach dir, $(SUBDIRS), $(call build_subdir, $(dir)))

嗯,这里要注意:foreach 和 call 配合使用,能写出很灵活的编译框架。

4.3 多目标编译:一次编译,到处生成

实际项目中,一个Makefile往往要生成多个目标。比如一个库文件加一个可执行文件,或者同时生成静态库和动态库。

写法其实很直接:

all: libfoo.a libfoo.so app

libfoo.a: foo.o bar.o
    ar rcs $@ $^

libfoo.so: foo.o bar.o
    gcc -shared -o $@ $^

app: main.o libfoo.a
    gcc main.o -L. -lfoo -o $@

你想想看,一个make命令下去,静态库、动态库、可执行文件全出来了。是不是很爽?

我曾经踩过的坑:多目标编译时,如果目标之间有依赖关系,一定要把依赖写清楚。比如上面的 app 依赖 libfoo.a,如果你先改了 foo.c 然后只 make app,Makefile会自动先编译 libfoo.a。这就是依赖追踪的魅力。

4.4 伪目标:别让文件名坑了你

伪目标这个概念,我第一次接触时觉得有点绕。说白了,就是告诉Make:“这个目标不是文件,别拿文件名跟我较真。”

最常见的伪目标就是 clean:

.PHONY: clean

clean:
    rm -f *.o *.a *.so app

为什么要加 .PHONY?假设你的目录里恰好有一个叫 clean 的文件,不加伪目标的话,Make会认为 clean 已经是最新的了,什么都不做。我当年就因为这个bug排查了半天...

常用的伪目标还有:

  • all — 默认目标
  • clean — 清理
  • install — 安装
  • test — 运行测试

我习惯把所有非文件目标都声明为伪目标:

.PHONY: all clean install test dist
我的建议:只要这个目标不是用来生成文件的,就加上 .PHONY。宁可多写,不要漏写。这是血的教训换来的经验。

4.5 综合示例:一个完整的进阶Makefile

最后,我把今天讲的内容串起来,给你看一个实际项目里能用的Makefile:

CC = gcc
AR = ar
DEBUG ?= 0

# 条件判断:编译模式
ifeq ($(DEBUG), 1)
    CFLAGS = -g -O0 -Wall -DDEBUG
else
    CFLAGS = -O2 -Wall -DNDEBUG
endif

# 自动收集源文件
SRC_DIR = src
OBJ_DIR = obj
SRC = $(wildcard $(SRC_DIR)/*.c)
OBJ = $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SRC))

# 多目标
TARGETS = libfoo.a app

.PHONY: all clean

all: $(TARGETS)

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

$(OBJ_DIR):
    mkdir -p $@

# 静态库
libfoo.a: $(OBJ)
    $(AR) rcs $@ $^

# 可执行文件
app: main.o libfoo.a
    $(CC) main.o -L. -lfoo -o $@

clean:
    rm -rf $(OBJ_DIR) *.o *.a app

这个Makefile包含了我们今天讲的所有内容:条件判断、函数使用、多目标编译、伪目标。你拿去改改就能用在真实项目里。

好了,这一章就到这。下一章我们聊聊Makefile的终极形态——自动依赖生成和模板化编译。到时候你会发现,原来Makefile可以写得这么优雅。