PAYATHON 2026

如何用 Make 组织 C 库项目,并按需构建 examples 下的多个示例?

数据阿明

可以保留 examples/foo、examples/bar 这样的目录结构,用顶层 Makefile 统一管理库和示例。默认执行 make 只构建库,执行 make examples 构建全部示例,也可以直接指定某个示例的输出目标。

现有 Makefile 中的 $(CC) ... -o bin/libproject.a 执行的是链接操作。输出文件名以 .a 结尾,也不会自动生成静态库。静态库需要使用 ar 将目标文件归档。

先把库实现与示例入口分开

顶层 src/ 只放库的实现文件,用于演示的 main.c 移到相应示例的 src/ 中。每个示例都有自己的程序入口,分别链接成可执行文件。

对于这种规模的项目,可以先使用一个顶层 Makefile,暂时不必在各个示例目录中创建 Makefile:

project/
├── Makefile
├── include/
├── src/
├── examples/
│   ├── foo/
│   │   ├── include/
│   │   └── src/
│   │       └── main.c
│   └── bar/
│       ├── include/
│       └── src/
│           └── main.c
├── obj/                    # 构建时生成
└── bin/                    # 构建时生成
    ├── libproject.a
    └── examples/
        ├── foo
        └── bar

示例自己的头文件放在对应的 include/ 中;没有私有头文件时,可以省略该目录。

用顶层 Makefile 管理依赖

下面的写法使用 GNU make,适用于 GCC、Clang 等支持 -MMD -MP 的编译器。它假设库源码直接位于 src/*.c,各示例的源码直接位于 examples/<名称>/src/*.c,不会递归搜索更深层目录。

Makefile 中每条命令行必须以真正的 Tab 开头。

.DEFAULT_GOAL := all

SRC_DIR := src
OBJ_DIR := obj
BIN_DIR := bin

LIB := $(BIN_DIR)/libproject.a
LIB_SRC := $(wildcard $(SRC_DIR)/*.c)
LIB_OBJ := $(patsubst $(SRC_DIR)/%.c,$(OBJ_DIR)/lib/%.o,$(LIB_SRC))

EXAMPLE_DIRS := $(patsubst %/,%,$(wildcard examples/*/))
EXAMPLE_NAMES := $(notdir $(EXAMPLE_DIRS))
EXAMPLE_SRC := $(wildcard examples/*/src/*.c)
EXAMPLE_OBJ := $(patsubst examples/%.c,$(OBJ_DIR)/examples/%.o,$(EXAMPLE_SRC))
EXAMPLE_BINS := $(addprefix $(BIN_DIR)/examples/,$(EXAMPLE_NAMES))

CPPFLAGS += -Iinclude -MMD -MP
CFLAGS += -Wall
LDLIBS += -lm

.PHONY: all examples clean

all: $(LIB)

examples: $(EXAMPLE_BINS)

$(LIB): $(LIB_OBJ)
	mkdir -p $(@D)
	$(AR) rcs $@ $^

$(OBJ_DIR)/lib/%.o: $(SRC_DIR)/%.c
	mkdir -p $(@D)
	$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@

$(OBJ_DIR)/examples/%.o: examples/%.c
	mkdir -p $(@D)
	$(CC) $(CPPFLAGS) -I$(dir $<)../include $(CFLAGS) -c $< -o $@

define EXAMPLE_template
$(BIN_DIR)/examples/$(1): $(patsubst examples/%.c,$(OBJ_DIR)/examples/%.o,$(wildcard examples/$(1)/src/*.c)) $(LIB)
	mkdir -p $$(@D)
	$$(CC) $$(CFLAGS) $$(LDFLAGS) $$^ $$(LDLIBS) -o $$@
endef

$(foreach name,$(EXAMPLE_NAMES),$(eval $(call EXAMPLE_template,$(name))))

clean:
	$(RM) -r $(BIN_DIR) $(OBJ_DIR)

-include $(LIB_OBJ:.o=.d) $(EXAMPLE_OBJ:.o=.d)

这里保留了原配置中的 -lm。如果库和示例都不依赖数学库,可以删除 LDLIBS += -lm。

EXAMPLE_template 为每个示例生成独立的链接规则。例如,foo 的目标依赖 examples/foo/src/ 下源码对应的目标文件,以及 bin/libproject.a。模板中的 $$ 让变量和自动变量经过 eval 后仍能在后续阶段展开。

-I$(dir $<)../include 根据当前源文件确定示例的私有头文件目录。例如,编译 examples/foo/src/main.c 时,对应路径是 examples/foo/src/../include。

如何构建

# 只构建静态库
make

# 构建全部示例,自动先构建所需的库
make examples

# 只构建 foo 及其依赖
make bin/examples/foo

# 并行构建全部示例
make -j examples

# 清理生成文件
make clean

如果以后希望默认同时构建示例,可以把默认目标改为:

all: $(LIB) examples

每个示例目录都需要有可链接成一个程序的源码,通常包含一个 main。上面的目录发现规则会把 examples/ 下的每个直接子目录视为示例;如果其中还放了公共资源目录,可以改成显式列表:

EXAMPLE_NAMES := foo bar

为什么这样安排

库的 .c 文件先编译成 .o,再由 $(AR) rcs 生成静态库。示例则通过 $(CC) 将自己的目标文件与静态库链接成可执行文件。

示例的依赖列表先列目标文件,再列 $(LIB),因此链接命令中的 $^ 也保持这一顺序。对于按命令行顺序处理静态库的链接器,这有助于正确解析符号;库依赖的 -lm 放在后面。

-MMD -MP 生成 .d 依赖文件,末尾的 -include 将其读入,使头文件修改后能触发重新编译。示例目标直接依赖静态库,因此库更新后,相关示例也会重新链接。

不同示例中的同名文件不会互相覆盖。foo/src/main.c 和 bar/src/main.c 分别生成到 obj/examples/foo/src/main.o 和 obj/examples/bar/src/main.o。

是否需要每个目录都有 Makefile

源码目录不必各自配备 Makefile。统一管理规则可以让 Make 直接看到库、目标文件和示例之间的依赖,比较适合当前这类小型项目。

如果不同示例以后有明显不同的构建流程,也可以改成递归 Make。调用子 Make 时应使用 $(MAKE) -C ...,并明确安排先完成库构建、再开始示例构建的依赖关系。仅把库和示例并列写成同一目标的前置条件,不能保证它们在并行构建时按先后顺序执行。

*nix 项目的几个注意点

  • libproject.a 符合常见静态库命名习惯。放在 bin/ 中可以工作;也可以按项目约定放到 lib/ 或 build/lib/,相应调整 Makefile 即可。
  • 公共头文件放在顶层 include/,只供库内部使用的头文件可以留在 src/。示例通过公共头文件使用库。
  • 库归档阶段无需使用 LDFLAGS 或 LDLIBS。这些选项用于最终程序的链接;归档本身不会把 libm 等依赖打包进静态库。
  • clean 中列出的目录应专门存放生成文件,避免把手工维护的文件放进去。
  • 如果删除了库源码,已有 .a 中的旧成员不会因 ar rcs 自动消失。遇到这种情况,应删除旧归档后重建,或执行 make clean 再构建。
  • 这份配置包含 GNU make 扩展,并依赖特定编译器选项。如果需要支持 BSD make 或其他工具链,应另行调整构建规则。

备注:内容仅供参考。