尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Makefile 实战指南:自动化构建、依赖管理与排错全解

Makefile 实战指南:自动化构建、依赖管理与排错全解 我一直觉得构建这件事是区分写代码和做工程的一道分水岭。你当然可以每次手动敲gcc main.c utils.c -o app但当你需要管理几十个源文件、处理头文件依赖、区分 Debug 和 Release 配置还要在多人协作时保证大家构建出一模一样的东西时手动编译就成了灾难。这时候Makefile 几乎是绕不开的答案。这篇文章就围绕自动化构建这个主题把 make 和 Makefile 的来龙去脉拆开讲讲。我会从为什么需要它说起然后带你手把手写一份不算精致但足够实用的 Makefile再把我踩过的几个典型报错完整复盘一遍。最后聊一下 make 和 cmake 的关系——毕竟现在很多人一上来就学 cmake反而把更底层的 make 给忽略了。无论你是刚被make: make 不是内部或外部命令折磨过的新手还是在 Makefile 里被头文件路径搞到头大的老手这篇文章都值得看完。1. 为什么我把构建这件事交给 make 而不是脚本先说个场景。你写了一个项目里面有main.c、utils.c、parse.c还有一堆头文件。最早你靠编译命令解决一切gcc -c main.c -o main.o gcc -c utils.c -o utils.o gcc -c parse.c -o parse.o gcc main.o utils.o parse.o -o app头几次还好但很快你就发现两个问题一来命令越来越长二来你改了一个头文件却没法快速知道哪些.c文件需要重新编译。你要是偷懒全部重新编译一遍项目稍微大点一次全量构建好几分钟就没了。我见过一个同事每次构建都手动rm -rf build make美其名曰干净实际上面试问到他增量编译原理他一脸茫然。这时候有人会想那我写个 shell 脚本把编译命令按顺序包起来不就行了吗确实可以解决命令太长的问题但解决不了哪些文件需要重编的问题。脚本不知道config.h改了之后哪些.o文件依赖它。它只能无脑执行你写好的命令序列。如果碰上一个文件编译失败脚本通常也没法智能地告诉你具体是哪个依赖链断掉了更别提并行编译了。Make 的价值恰恰在这里它建立在文件依赖关系之上。Makefile 的核心不是写命令而是描述目标、依赖和规则的三角关系。Make 程序会自动比较目标和依赖的时间戳谁新就重新生成谁链路向上传导。比如app依赖main.o utils.o parse.o而main.o依赖main.c defs.h。你修改了defs.hmake就会重新编译main.o然后自动链接app但utils.o如果和defs.h没关系它就不会被碰原封不动保留。这么说吧make 就是构建领域的增量计算引擎。它不关心你用什么语言——C/C、Fortran、甚至 LaTeX只要你定义好依赖和生成规则它就能精确地把这份活干完。还有一点很关键Makefile 本身就是一种文档。新同学拉下代码看一眼 Makefile 里的目标名和依赖基本就能猜出这个项目的构建流程。这比你去翻 README 里的构建步骤靠谱得多——文档会过期Makefile 里的依赖关系却始终和代码保持同步因为它就是构建过程的亲历者。2. 手写第一份 Makefile从能跑到好用的四步迭代说实话我并不推荐一上来就丢给你一份花里胡哨的 Makefile。我第一次写 Makefile 时抄了一堆变量、通配符、自动生成依赖的骚操作结果跑出问题根本不知道是哪行出的错。正确的做法是像盖房子一样逐层加料每一步都保证能跑再往上累加。2.1 第一步先把编译规则写扎实最简单的 Makefile 长这样CC gcc CFLAGS -Wall -g app: main.o utils.o parse.o $(CC) $(CFLAGS) main.o utils.o parse.o -o app main.o: main.c defs.h $(CC) $(CFLAGS) -c main.c -o main.o utils.o: utils.c utils.h $(CC) $(CFLAGS) -c utils.c -o utils.o parse.o: parse.c parse.h defs.h $(CC) $(CFLAGS) -c parse.c -o parse.o clean: rm -f *.o app这里CC和CFLAGS是变量。用变量的原因很简单如果后来想换编译器、加优化参数只需要改文件头部一两行而不是到处替换。换句话说变量让 Makefile 具备了可配置性。这段 Makefile 有四个构建目标app、三个.o文件、clean。其中clean不是文件是一个伪目标——你执行make clean时它直接执行命令不会去检查clean文件是否存在。这种目标后面会越来越多所以我们会用.PHONY显式声明后面细说。此时你执行make app或直接make默认第一个目标make 就会逐条检查app是否比所有.o都新不是就重新生成。main.o是否存在不存在就去编译main.c。这就是最基础的自动化构建闭环。2.2 第二步用模式规则干掉重复代码上面三行编译规则除了文件名不一样格式完全一样。写三个还好写十个源文件呢你会崩溃的。这时候使用模式规则pattern rule来收敛重复CC gcc CFLAGS -Wall -g app: main.o utils.o parse.o $(CC) $(CFLAGS) main.o utils.o parse.o -o app %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f *.o app浪花点在于%.o: %.c这条规则。它说任何一个.o文件都从同名的.c文件生成。$表示依赖列表里的第一个文件就是那个.c$表示目标文件名就是那个.o。这样只要你列好了app依赖哪些目标文件make 会自动推导出编译命令不需要为每个.c单独写规则。但是注意一个细节main.o和parse.o都依赖defs.h这种头文件依赖从规则里消失了。你改了defs.hmake并不会认为main.o过时这就可能导致链接出一个老模块和新头文件混搭的产物。这正是很多改了半天代码不生效的根源之一。怎么彻底解决看第三步。2.3 第三步自动生成头文件依赖你当然可以手工在每个.o规则后面列出它依赖的头文件但每改一次头文件包含关系你就要同步改 Makefile太容易漏了。正解是让编译器替我们算出依赖清单。GCC 支持-MMD参数它在编译时顺手生成一个.d文件内容就是该.c的依赖列表CC gcc CFLAGS -Wall -g DEPFLAGS -MMD -MP app: main.o utils.o parse.o $(CC) $(CFLAGS) main.o utils.o parse.o -o app %.o: %.c $(CC) $(CFLAGS) $(DEPFLAGS) -c $ -o $ clean: rm -f *.o *.d app -include $(wildcard *.d)末尾的-include $(wildcard *.d)把当前目录下的所有.d文件读进来。-include前面的小横线表示如果文件不存在比如初次构建还没有生成.d不要报错继续执行。这样第二次及以后的构建make 就能精确知道每个.o依赖哪些头文件。-MP参数会为头文件生成空目标避免头文件被删除后 make 报缺少目标错误。这一步做完你的 Makefile 才算真正具备自动化的灵魂——改任何.c或.h增量构建都会自动、精确地重编该重编的部分其他文件动都不动。实测一个几十个源文件的项目全量构建 1 分钟改一个头文件后的增量构建往往一两秒就结束了。2.4 第四步加上多目标、多配置和必选变量项目通常不止跑一个app可能还有单元测试、静态检查目标.PHONY: all test release debugclean all: app release: CFLAGS -O2 -DNDEBUG release: app debug: CFLAGS -O0 -g -DDEBUG debug: app test: app ./app --run-tests clean: rm -f *.o *.d app -include $(wildcard *.d)这里有个小技巧在目标的后面写上release: CFLAGS -O2这是目标特定变量赋值只有构建release目标时CFLAGS才变成-O2其他目标不受影响。所以我每次构建make release # 产出开优化的产品版 make debug # 产出可调试版 make test # 构建并跑测试还有一个很实用的细节如果你希望某些变量不被外部环境覆盖用override关键字如果你希望必须传入某个变量、否则让构建立刻失败可以这样ifndef VERSION $(error VERSION is not set. Usage: make app VERSIONx.y.z) endif这种方式在发布版本号时极其省心——别人拿过去一跑没带VERSIONbuild 直接弹错误提示而不是生成一个版本号未知的产物。3. 高频报错的完整排查链路三个典型案例复盘这里得说点真心话看 Makefile 报错最忌盯着最后一行看。make 的报错有时候很隐晦真正的原因可能在前面几行甚至跟当前目标毫无关系的变量展开里。我把网上热词里最常被问到的三个实际案例完整复盘一遍你以后遇到同类问题基本能照着这个思路一条条排除。3.1 案例一make: *** No rule to make target header.h, needed by main.o这个报错非常经典因为它通常根本不是你的 Makefile 里缺了什么规则而是依赖文件真的不存在。当时我负责移植一个第三方库到嵌入式平台编译时报出缺rv1106_gpio.h但源码目录里明明有这个头文件。我第一反应是路径写错了检查发现 Makefile 里依赖写的相对路径而当前构建目录是 build 子目录头文件在源码根目录的 include 下——make 在解析依赖时没有去 include 目录找。排查链路是这样的先确认报错目标是哪一个make -n以演习模式打印将要执行的命令不真正执行。从输出的尾部往前找看是哪条规则导致header.h被列为前置需求。用make -p打印当前 Makefile 解析出的全部变量和规则用 grep 搜索header.h弄清楚 make 认为它应该在哪。如果路径没问题检查头文件是不是确实存在ls -l include/rv1106_gpio.h。都不行看.d文件里记录的老依赖路径是否已经失效——特别是你移动过头文件目录、而.d文件还是老的绝对路径时-include一下就把错误的依赖关系带进来了。顺带一提这类头文件路径找不到的问题在老牌 Makefile 里最常用的解法是给编译器加-I参数CFLAGS -Iinclude -I../common/include其中-I.表示当前目录-I..表示上级目录。路径本身写得越规范依赖消失的概率就越低。我的习惯是 Makefile 里所有目录引用都用变量集中管理INC_DIRS -Iinclude -Ithird_party/xxx/include新同事接项目时改一处即可不用一个个翻规则。3.2 案例二make: make 不是内部或外部命令 / cmdlet 识别不了 make这个报错出现的频率特别高因为它不只是 Makefile 的问题而是Windows 环境默认没有 make 命令。很多人第一次在 VSCode 终端里敲make得到的就是这串报错。VSCode 自带的终端其实是 PowerShell它不认识make这个命令于是报出无法将make项识别为 cmdlet、函数、脚本文件或可运行程序的名称。解决方案分几个层级按推荐顺序排安装 MSYS2 或 Git for Windows装好之后把安装目录/usr/bin加进系统 PATH。Git for Windows 自带一个make.exe不过它是mingw32-make和标准 GNU make 略有差异。MSYS2 里更干净pacman -S make就拿到正牌 GNU make。如果装了 Visual Studio可以用它的Developer Command Prompt里面自带了nmake但不是make语法还略有不同不建议新手在nmake和make之间反复横跳容易两头都写岔。在 WSL 里跑如果你的代码最终在 Linux 环境部署直接在 WSL 里sudo apt install make整个体验和 Linux 一致。唯一的坑是文件路径转换别把 Windows 的C:\路径直接塞进 Makefile 里。这里有个情绪层面的建议不用觉得是自己不会命令行才搞不定这个问题。Windows 与 make 本来就是两个世界不是你的问题只是生态割裂。装好环境敲make -v看到版本号这事就算解决了一半。3.3 案例三undefined reference to X 但单独编译这个文件又是好的这个案例特别有迷惑性因为报错来自链接阶段而新手往往在编译阶段找不到任何问题——所有.o都生成成功了undefined reference却接二连三地蹦出来。我之前在做一个网络库时send_buffer.o里调用了一个crc32()函数链接时报找不到可我看到libcrc.a就躺在lib/目录里。关键问题往往出在链接顺序上。GNU ld 在处理静态库时是按需提取的它从左到右扫描输入文件如果一个.o文件引用了某个符号就会去当前还没扫描完的后续静态库中寻找。所以如果你把静态库放在引用它的.o前面# 错误示范库在前 $(CC) $(CFLAGS) -lmylib src/send_buffer.o -o app链接器先扫描-lmylib此时并不知道后面send_buffer.o需要crc32于是直接跳过这个库等扫到send_buffer.o时符号解析不到报错。正确的做法是把依赖库放在所有引用它的目标文件之后# 正确示范库在后 $(CC) $(CFLAGS) src/send_buffer.o -Llib -lmylib -o app还有一个高频原因是.o文件老了。你在当前目录改了源码但make因为依赖信息缺失没有-MMD没有重编.o旧.o里的符号和现在的源码对不上链接自然失败。这时候先make clean再全量构建通常能排除 80% 的奇怪编译问题。如果 clean 之后还是不行再从符号本身入手——用nm看libmylib.a里有没有定义crc32它的签名是不是和声明一致。4. 不要迷信 cmakemake 与 cmake 的真实分工在热词里cmake 和 makefile 的区别是搜索量相当高的问题。很多人入门时直接学 cmake反而把 make 忽略了我觉得这是有点遗憾的。这两个工具的关系一句话可以讲清楚cmake 是 Makefile 的生成器make 是 Makefile 的解释执行器。cmake 根据CMakeLists.txt生成对应的 Makefile或其他构建系统的文件然后 make 负责按照这份文件真正执行编译链接。这么一说你就明白了cmake 解决的是跨平台工程组织和配置的问题make 解决的是文件依赖与增量构建的问题。它们不在同一层。你可以单独用 make 快速构建一个小模块也可以在 cmake 项目里把复杂的模块交给 cmake 组织然后底层仍由 make 执行。我用一个表格区分它们各自最适合的场景维度make Makefilecmake定位构建执行器处理依赖与增量编译构建系统生成器自动生成 Makefile/Ninja 等跨平台依赖环境自带 makeWindows 下麻烦些天然支持 Windows/Linux/macOSIDE 集成好学习曲线基础规则简单高级写法绕语法较独特但工程化能力全面第三方库管理手动 -I/-L 指定find_package 自动定位适用场景中小项目、嵌入式、快速原型大型项目、跨平台分发、需要缓存配置那什么时候不该上 cmake答案是当你只需维护一个源码树下 10 个文件的小工具时。这时候 cmake 的声明式抽象反而让你比写 Makefile 更费劲生成的构建目录也占地方。我自己写一次性实验代码、脚本工具永远直接用 Makefile简单直接需要交付给团队、多平台编译、引入第三方库时才用 cmake 搭建骨架。两者并不对立。我常干的事情是在外层用 cmake 管理整个项目的配置和依赖查找在内层某个高度定制化的编译环节故意让 cmake 调用我手写的 Makefile——这种混合编排在实际项目中非常实用。5. 多模块项目的 Makefile 组织经验从单文件到目录递归项目一大你就不可能把所有规则平铺在一个 Makefile 里。这里分享我常用的两种组织方式都是实际项目中验证过的。5.1 方式一单个 Makefile 目录变量不推荐一开始就搞递归 make这个模式容易把依赖关系搞碎并行构建还会有奇怪问题。对于几百个文件的规模单个 Makefile 完全可以撑住。SRC_DIR : src OBJ_DIR : build/obj BIN_DIR : build/bin SRCS : $(wildcard $(SRC_DIR)/*.c) OBJS : $(patsubst $(SRC_DIR)/%.c,$(OBJ_DIR)/%.o,$(SRCS)) DEPS : $(OBJS:.o.d) app: $(OBJS) mkdir -p $(BIN_DIR) $(CC) $(CFLAGS) $^ -o $(BIN_DIR)/$ $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c mkdir -p $(OBJ_DIR) $(CC) $(CFLAGS) -MMD -MP -c $ -o $ -include $(DEPS)wildcard、patsubst都是 make 自带的函数用来做文件列表处理。前者搜出所有.c文件后者把路径从src/foo.c映射成build/obj/foo.o。这样新加一个源文件你完全不用改 Makefile构建系统自动把所有.c纳入编译范围。这个体验是用最笨的手写规则方式永远得不到的。$^表示所有依赖文件列表。这样链接命令不会漏掉以后新增的目标文件代码也更简洁。5.2 方式二根 Makefile 子目录 Makefile模块之间边界清晰、部分模块需要独立发布时我会在根目录放一个只做调度的 Makefile各子目录保留自己的构建逻辑SUBDIRS core net utils .PHONY: all $(SUBDIRS) all: $(SUBDIRS) $(SUBDIRS): $(MAKE) -C $ clean: for dir in $(SUBDIRS); do \ $(MAKE) -C $$dir clean; \ done这个模式里$(MAKE)不直接写成make是因为-C切换目录后make 会继承命令行上的变量。如果你在命令行里传入make BUILD_TYPEdebug子目录的 make 也会自动拿到这个变量不用一层层手动转发。这里要特别提醒一下递归 make 的并行构建make -j8体验一般比较糟糕多个子目录同时跑输出会交错在一起而且依赖关系无法跨目录判断。所以我在实践中会把必须并行提速的编译收敛到单一 Makefile 的规则里子目录递归只负责单元级构建。5.3 几个提升体验的小参数每天花大量时间在命令行构建这几个参数值得记下来make -j或者make -j8并行构建CPU 核数两倍左右通常提速明显。注意不要盲目-j64IO 反而会过载。make -n演习只看命令不执行。改 Makefile 担心破坏构建前我都会先跑它看看逻辑对不对。make -B强制重新构建所有目标忽略时间戳。比clean make快一步也不用担心 clean 写漏。make -C build自动切换目录再执行构建适合在源码根目录统一管理 build 目录。还有个小技巧给每个目标加前缀可以让命令不回显。比如mkdir -p $(OBJ_DIR)这样终端里不会刷出mkdir命令本身输出清爽得多。如果某条命令你希望保留回显但想加提示信息用$(info ...)在 Makefile 解析期打印文字或者用echo building module X 主动输出进度。6. 两个极易忽略的细节环境变量覆盖和并行安全最后补充两个我踩过坑的细节它们平时不容易被发现一旦触发排查成本极高。6.1 环境变量会覆盖 Makefile 里的赋值这个坑太隐蔽了。假设你在 Makefile 里写了CC gcc但你的 shell 环境里导出了CCclang那么 make 会优先用环境变量CC忽略 Makefile 里的赋值。这本来算 feature允许用户在命令行调整工具链但在 CI 环境特别容易出事——某个流水机的环境恰好导出了CC本地构建出来是 GCC 产物CI 里却变成了 Clang 产物两边行为不一致查了两天才定位。想强制覆盖环境变量就在赋值前加overrideoverride CC : gcc另外和:的区别也要记住。是递归展开变量等变量引用时再做替换:是立即展开赋值时就把值定好。在实际使用中我写路径列表几乎一律用:避免运行时才展开带来的定位困难。6.2 并行构建时别在规则里用临时文件覆盖make -j8打开后多条规则会同时跑。如果你的某条命令里写了 temp.txt这种临时文件输出而这条规则又在里面使用了同一个文件名两个进程同时写一个文件轻则相互覆盖重则读到半截文件导致编译产物损坏。解决思路是要么给临时文件加上目标专属后缀比如temp_$.txt要么保证每个规则使用的临时文件名唯一。这个坏味道在串行时完全感知不到一上-j就原形毕露。还有一点并行编译时.d 文件的生成时机可能在多文件同时编译时稍有错位但只要规则里正确写了-MMD第一次全量构建和后续增量构建都不会出问题。真出了问题先关掉-j串行跑一遍错误信息会清晰得多。以我多年的使用经验Makefile 这套东西真正上手之后最值钱的不是那些花哨的内置函数和变量技巧而是你开始习惯用依赖 规则的思维去组织构建流程。你会在设计源文件时就考虑模块边界会在新加头文件时下意识想清楚它的依赖影响面甚至会对重编译一次要多久这件事变得敏感起来。这些习惯最后都会直接提升你的开发效率。如果读完这篇文章你决定从今天起把源代码根目录的那个build.sh换成一份像样的 Makefile那我的目的就达到了。别怕一开始写得笨拙先保证它能跑再逐步加入自动依赖、并行和配置化这是每个工程师都会走的自然路径。
返回列表