1. 从"看得懂"到"用得活":Makefile字符串函数的真正价值
做Makefile的人大多有这样的经历:写了几十条规则,变量用了不少,但一到"把标识替换成目录""把源文件列表转换成目标文件列表"这类需求,就只能手动写一堆重复规则,或者复制粘贴好几遍同样的名字列表。等工程规模上来,维护成本急剧上升。
我第一次真正被字符串函数"拯救",是在一个目录结构经常调整的嵌入式项目里。最开始所有源文件摆在一个目录下,编译规则非常简单:
OBJS = main.o uart.o spi.o后来为了理清代码结构,把外设驱动拆到drivers/下,显示逻辑放到display/下。如果还用上面的写法,每个.o都得手动加前缀、改路径,几十个文件列表改一次,效率低不说,还容易漏。后来我换成了这样:
SRC_DRIVERS = $(wildcard drivers/*.c) SRC_DISPLAY = $(wildcard display/*.c) OBJS_DRIVERS = $(patsubst %.c,%.o,$(SRC_DRIVERS)) OBJS_DISPLAY = $(patsubst %.c,%.o,$(SRC_DISPLAY))从那以后,不管往目录里加多少文件,编译目标列表自动跟着变。这就是字符串函数的价值,它把Makefile从"手工清单"提升到"规则描述"的层面。
这篇文章我就把patsubst、filter、strip、subst、word、foreach这些高频字符串函数按实际使用场景串起来讲。
2. patsubst 与 subst:文本替换的两把板斧
2.1 先搞懂模式匹配的核心规则
patsubst的全称是pattern substitution,也就是"模式替换"。它的语法是:
$(patsubst 模式, 替换为, 文本列表)这里的关键是%这个通配符。它匹配任意长度的字符序列(长度可以为0)。比如%.c能匹配main.c、uart.c,也能匹配a/b/c/foo.c,%捕获的部分就是去掉.c后剩下的内容。
看几个实际例子:
$(patsubst %.c,%.o,main.c uart.c spi.c) # 结果: main.o uart.o spi.o $(patsubst src/%.c,obj/%.o,src/main.c src/uart.c) # 结果: obj/main.o obj/uart.o第二个例子值得注意:%在模式里不只是匹配文件名,它还能匹配路径的一部分。src/%.c匹配src/main.c时,%捕获的是main,替换端obj/%.o就变成obj/main.o。这让目录重定向变得极其方便。
如果说patsubst是"模式感应的替换",那subst就是"字面替换"。它不认%,纯粹把一段文本原样替换成另一段:
$(subst 被替换, 替换为, 文本) $(subst .c,.o,main.c uart.c) # 结果: main.o uart.o2.2 在实际工程里怎么选
这两者用起来有几条经验法则:
- 要做"前后缀替换",比如
.c变.o、.cpp变.o、.c变.d(依赖文件),优先用patsubst,因为它把模式匹配写在明面上,逻辑清晰。 - 要做"固定单词替换",比如把变量值里的
debug全部换成release,用subst更直接。 subst的替换是"全局"的,文本里出现的每一处都会被替换。比如$(subst a,x,a b a c)的结果是x b x c,不管a处在单词中间还是单独出现。patsubst的匹配是按%规则来的,$(patsubst %a,%x,a b a c)会把末尾是a的项替换,结果是x b x c?不对,要小心:patsubst是对列表里的每一项分别匹配,a匹配%a时%为空,替换为%x也就是x;b不匹配;第二个a也一样变成x,结果确实是x b x c。
2.3 经典场景:自动生成编译目标
这是字符串函数在Makefile里最经典的应用。通常配合wildcard一起用:
SRC := $(wildcard src/*.c) OBJ := $(patsubst src/%.c, build/%.o, $(SRC)) build/%.o: src/%.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) -c $< -o $@这段规则的含义是:所有src/下的.c文件,经过patsubst都生成了build/下的.o路径,而make会根据这些目标路径去尝试匹配build/%.o规则。这是Makefile自动化构建最常见、也是最重要的用法。
我补充一个在真实项目里踩过的坑:用patsubst做路径转换时,源路径和输出路径的目录层级必须对齐,否则匹配不成功。比如:
$(patsubst src/%.c, build/obj/%.o, src/lib/net/eth.c)这里%捕获的是lib/net/eth,替换后是build/obj/lib/net/eth.o,目录层级完整保留。但如果你写的是:
$(patsubst src/%.c, build/%.o, src/lib/net/eth.c)结果就是build/lib/net/eth.o,build下的目录结构自动跟着源目录走。如果你在后面那条规则里也保留了完整的子目录,就没问题;如果没注意,很容易出现"文件生成了但依赖链断掉"的诡异现象。排查半天,最后发现是路径层级没对齐。
3. filter 和 filter-out:列表筛选,专治"只要一部分"
3.1 这两个函数到底在筛什么
filter的意思是从一个列表里"挑出符合模式的项",filter-out相反,是"剔除符合模式的项"。
$(filter 模式1 模式2 ..., 文本列表) $(filter-out 模式1 模式2 ..., 文本列表)注意,filter支持多个模式,用空格分隔,只要匹配其中任意一个,就会被保留(filter-out则是任何一个都不匹配才会保留)。这里同样用%做通配。
几个例子:
$(filter %.c %.s, main.c boot.s util.h) # 结果: main.c boot.s $(filter-out %.h %.inc, main.c util.h config.inc) # 结果: main.c3.2 实际项目中怎么用
3.2.1 过滤掉不需要参与编译的文件
很多项目的源码目录里,并不是所有.c都要编进同一个镜像。比如有单元测试文件、平台无关的参考实现、或者临时注释掉的文件。最笨的办法是把文件名删掉,但很容易误删;用filter-out就能在变量层面处理:
SRC_ALL := $(wildcard src/*.c) SRC_EXCLUDE := src/TestStub.c src/ReferenceImpl.c SRC := $(filter-out $(SRC_EXCLUDE), $(SRC_ALL))filter-out的第一参数可以是一个变量,它会展开成一个空格分隔的列表。这样把不想编译的文件集中写在SRC_EXCLUDE里,维护起来非常直观。
3.2.2 配合不同编译选项
嵌入式项目的常见场景:多数文件用普通CFLAGS编译,个别文件需要额外的优化选项,比如启动文件、ITCM/DTCM 相关的初始化代码。
SRC := $(wildcard src/*.c) # 需要特殊优化选项的文件 SRC_FAST := $(filter %_optimized.c, $(SRC)) # 其他普通文件 SRC_NORMAL := $(filter-out %_optimized.c, $(SRC)) $(SRC_NORMAL:%.c=build/%.o): build/%.o: src/%.c $(CC) $(CFLAGS) -c $< -o $@ $(SRC_FAST:%.c=build/%.o): build/%.o: src/%.c $(CC) $(CFLAGS) -O3 -c $< -o $@这样一拆,哪些文件走快优化、哪些走常规优化,全在变量定义里一目了然。
3.3 filter 的边界:别拿它做"精确匹配等于"
有一个容易踩的坑:filter不是精确匹配,它做的是模式匹配。如果你写$(filter debug, $(MODE)),只有当MODE里恰好有一个 "debug" 项时才命中。但如果MODE的值是 "debug_all",那不会命中——这是filter的特性。反过来,$(filter debug%, $(MODE))就能命中debug_all。
我建议在需要"判断变量是否等于某个值"时,不要依赖filter,直接用ifeq更清晰:
ifeq ($(BUILD_MODE),release) CFLAGS += -O2 endif而filter更适合"是否存在某个后缀的文件""是否包含某个前缀的项"这类模糊判断。
4. strip、words、word、wordlist:精确控制文本节奏
4.1 strip 不只是去空格
strip的官方用途是去掉首尾空白并压缩内部连续空格。刚学Makefile时我以为它只是"格式化输出用的",后来才发现在变量比较和列表长度判断里,它经常起关键作用。
$(strip 文本) $(strip a b c ) # 结果: a b c $(strip 带前导空格 的文本串 ) # 结果: 带前导空格 的文本串注意:strip压缩的是"单词之间的连续空格",不是把文本里的空格全删掉。比如$(strip a b)结果是a b,不是ab。
有一个很容易出问题的场景:变量值在跨行写的时候,常常带出意外的空格。
VERSION = 1.0 \ 2.0这种续行写法容易让变量带上多余空白,初学时容易在这种地方浪费一晚上排查。有些Makefile坑就是strip这个函数可以救命的:
VERSION := $(strip 1.0 \ 2.0)4.2 words、word、wordlist 只管"数"和"取"
$(words 文本)返回单词个数$(word N, 文本)返回第N个单词(从1开始数)$(wordlist S, E, 文本)返回第S到第E个单词
示例:
$(words a b c) # 结果: 3 $(word 2, a b c) # 结果: b $(wordlist 2, 3, a b c d) # 结果: b c4.3 它们组合起来能干什么
4.3.1 判断列表是否为空
ifneq ($(strip $(SRC)),) OBJS := $(SRC:.c=.o) endif这一招很常用:先strip去掉由于换行造成的首尾空格,再判断是否为空。如果你直接ifneq ($(SRC),),因为续行符可能给SRC带上换行或空格,导致"看似为空其实非空"的边界问题。
提示:Makefile里对"空"的判断,最好统一写成
ifneq ($(strip $变量),),这是我在多个项目里反复验证过的稳妥写法。
4.3.2 获取列表最后一个元素
这个需求在构建版本号、拼接命令时经常出现:
LAST_ITEM := $(word $(words $(LIST)), $(LIST)) SECOND_ITEM := $(word 2, $(LIST))$(words $(LIST))得到列表长度,把它作为word的下标,就能取出最后一个元素。这个思路很直接,但第一次看到时需要转个弯。
4.3.3 截断前N个元素
FIRST_THREE := $(wordlist 1, 3, $(LIST))需要取前几个文件、前几个参数时,wordlist比shell命令更轻量,不产生新进程。
5. 字符串函数的组合实战:一个可靠的构建通用模板
5.1 需求描述
实际项目里很少用单个函数解决所有问题,更多是几个函数叠加。我在这里给出一个接近真实场景的模板,大家可以直接抄。
假设项目结构如下:
project/ ├── Makefile ├── src/ │ ├── main.c │ ├── util/ │ │ ├── helper.c │ │ └── helper.h │ ├── drivers/ │ │ ├── uart/ │ │ │ ├── uart.c │ │ │ └── uart.h │ │ └── spi/ │ │ ├── spi.c │ │ └── spi.h │ └── tests/ │ ├── test_util.c │ └── test_driver.c ├── build/ # 临时存放编译产物 └── output/ # 最终镜像和可执行文件需求是:
- 编译
src/下所有.c文件,但排除src/tests/目录下的测试文件。 - 所有
.o文件放在与 src 结构对应的build/子目录下。 - 生成依赖文件(
.d),用于头文件变更时自动触发重编。 - 最终链接生成
output/app.elf。
5.2 一步步拆解
第一步:枚举源文件
SRC := $(wildcard src/*.c src/*/*.c) TEST_SRC := $(wildcard src/tests/*.c) SRC := $(filter-out $(TEST_SRC), $(SRC))这里用wildcard枚举,再用filter-out剔除测试文件。注意src/*/*.c只匹配两级目录,不会递归到更深层。如果源文件层级更深,可以用$(shell find src -name "*.c"),但会有shell进程开销,通常中小项目用两层通配就够。
第二步:转换成对象文件路径
OBJ := $(patsubst src/%.c, build/%.o, $(SRC))%会捕获src/后面的路径,包括util/helper、drivers/uart/uart等等,替换成build/util/helper.o、build/drivers/uart/uart.o。目录层级自动保持。
第三步:生成依赖文件列表
DEP := $(patsubst %.o, %.d, $(OBJ))第四步:写模式规则
build/%.o: src/%.c @echo "编译: $<" @mkdir -p $(dir $@) $(CC) $(CFLAGS) -MMD -MP -c $< -o $@注意-MMD -MP选项:编译器在编译时自动生成.d依赖文件,放在和.o相同的目录下。这也解释了为什么需要mkdir -p $(dir $@),因为.d文件也会跟着输出到那个目录。
第五步:包含依赖文件
-include $(DEP)这个-include前的减号意思是:如果.d文件不存在,也不要报错。首次编译时.d还没生成,如果直接include会直接失败,用-include就能静默跳过。
5.3 组合后的完整Makefile
CC := gcc CFLAGS := -Wall -Wextra -g TARGET := output/app.elf SRC := $(wildcard src/*.c src/*/*.c) TEST_SRC := $(wildcard src/tests/*.c) SRC := $(filter-out $(TEST_SRC), $(SRC)) OBJ := $(patsubst src/%.c, build/%.o, $(SRC)) DEP := $(patsubst %.o, %.d, $(OBJ)) all: $(TARGET) $(TARGET): $(OBJ) @mkdir -p $(dir $@) $(CC) $(CFLAGS) $^ -o $@ build/%.o: src/%.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) -MMD -MP -c $< -o $@ -include $(DEP) clean: rm -rf build output .PHONY: all clean这段模板我实际用在好几个项目里,改动成本很低。最核心的威力在于:OBJ、DEP完全由SRC派生出,以后新增源文件,只要放进src/目录,编译列表自动更新,不用再改Makefile。
5.4 这个场景里字符串函数的分工
用表格总结一下各函数的角色:
| 函数 | 在本模板里的作用 | 替代方案 | 为什么选它 |
|---|---|---|---|
wildcard | 枚举源文件 | shell find | 内置函数、无进程开销、直观 |
filter-out | 排除测试文件 | 手动删除列表项 | 可维护性好,排除项集中管理 |
patsubst | 路径转换src/%.c -> build/%.o | subst | %自动按层级匹配,路径结构自动保留 |
patsubst | .o -> .d依赖文件 | 手写 | 二次利用同一模式替换思路,实现依赖收集 |
dir | 获取输出目录,配合mkdir -p | 手动拼接 | 和$@组合后自动建目录,不用关心路径层级 |
6. foreach 循环:当字符串函数不够用时的"最后手段"
6.1 为什么需要它
偶尔会遇到单个模式替换搞不定的情况。比如,你需要按目录结构生成多个输出文件的路径,每个子目录额外加一层结构;或者需要对列表中的每一项做复杂的递归处理。foreach允许你定义一个临时变量,对列表逐项展开表达式。
$(foreach 变量, 列表, 表达式)它的工作方式和其他语言里的for循环差不多,但Makefile里它更像"用给定变量名逐项代入表达式再展开"。
6.2 典型示例:镜像目标列表
假设你要生成f1.img,f2.img等多个镜像文件:
IMAGES := $(foreach n, 1 2 3 4, img$(n).bin) # 结果: img1.bin img2.bin img3.bin img4.bin当然,这个例子用patsubst也能做:$(patsubst %,img%.bin,1 2 3 4),但foreach的优势在复杂场景,比如每个子目录需要一套独立处理逻辑时。
6.3 foreach 嵌套的"坑"
foreach允许嵌套,但嵌套时变量名不能冲突。内部foreach使用同一变量名时,外层的值会暂时被覆盖,这在某些 Make 版本里会有行为差异。
$(foreach dir, a b, \ $(foreach file, x y, $(dir)/$(file))) # 结果: a/x a/y b/x b/y这个例子里,内层用了file,和外层dir不冲突,结果正确。如果把内层变量也命名为dir,内层展开时会遮蔽外层dir,导致结果变成a/x a/y a/x a/y之类的错乱。我建议嵌套时一定要起不同的变量名,哪怕多写几个字符也值得。
6.4 实战:给每个子目录单独生成链接脚本
有些项目每个子模块需要单独的链接脚本或配置生成规则。用foreach可以这样组织:
MODULES := drivers/uart drivers/spi display CONFIG_FILES := $(foreach m, $(MODULES), $(m)/link.ld) # 结果: drivers/uart/link.ld drivers/spi/link.ld display/link.ld它和patsubst的差异在于:这里MODULES列表是"经过计算的、可能还嵌套了其他函数"的结果,直接写%模式时,如果每个模块的目录层级不一样,patsubst匹配会非常困难,而foreach可以逐项精确操作。
7. 实际维护中我踩过的字符串函数大坑
7.1 变量值与空格
这是Makefile里最隐蔽的坑之一。看这个写法:
SRC = $(wildcard src/*.c) \ $(wildcard src/util/*.c)由于续行后的前面有空格,变量SRC里每一项之间可能带着额外的空白或者换行。很多函数对这并不敏感(比如patsubst对空格的处理还行),但ifeq比较、$(strip)使用不当,就可能出现莫名其妙的失败。
我的经验是:凡是想用ifeq判断字符串类型的变量,一律先strip再做比较。
7.2 filter 和 patsubst 的匹配细节
filter的模式列表不支持|或,多个模式之间只用空格。我见过有人写:
$(filter %.c|%.h, $(SRC))以为能匹配.c或.h,实际完全匹配不上。正确写法是:
$(filter %.c %.h, $(SRC))同理,patsubst每次只接受一个模式,不能像正则表达式那样用(a|b)。所有Makefile字符串函数的通配符都只有%,没有正则能力,这是刚上手最容易形成误解的地方。
7.3 在 shell 命令里过度使用字符串函数
有些Makefile会把$(subst , ,...)用在规则的命令行里,比如:
target: command --flag=$(subst .,_,$(VERSION))这没问题,但如果你已经通过变量定义了带空格的复杂字符串,就直接把变量传入命令行,不要把整串逻辑塞进规则体里,规则体的可读性会急剧下降。把字符串处理结果先在变量阶段算好:
VERSION_STR := $(subst .,_,$(VERSION)) target: command --flag=$(VERSION_STR)这样既能利用Makefile函数,又保持规则体的清晰。特别是当命令行里同时有$(shell ...)和$(patsubst ...)混用时,展开顺序很容易让人头晕,不如把计算挪到变量赋值阶段。
7.4 patsubst 在模式不匹配时的行为
如果某些项不匹配模式,patsubst会原样保留。比如:
$(patsubst %.c, %.o, main.c todo.txt) # 结果: main.o todo.txttodo.txt不匹配%.c,就直接原样通过。这个特性有时被忽略,导致列表里混入了不该有的文件。在大型工程里,最好用$(filter)先验证变量内容,或者用$(warning $(SRC))临时打印出来观察。
提示:调试字符串函数,最快的方法是在Makefile里临时加一行
$(warning 变量名 = $变量名),make 会把展开结果打印到终端。排查完删掉即可。
8. 用字符串函数养成的"结构化思维"
8.1 从"写死列表"到"定义规则"
学习字符串函数前后最大的变化,不是多背了几个函数名,而是写Makefile的思路变了。以前写OBJS = main.o uart.o spi.o是"枚举思维",每加一个文件改一次;现在写OBJS = $(patsubst %.c,%.o,$(wildcard *.c))是"规则思维",只要目录内容变了,目标列表自动跟着变。
8.2 分层构建自己的"函数库"
随着项目变多,我会把常用的字符串处理段整理成宏(canned recipe)或者变量定义头文件。比如:
# common.mk SRC_TO_OBJ = $(patsubst src/%.c, build/%.o, $(1)) STRIP_LIST = $(strip $(1)) FILTER_OUT_DIRS = $(filter-out $(2), $(1))然后在其他Makefile里引入:
include common.mk OBJS := $(call SRC_TO_OBJ, $(SRC))这样不仅一份逻辑多处复用,也逼迫你把"变量怎么派生"想得更清楚。
8.3 边界情况:什么时候不该用字符串函数
字符串函数再方便,也有不适合的场景。比如:
- 需要对文件内容做复杂处理,应该用
sed、awk,甚至借助外部脚本。 - 需要对源文件做"是否存在"判断,应该用
wildcard而不是硬编码列表。 - 需要递归列举深层目录文件,
$(shell find ...)更合适,但要注意进程开销。
Makefile 的字符串函数更适合做"列表派生""路径转换""筛选过滤"这类事,把它想象成胶水,而不是万能工具。