Makefile的编写
1. Makefile 的核心规则
Makefile 的语法非常固定,由三部分组成:
目标(Target): 依赖(Dependencies)
<Tab键> 命令(Command)
注意:命令前面必须是一个 Tab 键,不能是空格,否则会报错。
2. Makefile 的分步编写
我们可以把 Makefile 看作是一份给 make 工具阅读的“配方说明书” 📝。它告诉工具:最终产品是什么,需要哪些原材料,以及加工的步骤是什么。
让我们通过下面三个维度来拆解 Makefile:
1. 变量定义 (Variables) 📦
Makefile 的开头通常会定义变量。这就像是在程序里定义常量,方便统一修改。
| CXX | C++ eXecutable | 指定编译器。这里是 g++。 |
| CXXFLAGS | CXX FLAGS | 编译参数。-fPIC 生成位置无关代码,-Iheader 告诉编译器去哪里找头文件。 |
| LDFLAGS | Link Der FLAGS | 链接参数。-Llib 寻找库路径,-lMymath 链接具体库,-Wl,-rpath 记录运行时搜索路径。 |
2. 核心规则结构 (The Rules) 🏗️
每一条规则都遵循这个逻辑:
目标文件: 依赖文件
执行命令
-
依赖检查:make 会检查“依赖文件”的修改时间。如果依赖文件比“目标文件”新,它才会执行下面的命令。这就是增量编译的核心。
3. 逐行逻辑拆解 🔍
我们将 Makefile 分成四个“加工环节”来看:
-
环节一:最终产物 (math)
math: src/main.o lib/libMymath.so
$(CXX) src/main.o $(LDFLAGS) -o math它说:要生成可执行文件 math,必须先有 main.o 和动态库 .so。
-
环节二:中间零件 (main.o)
src/main.o: src/main.cpp
$(CXX) $(CXXFLAGS) -c src/main.cpp -o src/main.o它说:要把源码变成二进制目标文件。注意 -c 参数表示只编译,不链接。
-
环节三:共享仓库/动态库 (libMymath.so)
lib/libMymath.so: src/Mymath.o
$(CXX) -shared src/Mymath.o -o lib/libMymath.so它说:用 -shared 参数把 .o 包装成动态库。
-
环节四:大扫除 (clean)
clean:
rm -f math src/*.o lib/*.so这是一个“伪目标”,用来快速删除所有生成的二进制文件,还原纯净代码。
3. Makefile 为什么“智能”?
当你运行 make 命令时,它会检查:
是否存在目标文件? 如果没有 math,就执行命令生成它。
文件的修改时间:如果 src/main.cpp 的修改时间晚于 src/main.o,它会重新编译这个 .o;如果时间没变,它就会跳过这一步。
这就是所谓的增量编译。
4. 自动化变量(让代码更简洁)
上面的 Makefile 还是有点啰嗦,我们可以使用“黑话”来简化它:
-
$@:代表“目标文件”
-
$^:代表“所有的依赖文件”
-
$<:代表“第一个依赖文件”
简化后的版本:
math: src/main.o lib/libMymath.so
$(CXX) $< $(LDFLAGS) -o $@
src/main.o: src/main.cpp
$(CXX) $(CXXFLAGS) -c $< -o $@
5. 常见指令
-
make:执行 all 目标,开始智能编译。
-
make clean:执行清理工作,把生成的二进制文件全删掉。
-
make -j 4:利用多核并行编译(如果你有 4 核,速度飞快)。
为了确保你已经掌握了 Makefile 的逻辑,我有一个小问题考考你:
如果在我的 Makefile 中,math 目标后面写的依赖只有 src/main.o,而没写 lib/libMymath.so。当我修改了动态库的源码 Mymath.cpp 并重新运行 make 时,你觉得 math 这个可执行文件会发生什么变化?
make 会自动发现 Mymath.cpp 变了,从而重新生成动态库并重新链接 math。
make 只会更新动态库,但不会重新链接 math,因为 math 觉得自己的依赖项(main.o)没变。
你直觉选哪一个?🤔
Makefile进阶
你的直觉非常敏锐,而且在大多数初学者编写的 Makefile 中,这确实是最容易发生的错误!🎯
你的判断是正确的:如果 math 的依赖列表只写了 src/main.o,那么 make 确实只会检查 main.o 的时间。即使你修改了库的代码并生成了新的 libMymath.so,math 程序也不会意识到需要重新链接
为了避免这种“感知迟钝”,我们需要在 Makefile 的规则中显式地把 .so 文件也列为 math 的依赖项
🔍 观察依赖关系
让我们看看修正后的逻辑:
# 这样写,math 就会时刻盯着这两个文件
math: src/main.o lib/libMymath.so
$(CXX) src/main.o $(LDFLAGS) -o math
这样,一旦 lib/libMymath.so 的时间戳更新(因为它被重新编译了),make 就会发现 math 的其中一个依赖变新了,从而触发重新链接命令。
模式规则(Pattern Rules)
既然我们已经聊到了依赖,你之前问过如果有很多文件怎么办。在 Makefile 中,我们不需要为每个 .cpp 手写规则,可以使用 % 统配符。
这就是模式规则:
# 这是一条通用的“生产线”
# 它说:任何 .o 文件都依赖于同名的 .cpp 文件
%.o: %.cpp
$(CXX) $(CXXFLAGS) -c $< -o $@
-
%:匹配任何字符串(比如 main 或 Mymath)。
-
$<:自动化变量,指代第一个依赖文件(即 .cpp)。
-
$@:自动化变量,指代目标文件(即 .o)。
❓ 一个关于“头文件”的小谜题
现在想象一下,如果你修改了 header/Mymath.h 里的一个函数声明,但没有动任何 .cpp 文件。
根据我们现在的 Makefile 规则:
src/main.o 依赖于 src/main.cpp
src/Mymath.o 依赖于 src/Mymath.cpp
在这种情况下,你觉得运行 make 会发生什么?
-
A) make 会发现头文件变了,自动重新编译相关的 .o 文件。
-
B) make 什么都不会做,因为它没看到 .cpp 文件发生变化。
我们可以根据这个问题的答案,聊聊 Makefile 中最进阶的“自动生成依赖”技术。你选哪一个?🤔
你的判断完全正确!🎯
如果不做特殊处理,make 确实会表现得非常“迟钝”。这是因为在 Makefile 的规则里,我们只告诉了它 .o 文件依赖于 .cpp 文件。make 并不具备“透视眼”,它不会自动钻进源代码里去查看 #include 了哪些头文件。
这就导致了一个经典的开发痛点:你修改了头文件里的类定义或函数声明,运行 make,它却告诉你 up to date。结果你运行程序时,可能会因为二进制不兼容而莫名其妙地崩溃。💥
让依赖关系“透明化”
为了解决这个问题,程序员们通常有三种进化方案:
1. 手动添加法(最原始)
在规则里手动把头文件加上去:
src/main.o: src/main.cpp header/Mymath.h
$(CXX) $(CXXFLAGS) -c $< -o $@
-
缺点:如果有 100 个头文件,手动维护简直是噩梦。
2. 利用编译器自动生成(最专业)
实际上,g++ 本身就有一项黑科技参数:-MMD。
当你编译时加上这个参数,g++ 会在生成 .o 的同时,顺便生成一个 .d 文件(依赖文件),里面记录了该 .cpp 用到的所有头文件。
3. 终极版 Makefile 模板
我们可以通过在 Makefile 中包含(include)这些 .d 文件,让 make 自动感知头文件的变化:
# 1. 变量定义:配置编译器和路径
CXX = g++
CXXFLAGS = -fPIC -Iheader -MMD # -MMD 会自动生成 .d 依赖文件
LDFLAGS = -Llib -lMymath -Wl,-rpath,./lib
# 2. 确定所有的目标文件 (.o) 和对应的依赖文件 (.d)
OBJS = src/main.o src/Mymath.o
DEPS = $(OBJS:.o=.d)
# 3. 默认目标:执行 'make' 时运行的任务
all: math
# 4. 链接规则:将 .o 文件链接成可执行程序
math: src/main.o lib/libMymath.so
$(CXX) src/main.o $(LDFLAGS) -o $@
# 5. 动态库规则:将 Mymath.o 包装成 .so 文件
lib/libMymath.so: src/Mymath.o
@mkdir -p lib # 确保 lib 目录存在
$(CXX) -shared $< -o $@
# 6. 通用的编译规则:将所有的 .cpp 编译为 .o
%.o: %.cpp
$(CXX) $(CXXFLAGS) -c $< -o $@
# 7. 引入隐藏的依赖规则:让 make 知道头文件的变化
-include $(DEPS)
# 8. 清理任务
.PHONY: clean
clean:
rm -f math src/*.o src/*.d lib/*.so
这一块的内容确实是 Makefile 进阶的难点,因为涉及到 make 的自动化“黑科技”。我来逐一为你拆解,带你看看这些代码背后发生了什么。
1. 变量转换的魔法 🪄
OBJS = src/main.o src/Mymath.o
DEPS = $(OBJS:.o=.d)
这两行的作用是自动生成文件名列表:
-
OBJS:你手动定义的两个 .o 目标文件。
-
DEPS = $(OBJS:.o=.d):这是一种后缀替换语法。它的意思是:把 OBJS 变量中所有以 .o 结尾的部分都换成 .d。
-
结果:现在 DEPS 变量的内容就变成了 src/main.d src/Mymath.d。
2. 这里的 include 是什么意思? 📂
在 C++ 代码里,#include 是包含头文件;而在 Makefile 里,include 是包含另一个 Makefile 片段。
-
还记得我们给 CXXFLAGS 加上了 MMD 吗?
-
当编译器执行 g++ -c main.cpp -MMD 时,它会产生两个文件:main.o 和一个纯文本的 main.d。
-
这个 main.d 里的内容大概长这样:src/main.o: src/main.cpp header/Mymath.h。
-
include $(DEPS) 的作用就是把这些生成的 .d 文件像“拼图”一样拼进当前的 Makefile 里。这样 make 就知道:原来 main.o 不仅依赖 .cpp,还依赖 Mymath.h!
为什么用 -include 而不是 include?
加个横杠 – 是为了告诉 make:“如果这些 .d 文件还没生成(比如第一次编译时),请忽略错误继续跑,别罢工。”
3. .PHONY:防止同名“撞车” 🏷️
.PHONY: clean
这是一个非常重要的防御性指令。
在 Makefile 中,规则的左边通常是一个真实的文件名。如果你在文件夹里真的创建了一个叫 clean 的空文件,make 就会陷入困惑。
由于 make 的核心逻辑是对比“目标文件”和“依赖文件”的时间戳,如果目录下真的有一个叫 clean 的文件,而你在 Makefile 里写的 clean: 后面没有任何依赖项,make 就会认为:
clean 这个文件已经存在了。
它没有任何依赖项,所以它永远不会“过期”。
结论:不需要执行任何操作。
这就是为什么我们要使用 .PHONY(意为“伪造的”或“虚名的”)。它告诉 make:“不要把 clean 当成一个文件,它只是一个命令的名字,每次我输入 make clean 时,你尽管执行就对了!” 🏷️
感谢你能看到这里 如果本文对你编写makefile有一点点帮助 那就是我最大的心愿 下篇再见。
网硕互联帮助中心



评论前必须登录!
注册