Multi-Directory Projects
Summary: in this tutorial, you will learn learn how to organize makefiles for projects with multiple directories — using include, recursive make, and the non-recursive approach with module.mk files.
Multi-Directory Projects
So far, all your projects have been in a single directory. Real projects have structure — source files in src/, headers in include/, tests in tests/, libraries in lib/. This tutorial shows you how to organize your Makefile for multi-directory projects.
What you'll learn in this tutorial:
- How to use
includeto split a Makefile into multiple files - The recursive Make approach (sub-Makefiles in each directory)
- The non-recursive Make approach (one top-level Makefile with
module.mkfiles) - When to use which approach
- How to handle build output directories
The include Directive
Before we talk about project organization, you need to know about include. It reads another file and inserts its contents into the current Makefile:
include config.mk
include src/module.mkThis is like copy-pasting the contents of those files right here. Any variables, rules, or other directives in the included file become part of your Makefile.
# config.mk
CC := gcc
CFLAGS := -Wall -Wextra -O2
PREFIX := /usr/local# Makefile
include config.mk
# Now CC, CFLAGS, and PREFIX are all available
myapp: main.c
$(CC) $(CFLAGS) main.c -o myappUse -include (with a dash) to include files that might not exist. Without the dash, Make errors out if the file is missing. With the dash, it silently continues:
-include config.local.mk # No error if this file doesn't existThis is useful for optional per-user configuration files.
Project Structure Example
For the rest of this tutorial, imagine a project like this:
myproject/
├── Makefile # Top-level Makefile
├── include/
│ ├── utils.h
│ └── parser.h
├── src/
│ ├── main.c
│ ├── utils.c
│ └── parser.c
├── lib/
│ ├── json.c
│ └── json.h
└── tests/
├── test_utils.c
└── test_parser.c
There are three common approaches to managing this with Make:
Approach 1: Single Makefile (Simplest)
For small-to-medium projects, you can just list all the paths in one Makefile:
CC := gcc
CFLAGS := -Wall -Wextra -I include -I lib -MMD -MP
TARGET := build/myapp
TEST_BIN := build/tests
# Source files
SRCS := $(wildcard src/*.c)
LIB_SRCS := $(wildcard lib/*.c)
TEST_SRCS := $(wildcard tests/*.c)
# Object files (all go into build/)
OBJS := $(patsubst %.c,build/%.o,$(SRCS) $(LIB_SRCS))
TEST_OBJS := $(patsubst %.c,build/%.o,$(TEST_SRCS))
DEPS := $(OBJS:.o=.d) $(TEST_OBJS:.o=.d)
.PHONY: all test clean
all: $(TARGET)
$(TARGET): $(OBJS)
@mkdir -p $(dir $@)
$(CC) $^ -o $@
test: $(TEST_BIN)
./$(TEST_BIN)
$(TEST_BIN): $(TEST_OBJS) $(filter-out build/src/main.o,$(OBJS))
@mkdir -p $(dir $@)
$(CC) $^ -o $@
# Compile any .c to build/*.o
build/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -rf build
-include $(DEPS)What's $(filter-out build/src/main.o,$(OBJS))? When building tests, you don't want main.o (which has its own main() function) — the test file has its own main(). The filter-out removes it from the object list.
When to use this: Projects with fewer than ~30 source files. It's simple, everything is in one place, and there's no complexity to manage.
Approach 2: Recursive Make (Sub-Makefiles)
In this approach, each directory has its own Makefile, and the top-level Makefile calls into them:
myproject/
├── Makefile # Calls sub-Makefiles
├── src/
│ └── Makefile # Builds source files
├── lib/
│ └── Makefile # Builds the library
└── tests/
└── Makefile # Builds and runs tests
Top-Level Makefile
# Top-level Makefile
SUBDIRS := src lib tests
.PHONY: all clean test $(SUBDIRS)
all: src lib
test: all tests
# Build each subdirectory
src lib tests:
$(MAKE) -C $@What does $(MAKE) -C $@ mean?
$(MAKE)— Runs Make itself (using the same Make program). Always use$(MAKE)instead of hardcodingmake— this ensures options like-j(parallel builds) are passed down.-C $@— Change to directory$@before reading the Makefile there. Since the target names match directory names (src,lib,tests),$@becomes the directory name.
So $(MAKE) -C src means "run Make inside the src/ directory."
Subdirectory Makefile (src/Makefile)
# src/Makefile
CC := gcc
CFLAGS := -Wall -Wextra -I ../include -I ../lib
SRCS := $(wildcard *.c)
OBJS := $(SRCS:.c=.o)
.PHONY: all clean
all: $(OBJS)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f *.oCleaning All Subdirectories
# In the top-level Makefile
clean:
@for dir in $(SUBDIRS); do \
$(MAKE) -C $$dir clean; \
doneRecursive Make has a big downside: Make can't see the full dependency graph. When src/Makefile runs, it doesn't know about files in lib/. This means:
- Make might build things in the wrong order
- Parallel builds (
make -j4) might not work correctly - Changes in one directory might not trigger rebuilds in another
This is why many experienced developers prefer the non-recursive approach (below).
Passing Variables to Sub-Makes
Sometimes you need the same variables in all Makefiles. You can pass them with export:
# Top-level Makefile
export CC := gcc
export CFLAGS := -Wall -Wextra -O2
# Now CC and CFLAGS are available in all sub-Makefiles
all:
$(MAKE) -C src
$(MAKE) -C libOr pass them on the command line:
all:
$(MAKE) -C src CC=$(CC) CFLAGS="$(CFLAGS)"Approach 3: Non-Recursive Make (Recommended)
This approach uses one top-level Makefile that includes small module.mk files from each directory. It gives Make full visibility into all dependencies:
myproject/
├── Makefile # Master Makefile
├── src/
│ └── module.mk # Declares files in src/
├── lib/
│ └── module.mk # Declares files in lib/
└── tests/
└── module.mk # Declares files in tests/
The Master Makefile
# Makefile (top-level)
CC := gcc
CFLAGS := -Wall -Wextra -I include -I lib -MMD -MP
BUILD := build
# These will be filled in by module.mk files
SRCS :=
LIBS :=
TEST_SRCS :=
# Include each module's file list
include src/module.mk
include lib/module.mk
include tests/module.mk
# Generate object file paths
OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SRCS) $(LIBS))
TEST_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TEST_SRCS))
ALL_OBJS := $(OBJS) $(TEST_OBJS)
DEPS := $(ALL_OBJS:.o=.d)
TARGET := $(BUILD)/myapp
TEST_BIN := $(BUILD)/test_runner
.PHONY: all test clean
all: $(TARGET)
$(TARGET): $(OBJS)
@mkdir -p $(dir $@)
$(CC) $^ -o $@
test: $(TEST_BIN)
./$(TEST_BIN)
$(TEST_BIN): $(TEST_OBJS) $(filter-out $(BUILD)/src/main.o,$(OBJS))
@mkdir -p $(dir $@)
$(CC) $^ -o $@
# One pattern rule handles ALL .c files
$(BUILD)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -rf $(BUILD)
-include $(DEPS)Module Files
Each module.mk file simply appends to the shared variable lists:
# src/module.mk
SRCS += src/main.c \
src/utils.c \
src/parser.c# lib/module.mk
LIBS += lib/json.c# tests/module.mk
TEST_SRCS += tests/test_utils.c \
tests/test_parser.cWhy is this better than recursive Make?
- Full dependency graph — Make sees all files at once, so it can correctly determine build order
- Parallel builds work —
make -j8is safe because Make knows all dependencies - Faster — One Make process instead of launching a new one for each directory
- Simpler — Each
module.mkis just a list of files, no complex logic needed
Auto-Discovering Files in Module.mk
Instead of listing files manually, you can use wildcard:
# src/module.mk
SRCS += $(wildcard src/*.c)# lib/module.mk
LIBS += $(wildcard lib/*.c)This is less typing, but listing files explicitly has an advantage: if you accidentally leave a file in the directory, it won't get compiled. This can prevent surprises.
When to Use Which Approach
| Approach | Best For | Pros | Cons |
|---|---|---|---|
| Single Makefile | Small projects (under 30 files) | Simple, no extra files | Gets unwieldy for large projects |
| Recursive Make | Independent sub-projects | Each directory is self-contained | Broken dependency tracking, slower |
| Non-Recursive Make | Medium to large projects | Full dependency graph, fast parallel builds | Must use relative paths from root |
Start with the single Makefile approach. If your project grows and the Makefile gets hard to manage, switch to non-recursive Make with module.mk files. Only use recursive Make if your directories are truly independent projects (like a monorepo with separate applications).
Handling Build Output Directories
A common challenge is keeping compiled files organized. Here's a pattern that mirrors your source tree in the build directory:
Source: Build:
src/main.c → build/src/main.o
src/utils.c → build/src/utils.o
lib/json.c → build/lib/json.o
tests/test.c → build/tests/test.o
The earlier pattern rule handles this automatically:
$(BUILD)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@The @mkdir -p $(dir $@) creates the output directory if it doesn't exist. The @ suppresses printing the mkdir command.
Practical Example: Complete Non-Recursive Project
Here's a full example putting everything together:
# === Configuration ===
CC := gcc
CFLAGS := -Wall -Wextra -I include -MMD -MP
LDLIBS :=
BUILD := build
DEBUG ?= 0
ifeq ($(DEBUG),1)
CFLAGS += -g -O0 -DDEBUG
BUILD := build/debug
else
CFLAGS += -O2
BUILD := build/release
endif
# === File Lists (filled by module.mk includes) ===
SRCS :=
LIB_SRCS :=
TEST_SRCS :=
# === Include Modules ===
include src/module.mk
include lib/module.mk
include tests/module.mk
# === Derived Variables ===
OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SRCS) $(LIB_SRCS))
TEST_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TEST_SRCS))
ALL_DEPS := $(OBJS:.o=.d) $(TEST_OBJS:.o=.d)
TARGET := $(BUILD)/myapp
TEST_BIN := $(BUILD)/test_runner
# === Targets ===
.PHONY: all test clean info
all: $(TARGET)
@echo "Built: $(TARGET)"
test: $(TEST_BIN)
@echo "Running tests..."
@./$(TEST_BIN)
$(TARGET): $(OBJS)
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) $^ $(LDLIBS) -o $@
$(TEST_BIN): $(TEST_OBJS) $(filter-out $(BUILD)/src/main.o,$(OBJS))
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) $^ $(LDLIBS) -o $@
$(BUILD)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -rf build
info:
@echo "=== Build Configuration ==="
@echo "Build dir: $(BUILD)"
@echo "Debug: $(DEBUG)"
@echo "Compiler: $(CC)"
@echo "CFLAGS: $(CFLAGS)"
@echo ""
@echo "=== Files ==="
@echo "Sources: $(SRCS)"
@echo "Libraries: $(LIB_SRCS)"
@echo "Tests: $(TEST_SRCS)"
@echo "Objects: $(OBJS)"
-include $(ALL_DEPS)Summary
Here's what you've learned:
include file.mkinserts another file's contents into your Makefile; use-includeto silently ignore missing files- Single Makefile works well for small projects — just list all paths
- Recursive Make (
$(MAKE) -C dir) runs sub-Makefiles but breaks dependency tracking - Non-recursive Make (one master Makefile +
module.mkincludes) is the recommended approach for larger projects - Always use
$(MAKE)instead of hardcodingmakein recipes - Use
exportto pass variables to sub-Makefiles @mkdir -p $(dir $@)creates output directories as needed
In the next tutorial, you'll learn advanced techniques — parallel builds, debugging Makefiles, and special targets.
Set Up a Non-Recursive Build
Given this project structure:
project/
├── include/
│ └── app.h
├── src/
│ ├── main.c
│ └── app.c
├── lib/
│ └── helper.c
└── tests/
└── test_app.c
Create:
- A top-level
Makefileusing the non-recursive approach - A
module.mkfile for each directory (src/,lib/,tests/) - Support for
make,make test,make clean, andmake info
Show Solution
src/module.mk:
SRCS += src/main.c \
src/app.clib/module.mk:
LIB_SRCS += lib/helper.ctests/module.mk:
TEST_SRCS += tests/test_app.cMakefile:
CC := gcc
CFLAGS := -Wall -Wextra -I include -MMD -MP
BUILD := build
# File lists
SRCS :=
LIB_SRCS :=
TEST_SRCS :=
# Include modules
include src/module.mk
include lib/module.mk
include tests/module.mk
# Object files
OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SRCS) $(LIB_SRCS))
TEST_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TEST_SRCS))
DEPS := $(OBJS:.o=.d) $(TEST_OBJS:.o=.d)
TARGET := $(BUILD)/myapp
TEST_BIN := $(BUILD)/test_runner
.PHONY: all test clean info
all: $(TARGET)
@echo "Built $(TARGET)"
test: $(TEST_BIN)
@echo "Running tests..."
@./$(TEST_BIN)
$(TARGET): $(OBJS)
@mkdir -p $(dir $@)
$(CC) $^ -o $@
# Exclude main.o from test build (it has its own main())
$(TEST_BIN): $(TEST_OBJS) $(filter-out $(BUILD)/src/main.o,$(OBJS))
@mkdir -p $(dir $@)
$(CC) $^ -o $@
$(BUILD)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -rf $(BUILD)
info:
@echo "Sources: $(SRCS)"
@echo "Libraries: $(LIB_SRCS)"
@echo "Tests: $(TEST_SRCS)"
@echo "Objects: $(OBJS)"
@echo "Target: $(TARGET)"
-include $(DEPS)Test it:
make info # Verify file discovery
make # Build the application
make test # Build and run tests
make clean # Clean upKey points:
- Each
module.mkjust appends to shared variables — no rules or complexity - The master Makefile has one pattern rule for ALL compilations
$(filter-out ...)removesmain.ofrom the test binary-include $(DEPS)handles header dependencies from-MMD -MP
Written by the ShellRAG Team
The ShellRAG editorial team writes practical, beginner-friendly Makefile tutorials with tested code examples and real-world use cases. Every article is technically reviewed for accuracy and updated regularly.
Learn more about us →