第4章 Makefile 实战:多目录递归构建
好,咱们直接进入正题。上一章我们聊了单目录的Makefile怎么写,那都是热身。真实的大型项目,目录结构少说也有三四层,源文件散落在各个子目录里。这时候,怎么组织构建就成了头疼事。
我个人习惯用两种方式:include 和 submake。说白了,一个像“复制粘贴”,一个像“分而治之”。今天我们就拿一个实际项目来拆解,顺便把静态库和动态库的构建也一并搞定。
4.1 项目结构设计
先看一个典型的C++项目结构。我当年接手过一个遗留系统,目录比这还乱,但核心思路是一样的。
project/
├── lib/ # 公共库源码
│ ├── math/
│ │ ├── add.cpp
│ │ └── add.h
│ └── utils/
│ ├── log.cpp
│ └── log.h
├── src/ # 主程序源码
│ ├── main.cpp
│ └── config.cpp
├── include/ # 公共头文件
│ └── common.h
├── build/ # 构建输出目录
└── Makefile # 顶层Makefile
你想想看,如果每个子目录都单独写一套编译规则,那Makefile会膨胀到什么程度?维护起来简直是噩梦。
4.2 方案一:使用 include 统一管理
这个方案的核心思想是:在顶层Makefile中定义所有规则,子目录只提供源文件列表。我比较推荐中小型项目用这个,因为逻辑集中,调试方便。
4.2.1 顶层 Makefile 设计
我们先定义变量,把各个子目录的源文件路径收集起来。
# 顶层 Makefile
CC = g++
CFLAGS = -Wall -Iinclude
# 收集所有源文件
SRCS = $(wildcard src/*.cpp)
SRCS += $(wildcard lib/math/*.cpp)
SRCS += $(wildcard lib/utils/*.cpp)
# 生成目标文件路径(放到 build 目录下)
OBJS = $(patsubst %.cpp, build/%.o, $(SRCS))
# 静态库和动态库
STATIC_LIB = build/lib/libcommon.a
SHARED_LIB = build/lib/libcommon.so
# 主程序
TARGET = build/bin/app
.PHONY: all clean
all: $(TARGET) $(STATIC_LIB) $(SHARED_LIB)
# 通用编译规则
build/%.o: %.cpp
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
# 链接主程序
$(TARGET): $(OBJS)
@mkdir -p $(dir $@)
$(CC) $^ -o $@ -Lbuild/lib -lcommon
# 构建静态库
$(STATIC_LIB): $(filter build/lib/%, $(OBJS))
@mkdir -p $(dir $@)
ar rcs $@ $^
# 构建动态库
$(SHARED_LIB): $(filter build/lib/%, $(OBJS))
@mkdir -p $(dir $@)
$(CC) -shared -fPIC $^ -o $@
clean:
rm -rf build
关键点:$(filter build/lib/%, $(OBJS)) 这个用法很巧妙。它只筛选出 lib 目录下的目标文件,专门用来打包成库。主程序的目标文件不参与库的构建。
4.2.2 子目录 Makefile.inc
为了让结构更清晰,我习惯在每个子目录放一个 Makefile.inc,只声明该目录的源文件。
# lib/math/Makefile.inc
LIB_MATH_SRCS = lib/math/add.cpp
# lib/utils/Makefile.inc
LIB_UTILS_SRCS = lib/utils/log.cpp
然后在顶层 Makefile 里用 include 把它们引进来:
include lib/math/Makefile.inc
include lib/utils/Makefile.inc
SRCS = $(LIB_MATH_SRCS) $(LIB_UTILS_SRCS) src/main.cpp src/config.cpp
我的经验:用 include 方式时,注意变量名不要冲突。我见过团队里两个人同时定义了 SRCS,结果编译出来的东西乱七八糟。建议每个子目录的变量名加上前缀,比如 LIB_MATH_SRCS。
4.3 方案二:使用 submake 递归构建
当项目大到一定程度,比如有几十个独立模块,每个模块都有自己的编译选项和依赖,这时候 include 方式就显得力不从心了。我更喜欢用 submake——每个子目录有自己的 Makefile,顶层只负责调用它们。
4.3.1 子目录独立 Makefile
每个子目录的 Makefile 可以独立编译,也可以被顶层调用。
# lib/math/Makefile
CC = g++
CFLAGS = -Wall -fPIC -I../../include
SRCS = add.cpp
OBJS = $(SRCS:.cpp=.o)
LIB = ../libmath.a
.PHONY: all clean
all: $(LIB)
$(LIB): $(OBJS)
ar rcs $@ $^
%.o: %.cpp
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) $(LIB)
4.3.2 顶层 Makefile 调度
顶层 Makefile 通过 $(MAKE) -C 进入子目录执行构建。
# 顶层 Makefile
SUBDIRS = lib/math lib/utils src
.PHONY: all clean $(SUBDIRS)
all: $(SUBDIRS)
$(SUBDIRS):
$(MAKE) -C $@
clean:
for dir in $(SUBDIRS); do \
$(MAKE) -C $$dir clean; \
done
注意:submake 方式有个坑——并行构建时的依赖问题。如果你用 make -j4,子目录之间没有明确的依赖顺序。比如 lib 还没编译完,src 就开始链接了,那肯定报错。
我曾经在一个项目中踩过这个坑,排查了半天才发现是并行构建导致的。解决方案是显式声明依赖:
# 显式声明依赖顺序
lib/math: lib/utils
src: lib/math lib/utils
4.4 静态库 vs 动态库:构建差异
这里我多说一句,静态库和动态库在构建时的区别。很多新手容易搞混。
| 特性 | 静态库 (.a) | 动态库 (.so) |
|---|---|---|
| 编译选项 | 普通编译即可 | 需要 -fPIC(位置无关代码) |
| 打包命令 | ar rcs |
gcc -shared |
| 链接方式 | 编译时直接嵌入 | 运行时动态加载 |
| 文件大小 | 较大(包含所有代码) | 较小(只包含接口) |
| 部署 | 单文件即可运行 | 需要附带 .so 文件 |
嗯,这里要注意:动态库编译时一定要加 -fPIC。我见过有人忘了加,结果链接时报了一堆重定位错误,排查起来很头疼。
4.5 实战:混合构建示例
最后,我们结合两种方案,做一个完整的混合构建。顶层用 submake 调度,每个子目录内部用 include 管理源文件。
# 顶层 Makefile
SUBDIRS = lib/math lib/utils src
STATIC_LIBS = build/lib/libmath.a build/lib/libutils.a
TARGET = build/bin/app
.PHONY: all clean $(SUBDIRS)
all: $(TARGET)
# 先构建所有子目录
$(SUBDIRS):
$(MAKE) -C $@
# 链接主程序,依赖静态库
$(TARGET): $(SUBDIRS) src/main.cpp
@mkdir -p $(dir $@)
g++ src/main.cpp -o $@ -Lbuild/lib -lmath -lutils
clean:
for dir in $(SUBDIRS); do \
$(MAKE) -C $$dir clean; \
done
rm -rf build
核心思路:顶层只负责“调度”和“链接”,具体的编译细节交给子目录自己处理。这样每个模块的维护者可以独立修改自己的 Makefile,互不干扰。
4.6 避坑指南
最后,分享几个我踩过的坑,希望能帮你省点时间。
- 路径问题:submake 中,
$(MAKE) -C会切换工作目录。如果你在子 Makefile 里用了相对路径,记得基于子目录的位置来写。我习惯用$(abspath ...)转成绝对路径。 - 变量传递:顶层定义的变量,默认不会传递到子 Makefile。需要用
export关键字。比如export CFLAGS。 - 清理不彻底:submake 方式下,
make clean需要遍历所有子目录。我建议在顶层 clean 规则里加上rm -rf build,确保所有中间文件都被删除。 - 并行构建的依赖:前面提过,用
make -j时一定要显式声明子目录的依赖顺序,否则会出现诡异的编译错误。
好了,这一章的内容就到这里。多目录构建其实不难,关键是选对方案。中小项目用 include,大项目用 submake,或者两者结合。下一章我们会聊聊如何用 Makefile 实现增量编译和条件编译,敬请期待。