1. 为什么字符串处理函数是 Makefile 里最被低估的一环很多人写 Makefile 的经历都差不多一开始只会写死规则gcc -o main main.c这种一行行的硬编码项目小的时候没问题一旦源文件从三个变成三十个或者需要在不同平台、不同编译选项之间切换整个文件就开始失控。这时候大多数人会去查变量、查模式规则、查自动变量但很少有人第一时间想到字符串处理函数。我见过不少项目Makefile 里充斥着大量重复的路径拼接、手动维护的源文件列表、针对不同目录写死的编译规则。问起来为什么不用函数处理回答往往是函数那套语法看着就头大。但实际情况是Makefile 的字符串处理函数一共就那么几个语法统一用熟之后能把 Makefile 的体积压缩一半以上而且可维护性完全不是一个量级。这篇内容聚焦八个最常用的字符串处理函数patsubst、subst、strip、findstring、filter、filter-out、sort、word。它们解决的核心问题就一类在 Makefile 解析阶段对文本做批量、规则化的变换。注意这里的关键词是解析阶段不是 shell 执行阶段。这意味着这些函数在 make 读取 Makefile 的时候就已经算完了不产生任何子进程开销这也是它们比在 recipe 里调 shell 命令更优雅的原因。适合谁看如果你已经能写出基本的 Makefile知道变量赋值和模式规则是怎么回事但每次遇到怎么把一堆 .c 文件自动转成 .o 文件列表怎么从源文件列表里排除掉测试文件怎么给每个文件名加上前缀这类问题时还在手动敲那这八个函数就是你需要补上的那块拼图。下面我按实际使用频率和难度把它们拆开讲每个都配上能直接抄的场景。2. 文本替换三件套subst、patsubst 与 strip 的分工2.1 subst最直白的全局字符串替换subst的语法是$(subst from,to,text)作用是把text中所有的from替换成to。它做的是纯粹的字符串匹配不涉及任何模式概念就是看到这个子串就换掉。举个实际场景。假设你的项目里有一批文件路径用的是反斜杠风格比如从某些配置里读出来的但 Makefile 在类 Unix 环境下需要正斜杠RAW_PATHS : src\main.c src\util.c src\io.c FIXED_PATHS : $(subst \,/,$(RAW_PATHS)) # 结果: src/main.c src/util.c src/io.c这里有个容易踩的坑subst的from参数如果本身包含逗号或者空格需要特别小心因为逗号是参数分隔符。比如你想把逗号替换成空格得写成$(subst ,, ,$(TEXT))这种看起来很奇怪的形式——第一个逗号后面紧跟的是要替换成的字符这里是一个空格然后才是逗号分隔的第三个参数。我第一次写的时候在这里卡了很久因为视觉上完全分不清哪个逗号是分隔符哪个是内容。另一个实战用法是批量修改文件后缀。比如你有一批.cpp文件想临时当成.c处理虽然不常见但在某些混合编译场景下会遇到CPP_FILES : a.cpp b.cpp c.cpp AS_C_FILES : $(subst .cpp,.c,$(CPP_FILES))subst的优点是直观缺点是它不认词边界。如果你想把所有独立的foo替换掉但foobar里的foo也被换了subst是分不出来的。这种时候就需要patsubst。2.2 patsubst带模式匹配的替换处理文件列表的主力patsubst的语法是$(patsubst pattern,replacement,text)它把text按空格拆成一个个词对每个词检查是否匹配pattern匹配的话就按replacement生成新词。pattern和replacement里都可以用%作为通配符代表任意长度的任意字符。这是 Makefile 里使用频率最高的字符串函数没有之一。最经典的用法就是把源文件列表转成目标文件列表SRCS : main.c util.c io.c parser.c OBJS : $(patsubst %.c,%.o,$(SRCS)) # 结果: main.o util.o io.o parser.o%在这里的语义是%.c匹配任何以.c结尾的词%捕获的部分也就是文件名主干在%.o里被原样填回去。所以main.c匹配%.c时%等于main替换后就是main.o。这里有个细节值得说清楚patsubst里的%和模式规则里的%是同一套逻辑但作用域不同。模式规则里的%是在 make 匹配目标和依赖时用的patsubst里的%纯粹是文本层面的。理解这一点很重要因为很多人会把两者混淆写出一些逻辑上说不通的规则。patsubst还有一个简写形式用起来更省事。当pattern和replacement都是%开头结尾的简单形式时可以写成$(VAR:%.c%.o)或者更短的$(VAR:.c.o)OBJS : $(SRCS:.c.o) # 等价于 $(patsubst %.c,%.o,$(SRCS))但要注意.c.o这种简写只适用于后缀替换如果你想做更复杂的变换比如给每个文件名加目录前缀就必须用完整的patsubstSRCS : main.c util.c OBJS : $(patsubst %.c,build/%.o,$(SRCS)) # 结果: build/main.o build/util.o2.3 strip清理多余空白让后续处理不出意外strip的语法最简单$(strip text)。它做两件事去掉text首尾的空白把中间连续的多个空白字符合并成一个空格。为什么这个函数重要因为 Makefile 里变量的值经常是通过拼接、条件赋值、环境变量继承等方式来的很容易带上多余的空格。而这些空格在后续用filter、word等函数处理时会造成意想不到的结果。比如DIRS : src include lib # 注意中间有多个空格 CLEAN_DIRS : $(strip $(DIRS)) # 结果: src include lib如果不strip$(word 2,$(DIRS))取出来的可能是空字符串因为第二个词是两个空格之间的空内容而不是include。这种 bug 特别隐蔽因为打印出来看的时候多个空格和单个空格在终端里几乎看不出区别。我的习惯是任何从外部来源环境变量、shell 命令输出、文件读取拿到的字符串在参与函数处理之前先strip一遍。这个习惯帮我省掉了无数次排查空白字符的麻烦。3. 筛选与排除findstring、filter、filter-out 的配合打法3.1 findstring判断子串是否存在findstring的语法是$(findstring find,text)。如果find是text的子串返回find否则返回空字符串。注意它返回的是find本身不是位置索引也不是布尔值。这个函数最常见的用法是配合条件判断ifneq ($(findstring debug,$(BUILD_TYPE)),) CFLAGS -g -O0 else CFLAGS -O2 endif这里$(findstring debug,$(BUILD_TYPE))如果BUILD_TYPE里包含debug就返回debug非空ifneq判断为真。如果BUILD_TYPE是release返回空走else分支。有个细节要注意findstring是子串匹配不是词匹配。如果BUILD_TYPE是debug-asan它也会匹配到debug。这在某些场景下是想要的但在另一些场景下会误判。比如你想精确匹配debug这个词用findstring就不够严谨应该用filter。3.2 filter按模式筛选出匹配的词filter的语法是$(filter pattern...,text)。它把text按空格拆成词返回所有匹配任意一个pattern的词。pattern里可以用%通配符。最典型的场景是从一堆文件里挑出特定类型的ALL_FILES : main.c util.c config.h readme.md C_FILES : $(filter %.c,$(ALL_FILES)) # 结果: main.c util.cfilter支持多个模式用空格分隔SRC_LIKE : $(filter %.c %.cpp %.cc,$(ALL_FILES))这个特性在混合语言项目里特别有用。比如一个项目同时有 C 和 C 源文件你想分别处理C_SRCS : $(filter %.c,$(ALL_SRCS)) CXX_SRCS : $(filter %.cpp %.cc %.cxx,$(ALL_SRCS))filter的另一个高频用法是精确匹配某个词。前面说findstring做不了精确词匹配filter可以ifneq ($(filter debug,$(BUILD_TYPE)),) # 只有当 BUILD_TYPE 恰好包含独立的 debug 词时才成立 endif这里BUILD_TYPE如果是debug-asanfilter debug不会匹配因为debug-asan作为一个整体词不等于debug。这就是词匹配和子串匹配的区别。3.3 filter-out反向筛选排除指定模式filter-out的语法是$(filter-out pattern...,text)行为跟filter正好相反返回所有不匹配任何pattern的词。这个函数在排除测试文件、排除平台特定文件时特别好用ALL_SRCS : main.c util.c test_main.c test_util.c platform_win.c PROD_SRCS : $(filter-out test_%.c platform_%.c,$(ALL_SRCS)) # 结果: main.c util.cfilter和filter-out经常成对出现一个负责要什么一个负责不要什么。在实际项目里我通常会用filter-out来剔除那些需要特殊处理的文件剩下的走通用规则SPECIAL_SRCS : $(filter %_special.c,$(ALL_SRCS)) NORMAL_SRCS : $(filter-out %_special.c,$(ALL_SRCS)) $(NORMAL_SRCS:.c.o): %.o: %.c $(CC) $(CFLAGS) -c $ -o $ $(SPECIAL_SRCS:.c.o): %.o: %.c $(CC) $(CFLAGS) $(SPECIAL_FLAGS) -c $ -o $这种通用规则 特殊规则的分层写法比给每个文件单独写规则要清爽得多而且新增普通文件时完全不用改 Makefile。4. sort 与 word列表去重排序和按位置取值4.1 sort不只是排序还能去重sort的语法是$(sort list)。它把list按空格拆成词去掉重复的然后按字典序排序最后用单个空格连接返回。很多人只知道它能排序忽略了去重这个功能。实际上在 Makefile 里去重往往比排序更有价值。比如多个模块各自声明了自己依赖的头文件目录合并的时候难免重复MODULE_A_INCLUDES : include/common include/module_a MODULE_B_INCLUDES : include/common include/module_b ALL_INCLUDES : $(sort $(MODULE_A_INCLUDES) $(MODULE_B_INCLUDES)) # 结果: include/common include/module_a include/module_binclude/common只出现一次重复的被自动去掉了。这个技巧在管理编译选项、链接库列表时同样适用。需要注意的是sort的排序是字典序而且是区分大小写的。大写字母排在小写字母前面所以Zlib会排在apple前面。如果你对顺序有特定要求不能依赖sort的结果得自己控制。4.2 word按位置取词word的语法是$(word n,text)返回text中第n个词从 1 开始计数。如果n超出范围返回空字符串。单独用word的场景不多它更多是配合其他函数做精细控制。比如你有一个版本号字符串1.2.3想分别取出主版本号和次版本号VERSION : 1.2.3 MAJOR : $(word 1,$(subst ., ,$(VERSION))) MINOR : $(word 2,$(subst ., ,$(VERSION))) PATCH : $(word 3,$(subst ., ,$(VERSION)))这里先用subst把点换成空格让1.2.3变成1 2 3三个词再用word按位置取。这个组合拳在处理版本号、路径分段、配置项解析时非常实用。跟word配套的还有几个函数值得一并记住wordlist取一个范围的词firstword取第一个词lastword取最后一个词。它们共享同一套按空格分词的逻辑。FILES : a.c b.c c.c d.c FIRST : $(firstword $(FILES)) # a.c LAST : $(lastword $(FILES)) # d.c MIDDLE : $(wordlist 2,3,$(FILES)) # b.c c.cwordlist的语法是$(wordlist start,end,text)取从start到end含两端的词。这个在分批处理文件列表时有用比如把源文件分成几组并行编译。5. 把八个函数串起来一个真实项目的文件列表管理方案光看单个函数的用法还不够真正体现价值的是把它们组合起来解决实际问题。下面这个场景来自我做过的一个跨平台项目源文件分布在多个目录需要根据目标平台筛选不同的文件还要自动生成目标文件路径。项目结构大概是这样SRC_DIRS : src src/net src/platform ALL_SRCS : $(foreach dir,$(SRC_DIRS),$(wildcard $(dir)/*.c))wildcard先把每个目录下的.c文件都抓出来foreach负责遍历目录列表。这一步之后ALL_SRCS是一个包含完整路径的文件列表比如src/main.c src/net/socket.c src/platform/win.c src/platform/linux.c。接下来要排除掉不属于当前平台的文件。假设目标平台通过TARGET_OS变量指定ifeq ($(TARGET_OS),windows) EXCLUDE_PATTERN : %/linux.c %/mac.c else ifeq ($(TARGET_OS),linux) EXCLUDE_PATTERN : %/win.c %/mac.c else EXCLUDE_PATTERN : %/win.c %/linux.c endif PLATFORM_SRCS : $(filter-out $(EXCLUDE_PATTERN),$(ALL_SRCS))filter-out配合%通配符把不属于当前平台的文件剔掉。注意这里用的是%/linux.c而不是linux.c因为ALL_SRCS里是带路径的模式必须匹配完整路径。然后要把源文件路径转成目标文件路径统一放到build/目录下并且保持原有的目录结构OBJS : $(patsubst %.c,build/%.o,$(PLATFORM_SRCS))patsubst在这里做了两件事把.c换成.o同时在前面加上build/前缀。src/net/socket.c会变成build/src/net/socket.o。但build/src/net/这个目录可能不存在编译时会报错。所以还需要生成目录创建规则OBJ_DIRS : $(sort $(dir $(OBJS))) # $(dir ...) 取出每个路径的目录部分sort 去重$(dir ...)是另一个文件名字函数返回每个词的目录部分包含末尾斜杠。sort在这里的作用是去重因为多个.o文件可能共享同一个目录。最后得到的就是所有需要创建的目录列表。完整的规则大概是这样$(OBJS): build/%.o: %.c mkdir -p $(dir $) $(CC) $(CFLAGS) -Iinclude -c $ -o $mkdir -p $(dir $)确保目标目录存在$(dir $)取出当前目标的目录部分。这个写法比预先创建所有目录更稳妥因为它是按需创建的。整个流程走下来用到的函数有foreach、wildcard、filter-out、patsubst、sort、dir其中字符串处理函数占了四个。如果没有这些函数同样的逻辑得靠手写文件列表或者外部脚本生成维护成本完全不一样。6. 几个我踩过的坑和对应的排查思路6.1 逗号在 subst 里的陷阱前面提过subst的逗号问题这里展开说。$(subst from,to,text)里make 是按逗号来切分参数的所以from和to里如果本身有逗号就会导致参数错位。比如你想把分号替换成逗号# 错误写法 RESULT : $(subst ;,,$(INPUT))这里 make 看到的是subst后面跟了三个参数;、空、$(INPUT)。但实际上你想要的to是逗号而逗号没法直接写。解决办法是用变量存逗号comma : , RESULT : $(subst ;,$(comma),$(INPUT))这个技巧在 Makefile 里很常用因为逗号、空格这些分隔符没法直接字面量写。空格的处理更麻烦得用$(subst ...)配合$(empty)之类的技巧或者干脆用$(subst $(space),...)其中space是通过特殊方式定义的。6.2 filter 的模式必须匹配整个词filter和filter-out的模式匹配是整词匹配不是子串匹配。这一点跟findstring完全不同混淆了就会出 bug。比如你有一批文件main.c、main_test.c、main_util.c想筛选出所有以main开头的# 错误写法不会匹配任何文件 WRONG : $(filter main%,$(FILES)) # 正确写法 RIGHT : $(filter main%,$(FILES))等等这里main%其实是能匹配main.c的因为%可以匹配.c。但如果你想匹配的是包含 main 的文件filter做不到因为它是从头开始匹配的。main_test.c能被main%匹配但test_main.c不能。这种时候要么用%main%如果 make 版本支持要么用findstring配合foreach自己实现。6.3 sort 会改变顺序别在需要保序的地方用sort的去重功能很好用但它同时会排序这会打乱原有的顺序。如果你的文件列表顺序有讲究比如链接顺序影响符号解析就不能用sort去重。这种时候的替代方案是用$(if $(filter $x,$(seen)),,$(seen) $x)这种手动去重的写法或者接受重复链接器通常能处理重复的库。我在处理静态库链接顺序时踩过这个坑sort把库的顺序打乱后导致符号找不到排查了半天才发现是sort干的。6.4 word 的索引从 1 开始越界返回空word的索引是 1-based不是 0-based。$(word 0,text)返回空$(word 1,text)才是第一个词。这个跟大多数编程语言不一样写的时候容易顺手写成 0。越界不报错而是返回空这个设计有利有弊。好处是可以用$(if $(word 5,$(LIST)),...)来判断列表是否有至少 5 个元素。坏处是如果索引算错了不会报错只会静默地拿到空字符串然后在后续处理中引发更难定位的问题。我的建议是在依赖word取值的地方加一个$(if ...)做保护至少能在值为空时给出明确的错误信息。7. 函数组合的进阶套路与性能考量7.1 用 foreach 配合字符串函数做批量变换foreach本身不是字符串函数但它跟字符串函数配合能发挥很大威力。语法是$(foreach var,list,text)对list里的每个词把var设为该词展开text。比如给每个源文件生成一个对应的依赖文件路径SRCS : a.c b.c c.c DEPS : $(foreach src,$(SRCS),$(src:.c.d)) # 结果: a.d b.d c.d或者给每个目录加上-I前缀DIRS : include src include/net INCLUDES : $(foreach dir,$(sort $(DIRS)),-I$(dir)) # 结果: -Iinclude -Iinclude/net -Isrc注意这里先sort去重再foreach避免生成重复的-I参数。顺序上sort在前foreach在后这个顺序不能反。7.2 函数嵌套的展开时机Makefile 的函数是在解析阶段展开的嵌套函数会从内到外依次求值。理解这一点对写出正确的嵌套很重要。RESULT : $(patsubst %,$(PREFIX)/%,$(filter %.c,$(FILES)))展开顺序是先算$(filter %.c,$(FILES))得到.c文件列表再把这个列表作为patsubst的第三个参数同时$(PREFIX)被展开最后做替换。如果PREFIX本身是个函数调用它也会在这一步展开。这种嵌套写法很紧凑但可读性差。我的经验是超过两层的嵌套就拆成中间变量一方面好读另一方面方便调试——你可以在中间变量后面加$(info ...)打印出来看。7.3 大列表处理的性能字符串函数处理几千个文件时性能通常不是问题因为都是在内存里做文本操作。但有几个地方要注意sort的去重是 O(n log n) 的排序加去重列表特别大时会有开销。filter和filter-out对每个词都要跟每个模式比对模式多的时候开销是乘积关系。foreach如果嵌套使用展开次数是各层列表长度的乘积容易爆炸。我遇到过一次foreach嵌套导致 make 启动要好几秒的情况原因是三层foreach遍历了几百个目录和上千个文件。解决办法是把能提前算的算好减少嵌套层数或者用wildcard直接抓文件而不是手动拼接路径。不过说实话大多数项目的规模远没到需要担心性能的程度。真正影响 make 启动速度的往往是$(shell ...)调用那才是每次都要 fork 子进程的大头。字符串函数相比之下几乎可以忽略。8. 我个人的使用习惯和几条实用建议写了这么多 Makefile我慢慢形成了一些固定习惯这里分享出来供参考。第一变量命名带上用途前缀。比如SRCS_开头的存源文件列表OBJS_开头的存目标文件列表DIRS_开头的存目录列表。这样在写函数的时候一眼就能看出变量里装的是什么减少搞混的概率。第二复杂表达式拆成多行。Makefile 支持用\续行把一长串函数调用拆开写每行一个函数可读性会好很多OBJS : $(patsubst %.c,build/%.o,\ $(filter-out %_test.c,\ $(wildcard src/*.c)))第三善用$(info ...)和$(warning ...)调试。这两个函数在解析阶段打印信息不影响构建。当你搞不清某个变量展开后到底是什么在它后面加一行$(info VAR$(VAR))make 启动时就会打印出来。这比在 recipe 里echo要早得多能帮你定位解析阶段的问题。第四strip该用就用别省。前面说过从外部来的字符串先strip一遍能避免大量空白字符引发的诡异问题。这个习惯的成本几乎为零收益却很高。第五别追求一行搞定。Makefile 的函数嵌套能力很强理论上可以把整个构建逻辑写成一个巨大的表达式。但那样写出来的东西没人能维护包括三个月后的你自己。拆成中间变量多写几行让每一步的意图都清晰这才是长期可维护的写法。最后说一个我最近才用顺的技巧$(if $(filter ...),...)这个组合可以用来做如果列表里包含某个元素就怎样的判断比findstring更精确比写一堆ifeq更紧凑。比如判断编译选项里是否包含某个 flagifneq ($(filter -fsanitize%,$(CFLAGS)),) LDFLAGS -fsanitizeaddress endif这里filter用%匹配任意-fsanitize开头的选项只要有一个匹配就加上对应的链接选项。这种写法在处理用户自定义编译选项时特别灵活不用穷举所有可能的值。