☰
Makefile字符串函数详解:patsubst与filter等高频函数的工程实战
2026/10/20 13:43:27 网站建设 项目流程

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.o

2.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.c

3.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 c

4.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/ # 最终镜像和可执行文件

需求是:

  1. 编译src/下所有.c文件,但排除src/tests/目录下的测试文件。
  2. 所有.o文件放在与 src 结构对应的build/子目录下。
  3. 生成依赖文件(.d),用于头文件变更时自动触发重编。
  4. 最终链接生成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/%.osubst%自动按层级匹配,路径结构自动保留
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.txt

todo.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 的字符串函数更适合做"列表派生""路径转换""筛选过滤"这类事,把它想象成胶水,而不是万能工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询