第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 实现增量编译和条件编译,敬请期待。