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 include to 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.mk files)
  • 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.mk

This 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 myapp

Use -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 exist

This 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 hardcoding make — 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 *.o

Cleaning All Subdirectories

# In the top-level Makefile
clean:
	@for dir in $(SUBDIRS); do \
		$(MAKE) -C $$dir clean; \
	done

Recursive 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 lib

Or pass them on the command line:

all:
	$(MAKE) -C src CC=$(CC) CFLAGS="$(CFLAGS)"

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.c

Why is this better than recursive Make?

  1. Full dependency graph — Make sees all files at once, so it can correctly determine build order
  2. Parallel builds workmake -j8 is safe because Make knows all dependencies
  3. Faster — One Make process instead of launching a new one for each directory
  4. Simpler — Each module.mk is 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

ApproachBest ForProsCons
Single MakefileSmall projects (under 30 files)Simple, no extra filesGets unwieldy for large projects
Recursive MakeIndependent sub-projectsEach directory is self-containedBroken dependency tracking, slower
Non-Recursive MakeMedium to large projectsFull dependency graph, fast parallel buildsMust 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.mk inserts another file's contents into your Makefile; use -include to 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.mk includes) is the recommended approach for larger projects
  • Always use $(MAKE) instead of hardcoding make in recipes
  • Use export to 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:

  1. A top-level Makefile using the non-recursive approach
  2. A module.mk file for each directory (src/, lib/, tests/)
  3. Support for make, make test, make clean, and make info
Show Solution

src/module.mk:

SRCS += src/main.c \
        src/app.c

lib/module.mk:

LIB_SRCS += lib/helper.c

tests/module.mk:

TEST_SRCS += tests/test_app.c

Makefile:

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 up

Key points:

  • Each module.mk just appends to shared variables — no rules or complexity
  • The master Makefile has one pattern rule for ALL compilations
  • $(filter-out ...) removes main.o from the test binary
  • -include $(DEPS) handles header dependencies from -MMD -MP
Was this page helpful?
SR

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 →