Back to Technology

GNU Make Mastery Part 9: Project Architecture & Multi-Directory Builds

February 25, 2026 Wasil Zafar 22 min read

Scale your Makefile from a single directory to an enterprise-grade project: compare recursive $(MAKE) -C sub-makes with non-recursive include, choose the right out-of-source build layout, and avoid the classic pitfalls that haunt large C codebases.

Table of Contents

  1. Why Structure Matters
  2. Recursive Make
  3. Non-Recursive Make
  4. Out-of-Source Builds
  5. Complete Project Example
  6. Next Steps

Why Structure Matters

Part 9 of 16 — GNU Make Mastery Series. Everything so far has lived in one directory. Real projects span dozens of directories, libraries, and subsystems. This part covers two fundamentally different approaches and when to use each.

Canonical Project Layout

myproject/
├── Makefile          # top-level entry point
├── include/          # public headers
│   └── myproject/
├── src/
│   ├── core/         # subsystem: core
│   │   ├── Module.mk # non-recursive: per-dir fragment
│   │   ├── core.c
│   │   └── core.h
│   ├── net/          # subsystem: networking
│   │   ├── Module.mk
│   │   └── net.c
│   └── main.c
├── lib/              # produced .a / .so files
├── build/            # all object and dep files (out-of-source)
└── tests/
    └── Makefile

Sample Source Files

Self-contained examples: Multi-module project examples reference main.c, net.c, and logger.c. Drop these files into the module subdirectories shown in the project-tree snippets and the Makefiles will build cleanly.

main.c

/* main.c — entry point */
#include <stdio.h>
#include "logger.h"

int main(void) {
    log_info("Application started");
    return 0;
}

logger.h

/* logger.h */
#ifndef LOGGER_H
#define LOGGER_H
void log_info(const char *msg);
void log_error(const char *msg);
#endif

logger.c

/* logger.c — simple logging helpers */
#include <stdio.h>
#include "logger.h"

void log_info(const char *msg)  { printf("[INFO]  %s\n", msg); }
void log_error(const char *msg) { fprintf(stderr, "[ERROR] %s\n", msg); }

net.c

/* net.c — minimal networking stub */
#include <stdio.h>

void net_connect(const char *addr, int port) {
    printf("Connecting to %s:%d\n", addr, port);
}

void net_disconnect(void) { printf("Disconnected\n"); }

Recursive Make

$(MAKE) -C Pattern

$(MAKE) -C dir changes into dir and invokes a new Make process there. Always use $(MAKE) (not make) so the same Make binary and jobserver options propagate.

# Top-level Makefile (recursive approach)
SUBDIRS := src/core src/net tests

.PHONY: all clean $(SUBDIRS)

all: $(SUBDIRS)

# Each subdir target triggers a sub-make in that directory
$(SUBDIRS):
	$(MAKE) -C $@              # -C changes to subdir; $(MAKE) propagates jobserver

# Pass build config down to each sub-make
$(SUBDIRS): BUILD ?= release
$(SUBDIRS):
	$(MAKE) -C $@ BUILD=$(BUILD)

clean:
	for dir in $(SUBDIRS); do $(MAKE) -C $$dir clean; done  # $$dir: escape $ for shell

Classic Pitfalls

Pitfalls
  • Broken parallelism: make -j8 at the top level doesn't parallelize across subdirs without the jobserver. Use $(MAKE) -C (not a shell loop) so Make can coordinate jobs.
  • Stale inter-directory deps: If src/net depends on a header rebuilt by src/core, recursive Make may not see the dependency. Express it explicitly.
  • Ordering: Use prerequisite chaining to enforce order: src/net: src/core

Passing Variables to Sub-makes

# Method 1: export (makes variable visible to all child processes)
export CC := arm-linux-gnueabihf-gcc
export CFLAGS := -Wall -O2

# Method 2: pass on command line (overrides child Makefile vars)
$(MAKE) -C src/core CC=$(CC) CFLAGS="$(CFLAGS)"

# Method 3: export all variables (use sparingly — can cause surprises)
export

Non-Recursive Make

In the non-recursive approach, a single top-level Makefile includes per-directory fragments (Module.mk) that append to global variable lists. Make has a single, complete dependency graph — enabling correct parallelism and cross-directory deps.

include Module.mk Pattern

# src/core/Module.mk — note use of $(d) for current dir prefix
d := src/core

CORE_SRCS := $(d)/allocator.c $(d)/logger.c $(d)/config.c
CORE_OBJS := $(patsubst $(d)/%.c,build/$(d)/%.o,$(CORE_SRCS))

# Append to global lists
SRCS += $(CORE_SRCS)
OBJS += $(CORE_OBJS)

# Module-specific compile flags
$(CORE_OBJS): CFLAGS += -DCORE_MODULE

build/$(d)/%.o: $(d)/%.c
	@mkdir -p $(dir $@)
	$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
# Top-level Makefile (non-recursive: single Make process)
CC      := gcc
CFLAGS  := -Wall -Wextra -std=c11 -O2
BUILDDIR := build
TARGET  := myapp

# Include all module fragments — they append to SRCS and OBJS
include src/core/Module.mk
include src/net/Module.mk

