ARTICLE DETAIL

资讯详情

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

从依赖到构建:全面搞定Makefile核心语法与工程实战

从依赖到构建:全面搞定Makefile核心语法与工程实战 写Makefile这事说难不难说简单也容易翻车。我见过不少人C/C代码写得飞起一到构建环节就抓瞎要么在IDE里点鼠标点得心累要么在服务器上对着 “make: *** No targets specified and no makefile found” 发呆。今天这篇就想把Makefile这件事从头到尾捋一遍从为什么还在用、怎么上手、怎么排错到它和CMake到底是什么关系一次说清楚。这里聊的Makefile不是教科书里那种只用来编译一两个文件的玩具而是能真正拿到真实工程里用的写法。不管你是刚接触命令行编译的菜鸟还是已经在嵌入式Linux比如RV1106这类板子上被头文件路径折腾过的开发者这篇文章应该都能给你一些能直接落地的参考。1. 为什么到今天还在用Makefile很多人一上来就问现在都有CMake了还有必要学Makefile吗这个问题我在不同场合被问过无数次结论很明确——非常有必要而且要学就学扎实。1.1 构建系统本质上在干什么先把Makefile放到更大的图景里看。一次完整的程序构建至少包含预处理、编译、汇编、链接这几步。你写了几十个甚至上百个源文件如果每次改一个文件都要全量重新编译时间成本完全没法接受。Makefile干的事情本质上就是帮你判断哪些文件需要重新编译并按照依赖关系按顺序执行编译命令。这个“判断哪些文件变了”的机制靠的是文件时间戳。make会比较目标文件和依赖文件的修改时间如果依赖比目标新就重新生成目标。所以Makefile的核心不是语法多花哨而是依赖关系描述得准不准。另外构建过程还涉及编译选项、头文件搜索路径、宏定义、静态库和动态库链接参数。这些东西散落在命令行的历史记录里换个环境就失效。写进Makefile整个构建流程就变成可重复、可分享、可版本管理的东西了。1.2 和直接写脚本的区别有人会说我写一个shell脚本里面按顺序gcc每一个文件不一样吗不一样差别还挺大。shell脚本是无脑地从头跑到尾就算你只改了一个文件它也会把全部源文件重新编译一遍。而make只编译发生变化的文件这是本质区别。在一个大工程里全量编译可能十分钟增量编译只要几秒这个体验差距是决定性的。再一个差别是错误处理。Makefile里某一条命令失败了make会立刻停下来报错不会像shell脚本那样继续往后跑避免了“前面编译已经挂了后面还在傻乎乎地链接”这种浪费时间的操作。还有依赖管理。Makefile可以精确描述头文件和源文件之间的依赖关系某头文件一改所有包含它的源文件都会重新编译。这一点脚本很难做到这么细。1.3 哪些场景非它不可现在的构建工具体系里CMake、Ninja、Bazel满天飞但Makefile依然活跃在大量场景里。嵌入式Linux开发瑞芯微、全志这类芯片的SDK里大量工程还是用Makefile组织。比如RV1106的官方SDK甚至很多驱动模块的编译方式就是make命令配合内核的Kbuild系统。开源软件的经典构建方式configure脚本生成Makefile然后make、make install三步走这是Linux下持续几十年仍然顺手的标准流程。小中型C/C项目如果一个项目就十几个文件引入CMake反而显得笨重手写一个两百行的Makefile维护成本更低也更直接。构建Algo、插件、固件等特定产物很多底层组件需要在特定目录、特定工具链下构建Makefile那套清晰的目标规则比大而全的构建系统更贴合需求。所以Makefile不是被淘汰的老古董而是还在大量发挥作用的工程基础技能。搞懂了它再去看CMake生成的复杂Makefile甚至去看内核Kbuild的Makefile你都只会觉得越来越通而不是越来越懵。2. 先把Makefile这几行语法吃透Makefile语法不多但每一条都值得琢磨清楚。接下来把最高频的几块过一遍这部分基本是“菜鸟教程”必须覆盖的内容但我尽量把背后的原理和容易踩的坑一起说。2.1 规则三要素目标、依赖和命令Makefile里最基础的单元是规则格式长这样目标: 依赖... 命令这里有个坑必须提前说命令前面必须是Tab键不能是空格。别笑这个坑十个人里至少有六个人踩过而且报错信息非常诡异叫 “missing separator”。如果你复制网页上的Makefile缩进经常被复制成空格然后make就给你来这么一句。目标一般是一个文件名也可以理解为“这条规则要生成什么”。依赖是生成目标所需要的文件或其他目标。命令则是真正干活的那条shell命令。一个最小例子hello: hello.c gcc -o hello hello.c它的意思是要生成hello这个文件前提是hello.c存在且比hello新。如果hello已经存在且hello.c没改过make会告诉你“hello是最新的”一条命令都不执行。理解了这个时间戳判断逻辑后面大部分“为什么没重新编译”的问题都能自行解决。2.2 变量与自动变量让你少写一半废话如果写规则时把文件名写死那跟shell脚本也没啥区别。Makefile真正顺手的兵器是变量和自动变量。变量的定义和赋值方式有几种容易让人晕赋值符含义典型场景递归展开使用时才展开适合引用后面才定义的变量:立即展开定义时立刻取值适合需要当前值确定的场景?如果未定义则赋值设置默认值追加累加源文件、依赖库初学者一个常见的错误是把和:混用结果变量互相引用导致死循环。例如A $(B) B $(A)如果用make会提示递归变量引用而用:则会先取当下的值取到的很可能是空。自动变量是那种“不用自己起名字”的魔法变量$代表当前规则的目标$^代表全部依赖$代表第一个依赖举个例子main.o: main.c utils.h gcc -c -o $ $这里$就是 main.o$就是 main.c。写规则瞬间就少了而且就算依赖顺序调整也不用每个命令都改一遍。2.3 伪目标、模式规则和隐含规则先说伪目标。如果一个目标名和真实文件名重名make就会认为“目标已经存在了”不会执行规则。最典型的就是clean。很多项目都有一个clean规则用来删除编译产物但你不可能在目录里放一个叫clean的文件吧万一有这条规则就失效了。解决办法是用.PHONY把它声明为伪目标.PHONY: clean clean: rm -f *.o hello声明了伪目标后make不检查有没有同名文件每次都会执行里面的命令。除了cleaninstall、test、all这些一般也都声明为伪目标。模式规则是Makefile的“通配符”用%匹配任意非空字符串。比如%.o: %.c $(CC) -c $(CFLAGS) $ -o $这条规则表示“任意.c文件都可以用该规则编译成对应的.o文件”非常省事。有了它每个源文件不用逐条写规则。隐含规则则更激进如果你完全不写生成.o的规则make也会尝试用内置规则默认用$(CC) -c加上一堆默认参数去编译。但默认参数很简陋没有你的-I头文件路径也没有优化选项。所以我的习惯是不依赖隐含规则显式写清楚编译和链接命令虽然多几行但可控性高很多别人接手也容易看懂。3. 实操手写一个多目录C工程Makefile纸上谈兵没用下面用一个具体的多目录C工程来手写。这个工程的结构不复杂但对准了很多实际痛点源文件分散、头文件引用、自动收集文件、增量编译、清理产物。3.1 工程结构设计与思路假设项目长这样project/ ├── include/ │ └── utils.h ├── src/ │ ├── main.c │ ├── utils.c │ └── net/ │ └── socket_helper.c └── Makefile头文件在include目录源文件散落在src和src/net后面还想把编译产物统一放到build目录不污染源码树。这种结构在真实项目里非常常见。在设计Makefile之前想清楚几点用wildcard函数自动收集源文件列表不用手动一个个加文件名用patsubst把.c文件路径转换成.o文件路径头文件路径用-I参数传给编译器编译时生成.d依赖文件自动跟踪头文件变化产物目录统一管理方便清理3.2 分步写一个可直接用的Makefile下面这个Makefile是我在类似小中型项目里常用模板的简化版本CC gcc CFLAGS -Wall -Wextra -O2 -g CPPFLAGS -Iinclude -Iinclude/net LDFLAGS BUILD_DIR build SRC_DIR src SRCS $(shell find $(SRC_DIR) -name *.c) OBJS $(patsubst %.c,$(BUILD_DIR)/%.o,$(SRCS)) DEPS $(OBJS:.o.d) TARGET bin/app .PHONY: all clean all: $(TARGET) $(TARGET): $(OBJS) mkdir -p $(dir $) $(CC) $(LDFLAGS) -o $ $^ $(BUILD_DIR)/%.o: %.c mkdir -p $(dir $) $(CC) $(CPPFLAGS) $(CFLAGS) -MMD -MP -c $ -o $ -include $(DEPS) clean: rm -rf $(BUILD_DIR) $(TARGET)拆开看几个关键点wildcard在变量里不好嵌套复杂写法所以这里直接用$(shell find ...)递归搜源文件。如果知道文件都在同一层用$(wildcard src/*.c)就够。patsubst把src/main.c转成build/src/main.o注意这个转换结果里依然保留相对路径目录结构不会扁平成一层。每条编译规则里的mkdir -p $(dir $)确保目标目录存在。$(dir $)提取的是目标文件的目录部分比如build/src/net/。-MMD -MP让编译器在编译时生成.d文件里面记录了源文件对应的头文件依赖然后用-include $(DEPS)把这些依赖文件引进来。这样改任何头文件Makefile都能感知到对应的.o需要重新编译。这是现代C工程里比较标准的做法靠它才能解决“改了头文件但没重新编译”的经典问题。编译命令里的mkdir -p前面的作用是make执行命令前默认会打印这条命令加上后只执行不打印输出干净很多。3.3 头文件路径的正确打开方式头文件找不到是特别高频的报错尤其是把工程从IDE搬到命令行构建时经常遇到fatal error: xxx.h: No such file or directory。Makefile里跟头文件搜索相关的变量是CPPFLAGS。注意不是CFLAGS。CFLAGS 是给C编译器的选项CXXFLAGS给CCPPFLAGS是C预处理器的选项-I就放这里。虽然很多人习惯把-I塞进CFLAGS里也能跑但规范一点CPPFLAGS更准确尤其是在同时编译C和C混编项目时可以一次指定两处生效。-I参数后面跟的是搜索路径。比如-Iinclude意思是编译器在遇到#include utils.h时会先搜索当前源文件所在目录再去include目录找。如果写成-Iinclude/net那#include socket_helper.h就能直接命中。还有一个容易出问题的点搜索路径的顺序。如果有多个同名头文件分布在不同目录-I会按照从左到右的顺序查找。最常见的坑是系统的/usr/include里有一个旧版本的头文件你把自己项目的目录放在后面结果编译器找到了旧的引入了莫名其妙的类型定义差异。所以自己的项目目录一定要放在靠前的位置。再补充一点#include ...和#include ...的搜索规则其实不同。前者先从当前文件所在目录找再找-I路径后者跳过当前目录直接从-I路径和系统目录找。因此在代码里项目内头文件用双引号第三方库和系统头文件用尖括号这个习惯能让Makefile里的路径设置更可预期。3.4 交叉编译RV1106这种嵌入式板子怎么改如果你的目标平台是嵌入式Linux比如瑞芯微RV1106这样的小型SoC板子Makefile的调整思路其实特别简单换编译器加工具链路径必要时调整链接参数。我接触到RV1106这类平台的SDK时工具链通常是arm-rockchip830-linux-uclibcgnueabihf-gcc这种前缀很长、但内核架构还是armhf的交叉编译器。拿到SDK之后往往需要先做一件事设置工具链的PATH。CROSS_COMPILE arm-rockchip830-linux-uclibcgnueabihf- CC $(CROSS_COMPILE)gcc AR $(CROSS_COMPILE)arCROSS_COMPILE是内核和U-Boot构建系统里用得最多的一个变量很多平台的Makefile都在用这种套路。定义好CC之后后面所有编译规则都不用改它们统一引用$(CC)。嵌入式平台经常还涉及静态链接、去掉调试符号、指定链接脚本等需求。比如LDFLAGS -static CFLAGS -mcpucortex-a7 -mfloat-abihard -mfpuneon不同芯片的cortex内核和浮点ABI参数可能不同具体以芯片手册和SDK默认配置为准这里只是演示思路。另一个让我多次吃瘪的地方是交叉编译时系统库路径和头文件路径都和本机不同。本机的/usr/include很可能不存在于交叉工具的sysroot里所以CPPFLAGS里的-I必须指向SDK的include目录。如果程序依赖第三方库库文件路径用-L指定链接库名用-lxxx。很多工程在开发机上能编译一交叉编译就报找不到crt1.o基本都是sysroot工具链路径没配对。4. 那些让你挠头的报错一次说清Makefile的语法报错和编译报错是两码事。这一节把最常见、最让人纠结的几个问题直接拿出来说透。4.1 make没有指明目标并且找不到makefile这是新手最容易撞上的报错完整信息大概是make: *** No targets specified and no makefile found. Stop.这句话字面意思很清楚当前目录下没有Makefilemake不知道该干嘛。原因无非这几个文件名不匹配make默认找Makefile、makefile或GNUmakefile。如果你建的文件叫makefile.txt或Makefile.v1make当然找不到。当前目录不对在项目的子目录里执行了make而Makefile在上级目录。Makefile内容为空文件存在但没有写任何规则同样会报这个错。排查方法先确认文件存在、名字对不对再确认当前路径。如果你要指定别的文件名可以用make -f 文件名。更让人迷惑的一个变种是报错里同时出现“规则makefile没有目标”之类的话这往往是因为你在Makefile里用include foo.mk引入了一个不存在的文件导致make在解析阶段就出问题。这种情况建议检查include行必要时使用-include弱化对文件的强依赖。4.2 头文件找不到和重复定义头文件相关最常见的是fatal error: xxx.h: No such file or directory前面已经说过用CPPFLAGS加-I。这里再补充一个排查顺序确认头文件确实存在于某个路径没被遗漏在Git里。确认Makefile里指定的搜索路径覆盖了该目录。在命令行手动跑一次编译器把Makefile里的变量展开后手动执行看能不能编译以此排除是Makefile拼接错误还是头文件内容本身有问题。检查头文件里是否写了#ifndef或#pragma once防止重复包含。重复定义报错经常不是头文件路径的问题而是宏保护缺失。还有一个很隐蔽的坑.d依赖文件过期了。编译选项改了、头文件路径改了但之前生成的.d文件还引用旧路径make认为依赖关系成立不重编。这种情况最直接的解决办法就是make clean之后重新构建。4.3 目标文件不会自动更新“我明明改了源文件为什么make说它是最新的”这个问题我见过好多次原因往往不是特别玄乎但挺隐蔽。系统时间不一致。比如文件在NFS共享目录里目标文件和源文件的时间戳因为时区、时钟漂移导致比较结果异常。简单验证办法是touch一下源文件再执行make。修改时间比目标文件更早。这种情况常见于“从Git仓库checkout”代码时间戳被保留为旧时间。解决办法也是touch源文件。依赖关系没声明。比如只写了main.o: main.c没写main.o: utils.h。修改utils.h后make根本不知道main.o需要重新生成。这就是为什么要用-MMD -MP自动生成依赖文件。4.4 快速问题速查表现象常见原因处理办法missing separator命令前用了空格不是Tab编辑器里显示控制字符改成TabNo targets specified目录下没有Makefile或文件名不对确认文件名和当前路径make: Nothing to be done目标已存在且比依赖新touch依赖文件或make clean后重来头文件找不到缺少 -I 路径或路径写得不对检查CPPFLAGSundefined reference链接时缺少库或库顺序不对补 -L 和 -l注意顺序改了头文件不重编依赖文件缺失或过期开启 -MMD -MP或清理重编变量循环引用用互相引用改成:重构依赖链接时库的顺序这个细节值得单独提醒一句对静态库来说被依赖的库要放在依赖者后面。比如main.o依赖libfoo.a而libfoo.a又依赖libbar.a链接命令应该写成-lfoo -lbar顺序反了会出现undefined reference。5. CMake和Makefile的本质区别以及我选型的心得平时在社区里看到“cmake和makefile区别”这种问题出现频率相当高。很多人把它们当成同一种东西的两个版本其实差得很远。5.1 它们根本不在同一层Makefile是一种构建脚本直接由make程序解释执行。CMake则是一种构建规则描述语言本身不直接构建它运行后会生成一个构建系统文件默认生成的就是Makefile也可以生成Ninja build文件甚至能生成Visual Studio工程。这句话是关键CMake是生成Makefile的工具之一。套用一句好记的话“CMake负责metaMakefile负责actual。” 所以你在一个用CMake管理的项目里敲make其实走的依然是Makefile只不过那份Makefile是CMake生成的。这也解释了很多人的困惑为什么CMake项目里的Makefile那么难读因为它不是给你维护的而是给make执行的。里面大量的变量、模式和中间状态由CMake生成的规则接管人读起来当然头大。5.2 可移植性和生态差异Makefile本身是跨平台的工具但内容很容易写平台相关。你在Linux下写的rm -rf在Windows的cmd里就是非法命令。CMake则替你抹平了这些差异通过add_executable、target_link_libraries这种抽象语法让你不用关心底层到底用什么命令做清理和复制。CMake还提供了一整套查找依赖包、生成安装规则、支持测试框架的生态。如果你要构建一个大型跨平台项目、或者要集成第三方库选CMake基本是当下默认答案。但CMake也有它的复杂性。它的学习曲线其实比Makefile更陡峭变量作用域、target属性、生成器表达式这些概念的抽象度高出了问题排查起来反而没有Makefile那么直接。有一些小项目用CMake写出来的CMakeLists.txt比Makefile还绕维护者换一茬就没人敢动了。5.3 自动生成Makefile的工具路线除了CMake生成Makefile的经典路线还有GNU autotools那套写configure.ac和Makefile.am然后autoreconf生成configure脚本用户执行configure生成Makefile。开源软件的老三样./configure make make install就是这么来的。这套体系对库项目、需要在全国各种Unix变体上兼容的场景非常合适但上手门槛比Makefile高出一大截。除非你要维护一个发布给全世界用户的开源库否则我一般不太建议普通人入坑autotools。还有像qmake、meson这些系统meson也能生成后端的Ninja但在“生成Makefile”这个语境里主流还是CMake和autotools。5.4 我的建议与选型判断直接给一个参考路径项目类型推荐方式理由单目录、三五文件的小工具手写Makefile最少心智负担一眼看穿全部逻辑多目录但没有跨平台需求手写Makefile结构和依赖关系好掌控构建快需要跨平台构建、打包发布CMake生态完善便于对接CTest和CPack嵌入式Linux SDK跟随SDK既有构建体系芯片厂商通常已给出Makefile链不要为改而改大型开源库项目CMake或autotools维护成本由体系解决避免自己造轮子我把手写Makefile放到这么靠前的位置不是因为固守习惯而是基于一个现实Makefile是理解一切构建系统的基础。你如果连目标、依赖、变量这套概念都搞不清看CMake生成的Makefile会像看天书反之Makefile基础扎实了理解CMake的抽象反而容易得多。5.5 手写Makefile时我习惯保留的几个功能哪怕有CMake存在我自己维护的不少工具项目依然坚持用Makefile因为里面有一套顺手的小功能默认目标all写清楚主产物让make直接可以出成果make install提供安装路径变量方便本地调试时装到临时目录make test跑简单测试用例不用引入额外的测试框架make clean里带上备份文件清理避免.o和临时文件污染目录make help用注释输出可用目标别人拿到Makefile不用翻文档这些目标统一用.PHONY声明放在文件末尾或顶部成组整理阅读体验好很多。比如.PHONY: help help: echo make 构建主程序 echo make install 安装到指定目录 echo make test 运行测试 echo make clean 清理构建产物一个Makefile做到这个程度已经不是“会写”的范畴而是变成了项目自动化入口。别人拿到你这份代码第一件事敲make help马上就对这个项目怎么构建、怎么验证了如指掌。最后再说点个人经验前前后后维护过的Makefile加起来几十个是有的。踩过的坑里最让我印象深刻的倒不是语法报错而是“Makefile太精巧读代码的人跟不上”。有些老项目里的Makefile写满了多层变量展开、条件分支、define宏构建逻辑全靠反推。所以后来我给自己定了几个Deadly Simple的原则变量命名不要过度缩写每一条规则只干一件事关键路径用注释说明“为什么这么写”不是特别必要就不搞递归make那套跨目录调用的花活。一个合格的Makefile应该让三个月后的自己一眼就能看明白而不是像一个天书谜题。如果你现在刚接触我的建议很简单找一个小项目不用CMake不用IDE亲手写一个Makefile出来把源文件、头文件、产物目录、clean规则都整理好然后体验一下改一个源文件后执行make那种“只重新编译该编译的东西”的精准感。手感建立起来之后再去看CMake生成的巨型Makefile也好看Linux内核Kbuild也好你会发现原来所有构建系统讲的都是同一件事依赖关系以及怎么高效地更新它们。构建这个环节没有银弹但先弄懂Makefile至少能让你在所有构建工具面前都有底气。
返回列表