如果你在构建日志里见过make[2]: *** [makefile:18: libs] Error 1,又曾在递归调用时看到子 make 命令行尾巴上拖着一长串VAR=value,那这篇东西就是给你写的。MAKEOVERRIDES是 GNU make 内置的一个特殊变量,它专门记录“在 make 命令行里传进来的变量定义”,并且在递归调用子 make 时自动往下传。它平时不怎么显眼,可一旦你写多目录工程、多级 makefile,或者碰上“Makefile 里明明赋值了却不生效”这类玄学问题,它就变成了排查的关键线索。
这篇内容我会先把这个变量彻底拆开,再带你复现它最坑的“滚动累积”行为,接着给出几条可以直接抄的实战用法,最后汇总几个我和同事真实踩过的故障。无论你是刚接触 makefile,还是已经被递归构建折磨过一阵子,都能从中找到能直接落地的经验。
1. 先搞清楚 MAKEOVERRIDES 里到底装了什么
1.1 它是什么时候出现的
GNU make 在启动的时候会扫描命令行参数,凡是看到VAR=value这种形式,它不会当成普通目标处理,而是统一收进一个“命令行变量集合”里,这个集合的载体就是MAKEOVERRIDES。
举个例子,你执行:
make DEBUG=1 VERBOSE=2 all那么在这一层 make 环境里,MAKEOVERRIDES的值就是:
DEBUG=1 VERBOSE=2注意这里存的是“赋值语句的字符串”,不是单纯的变量名,也不是变量值。多个赋值之间用空格分隔,顺序基本就是你在命令行里的输入顺序。这个设计很像一个“值班笔记本”:make 把外部传入的关键参数记下来,后续谁需要谁就翻这个本子。
它的名字也起得很直白:OVERRIDES就是“覆盖”的意思。因为命令行变量在 GNU make 的优先级体系里非常高,它会覆盖 makefile 内部的普通赋值。
优先级从高到低大致是:
override指令定义的变量- 命令行变量(也就是
MAKEOVERRIDES里记录的这些) - makefile 内部的普通赋值
- 环境变量
- make 内置的默认规则变量
所以很多新手问“为什么我在 Makefile 里写了CFLAGS=-O2,命令行传一个-O0进来,结果用的还是-O0”,原因就在这:命令行定义优先。
还有一个容易忽略的细节:?=条件赋值在这种场景下也会失效。因为命令行变量已经“存在”了,VAR ?= value只在变量从未定义时才赋值,所以哪怕你在 makefile 里写了CFLAGS ?= -O2,用户命令行传了CFLAGS=-O0,最后依然用-O0。这个特性很多人要踩过一次才会长记性。
1.2 一个最简实验:亲手打印 MAKEOVERRIDES
与其背文档,不如直接上手看。写一个最小的 makefile:
all: @echo "MAKEOVERRIDES=[$(MAKEOVERRIDES)]" @echo "MAKEFLAGS=[$(MAKEFLAGS)]"然后执行:
make CFLAGS="-O2 -g" DEBUG=1输出长这样:
MAKEOVERRIDES=[CFLAGS=-O2 -g DEBUG=1] MAKEFLAGS=[CFLAGS=-O2 -g DEBUG=1]两个变量里都能看到命令行变量定义。区别在于,MAKEFLAGS里不仅包含这些变量定义,还会带上命令行选项,比如-j4、-C subdir、-f xxx.mk之类。而MAKEOVERRIDES只关心“变量定义”这一件事。
建议你现在就打开终端跑一下这个实验,用 5 分钟建立起直观印象。后面所有坑都能从这里推导出来。
1.3 容易混淆的三个名字:MAKEFLAGS、.VARIABLES、origin 函数
平时排查问题经常会同时碰到几个相似的概念,我整理成一张对照表,建议收藏:
| 名称 | 内容 | 传递方式 | 主要用途 |
|---|---|---|---|
MAKEOVERRIDES | 命令行变量定义的集合 | 自动并入MAKEFLAGS,递归时传给子 make | 判断/过滤命令行传入的变量 |
MAKEFLAGS | 命令行选项 +MAKEOVERRIDES内容 | 作为环境变量自动导出给子 make | 让子 make 继承父 make 的参数 |
.VARIABLES | makefile 中出现过的变量名集合 | 不传递 | 列出所有已定义的变量名 |
$(origin VAR) | 查询某个变量来源 | 函数调用,无传递 | 判断变量来自命令行、文件、环境等 |
其中$(origin)函数是判断“某个变量是否来自命令行”的最干净手段。比如你想知道DEBUG是不是用户在命令行里指定的:
ifeq ($(origin DEBUG), command line) $(info DEBUG was set on the command line) endif相比直接解析MAKEOVERRIDES,origin更精准、更省事。那MAKEOVERRIDES的价值在哪?它强在“批量视角”:不看单个变量,而是把整批命令行变量定义一次性拿到手,能整体打印、整体过滤、整体转发。这在下面第三节会具体演示。
2. 递归 make 时 MAKEOVERRIDES 的积累过程
2.1 为什么必须用 $(MAKE) 而不是直接写 make
先纠正一个常见习惯:在 target 的命令行里调用子构建,永远不要直接写make,必须写$(MAKE)。
原因有两条。
第一,$(MAKE)展开后是当前 make 程序的完整路径加上必要的内部参数,它会把你当前这一层 make 的命令行选项通过环境变量传给子 make。如果你直接写make,那么-j4、-C、-f这些选项很可能传不下去,特别是并行构建会直接失效。
第二,GNU make 对含$(MAKE)的 recipe 有特殊处理:当你执行make -n(只打印不执行)时,包含$(MAKE)的命令会被展示出来,因为 make 知道它可能引发递归行为,需要让你看到完整的调用链。而直接写make的命令在-n模式下会被忽略。
所以下面所有递归示例,我都统一用$(MAKE)。这是 GNU make 的一条铁律,跟MAKEOVERRIDES的传递机制也直接相关:命令行变量定义之所以能一级一级传下去,靠的就是$(MAKE)背后那套自动导出机制。
2.2 三层 makefile 复现“滚动雪球”现象
理解了基本规则,现在看一个我建议所有人都亲手跑一遍的复现实验。
准备三个文件:
# top.mk all: @echo "top MAKEOVERRIDES=$(MAKEOVERRIDES)" $(MAKE) -f mid.mk FLAG_A=1 # mid.mk all: @echo "mid MAKEOVERRIDES=$(MAKEOVERRIDES)" $(MAKE) -f sub.mk FLAG_B=2 # sub.mk all: @echo "sub MAKEOVERRIDES=$(MAKEOVERRIDES)"执行:
make -f top.mk ROOT=1输出结果如下:
top MAKEOVERRIDES=ROOT=1 mid MAKEOVERRIDES=ROOT=1 FLAG_A=1 sub MAKEOVERRIDES=ROOT=1 FLAG_A=1 FLAG_B=2看到了吗?每一层递归都会把上一层的命令行变量定义原样带下去,再叠加自己这一层新增的变量。MAKEOVERRIDES就像滚雪球,层数越深,携带的内容越多。
如果只是两层三层,问题还不明显。但大型项目的 makefile 经常有七八层甚至十几层递归,每层都加几个变量,到最后MAKEOVERRIDES可能会膨胀到几十 KB。在极端情况下,子 make 的启动命令会超过操作系统单条命令长度限制,直接报Argument list too long。别笑,我在实际构建系统里真的见过。
2.3 累积的后果:变量覆盖优先级陷阱
滚动累积带来的最典型问题,就是“子 makefile 里的赋值突然失效”。
假设你的子目录libsub/Makefile里有这么一段:
CC = gcc CFLAGS = -O2单独在libsub目录里执行make,一切正常,CFLAGS就是-O2。但在顶层目录执行整体构建时,顶层命令行可能传入了一个CFLAGS=-O0。这个值会通过MAKEOVERRIDES一路传到子 make,而子 make 里命令行变量的优先级比文件内赋值更高,于是libsub/Makefile里的CFLAGS = -O2形同虚设,最终用-O0编译。
这就是很多人调试半天也找不到原因的经典场景。你以为是某个子目录里的 makefile 写错了,实际上是父层命令行变量顺着MAKEOVERRIDES潜入了子进程,并且保持了“最高优先级”的身份。
更隐蔽的是,这种覆盖不体现在父 makefile 里,也不容易通过grep找到。只有把子 make 环境里的MAKEOVERRIDES打出来,你才能看到完整真相。
3. 实战用法:拿 MAKEOVERRIDES 能做哪些正经事
3.1 判断变量是否来自命令行:批量检查场景
我个人的习惯是,能直接用origin函数就绝不用MAKEOVERRIDES,因为它更可靠。但MAKEOVERRIDES在批量场景里有不可替代的价值。
比如你给构建系统写过一层统一的调试入口,想在启动时把所有命令行传入的变量全部打印出来:
all: @echo "------------------------------------------------------------------" @echo "Command-line variable definitions:" @echo "$(MAKEOVERRIDES)" @echo "------------------------------------------------------------------"这种一次性输出整批变量的操作,origin函数做不到,因为它是一个一个变量查的。MAKEOVERRIDES就像一个快照,想怎么打印、怎么解析都行。
再比如你想判断“用户是否在命令行指定过任何一个版本号相关的变量”:
ifneq ($(filter BUILD_ID=% VERSION=%, $(MAKEOVERRIDES)),) $(info Build version option detected on command line) endif这段逻辑会同时检查BUILD_ID=...和VERSION=...两组变量定义。用$(filter ...)去匹配MAKEOVERRIDES里的条目,比逐个写origin判断要简洁很多。
3.2 按需过滤并转发命令行变量
另一种常见需求是:我不想把父层的所有命令行变量都传给子构建,只想挑几个白名单变量传下去。
可以这样构造:
PASS_VARS := $(filter BUILD_ID=% TARGET_ARCH=%, $(MAKEOVERRIDES)) sub: $(MAKE) -C sub $(PASS_VARS)这样在子 make 的命令行里,只会显式出现BUILD_ID=xxx TARGET_ARCH=xxx这两个变量定义。
但这里有个必须提醒你的坑:如果你不处理MAKEOVERRIDES本身,那么即使你只显式传了两个白名单变量,GNU make 依然会把父层完整的MAKEOVERRIDES内容通过MAKEFLAGS环境变量带给子 make。也就是说,“只传白名单”这个目标,在默认机制下是做不到的。你写$(filter ...)只是把变量“再强调了一遍”,并不能截断自动传递。
如果你真的想实现严格意义的白名单,需要先掐断自动传递通道,再手动转发,这个我在 3.4 里单独说。
3.3 配合 override 给用户变量追加内容
还有一个高频需求:用户从命令行传入了CFLAGS,但你想在用户值的基础上强制追加编译参数,并且不希望用户用任何方式去掉它。
命令行的优先级高于普通 makefile 赋值,所以如果你直接写:
CFLAGS += -g当用户从命令行传CFLAGS=-O0时,makefile 里这条追加会被命令行定义盖掉,最终CFLAGS依然只有-O0,-g根本加不进去。
解决办法是使用override:
override CFLAGS += -g加了override之后,最终值变成-O0 -g。这东西相当于“比命令行变量更高一档”,专门用来在 makefile 里强制修正外部传入的参数。
但这里还有个衍生坑需要注意:override修改的是“当前 make 进程内”的变量值,它不会反向写回MAKEOVERRIDES。也就是说,如果当前 make 还要递归调用子 make,子 make 通过MAKEOVERRIDES收到的是原版命令行值CFLAGS=-O0,而不是你 override 后的-O0 -g。
如果你希望这个追加效果也传到子构建,还是要显式转发:
sub: $(MAKE) -C sub "CFLAGS=$(CFLAGS)"3.4 彻底阻止命令行变量传递的两种姿势
有些场景要求子 make 完全不受父层命令行变量影响,比如子目录里的代码必须严格使用自己的编译参数,不接受外部覆盖。这时候你需要主动掐断自动传递。
第一种做法是在父 makefile 里写:
unexport MAKEOVERRIDESGNU make 官方文档提到,这可以让命令行变量定义不传给子 make。我用的时候发现它影响的是“变量定义”这一部分,命令行选项如-j还是照常传。
第二种做法更彻底:
unexport MAKEFLAGS这会把整个MAKEFLAGS环境变量都拦截掉,包括命令行选项和变量定义。副作用是-j并行配置也传不下去,子 make 会退化成单任务构建,速度可能明显下降。
如果你只针对某一次调用做隔离,不想影响全局行为,还可以在递归命令里用env命令:
sub: env -u MAKEFLAGS $(MAKE) -C sub这会在启动子 make 前临时清掉MAKEFLAGS环境变量,效果和unexport MAKEFLAGS类似,但作用范围只限这一条命令。我在处理个别“特别倔”的子模块时用过这个方法,简单粗暴,不会波及别的 target。
4. 排查记录:那些年和 MAKEOVERRIDES 有关的诡异故障
4.1 故障一:嵌入式工程里报 libs target error 1
之前做嵌入式工程,用的是 Vitis 那套多级 make 结构,构建日志里反复出现:
make[2]: *** [makefile:18: libs] Error 1第一反应当然是去看第 18 行到底执行了什么。结果发现第 18 行是一个递归调用,真正的错误发生在下一层。继续往下挖,最后根本不是 libs 这个 target 本身的问题,而是链接阶段用错了变量。
排查时我在关键子 makefile 里临时加了一行:
$(info [libs] MAKEOVERRIDES=$(MAKEOVERRIDES))打出来之后发现,父层传入的CROSS_COMPILE和子 makefile 内部定义的工具链前缀不一样,两个值同时在MAKEOVERRIDES里存在,优先级更高的命令行版本覆盖了子层配置,导致工具链被整体换掉,编译参数牛头不对马嘴,最终在链接库的时候失败。
那次之后我有两个心得:
Error 1只是结果,不是原因。先想办法把失败那一步的完整命令拿出来,看变量展开成什么了。- 递归层级越深,越要主动打印一次
MAKEOVERRIDES,否则外部变量什么时候混进来的都不知道。
4.2 故障二:子 make 提示“没有指明目标并且找不到 makefile”
这个报错字面意思是“当前目录下没有 Makefile,而且你也没告诉我目标是什么”。我遇到的一次,原因很反直觉:不是没有文件,而是MAKEOVERRIDES里的变量值带有空格,多层拼接之后,子 make 命令行解析错乱了。
假设你在顶层执行:
make APP_NAME="hello world"那么MAKEOVERRIDES里存的是:
APP_NAME=hello world这个值在传给子 make 的时候,如果中间环节没有做好引用处理,子 make 会把APP_NAME=hello当一个赋值,把world当成一个目标名或者 makefile 名来解析。于是它开始找名为world的 Makefile,自然找不到,最终抛出“没有指明目标并且找不到 makefile”。
这种问题极其隐蔽,因为父层看起来很合理,子层的报错又完全牛头不对马嘴。现在我写递归调用时,凡是变量值可能含空格的,都会刻意用单引号或双引号把它们包严实,例如:
sub: $(MAKE) -C sub "APP_NAME=$(APP_NAME)"并在外层加一条经验法则:命令行变量值尽量不要带空格,能用一个 token 表达就绝不用两个。
4.3 故障三:变量值带空格导致 filter 判断失效
继续上面的场景。假设你想在 makefile 里判断用户是否传了APP_NAME:
ifneq ($(filter APP_NAME=%, $(MAKEOVERRIDES)),) $(info APP_NAME found) endif如果用户在命令行执行的是:
make APP_NAME="hello world"那么MAKEOVERRIDES的值是:
APP_NAME=hello world$(filter)是按空格分词匹配的,它会把APP_NAME=hello和world当作两个条目,结果自然对不上。这个判断就会失效。
想粗略判断是否存在某个变量定义,可以用$(findstring):
ifneq ($(findstring APP_NAME=, $(MAKEOVERRIDES)),) $(info APP_NAME maybe found) endif但这也只能做到“大致可用”,一旦变量名存在包含关系,比如APP_NAME和MY_APP_NAME,findstring一样会误判。最稳妥的手段还是回到origin函数:
ifeq ($(origin APP_NAME), command line) $(info APP_NAME definitely comes from command line) endif所以我的建议是:需要精确判断“某个变量来自哪里”,优先用origin;需要整体快照、批量过滤、打印调试,再动用MAKEOVERRIDES。两者配合,基本能覆盖所有实际场景。
4.4 排查步骤速查表
把多年和MAKEOVERRIDES打交道的经验浓缩成一张表:
| 现象 | 可能原因 | 快速排查方法 |
|---|---|---|
| 子 makefile 内赋值不生效 | 父层命令行变量经MAKEOVERRIDES覆盖子层 | 在子 makefile 入口打印$(MAKEOVERRIDES) |
| 递归命令越来越长直至报错 | 多层递归不断累积变量定义 | 用make -n查看真实展开的命令行 |
| 子 make 找不到 Makefile | 变量值带空格引发命令行解析错乱 | 检查MAKEOVERRIDES中是否含空格值 |
| 变量判断逻辑失效 | 按空格分词匹配带空格的赋值串 | 改用$(origin)函数 |
| 工具链/编译参数莫名变化 | 上层传入变量覆盖子层局部配置 | 打印各层MAKEOVERRIDES做对比 |
另外再分享两个调试时的实用小技巧:
- 使用
make -n查看递归调用的真实命令,你会看到MAKEOVERRIDES=...被拼接到子命令里,这是最快还原现场的方式。 - 使用
make -p打印整个 make 数据库,在输出最前面就能看到MAKEOVERRIDES = xxx,不用修改任何 makefile 就能拿到当前环境下的值。
最后说一句我自己的体会。MAKEOVERRIDES这个变量平时存在感很低,文档里也只有寥寥几段话,但它几乎参与了所有递归构建的变量传递。你遇到“变量不生效”“子层配置被莫名覆盖”“递归命令长到离谱”这些经典问题时,它就是破案的第一现场。建议所有写多目录构建的人,排查问题时先做两件事:第一眼去看MAKEOVERRIDES,第二眼去看MAKEFLAGS。
如果这篇内容帮到了你,不妨把你自己的案例也记录下来——分布式构建世界里,被同一个坑绊倒两次的次数,远比我们想象的要多。