DEPS := $(OBJS:.o=.d)              # .o → .d for dependency tracking

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(LDFLAGS) -o $@ $^          # single link step sees all objects

-include $(DEPS)                       # pull in auto-generated .d files

clean:
	rm -rf $(BUILDDIR) $(TARGET)

Advantages & Trade-offs

Comparison
RecursiveNon-Recursive
Cross-dir depsError-prone to expressNatural — single graph
Parallel buildsNeeds jobserver tuningFull -j exploitation
Module independenceEach subdir self-containedAll fragments share namespace
Incremental buildsPer-subdir Makefiles re-evaluatedSingle Make database
Best forLarge, loosely-coupled monoreposTightly-coupled libraries

Out-of-Source Builds

Never pollute your source tree with build artifacts. Keep all .o, .d, and output files in a separate build/ tree, mirroring the source structure:

SRCDIR   := src
BUILDDIR := build

# Mirror src/ tree under build/ so source stays clean
# e.g. src/core/allocator.c → build/src/core/allocator.o
SRCS := $(shell find $(SRCDIR) -name '*.c')                    # recursive file discovery
OBJS := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRCS))      # remap paths to build/

$(BUILDDIR)/%.o: $(SRCDIR)/%.c
	@mkdir -p $(dir $@)                # create matching subdirectory under build/
	$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
Multiple build flavors: Parameterize the build directory by config so debug and release artifacts never collide:
BUILD    ?= release
BUILDDIR := build/$(BUILD)

ifeq ($(BUILD),debug)
    CFLAGS += -g3 -O0 -DDEBUG
else
    CFLAGS += -O2 -DNDEBUG
endif

# Now:  build/debug/src/core/allocator.o
#       build/release/src/core/allocator.o

Complete Non-Recursive Project Example

# =============================================================
# Top-level Makefile — non-recursive, out-of-source
# =============================================================
CC       := gcc
CFLAGS   := -Wall -Wextra -std=c11 -Iinclude
BUILD    ?= release
BUILDDIR := build/$(BUILD)
TARGET   := $(BUILDDIR)/myapp

ifeq ($(BUILD),debug)
CFLAGS   += -g3 -O0 -DDEBUG
else
CFLAGS   += -O2 -DNDEBUG
endif

# Accumulator variables — populated by Module.mk files
SRCS :=
OBJS :=

# Include per-directory build fragments
include src/core/Module.mk
include src/net/Module.mk

DEPS := $(OBJS:.o=.d)

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
	@mkdir -p $(dir $@)
	$(CC) $(LDFLAGS) -o $@ $^
	@echo "Built: $@"

$(BUILDDIR)/%.o: src/%.c
	@mkdir -p $(dir $@)
	$(CC) $(CFLAGS) -MMD -MP -c $< -o $@

-include $(DEPS)

clean:
	rm -rf build $(TARGET)

# ── Variable Inspector ──────────────────────────────────────
.PHONY: info
info:
	@echo "CC       = $(CC)"
	@echo "CFLAGS   = $(CFLAGS)"
	@echo "BUILD    = $(BUILD)"
	@echo "BUILDDIR = $(BUILDDIR)"
	@echo "TARGET   = $(TARGET)"
	@echo "SRCS     = $(SRCS)"
	@echo "OBJS     = $(OBJS)"
	@echo "DEPS     = $(DEPS)"

Create Source & Module Files

The Makefile uses non-recursive include src/*/Module.mk fragments that append to accumulator variables. Create the full directory layout:

mkdir -p src/core src/net include

cat > include/app.h << 'EOF'
#ifndef APP_H
#define APP_H
void core_init(void);
void net_connect(const char *host);
#endif
EOF

cat > src/core/main.c << 'EOF'
#include <stdio.h>
#include "app.h"
int main(void) {
    core_init();
    net_connect("localhost");
    return 0;
}
EOF

cat > src/core/init.c << 'EOF'
#include <stdio.h>
#include "app.h"
void core_init(void) { printf("Core initialized\n"); }
EOF

# Module.mk — appends sources to accumulator variable
cat > src/core/Module.mk << 'EOF'
SRCS += src/core/main.c src/core/init.c
OBJS += $(BUILDDIR)/core/main.o $(BUILDDIR)/core/init.o
EOF

cat > src/net/client.c << 'EOF'
#include <stdio.h>
#include "app.h"
void net_connect(const char *host) { printf("Connecting to %s\n", host); }
EOF

cat > src/net/Module.mk << 'EOF'
SRCS += src/net/client.c
OBJS += $(BUILDDIR)/net/client.o
EOF

Try It

# Inspect accumulated variables
make info
make BUILD=debug info    # different build dir

# Build the project
make
./build/release/myapp     # "Core initialized" + "Connecting to localhost"

# Debug build
make BUILD=debug
./build/debug/myapp

make clean

Next in the Series

In Part 10: Cross-Compilation & Toolchains, we set CROSS_COMPILE prefixes, sysroot flags, and PKG_CONFIG_SYSROOT_DIR to build firmware for embedded ARM targets from an x86 Linux host.

Technology