ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Makefile高级技巧:条件判断、循环与自定义函数构建多平台项目

Makefile高级技巧:条件判断、循环与自定义函数构建多平台项目 说实话Makefile写久了你会发现大部分人把它当成一个按顺序执行shell命令的脚本在用这也没错但格局小了。真正的Makefile尤其是要支撑多平台、多配置、大量源文件的项目时它本质上是一门小型的领域专用语言。你能不能让构建脚本根据环境自动选择编译器能不能批量生成几十条规则而不是手写复制能不能把反复出现的构建逻辑抽出来只写一次答案就在条件判断、循环和自定义函数这三个特性里。这篇文章是Makefile系列里的进阶篇第十篇假设你已经掌握了基础规则、变量、常见函数wildcard、patsubst、shell这些今天咱们重点啃硬骨头我会把这三个特性的原理讲透再用一个实际的多平台编译案例把它们串起来最后把我在实战里踩过的坑和排查手段一并交代。1. 为什么Makefile需要条件判断、循环和自定义函数1.1 构建脚本的三个进化工具你可以把Makefile的演进理解成一条线刚开始是一行规则对应一个编译动作比如main.o: main.c; gcc -c main.c。项目小的时候规则少写起来没问题。可一旦项目变成几十个源文件、需要同时支持Debug和Release、还需要交叉编译到嵌入式平台这种平铺式的写法立刻失控。这时候就需要三种能力。条件判断解决的是同一个Makefile适配多种场景的问题。比如我用同一份代码在x86 Linux上编译是本地跑在ARM板子上编译是交叉编译编译器前缀不同、头文件路径不同、链接库不同。如果没有条件分支你得维护两个Makefile改动一处就要同步另一处很容易漏改。循环解决的是批量处理大量同类目标的问题。比如src/下有三十个.c文件你不想每条规则手写也不想每次新增文件都去改Makefile。用循环或者说遍历机制源文件的增删是自动感知的。自定义函数解决的是逻辑复用和维护成本的问题。当你发现Makefile里连续三段规则结构完全一样只是文件名不同这时候就应该把这段逻辑抽成一个函数带参数调用。否则等你需要调整编译选项时得在七个地方同步改漏一个就是诡异报错。我把这三个特性并列放在一起讲还有一个原因它们不是孤立的在实际工程里经常组合使用逻辑闭环。比如用$(foreach)遍历源文件列表对每个文件调用自定义函数函数内部用条件判断区分不同平台——这才是完整的Makefile架构。1.2 什么场景该用什么场景不该用在往下看语法之前先聊一句度的问题。Makefile的特性不是越多越好。我见过一些项目为了炫技把所有逻辑全部塞进eval和define里最后Makefile读起来比C模板还难懂调试的时候谁都别想改。我的经验是小型项目源文件少于十个单一平台顺序规则加几个变量就够了不需要上条件判断和函数。如果你强迫自己用上了反而降低了可读性。中型以上、多配置、多目录的项目这三个特性就是标配。一个简单的判断标准如果同一段规则你需要复制粘贴超过两次那就值得封装如果整个Makefile里只有一个编译器、一个目标平台那你也别硬造抽象。另外还要注意Makefile毕竟不是通用编程语言它擅长的是文本展开和控制构建流程不适合做复杂的算法逻辑。如果你发现需要在Makefile里写几十行shell脚本来处理数据那说明这个步骤应该拆出来放到独立脚本里Makefile只需要调用它就行。想清楚边界再开始写代码。2. 条件判断让构建脚本学会随机应变2.1 四个条件指令的语法和区别Makefile里的条件判断指令有四个ifeq、ifneq、ifdef、ifndef。使用方式和C语言的预处理指令#if很像是解析期处理的不是构建期执行这点一定要先理解。也就是说Makefile被读进来的时候条件分支就已经决定了哪些内容保留、哪些内容丢弃这和shell里运行时的if有本质区别。ifeq用来判断两个值是否相等。最常见的写法是ifeq ($(VAR), value)注意中间有一个逗号。判断成立时执行到else或endif之间的内容。ifneq正好相反两个值不等时才成立。ifdef判断一个变量是否被定义了ifndef就是没定义才成立。这里有一个非常经典的大坑我在第2.3节详说先记住一个粗浅的理解这四种指令本质是让Makefile解析时产生不同的内容用条件包含的方式来控制构建参数。2.2 实战一份Makefile适配Debug和Release先看一个最常用的场景切换编译模式。需求是构建时执行make BUILD_MODEdebug得到带调试符号的版本执行make BUILD_MODErelease得到优化版本。BUILD_MODE ? release ifeq ($(BUILD_MODE), release) CFLAGS : -O2 -DNDEBUG LDFLAGS : -s else ifeq ($(BUILD_MODE), debug) CFLAGS : -O0 -g -DDEBUG LDFLAGS : else $(error 未知的BUILD_MODE: $(BUILD_MODE)请使用release或debug) endif CC : gcc SRCS : $(wildcard src/*.c) OBJS : $(patsubst %.c,%.o,$(SRCS)) app: $(OBJS) $(CC) $(LDFLAGS) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f app $(OBJS)看着是不是挺像配置文件规则的组合执行make BUILD_MODEdebug的时候Makefile解析阶段发现BUILD_MODE等于debug于是CFLAGS被赋成-O0 -g -DDEBUG而make BUILD_MODErelease时CFLAGS变成-O2 -DNDEBUG。注意?的意思是如果用户在命令行传入或环境变量里已有BUILD_MODE就沿用传入值否则用默认的release。这比和:更合适它保留了命令行覆盖的可能。$(error ...)是另一个实用指令一旦走到这个分支Make会直接报错退出适合拿来兜底。我见过很多项目用默认分支静默通过的方式处理未知模式结果用户拼错一个单词编译出来的却是完全错误的东西排查难度极大。与其这样不如直接报错。2.3 条件判断容易踩的坑先说空格问题。ifeq ($(BUILD_MODE),release)和ifeq ($(BUILD_MODE), release)是不同的。前者判断$(BUILD_MODE)展开后的值和release是不是相等后者判断的是值和releaserelease前面带一个空格是否相等。如果你在命令行传了BUILD_MODErelease没有空格第二个判断永远不成立。这算Makefile里最经典的肉眼找不到bug之一。第二个坑是ifdef的语义。ifdef VARIABLE只判断这个变量是否已被定义不判断它是否为空。也就是说A :定义了但值为空的情况下ifdef A是成立的。这在很多场景下会干扰预期。如果你需要判断变量非空应该用ifeq ($(strip $(A)),)即去掉空格后为空字符串。第三个坑是条件指令不能以Tab开头。这一点和规则中的命令区域冲突。如果一行以Tab开头Makefile会认为它是某个规则下的shell命令但如果它其实是ifeq或endif就会引发解析错误。所以这些关键字必须顶格写。第四个思维上的坑是递归展开变量的问题。如果你用的是定义变量递归展开式在条件判断里读取到的值可能是尚未完全展开的状态。比如FOO $(BAR) ifeq ($(FOO), hello) ... endif BAR hello因为变量在ifeq那一行的展开时机不同这种代码的行为容易变得不可预测。我自己的习惯是在条件判断和函数内部尽量用:或?让变量值在定义时就确定下来减少踩雷概率。3. 循环Makefile里没有for但有的是办法很多人第一次找Makefile for循环会发现找不到语法。Makefile本身没有类似C语言for的关键字。它实现循环的思路是借助函数展开最常用的是$(foreach var, list, text)其次是递归Make和借助shell循环。这三种方式各有适用场景我们把它们拆开看。3.1 foreach解析期的文本级遍历$(foreach var, list, text)的工作过程是把list按空格拆成若干项依次赋给变量var然后展开text最后把所有展开结果用空格连接起来。注意它发生在Makefile解析阶段返回的是一段文本不是执行命令。有一种理解方式foreach本质上是在生成内容。例如我想为src/下所有.c文件生成对应的.o文件列表SRCS : $(wildcard src/*.c) OBJS : $(foreach f, $(SRCS), $(patsubst %.c,%.o,$(f)))这段代码里f依次取到src/main.c、src/util.c等值然后$(patsubst %.c,%.o,$(f))把每个值里的.c后缀替换成.o最后被空格连接成src/main.o src/util.o ...。实际上这个场景用$(patsubst %.c,%.o,$(SRCS))一行就够了不需要foreach。真正需要foreach的往往是对每个元素做多步处理的场景比如同时处理好几个中间变量。foreach里的var是一个临时变量只在text内有效不会污染全局命名空间。另外list可以用已经定义的变量甚至可以嵌套foreach比如遍历平台列表再遍历每个平台下的模块列表。但嵌套层数建议控制在两层以内多了很难读。还有一点foreach连接的各项之间默认是空格。如果text展开后本身包含空格比如一个规则片段那结果会被重新拆开语义上容易出错。如果你希望每一项之间用换行连接比如生成多条规则你就需要在text末尾手动加上$$(newline)或者用define配合eval来实现。这就是为什么foreach往往和后面要说的自定义函数、eval组合使用。3.2 递归Make项目维度的循环另一个常见循环是递归调用Make本身。多目录工程里每个子模块一个Makefile顶层Makefile负责遍历目录逐个执行构建。这个模式虽然简单但坑不少。我在第5节的案例里会演示完整做法这里先给一个基础版SUBDIRS : lib src tests .PHONY: all $(SUBDIRS) all: $(SUBDIRS) $(SUBDIRS): $(MAKE) -C $当执行make all时all依赖lib、src、tests三个伪目标Make会依次进入每个子目录执行make。注意我用了$(MAKE)而不是直接写make这样可以保证递归调用时带上顶层传入的参数比如make -j8的作业数。另外一个细节是-C参数的后面跟的是目录名进了子目录之后当前makefile是子目录里的Makefile而不是顶层这个。这种方式的循环体现在$(SUBDIRS)这个列表上。列表里每项都会作为目标名被依次构建。对构建系统来说目录往往就是模块边界各自独立这种粒度天然的隔离有助于降低耦合。代价是变量传递需要显式操作子make不会自动继承顶层变量你得用export导出。这也是新手最容易困惑的点。3.3 借助shell循环什么时候才值得用第三种是直接借用shell的循环比如list: for f in src/*.c; do echo 处理 $$f; done注意shell里变量要写成$$f因为Makefile解析时会先把$f当成Make变量去展开展开结果是空再去执行shell时就变成echo 处理 了。用$$转义成shell的$是最常见的写法。这个方式适合在规则的命令区域做临时性遍历。比如批量重命名、批量统计文件行数等。它的执行时机是在构建期由shell去处理和foreach的解析期生成不一样。如果任务只是编译文件我不建议用shell循环去逐个调用gcc那样等于放弃了Make的依赖分析和并行能力。Makefile的正确用法是让文件之间有依赖关系Make自己决定什么需要重编你用shell循环强制遍历一旦文件没变化也会全部重新编译效率极低。4. 自定义函数把重复的构建逻辑封装起来4.1 define/endef定义函数的本质Makefile里的函数其实是一种可复用的文本块。它的本质和变量展开没有区别——define定义了一个变量只不过这个变量可以包含多行文本并且支持参数引用。这个认知很重要你就能理解为什么函数能返回内容因为展开后的文本就是返回值。定义一个函数的语法define make-rule $1: $2 $(CC) $(CFLAGS) -c $2 -o $1 endef这里make-rule是函数名$1和$2是参数占位符。define...endef之间可以有多行内容。调用的时候用$(call make-rule, target, source)call会把参数依次绑定到$1、$2然后展开整个文本块。注意$1在define内是字面写的展开时才被替换成实际参数。这个机制用起来比想象中灵活。比如我可以定义多个通用规则模板用不同参数调用自动生成不同的构建规则。这是从复制粘贴式Makefile走向工程化Makefile的关键一步。4.2 call传参的细节$(call)的参数分隔符是逗号但参数内部如果包含逗号比如-DNAMEa,b就会被错误拆成两个参数。解决办法是定义一个逗号变量comma : ,然后在参数里写$(comma)代替字面逗号。这个技巧在处理包含逗号的复杂参数时非常省心。另一个细节是define函数体里的$符号需要特别注意。如果你要生成shell命令函数体内部还会用到shell变量那就得写$$来转义。这也不难只要记住一个口诀Make本身会先把函数体展开一次你想让最终文本里留下一个$就得写两个$$。最后一个容易被忽略的点是define定义的文本块里如果含有Tab字符展开后Tab会被保留。如果你用它生成规则并放在目标行的下一行Make会认为那是一段命令从而照原样交给shell执行。利用这一点我们才能写出完整的规则生成函数。但如果函数体开头是字母而不是Tab那展开出来的可能只是普通文本不会变成规则。4.3 高阶玩法$(eval)动态生成规则有了foreach和define我们已经能批量产生文本了但要让这些文本真正生效为Makefile规则还需要$(eval)函数帮忙。$(eval text)会把text作为Makefile的语法内容进行解析相当于动态地往Makefile里塞代码。比如define build-object $(BUILD_DIR)/$(basename $(notdir $1)).o: $1 mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) -c $1 -o $ endef $(foreach src, $(SRCS), $(eval $(call build-object, $(src))))执行过程是这样foreach遍历SRCS对每个src调用build-object生成一段以目标: 源文件 Tab 编译命令 组成的文本。$(eval)再把这段文本解析成真正的规则。最终效果是如果你有30个.c文件Makefile读完后内存里就有30条独立的规则每条规则都有自己的源文件和目标文件依赖。这个组合是处理大量文件、统一编译模式的最佳实践。因为它把规则生成的逻辑集中在一个函数里改编译选项只需改一处同时新增文件时wildcard会自动检测到你无需改动Makefile。用$(eval)时需注意eval的实参必须能在一次展开中得到完整的语法结构。如果展开后的文本不是合法规则比如缺少冒号或者没有换行Make会报错。此外调用$(eval)的位置会影响规则的可见范围一般放在Makefile末尾或伪目标定义之前确保生成规则早于其他依赖解析。5. 实操过程三种特性联合使用的完整案例5.1 案例场景设定为了让你直观看到三个特性的实际排序我构造一个贴近真实工作的场景一个小型C项目源码在src/目录头文件在include/目录输出目录是build/。项目需要在三种模式下构建本机Linux直接gcc编译运行在开发机上。ARM交叉编译使用arm-linux-gnueabihf-gcc最终部署到嵌入式设备。单元测试模式在Linux上用-DUNIT_TEST编译链接测试框架。同时src/下会不断新增.c文件不能每次新增都改写Makefile。这就是一个需要条件判断、循环、自定义函数三件套齐全的场景。5.2 完整Makefile逐段解析先给出完整内容再逐段拆# 顶层构建配置 TARGET : app BUILD_DIR : build PLATFORM ? linux BUILD_MODE ? release SRCS : $(wildcard src/*.c) OBJS : $(patsubst src/%.c,$(BUILD_DIR)/%.o,$(SRCS)) DEPS : $(OBJS:.o.d) CC : gcc CFLAGS : -Wall -Wextra -Iinclude # 条件判断按平台切换工具链 ifeq ($(PLATFORM), linux) CROSS_COMPILE : else ifeq ($(PLATFORM), arm) CROSS_COMPILE : arm-linux-gnueabihf- else $(error 未知PLATFORM: $(PLATFORM)可用值: linux, arm) endif # 条件判断按构建模式切换编译选项 ifeq ($(BUILD_MODE), release) CFLAGS -O2 -DNDEBUG else ifeq ($(BUILD_MODE), debug) CFLAGS -O0 -g -DDEBUG else ifeq ($(BUILD_MODE), test) CFLAGS -O0 -g -DUNIT_TEST else $(error 未知BUILD_MODE: $(BUILD_MODE)可用值: release, debug, test) endif CC : $(CROSS_COMPILE)gcc AR : $(CROSS_COMPILE)ar # 自定义函数统一生成编译规则 define make-object-rule $(BUILD_DIR)/$(basename $(notdir $1)).o: $1 mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) -MMD -MP -c $1 -o $ endef # 循环函数调用eval批量生成所有.o规则 $(foreach src, $(SRCS), $(eval $(call make-object-rule, $(src)))) # 链接目标 $(TARGET): $(OBJS) $(CC) $(OBJS) -o $ # 伪目标 .PHONY: all clean all: $(TARGET) clean: rm -rf $(BUILD_DIR) $(TARGET) # 条件判断当编译模式为test时运行单元测试 ifeq ($(BUILD_MODE), test) .PHONY: runtest runtest: $(TARGET) ./$(TARGET) endif # 依赖文件包含 -include $(DEPS)这段代码其实不长但它同时用上了本节讲的三种特性。下面逐段拆。第一部分是变量区。SRCS用wildcard自动发现源文件OBJS用patsubst把src/foo.c映射成build/foo.o。这样新加一个src/bar.cOBJS会自动多出build/bar.o这就是数据驱动的出发点。第二部分是两段条件判断。第一段按PLATFORM切换CROSS_COMPILE第二段按BUILD_MODE切换编译选项。注意我用来追加CFLAGS这样既保留了基础-Wall -Wextra -Iinclude又能叠加不同模式下的选项。这种组合写法能避免多平台多模式交叉时的排列组合爆炸。第三部分是自定义函数。make-object-rule接受一个源文件路径作为参数$1生成一条规则目标文件是build/下同名.o依赖这个源文件命令体先建目录再编译。注意-MMD -MP选项它会自动生成.d依赖文件实现头文件变更后自动重编。这是一个非常实用的工程习惯。第四部分是核心$(foreach src, $(SRCS), $(eval $(call make-object-rule, $(src))))。它遍历所有源文件对每个文件调用函数生成一段规则文本再由eval解析为真正的规则。这里不必手写规则新增文件也无需修改这里。这正是循环函数动态规则生成的价值。最后是伪目标和条件判断的混合runtest目标只在BUILD_MODEtest下被定义这样make BUILD_MODEtest runtest就能执行单元测试而普通构建时根本不会定义这个目标避免误调用。5.3 实际执行与验证顶层目录执行make PLATFORMlinux BUILD_MODEdebugMake读完整个Makefile后解析出build/main.o、build/util.o等多条规则然后逐一编译。由于依赖文件-MMD已生成之后你改了某个头文件Make会自动只重编那些包含了该头文件的.o文件不会全量重编。这就是Makefile增量构建的核心体验。换到ARM交叉编译make PLATFORMarm BUILD_MODErelease条件判断会设置CROSS_COMPILEarm-linux-gnueabihf-从而CCarm-linux-gnueabihf-gcc编译产物可以直接拷到板子运行。你可以用make -n先做一次演练只打印命令不执行确认条件判断和函数生成的内容符合预期。这一步在排错时极其有用。6. 常见问题与排查技巧实录6.1 问题速查表我把实战中容易踩的坑整理了一个对照表方便你定位问题现象可能原因解决思路条件判断不生效走了默认分支空格或大小写不一致检查ifeq比较时两边是否有多余空格用$(strip)处理ifdef对空变量判断为真混淆已定义和非空改用ifeq ($(strip $(VAR)),)判断foreach展开出来的规则没生效缺少$(eval)或生成的文本不是合法规则确认有$(eval)包裹并用$(info ...)打印展开结果函数体内shell变量被吞忘记$$转义把$var写成$$var递归Make时变量丢失子Make不继承顶层变量用export VAR或export全部导出新增源文件后不编译没有用wildcard自动发现或路径不匹配确认SRCS是否包含新文件make -p查看变量修改头文件后全部重编缺少-MMD -MP依赖生成在编译命令里加上依赖生成选项并-include *.deval生成的规则里命令前有空格而非Tabdefine函数体内缩进用了空格确保函数体内命令以Tab开头这些几乎都是我在真实项目里遇到过的。条件判断的空格问题我踩过整整一个下午最后发现ifeq ($(UNAME),Linux)写成了ifeq ($(UNAME), Linux)而shell环境变量里没有那个空格。从那次以后我凡是比较之前都会$(strip)两边再比。6.2 调试Makefile的几个狠招如果你不确定一段函数展开后是什么内容最快的方法是加$(info ...)打印。它能在解析期直接输出文本不会影响构建流程。比如$(info OBJS $(OBJS)) $(info foreach result $(foreach src,$(SRCS),$(notdir $(src))))我把这招叫做Makefile的printf调试法。第二个是make -n它只打印将要执行的命令不真正执行。这对检查规则生成是否正确非常友好。如果你想看make -n之后Make到底认为哪些文件需要更新再配合make -d可以输出调试信息信息量很大一般配合grep使用只看关键行。第三个是make -p它会打印Makefile的完整数据库包括所有变量和规则。这个输出非常长建议配合grep ^OBJS之类的过滤。当你怀疑某个变量没有被正确赋值时-p是最权威的答案来源。还有一个比较少人提的排查思路把函数展开结果单独写进一个临时Makefile里观察。比如include my-func.mk $(info $(call my-func, a, b))在命令行执行make -f test.mk输出的就是函数展开后的文本。这样做的好处是隔离环境不需要看整个工程。我记得每次改完evel相关的函数我都会先这样验证一遍再放到大项目里跑。6.3 关于设计取舍的三点心得第一个心得是渐进式引入。不要一开始就把Makefile写成全家桶先保留简单的规则等确实需要多平台时再加条件判断源文件数量大了再引入foreach和define。脱离实际复杂度做过度设计最后只会给自己增加维护负担。第二个心得是命名和注释。自定义函数的名字尽量用动词短语比如make-object-rule、generate-deps不要叫f1、f2。Makefile不像普通代码有IDE的跳转和补全命名不清真的会坑后来人包括三个月后的自己。第三个心得是善用变量分层。顶层变量、模式参数变量、函数参数变量要分清楚别把局部变量污染到全局。比如在foreach循环里用临时变量名时我习惯加前缀_或使用不常见的名字避免和已有变量冲突。这个习惯是从C语言局部变量命名那里迁移过来的放在Makefile里一样好用。讲到这儿Makefile的条件判断、循环、自定义函数这三板斧就聊得差不多了。根据我个人经验这三样东西真正改变了我的构建脚本编写方式以前是写规则现在是设计规则生成器。那种面对几十个源文件、三种平台、两种编译模式还不慌的感觉是值得每个做C/C项目的人体验一下的。最后再分享一个小技巧写完一段复杂的Makefile先make -n演练一遍再正式执行这比事后排错省力得多。
返回列表