Makefile进阶案例_高手专用
Makefile理论分析
[源文件层] [中间产物层] [最终目标层]
a.cc ----> a.o
b.cc ----> b.o ----> TARGET (my_app)
/
c.cc ----> c.o
Makefile是一个从上而下的自动化编译器, 从TARGET出发开始;
"TARGET可执行文件"的分析:
TARGET文件是一个可执行文件, 通常是一个项目中多个.cc文件模块的集合体, 因此可执行文件在链接时会依赖多个.o文件
OBJS1 = main1.o utils.o
分析了TARGET的依赖列表后, Make得知需要链接哪些.o文件, 然后就会去执行.o文件的编译
".o目标文件"的分析
每个.cc都会被编译成一个.o;
一个.o文件依赖于: 一个".cc源文件" 和多个 ".hpp头文件";.o文件通常不直接依赖于多个.cc文件, 因为两个.cc文件之间是通过头文件交互的;
例如:a.o: a.cc a.h b.h
a.cc需要调用b.cc的函数, 那么只需要b.cc包含其b.hpp头文件, 并且实现b.hpp头文件里的函数声明, 然后a.cc包含b.hpp头文件即可, 不需要a.cc去#include "b.cc",这是会导致链接重定义报错的;
".cc源文件"的编译
这是一个很标准的编译操作:
a.o: a.cc a.h b.h
g++ -c a.cc
但是这里有很多的问题:1.通常一个项目有大量的头文件引用难道都需要包含吗?
答:其实你可以不包含任何的头文件
a.o: a.cc
g++ -c a.cc这样编译也是可以正确得到a.o的, 但是这会导致如果你在a.h和b.h中修改一些内容, 重新make时, make无法检测到这些改变, 因为make只会检测你依赖的文件, 所以不会更新, 如果你想更新, 必须把TARGET文件删掉, 全部重新make一次
2.如何解决修改不更新?
“黑科技”组合拳:
编译时加上-MMD (或者-MD) "g++ -c main.cc -MMD ..."
DEPS = $(ALL_OBJS:.o=.d) "推理出未来的.d文件合集方便清理"
-include $(DEPS) "代码末尾导入"
这两个东西写在代码里面就可以保证即使你不显式说明你依赖的头文件, 编译后, 你修改头文件依然可以直接make更新
MMD和MD的差别很小:
-MD (Make Dependency): 生成所有头文件的依赖,包括系统头文件。
-MMD (Make Mask Dependency): 生成依赖,但忽略系统头文件(即忽略用 <...> 包含且在系统路径下的文件)。
下面是它们最精确的分工说明:
1. DEPS = $(ALL_OBJS:.o=.d)
分工:【预定名单】(只在内存中工作)
角色:文员。
动作:字符串处理。
具体干了什么:
它根本没去碰硬盘,也没生成任何文件。它只是在 Make 的内存里算了一笔账:
“既然你要生成 a.o 和 b.o,那么按照计划,应该会有 a.d 和 b.d 这两个文件与之对应。我先把这两个名字记在一个叫 DEPS 的变量里。”产出:一个包含文件名字符串的列表(例如 "main.d utils.d")。
2. -include $(DEPS)
分工:【导入情报】(读取硬盘)
角色:情报官。
动作:文件读取。
具体干了什么:
Make 运行到这行时,会暂停一下,拿起 DEPS 里的名单,去硬盘上找这些 .d 文件。
如果找到了:把 .d 文件里的内容(比如 main.o: main.cc main.h)原封不动地复制粘贴到当前的 Makefile 逻辑中。于是 Make 突然就“懂”了头文件的依赖关系。
如果没找到(比如第一次编译):因为前面有个减号 -,它会耸耸肩说“没事,找不到就算了”,然后继续往下执行,不会报错。
产出:动态更新了 Make 内存中的依赖规则图。
3. -MMD (在 CXXFLAGS 中)
分工:【生成情报】(写入硬盘)
角色:前线工兵(实际上是 g++ 编译器)。
动作:文件写入。
具体干了什么:
这是给 g++(而不是 Make)看的指令。
当 Make 决定要编译 main.cc 时,它调用 g++ -c main.cc -MMD ...。
g++ 在一边编译代码生成 .o 的同时,一边偷偷做笔记:“哦,这个文件引用了 stdio.h 和 my_header.h”。
编译完成后,g++ 会在硬盘上创建一个 main.d 文件,把刚才的笔记写进去。产出:硬盘上真实的 .d 文件(为下一次编译做准备)。
参考代码:
使用此模板, 可覆盖99%的场景
CXX = g++
CXXFLAGS = -Wall -g -MMD -MP
# 1. 定义所有要生成的目标
TARGET1 = app1
TARGET2 = app2
TARGETS = $(TARGET1) $(TARGET2)
#--------------------------------------------------------
# 2.1 第一种方法: 分别定义每个目标需要的 .o 文件
# 假设 utils.o 是共用的,main1.o 和 main2.o 是各自独立的
OBJS1 = main1.o utils.o
OBJS2 = main2.o utils.o
#------------------------------------------------------
#2.2 第二种方法:
# 定义源文件 (Source)
#SRC1 = main1.cc utils.cc
#SRC2 = main2.cc utils.cc
# 定义目标对象 (Object) -> 注意这里是 .o
#OBJS1 = $(SRC1:.cc=.o)
#OBJS2 = $(SRC2:.cc=.o)
#---------------------------------------------------------
# 将所有涉及的 .o 放在一起,用于计算依赖 (.d) 和清理
ALL_OBJS = $(OBJS1) $(OBJS2)
DEPS = $(ALL_OBJS:.o=.d)
# --- 规则区 ---
.PHONY: all clean
# all 生成所有目标
all: $(TARGETS)
# 3. 为每个目标单独写链接规则
$(TARGET1): $(OBJS1)
$(CXX) $(CXXFLAGS) $^ -o $@
$(TARGET2): $(OBJS2)
$(CXX) $(CXXFLAGS) $^ -o $@
# 通用编译规则(保持不变)
# 所有的 .cc 变成 .o 的方式是一样的
%.o: %.cc
$(CXX) $(CXXFLAGS) -c $< -o $@
-include $(DEPS)
clean:
rm -f $(TARGETS) $(ALL_OBJS) $(DEPS)
模板2:
CXX = g++
CXXFLAGS = -Wall -g -MMD -MP
# 定义所有要生成的可执行文件
TARGET1 = [可执行文件名1]
TARGET2 = [可执行文件名2]
#...
#汇总所有可执行文件,方便一次生成全部和一次清理全部
TARGETS = $(TARGET1) $(TARGET2) #...
# 为每一个可执行文件构建源文件依赖列表
SRC1 = [依赖文件1.cc] [依赖文件2.cc] ...
SRC2 = [依赖文件3.cc] [依赖文件2.cc] ...
# 根据各自的源文件列表自动推理构建.o目标文件列表
OBJS1 = $(SRC1:.cc=.o)
OBJS2 = $(SRC2:.cc=.o)
# 汇总所有.o 目标文件, 用于计算依赖 (.d) 和清理
ALL_OBJS = $(OBJS1) $(OBJS2)
DEPS = $(ALL_OBJS:.o=.d)
# --- 规则区 ---
all: $(TARGETS) #方便一次生成所有可执行文件
# 为每个目标单独写链接规则
$(TARGET1): $(OBJS1)
$(CXX) $(CXXFLAGS) $^ -o $@
$(TARGET2): $(OBJS2)
$(CXX) $(CXXFLAGS) $^ -o $@
# 通用编译规则
# 所有的 .cc 变成 .o 的方式是一样的
%.o: %.cc
$(CXX) $(CXXFLAGS) -c $< -o $@
# 上面这个编译规则没有显式说明.o对头文件的依赖关系
# 这里自动插入头文件依赖关系
-include $(DEPS)
# 清理产物
clean:
rm -f $(TARGETS) $(ALL_OBJS) $(DEPS)
# 规定all和clean是伪目标,直接make不会调用
# 必须手动make all/make clean
.PHONY: all clean
.PHONY 的核心定义
.PHONY是 Makefile 中的特殊目标(special target),中文常译作伪目标。它的核心作用是告诉make工具:这个目标不是一个实际存在的文件,不要去检查它是否存在、是否有更新,只要执行这个目标对应的命令即可。
一些补充:
关于通配符%:
%.o: %.cc 的意思是根据某个.o文件找到其依赖的同名.c文件, 当make分析TARGET需要a.o b.o c.o ...等.o文件时, 会自动陆续搜索这些.o文件的编译方式, 就会采纳 %.o: %.cc 这个规则, 然后陆续执行以下步骤:
%.o: %.cc
$(CXX) -c $< -o $@等价为:
a.o: a.cc
$(CXX) -c $< -o $@b.o: b.cc
$(CXX) -c $< -o $@c.o: c.cc
$(CXX) -c $< -o $@.....
命令
$(CXX) -c $< -o $@是 C++ 编译的核心指令:以a.o: a.cc为例
$(CXX):make 内置变量,默认对应g++(Linux)/cl.exe(Windows),专门用于编译 C++ 代码(比CC(对应gcc)更贴合.cc文件);-c:只编译 / 汇编生成.o,不链接(生成目标文件的关键参数);$<:自动变量,取 “第一个依赖文件”(即a.cc);$@:自动变量,取 “当前目标文件”(即a.o)。
问题1的详细解释:
如果你只写 %.o: %.cc 而不配合自动依赖生成(-MMD 和 -include),那么这样写其实是“有缺陷”的。
我们分两层来理解:
1. 为什么“编译命令”能跑通?(编译器层)
不管你在 Makefile 里怎么写依赖,只要执行了下面这条命令:
g++ -c a.cc -o a.o
编译是一定能成功的。
-
原因:编译器 g++ 并不看 Makefile。它去读 a.cc 的内容,在代码里发现了 #include "a.h",然后编译器自己会去文件系统里找 a.h 并把内容读进来。
-
结论:Makefile 里的依赖列表(冒号后面的东西),是写给 Make 看的,而不是给 g++ 看的。
2. 既然编译能成功,为什么说它有缺陷?(Make 工具层)
Makefile 的依赖列表(冒号后面)的作用只有一个:决定何时需要重新编译。
假设你只写了通用规则:
# 只有这一条规则
%.o: %.cc
$(CXX) -c $< -o $@
Make 此时认为:a.o 只 依赖于 a.cc。
场景模拟:
-
第一次编译:Make 看到 a.cc,执行编译,成功生成 a.o。
-
修改头文件:你打开 a.h,修改了一个结构体的定义。
-
再次 make:
-
Make 检查 a.o 和 a.cc 的时间戳。
-
Make 发现:a.cc 没变。
-
Make 结论:“源文件没变,不需要重新编译。”
-
-
结果:你的程序里用了旧的 a.h 逻辑,产生严重的 Bug(比如内存布局错误)。
所以,传统的“笨办法”必须把头文件手动写出来:
a.o: a.cc a.h b.h <-- 必须显式告诉 Make,这些变了也要重编
3. 那为什么现代 Makefile 敢不写头文件?
你的代码里之所以敢这么写:
%.o: %.cc
$(CXX) $(CXXFLAGS) -c $< -o $@
是因为你用了 “黑科技”组合拳:
-
-MMD (在 CXXFLAGS 里)
-
-include $(DEPS)
魔法是如何发生的?
这是一种 “先上车,后补票” 的机制:
-
第一次编译时:
Make 确实不知道 a.o 依赖 a.h。它只根据 %.o: %.cc 进行编译。
但是在编译的过程中,g++ 因为带着 -MMD 参数,它在编译 a.cc 的同时,悄悄生成了一个 a.d 文件。
a.d 的内容就是编译器帮你分析好的:a.o: a.cc a.h b.h。 -
第二次编译时:
Makefile 执行到了 -include $(DEPS)。
它把 a.d 里的内容读进来了!
此时,内存里的 Makefile 动态地 变成了这样:# 原有的通用规则 %.o: %.cc g++ ... # 动态插入的规则 (来自 a.d) a.o: a.cc a.h b.hMake 突然就“开窍”了!如果你改了 a.h,Make 现在知道 a.o 依赖它了,于是就会触发重新编译。
总结
-
a.o: a.cc a.h (显式写法):
这是静态写死依赖。虽然准确,但太累了,每加一个 #include 都要去改 Makefile。 -
%.o: %.cc (你的写法):
这是动态生成依赖。-
它表面上看起来不包含头文件依赖。
-
但它依赖 -MMD 生成 .d 文件,再通过 -include 把详细的依赖关系(包含头文件)注入回来。
-








