Makefile核心原理与实战:依赖、增量编译与CMake取舍

发布时间:2026/10/8 3:23:45
Makefile核心原理与实战:依赖、增量编译与CMake取舍 很多人提到Makefile总觉得这是上世纪的东西但只要你还在Linux终端下写C语言、交叉编译嵌入式固件或者接手任何一个持续集成脚本迟早都要和Makefile正面交手。Makefile是一款构建工具make的脚本文件它解决的是什么文件需要重新编译、怎么编、编完怎么链接这一整套依赖关系问题。这篇精华版不会按GNU手册从头抄而是把我这些年实际写Makefile踩过的坑、常用的套路、以及和CMake怎么取舍一次讲清楚。我刚入行的时候也犯过傻觉得Makefile就是把gcc命令收集到一个文件里后来被改了头文件却不重新编译这种情况折腾到怀疑人生才明白Makefile的核心不是命令而是依赖。把这层关系想透后面的语法和技巧都是水到渠成。1. 先把本质搞清楚Makefile是一张构建关系表不是命令记事本1.1 一个最小案例告诉我们的真相假设你现在有一个hello.c最朴素的编译方式是这样gcc -o hello hello.c文件多了以后比如有main.c、tools.c、video.c你会自然写一个shell脚本gcc -c main.c gcc -c tools.c gcc -c video.c gcc -o demo main.o tools.o video.o很多人把Makefile写成了上面这个shell脚本的翻版把每一行编译命令原样抄进去。这其实是对Makefile最大的误解。Makefile真正做的事情是描述目标文件、依赖文件、生成命令三者之间的关系然后在每次执行make时根据文件时间戳判断哪些目标需要重新生成。所谓依赖就是如果依赖文件比目标文件新那么目标文件已经过期必须重新编译。这个逻辑才是Makefile的灵魂。1.2 时间戳与增量编译的基本原理make判断是否需要重建一个目标靠的是文件的时间戳不是内容对比。比如规则main.o: main.c tools.h gcc -c main.cmake执行时检查main.o和main.c、tools.h这两个依赖的时间戳。如果main.c或tools.h比main.o新就说明源码有改动需要重新执行下面的gcc命令如果main.o比所有依赖都新说明该目标已经是最新状态什么都不做。这就是增量编译的基础我只改了一个.c文件其他文件不用跟着重编。一个几百文件的项目如果每次全量编译一次构建可能花费几分钟有了正确依赖可能只需要一两秒。这里藏着一个新手最容易踩的坑你改了头文件但Makefile里没有把该头文件写成某个.o的依赖make根本不知道头文件变了所以不会重新编译。这个我们后面单独开一节讲。1.3 make没有指明目标并且找不到makefile到底是什么意思这条报错几乎是所有新手都会遇到的make: *** No targets specified and no makefile found. Stop.拆开看是两件事当前目录没有找到名为GNUmakefile、makefile、Makefile的文件优先级依次是GNUmakefile makefile Makefile你也没有用make target的方式指定一个目标make在没有找到makefile时自然无从下手。解决办法很简单确保你的构建文件叫Makefile大写M是Linux社区最常用的约定或者用make -f 你的文件名指定。如果makefile存在但还是报错那就得看里面的目标名了。make执行时如果用默认目标会使用文件中第一个非模式规则的目标。如果第一个目标写的是clean那执行make直接清理不编译任何东西。所以常规做法是让all作为第一个目标all: demo这样执行make就等于执行make all也就是编译demo。2. 从规则语法开始target、prerequisites、recipe三要素2.1 基础格式和那个特殊的Tab一条基本规则长这样目标文件: 依赖文件1 依赖文件2 ... 命令1 命令2有几个硬性规则第一行是目标: 依赖冒号左右有没有空格都行接下来每行命令必须以Tab开头不能用普通空格替代命令会自动传给shell执行每行命令是在独立的shell里跑的为什么是Tab不是空格因为make的语法解析器当初就把Tab当作命令起始符这个是历史遗留。哪怕今天看起来很不合理你依然得遵守。如果你用空格缩进make会提示“missing separator”这是最常见的报错之一。2.2 一个三文件的编译示例假设项目文件是main.c、tools.c、tools.h初始版本Makefile可以这样写demo: main.o tools.o gcc -o demo main.o tools.o main.o: main.c tools.h gcc -c main.c tools.o: tools.c tools.h gcc -c tools.c clean: rm -f demo main.o tools.o执行make demo时make会先看demo是否存在、是否比main.o和tools.o新。如果其中任何一个.o缺失或者源码被改过就递归检查它自己的依赖关系然后按需编译。注意demo这个目标并没有all如果你直接在命令行执行make默认目标就是demo效果一样。但我建议显式加一个all防止以后在文件前面插入其他规则导致默认目标变化。2.3 隐含规则与内置变量Edit不写命令也能编译GNU make里内置了一堆隐含规则。比如你写main.o: main.cmake会自己找到命令cc -c main.c -o main.o它默认调用的是cc你可以用变量覆盖。常用的内置变量有变量含义CCC编译器默认ccCFLAGSC编译参数比如-O2 -WallCPPFLAGS预处理参数比如-I头文件路径LDFLAGS链接参数比如-L库路径LDLIBS链接的库名比如-lpthread -lm如果不想依赖隐晦的内建规则也可以显式写模式规则%.o: %.c $(CC) $(CPPFLAGS) $(CFLAGS) -c $ -o $$代表第一个依赖$代表目标。这种写法在交叉编译时特别重要因为我们要把CC替换成交叉编译器把CFLAGS加进芯片SDK的头文件路径。以上是Makefile的骨架。很多人学到这里就去写了后面一碰项目就出问题因为变量和函数没用好。下一节把语法补完整。3. 变量、函数、自动化变量构建脚本的编程能力3.1 变量定义与赋值时机 和 : 的差别Makefile变量和C语言宏有点像就是文本替换。但赋值方式不同会带来完全不同的行为。A file1.o $(B) B file2.o如果用A的取值是递归展开的当A真正被使用的时候B已经赋值了所以A最终得到file1.o file2.o。这看着很方便但容易造成循环引用比如A $(B)和B $(A)会让make无限循环。用:则是简单展开变量在赋值那一刻就被展开成当前值A : file1.o $(B) B : file2.o此时A的值就是file1.o因为赋值时B尚未定义。官方实践里大多数人会倾向使用:行为可预期。还有两个常用操作符追加内容?如果变量没赋值过才赋值交叉编译时我习惯写CC ? gcc意思是如果用户没在命令行指定CC默认用gcc如果用户执行make CCclang就尊重用户的输入。3.2 自动化变量$、$、$^的价值自动化变量是让Makefile不再重复写目标名和依赖名的关键。最常用的几个变量含义示例$当前目标名main.o$第一个依赖名main.c$^所有依赖的列表去重main.c tools.h$?比目标新的依赖列表变更过的源文件$(D)目标所在目录obj/$(F)目标文件名main.o一个比较典型的场景$(BIN): $(OBJS) $(CC) $(LDFLAGS) -o $ $^ $(LDLIBS)这样写的好处是以后加源文件只需要改列表链接命令基本不用动。3.3 常用函数与通配符wildcard、patsubst、shellGNU make内置函数非常实用。SRCS : $(wildcard *.c) OBJS : $(patsubst %.c,%.o,$(SRCS))wildcard把当前目录下所有.c文件展开成一个列表patsubst做后缀替换得到对应的.o列表。这比手动一个个写文件好维护得多。如果源文件分布在多个目录用foreach循环很方便SRC_DIRS : src common SRCS : $(foreach dir,$(SRC_DIRS),$(wildcard $(dir)/*.c))shell函数可以在解析Makefile时执行shell命令比如获取当前时间或调用工具链版本BUILD_DATE : $(shell date %Y%m%d)不过要克制$(shell ...)在make解析阶段就会执行滥用会让构建输出混乱。还有notdir、basename、addprefix等都是处理路径字符串用的。掌握以上几个就足够应付大多数项目了。4. 头文件依赖与自动依赖生成增量编译中最容易漏掉的雷4.1 为什么改了头文件make却不重新编译这是我在实际项目里遇到最多的问题排查过程也很有代表性。场景main.c里写了#include tools.hMakefile里只有main.o: main.c。此时你修改了tools.h再执行make它发现main.c的修改时间没变main.o没变于是什么都不做。可实际上调用接口变了最后的可执行文件还是旧的。问题的根因是Makefile里根本没有把头文件纳入依赖关系make只认写在规则里的依赖不会自动去分析源码的include。解决办法不能靠手写因为头文件经常有嵌套包含手写很快会漏掉。正确做法是用编译器生成依赖文件。4.2 用编译器自动生成 .d 依赖文件GCC和Clang都支持-MM参数可以输出适合make的依赖规则。例如gcc -MM main.c输出main.o: main.c tools.h common.h这正好就是一条Makefile规则。于是我们可以为每个.c生成一个对应的.d文件再把它include进主MakefileSRCS : $(wildcard *.c) OBJS : $(patsubst %.c,%.o,$(SRCS)) DEPS : $(patsubst %.c,%.d,$(SRCS)) %.d: %.c $(CC) $(CPPFLAGS) -MM $ $ include $(DEPS)解释一下执行流程make读取Makefile时遇到include $(DEPS)如果对应的.d文件不存在make会尝试用上面的规则生成它生成后重新读取这些依赖规则这样头文件一旦变化main.o就被标记为过期自动重编首次构建多了一个生成.d的步骤但之后增量编译十分可靠。还可以加-MP参数让GCC为每个头文件生成一个空目标避免删除头文件后因找不到目标而报错。完整写法可以这样%.d: %.c $(CC) $(CPPFLAGS) -MM -MP $ $这个技巧对能否稳定增量编译影响非常大强烈建议所有C/C项目都用起来。4.3 伪目标与.PHONYclean为什么易失效在Makefile里clean这类目标通常不产生名为clean的文件它只是执行删除命令的“动作”。如果不加声明而恰好当前目录里存在一个叫clean的文件make会发现clean没有依赖而且存在比任何依赖都新的目标文件于是认为它不需要执行命令就不跑了。正确的做法是把所有不代表真实文件的目标都标记成伪目标.PHONY: all clean distclean伪目标的意思是不要做时间戳检查只要你在命令行显式提出或者被其他规则依赖就直接执行命令。注意顺序。如果你把clean写在Makefile的第一行执行make时默认目标就是clean新手很容易被坑。我的习惯是all永远放第一。4.4 并行编译的隐患依赖不精确就上-jmake -j4多核编译能显著提速但前提是依赖关系足够准确。如果某个.o实际上依赖一个头文件但你没有把它写进规则并行编译时可能两个文件同时编译暂态文件名冲突最后出现莫名其妙的链接错误。加了自动依赖生成后并行编译的安全性会好很多。不过还要注意目标间的依赖顺序比如某些代码生成器必须先于编译执行你就不能简单让所有目标并行。此时可以用order-only prerequisites或者用$(shell ...)做阶段控制。新手阶段先保证单核编译正确再考虑并行。5. 实战给RV1106平台写一份能落地的交叉编译Makefile5.1 场景说明交叉编译和头文件路径RV1106是瑞芯微推出的一颗IPC视觉处理芯片开发时通常在一台Linux PC上编译代码最终跑到嵌入式板卡上。这种编译环境和运行环境不同的做法叫交叉编译核心变化是编译器不再是gcc而是某种带前缀的交叉编译器比如arm-rockchip830-linux-uclibcgnueabihf-gcc。为了解决这个问题芯片厂商会提供SDK里面包括工具链、头文件和库文件。我们需要做的就是把前缀、路径正确传入Makefile。5.2 一个完整的RV1106项目Makefile假设项目包含main.c、video.c、capture.c三个源文件SDK根目录是/opt/rv1106_sdk可以这样组织CROSS_COMPILE ? arm-rockchip830-linux- CC : $(CROSS_COMPILE)gcc AR : $(CROSS_COMPILE)ar STRIP : $(CROSS_COMPILE)strip SDK_DIR : /opt/rv1106_sdk CFLAGS : -O2 -Wall CPPFLAGS : -I$(SDK_DIR)/include -I$(SDK_DIR)/include/rockchip LDFLAGS : -L$(SDK_DIR)/lib LDLIBS : -lpthread -lm SRCS : main.c video.c capture.c OBJS : $(patsubst %.c,%.o,$(SRCS)) DEPS : $(patsubst %.c,%.d,$(SRCS)) BIN : demo .PHONY: all clean all: $(BIN) $(BIN): $(OBJS) $(CC) $(LDFLAGS) -o $ $(OBJS) $(LDLIBS) $(STRIP) $ %.o: %.c $(CC) $(CPPFLAGS) $(CFLAGS) -c $ -o $ %.d: %.c $(CC) $(CPPFLAGS) -MM -MP $ $ -include $(DEPS) clean: rm -f $(BIN) $(OBJS) $(DEPS)这里几个细节值得说明。CPPFLAGS放头文件路径CFLAGS放编译优化选项分开放是GNU make的传统做法。LDFLAGS在链接时使用LDLIBS保证放在源文件或目标文件之后因为链接器对库的顺序敏感。$(STRIP) $这一步是可选的用于裁剪符号表固件体积能小很多。我倾向于在生成可执行文件后直接strip避免后面忘记。如果工具链前缀不是arm-rockchip830-linux-你可以改CROSS_COMPILE变量或者从命令行覆盖make CROSS_COMPILEarm-linux-gnueabihf-5.3 头文件路径出错了怎么排查如果你的源文件里写的是#include video.h编译器会先查看源文件所在目录也就是当前目录下的video.h。如果video.h不在当前目录而在/opt/rv1106_sdk/include下但你又没有指定-I就会报错fatal error: video.h: No such file or directory这时候用-I告诉编译器去哪里找问题就解决了。但如果video.h还在更深层路径比如include/rockchip/video.h你代码里写#include video.h需要加-I$(SDK_DIR)/include/rockchip如果你写的是#include rockchip/video.h只需要加-I$(SDK_DIR)/include即可。这一步很容易把多级目录搞混。排查手段推荐两个执行make -n只打印要执行的命令不真正执行看-I参数是否带对了执行make V1如果你在Makefile里没有把$(info ...)加进去就用它把所有变量打出来也可以在Makefile里临时加一行$(info CPPFLAGS...$(CPPFLAGS))直接看到变量值5.4 常见错误处理清单现象可能原因处理No targets specified and no makefile found文件名不对或目录不对检查文件名是Makefile或使用make -f xxxmissing separator命令前没用Tab把行首空格改成Tabundefined reference to链接顺序或库路径不对检查LDLIBS放在目标文件之后确认LDFLAGS的-L正确改了头文件没重编依赖文件没有包含头文件检查.d依赖生成是否启用是否includeNo rule to make target xxx.h头文件被删除但依赖里还有用-MP生成空目标或删除对应.d文件交叉编译符号格式不对用了本机gcc而不是交叉编译器检查CC变量是否被覆盖6. Makefile和CMake怎么选场景化的理性对比6.1 CMake到底在解决什么问题CMake不直接参与编译它是个构建系统的生成器。你写一份CMakeLists.txtCMake会生成Makefile、Ninja文件或者IDE工程文件。也就是说CMake生成的Makefile和手写Makefile在底层并没有本质区别区别在于CMake自动帮你做了很多依赖管理和平台探测。比如你在CMakeLists.txt里写target_include_directories(demo PRIVATE ${SDK_DIR}/include)CMake会记录这个include路径在生成文件里编译时自动带上。跨平台时CMake能识别Windows、Linux、macOS的不同编译器和链接规则这一点确实比手写Makefile强得多。6.2 什么情况下坚持手写Makefile更划算我见过不少团队项目就两个文件非得上CMake不可最后维护起来比Makefile还痛苦。手写Makefile的优势在于构建逻辑透明出错容易定位不需要额外安装和生成步骤适合快速原型、小型工具库、单一平台拿前面的RV1106例子来说如果项目规模只有三五个源文件手写Makefile完全够用而且修改完直接make不要额外跑cmake。但如果项目满足下面任意一条建议迁移到CMake需要跨平台编译有多个子目录源文件超过几十个需要自动化生成配置头文件要对外提供库并且希望用户通过find_package集成团队规模大构建规则需要多人协作维护这时候CMake的价值就体现出来了它帮你管理了目录、源文件列表、选项开关和安装规则底层还是调用Make或Ninja。6.3 两者并非互斥理解底层更重要很多从CMake入门的人遇到CMake生成的makefile出错就发怵。其实追到根上CMake生成的所有Makefile仍然遵循本章讲的规则目标、依赖、命令、时间戳。你只是不直接写它们而已。我的建议是新手先花一周把Makefile基本原理搞清楚再上CMake。不是为了炫耀技能而是为了在出问题时不至于把构建系统当成黑盒。理解了目标依赖关系CMake里那些add_library、target_link_libraries也就没那么玄乎了。最后分享一点个人体会。我维护过不少项目的构建文件最大的感受是Makefile的价值永远不在语法漂亮而在依赖关系是否诚实。一个20行的Makefile如果你把头文件依赖、伪目标、干净利落的clean都做齐了它比100行但到处是FORCE的CMake还可靠。所以别小看这份老古董它值得你认真对待。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询