Makefile调试:隐式规则与变量展开

说实话,Makefile 这东西,看着简单,用起来坑不少。

我见过太多人,明明代码写得挺好,结果 Makefile 里一个小问题,编译出来就是不对。更气人的是,你盯着它看半天,它就是不报错——就是结果不对。

今天咱们聊聊 Makefile 调试里两个最让人头疼的东西:隐式规则变量展开

隐式规则:你以为你懂了,其实你没懂

什么叫隐式规则?说白了就是 Make 自己偷偷帮你做的一些事。

比如你写一句:

hello.o: hello.c
    gcc -c hello.c -o hello.o

这没问题,显式规则,清清楚楚。

但如果你只写:

hello.o: hello.c

Make 会自己去找怎么把 .c 变成 .o。它有一套内置的规则库,比如:

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

这就是隐式规则。

关键点:隐式规则不是万能的。它依赖变量 CC、CFLAGS 这些。如果你没定义,它就用默认值。默认的 CC 是 cc,CFLAGS 是空的。

我在项目里遇到过一回,同事写了个 Makefile,只写了依赖关系,没写编译命令。他以为 Make 会自动用 gcc,结果系统里 cc 指向的是 clang,编译出来行为不一样。查了半天,最后发现是隐式规则在作怪。

隐式规则的调试方法

怎么知道 Make 用了哪条隐式规则?

我个人的习惯是先用 make -p 看看。

$ make -p | grep -A 10 "%.o: %.c"

这会打印出所有内置规则。你可以看到 Make 到底准备怎么编译你的文件。

另一个更实用的命令是 make -d(debug 模式)。它会输出 Make 的决策过程,包括它尝试了哪些规则、匹配了哪些模式、最终选择了哪条。

$ make -d hello.o 2>&1 | grep "Looking for"

嗯,这里要注意,-d 的输出量很大,建议用 grep 过滤一下。

小技巧:如果你只想看某个特定目标的调试信息,可以用 make --debug=v,它比 -d 精简一些。

变量展开:两种模式,一个坑

Makefile 里的变量展开有两种方式:简单展开递归展开

简单展开用 :=

CC := gcc
CFLAGS := -Wall -O2

递归展开用 =

CC = gcc
CFLAGS = -Wall -O2

区别在哪?

简单展开:在定义时就计算好值。后面再改变量,不影响已经定义好的。

递归展开:在使用时才计算。后面改了变量,前面引用的地方也会变。

举个例子:

# 递归展开
A = hello
B = $(A) world
A = hi
# 此时 B 的值是 "hi world"

# 简单展开
A := hello
B := $(A) world
A := hi
# 此时 B 的值是 "hello world"

你看,同样都是赋值,结果完全不同。

警告:递归展开容易导致无限递归。比如:

A = $(A) something

这会让 Make 陷入死循环。我见过有人不小心写错了,Make 直接卡死。

变量展开的调试方法

怎么知道变量最终展开成了什么?

我常用的方法是用 $(info ...) 打印变量值:

$(info CC = $(CC))
$(info CFLAGS = $(CFLAGS))

这会在 Make 解析阶段就打印出来,不需要真正执行编译。

另一个方法是 $(warning ...),它和 info 类似,但会带上文件名和行号:

$(warning CC = $(CC))

我个人更推荐用 warning,因为你能知道这个打印是在哪一行触发的。

调试技巧:如果你怀疑某个变量被意外覆盖了,可以在关键位置加 $(origin VARIABLE) 来查看变量来源:

$(info CFLAGS origin: $(origin CFLAGS))

它会告诉你变量是来自环境、命令行、还是 Makefile 内部。

实战:一个典型的调试场景

假设你有这样一个 Makefile:

CC = gcc
CFLAGS = -Wall
SRC = main.c utils.c
OBJ = $(SRC:.c=.o)

all: program

program: $(OBJ)
    $(CC) -o $@ $^

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

看起来没问题对吧?

但如果你运行 make,发现编译命令里没有 -Wall。为什么?

我们来调试一下:

$ make -p | grep "CFLAGS"

输出可能显示:

CFLAGS = 

嗯?我明明定义了 CFLAGS = -Wall 啊。

再仔细看,原来 Makefile 里有一行:

CFLAGS = -Wall

但后面又有一行:

include config.mk

config.mk 里写了:

CFLAGS = -O2

因为用的是 =(递归展开),后面的赋值覆盖了前面的。

这就是我常说的:变量覆盖是 Makefile 里最常见的 bug 之一

如何避免变量展开的坑

我总结了几个原则:

  1. 能用 := 就别用 =。简单展开更可控,不容易被意外覆盖。
  2. ?= 做条件赋值。只有变量未定义时才赋值,避免覆盖已有的值。
  3. += 追加值。比如 CFLAGS += -g,这样不会丢掉之前的值。
  4. 关键变量用 override 保护。如果你不想让命令行参数覆盖你的变量,可以用 override CFLAGS += -Wall

核心原则:Makefile 的变量展开是静态的,但执行顺序是动态的。理解这两者的关系,是调试 Makefile 的关键。

知识体系总览

下面这张图总结了 Makefile 调试的核心逻辑:

Makefile调试核心知识体系 隐式规则 变量展开 内置规则:%.o: %.c 依赖变量:CC、CFLAGS、LDFLAGS 调试命令:make -p / make -d 简单展开 := (定义时计算) 递归展开 = (使用时计算) 调试方法:$(info) / $(warning) 调试核心:理解Make的决策过程 隐式规则 + 变量展开 = 90%的Makefile问题

总结一下

Makefile 调试,说白了就是搞清楚两件事:

  • Make 用了哪条规则?——用 -p-d 看。
  • 变量最终是什么值?——用 $(info)$(warning) 看。

我曾经在一个大型项目里,因为一个变量被递归展开覆盖,导致整个项目的编译选项全错了。从那以后,我写 Makefile 都坚持用 :=,除非有特殊需求。

记住:Makefile 不报错,不代表它做对了。学会调试,才能掌控它。


公众号:蓝海资料掘金营,微信deep3321