ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

sources文件里的Make宏:求值时机、展开顺序与排查

sources文件里的Make宏:求值时机、展开顺序与排查

盯着一个 sources 文件看了半个小时,每一行都认识,连起来却不知道它在说什么——这种体验在构建系统里太常见了。sources 文件本身往往只有几十行,但它牵扯的东西特别多:宏的定义地点可能在几百行之外,展开时机可能被你写的那行赋值符号悄悄改变,而最关键的是,宏展开出错时构建系统通常不报错,它只是安静地少编译一个文件。这篇就把 sources 文件里那些宏掰开揉碎讲一遍:它们是什么、什么时候被求值、为什么看起来没定义却能跑、出问题时怎么一层层把真实值挖出来。不管你是刚接手一个陌生构建工程,还是自己写配置脚本时被$(call)和$(1)绕晕过,下面这些内容应该都能对上号。我尽量不写"宏就是文本替换"这种一句话结论,而是把它在真实工程里的行为讲透,包括我自己踩过的几个坑。

1. 那份 sources 文件为什么读起来像天书:宏在配置里的两层身份

1.1 先分清 sources 文件到底被谁读

名字叫 sources 的文件在构建系统里有两种截然不同的身份,搞混这一点,后面的理解基本都会跑偏。

第一种身份是源码清单:文件里一行一个路径或者一行一个模块名,被主 Makefile 用include拉进来,然后通过wildcard、patsubst之类的函数变成编译目标。这种文件里的"宏"主要承担路径拼接和条件筛选的职责,典型写法是SRC_$(MODULE) := ...或者CFLAGS += $(addprefix -I,$(INC_DIRS))。

第二种身份是模板定义:文件里用define ... endef定义了一组可复用的多行宏,然后在上层用$(call 宏名,参数1,参数2)展开成完整的规则或变量赋值。这种文件读起来最像天书,因为你在文件里看到的所有$(1)、$(2)在定义处全都是"占位符",只有被 call 的那一刻才有值。

注意:判断你的 sources 文件属于哪一种,最快的办法是看它有没有define关键字,以及它内部是否出现$(1)这类以数字为名字的变量。有,就是模板型;没有,就是清单型。这两种类型的排查手法完全不同。

我在实际项目里遇到的大多数困惑,都来自"以为是清单,其实是模板"——你照着清单型的思路去 grep 一个变量名,永远 grep 不到定义,因为它是作为参数传进来的。

1.2 宏不是变量,是"还没兑现的承诺"

很多人把 Make 里的宏当成普通变量来理解,这是第一个认知偏差。普通变量的语义是"存了一个值,用的时候取出来";而宏的语义更接近"存了一段文本,用的时候重新解释一遍"。

差别在于重新解释这三个字。对一个递归展开的宏,你每次引用它,构建系统都会把它的文本重新走一遍展开流程。这意味着宏内部引用的其他变量,取的是"引用那一刻"的值,而不是"定义那一刻"的值。这就解释了一个非常经典的疑问:为什么我在文件开头把某变量设成 A,宏里引用了它,中间又把它改成 B,最后展开出来是 B?

这个行为不是 bug,是特性。它让配置系统可以把"路径根目录"这种后期才确定的量延迟到最后一刻求值。但它也让代码的阅读顺序和求值顺序脱钩,这是 tian 书感的直接来源。我个人的判断标准很简单:只要一个宏引用了别的变量,就必须确认它被求值的时刻,否则你永远说不清它等于什么。

1.3 三个求值时刻决定了你看到的到底是什么

一个 sources 文件里的表达式,可能在三个不同的时刻被求值,而且这三个时刻的先后顺序是固定的:

求值时刻触发场景典型写法出问题的表现
解析期(parse time)文件被 include 时立刻求值:=赋值、$(wildcard ...)、$(shell ...)文件还没生成就去找,结果为空
展开期(expand time)引用变量时求值=的递归变量、$(call ...)拿到的是"最��"的值,不是你想要的那个
执行期(recipe time)规则命令真正执行时recipe 里的$$VAR、$@、$<make 层的$和 shell 层的$打架

