1. 为什么文件名操作函数值得单独拎出来讲写过稍微复杂一点 Makefile 的人都有体会真正让人抓狂的往往不是编译规则本身而是路径拼接。源文件散落在src/、src/driver/、src/module/好几个目录里目标文件却要统一放到build/obj/下面中间还得把.c换成.o。如果全靠手写改一个目录结构就得把整个 Makefile 翻一遍维护成本高得离谱。Makefile 提供了一组专门处理文件名和路径的函数官方文档里叫File Name Functions一共八个dir、notdir、suffix、basename、addsuffix、addprefix、join、wildcard。它们看起来简单但组合起来能解决绝大多数路径变换问题。我见过不少项目Makefile 里一堆硬编码的路径字符串一问才知道作者根本不知道有这几个函数白白多写了几百行。这篇文章面向的是已经能写出基本 Makefile、但对函数部分还比较陌生的读者。我会把这八个函数逐个拆开讲清楚每个函数到底做了什么、参数怎么传、返回值长什么样然后重点讲它们怎么组合使用。所有示例都可以直接复制到终端里跑建议边看边试比干看文档印象深得多。提示Makefile 函数调用语法是$(函数名 参数1,参数2)函数名和参数之间用空格分隔参数之间用逗号分隔。逗号后面的空格会被当作参数的一部分这一点新手特别容易踩坑。在开始之前先明确一个概念这些函数操作的对象是字符串只不过这些字符串恰好长得像文件名。Make 并不关心文件是否真实存在wildcard除外它只是按照规则对字符串做变换。理解这一点后面很多奇怪的行为就都能解释了。2. 八个函数逐个拆解从 dir 到 wildcard2.1 dir 与 notdir一对互补的路径拆分工具dir函数的作用是提取目录部分。它的规则很简单对每个文件名取最后一个斜杠包括斜杠本身之前的所有内容。如果文件名里没有斜杠就返回./。# 测试 dir 函数 $(info $(dir src/main.c)) $(info $(dir src/driver/uart.c)) $(info $(dir main.c))把这几行写进一个测试 Makefile执行make后输出是src/ src/driver/ ./注意第三个结果main.c没有斜杠返回的是./而不是空字符串。这是dir的一个固定行为很多人第一次见会觉得意外。它的设计意图是保证结果始终是一个合法的目录路径方便后续拼接。notdir则完全相反它去掉目录部分只保留文件名$(info $(notdir src/main.c)) $(info $(notdir src/driver/uart.c)) $(info $(notdir src/))输出main.c uart.c第三个例子值得注意src/以斜杠结尾notdir取最后一个斜杠之后的内容结果是空字符串。这个特性在实际项目中很有用——当你用wildcard展开一个目录列表时目录项会带尾部斜杠用notdir就能把它们过滤掉只留下真正的文件。我在一个多目录项目里就吃过这个亏。当时用wildcard src/*拿到所有条目想当然地以为都是文件结果里面混着子目录后续处理全乱了。后来加上$(notdir ...)配合过滤问题才解决。这个技巧后面讲wildcard时还会展开说。2.2 suffix 与 basename后缀名的提取与剥离suffix提取最后一个点号及其后面的内容也就是文件扩展名。basename则去掉这个扩展名保留前面的部分。$(info $(suffix src/main.c)) $(info $(suffix src/archive.tar.gz)) $(info $(suffix Makefile)) $(info $(basename src/main.c)) $(info $(basename src/archive.tar.gz))输出.c .gz src/main src/archive.tar这里有两个关键点需要说清楚。第一suffix只看最后一个点。archive.tar.gz的后缀是.gz不是.tar.gz。如果你需要处理双重扩展名得自己写逻辑标准函数帮不了你。第二Makefile没有点号suffix返回空字符串basename原样返回。这个行为在批量处理时要注意因为空字符串参与后续拼接可能会产生意料之外的结果。basename和suffix的组合是文件类型转换的基础。比如把.c换成.o标准写法是OBJ : $(basename src/main.c).o先用basename剥掉.c再手动拼上.o。这个模式在 Makefile 里出现频率极高几乎每个 C/C 项目都会用到。理解了这个组合后面addsuffix和addprefix就好懂了。2.3 addsuffix 与 addprefix批量加前后缀这两个函数是批量操作的利器。addsuffix给列表里每个元素加上后缀addprefix加上前缀。SRCS : main.c uart.c spi.c $(info $(addsuffix .o,$(SRCS))) $(info $(addprefix build/,$(SRCS))) $(info $(addprefix build/,$(addsuffix .o,$(SRCS))))输出main.c.o uart.c.o spi.c.o build/main.c build/uart.c build/spi.c build/main.c.o build/uart.c.o build/spi.c.o第三个例子展示了函数嵌套的写法内层addsuffix先把.c变成.c.o外层addprefix再加上build/。这种嵌套在 Makefile 里非常常见读的时候从内往外看就行。这里有个容易踩的坑addsuffix加的是后缀不是替换扩展名。main.c加上.o得到的是main.c.o不是main.o。想要替换扩展名必须用basename先剥掉原扩展名$(addsuffix .o,$(basename $(SRCS)))这一行才是把main.c uart.c spi.c变成main.o uart.o spi.o的正确写法。我见过太多人在这一步写错编译出来的目标文件名带着.c.o虽然也能跑但看着别扭而且和链接规则对不上。2.4 join按位置配对拼接join的行为和前面几个不太一样。它把两个列表按位置一一配对用第二个列表的元素去拼接第一个列表的元素中间不加任何分隔符。DIRS : src/ include/ lib/ FILES : main.c uart.h spi.c $(info $(join $(DIRS),$(FILES)))输出src/main.c include/uart.h lib/spi.c注意join不是笛卡尔积。它不会把每个目录和每个文件都组合一遍而是第 1 个配第 1 个、第 2 个配第 2 个。如果两个列表长度不一样多出来的元素会原样保留$(info $(join a b c,1 2))输出是a1 b2 c。第三个元素c没有对应的配对项直接保留。这个特性在实际项目中用得相对少但在处理并行数组时很有用。比如你有一组源文件路径和一组对应的编译选项需要一一配对join就能派上用场。不过大多数情况下addprefix配合notdir更直观join属于知道有这么个东西、需要时能想起来就行的函数。2.5 wildcard唯一会碰真实文件系统的函数前面七个函数都是纯字符串操作wildcard是唯一的例外——它会实际去磁盘上查找匹配的文件。$(info $(wildcard src/*.c)) $(info $(wildcard *.mk)) $(info $(wildcard nonexistent/*.c))假设src/下有main.c和uart.c当前目录有config.mk输出是src/main.c src/uart.c config.mk第三个例子中路径不存在wildcard返回空字符串不会报错。这个特性让它可以安全地用于探测文件是否存在。wildcard支持的通配符和 shell 基本一致*匹配任意字符?匹配单个字符[...]匹配字符集。但它不递归src/*.c只匹配src/下一层的.c文件不会进子目录。想要递归查找得配合shell find或者手动列出子目录。注意wildcard的执行时机是在 Makefile 解析阶段不是执行阶段。这意味着如果文件是在 make 运行过程中生成的wildcard看不到它们。这个坑在自动生成依赖文件时特别容易踩到。我在一个项目里就遇到过这个问题用wildcard收集所有.c文件但其中一部分是代码生成器在构建过程中产生的。结果第一次 make 时这些文件还没生成wildcard返回空链接直接失败。解决办法是把生成的文件单独列出来或者用两次 make。这个教训值得记牢。3. 把函数串起来路径变换的实战套路3.1 从源文件列表生成目标文件列表这是最经典的场景。假设项目结构是这样的project/ ├── src/ │ ├── main.c │ ├── driver/ │ │ ├── uart.c │ │ └── spi.c │ └── module/ │ └── parser.c └── Makefile目标是把所有.c编译成.o统一放到build/下并且保持目录结构。也就是说src/driver/uart.c要编译成build/driver/uart.o。第一步收集所有源文件。因为wildcard不递归需要手动列出子目录SRC_DIRS : src src/driver src/module SRCS : $(foreach dir,$(SRC_DIRS),$(wildcard $(dir)/*.c))这里用到了foreach它不在本文的八个函数之列但配合wildcard处理多目录非常常用。执行后SRCS的值是src/main.c src/driver/uart.c src/driver/spi.c src/module/parser.c第二步把src/前缀替换成build/同时把.c换成.o。这里有个技巧先用notdir拿到纯文件名再用addprefix加目标目录。但这样会丢失目录结构uart.c和parser.c都会跑到build/根下。要保留结构得用字符串替换OBJS : $(SRCS:src/%.cbuild/%.o)这是 Makefile 的替换引用语法不是函数但和文件名操作函数配合得天衣无缝。src/driver/uart.c会被替换成build/driver/uart.o目录结构完整保留。如果你非要用函数实现可以这样OBJS : $(addprefix build/,$(patsubst src/%.c,%.o,$(SRCS)))patsubst做模式替换addprefix加前缀。效果一样但比替换引用啰嗦。实际项目中替换引用更简洁推荐优先使用。3.2 自动创建目标目录生成的目标文件在build/driver/下但build/driver/这个目录可能不存在。Make 不会自动创建目录得手动处理。常见做法是用dir提取所有目标文件的目录去重后逐个创建OBJ_DIRS : $(sort $(dir $(OBJS))) $(OBJ_DIRS): mkdir -p $dir提取目录sort去重sort在这里的作用是去重加排序不是单纯排序。这样build/、build/driver/、build/module/都会被创建。这里有个细节dir返回的目录带尾部斜杠比如build/driver/。mkdir -p能正确处理带斜杠的路径所以没问题。但如果你后续要用这个目录名做其他拼接记得斜杠的存在。3.3 处理混合扩展名的源文件有些项目里既有.c又有.cpp需要分别处理。这时候suffix和basename就派上用场了ALL_SRCS : $(wildcard src/*.c src/*.cpp) C_SRCS : $(filter %.c,$(ALL_SRCS)) CPP_SRCS : $(filter %.cpp,$(ALL_SRCS)) C_OBJS : $(addprefix build/,$(addsuffix .o,$(basename $(notdir $(C_SRCS))))) CPP_OBJS : $(addprefix build/,$(addsuffix .o,$(basename $(notdir $(CPP_SRCS)))))这一串嵌套读起来费劲但从内往外拆就清楚了notdir去目录basename去扩展名addsuffix .o加.oaddprefix build/加目录。最终main.c变成build/main.o。这种写法适合源文件都在同一层目录的情况。如果目录结构复杂还是用替换引用更省事。4. 那些文档不会告诉你的坑4.1 空格和逗号函数参数的隐形杀手Makefile 函数的参数分隔符是逗号但逗号后的空格会被保留。看这个例子$(info $(addprefix build/, main.c uart.c))你以为结果是build/main.c build/uart.c实际上build/后面有个空格变成build/ main.c和build/ uart.c前缀里带了空格。正确写法是逗号后不留空格$(info $(addprefix build/,main.c uart.c))这个坑极其隐蔽因为输出看起来差不多但路径里多了空格后续mkdir或编译时就会报莫名其妙的错。我的建议是函数参数里永远不要在逗号后加空格除非你明确知道需要那个空格。4.2 wildcard 的时机问题前面提过wildcard在 Makefile 解析阶段执行。这意味着generated.c: ./generate.sh $ OBJS : $(wildcard generated.c:.c.o)如果generated.c还不存在wildcard返回空OBJS就是空的。即使后面的规则会生成generated.cOBJS也不会自动更新。这是 Make 的两阶段执行模型决定的先解析整个 Makefile再执行规则。解决办法有两个一是把生成的文件显式列出来不依赖wildcard二是用$(shell ...)在需要的时候动态获取。但$(shell ...)也是在解析阶段执行同样有这个问题。真正可靠的做法是分两次 make或者用include引入生成的依赖文件。4.3 dir 返回 ./ 的连锁反应dir对无斜杠路径返回./这个行为在拼接时可能出问题$(info $(addprefix build/,$(dir main.c)))结果是build/./虽然build/./和build/等价但看着不干净。如果后续要用这个字符串做比较或匹配./的存在可能导致意外的不相等。处理办法是用patsubst或subst把./去掉DIRS : $(patsubst ./%,%,$(dir $(FILES)))或者干脆避免对无目录的文件用dir先用addprefix加上目录再处理。4.4 notdir 对目录返回空字符串前面提过notdir src/返回空。这个特性可以用来过滤目录ENTRIES : $(wildcard src/*) FILES : $(foreach e,$(ENTRIES),$(if $(notdir $(e)),$(e)))wildcard src/*返回文件和目录的混合列表目录带尾部斜杠。notdir对目录返回空if判断为空就跳过。这样FILES里只剩真正的文件。这个技巧在处理不确定内容的目录时很有用比用shell test -f逐个判断高效得多。5. 组合技几个可以直接抄的模板5.1 多目录源文件的通用收集模板# 源文件目录列表按需增删 SRC_DIRS : src src/driver src/module # 收集所有 .c 文件 SRCS : $(foreach d,$(SRC_DIRS),$(wildcard $(d)/*.c)) # 生成对应的 .o 文件路径保持目录结构 OBJS : $(SRCS:%.cbuild/%.o) # 提取所有需要的目录并去重 OBJ_DIRS : $(sort $(dir $(OBJS))) # 创建目录的规则 $(OBJ_DIRS): mkdir -p $ # 编译规则 build/%.o: %.c | $(OBJ_DIRS) $(CC) $(CFLAGS) -c $ -o $这个模板覆盖了大多数中小型 C 项目。SRC_DIRS手动维护新增目录时加一行即可。OBJS用替换引用生成自动保持目录结构。OBJ_DIRS用dir加sort去重确保每个目标目录都被创建。注意编译规则里的| $(OBJ_DIRS)这是顺序依赖order-only prerequisite表示目标目录必须先存在但目录的时间戳变化不触发重新编译。这个细节很关键不加的话每次 make 都会因为目录时间戳更新而重新编译所有文件。5.2 头文件依赖的自动生成C 项目里源文件依赖头文件头文件改了要重新编译。手动维护依赖关系不现实得用编译器自动生成DEPS : $(OBJS:.o.d) build/%.o: %.c | $(OBJ_DIRS) $(CC) $(CFLAGS) -MMD -MP -c $ -o $ -include $(DEPS)-MMD让编译器生成.d依赖文件-MP防止头文件被删除后报错。-include前面的减号表示文件不存在时不报错。这样第一次 make 时.d文件还没生成-include静默跳过第二次 make 时依赖关系就生效了。这里$(OBJS:.o.d)又是替换引用把.o换成.d。和文件名操作函数配合整个依赖管理就自动化了。5.3 清理规则的写法.PHONY: clean clean: rm -rf build简单直接。build/目录整个删掉下次 make 重新创建。不要用rm -f $(OBJS)逐个删那样会留下空目录而且文件多了命令行会超长。6. 我踩过的几个真实坑第一个坑是关于wildcard和sort的顺序。有次我写$(sort $(wildcard src/*.c))想着先去重再排序。结果发现wildcard本身不会返回重复项sort在这里纯粹是排序。但sort会改变文件顺序导致链接顺序变化某些依赖顺序的库就链接失败了。后来改成保持wildcard的原始顺序问题消失。sort不是无害的它会重排列表在链接顺序敏感的场景要慎用。第二个坑是addprefix和addsuffix的嵌套顺序。我一开始写成$(addsuffix .o,$(addprefix build/,$(SRCS)))想着先加目录再加后缀。结果是build/main.c.o因为addsuffix加在了整个路径后面。正确顺序应该是先basename去扩展名再addsuffix加新扩展名最后addprefix加目录。嵌套函数的执行顺序是从内到外写的时候要从最终结果倒推。第三个坑是dir对相对路径和绝对路径的处理。dir /home/user/src/main.c返回/home/user/src/dir src/main.c返回src/看起来一致。但如果路径是../src/main.cdir返回../src/这个路径在mkdir -p时能正确创建但在某些工具里可能被规范化成不同的形式。跨目录引用时尽量用绝对路径或者从项目根目录开始的相对路径避免..带来的不确定性。第四个坑是notdir处理 Windows 风格路径。Makefile 在 Windows 上运行时路径分隔符可能是反斜杠。notdir只认正斜杠对src\main.c会返回整个字符串因为它找不到正斜杠。解决办法是在 Makefile 里统一用正斜杠或者用subst先把反斜杠换成正斜杠FIXED : $(subst \,/,$(PATH))这个坑在跨平台项目里特别常见Windows 上写的 Makefile 拿到 Linux 上跑或者反过来路径分隔符不一致就会出问题。7. 怎么快速验证函数行为最后分享一个我常用的调试技巧写一个专门的测试 Makefile把所有不确定的函数调用用$(info ...)打印出来执行make看输出。比在脑子里推演快得多也比查文档直观。# test.mk TEST : src/main.c src/driver/uart.c Makefile $(info dir: $(dir $(TEST))) $(info notdir: $(notdir $(TEST))) $(info suffix: $(suffix $(TEST))) $(info basename: $(basename $(TEST))) $(info addsuffix:$(addsuffix .o,$(TEST))) $(info addprefix:$(addprefix build/,$(TEST))) $(info join: $(join a b c,1 2 3)) $(info wildcard: $(wildcard *.mk))执行make -f test.mk所有结果一目了然。改一个参数重新跑一遍比任何文档都快。我到现在还保留着这个测试文件遇到不确定的行为就改改参数跑一下。提示$(info ...)在解析阶段输出不需要定义任何目标。如果 Makefile 里没有目标make 会报 No targets 错误但$(info ...)的内容已经打印出来了。加一个空的all:目标就能避免这个错误。这八个函数单独看都不复杂难的是在合适的场景想起它们、用对它们。我的经验是遇到路径拼接需求时先停下来想想有没有现成的函数能用而不是直接上手写字符串。用得多了自然就形成条件反射了。