Makefile 基础:目标、依赖、规则、变量、自动变量、伪目标、模式规则

说实话,很多工程师对 Makefile 的态度就是「能跑就行」。我早期带项目时也这么想,直到有一次在 30 多个子目录的项目里改一个公共头文件,结果 rebuild 了整整 20 分钟——后来发现是依赖写漏了。嗯,从那以后我老老实实把 Makefile 基础啃透了。

这一节我们不讲花哨的,就讲 Makefile 最核心的 7 个概念。你吃透了它们,大型项目的构建脚本就能看懂七七八八了。

1. 目标(Target)与依赖(Prerequisites)

Makefile 里最基础的结构就三样东西:目标、依赖、规则。

目标就是你要生成的东西,比如一个 .o 文件或者一个可执行文件。依赖就是生成目标需要哪些原材料。规则就是怎么从原材料加工出目标。

# 最简单的例子
hello.o: hello.c hello.h
    gcc -c hello.c -o hello.o

这里 hello.o 是目标,hello.c 和 hello.h 是依赖,下面那行缩进的命令就是规则。

我的习惯:写依赖时尽量写全。漏掉一个头文件,改它时就不会重新编译,排查起来特别痛苦。我在项目中遇到过好几次这种「改头文件不重编」的坑,后来干脆写了个脚本自动扫描 #include 来生成依赖。

2. 规则(Recipe)的执行逻辑

规则就是一组 shell 命令。Make 执行规则时有个关键行为:只会在目标不存在,或者依赖比目标新的时候才执行。

为什么会这样?因为 Make 的设计哲学就是「最小化重建」。你想想看,如果每次 make 都全部重新编译,那跟写个 shell 脚本有啥区别?

# 只有 main.c 变了,才会重新编译 main.o
main.o: main.c
    gcc -c main.c -o main.o

utils.o: utils.c utils.h
    gcc -c utils.c -o utils.o

app: main.o utils.o
    gcc main.o utils.o -o app

这里有个细节:规则里的命令前面必须是 Tab 键,不能用空格。我见过新手在这上面卡半小时,嗯,Make 就是这么固执。

3. 变量(Variables)——别写死

大型项目里,编译器路径、编译选项、源文件列表这些东西如果写死,后期维护就是噩梦。变量就是用来解决这个问题的。

CC = gcc
CFLAGS = -Wall -O2 -Iinclude
OBJS = main.o utils.o network.o

app: $(OBJS)
    $(CC) $(CFLAGS) -o app $(OBJS)

变量用 $() 或 ${} 引用。我个人习惯用 $(),因为跟 shell 的 ${} 不容易混淆。

变量类型 赋值方式 特点
简单赋值 := 立即展开,后面改值不影响
递归赋值 = 使用时才展开,可能循环引用
条件赋值 ?= 只有未定义时才赋值
追加赋值 += 在原有值后面追加

我曾经踩过的坑:用 = 递归赋值时,不小心引用了自身,结果 Make 直接报「循环变量引用」。后来我统一改用 :=,除非明确需要延迟展开。你想想看,一个构建脚本里变量互相引用,排查起来多头疼。

4. 自动变量(Automatic Variables)——省事神器

写规则时,目标名和依赖名经常要重复写。自动变量就是用来偷懒的。

# 不用自动变量
main.o: main.c main.h
    gcc -c main.c -o main.o

# 用自动变量
main.o: main.c main.h
    gcc -c $< -o $@

常用的自动变量就这几个:

  • $@ —— 目标文件名
  • $< —— 第一个依赖文件名
  • $^ —— 所有依赖文件列表(去重)
  • $? —— 比目标新的依赖文件列表
  • $* —— 目标文件去掉后缀的部分

我建议你记住前三个就够了。我在项目中 90% 的场景只用 $@ 和 $<。

5. 伪目标(Phony Target)——别跟文件重名

你有没有遇到过这种情况:目录下有个 clean 文件,然后执行 make clean 就报「已经是最新」?这就是伪目标要解决的问题。

伪目标不是真正的文件,它只是一个动作的名字。用 .PHONY 声明一下就行。

.PHONY: clean all install

all: app

clean:
    rm -f *.o app

install:
    cp app /usr/local/bin/

声明了 .PHONY 之后,Make 就不会去检查有没有 clean 这个文件,每次都老老实实执行命令。

我的建议:所有不是生成文件的目标,都加上 .PHONY。包括 all、clean、test、distclean 这些。养成习惯后,就不会被「文件重名」这种低级问题坑到。

6. 模式规则(Pattern Rules)——批量处理

大型项目里源文件成百上千,不可能为每个 .c 文件写一条规则。模式规则就是用来匹配一类文件的。

# 把所有 .c 文件编译成对应的 .o 文件
%.o: %.c
    $(CC) $(CFLAGS) -c $< -o $@

% 是通配符,匹配任意字符串。上面这条规则的意思是:任何 .o 文件,如果它对应的 .c 文件存在,就用这条规则来编译。

配合变量使用,效果更佳:

SRCS = main.c utils.c network.c
OBJS = $(SRCS:.c=.o)

%.o: %.c
    $(CC) $(CFLAGS) -c $< -o $@

app: $(OBJS)
    $(CC) -o $@ $^

这里 $(SRCS:.c=.o) 是个变量替换技巧,把 .c 后缀全换成 .o。你想想看,如果新增一个源文件,只需要在 SRCS 里加一行就行,多省事。

7. 综合示例:一个小型项目的 Makefile

把上面这些知识点串起来,一个典型的小型项目 Makefile 长这样:

CC := gcc
CFLAGS := -Wall -O2 -Iinclude
LDFLAGS := -lm

SRCS := $(wildcard src/*.c)
OBJS := $(SRCS:.c=.o)
TARGET := bin/app

.PHONY: all clean test

all: $(TARGET)

$(TARGET): $(OBJS)
    $(CC) $(LDFLAGS) -o $@ $^

%.o: %.c
    $(CC) $(CFLAGS) -c $< -o $@

clean:
    rm -f $(OBJS) $(TARGET)

test: $(TARGET)
    ./$(TARGET) --test

这个例子涵盖了:变量赋值、通配符、模式规则、自动变量、伪目标。你把它吃透了,写 50 个源文件的项目完全够用。

核心要点回顾:

  • 目标 + 依赖 + 规则 = Makefile 的骨架
  • 变量别写死,用 := 更安全
  • 自动变量 $@ $< $^ 记牢
  • 伪目标用 .PHONY 声明
  • 模式规则用 % 批量匹配

下一节我们会把这些基础放到多目录场景下,看看怎么组织 src、include、lib 这些目录的构建。到时候你会发现,基础打牢了,复杂的东西其实就是组合而已。