我见过最典型的翻车现场是这样的:某人在 sources 文件里写了FILES := $(wildcard $(GEN_DIR)/*.c),然后抱怨为什么生成目录里的文件一个都没被编进去。原因很直白——:=是解析期求值,解析这个文件的瞬间GEN_DIR里还什么都没有,通配符自然返回空。改成FILES = $(wildcard $(GEN_DIR)/*.c)或者干脆把求值挪到规则依赖里,问题立刻消失。

但反过来说,把所有:=都改成=也不是好主意。递归展开的代价是每次引用都要重跑一遍展开,如果里面有$(shell ...),那就是每次引用都 fork 一个子进程。我在一个中型工程里见过因为这种写法导致make -n就要跑三秒的情况。所以选择哪个符号,本质上是在"求值时机"和"求值次数"之间做权衡,而不是哪个更"现代"。

2. 把 sources 当作 Makefile 片段读:展开顺序与作用域的真实规则

2.1 include 是一条时间线,不是一张目录树

新手读构建系统常有的错觉是:文件之间是"目录树"关系,父子文件之间有作用域隔离。实际上 GNU Make 的include完全是文本拼接——被 include 的文件内容原地插入,所有变量共享同一个全局命名空间,没有模块级作用域这回事。

这个事实解释了一个高频疑问:为什么我在 A 文件里定义的宏,在 B 文件里也能用?答案是没有"文件"这个边界。同时也解释了反向的疑问:为什么我在 B 文件里改了同名变量,A 文件里的行为跟着变了?因为根本就是同一个变量。

真正构成约束的不是文件边界,而是时间线。变量在被赋值之前是空的,赋值之后才有值。所以 sources 文件被 include 的位置极其关键:它必须在它依赖的所有变量定义之后、在所有依赖它的规则定义之前。这个"黄金区间"如果放错了,你会看到一半功能正常一半功能失效,非常难查。

我排查这类问题的固定动作是:在顶层构建文件里按顺序打印$(origin 变量名),看它到底是file(文件里定义的)、command line(命令行传的)、还是undefined。一次打印就能把"到底是谁覆盖了谁"这件事说清楚。

2.2=与:=选错的代价

这两个符号的区别几乎所有教程都会讲,但落到 sources 文件这类配置密集的场景,代价比想象中大得多。

:=是立即求值:右边在赋值这一瞬间就被完全展开,之后无论左边引用的变量怎么变,值都固定。=是延迟求值:右边原封不动存起来,每次引用才展开。

在有依赖链的场景里,这个差异会连锁放大。举个例子:

TOPDIR := /opt/build/root SRC := $(TOPDIR)/src TOPDIR := /opt/build/staging

最后SRC等于/opt/build/root/src,因为:=把它钉死在了第一次赋值的那一刻。如果第二行写成SRC = $(TOPDIR)/src,结果就变成/opt/build/staging/src。

在 sources 文件里,TOPDIR这类变量经常在中途被重新赋值(初始化一次、命令行覆盖一次、子模块再改一次),所以路径类宏用:=还是=,直接决定了最终指向哪个目录。我的经验规律是:纯粹由常量拼出来的路径用:=,包含"可能在后面才确定"的变量的用=。前者快且安全,后者灵活但要小心。

还有第三个符号?=,只在变量未定义时赋值。它在 sources 文件里常用来给可选项设默认值,比如TARGET_ARCH ?= arm。坑在于:如果环境变量里恰好有同名变量,?=会静默失效,你以为的默认值根本没生效。这种问题在有 CI 环境变量注入的场景里特别隐蔽。

2.3 未定义宏不报错,这既是便利也是陷阱

GNU Make 对未定义变量的处理是展开成空字符串,不警告、不报错。这条规则让配置脚本可以写得很优雅(不存在的可选变量自然消失),但也是 sources 文件里最阴险的坑。

设想你写了一个宏,内部引用了$(CONFIG_FEATURE_X),而这个名字拼错了,少写了一个字母。展开结果就是空——某个编译选项没加上,某段代码没被编译,某个源文件被跳过。构建照样成功,产物照样生成,只是少了点东西。这种 bug 可能要到产品测试阶段才暴露。

对这种静默失败,我有两个对抗手段。第一是在调试阶段给 make 加上--warn-undefined-variables,所有未定义变量引用都会打警告。这个开关在正常构建时不要开,输出会淹没你,但排查问题时非常好用。第二是在 sources 文件里对自己写的宏做主动断言:

ifeq ($(strip $(CONFIG_TARGET_NAME)),) $(error CONFIG_TARGET_NAME 未定义或为空,请检查配置生成步骤) endif

$(error ...)会在解析期直接终止构建并打印消息,比等到编译失败再回头找要省太多时间。$(warning ...)则是打一条警告继续跑。这两个函数是我在配置脚本里用得最多的排查工具,成本几乎为零。

3. sources 里高频出现的那几类宏,逐个对上号

3.1 位置参数$(1)$(2):只在 call 的上下文里活着

看到$(1)、$(2)这样的写法,可以百分百确定当前代码位于一个define ... endef块内部。它们是调用时传入的位置参数,$(0)是宏本身的名字。

关键在于:位置参数没有独立作用域,它们只在 call 展开期间临时生效。这意味着两件事。第一,如果你在 define 块之外引用$(1),得到的是空——不是报错,是空。第二,如果 define 块内部又调用了另一个宏,内层宏的$(1)会覆盖外层的$(1),调用返回后外层恢复。

第二点是最容易出问题的地方。我遇到过一个例子,外层宏用$(1)表示模块名,内部调了一个工具宏,那个工具宏也用$(1),结果展开出来模块名变成了工具宏的第一个参数。排查了半天才发现是参数名撞车。

解决方式很土但有效:在 define 块开头,先给位置参数起个人名:

define build_module $(eval MOD_NAME := $(strip $(1))) $(eval MOD_SRCS := $(strip $(2))) ... endef

用$(eval ...)把参数存进具名变量,后续全部用具名变量引用,彻底避开嵌套 call 的参数冲突。代价是多一次 eval,但可读性和安全性提升明显。

注意:$(call 宏名,a,b,c)里如果参数本身包含逗号,必须用变量间接传递,或者对逗号做转义。直接写逗号会被当成参数分隔符,参数数目对不上,而且不报错。

3.2 路径类宏:TOPDIR、CURDIR 与相对路径的拉扯

sources 文件里出现频率最高的宏就是路径类。三个名字必须分清:

CURDIR是 make 的内建变量,表示当前工作目录,也就是你敲 make 时所在的目录。注意它不是"当前正在解析的这个 Makefile 所在目录"——很多构建系统在顶层切换目录后再 include 子目录文件,这时候CURDIR已经变了,但你以为它还是子目录。

MAKEFILE_LIST的最后一个元素,才是当前文件路径(相对路径)。想拿到当前文件的绝对目录,常见写法是:

THIS_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))

这个表达式在 sources 文件内部使用是可靠的,因为它随解析动态变化。但它不能跨文件传递——存进普通变量之后,后面再用就固定成当时那个值了。

第三类是工程自定义的根目录宏,比如TOPDIR、SRC_ROOT、OUT_DIR。这类宏的坑几乎全在"相对路径的基准点"上。同一个./src,在不同文件里被解释成不同的绝对路径,如果某个文件恰好是在别的目录下被 include 的,路径就全错了。

我的习惯是:在 sources 文件里出现的所有路径宏,一律先abspath再使用。多写一层函数,换来的是无论从哪个目录调用都正确。这个习惯帮我省掉的排查时间,远超写$(abspath ...)的那几秒钟。

3.3 函数类宏:strip、wildcard、patsubst、foreach 的求值时机

这几个函数在 sources 文件里出现的频率极高,但它们的求值时机差别很大,必须分开记。

$(strip x)展开时立即执行,去掉首尾空白并把连续空白压成一个空格。它最常见的用途不是"美化",而是修 bug——多行变量拼接之后必然带进换行和缩进,如果直接拿去当条件判断的左值,ifeq ($(VAR),y)就会比较失败,因为实际值是y或者y。养成习惯:条件判断里的变量一律包一层 strip。

$(wildcard pattern)也是立即求值。它的坑前面提过了——求值时刻的文件系统状态决定结果。而且它不匹配隐藏文件,也不递归搜索子目录,想要递归得配合$(shell find ...),但后者的代价是解析期就跑一次 shell。

$(patsubst %.c,%.o,$(SRCS))和$(SRCS:.c=.o)效果等价,前者更灵活(可以带目录前缀)。注意patsubst里的%只匹配一段,不要指望它跨目录层级匹配,路径里有斜杠时经常不如预期。

$(foreach var,list,text)的坑在于它的text部分是立即展开的。也就是说,如果你在 foreach 内部想引用循环变量的"延迟值",是做不到的,必须靠$(eval ...)生成新的变量或规则。这也是为什么很多构建脚本里 foreach 和 eval 总是成对出现:

$(foreach m,$(MODULES),$(eval $(call build_module,$(m))))

这行代码看着简单,实际包含了三层:foreach 遍历模块名,call 调用模板函数生成规则文本,eval 把文本当 Makefile 代码执行。少了任何一层都不行。我在刚开始用时经常漏掉 eval,结果各种规则文本被当成字符串塞进某个变量,构建静默地什么都不做。

3.4 条件与开关类宏:CONFIG_ 前缀背后的生成流程

带CONFIG_前缀的宏绝大多数不是手写的,而是从配置文件通过工具生成出来的,形式可能是 Makefile 片段,也可能是 C 头文件。理解这个生成流程,能解开一大半"为什么这个开关不生效"的疑问。

典型链路是:配置描述文件 → 生成工具 → 中间头文件 + 中间 Makefile 片段 → 被 sources 文件 include。断点可能出现在每一环:

断点位置表现排查方式
配置项未被写入描述文件值始终是n看配置描述文件里有没有这一项
生成工具没跑中间文件是旧的或不存在的看中间文件的时间戳,手动删掉重跑
中间文件没被 include变量未定义,展开为空$(origin CONFIG_X)返回 undefined
值是y但带空格ifeq判断失败打印[$(CONFIG_X)]看中括号里有没有空格
判断写在 include 之前判断时变量还不存在检查 include 顺序

我遇到最多的是最后一种:判断块的物理位置在 include 之前,逻辑上却依赖 include 之后的变量。这种问题不会报错,只会安静地走 else 分支。养成一个习惯——把条件判断集中在文件末尾,或者明确放在所有 include 之后,能避免大量这类问题。

4. 那些"我明明没定义它"的疑问:宏从哪儿冒出来的

4.1 origin 和 flavor:两个能把来源问清楚的函数

$(origin VAR)返回变量的来源,取值包括undefined、default、environment、file、command line、override、automatic。这个函数在排查"我的赋值为什么不生效"时几乎是必杀技。

优先级从高到低大致是:override> 命令行 >file。也就是说,如果你在 sources 文件里写了CFLAGS := -O2,但命令行上带了CFLAGS=-O0,你文件里的赋值会被忽略,而且没有任何提示。这就是为什么"改了配置没反应"的场景里,第一步要确认命令行有没有传同名变量。

$(flavor VAR)返回simple或recursive,告诉你这个变量是:=还是=定义的。这个信息在判断"值为什么变了"时很关键——recursive的变量值会随引用的变量变化,simple的不会。

$(value VAR)则返回变量的未展开原文。这个函数特别适合检查 define 定义的多行宏:用$(info $(value 宏名))能看到宏的原始定义文本,包括换行和缩进,判断语法有没有写错一目了然。

这三个函数配合起来,基本可以回答关于宏的所有"它是什么、从哪来、怎么求值"的问题。我在 sources 文件末尾经常留一段调试代码,用ifdef DEBUG_MAKE包起来,打开就把所有关键变量打印一遍:

ifdef DEBUG_MAKE $(info === 关键变量快照 ===) $(info TOPDIR = $(TOPDIR)) $(info TOPDIR 源 = $(origin TOPDIR)) $(info SRCS = $(SRCS)) $(info SRCS 类型 = $(flavor SRCS)) $(info === 快照结束 ===) endif

成本极低,收益极大。建议每个稍微复杂点的 sources 文件都留这么一段。

4.2 环境变量、命令行赋值与被覆盖的默认值

除了文件里的赋值,宏还有两个外部来源:环境变量和命令行。这两个来源的优先级高于文件内的普通赋值,所以它们是"我的默认值失效了"的主要嫌疑。

环境变量这块,最容易出问题的是那些名字很通用的变量,比如ARCH、TARGET、CC、CFLAGS。这些名字在很多开发机上是常驻的,一套环境跑得好好的,换台机器就出问题。排查方法就是$(origin)看它是不是environment。

如果确认是环境变量污染,有两种处理方式。一是用override强制覆盖,override CFLAGS := ...;二是用unexport把它从传给子 make 的环境里剔除。前者更适合明确要接管某个变量的场景,后者适合"这个变量根本不该被外部影响"的场景。

命令行优先级的处理则更微妙。make CFLAGS=-O0这种写法是很常见的调试手段,但如果你的构建脚本里对CFLAGS做了大量追加操作,这些追加会全部丢失,因为命令行赋值把整个变量接管了。想要"在命令行基础上继续追加",得用override CFLAGS += ...。这个细节我在文档里看了好几遍才真正记住,因为它的行为不符合直觉。

4.3 define/endef 多行宏里的隐藏陷阱

define ... endef定义的多行宏,表面上是"把一段文本打包",实际使用时有三处极易翻车。

第一处是行首缩进。define 块内部的内容会原样保留缩进,包括空格和 tab。如果这段宏最终要被 eval 成 Makefile 规则,行首的 tab 是有意义的(recipe 行必须用 tab 开头),但如果是空格,规则就不成立,make 会安静地把这些行当成别的东西。我在一个项目里遇到过 eval 出来的规则完全不执行的情况,最后发现是编辑器把 tab 自动转成了四个空格。

第二处是末尾换行。define 块的最后一行后面如果多一个空行,展开出来的文本会多一个换行符,拼接到规则里可能导致语法错误或者意外的空规则。用$(strip)包一层能缓解,但 strip 也会把内部有意义的换行压掉,所以要小心使用位置。

第三处是宏内部的$转义。如果你希望某个$一路传到 shell 而不是在 make 层被展开,在 define 块里要写成$$。而且如果这个 define 块还要被 eval 二次展开,转义层数要再加一层,变成$$$$。这个层数问题几乎是所有复杂构建脚本的必经之路,我至今没找到比"写一层、打印一层、确认一层"更可靠的办法。

5. 排查实录:新增条目没被编译进去的完整链路

5.1 现象与第一轮假设

说一个我印象比较深的排查过程。背景是某构建工程,我在 sources 文件里新增了一个模块条目,格式完全照抄旁边几行,路径也确认存在。构建跑完,没有任何报错,但产物里就是没有这个模块的目标文件。

第一轮假设有三个:一是新加的行被注释掉了或者拼写有误;二是这个模块的源文件通配符没匹配上;三是模块列表变量在别处被重新赋值覆盖了。

逐条验证:第一,用grep -n把那一行找出来,没有注释符号,拼写和旁边的条目对比一致。排除。第二,单独写一个测试目标,用$(wildcard 路径/*.c)打印,能看到文件列表,说明路径和通配符本身没问题。排除。第三,这个需要看变量最终值,进入下一轮。

5.2 用 info/warning 把中间值打出来

在 sources 文件末尾插入了一行$(info MODULE_LIST = $(MODULE_LIST)),构建时输出里能看到我的新模块确实在列表里。这就排除了"被覆盖"的可能,说明模块名进去了,问题出在后面的处理环节。

继续往里追。模块列表是被一个 foreach + call 组合消费的,于是在 call 的宏内部也插了一行 info,打印每个模块名和它对应的源文件列表。这时候问题浮现了:新模块被遍历到了,但它的源文件列表是空的。

回到宏内部看源文件是怎么得到的,发现是这么写的:

$(eval MOD_SRCS := $(wildcard $(MOD_DIR)/$(MOD_NAME)/*.c))

到这里基本锁定是求值时机问题。因为:=是立即求值,而整个 foreach + eval 的执行发生在解析期——也就是说,在解析到这一行的时候,构建系统还没做任何"生成源文件"的动作。

5.3 定位到 wildcard 的求值时刻

为了验证这个判断,我做了个简单实验:把:=改成=,也就是让MOD_SRCS变成递归展开。重新构建,模块被编进去了。

这印证了假设,但也暴露了另一个问题:改成=之后,每次引用MOD_SRCS都会重新跑一次 wildcard,构建时间明显变长(这个工程模块多,每个模块都要扫目录)。

更严重的是,MOD_SRCS还会被用在依赖声明里。递归展开的变量出现在依赖位置时,make 会在解析期就求值一次,那个时刻文件还是不存在,依赖关系依然是空的。所以改成=只是碰巧因为别处的引用时机靠后才生效,本质问题没解决。

5.4 修复方案与验证方法

正确的修法是把源文件的收集从"解析期"挪到"执行期",或者说,让生成源文件的步骤成为收集步骤的前置依赖。具体做法有两种。

方案一是加一个显式的收集目标,让它依赖生成步骤,然后在规则体内重新计算文件列表:

$(MOD_OBJS): $(GEN_STAMP) $(eval MOD_SRCS := $(wildcard $(MOD_DIR)/$(MOD_NAME)/*.c)) ...

但这写法很别扭,因为 eval 在 recipe 里的行为不好控制。

方案二更干净:让生成步骤在解析期之前完成,或者说,用 make 的"重新执行自身"机制。在某构建框架里,这通常表现为"生成中间 Makefile 片段,然后 include 它"的两阶段模式。第一阶段 make 只负责跑生成工具,第二阶段重新解析所有文件,这时候文件已经存在,wildcard能正常工作。

我最终采用的是方案二的简化版:把源文件收集的逻辑从 sources 文件里移出去,改为由生成工具一并输出一个"文件清单片段",sources 文件只负责 include 这个片段。这样 sources 文件里不再有 wildcard,求值时机的问题从根上消失了。

验证方法也简单:清空构建目录,从零开始跑一遍,确认新模块在产物里;然后只改一个新源文件,增量构建一次,确认改动被正确重编。两个方向都通过,才算真的修好。只看全量构建通过是不够的,增量场景才是求值时机问题最容易复发的地方。

6. 改 sources 文件时的几条硬规矩

6.1 语法层面的自检清单

每次动 sources 文件之前,我会快速过一遍这几条,能拦掉大部分低级错误。

检查项为什么怎么查
define 块内行首是 tab 还是空格recipe 行必须 tab 开头用cat -A看行首是^I还是空格
赋值符号选对了吗:=立即、=延迟问自己"这行引用的变量会在后面变吗"
call 的参数有没有多余空格参数带空格会污染结果参数之间逗号后不要留空格
条件判断的左值 strip 了吗多行拼接带来换行写成ifeq ($(strip $(X)),y)
eval 有没有漏漏了就把规则当字符串存了检查每个 call 是不是都被 eval 包着
include 顺序对吗变量必须先定义后引用判断块统一放文件末尾
路径有没有 abspath相对路径基准点不一致路径宏一律$(abspath ...)

这份清单我贴在显示器边上贴了很久,后来变成条件反射就不需要了。但刚开始接手陌生构建工程时,逐条对一遍能省掉大量来回。

6.2 让宏"可观测"的三种低成本手段

宏最大的问题是不透明。对抗不透明,我常备三种手段,成本都很低。

第一种是$(info ...)打印。它输出到 stdout,不影响构建流程,可以随时插随时删。适合快速看一眼中间值。缺点是位置敏感——放在哪个解析阶段,看到的就是哪个阶段的值。

第二种是$(warning ...)报警。输出到 stderr,带上文件名和行号,比 info 更容易定位。适合放在宏内部,作为"走到这里了"的标记。

第三种是make -p导出全部变量和规则。输出量巨大,但配合 grep 极其好用:

make -p -n 2>/dev/null | grep -A2 '^SRCS *[:?]*='

注意要用-n避免真的执行构建。-p的输出里每个变量都会带上定义所在的文件和行号,这是追根溯源最快的方式。我经常用它来确认"这个变量到底是被哪一行定义的",尤其是当一个变量在多个文件里被反复赋值时。

还有一种组合技值得一提:make -p加上--warn-undefined-variables,能看到所有变量的最终状态以及所有未定义引用。这两条一起用,基本等于给整个构建系统做了一次 X 光。

6.3 一个最小复现工程,验证你对宏的理解

如果你对某个宏的行为拿不准,最有效的办法是搭一个几十行的最小工程验证,而不是在主工程里试。主工程变量太多,任何观察结果都不可信。

最小工程的结构很简单:一个顶层 Makefile,一个 sources 文件,两三个空源文件。然后在 sources 文件里写几行你想验证的写法,跑一遍看结果。

我验证"求值时机"时用的模板大概是这样:

# sources.mk FILES_IMM := $(wildcard $(DIR)/*.c) FILES_DEF = $(wildcard $(DIR)/*.c) all: @echo "立即: [$(FILES_IMM)]" @echo "延迟: [$(FILES_DEF)]" @touch $(DIR)/new.c @echo "创建后 立即: [$(FILES_IMM)]" @echo "创建后 延迟: [$(FILES_DEF)]"

跑一遍就能直观看到:FILES_IMM在解析时定死,创建新文件后不变;FILES_DEF每次引用重新扫描,能看到新文件。这个几十行的例子比看十篇文档都管用。

同样的方法可以验证$(1)在 call 外是否为空、$$到底传到哪一层、foreach里的变量作用域等等。我的经验是,凡是拿不准的语法,花五分钟在最小工程里验证一次,比在主工程里猜半小时要划算得多。而且验证出来的结论可以写进团队文档,后面接手的人不用再踩一遍。

最后分享一个小习惯:在 sources 文件顶部写几行注释,说明这个文件被谁 include、它依赖哪些变量已经在此之前定义好、它自己导出了哪些变量。这三句话看着不起眼,但在一个几十个文件互相 include 的构建系统里,它们是唯一能让人快速建立心智模型的东西。我接手过的维护得最好的构建工程,sources 文件都不长,但顶部注释都写得很细。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进